Builder Codes
Builder Codes let integrators earn fees by routing order flow through their application. A builder fee rides on top of the taker's normal tiered fee: it attaches to an order through builderIdx and builderFeeTenthBps, and it settles to the builder's revenue-share account when the order fills. The program caps it at 1% of notional via MAX_BUILDER_FEE_TENTH_BPS.
Not every fill pays it. The fee is waived, and the fill still goes through at zero builder fee, on liquidation fills and on any fill where the taker does not clear initial margin under the program's strict oracle rules. Build accounting around fees that arrive, not fees that were expected.
For the concepts behind this system, the three actors, the escrow model, and the payout paths, see Velocity Builder Codes. This page covers the SDK calls that implement it.
Builder codes work on any perp order: regular onchain order placement (placePerpOrder, placeAndTakePerpOrder, placeAndMakePerpOrder, fillPerpOrder) as well as Swift signed-message orders. builderIdx/builderFeeTenthBps are plain optional fields on OrderParams, not a Swift-only mechanism.
The model has three actors and three setup steps:
- Builder initializes a
RevenueShareaccount: a one-time, per-authority setup that lets the builder accrue fee-share rewards. - User initializes a
RevenueShareEscrowaccount: tracks the builders this user has approved and any pending (unsettled) builder-fee orders. - User approves the builder in their escrow with a maximum fee cap (
changeApprovedBuilder).
Only after all three steps can the user's orders carry that builder's fee.
Setup
Create and subscribe separate VelocityClient instances for the builder and user authorities:
import { Connection, Keypair } from "@solana/web3.js";
import { VelocityClient, Wallet } from "@velocity-exchange/sdk";
const connection = new Connection("<RPC_URL>", "confirmed");
// Builder wallet (revenue share provider)
const builderKeypair = Keypair.fromSecretKey(/* builder secret key */);
const builderWallet = new Wallet(builderKeypair);
const builderAuthority = builderKeypair.publicKey;
// User wallet (end user)
const userKeypair = Keypair.fromSecretKey(/* user secret key */);
const userWallet = new Wallet(userKeypair);
const takerAuthority = userKeypair.publicKey;
// Builder client
const builderClient = new VelocityClient({
connection,
wallet: builderWallet,
env: "mainnet-beta",
});
await builderClient.subscribe();
// User client
const userClient = new VelocityClient({
connection,
wallet: userWallet,
env: "mainnet-beta",
});
await userClient.subscribe();SDK Usage
Builder: Initialize Revenue Share
Create the builder's onchain RevenueShare configuration account (one per authority, not per subaccount). This must exist before the builder can receive any fees:
await builderClient.initializeRevenueShare(builderAuthority);User: Initialize Escrow
Create the user's RevenueShareEscrow account used for builder-fee tracking and approvals. numOrders sizes the pending-order slot list (it can be grown later with resizeRevenueShareEscrowOrders, but never shrunk): size it to at least the number of concurrently open orders expected across all of this user's subaccounts:
// numOrders should cover concurrent open orders across all of the user's subaccounts
await userClient.initializeRevenueShareEscrow(takerAuthority, 16);On creation, the escrow's referrer field is copied from the user's existing UserStats.referrer, if any (see Fill-time enforcement below).
User: Approve a Builder (Max Fee)
Approve a builder and set the maximum fee they may charge. The builderIdx used on orders references the position of this approval in the user's RevenueShareEscrow.approvedBuilders list:
// maxFeeTenthBps is in tenths of a basis point (10 = 1 bp = 0.01%)
await userClient.changeApprovedBuilder(builderAuthority, 200, true); // cap: 20 bps (0.2%)Call with add = false to revoke a builder. That fails with CannotRevokeBuilderWithOpenOrders while the builder still has open, unsettled orders outstanding: cancel or settle those first.
Order Placement (Builder Fee on a Regular Order)
Include builderIdx and builderFeeTenthBps directly on OrderParams for any perp order placement:
import { MarketType, OrderType, PositionDirection } from "@velocity-exchange/sdk";
await userClient.placePerpOrder({
orderType: OrderType.LIMIT,
marketType: MarketType.PERP,
marketIndex: 0,
direction: PositionDirection.LONG,
baseAssetAmount: userClient.convertToPerpPrecision(1),
price: userClient.convertToPricePrecision(150),
// Builder fee fields, set by the builder's app UI
builderIdx: 0, // index in taker's approvedBuilders list
builderFeeTenthBps: 50, // fee for this order: 5 bps (50 * 0.1 bps)
});The same two fields go on a Swift signed order message, set on the message alongside signedMsgOrderParams. See Sign the order message.
builderFeeTenthBps must be within both caps: the user's maxFeeTenthBps for that builder, and the protocol-wide MAX_BUILDER_FEE_TENTH_BPS of 1000 (1% of notional), an onchain Rust constant in the velocity program rather than an SDK export. Exceeding either fails the placement.
Changing a Builder-Coded Order
modifyOrder and modifyOrderByUserOrderId reject any order that carries a builder code, with CannotModifyBuilderOrder (error 6366 / 0x18de, "Cannot modify a builder-coded order; cancel and re-place instead"). The fee attribution lives in a RevenueShareEscrow row keyed to the order's orderId, and a modify re-places under a new id, which would strip the attribution.
Cancel and re-place instead. cancelAndPlaceOrders does both in one transaction, so the old order is not fillable in the gap:
import { MarketType, OrderType, PositionDirection } from "@velocity-exchange/sdk";
// Replace a builder-coded order: cancel, then place with the builder params set again
await userClient.cancelAndPlaceOrders(
{ marketType: MarketType.PERP, marketIndex: 0, direction: PositionDirection.LONG },
[
{
orderType: OrderType.LIMIT,
marketType: MarketType.PERP,
marketIndex: 0,
direction: PositionDirection.LONG,
baseAssetAmount: userClient.convertToPerpPrecision(1),
price: userClient.convertToPricePrecision(151),
builderIdx: 0,
builderFeeTenthBps: 50,
},
]
);A client that reuses one modify path for all order updates should branch on the builder fields before calling it.
Fill-time enforcement
Perp fills fail onchain with UnableToLoadRevenueShareAccount (error 6324 / 0x18b4) unless the taker's RevenueShareEscrow is passed as a remaining account when either:
- the taker's order carries a builder code (
hasBuilderParams(orderParams)is true), or - the taker's
UserStats.referrerStatushas theBuilderReferralbit set and their escrow has a referrer (escrowHasReferrer(escrow)).
Liquidation fills are exempt, as is any fill made while the BuilderCodes bit of FeatureBitFlags is clear on State.featureBitFlags; read the state account for that bit's setting. Fillers and keepers must attach the escrow for any taker that matches one of the above, and the SDK's fill and place-and-make builders accept the taker's decoded escrow directly:
import { hasBuilderParams, isBuilderReferral } from "@velocity-exchange/sdk";
// Resolve whether this taker's escrow must be attached
const takerNeedsEscrow =
hasBuilderParams(takerOrder) || isBuilderReferral(takerUserStats);
const takerEscrow = takerNeedsEscrow
? await revenueShareEscrowMap.mustGet(takerAuthority.toString())
: undefined;
await fillerClient.fillPerpOrder(
takerUserAccountPublicKey,
takerUserAccount,
order,
makerInfo,
txParams,
fillerSubAccountId,
fillerAuthority,
hasBuilderFee,
takerEscrow // attached only when required; validated against taker's authority
);fillPerpOrder and getFillPerpOrderIx, placeAndTakePerpOrder and getPlaceAndTakePerpOrderIx, placeAndMakePerpOrder and getPlaceAndMakePerpOrderIx, and getPlaceAndMakeSignedMsgPerpOrderIxs all accept this optional trailing takerEscrow. The builders validate takerEscrow.authority against the taker's authority and throw if they don't match.
For keepers filling many takers, RevenueShareEscrowMap caches escrow accounts by authority, which avoids a re-fetch per fill:
import { RevenueShareEscrowMap } from "@velocity-exchange/sdk";
const revenueShareEscrowMap = new RevenueShareEscrowMap(fillerClient);
await revenueShareEscrowMap.subscribe();
const escrow = revenueShareEscrowMap.get(takerAuthority.toString()); // undefined if not (yet) cachedReferral rewards also only accrue (and referral slots are only created) for escrows that have a referrer: an escrow with no referrer set is exempt from the enforcement above even if the taker's referrerStatus bit happens to be set.
Market makers filling as makerInfo are unaffected by any of this: the enforcement gates the taker's escrow only. A maker never passes its own RevenueShareEscrow to fill an order, builder-coded or not.
Collecting accrued fees
An accrued row leaves escrow through one of three instructions, all paying out of the perp market's PnL pool:
| Method | Caller | Behavior |
|---|---|---|
settleRevenueShare | Anyone | Pays the accrued builder and referrer rows of one escrow on one perp market, without needing the owner. Rejects a delisted market. |
settlePNL | Escrow owner | Runs the same sweep as a side effect, but only when it moves PnL for that market. |
forfeitRevenueShareOrder | Anyone | Writes off one row the program cannot pay. Moves no tokens. |
Do not rely on settlePNL. Once the escrow owner closes their position and stops trading, no PnL settle can pay the row, and the market's pendingRevenueShare holds PnL-pool value against the claim indefinitely. settleRevenueShare exists so a builder or keeper can collect on its own schedule:
const escrow = await builderClient.fetchRevenueShareEscrowAccount(takerAuthority);
// Pays this escrow's builder and referrer rows for perp market 0
await builderClient.settleRevenueShare(takerAuthority, escrow, 0);The SDK builds remainingAccounts: the oracle and market accounts, then the owner's onchain sub-accounts read-only (the program needs them to mark rows Completed before paying), then the beneficiaries' User and RevenueShare accounts writable.
To find the work list, subscribe a RevenueShareEscrowMap and ask which escrows still owe on a market. Call syncAll() first, because a partial cache under-reports:
import { PublicKey } from "@solana/web3.js";
await revenueShareEscrowMap.syncAll();
const owing = revenueShareEscrowMap.getEscrowsOwingRevenueShare(0);
for (const [authority, escrow] of owing) {
await builderClient.settleRevenueShare(new PublicKey(authority), escrow, 0);
}The sweep pays a row only when its feesAccrued fits in the payable part of the PnL pool. Check that before sending, to avoid paying for a transaction that settles nothing:
import { calculateRevenueShareSweepAvailable } from "@velocity-exchange/sdk";
const perpMarket = builderClient.getPerpMarketAccountOrThrow(0);
const spotMarket = builderClient.getSpotMarketAccountOrThrow(perpMarket.quoteSpotMarketIndex);
const oraclePriceData = builderClient.getOracleDataForPerpMarket(0);
// QUOTE_PRECISION (1e6): pnlPoolTokens - max(netUserPnl, 0) - bankruptcy IF tranche, floored at 0
const available = calculateRevenueShareSweepAvailable(perpMarket, spotMarket, oraclePriceData);Writing off an unpayable row
forfeitRevenueShareOrder clears a row so pendingRevenueShare can reach zero and the delist is not blocked. The market must be in settlement or delisted, and the program requires proof it cannot pay: the beneficiary has no payout User account, the closed pool is smaller than the row, or the row names no beneficiary the program can reach. A still-payable row is rejected with RevenueShareOrderNotForfeitable (error 6373 / 0x18e5).
// orderIndex is the row's index within escrow.orders
await builderClient.forfeitRevenueShareOrder(takerAuthority, escrow, 0, orderIndex);The SDK resolves the row's beneficiary the same way the program does (the referrer for a referral row, otherwise approvedBuilders[builderIdx]) and passes that authority's sub-account 0. The program re-derives the address and rejects any other one.