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:
- The receiver configured for the tag.
- The PartyB's default solver-fee receiver.
- 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
- PartyA sends a quote with open-side and close-side solver fee rate caps.
-
PartyB locks the quote, then opens it with the fee-aware
PartyBExecutionFacet.openPositionoverload when open solver fees should be charged, passing the tagged fee entries. It can instead usePartyBExecutionFacet.lockAndOpenPositionto perform both ordinary legs and the optional fee list in one call. - For close, PartyA uses the existing close method; close solver fee caps come from the original quote.
-
PartyB fills normal close requests with the fee-aware
PartyBExecutionFacet.fillCloseRequestoverload when a close solver fee should be charged. -
For close-to-liquidation flows with solver fees, PartyB should call the fee-aware
PartyBExecutionFacet.fillCloseRequestToLiquidationmethod. The legacyfillCloseRequestToLiquidationmethod 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
openPositionorlockAndOpenPosition. - Normal close solver fees can only be charged through the fee-aware
fillCloseRequestoverload. - 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
fillCloseRequestToLiquidationuses the fee-list total quoted formaxFillAmountas 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 amaxFillAmountbelow 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.
-
fillCloseRequestToLiquidationis kept only for backward compatibility and does not account for solver fees. - Force close remains restricted to
LIMITclose requests.