Operational Fees

Operational fees charge per-operation work through standing payer-to-charger allowances, separately from quote pricing.

Summary

v0.8.6 introduces a standing per-(payer, charger) operational-fee allowance. A charger can be a solver (PartyB) or a registered relayer. Each charge draws from the named payer's Symmio collateral balance and decrements that allowance.

The allowance is remaining spend authority, like an ERC20 allowance. Approving 100 and charging 30 leaves an allowance of 70; approving 100 again replaces that remaining value with 100.

The same allowance can carry a simple priority multiplier. 10000 means normal priority; a higher value is an off-chain scheduling signal that relayers and solvers may use when capacity is tight. It does not change the submitted fee.

The debit draws the payer's free balance first, then allocated margin as fallback. Fees land in the charger's configured receiver account (defaulting to the charger itself). A charge reverts if the resolved receiver is the payer, so a charger cannot route a payer's operational fee back into the same free-balance bucket.

This is separate from the v0.8.6 solver rate fee, which is notional-based, per-quote, and stored in SolverFeeCaps.

Upgrade note: the consumable allowance mapping uses the next storage-layout slot. Allowances written by the previous cumulative-budget implementation are intentionally ignored, so payers must approve chargers again after the upgrade.

Why operational fees exist

Some service costs are flat per-operation charges rather than notional-based trading fees. Operational fees give those costs a dedicated path, outside open and close price semantics.

Setup API

A registered PartyB is already an eligible charger. Governance must register any other service, such as a relayer, before a payer can grant it an allowance:

// FEE_ADMIN_ROLE; needed only for a non-PartyB service
function registerOperationalFeeCharger(address charger) external;

// The payer sets each charger's absolute remaining allowance.
function approveOperationalFee(address[] calldata chargers, uint256[] calldata amounts) external;

// The payer may set the allowances and off-chain priority signals together.
function approveOperationalFeeWithMultiplier(
    address[] calldata chargers,
    uint256[] calldata amounts,
    uint256[] calldata feeMultipliers
) external;

Both approval methods are batch setters: each amounts[i] replaces the payer's remaining allowance for chargers[i]; it is not an increment. Charges decrement this value. An increase applies immediately, while a reduction follows the delay explained under Timelocked reductions.

Integrations can inspect eligibility and the payer's effective remaining allowance before charging:

function isOperationalFeeCharger(address charger) external view returns (bool);

function getOperationalFeeAllowance(address payer, address charger)
    external
    view
    returns (
        uint256 allowance,
        uint256 pendingAllowance,
        uint256 reductionReadyAt,
        uint256 feeMultiplier
    );
  • allowance is the effective remaining amount the charger may draw.
  • pendingAllowance and reductionReadyAt describe a requested lower remaining allowance that is still waiting for the timelock. Once ready, the view reports min(allowance, pendingAllowance) as the effective allowance and returns zero for both pending fields.
  • feeMultiplier is the effective off-chain priority signal; the default is 10000.

There is no cumulative charged field. Integrations that need charge history should index the emitted event:

event OperationalFeeCharged(
    address indexed payer,
    address indexed charger,
    address receiver,
    uint256 amount
);

Trust model

The allowance grant is the consent. Core does not require the payer to sign each specific operation or quote; it only checks amount <= allowance[payer][charger] before collecting the fee, then decrements the allowance by amount.

The authority is bounded by four controls:

  1. Registry: registered PartyBs are chargers by default. Non-PartyB services, such as relayers, must be registered by governance (FEE_ADMIN_ROLE) before they can charge.
  2. Remaining allowance: each payer grants independent spend authority to each charger, so one charger cannot spend another charger's allowance.
  3. Timelocked reductions + governance unregister: a payer can walk away after the delay; governance can disable an explicitly registered service charger via unregisterOperationalFeeCharger.
  4. Guards. The charge path makes no external calls, and the payer must not be suspended or under active PartyA liquidation.

Fee source in perps-core

In perps-core, the fee source is the payer address passed to chargeOperationalFee(payer, amount). Core does not derive the payer from a quote, the signer, the relayed operation, or any account hierarchy.

For that named payer, core checks and consumes allowance[payer][charger], debits balances[payer] first, then falls back to allocatedBalances[payer] if free balance is not enough. The charged amount is credited to the charger's configured receiver.

That receiver is configured with:

function setOperationalFeeReceiver(address charger, address receiver) external;

The caller must be either the charger itself or an address holding FEE_ADMIN_ROLE, the same role that registers chargers in the first place. Passing address(0) resets the receiver back to the charger. The receiver is resolved at charge time, so a change only affects subsequent fees. ViewFacet.getOperationalFeeReceiver(charger) returns the effective receiver.

Solver rate fees have parallel but independent default and per-tag settings: ControlFacet.setSolverFeeReceiver and setSolverFeeReceiverForTag, gated by PARTY_B_MANAGER_ROLE instead. Keeping the two mechanisms separate lets a solver route flat operational costs and tagged per-notional trading revenue to different accounts. See Solver Fees.

AccountLayer accounts. Users operating through SubAccounts or virtual accounts may want operational fees to come from a parent SubAccount instead of the VA that appears in the signed operation. Perps-core will not resolve that relationship. The solver or relayer should determine the intended payer before calling core, whether in service code or an on-chain relayer contract, and pass that address to chargeOperationalFee. The SYMMIO-operated gasless relayer bills the parent SubAccount first; when the parent's balance or allowance cannot cover an op's fee, that op is charged to its own signer VA instead, provided the VA still exists and holds its own allowance and balance for the relayer.

Example: AccountLayer VA charged to a parent SubAccount

A user has a SubAccount SA and a virtual account VA-BTC-Long. The signed operation acts on the VA, but the integration wants the fee to come from the parent SubAccount.

  1. The user grants allowance[SA][relayer] = N.
  2. A registered relayer receives a "remove margin from VA-BTC-Long" signed op.
  3. The relayer determines VA-BTC-Long's parent SubAccount before charging and calls chargeOperationalFee(SA, fee). The fee is drawn from SA's free balance; no VA-BTC-Long margin is touched.
  4. The relayer submits InstantLayer.executeBatch for the signed operation.

Solvency

chargeOperationalFee is hierarchy-blind and signature-free: it always debits the named payer free-first, and the allocated remainder (if any) is bounded by a balance-only guard. It never runs a full liquidatability check, because the charge entrypoint carries no Muon upnl signature for the payer.

This relaxed check is intentional for operational fees. Solvers and relayers are trusted charging parties (registered PartyBs or governance-registered services), these fees are expected to be small per-operation amounts, and the payer's allowance caps future spend until the payer explicitly refreshes it. Core therefore checks that any allocated remainder exists, but does not require a full liquidatability signature for this path.

Portion Source Solvency check
Free balance balances[payer] None (not margin)
Allocated remainder allocatedBalances[payer] Balance-only guard (allocatedBalances[payer] >= remainder); no full liquidatability check (no sig available)

This is the same treatment for every operational-fee charger, including a registered solver. The solver's separate per-quote rate fee takes the opposite approach: it rides the execution call itself, bounded by the quote cap and a post-fee solvency check on that execution. See Solver Fees.

Timelocked reductions

Operational-fee allowance is the charger's remaining spend authority from a payer. Increasing the current live allowance is immediate, so a user can top up a solver or relayer whenever more operational-fee room is needed.

Reducing or revoking the allowance is delayed. During that delay, the requested lower target is not yet effective: the live allowance remains spendable and every charge continues to decrement it. This prevents a payer from approving an operation, letting a trusted charger take on the work, and then revoking the allowance before the small operational fee can be collected.

Once the delay has passed, the reduction is handled through lazy evaluation. View calls report the lower effective allowance automatically, and the next state-changing use of that allowance folds the pending value into storage before continuing. Charges made during the delay continue to decrement the live allowance; applying the pending reduction uses the lower of the live and pending values, so it can never replenish allowance already consumed. The user does not need a second transaction.

For example, suppose the live allowance is 100 and the payer schedules a reduction to 20. If the charger consumes 90 during the delay, the live allowance is 10. At maturity the effective allowance remains min(10, 20) = 10; the pending reduction never restores consumed allowance.

Priority multiplier

Each payer can attach a fee multiplier to a charger's allowance. The default is 10000, or 1x. A higher value is a priority signal for trusted chargers: if a relayer or solver cannot handle every request, it can prefer work from payers who accepted a higher multiplier.

The multiplier does not replace the allowance. The allowance remains the hard limit on future spend, and the charger can never draw more than the remaining approved amount. Core stores and reports the multiplier but does not multiply a submitted charge or enforce a pricing formula; interpreting the signal is an off-chain charger policy.

Gasless grant

Operational-fee approval is a normal core call. To grant an allowance without gas, the user signs an InstantLayer SignedOperation targeting the approval call.

The approval itself can therefore be gasless. This does not make the off-chain relayer the charger: chargeOperationalFee identifies the charger from raw msg.sender, while a core call routed through InstantLayer arrives from AccountLayer. Core does not provide a bundled approve-charge-execute entrypoint.

Charging while relaying

An EOA relayer can call chargeOperationalFee directly as a registered charger, then submit the signed operation, but those are separate transactions and do not share atomic rollback. To collect and execute atomically, an integration needs a bundling contract that is itself the registered and approved charger and that makes both calls in one transaction. In that wrapper design, a later execution revert also reverts the earlier charge.