Skip to main content

Builder Fees

Builder Fees let third-party interfaces earn a fee on position orders they submit to GMTrade. The builder publishes a fee rate. The trader authorizes that rate on each order. After execution, the fee is settled to the builder's claim vault and can be withdrawn by the builder.

This guide uses @gmsol-labs/gmsol-sdk@0.11.0-beta.1. Builder Fees are additional to GMTrade's trading fees; they are not referral rebates.

How It Works​

  1. The builder prepares its User Account and a claim vault for each fee token.
  2. The builder sets its fee rate with set_builder_fee_factor.
  3. The trader creates an order with set_builder_fee in the SDK options. The trader signs both order creation and fee authorization.
  4. Order execution calculates the fee and records it on the order. The tokens remain in the order's escrow.
  5. Anyone can call settle_builder_fee to move the recorded fee to the builder's claim vault.
  6. The builder calls claim_builder_fees to withdraw the vault balance.

Order submission and order execution are separate steps. Confirming the creation transaction does not mean that the order has executed or earned a fee.

Successful execution with a nonzero Builder Fee factor emits BuilderFeeCharged. Its payable_amount is the calculated fee before any shortfall cap, and paid_amount is the amount actually charged, both in fee-token base units. The event is emitted even when the cap reduces paid_amount to zero; a zero factor emits no such event. Charging records the fee for later settlement and does not mean the builder has already received or claimed it.

The event includes the Store, market token, and fee token, but no order or builder IDs. Attribute it using the execution transaction's instruction context and the order's recorded builder. Do not infer the recipient from the settlement payer or assume that an entire transaction contains only one order.

Fee Rules​

Rate and Calculation​

Rates use 20 decimal places. Pass them as JavaScript bigint values, not floating-point percentages.

RateSDK factor
0.01% (1 basis point)10n ** 16n
0.1% (10 basis points)10n ** 17n
1%10n ** 18n

The fee is based on the position size change in USD, not the collateral deposited. For example, a 1,000 USD size increase at 0.01% has a 0.10 USD fee before conversion to token units.

The contract first computes floor(sizeDeltaUsd * factor / 10^20) in its USD fixed-point units. It then divides by the fee token's lower oracle price and rounds up to a whole token base unit. A zero factor charges no fee.

OrderFee token and payment
IncreaseCharged in the position's collateral token, after any pay-token swap. The fee reduces the collateral added to the position. Insufficient added collateral causes execution to fail; no partial fee is charged.
DecreaseCharged in the order's final output token, after any receive-token swap, using the actual executed size change. The fee is capped at the available output in that token. A secondary output counts toward this cap only if it is the same token.

For an increase order, insufficient added collateral to cover the fee raises BuilderFeeExceedsCollateral. With throw_on_execution_error = false, this is handled as a soft execution failure: if the execution transaction succeeds, the order transitions to Cancelled and no partial Builder Fee is charged. With throw_on_execution_error = true, the error reverts the transaction, including any order-state changes made by that transaction. Transaction success alone therefore does not prove that the order executed successfully; check its resulting status or execution events.

For decrease orders, the minimum-output check uses the output before Builder Fee settlement. Account for the fee when presenting the trader's expected net proceeds.

Supported Orders​

Builder Fees support MarketIncrease, LimitIncrease, MarketDecrease, LimitDecrease, and StopLossDecrease orders. They do not support swap orders, liquidations, or auto-deleveraging. A nonzero Builder Fee also rejects the CollateralToPnlToken decrease swap type.

Authorization and Limits​

  • The rate must not exceed the target Store's MaxBuilderFeeFactor. A zero cap prevents nonzero rates. The contract checks the cap both when the builder publishes a rate and when a trader attaches it to an order.
  • The Store's Builder Fee feature must be enabled to attach a nonzero fee.
  • expected_factor must exactly match the builder's published rate when set_builder_fee executes. A mismatch fails the transaction.
  • Order execution later uses the checkpointed rate without rechecking the builder's current published rate. Later changes to the builder's rate do not change that order's fee.
  • Fees can only be attached to pending orders. Market orders additionally require the fee checkpoint to occur in their creation slot. Submit creation and fee authorization in the same transaction.

Disabling the feature blocks new nonzero fee checkpoints. It does not cancel existing checkpoints or block execution, settlement, or claiming of existing fees.

SDK Setup​

Install the version used by this guide explicitly:

npm install @gmsol-labs/gmsol-sdk@0.11.0-beta.1 @coral-xyz/anchor@0.31.1 @solana/web3.js@1 @solana/spl-token@0.4

The SDK uses WebAssembly. Configure your application's bundler to load its WASM module. The examples below extend an existing Solana integration: supply a connection, wallet, Store address, and an Anchor Program constructed with the IDL matching the deployed Store program. An older IDL without the Builder Fee instructions and account fields will not work.

The builder wallet signs setup, rate changes, and claims. The trader wallet signs order creation. A settlement payer only needs to sign and pay for the settlement transaction.

Submit SDK Transactions​

The SDK methods construct transactions; they do not sign or send them. Use the following helper for the examples below. signTransaction must belong to the wallet passed as payer when building the group.

import { Program, type BN } from "@coral-xyz/anchor";
import {
Connection,
PublicKey,
SystemProgram,
Transaction,
VersionedTransaction,
type BlockhashWithExpiryBlockHeight,
} from "@solana/web3.js";
import {
createAssociatedTokenAccountIdempotentInstruction,
getAssociatedTokenAddressSync,
} from "@solana/spl-token";
import {
claim_builder_fees,
create_orders,
set_builder_fee_factor,
settle_builder_fee,
type CreateOrderKind,
type CreateOrderOptions,
type CreateOrderParams,
type StoreProgram,
type TransactionGroup,
} from "@gmsol-labs/gmsol-sdk";

// Supply these from your integration. These declarations are not initialization.
declare const connection: Connection;
declare const anchorProgram: Program;
declare const storeAddress: PublicKey;
declare const signTransaction: (
transaction: VersionedTransaction,
) => Promise<VersionedTransaction>;

const sdkProgram: StoreProgram = {
id: anchorProgram.programId.toBase58(),
store: storeAddress.toBase58(),
};

async function submitGroup(
group: TransactionGroup,
block: BlockhashWithExpiryBlockHeight,
) {
try {
// Preserve group order: later groups may depend on earlier transactions.
for (const stage of group.serialize()) {
for (const bytes of stage) {
const transaction = VersionedTransaction.deserialize(new Uint8Array(bytes));
const signed = await signTransaction(transaction);
const signature = await connection.sendRawTransaction(signed.serialize());
const result = await connection.confirmTransaction(
{ signature, ...block },
"confirmed",
);
if (result.value.err) {
throw new Error(JSON.stringify(result.value.err));
}
}
}
} finally {
group.free();
}
}

If a blockhash expires or submission fails, check which transactions landed before rebuilding. Do not blindly resubmit a whole order group.

1. Prepare Builder Accounts​

The builder argument is the builder's User Account PDA, not its wallet address. Its seeds are "user", the Store address, and the builder wallet address.

The claim vault is the fee token's associated token account (ATA) owned by that PDA. It is not the builder wallet's ATA. This path uses the legacy SPL Token program.

Both accounts must exist before a nonzero fee is attached. The SDK's fee methods do not create them. This version does not export a high-level JS prepare_user function, so use the matching Anchor IDL for initialization and SPL Token for the vault:

function builderUserAddress(builderWallet: PublicKey) {
return PublicKey.findProgramAddressSync(
[Buffer.from("user"), storeAddress.toBuffer(), builderWallet.toBuffer()],
anchorProgram.programId,
)[0];
}

async function prepareBuilder(builderWallet: PublicKey, feeMint: PublicKey) {
const builderUser = builderUserAddress(builderWallet);
const claimVault = getAssociatedTokenAddressSync(feeMint, builderUser, true);
const prepareUser = await anchorProgram.methods
.prepareUser()
.accountsStrict({
owner: builderWallet,
store: storeAddress,
user: builderUser,
systemProgram: SystemProgram.programId,
})
.instruction();

// The Anchor provider wallet must be builderWallet.
const transaction = new Transaction().add(
prepareUser,
createAssociatedTokenAccountIdempotentInstruction(
builderWallet, claimVault, builderUser, feeMint,
),
);
await anchorProgram.provider.sendAndConfirm!(transaction, [], {
commitment: "confirmed",
});
return { builderUser, claimVault };
}

Prepare a vault for each fee mint your integration uses. For an increase order, this is the collateral mint. For a decrease order, use its final output mint. These preparation instructions can be repeated for existing accounts.

2. Publish the Builder Rate​

async function publishBuilderRate(builderWallet: PublicKey) {
const block = await connection.getLatestBlockhash("confirmed");
const group = set_builder_fee_factor({
payer: builderWallet.toBase58(),
factor: 10n ** 16n, // 0.01%; must be within the Store's configured cap.
program: sdkProgram,
recent_blockhash: block.blockhash,
});
await submitGroup(group, block);
}

Only the User Account's owner can publish its rate. Setting the rate to zero stops advertising a nonzero fee; it does not revoke fees already recorded on orders.

3. Create an Order with a Builder Fee​

Use your existing order parameters and market hints. Read the builder's rate, display it to the trader, and pass the approved value as expected_factor. Do not silently accept a new rate after the trader approves.

async function createOrdersWithBuilderFee(
kind: CreateOrderKind,
orders: CreateOrderParams[],
options: Omit<CreateOrderOptions, "recent_blockhash" | "program" | "set_builder_fee">,
builderUser: PublicKey,
approvedFactor: bigint,
) {
const block = await connection.getLatestBlockhash("confirmed");
const group = create_orders(kind, orders, {
...options,
program: sdkProgram,
recent_blockhash: block.blockhash,
set_builder_fee: {
builder: builderUser.toBase58(),
expected_factor: approvedFactor,
},
});
await submitGroup(group, block);
}

Here, options.payer is the trader. The same builder and rate apply to every order in the call. The SDK places each fee checkpoint immediately after its order's creation instruction in the same atomic group. For increase orders, it also requests the collateral-token output escrow when no explicit receive token is supplied.

To read the published rate, fetch the User Account with connection.getAccountInfo(builderUser) and decode its data with anchorProgram.coder.accounts.decode("userHeader", accountInfo.data). Read builderFeeFactor and convert the Anchor BN with BigInt(value.toString()). Decode the Store account as "store" and read factor.maxBuilderFeeFactor to display the configured cap.

For an existing pending order, the owner can use the contract's set_builder_fee instruction to switch to another builder or checkpoint the current builder's newly published rate. Each call must supply an expected_factor matching the selected builder's published rate. Execution uses the builder and rate from the last successful checkpoint; settlement sends the fee to that builder's claim vault. Market orders remain subject to the creation-slot restriction, even while pending.

Revocation checkpoints a User Account advertising zero with expected_factor = 0. It cannot erase a fee after execution. This SDK version has no standalone high-level JS set_builder_fee function; use the IDL if your integration needs these operations.

4. Settle Executed Orders​

SDK version limitation

In 0.11.0-beta.1, a settlement hint with builder_fee_amount = 0 causes the SDK to omit the transfer accounts, even if the order has a builder. Do not build settlement from a pending-order snapshot and place execution before it in the same transaction. Execution can create a fee that settlement then cannot transfer.

Wait for execution to complete, fetch the order again, and construct settlement from that fresh account. The example below accepts completed orders only.

Build the hint from these fields of the decoded Order account. Anchor uses camelCase field names; the SDK expects snake_case keys.

SDK hintAnchor Order field
builder_fee_amountbuilderFeeAmount
builderbuilder (User Account PDA)
final_output_tokentokens.finalOutputToken.token
escrowtokens.finalOutputToken.account
// Fields used below, decoded by the matching Anchor IDL.
type BuilderFeeOrder = {
header: { actionState: number; store: PublicKey };
builderFeeAmount: BN;
builder: PublicKey;
tokens: { finalOutputToken: { token: PublicKey; account: PublicKey } };
};

async function settleExecutedOrder(orderAddress: PublicKey, payer: PublicKey) {
const account = await connection.getAccountInfo(orderAddress, "confirmed");
if (!account || !account.owner.equals(anchorProgram.programId)) {
throw new Error("Order account missing or owned by another program");
}
const order = anchorProgram.coder.accounts.decode<BuilderFeeOrder>(
"order", account.data,
);
if (!order.header.store.equals(storeAddress)) {
throw new Error("Order belongs to another Store");
}
// ActionState::Completed = 1 in the matching contract version.
if (order.header.actionState !== 1) {
throw new Error("Wait for order execution before building settlement");
}
if (order.builderFeeAmount.isZero()) return;

// This SDK types the u64 amount as number. BN.toNumber() rejects values
// outside JavaScript's safe integer range instead of losing precision.
const block = await connection.getLatestBlockhash("confirmed");
const group = settle_builder_fee({
payer: payer.toBase58(),
program: sdkProgram,
recent_blockhash: block.blockhash,
orders: new Map([[orderAddress.toBase58(), {
builder_fee_amount: order.builderFeeAmount.toNumber(),
builder: order.builder.toBase58(),
final_output_token: order.tokens.finalOutputToken.token.toBase58(),
escrow: order.tokens.finalOutputToken.account.toBase58(),
}]]),
});
await submitGroup(group, block);
}

Under normal protocol invariants, the order escrow covers the recorded fee, and settlement transfers that full amount to the builder's claim vault. As a defensive measure, the contract transfers min(recorded_amount, escrow.amount) and clears the recorded amount after a successful transfer. The BuilderFeeSettled event reports both recorded_amount and settled_amount; integrations reconciling fees should compare them and flag any discrepancy.

Settlement is permissionless; the payer does not become the fee recipient. A zero recorded amount is a no-op. If another caller has already closed the order, fetching it fails: check its transaction history instead of retrying against a missing account.

An order with an unpaid Builder Fee cannot close (UnsettledBuilderFee). If your integration closes orders, settle first or supply CloseOrderArgs.settle_builder_fee to close_orders. Its map keys must exactly match the orders being closed; each settlement is placed before its close in the same atomic group. The fresh-account requirement above still applies.

5. Claim Fees​

The builder claims each mint separately. claim_builder_fees withdraws the entire vault balance; it has no amount parameter. The destination must be an existing token account for that mint, and must not be the claim vault itself when funds are transferred.

async function claimFees(
builderWallet: PublicKey,
feeMint: PublicKey,
destination: PublicKey,
) {
const block = await connection.getLatestBlockhash("confirmed");
const group = claim_builder_fees({
payer: builderWallet.toBase58(),
token_mint: feeMint.toBase58(),
destination: destination.toBase58(),
program: sdkProgram,
recent_blockhash: block.blockhash,
});
await submitGroup(group, block);
}

Use the builder wallet to sign. The destination can be its ATA or another token account it chooses. Create the destination before calling this method. An existing empty claim vault is a no-op; a missing vault is an error.

Errors and Operational Notes​

Error or conditionAction
BuilderFeeFactorExceedsMaxFactorRead the Store cap and choose an allowed rate. A zero cap requires a protocol configuration change before charging fees.
BuilderFeeFactorMismatchedFetch the builder's current rate and obtain the trader's approval before rebuilding.
BuilderFeeOrderKindNotAllowed / BuilderFeeSwapTypeNotAllowedUse a supported position order and swap type.
BuilderFeeFinalOutputTokenNotInitialized / BuilderFeeFinalOutputTokenEscrowNotInitializedCreate the order with its fee-token escrow. Retrying the checkpoint does not create it.
PreconditionsAreNotMet when setting a feeCheck the order state and, for market orders, the creation-slot restriction. Fees cannot be attached after execution.
TokenMintNotProvided during settlementCheck for a stale zero-fee hint. Fetch the executed order again before building settlement.
Frozen claim vaultSettlement can fail, which also prevents closing orders that owe fees to that vault. Only the token's freeze authority can thaw it.

Use fresh random order nonces without reuse. A fee checkpoint is bound to an order address and market, not to the market-scoped order ID; reusing an address can expose a replacement order to an older signed checkpoint.

References​