Solver Fees

v0.8.6 adds explicit solver rate fees, charged from PartyA allocated balance instead of relying on execution-price spread. Each execution call carries a list of fee amounts with routing tags, so one trade can pay several receivers. Operational fees, which are flat per-operation charges shared with registered relayers, are a separate mechanism. See Operational Fees.

Summary

v0.8.6 adds explicit solver rate-fee accounting for quote open and close flows. Instead of forcing solvers to recover every fee through execution-price spread, PartyA can approve rate-based fee caps on the quote. PartyB can then charge solver fees against the relevant open or close cap. Fees ride the execution call as a list of SolverFeeEntry values, each pairing an amount with a bytes32 routing tag; each entry routes to the receiver configured for its tag, and the cap bounds the list's total.

Each charged amount is deducted from allocatedBalances[partyA] and credited to the receiver resolved for its tag, which defaults to balances[partyB] itself. This separates solver revenue from open and close prices, records it on-chain, and lets one execution split a fee across several attributed payouts.

Flat operational fees, charged by solvers or registered relayers per operation rather than per notional, use a separate mechanism with their own standing per-(payer, charger) allowance. See Operational Fees.

Why fees are explicit

The old solver compensation path was implicit: the solver could make the open or close price worse for the user and keep the difference as spread. That can produce confusing execution prices, especially in markets where a small absolute fee looks like a large price spread.

Solver fee caps move that compensation into a direct accounting path. The price can remain closer to the expected market execution, while the fee is tracked as a separate debit from PartyA allocated balance.

Fee model

The solver rate fee is a rate cap applied against quote notional. Its caps and cumulative charges are stored per quote in solverFeeStates; operational fees remain separate from quote state. Each fee-aware execution call is valid as long as the cumulative charged amount stays inside the rate cap:

Here, openNotional is the quote quantity valued at its initial opened price. closeNotional is the cumulative quantity closed so far, including the current fill, valued at the corresponding close prices. openFeeCharged and closeFeeCharged are the cumulative fees already collected on their respective sides; newOpenSolverFee and newCloseSolverFee are the totals of the fee lists passed by the current execution -- the cap is checked once against the sum, so a list either fits the remaining budget or the whole call reverts. Both rate caps are 18-decimal ratios, which is why the cap calculation divides by 1e18. Notional and rate caps gives the exact stored-state formulas.

openFeeCharged  + newOpenSolverFee  <= openNotional  * openRateCap  / 1e18
closeFeeCharged + newCloseSolverFee <= closeNotional * closeRateCap / 1e18

MARKET_BEST_EFFORT close fees

The fee-aware close-to-liquidation path accepts MARKET_BEST_EFFORT, but its maxQuantity cannot cause the underfill: it must be at least the request's full quantityToClose. The supplied solver fee therefore remains absolute instead of entering LIMIT's quantity-cap proration branch. For the order lifecycle, full and partial fills, cancellation race, guardrails, integration routes, events, and bot retry rules, see Close-to-Liquidation Execution.

API

Solver fee rate caps are stored directly for the open and close sides. Operational fees are separate from the quote state. See Operational Fees.

struct SolverFeeCaps {
    uint256 openRateCap;
    uint256 closeRateCap;
}

struct SolverFeeState {
    uint256 openRateCap;
    uint256 closeRateCap;
    uint256 openFeeCharged;
    uint256 closeFeeCharged;
}

struct SolverFeeEntry {
    uint256 amount;
    bytes32 tag;
}

PartyA can create a quote with immutable open-side and close-side solver fee rate caps using the new sendQuote overload. PartyB sees the rate-fee budget before choosing whether to lock and open the quote.

function sendQuote(
    address[] memory partyBsWhiteList,
    uint256 symbolId,
    PositionType positionType,
    OrderType orderType,
    uint256 price,
    uint256 quantity,
    uint256 cva,
    uint256 lf,
    uint256 partyAmm,
    uint256 partyBmm,
    uint256 deadline,
    address affiliate,
    SingleUpnlAndPriceSig memory upnlSig,
    bytes memory data,
    SolverFeeCaps memory solverFeeCaps
) external returns (uint256 quoteId);

The legacy sendQuoteWithAffiliate and sendQuoteWithAffiliateAndData methods remain available and keep their selectors. The legacy no-affiliate sendQuote selector is removed in v0.8.6. Integrations that used it must migrate to one of the remaining methods. Legacy methods store zero solver fee caps, so PartyB cannot charge a solver fee unless the user used the capped quote API.

The existing SendQuote(address,uint256,address[],address,bytes,bytes) event keeps its original paramsData layout for backward compatibility. When a quote has non-zero caps, the protocol emits a separate SendQuoteSolverFeeCaps event with partyA, quoteId, openRateCap, and closeRateCap.

Close solver fee caps are fixed at quote creation. PartyA cannot lower or replace them in requestToClosePosition, which prevents a user from changing solver economics right before relying on force close.

Open and close solver fees are not exposed as standalone charge selectors; they are charged inside the matching execution transaction so the fee and the solvency check stay atomic. Where each fee lands is configured separately via ControlFacet.setSolverFeeReceiver and the per-tag override, as described under Fee receiver and Tagged receiver routing. The equivalent setting for operational fees is ControlFacet.setOperationalFeeReceiver, described in Operational Fees.

For execution paths with solver fees, PartyB uses the fee-aware PartyBExecutionFacet methods. These carry a list of per-quote rate fee entries, each an amount paired with its routing tag; an empty list means no fee. They do not bundle an operational fee. The solver charges operational fees separately via AccountFacet.chargeOperationalFee(address payer, uint256 amount), exactly like any other registered charger (see Operational Fees):

function openPosition(
    uint256 quoteId,
    uint256 filledAmount,
    uint256 openedPrice,
    PairUpnlAndPriceSig memory upnlSig,
    SolverFeeEntry[] calldata solverFees
) external;

function fillCloseRequest(
    uint256 quoteId,
    uint256 filledAmount,
    uint256 closedPrice,
    PairUpnlAndPriceSig memory upnlSig,
    SolverFeeEntry[] calldata solverFees
) external;

function fillCloseRequestToLiquidation(
    uint256 quoteId,
    uint256 maxFillAmount,
    uint256 closedPrice,
    PairUpnlAndPriceSig memory upnlSig,
    SolverFeeEntry[] calldata maxSolverFees
) external returns (uint256 filledAmount);

PartyBExecutionFacet.lockAndOpenPosition also combines the ordinary lock and open legs with the same optional open-fee list. For close-to-liquidation, each entry's amount is quoted for the solver-provided maxFillAmount, and the list total is the most that can be charged. The planner budgets that total into the boundary calculation. If the liquidation boundary or remaining-value fallback produces a smaller fill, every entry is pro-rated independently as amount * filledAmount / maxFillAmount, and entries that floor to zero are dropped rather than charged. A fee-bearing call must therefore pass a concrete quantity as its fee basis: an effectively unbounded maxFillAmount such as type(uint256).max makes every prorated entry round down to zero, so nothing is charged. Use the quote's remaining close quantity when no tighter cap is wanted. For MARKET_BEST_EFFORT, maxFillAmount must be at least the full requested quantity, so it never caps the fill; the same proration still applies when the liquidation boundary closes less than maxFillAmount.

The legacy fillCloseRequestToLiquidation method remains available for backward compatibility, but it reserves room only for the protocol close fee. It does not reserve room for solver fees. Use the solver-aware version whenever a close-to-liquidation fill will include a solver fee.

Integrators can read the stored rate caps and cumulative charges with:

function getSolverFeeState(uint256 quoteId) external view returns (SolverFeeState memory);

Bots can also preview the uncapped close-to-liquidation sizing before submitting the transaction. Because this existing view does not take maxFillAmount, maxSolverFee is the planned fee-list total quoted for the quote's full pending quantityToClose, or 0 for a fee-less close. The preview uses that per-quantity rate for every candidate. At execution, quote the same rate against the chosen maxFillAmount and apply that cap to the preview result:

function getMaxCloseAmountToLiquidation(
    uint256 quoteId,
    uint256 closedPrice,
    uint256 marketPrice,
    int256 upnlPartyA,
    uint256 maxSolverFee
) external view returns (uint256 maxCloseAmount, bool canCloseAll);

Notional and rate caps

Open solver fee rate caps use the opened quote notional:

openNotional = quote.quantity * quote.initialOpenedPrice / 1e18;

After a close, its cumulative solver fee rate cap corresponds to cumulative closed notional:

closeNotional = quote.closedAmount * quote.avgClosedPrice / 1e18;

Because the fee is charged before the close updates quote state, the live check adds fillAmount * fillPrice to the existing closedAmount * avgClosedPrice. This cumulative formula supports partial closes. If only half of a position has been closed, the close solver fee rate cap is based on that half. If the rest of the position closes later, the cumulative closed notional grows and can unlock more solver-fee capacity.

When a quote is partially opened, the solver fee rate caps are copied unchanged to the child pending quote. Each quote's notional is already smaller, so a rate cap scales with notional automatically.

Fee receiver

By default, solver fees are credited to the solver PartyB. A PartyB can route them to a treasury or revenue-splitting account without changing how quotes are priced or charged. This default is the middle level of the per-charge resolution order; a per-tag override, described in Tagged receiver routing, takes precedence over it:

function setSolverFeeReceiver(address partyB, address receiver) external;

The caller must be either the PartyB itself or an address holding PARTY_B_MANAGER_ROLE, the same authorization shape used for symbol listings. Passing address(0) resets the receiver back to the PartyB. The target must be a registered PartyB. ViewFacet.getSolverFeeReceiver(partyB) returns the effective receiver.

The receiver is resolved at charge time, so changing it only affects subsequent fees. Fees already collected stay where they landed. The receiver may not be the quote's PartyA: routing a fee back to the payer would move allocated balance into free balance without setting deallocateTimestamp, sidestepping the withdraw cooldown. That charge reverts with "SolverFee: Receiver is partyA".

This mirrors the operational fee receiver, but the two are independent settings, so a solver can send per-notional trading revenue and flat operational costs to different accounts. The operational fee equivalent is gated by FEE_ADMIN_ROLE rather than PARTY_B_MANAGER_ROLE, since registered chargers are not always PartyBs.

Tagged receiver routing

Every fee entry carries a bytes32 tag chosen by PartyB. Tags are routing keys, not authorization proofs: a short human-readable label fits directly as right-padded ASCII (up to 32 bytes), and longer or structured identifiers can be hashed off-chain. Reusing a tag is allowed, inside one list or across calls, and two entries that resolve to the same receiver simply credit it twice. Receiver resolution has three levels, evaluated per entry at charge time:

  1. The receiver configured for the tag.
  2. The PartyB's default solver-fee receiver.
  3. The PartyB address itself.
function setSolverFeeReceiverForTag(
    address partyB,
    bytes32 tag,
    address receiver
) external;

function getSolverFeeReceiverForTag(
    address partyB,
    bytes32 tag
) external view returns (address);

The registered PartyB or PARTY_B_MANAGER_ROLE may set either receiver. Passing address(0) to the tag setter clears that override and restores fallback behavior. Changing a receiver only affects later charges. The same PartyA guard applies per entry: a tag whose resolved receiver equals the quote's PartyA reverts the call.

Accounting

Each entry debits PartyA separately and credits the receiver resolved for its tag:

for each entry:
    receiver = solverFeeReceiversByTag[partyB][entry.tag]
               || solverFeeReceivers[partyB]
               || partyB

    allocatedBalances[partyA] -= entry.amount
    balances[receiver] += entry.amount

PartyB allocated balances are not touched. Fees are paid directly to free balances, making them separable from trading margin allocation.

Relevant events. One SolverFeeCharged is emitted per list entry, in list order; receiver is the account actually credited, and the indexed tag lets indexers filter by attribution key directly:

event SolverFeeCharged(
    uint256 indexed quoteId,
    address partyA,
    address indexed partyB,
    address receiver,
    uint256 symbolId,
    SolverFeeType feeType, // OPEN or CLOSE
    uint256 amount,
    bytes32 indexed tag
);

event SetSolverFeeReceiver(
    address indexed partyB,
    address indexed receiver
);

event SetSolverFeeReceiverForTag(
    address indexed partyB,
    address indexed receiver,
    bytes32 indexed tag
);

event BalanceChangePartyA(
    address indexed partyA,
    uint256 amount,
    BalanceChangeType _type
);

Solver-fee debits emit BalanceChangePartyA with OPEN_SOLVER_FEE_OUT or CLOSE_SOLVER_FEE_OUT. Operational-fee debits emit OPERATIONAL_FEE_OUT, as described in Operational Fees.

Integration flow

  1. PartyA sends a quote with open-side and close-side solver fee rate caps.
  2. PartyB locks the quote, then opens it with the fee-aware PartyBExecutionFacet.openPosition overload when open solver fees should be charged, passing the tagged fee entries. It can instead use PartyBExecutionFacet.lockAndOpenPosition to perform both ordinary legs and the optional fee list in one call.
  3. For close, PartyA uses the existing close method; close solver fee caps come from the original quote.
  4. PartyB fills normal close requests with the fee-aware PartyBExecutionFacet.fillCloseRequest overload when a close solver fee should be charged.
  5. For close-to-liquidation flows with solver fees, PartyB should call the fee-aware PartyBExecutionFacet.fillCloseRequestToLiquidation method. The legacy fillCloseRequestToLiquidation method is protocol-close-fee-only.

This keeps all solver-side fees tied to the execution that justifies the fee and to the solvency check for that execution.

The fee-aware open and close methods are Solidity overloads of the legacy PartyB actions in the merged ISymmio ABI. Clients using merged-ABI tooling (for example ethers) must reference them by fully-qualified signature, e.g. openPosition(uint256,uint256,uint256,(...),(uint256,bytes32)[]), where the trailing tuple array is the SolverFeeEntry list. lockAndOpenPosition is a new selector rather than an overload.

Edge cases

  • Fee-aware open and close calls require the quote's PartyB. Before a pending quote has a PartyB, an eligible registered PartyB can use lockAndOpenPosition; the ordinary lock leg enforces its whitelist and assigns the caller as PartyB.
  • Open solver fees can only be charged through fee-aware openPosition or lockAndOpenPosition.
  • Normal close solver fees can only be charged through the fee-aware fillCloseRequest overload.
  • There are no standalone external solver-fee charge selectors; the fee list always rides the execution call that justifies it.
  • A non-empty fee list must carry only positive amounts; an empty list means no fee. A suspended PartyA cannot be charged a solver fee.
  • Legacy quote APIs store zero solver fee caps, so solver fee charging reverts unless caps were explicitly approved.
  • Solver rate fee charges revert if PartyA allocated balance is lower than the requested fee amount.
  • Fee-aware open and normal close calls require PartyB's post-action available balance to remain nonnegative and PartyA's post-action available balance to cover the requested solver fee.
  • The fee-aware fillCloseRequestToLiquidation uses the fee-list total quoted for maxFillAmount as a per-quantity rate while calculating the maximum close. The selected fill and the final transfer use the same fee math, with per-entry flooring only ever charging slightly less than the planned total. A best-effort request rejects a maxFillAmount below its requested quantity.
  • Close fees are charged immediately before the close executes, with the rate cap evaluated against the cumulative closed notional including the pending fill. This keeps the fee transfer safe from final-close hooks (such as AccountLayer virtual-account cleanup) that deallocate PartyA's balance.
  • When PartyA is bound to the quote's PartyB and that PartyB is bindable, the fee-aware overloads skip the PartyA post-fee solvency check. This matches core bound-mode semantics, where the bound PartyB internalizes PartyA risk. Fee caps and the PartyA allocated-balance check still apply.
  • fillCloseRequestToLiquidation is kept only for backward compatibility and does not account for solver fees.
  • Force close remains restricted to LIMIT close requests.