Express Withdrawal System Design

Express Withdrawal lets users receive funds before the normal cooldown by combining provider liquidity with affiliate-backed credit, then reconciling when SYMMIO finalizes the withdrawal.

1. Withdrawal timing

SYMMIO delays withdrawal finalization until the request's cooldownEndTime. That safety window can make a user wait for funds after the balance is ready to withdraw. SAME_TX and WINDOWED use provider funds for an earlier payout, then reconcile the provider when SYMMIO releases the underlying withdrawal.

2. ExpressProvider's role

ExpressProvider validates the signed offer, fronts the selected pool and credit-line funds before cooldownEndTime, and handles the callbacks that reconcile the payout when SYMMIO finalizes the withdrawal.

3. How it works (high level)

The system offers three withdrawal speeds, all routed through the same ExpressProvider contract:

Option Name When user gets funds Capital source
SAME_TX Same-tx Same transaction General/affiliate pools plus any optional credit advance, transferred in onWithdrawRequest
WINDOWED Windowed ~20 seconds Express pools front it (general + affiliate + credit line)
STANDARD Standard At cooldownEndTime SYMMIO sends to Express after cooldown, Express forwards to user (credit not supported)

All three options go through the ExpressProvider, so the bot signs, monitors, processes, and finalizes them through one orchestration surface. On-chain fallbacks remain permissionless at their documented times. The configurable flat operator fee can price per-request operating costs, including small withdrawals.

In the sequence below, parts is the WithdrawReceiverPart[] array that fixes each receiver and provider route; Section 7.4 defines its signed hash. providerData is the nested encoded payload containing the signed offer, validator approvals when required, and credit data when used; Section 7.7 gives the exact encoding.

sequenceDiagram
    participant User
    participant Bot
    participant Validators
    participant SYMMIO
    participant Express as ExpressProvider

    User->>Bot: 1. Request withdrawal options (amount, receiver)
    Bot->>Bot: Check liquidity, risk, sign EIP-712 option
    Bot->>Validators: Request risk attestations when the option requires them
    Validators->>Bot: Return signed ValidatorApprovals when requested
    Bot->>User: 2. Return signed option + applicable validator/credit data + parts

    User->>SYMMIO: 3. initiateWithdraw(parts, providerData)
    SYMMIO->>Express: 4. onWithdrawRequest(request, collateral)
	Express->>Express: Verify signatures, validate validators when required, enforce fees
    Express->>Express: Reserve credit debt via LibCreditLine (if creditAmount > 0)
    Express->>SYMMIO: 5. acceptWithdrawRequest(user, reqId)

    alt SAME_TX (same-tx transfer, validators required)
        Express->>SYMMIO: advanceWithdraw (activate credit, if any)
        Express->>User: Transfer tokens inside onWithdrawRequest
        Note over User: User has funds in the same transaction
        Note over SYMMIO: when the core cooldown ends...
        Bot->>SYMMIO: 6. finalizeWithdrawRequest
        SYMMIO->>Express: 7. Tokens + onWithdrawComplete
        Express->>Express: Replenish pools, settle credit debt
    else WINDOWED (capital fronted)
        Note over Bot: ~20s (WINDOWED)
        Bot->>Express: 6. processWithdraw (front from pools + activate credit)
        Express->>User: 7. Transfer tokens
        Note over SYMMIO: when the core cooldown ends...
        Bot->>SYMMIO: 8. finalizeWithdrawRequest
        SYMMIO->>Express: 9. Tokens + onWithdrawComplete
        Express->>Express: Replenish pools, settle credit debt
    else STANDARD (credit not supported)
        Note over SYMMIO: when the core cooldown ends...
        Bot->>SYMMIO: 6. finalizeWithdrawRequest
        SYMMIO->>Express: 7. Tokens + onWithdrawComplete
        Bot->>Express: 8. processWithdraw (forward tokens)
        Express->>User: 9. Transfer tokens
    end

4. Where the money comes from

4.1 Balance pools

The ExpressProvider maintains two types of liquidity pools:

flowchart TD
    subgraph ExpressProvider
        GP[General Pool<br/>generalBalance / lockedGeneralBalance]
        FP[Affiliate Pool per affiliate<br/>affiliateBalances / lockedAffiliateBalances]
    end

    Funder -->|depositToGeneral| GP
    Funder -->|depositToAffiliate| FP

General Pool: System-wide, available to all users. Any address can fund it via depositToGeneral().

Affiliate Pool: Per-affiliate, available only to that affiliate's users. Any address can fund it via depositToAffiliate(affiliate, amount).

event GeneralDeposit(address indexed depositor, uint256 amount);
event GeneralWithdraw(address indexed recipient, uint256 amount);
event AffiliateDeposit(address indexed affiliate, address indexed depositor, uint256 amount);
event AffiliateWithdraw(address indexed affiliate, address indexed recipient, uint256 amount);

Pool logs include the funding address or withdrawal recipient, so accounting systems do not need to infer the participant from collateral-token transfer logs.

When the ExpressProvider fronts a SAME_TX or WINDOWED withdrawal, it deducts from these pools. Pools are replenished when SYMMIO releases the actual tokens after cooldown.

4.2 Credit line

Pools have a hard limit: enough unlocked tokens must have been deposited. The credit line supplies the signed credit portion from core instead of provider pools, subject to caps derived from the affiliate's Muon-attested eligible base. The attestation is a capacity input, not an on-chain lien over affiliate or user collateral.

How it works:

The Muon oracle computes an aggregate "eligible base" for each affiliate off-chain: roughly, how much value the affiliate's users have that is eligible for withdrawal from SYMMIO. The oracle signs an attestation: "Affiliate X has Y eligible base." The ExpressProvider uses this attestation to determine how much credit the affiliate can take on.

When a withdrawal uses credit, the ExpressProvider doesn't front its own tokens for the credit portion. Instead, it calls advanceWithdraw on SYMMIO, which releases the credit amount directly from SYMMIO's collateral to the provider. The provider records this as affiliate-scoped outstanding exposure. When the withdrawal finalizes, SYMMIO releases only the non-advanced remainder (totalExpressAmount - advancedAmount) to the provider, and the exposure is cleared; the affiliate does not make a token repayment on the normal path. SYMMIO core can stop new advances with pauseWithdrawAdvance() while still allowing withdrawal finalization, cancellation, and suspension callbacks to continue.

The debt lifecycle:

RESERVE ──→ ACTIVATE ──→ SETTLE
   │                        ↑
   └── CANCEL (if withdrawal cancelled before processing)
  1. Reserve (on acceptance): Validates the Muon attestation, checks exposure caps, records the request amount as reservedDebt
  2. Activate (on processing): Moves debt from "reserved" to "active", calls advanceWithdraw to pull tokens from SYMMIO
  3. Settle (on finalization): Removes the amount from activeDebt and deletes request accounting; core has completed the withdrawal and retains the already-advanced amount as part of that payout
  4. Cancel (if withdrawal is cancelled pre-processing): Releases the reservation, no tokens were moved

Caps and controls:

  • Protocol caps: protocolMaxDebt (absolute cap) and protocolMaxDebtBps (percentage of eligible base). Set by admin. Cannot be loosened by affiliates.
  • Affiliate caps: affiliateMaxDebt and affiliateMaxDebtBps. Must be stricter than or equal to protocol caps.
  • Effective cap = the tighter nonzero protocol/affiliate value; zero means uncapped on that axis. Both the absolute and BPS checks must pass when their effective value is nonzero.
  • Pause: setCreditLinePaused(affiliate, true) disables all new credit reservations and blocks activation of already-reserved, not-yet-paid credit for an affiliate.
  • Blacklist: setCreditLineBlacklisted(affiliate, user, true) blocks a specific user from using credit.

Credit is not supported for STANDARD withdrawals: only SAME_TX and WINDOWED, because STANDARD doesn't front capital.

All credit line logic lives inside the ExpressProvider diamond (ControlFacet for admin setters, ViewFacet for reads, LibCreditLine for debt operations). State is stored in CreditLineStorage (diamond storage, keyed by affiliate). See Section 9 for technical details.

4.3 Liquidity priority

When constructing a SAME_TX or WINDOWED option, the bot chooses funding sources in this order:

  1. Affiliate Pool (lowest system risk: affiliate's own capital)
  2. Credit Line (backed by Muon-attested eligible balances, not supported for STANDARD)
  3. General Pool (system-wide fallback)

The bot encodes its decision into the signed option as affiliateAmount (how much from the affiliate pool) and creditAmount (how much from the credit line). The remainder comes from the general pool: generalAmount = expressAmount - affiliateAmount - creditAmount.

4.4 Funding cycle

When the ExpressProvider fronts funds for SAME_TX or WINDOWED withdrawals, it temporarily depletes its pools. Those pools are replenished when SYMMIO releases the actual tokens after the configured cooldown:

sequenceDiagram
    participant User
    participant SYMMIO
    participant Express as ExpressProvider

    Note over User,Express: === WITHDRAWAL (drains then replenishes) ===
    User->>Express: (via SYMMIO callback) withdraw 500 USDC
    Note over Express: 300 from general, 100 from affiliate, 100 from credit line
    Express->>Express: LibCreditLine.reserveDebt(affiliate, user, reqId, 100, creditData)
    Note over Express: Pools locked
    Express->>Express: LibCreditLine.activate(affiliate, user, reqId)
    Express->>SYMMIO: advanceWithdraw(user, reqId, 100)
    Express->>User: transfer 500 USDC from pools + credit (T+20s)
    Note over Express: Pools reduced by 400, credit active for 100
    SYMMIO->>Express: finalizeWithdrawRequest (T+12h) sends 400 USDC back
    Note over Express: Pools replenished by 400
    Express->>Express: LibCreditLine.settle(affiliate, user, reqId)

5. The withdrawal flows

5.1 STANDARD: standard withdrawal (core cooldown)

STANDARD does not front any capital and does not support credit lines (CreditNotSupportedForStandard error). ExpressProvider acts as an intermediary so one service can orchestrate the flow; core finalization and the delayed Express payout fallback are permissionless.

sequenceDiagram
    participant User
    participant Bot
    participant SYMMIO
    participant Express as ExpressProvider

    User->>Bot: Request withdrawal options
    Bot->>Bot: No fast liquidity available
    Bot->>Bot: Sign EIP-712 option (type=STANDARD, creditAmount=0)
    Bot->>User: Return STANDARD + parts

    User->>SYMMIO: initiateWithdraw(parts, providerData)
    SYMMIO->>Express: onWithdrawRequest(request, collateral)
    Express->>Express: Verify signature, NO pool locking
    Express->>SYMMIO: acceptWithdrawRequest(user, reqId)
    Note over Express: Status = ACCEPTED, pools untouched

    Note over SYMMIO: when the core cooldown ends

    Bot->>SYMMIO: finalizeWithdrawRequest(user, reqId)
    SYMMIO->>Express: Transfer express tokens + onWithdrawComplete()
    Note over Express: Status = FINALIZED, tokens held for user

    Bot->>Express: processWithdraw(user, reqId, parts)
    Express->>User: Forward express tokens to receiver
    Note over Express: No risk check needed: the configured cooldown was the security window

Key properties:

  • No express pool locking on accept (no capital fronted)
  • Credit lines are not supported (creditAmount must be 0)
  • onWithdrawComplete sets status to FINALIZED (tokens arrive from SYMMIO)
  • processWithdraw requires FINALIZED status (not ACCEPTED)
  • processWithdraw forwards express tokens to the user and moves the ExpressProvider status from FINALIZED to PROCESSED
  • Cancellable only while Express status is ACCEPTED; locking it first makes the cancellation callback revert
  • Once finalized, suspend is no longer valid. A LOCKED STANDARD can be resolved by unlockAndProcess only after onWithdrawComplete has set finalizedAt, or by processWithdraw at cooldownEndTime for an operator and after the added tolerance for anyone else. The latter calls core finalization first when needed.

5.2 WINDOWED: windowed withdrawal (~20 seconds)

WINDOWED fronts capital from the ExpressProvider's pools. The user gets funds after a short security window during which the bot performs a risk check.

sequenceDiagram
    participant User
    participant Bot
    participant SYMMIO
    participant Express as ExpressProvider
    User->>Bot: Request withdrawal options (amount, receiver)
    Bot->>Bot: Check liquidity, risk score
    Bot->>Bot: Sign EIP-712 option (type=WINDOWED)
    Bot->>User: Return signed option + parts

    User->>SYMMIO: initiateWithdraw(parts, providerData)
    SYMMIO->>Express: onWithdrawRequest(request, collateral)
    Express->>Express: Verify signature, check nonce
    Express->>Express: Validate validator signatures if effective minimum is positive
    Express->>Express: Lock funds from pools
    Express->>SYMMIO: acceptWithdrawRequest(user, reqId)

    Note over Bot: Wait ~20 seconds (securityWindow)
    Bot->>Bot: Risk check (anomaly detection API)

    alt User is LOW RISK
        Bot->>Express: processWithdraw(user, reqId, parts)
        Express->>Express: Activate credit (if creditAmount > 0)
        Express->>User: Transfer amounts
    else User is HIGH RISK
        Bot->>Express: lockWithdraw(user, reqId)
        Note over Express: Status = LOCKED, admin reviews
    end

    Note over SYMMIO: when the core cooldown ends...
    Bot->>SYMMIO: finalizeWithdrawRequest(user, reqId)
    SYMMIO->>Express: Transfer express amounts + onWithdrawComplete()
    Express->>Express: Replenish pools (general + affiliate), settle credit debt

Timing:

  • processWithdraw by operator: allowed after acceptedAt + securityWindow (default 20s)
  • processWithdraw by anyone (permissionless fallback): allowed after acceptedAt + securityWindow + tolerancePeriod (default 80s)

5.3 SAME_TX: same-transaction transfer

SAME_TX transfers funds to the user inside onWithdrawRequest itself: the user gets funds in the same transaction as initiateWithdraw. This requires validators to be enabled (minValidatorSignatures(affiliate) > 0).

sequenceDiagram
    participant User
    participant Bot
    participant Validators
    participant SYMMIO
    participant Express as ExpressProvider

    User->>Bot: Request withdrawal options
    Bot->>Validators: Request risk attestations
    Validators->>Bot: Return signed ValidatorApprovals
    Bot->>Bot: Sign EIP-712 option (type=SAME_TX)
    Bot->>User: Return signed option + validator sigs + parts

    User->>SYMMIO: initiateWithdraw(parts, providerData)
    SYMMIO->>Express: onWithdrawRequest(request, collateral)
    Express->>Express: Verify bot signature + validator signatures
    Express->>Express: Lock and deduct pools
    Express->>SYMMIO: acceptWithdrawRequest(user, reqId)
    Express->>User: Transfer tokens in the same transaction
    Express->>Express: Status = PROCESSED

    Note over User: User has funds. Done.

    Note over SYMMIO: when the core cooldown ends...
    Bot->>SYMMIO: finalizeWithdrawRequest(user, reqId)
    SYMMIO->>Express: Tokens + onWithdrawComplete
    Express->>Express: Replenish pools

Key properties:

  • Validators are mandatory: ValidatorsRequiredForSameTx error if minValidatorSignatures(affiliate) == 0
  • Status goes directly NONE -> PROCESSED (skips ACCEPTED)
  • processWithdraw cannot be called (already PROCESSED)
  • Lock/cancel not applicable (funds already transferred)
  • Suspension is still possible from PROCESSED while SYMMIO has not finalized; it uses the post-payout rollback path
  • Finalization works identically to WINDOWED (pools replenished at cooldown)
  • nonReentrant guard on onWithdrawRequest prevents reentrancy during transfers
  • User pays gas for the transfer (included in their initiateWithdraw tx)

5.4 Accelerate: promote a pending STANDARD to WINDOWED

When an affiliate's credit cap is full, the bot can sign a STANDARD offer off-chain to avoid Express pool-liquidity and credit-cap rejection; all other initiation and acceptance checks still apply. The UI tells the user they may wait until cooldownEndTime. The user's request is ACCEPTED as STANDARD: no pool lock, no credit. The request remains eligible for acceleration: if capacity frees up before the configured cooldown expires, any caller can promote it into WINDOWED-style processing and pay the user without waiting for cooldown.

The bot polls every 30 minutes; the frontend can also trigger a retry on demand (for example, right after raising affiliateMaxDebt). Both call the same permissionless accelerateWithdraw function with a fresh bot-signed AccelerateOffer.

sequenceDiagram
    participant User
    participant Bot
    participant Caller as Caller<br/>(bot / frontend / user)
    participant SYMMIO
    participant Express as ExpressProvider
    participant Validators

    User->>Bot: Request withdrawal options
    Bot->>Bot: Check cap: cap is full
    Bot->>Bot: Sign EIP-712 option (type=STANDARD, creditAmount=0)
    Bot->>User: Return STANDARD + parts (UI: "may take until cooldownEndTime")

    User->>SYMMIO: initiateWithdraw(parts, providerData)
    SYMMIO->>Express: onWithdrawRequest
    Express->>SYMMIO: acceptWithdrawRequest
    Note over Express: Status = ACCEPTED (STANDARD)

    loop Every ~30 minutes
        Bot->>Express: creditLineTotalDebt(affiliate) + creditLineBadDebt(affiliate)
        alt Cap now has room
            Bot->>Bot: Sign AccelerateOffer (affiliateAmount, creditAmount, accelerationFee)
            Bot->>Validators: Request ValidatorAccelerateApproval (if minSigs > 0)
            Validators-->>Bot: Signatures + timestamps (must postdate last balance credit)
            Caller->>Express: accelerateWithdraw(user, reqId, parts, offer, validatorData, creditData)
            Express->>Express: Verify SIGNER_ROLE signature
            Express->>Express: Verify validator quorum + freshness (if minSigs > 0)
            Express->>Express: reserveDebt (cap check: atomic revert if still full)
            Express->>Express: Check accelerationFee <= maxAccelerationFee
            Express->>Express: Lock pool funds, store accelerationFee, optionType -> WINDOWED
            Express->>SYMMIO: advanceWithdraw (pull collateral early)
            Express->>User: Transfer tokens
            Note over Express: Status = PROCESSED
        else Still full
            Note over Bot: No-op: keep polling
        end
    end

    Note over SYMMIO: When cooldownEndTime arrives (if not accelerated)
    Bot->>SYMMIO: finalizeWithdrawRequest (normal STANDARD path)
    SYMMIO->>Express: onWithdrawComplete

State transition on successful acceleration:

ACCEPTED (STANDARD) ──accelerateWithdraw──► PROCESSED (WINDOWED)

The optionType field on WithdrawInfo is mutated from STANDARD to WINDOWED. Downstream lifecycle hooks (onWithdrawComplete, onWithdrawSuspend) then route through the existing WINDOWED branches unchanged: pools get replenished at core finalization, credit debt is settled, and suspend-after-processed rollback works via _handleProcessedRollback.

Preconditions (enforced in order):

  1. info.status == ACCEPTED
  2. info.optionType == STANDARD
  3. info.finalizedAt == 0 (core hasn't finalized yet)
  4. block.timestamp < info.cooldownEndTime (after that, the normal STANDARD finalization path takes over, avoiding a race with core)
  5. block.timestamp <= offer.deadline
  6. accelerateNonces[user][requestId] == offer.nonce
  7. keccak256(abi.encode(parts)) == info.partsHash
  8. Offer signature recovers to a SIGNER_ROLE holder
  9. If minValidatorSignatures(affiliate) > 0: a quorum of ValidatorAccelerateApproval signatures over (user, requestId, partsHash), each within validatorApprovalTimeout and each signed strictly after withdrawCooldownOf(user) (see 7.6)
  10. offer.affiliateAmount + offer.creditAmount <= info.expressAmount
  11. offer.accelerationFee <= info.maxAccelerationFee
  12. info.fee + operatorFee + offer.accelerationFee <= info.expressAmount
  13. Pool balances cover the requested split
  14. Credit cap accommodates offer.creditAmount (handled by LibCreditLine.reserveDebt)

Failure semantics:

  • Any failure (cap full, pool short, bad signature, etc.) reverts the entire transaction atomically. The STANDARD request is left untouched, the nonce is not consumed, and the same signature can be retried while its deadline and request preconditions remain valid.
  • The accelerateNonces counter is incremented only on successful acceleration. There is no separate on-chain nonce-cancellation method; before success, an offer stops working only through its deadline or another failed precondition. After success, the request is already PROCESSED and the nonce has advanced.

Preserved fields: expressAmount, fee, operatorFee, cooldownEndTime, acceptedAt, partsHash. Successful acceleration stores the bot-signed accelerationFee separately on WithdrawInfo before payout. This lets STANDARD remain cheap or free when it is not accelerated, while charging only the actually used acceleration premium when the request is upgraded.

Permissioning: accelerateWithdraw is permissionless. The caller is not verified. All trust comes from the SIGNER_ROLE signature embedded in the offer. This lets frontends, keepers, or even the user themselves submit the transaction without coordinating with the bot on who sends the tx.

What counts as an affiliate raising its own cap: the affiliate address calls ControlFacet.setMyCreditLineConfig(newMaxDebt, newMaxDebtBps) with looser values that remain within protocol caps. After this call, its backend can either notify the bot (which picks it up on the next 30-minute poll) or request a fresh AccelerateOffer from the bot's API and submit accelerateWithdraw itself.

Event model (indexers should track both acceleration and withdrawal lifecycle):

  • Initial accept: WithdrawAccepted(user, requestId, optionType=2): STANDARD
  • Successful acceleration: WithdrawAccelerated(user, requestId, affiliate, affiliateAmount, creditAmount, generalAmount) followed by WithdrawProcessed(user, requestId)
  • No longer acceleratable: WithdrawFinalized, WithdrawCancelled, or WithdrawSuspended. For unaccelerated STANDARD, WithdrawFinalized still precedes the Express processWithdraw payout.

5.5 Affiliate cap self-service & fee model

Raising a cap can make queued STANDARD withdrawals eligible for WINDOWED acceleration. To limit repeated self-service increases, cap adjustments use a quota and fee throttle. A SETTER_ROLE holder still has a direct bypass via setCreditLineAffiliateConfig for emergency support.

Self-service entrypoint:

ControlFacet.setMyCreditLineConfig(uint256 maxDebt, uint256 maxDebtBps)

msg.sender is treated as the affiliate identity: no parameter, no role check. Each affiliate self-manages their own slot; they cannot touch anyone else's.

Economics:

  • Decreases are always free. The on-chain classifier treats a change as a decrease iff neither dimension loosens (using 0 as "uncapped = infinity" when comparing). Tightening risk is unconditional and delay-free.
  • Increases use a per-window quota. Each affiliate gets maxFreePerWindow free increases per windowDuration-second epoch. The epoch resets the first time a call lands after block.timestamp >= epochStart + windowDuration.
  • Over-quota increases charge a fee. The protocol admin configures a fee ERC20 token, fee amount, and fee receiver. Once the free allowance is exhausted in the current window, each further increase transfers feeAmount of the fee token from the affiliate to the configured receiver via safeTransferFrom.
  • No-ops revert. Calling with unchanged values reverts NoOpCapChange so there's no incentive to "refresh" state for any reason.

Decrease / increase classification table:

Old (maxDebt, maxDebtBps) New Classification Counts? Fee?
(100, 1000) (50, 1000) Decrease No No
(100, 1000) (100, 500) Decrease No No
(100, 1000) (200, 1000) Increase Yes If over quota
(100, 1000) (50, 2000) Increase (bps loosened) Yes If over quota
(100, 1000) (0, 1000) Increase (self-cap removed, 0 = uncapped) Yes If over quota
(0, 0) (100, 1000) Decrease (infinity -> 100/1000) No No
(100, 1000) (100, 1000) No-op -- Revert NoOpCapChange

Dormant-by-default. New deployments start with capChangeWindowDuration == 0, which disables the throttle entirely (all increases are free). The feature activates only once the admin calls setCapChangeQuotaConfig and setCapChangeFeeConfig. Fee config must be set for any paid path to succeed: otherwise the charge reverts CapChangeFeeNotConfigured.

Admin configuration (SETTER_ROLE):

setCapChangeFeeConfig(address feeToken, uint256 feeAmount, address feeReceiver)
setCapChangeQuotaConfig(uint256 maxFreePerWindow, uint256 windowDuration)

All five parameters are individually mutable at any time. Mid-window changes take effect on the next setMyCreditLineConfig call. The fee token is an arbitrary ERC20 selected by the admin; the protocol does not assume SYMM, USDC, or any particular token.

Emergency bypass: the existing setCreditLineAffiliateConfig(address affiliate, uint256 maxDebt, uint256 maxDebtBps) remains gated by SETTER_ROLE. It bypasses the throttle and fee. Use for support cases (an affiliate is stuck over quota and needs a one-off adjustment).

Flow diagram:

flowchart TD
    A[Affiliate calls setMyCreditLineConfig] --> B{Protocol cap invariant ok?}
    B -->|No| Z1[revert AffiliateLimitExceedsProtocol]
    B -->|Yes| C{No-op?}
    C -->|Yes| Z2[revert NoOpCapChange]
    C -->|No| D{Decrease?<br/>neither dim loosened}
    D -->|Yes| E[Apply change, no counter, no fee]
    D -->|No| F{windowDuration == 0?}
    F -->|Yes| E
    F -->|No| G{Epoch expired?}
    G -->|Yes| H[Reset counter to 0<br/>epochStart = now]
    G -->|No| I[Keep counter]
    H --> J[Counter += 1]
    I --> J
    J --> K{Counter <= maxFreePerWindow?}
    K -->|Yes| E
    K -->|No| L{Fee config complete?}
    L -->|No| Z3[revert CapChangeFeeNotConfigured]
    L -->|Yes| M[safeTransferFrom feeAmount<br/>to feeReceiver]
    M --> E
    E --> N[Emit CreditLineAffiliateConfigSelfUpdated<br/>and CreditLineAffiliateConfigUpdated]

Events to monitor:

  • CreditLineAffiliateConfigSelfUpdated(affiliate, maxDebt, maxDebtBps, wasDecrease, feePaid): emitted on every successful self-service call, with feePaid == 0 for free changes
  • CreditLineAffiliateConfigUpdated(affiliate, maxDebt, maxDebtBps): re-emitted for indexer parity; also emitted by the admin bypass path, so indexers watching both get full coverage
  • CapChangeFeeConfigUpdated(feeToken, feeAmount, feeReceiver): when admin updates fee config
  • CapChangeQuotaConfigUpdated(maxFreePerWindow, windowDuration): when admin updates quota config

View getters (for frontend UX):

capChangeFeeConfig() returns (address feeToken, uint256 feeAmount, address feeReceiver)
capChangeQuotaConfig() returns (uint256 maxFreePerWindow, uint256 windowDuration)
capChangeAffiliateState(address affiliate) returns (uint256 count, uint256 epochStart, uint256 remainingFree, uint256 nextResetAt)

remainingFree automatically accounts for an elapsed window (returns the full maxFreePerWindow if the window has expired, since the next call will reset the counter). When windowDuration == 0, every increase is free regardless of that numeric return. With an active window, the view lets a frontend preview whether the next loosening consumes free quota or reaches the paid path.

6. Safety mechanisms

6.1 Security window

The security window (default 20 seconds) is the delay between acceptance and processing for WINDOWED withdrawals. During this window, the bot performs a risk check via an anomaly detection API. For SAME_TX, there is no post-acceptance window: the only risk gating is the validator attestations that are verified during acceptance. For STANDARD, the configured SYMMIO cooldown itself provides the security window.

6.2 Risk locking

If the anomaly detection API flags a user as risky between acceptance and processing:

stateDiagram-v2
    ACCEPTED --> LOCKED: lockWithdraw() [LOCKER_ROLE]
    LOCKED --> PROCESSED: unlockAndProcess() [UNLOCK_ROLE, false alarm]
    LOCKED --> PROCESSED: processWithdraw() [operator at cooldown; anyone after + tolerance]
    LOCKED --> SUSPENDED: onWithdrawSuspend [SYMMIO]

While locked, processWithdraw normally reverts. However, once the SYMMIO cooldown expires without suspension, processWithdraw accepts LOCKED withdrawals: the risk window is over and the withdrawal can be processed like any other (including the permissionless fallback after tolerancePeriod). An UNLOCK_ROLE holder can also unlock a WINDOWED request early via unlockAndProcess (false alarm); a STANDARD request must already have finalizedAt != 0 for that route. UNLOCK_ROLE, LOCKER_ROLE, and OPERATOR_ROLE are distinct permissions. The owner can grant them to the same address, so operational key separation, not the contract, must keep the processing bot from also controlling both sides of a lock.

SAME_TX: no post-acceptance risk check:

Funds are transferred in the same transaction as acceptance. The only risk gating is pre-acceptance: a positive validator threshold and a valid quorum are mandatory when the request is accepted. Once the user submits initiateWithdraw, the transfer is atomic and irreversible.

WINDOWED: risk check between acceptance and processing:

  1. Query anomaly detection API during the securityWindow (~20s)
  2. If LOW RISK: call processWithdraw
  3. If HIGH RISK: LOCKER_ROLE holder calls lockWithdraw to prevent permissionless processing, then notifies admin
  4. An authorized reviewer resolves the alert:
    • An UNLOCK_ROLE holder calls unlockAndProcess (false alarm)
    • A core SUSPENDER_ROLE holder calls SYMMIO's suspendWithdrawRequest (confirmed bad actor)

STANDARD: risk check during the configured cooldown:

  1. A LOCKER_ROLE holder can flag an ACCEPTED request with lockWithdraw; the contract does not impose a separate timestamp bound, although the intended review occurs during the configured cooldown
  2. When SYMMIO finalizes, onWithdrawComplete preserves the LOCKED status: tokens arrive but can't be forwarded
  3. No risk check is required after finalization because the configured cooldown has already supplied the security window
  4. Before finalization, admin may still suspend via SYMMIO. After cooldown, processWithdraw can resolve the lock (operator immediately, any caller after tolerancePeriod); unlockAndProcess remains the UNLOCK_ROLE early-resolution path.

6.3 Cancellation

flowchart TD
    A{Option Type?}
    A -->|SAME_TX| Z[NOT CANCELLABLE: funds already transferred]
    A -->|WINDOWED| B{Status?}
    A -->|STANDARD| D{Status?}

    B -->|ACCEPTED, not processed| E[SYMMIO calls onWithdrawCancelRequest]
    E --> F[Express unlocks pools + releases credit reservation]
    F --> G[Express calls acceptWithdrawCancelRequest on SYMMIO]

    B -->|PROCESSED| H[Cannot cancel - funds already sent]
    D -->|ACCEPTED| E
    D -->|LOCKED or FINALIZED| I[Cancellation callback reverts]

SAME_TX is non-cancellable because funds are already transferred in the same transaction. WINDOWED and STANDARD are cancellable only from Express ACCEPTED; onWithdrawCancelRequest rejects a LOCKED request too.

6.4 Suspension

An operator with SUSPENDER_ROLE on SYMMIO can suspend a user's withdrawal. SYMMIO calls onWithdrawSuspend on the ExpressProvider, which:

  1. Unlocks pool counter locks for pre-payout ACCEPTED/LOCKED withdrawals
  2. Releases credit line reservation (if creditAmount > 0)
  3. Sets status to SUSPENDED

If the withdrawal was already PROCESSED, including SAME_TX after same-transaction payout, _handleProcessedRollback is called instead. That path promotes pending fees to claimable buckets, adds the request's generalAmount to the cumulative generalBadDebt counter, and charges credit loss against the affiliate's unlocked balance before recording any uncovered credit as affiliate bad debt. It does not return collateral to core or replenish either pool.

Note: SAME_TX withdrawals cannot be cancelled after acceptance because the funds are already transferred in the same transaction. They can still be suspended from PROCESSED before SYMMIO finalization, which triggers post-payout rollback. STANDARD withdrawals can only be suspended before finalization; once onWithdrawComplete has delivered the tokens, suspension is invalid.

6.5 State machines

WINDOWED (capital fronted):

stateDiagram-v2
    [*] --> NONE
    NONE --> ACCEPTED: onWithdrawRequest

    ACCEPTED --> PROCESSED: processWithdraw (front from pools)
    ACCEPTED --> LOCKED: lockWithdraw (risk detected)
    ACCEPTED --> CANCELLED: onWithdrawCancelRequest
    ACCEPTED --> SUSPENDED: onWithdrawSuspend

    LOCKED --> PROCESSED: unlockAndProcess [UNLOCK_ROLE] (false alarm)
    LOCKED --> PROCESSED: processWithdraw (operator at cooldown; anyone after + tolerance)
    LOCKED --> SUSPENDED: onWithdrawSuspend

    PROCESSED --> FINALIZED: onWithdrawComplete (pools replenished)

SAME_TX (same-tx, validators required):

NONE → PROCESSED (onWithdrawRequest: validated + transferred)
PROCESSED → FINALIZED (onWithdrawComplete: pools replenished)

STANDARD (no fronting):

stateDiagram-v2
    [*] --> NONE
    NONE --> ACCEPTED: onWithdrawRequest

    ACCEPTED --> LOCKED: lockWithdraw [LOCKER_ROLE]
    ACCEPTED --> FINALIZED: onWithdrawComplete (tokens arrive)
    ACCEPTED --> CANCELLED: onWithdrawCancelRequest
    ACCEPTED --> SUSPENDED: onWithdrawSuspend

    LOCKED --> LOCKED: onWithdrawComplete (tokens arrive, lock preserved)
    LOCKED --> PROCESSED: unlockAndProcess [UNLOCK_ROLE] (false alarm)
    LOCKED --> PROCESSED: processWithdraw (operator at cooldown; anyone after + tolerance; finalizes + processes)
    LOCKED --> SUSPENDED: onWithdrawSuspend

    FINALIZED --> PROCESSED: processWithdraw (forward to user, no risk check needed)

For STANDARD, the intended risk review happens during the configured cooldown, but lockWithdraw itself only checks for ACCEPTED status and has no timestamp guard. Once SYMMIO has finalized and sent the tokens, the cooldown served as the security window. If onWithdrawComplete finds the request LOCKED, it preserves that status; tokens cannot be forwarded until an authorized unlockAndProcess or the timed processWithdraw fallback, and suspension is no longer valid. At cooldownEndTime, processWithdraw accepts a LOCKED request immediately from an OPERATOR_ROLE caller and after tolerancePeriod from anyone else. For STANDARD it calls core finalizeWithdrawRequest first when needed.

SYMMIO Withdrawal Status (for reference):

Status Description
PENDING Created by initiateWithdraw. Awaiting provider.
PROVIDER_ACCEPTED Provider accepted. Awaiting cooldown/processing.
PROVIDER_REJECTED Provider rejected. Funds refunded.
CANCEL_REQUESTED User requested cancellation. Awaiting provider response.
CANCELLED Cancelled. Funds refunded to user.
SUSPENDED Operator suspended. Funds refunded to user.
COMPLETED Finalized. Tokens transferred.

6.6 Finalize callback reentrancy

Finalizing a withdrawal calls back into the provider: onWithdrawComplete for an express part, and the IVirtualProvider equivalent for a pure-virtual request. That callback runs mid-finalization: the collateral has already moved, but the request is not yet marked COMPLETED.

Two entry points were reachable from inside that window, and both would have corrupted the request:

Both operations are rejected while a finalize callback is executing, each with its own revert string.
Entry point What re-entry would have done Revert
advanceWithdraw advanced against a request whose funds were already being released WithdrawFacet : Cannot advance during finalize callback
acceptWithdrawCancelRequest cancelled a request that was in the middle of completing WithdrawFacet : Cannot accept cancel during finalize callback

Core sets WithdrawStorage.finalizingInProgress immediately before each provider callback and clears it immediately after, so the flag is only ever observable from inside a callback. A provider that does not re-enter never sees it.

7. The signature scheme (EIP-712)

The bot signs withdrawal options using EIP-712 typed data. The ExpressProvider verifies these signatures on-chain in the onWithdrawRequest callback.

7.1 Domain

name: "ExpressProvider"
version: "1"
chainId: <chain id>
verifyingContract: <ExpressProvider proxy address>

7.2 Option type

WithdrawOption(
    address user,
    uint256 nonce,
    uint8 optionType,      // 0 = SAME_TX, 1 = WINDOWED, 2 = STANDARD
    uint256 availableAt,   // signed and stored, but not read by current processing logic
    address affiliate,      // affiliate pool to use
    uint256 affiliateAmount,// how much from affiliate pool
    uint256 creditAmount,  // how much from credit line (must be 0 for STANDARD)
    uint256 fee,           // affiliate fee in collateral decimals
    uint256 operatorFee,   // fixed operator fee in collateral decimals
    uint256 maxUserFee,    // max fee the user pays (reverts if exceeded)
    uint256 maxAccelerationFee, // max extra fee user authorizes if STANDARD is accelerated
    bytes32 partsHash,     // keccak256(abi.encode(parts))
    uint256 deadline       // signature expiry timestamp
)

7.3 Nonce management

Each user has a per-user nonce stored in ExpressProvider.nonces[user]. The bot must read the current nonce before signing. The nonce is consumed (incremented) atomically when onWithdrawRequest succeeds.

Bot must: Call expressProvider.nonces(user) to get current nonce before signing.

7.4 Parts hash

The partsHash binds the signature to exact withdrawal parts. Computed as:

partsHash = keccak256(abi.encode(parts))

Where parts is an array of WithdrawReceiverPart:

struct WithdrawReceiverPart {
    uint256 id;
    uint256 amount; // collateral decimals (e.g. 6 for USDC)
    int256 chainId;
    bytes receiver; // 20 bytes, the receiver address
    address virtualProvider; // must be address(0) on every part routed through this ExpressProvider
    address expressProvider; // ExpressProvider address
}

7.5 Accelerate offer type

When the bot wants to promote an already-ACCEPTED STANDARD request into WINDOWED-style processing (see §5.4), it signs a separate typed struct:

AccelerateOffer(
    address user,
    uint256 requestId,
    uint256 nonce,           // per-(user, requestId) counter, read via ViewFacet.accelerateNonce
    uint256 affiliateAmount, // how much from affiliate pool
    uint256 creditAmount,    // how much from credit line
    uint256 accelerationFee, // extra fee charged only if acceleration succeeds
    bytes32 partsHash,       // must equal info.partsHash (binds to the exact accepted request)
    uint256 deadline         // signature expiry timestamp
)

Notes:

  • Uses the same EIP-712 domain as WithdrawOption.
  • partsHash is bound into the struct so an acceleration signature for (userA, requestId=1) cannot be replayed against any other request.
  • The per-(user, requestId) nonce is isolated from the main g.nonces[user] counter: acceleration attempts do not affect the main nonce.
  • The nonce is incremented only on successful acceleration. A stale-but-still-valid signature keeps working across retries as long as caps or pool balances make it succeed.
  • accelerationFee is bound into the signature and must be no greater than the original STANDARD offer's stored maxAccelerationFee. It is charged only when acceleration succeeds.
  • operatorFee, expressAmount, and cooldownEndTime are not in the accelerate offer; they are pinned from the original STANDARD accept and reused unchanged.

7.6 Validator approval signature

Validators sign a separate EIP-712 message attesting to user legitimacy:

Type:

ValidatorApproval(
    address user,        // the withdrawing user
    uint256 nonce,       // must match the bot option nonce (ties to specific withdrawal)
    uint256 amount,      // SYMMIO request totalAmount (full request, not the express portion)
    uint256 timestamp,   // when the validator signed (freshness + must postdate the last balance credit)
    address symmio       // SYMMIO core address (cross-deployment replay protection)
)

Uses the same EIP-712 domain as the bot option (same contract, chain). The signature also binds the SYMMIO core address so a validator approval signed for one deployment cannot be replayed against an Express Provider that integrates with a different SYMMIO core.

Freshness binding (last balance credit): every approval must be signed strictly after the user's most recent protocol-internal credit into their withdrawable balance, read from core as withdrawCooldownOf(user) (core's deallocateTimestamp). Core stamps this timestamp on every deallocate variant and on internalTransferToBalance: the path realized PnL takes from a virtual account into its parent sub-account's withdrawable balance. If new funds land after the validators signed, the approvals are rejected with StaleValidatorApproval and must be re-issued against the post-credit state. External deposit() does not stamp the timestamp and therefore does not invalidate approvals (deposits are not protocol-generated PnL).

The timestamp tracks the withdrawable balance itself rather than trading activity. Under the AccountLayer the withdrawing address is a sub-account: it holds funds but never trades, so no position, settlement, or liquidation touches its own PartyA nonce: all of that happens on its virtual accounts. What does move when realized PnL becomes withdrawable is deallocateTimestamp, stamped on the receiving sub-account by internalTransferToBalance. Binding to it invalidates an approval exactly when new funds enter the balance the withdrawal draws from, and leaves it valid when unrelated trading occurs.

On-chain validation (in onWithdrawRequest):

  1. Check signatures.length >= minValidatorSignatures(affiliate) (falls back to minValidatorSignatures(address(0)) if affiliate-specific not set)
  2. For each signature:
    • Reject future timestamps (timestamp > block.timestamp)
    • Verify block.timestamp - timestamp <= validatorApprovalTimeout(affiliate) (falls back to validatorApprovalTimeout(address(0)) if affiliate-specific not set, default 30s)
    • Verify timestamp > ISymmio(symmio).withdrawCooldownOf(user): the approval must postdate the last credit into the user's withdrawable balance, else revert StaleValidatorApproval
    • Recover signer from EIP-712 digest
    • Verify signer via isValidator(affiliate, signer), which accepts either affiliate-specific registration or registration under the address(0) default
    • Verify signer address is strictly greater than the previous (ascending order = no duplicates)
  3. If any check fails, the callback and surrounding initiateWithdraw transaction revert; no request is accepted.

These checks are option-specific. SAME_TX requires an effective minimum greater than zero and validates the quorum; a zero minimum reverts with ValidatorsRequiredForSameTx. WINDOWED validates the quorum only when its effective minimum is greater than zero, otherwise it skips these checks. STANDARD always skips validator validation during acceptance, regardless of the configured minimum.

Accelerate approvals: when minValidatorSignatures(affiliate) > 0, the same validator quorum is also required on accelerateWithdraw (STANDARD -> WINDOWED promotion), since the STANDARD accept path skips validators. Validators sign a dedicated type over the frozen request:

ValidatorAccelerateApproval(
    address user,        // the withdrawing user
    uint256 requestId,   // the STANDARD request being accelerated
    bytes32 partsHash,   // pinned parts of the frozen request
    uint256 timestamp,   // when the validator signed (freshness + must postdate the last balance credit)
    address symmio       // SYMMIO core address (cross-deployment replay protection)
)

The same last-balance-credit freshness rule applies: each timestamp must satisfy timestamp > ISymmio(symmio).withdrawCooldownOf(user), else StaleValidatorApproval. This check is required here because STANDARD acceptance skips validators entirely, so the accelerate approval is the first and only attestation the withdrawal ever receives, and it gates the exact moment credit is advanced and pools are drained. Because accelerateWithdraw is permissionless, without it a user could front-run their own acceleration with a deallocate and have funds advanced against a state no validator inspected: the validatorApprovalTimeout window alone does not close that gap.

The approval binds (user, requestId, partsHash) rather than an amount because the frozen request already fixes the payout: initiateWithdraw debited the user's balance and pinned partsHash at creation, so later PnL cannot enlarge this withdrawal. It is also deliberately not bound to accelerateNonce, preserving the retry-on-cap-full behavior of the bot's AccelerateOffer.

7.7 Provider data encoding

The signed offer, validator attestations, and credit data are packed into providerData which the user passes to SYMMIO.initiateWithdraw(parts, speedUp, providerData). The encoding is nested:

providerData = abi.encode(offerData, validatorData, creditDataRaw)

offerData = abi.encode(
    uint256 nonce,
    uint8   optionType,
    uint256 availableAt,
    address affiliate,
    uint256 affiliateAmount,
    uint256 creditAmount,   // how much from credit line (0 if not using credit)
    uint256 fee,
    uint256 operatorFee,
    uint256 maxUserFee,
    uint256 maxAccelerationFee,
    uint256 deadline,
    bytes   signature       // bot's EIP-712 signature
)

validatorData = abi.encode(
    bytes[]   signatures,   // validator EIP-712 signatures (ordered by ascending signer address)
    uint256[] timestamps    // corresponding signing timestamps
)

creditDataRaw = abi.encode(CreditData) // empty bytes if creditAmount == 0
// CreditData contains:
//   bytes   reqId,             // Muon request ID
//   uint256 eligibleBase,      // Muon-verified affiliate-level eligible balance
//   uint256 timestamp,         // Muon signature timestamp
//   bytes   gatewaySignature,  // Gateway signature from Muon
//   SchnorrSign sigs           // Schnorr signatures

8. Fee model

The system supports per-affiliate fee configuration. Fees are charged on the total express amount (sum of all parts routed through this ExpressProvider) and accumulated for later collection by a FEE_CLAIMER_ROLE holder.

8.1 Affiliate configuration

Each affiliate (frontend) has an AffiliateConfig:

struct AffiliateConfig {
    uint256 feeRate; // fee in basis points (1 bp = 0.01%, max 10000)
    uint256 operatorFee; // fixed operator fee in collateral decimals (covers bot gas)
}

The admin sets this via:

function setAffiliateConfig(address affiliate, uint256 feeRate, uint256 operatorFee) external onlyRole(SETTER_ROLE);
  • feeRate is in basis points (e.g., 50 = 0.50%).
  • operatorFee is a fixed fee per withdrawal in collateral decimals (e.g., 1e6 = 1 USDC). Set per-affiliate to allow different operator fees for different frontends.

8.2 Fee calculation and signing

The bot computes the fee off-chain before signing the EIP-712 option:

fee = expressAmount * feeRate / 10000

Where expressAmount is the total amount from all parts routed through this ExpressProvider (i.e., parts where expressProvider == address(this)). This includes amounts funded from general pool, affiliate pool, and credit line. The computed fee is included as a field in the signed WithdrawOption typed data, binding it to the signature.

8.3 On-chain validation

When onWithdrawRequest decodes and validates the signed option, the contract independently re-derives the fee from the on-chain affiliateConfigs and rejects any mismatch:

uint256 feeBasis = amounts.expressAmount;
if (offer.fee != feeBasis * affiliateConfigs[offer.affiliate].feeRate / 10000) revert FeeMismatch();
if (offer.operatorFee != affiliateConfigs[offer.affiliate].operatorFee) revert OperatorFeeMismatch();
if (offer.fee + offer.operatorFee > feeBasis) revert FeesExceedExpressAmount();

Because the contract recalculates the fee, a signed mismatch reverts. The bot must use the exact feeRate and operatorFee from the affiliate's on-chain config.

8.4 STANDARD acceleration fee

STANDARD has a second, optional fee guard for later acceleration. The original WithdrawOption includes maxAccelerationFee, which is stored on WithdrawInfo. If the request is never accelerated, this maximum is never charged.

When the bot later signs AccelerateOffer, it includes the actual accelerationFee. The contract accepts it only when:

if (offer.accelerationFee > info.maxAccelerationFee) revert AccelerationFeeExceedsMaximum();
if (info.fee + operatorFee + offer.accelerationFee > info.expressAmount) revert FeesExceedExpressAmount();

On success, info.accelerationFee = offer.accelerationFee before transfer. Payout and settlement use info.fee + info.accelerationFee + operatorFee, so the existing fee deduction and pending-fee settlement paths account for the acceleration premium. The user pays this premium together with the base and operator fees.

The bot should compute accelerationFee from the current acceleration economics, normally using remaining cooldown (info.cooldownEndTime - block.timestamp) so earlier acceleration can cost more than acceleration near the normal finalization time. The on-chain contract enforces the user's maximum authorization and total-fee bound; pricing policy stays in the bot.

8.5 Fee deduction

Fees are validated during onWithdrawRequest and deducted during payout:

  • During payout, userFee = fee + operatorFee + accelerationFee is deducted from the user's withdrawal amount.
  • Fees are deducted from the collateral transfers by cascading across parts: the userFee is subtracted from the first part(s) until exhausted. The user receives partAmount - deduction for affected parts.
  • Parts where expressProvider != address(this) are skipped (not subject to fees from this provider).
  • For STANDARD: the fee is deducted from the forwarded tokens during processWithdraw, same as for WINDOWED.
  • The affiliate fee is recorded as pending or collected according to the payout path described below.
  • Operator fee: Tracked separately from the affiliate fee. It follows the same pending-to-collected lifecycle and is combined with the affiliate fee for the user's total deduction.
  • maxUserFee guarantee: The contract validates fee + operatorFee <= maxUserFee at acceptance.

8.6 Fee accumulation and collection

Fee deduction and fee claimability are separate. The payout path determines where the retained amount is recorded:

Payout path At payout When it becomes claimable
Unaccelerated STANDARD Write directly to collectedFees and collectedOperatorFees Immediately after processWithdraw
SAME_TX, WINDOWED, or accelerated STANDARD Write per-request pendingFees and pendingOperatorFees On onWithdrawComplete; a processed suspension also promotes the pending amounts before recording losses

The relevant storage is:

mapping(address => uint256) public collectedFees;          // affiliate => accumulated fees
mapping(address => uint256) public collectedOperatorFees;  // affiliate => accumulated operator fees (per-affiliate)
mapping(address => mapping(uint256 => uint256)) public pendingFees;         // user, requestId => retained affiliate fee
mapping(address => mapping(uint256 => uint256)) public pendingOperatorFees; // user, requestId => retained operator fee

Only collected balances are claimable. Pending balances remain tied to their request until finalization or processed suspension. A FEE_CLAIMER_ROLE holder claims collected balances via:

function claimFees(address affiliate, address to) external onlyRole(FEE_CLAIMER_ROLE);
function claimOperatorFees(address affiliate, address to) external onlyRole(FEE_CLAIMER_ROLE);

event FeesClaimed(address indexed affiliate, address indexed recipient, uint256 amount);
event OperatorFeesClaimed(address indexed affiliate, address indexed recipient, uint256 amount);

claimFees transfers the full collectedFees[affiliate] balance to the specified to address and resets the mapping to zero. claimOperatorFees transfers the full collectedOperatorFees[affiliate] balance to the specified to address and resets it to zero. Both events include that actual recipient for direct payout reconciliation.

8.7 Fee flow

sequenceDiagram
	participant Admin
	participant Bot
	participant Express as ExpressProvider
	participant User

	Admin->>Express: setAffiliateConfig(affiliate, 50, 1e6)
	Note over Express: feeRate=50bp (0.5%), operatorFee=1 USDC

	User->>Bot: Request withdrawal (1000 USDC)
	Bot->>Bot: fee = 1000 * 50 / 10000 = 5 USDC
	Bot->>Bot: Sign EIP-712 option (fee=5, operatorFee=1)
	Bot->>User: Return signed option

	User->>Express: (via SYMMIO) onWithdrawRequest
	Express->>Express: Verify fee matches on-chain feeRate & operatorFee

	Bot->>Express: processWithdraw (WINDOWED example)
	Express->>User: Transfer 994 USDC (1000 - 5 - 1)
	Express->>Express: pendingFees[user][requestId] = 5
	Express->>Express: pendingOperatorFees[user][requestId] = 1

	SYMMIO->>Express: onWithdrawComplete after cooldown
	Express->>Express: Promote pending fees to collected balances

    Admin->>Express: claimFees(affiliate, payoutAddress)
    Express->>Admin: Transfer 5 USDC to payoutAddress

9. Credit line: technical reference

See Section 4.2 for the conceptual overview. This section covers implementation details.

9.1 Architecture

Credit line logic lives inside the ExpressProvider diamond:

  • ControlFacet: credit line admin setters (Muon config, protocol/affiliate caps, pause, blacklist) live here alongside the other admin setters
  • ViewFacet: credit line read functions (debt totals, per-request debt, configuration getters) live here, alongside the other read-only views
  • LibCreditLine: debt operations (reserveDebt, activate, settle, releaseReservation, coverLoss) called internally by SymmioHookFacet, AccelerateFacet, and OperatorFacet
  • CreditLineStorage: diamond storage with per-affiliate mappings (AffiliateCredit struct). The AffiliateCredit struct is defined in types/CreditTypes.sol and held in CreditLineStorage.

9.2 Muon verification

When reserveDebt is called, LibCreditLine validates the Muon oracle attestation:

  1. Freshness: reject future timestamps, then require block.timestamp <= data.timestamp + muonFreshnessWindow. Initialization leaves this value at zero; deployment must set the intended window with setCreditLineMuonConfig before credit is used.
  2. Signature: The hash keccak256(abi.encodePacked(muonAppId, reqId, affiliate, eligibleBase, timestamp, chainId, address(this), symmio)) is verified with the four-argument IMuonSignatureVerifier.verify overload and uint8(MuonFunction.ExpressCredit) (category ID 8). Both the registered TSS key and registered gateway must be explicitly authorized for this category. The hash uses the affiliate address to scope attestations per affiliate, and binds both address(this) (the express provider diamond) and symmio (the SYMMIO core address) so an attestation signed for one deployment cannot be replayed against another.
  3. Caps: New used capacity (reservedDebt + activeDebt + badDebt + creditAmount) must not exceed effectiveMaxDebt (absolute) or effectiveMaxBps (BPS of eligibleBase)

Upgrade order: keep Express credit paused; deploy the forward-compatible verifier whose category boundary is uint8 and whose supportsMuonFunction(8) call returns true; register and authorize the intended TSS keys and gateways for category 8; point Express at that verifier; replace SymmioHookFacet and AccelerateFacet; test approved and retired signers; then unpause. Existing enum values 07 are unchanged and no Express storage migration is required. Fresh deployment, patch/reuse resolution, post-state validation, and setCreditLineMuonConfig all reject a nonzero verifier that does not explicitly advertise ExpressCredit support. The capability result is separate from signer authorization: both the intended TSS key and gateway still require permission for ID 8.

9.3 Debt lifecycle (detailed)

Phase Trigger LibCreditLine Action
Reserve onWithdrawRequest reserveDebt: validates Muon data, checks caps, adds to reservedDebt
Activate processWithdraw / unlockAndProcess / SAME_TX inline activate: moves from reservedDebt to activeDebt, calls advanceWithdraw on SYMMIO
Settle onWithdrawComplete settle: removes from activeDebt, deletes request state
Cancel onWithdrawCancelRequest (pre-payout) releaseReservation: removes from reservedDebt
Cover Loss onWithdrawSuspend (post-payout) coverLoss: deducts up to the unlocked affiliate balance, records any deficit as badDebt, and settles the active debt

10. Access control & roles

10.1 ExpressProvider roles

Role Who Can do
Diamond Owner Deployer/multisig Diamond cuts, role grants/revocations, two-step ownership, token rescue, and stuck request-debt clearing
WITHDRAWER_ROLE Deployer/multisig Withdraw liquidity from general and affiliate pools (withdrawFromGeneral, withdrawFromAffiliate)
SETTER_ROLE Deployer/multisig Set affiliate fees, timing, validators, Muon config, protocol/affiliate caps, cap-change fee/quota, credit pause, and blacklist state
FEE_CLAIMER_ROLE Deployer/multisig Claim accumulated fees (claimFees, claimOperatorFees)
OPERATOR_ROLE Bot service Call processWithdraw at the earliest processable time; anyone may call after tolerancePeriod
LOCKER_ROLE Risk detection service lockWithdraw; deploy it to a separate risk key if processing and locking authority must be separated
UNLOCK_ROLE Deployer/multisig unlockAndProcess; deploy it to an independent review key or multisig
SIGNER_ROLE Bot signer key Signs withdrawal options (verified on-chain via EIP-712)
PAUSER_ROLE Emergency operator/multisig Set the ExpressProvider-wide user-mutation pause with setPaused

Roles are stored in diamond storage and managed via grantRole/revokeRole on ControlFacet (owner-only).

10.2 Credit line access control

Credit used to be a separate management surface, which meant operators had to reason about another contract and another role set. In this design, the credit line is part of the ExpressProvider diamond: the same withdrawal flow that reserves, activates, settles, releases, or covers credit debt updates CreditLineStorage through LibCreditLine.

  • Configuration: SETTER_ROLE controls Muon config, protocol caps, an admin affiliate-cap bypass, credit pause, blacklist, and cap-change fee/quota. Each affiliate address may change only its own affiliate caps through setMyCreditLineConfig, subject to protocol caps, pause, quota, and fees.
  • Debt lifecycle (reserveDebt, activate, settle, releaseReservation, coverLoss): Called internally from the withdrawal hooks and operator flows
  • Removed external credit roles: there is no standalone EXPRESS_PROVIDER_ROLE, PROTOCOL_ADMIN_ROLE, or AFFILIATE_ADMIN_ROLE to grant for credit debt accounting.

10.3 Validator registration

Validators are not a role: they are registered per-affiliate via setValidator(affiliate, validator, enabled) (SETTER_ROLE). Using address(0) as affiliate sets a default validator for all affiliates. isValidator accepts a signer registered in either the affiliate-specific slot or the address(0) slot. A zero affiliate-specific minimum/timeout value uses the default numeric setting.

10.4 Trust relationships

  • ExpressProvider is registered as an Express Provider on SYMMIO
  • Credit accounting runs inside the ExpressProvider diamond (via ControlFacet setters / LibCreditLine), so there is no standalone credit-manager contract; activation still calls SYMMIO core and reservation still calls the configured signature verifier
  • LibCreditLine verifies Muon oracle attestations to validate credit eligibility
  • A typical deployment grants the service an OPERATOR_ROLE hot key and a SIGNER_ROLE signing key
  • Validators are registered per-affiliate on ExpressProvider via setValidator(affiliate, validator, enabled); their EIP-712 signatures are verified during onWithdrawRequest. Validators registered for address(0) serve as defaults for all affiliates

11. The bot

11.1 Options API

When a user requests withdrawal options:

  1. Read expressProvider.nonces(user) for current nonce
  2. Check anomaly detection API for user risk
  3. Calculate available liquidity across pools
  4. Generate up to 3 options:
Check Option Generated
Sufficient fast liquidity + validators enabled for affiliate SAME_TX (optionType=0)
Sufficient fast liquidity + low risk WINDOWED (optionType=1)
Always STANDARD (optionType=2)

All three options use the same EIP-712 signature and go through ExpressProvider.

  1. Collect validator attestations when the selected option requires them: SAME_TX requires an effective minimum greater than 0; WINDOWED validates a quorum only when its effective minimum is greater than 0; STANDARD skips validation at acceptance. Query validators registered for this affiliate (or the address(0) default) with (user, nonce, totalAmount). Each approval covers the full withdrawal totalAmount and must be signed strictly after core's withdrawCooldownOf(user) timestamp.
  2. Read affiliateConfigs(affiliate) to get feeRate and operatorFee
  3. Compute fee = expressAmount * feeRate / 10000
  4. For STANDARD candidates that may be upgraded later, compute and sign maxAccelerationFee as the user's upper bound for a future acceleration premium.
  5. If using credit line: obtain Muon attestation (CreditData) for the affiliate's aggregate eligible balance. Credit is not supported for STANDARD.
  6. Construct WithdrawReceiverPart[] with the correct expressProvider (set virtualProvider to address(0))
  7. Sign EIP-712 typed data (including creditAmount, fee, operatorFee, maxUserFee, and maxAccelerationFee fields)
  8. Return to user: { parts, providerData (includes option + validator signatures + credit data), estimatedTime, fee, operatorFee, maxUserFee, maxAccelerationFee }

11.2 Event monitoring

The bot must monitor these events on the ExpressProvider:

Event Action
WithdrawAccepted(user, requestId, optionType) For SAME_TX: no action here because WithdrawProcessed follows. For STANDARD signed due to cap-full: enqueue for accelerate polling (see §11.8). For other STANDARD requests, schedule core finalization at cooldownEndTime and the Express payout after finalization. For WINDOWED, schedule processWithdraw.
WithdrawProcessed(user, requestId) Schedule finalizeWithdrawRequest on SYMMIO at cooldownEndTime
WithdrawUnlockedAndProcessed(user, requestId) Schedule finalizeWithdrawRequest on SYMMIO at cooldownEndTime and clear the lock alert
WithdrawAccelerated(user, requestId, affiliate, affiliateAmount, creditAmount, generalAmount) Remove from accelerate retry queue. A WithdrawProcessed follows in the same tx: treat identically to native WINDOWED processing
WithdrawLocked(user, requestId) Cancel scheduled processing, notify admin
WithdrawCancelled(user, requestId) Cancel all scheduled actions for this withdrawal (including accelerate retries)
WithdrawSuspended(user, requestId) Cancel all scheduled actions for this withdrawal (including accelerate retries)
WithdrawFinalized(user, requestId) For SAME_TX/WINDOWED, confirm terminal completion. For STANDARD, schedule or detect the remaining processWithdraw payout; Express status is FINALIZED, not terminal, until then.

11.3 Scheduled actions

gantt
    title Example Bot Schedule for WINDOWED with a 12-hour Core cooldown
    dateFormat X
    axisFormat %s

    section Withdrawal
    Accept (T+0)           :milestone, 0, 0
    Risk check + processWithdraw :active, 20, 25
    Finalize on SYMMIO     :43200, 43205

    section Fallback
    User can process permissionlessly :crit, 80, 85
Option finalizeWithdrawRequest processWithdraw
SAME_TX cooldownEndTime (at cooldown end) N/A (transferred in onWithdrawRequest)
WINDOWED cooldownEndTime (at cooldown end) acceptedAt + securityWindow (20s)
STANDARD cooldownEndTime (at cooldown end) Operator: right after finalization. Anyone: after tolerancePeriod.

11.4 Permissionless fallback

If the bot fails to call processWithdraw, any address can call it after processableAt + tolerancePeriod (default 60s extra). The bot must detect user-initiated processing (via WithdrawProcessed events) and cancel its own scheduled call.

11.5 Event idempotency

The bot MUST handle duplicate or replayed event IDs idempotently. If the bot processes the same WithdrawAccepted event twice (e.g., due to a chain reorg or indexer replay), it must not schedule duplicate processWithdraw calls or corrupt internal state.

11.6 Performance targets

Metric Target
Same-tx withdrawal end-to-end latency Same transaction as initiateWithdraw (user pays gas)
Windowed withdrawal end-to-end latency < 30 seconds (20s security window + processing)
Options API response time < 2 seconds
Finalization scheduling accuracy Within 1 block of cooldownEndTime

11.7 State synchronization

When a user calls processWithdraw permissionlessly (after the tolerance period), the bot must detect the resulting WithdrawProcessed event and cancel its own scheduled processing for that withdrawal. Failure to do so results in a reverted transaction (harmless but wasteful).

11.8 Cap-aware accelerate polling

When the bot decides to sign a STANDARD offer because the affiliate credit cap is currently full (rather than because the user explicitly asked for STANDARD), it should flag the request internally as "would have been WINDOWED" and enter it into an accelerate retry queue. Every ~30 minutes the bot re-evaluates these queued requests and promotes any that now fit under the cap.

Decision logic for signing offers:

if user requested STANDARD:
    sign STANDARD option (creditAmount = 0)
else if reservedDebt + activeDebt + badDebt + desiredCreditAmount > effectiveMaxDebt:
    sign STANDARD option (creditAmount = 0) + mark "accelerate candidate"
else:
    sign WINDOWED option with the desired creditAmount

Where effectiveMaxDebt is the tighter nonzero protocol/affiliate value (both zero means uncapped). The BPS cap must also be checked against Muon's latest eligibleBase attestation.

Retry loop (every 30 minutes):

For each accelerate candidate in the queue, check:

  1. getWithdrawInfo(user, requestId).status == ACCEPTED and optionType == STANDARD: else drop from queue because it is no longer acceleratable; an unaccelerated FINALIZED STANDARD may still need Express payout processing.
  2. block.timestamp + safetyMargin < cooldownEndTime (e.g., 10 minutes): else drop from the acceleration queue and let core finalize normally. Adding the margin avoids unsigned subtraction when the cooldown is already close or elapsed.
  3. creditLineTotalDebt(affiliate) + creditLineBadDebt(affiliate) + desiredCreditAmount <= effectiveMaxDebt; if still full, leave in queue.
  4. If capacity exists, price the acceleration from current timing (for example, remaining cooldown), sign a fresh AccelerateOffer with the current accelerateNonce(user, requestId) and accelerationFee, request a current Muon eligibleBase attestation, and submit accelerateWithdraw.

Affiliate manual trigger: When the affiliate address raises affiliateMaxDebt via ControlFacet.setMyCreditLineConfig, its backend can either:

  • (a) Notify the bot via an internal webhook and wait for the next 30-minute poll to pick it up, or
  • (b) Fetch a fresh AccelerateOffer from the bot's API for a specific request and submit accelerateWithdraw directly from the frontend's backend. This is identical to the bot path: the signature is bot-issued, only the on-chain submitter differs.

Nonce management: The per-request accelerateNonce is the only nonce the bot must manage for acceleration. Read it with ViewFacet.accelerateNonce(user, requestId). On InvalidAccelerateNonce revert, re-fetch from chain and re-sign.

Stop conditions for retry loop:

  • WithdrawAccelerated / WithdrawProcessed emitted (success).
  • WithdrawFinalized / WithdrawCancelled / WithdrawSuspended emitted (stop acceleration; an unaccelerated STANDARD WithdrawFinalized still needs its Express payout).
  • block.timestamp + safetyMargin >= cooldownEndTime (bot policy: stop before the race boundary). The on-chain AccelerateCooldownElapsed revert begins only at block.timestamp >= cooldownEndTime.

12. Contract interfaces

12.1 ExpressProvider

Deployment topology

flowchart LR
    subgraph "Per Chain"
        SYMMIO[SYMMIO Core Diamond]
        EC[ExpressProvider<br/>EIP-2535 Diamond<br/>1 per chain]
    end
    subgraph "Global"
        Bot[Bot Service]
        Muon[Muon Oracle]
    end

    EC --> SYMMIO
    Bot --> EC
    Bot --> SYMMIO
    Muon --> EC
Component Count Upgradeable Description
ExpressProvider 1 per chain Yes (EIP-2535 Diamond) Main coordinator. Manages liquidity pools, validates bot signatures, locks/transfers funds, and handles credit lines. Split into ControlFacet, SymmioHookFacet, OperatorFacet, AccelerateFacet, and ViewFacet. Credit line state is stored in CreditLineStorage (diamond storage) with per-affiliate mappings.
Bot Service 1 global N/A Off-chain. Provides options API, signs options, monitors events, calls processWithdraw and finalizeWithdrawRequest.

SYMMIO callbacks (called by SYMMIO, not by bot)

// Called when user initiates a withdrawal with express parts
function onWithdrawRequest(WithdrawRequest memory request, address collateral) external;

// Called when SYMMIO finalizes the withdrawal (at cooldown end, sends tokens)
function onWithdrawComplete(WithdrawRequest memory request) external;

// Called when user requests cancellation
function onWithdrawCancelRequest(WithdrawRequest memory request) external;

// Called when core suspends the withdrawal; uses rollback accounting if Express already paid
function onWithdrawSuspend(WithdrawRequest memory request) external;

Bot/operator functions

// Transfer funds to user. WINDOWED uses securityWindow; STANDARD uses finalization;
// a LOCKED request uses cooldownEndTime. Non-operators add tolerancePeriod.
function processWithdraw(address user, uint256 requestId, WithdrawReceiverPart[] calldata parts) external;

// Lock a withdrawal due to risk detection. LOCKER_ROLE only.
function lockWithdraw(address user, uint256 requestId) external;

// Unlock and process after false alarm. UNLOCK_ROLE only (separate from operator).
// A STANDARD request must already have finalizedAt != 0; WINDOWED has no timing check here.
function unlockAndProcess(address user, uint256 requestId, WithdrawReceiverPart[] calldata parts) external;

// Promote an ACCEPTED STANDARD request and pay it through the express path. Permissionless with valid signed data.
function accelerateWithdraw(
    address user,
    uint256 requestId,
    WithdrawReceiverPart[] calldata parts,
    bytes calldata accelerateOfferData,
    bytes calldata validatorData,
    bytes calldata creditDataRaw
) external;

Control, funding, and recovery functions

function depositToGeneral(uint256 amount) external;                        // Anyone can fund
function withdrawFromGeneral(uint256 amount) external;                     // WITHDRAWER_ROLE
function depositToAffiliate(address affiliate, uint256 amount) external;     // Anyone can fund
function withdrawFromAffiliate(address affiliate, uint256 amount) external;  // WITHDRAWER_ROLE
function claimFees(address affiliate, address to) external;                 // FEE_CLAIMER_ROLE
function claimOperatorFees(address affiliate, address to) external;        // FEE_CLAIMER_ROLE

// Setter functions (SETTER_ROLE)
function setSecurityWindow(uint256 seconds) external;                      // Setter only
function setTolerancePeriod(uint256 seconds) external;                     // Setter only
function setAffiliateConfig(
    address affiliate,
    uint256 feeRate,
    uint256 operatorFee
) external;                                                                // Setter only
function setValidator(address affiliate, address validator, bool enabled) external; // Setter only
function setMinValidatorSignatures(address affiliate, uint256 count) external;     // Setter only
function setValidatorApprovalTimeout(address affiliate, uint256 seconds) external; // Setter only

// Credit line setter functions (SETTER_ROLE, on ControlFacet)
function setCreditLineMuonConfig(
    address signatureVerifier,
    uint256 muonAppId,
    uint256 muonFreshnessWindow
) external;                                                                // Setter only
function setCreditLineProtocolConfig(
    address affiliate,
    uint256 maxDebt,
    uint256 maxDebtBps
) external;                                                                // Setter only
function setCreditLineAffiliateConfig(
    address affiliate,
    uint256 maxDebt,
    uint256 maxDebtBps
) external;                                                                // Setter only
function setCapChangeFeeConfig(address feeToken, uint256 feeAmount, address feeReceiver) external; // Setter only
function setCapChangeQuotaConfig(uint256 maxFreePerWindow, uint256 windowDuration) external; // Setter only
function setCreditLinePaused(address affiliate, bool paused) external;     // Setter only
function setCreditLineBlacklisted(address affiliate, address user, bool blacklisted) external; // Setter only

// Affiliate self-service cap change (msg.sender is the affiliate; increases may consume quota or pay a fee)
function setMyCreditLineConfig(uint256 maxDebt, uint256 maxDebtBps) external;

// Canonical diamond role management (same interface as Core and AccountLayer)
function setAdmin(address user) external;                       // Diamond owner only
function grantRole(address user, bytes32 role) external;        // Role admin
function revokeRole(address user, bytes32 role) external;       // Role admin
function addRoleAdmin(bytes32 role, address admin) external;    // DEFAULT_ADMIN_ROLE
function removeRoleAdmin(bytes32 role, address admin) external; // DEFAULT_ADMIN_ROLE
function hasRole(address user, bytes32 role) external view returns (bool);
function isRoleAdmin(address user, bytes32 role) external view returns (bool);

// Compatibility selectors retained for existing ExpressProvider integrations
function grantRole(bytes32 role, address account) external;  // Diamond owner only
function revokeRole(bytes32 role, address account) external; // Diamond owner only
function hasRole(bytes32 role, address account) external view returns (bool);

// Two-step diamond ownership
function owner() external view returns (address);
function pendingOwner() external view returns (address);
function transferOwnership(address newOwner) external;
function acceptOwnership() external;
function cancelOwnershipTransfer() external;

// Pause and recovery
function setPaused(bool value) external; // PAUSER_ROLE
function rescueTokens(address token, address to, uint256 amount) external; // Owner only
function clearRequestDebt(address affiliate, address user, uint256 requestId) external; // Owner only
function repayCreditBadDebt(address affiliate, uint256 amount) external; // Permissionless payer

View functions

function nonces(address user) external view returns (uint256);
function getWithdrawInfo(address user, uint256 requestId) external view returns (WithdrawInfo memory);
function accelerateNonce(address user, uint256 requestId) external view returns (uint256);
function symmio() external view returns (address);
function collateral() external view returns (address);
function generalBalance() external view returns (uint256);
function lockedGeneralBalance() external view returns (uint256);
function generalBadDebt() external view returns (uint256);
function affiliateBalances(address affiliate) external view returns (uint256);
function lockedAffiliateBalances(address affiliate) external view returns (uint256);
function paused() external view returns (bool);
// Credit line queries (on ViewFacet)
function creditLineSignatureVerifier() external view returns (address);
function creditLineMuonAppId() external view returns (uint256);
function creditLineMuonFreshnessWindow() external view returns (uint256);
function creditLineProtocolMaxDebt(address affiliate) external view returns (uint256);
function creditLineProtocolMaxDebtBps(address affiliate) external view returns (uint256);
function creditLineAffiliateMaxDebt(address affiliate) external view returns (uint256);
function creditLineAffiliateMaxDebtBps(address affiliate) external view returns (uint256);
function creditLineTotalDebt(address affiliate) external view returns (uint256);
function creditLineReservedDebt(address affiliate) external view returns (uint256);
function creditLineActiveDebt(address affiliate) external view returns (uint256);
function creditLineRequestDebt(address affiliate, address user, uint256 requestId) external view returns (uint256);
function creditLineRequestActivated(address affiliate, address user, uint256 requestId) external view returns (bool);
function creditLinePaused(address affiliate) external view returns (bool);
function creditLineBlacklisted(address affiliate, address user) external view returns (bool);
function creditLineBadDebt(address affiliate) external view returns (uint256);

// creditLineTotalDebt is reservedDebt + activeDebt only. Add creditLineBadDebt
// when measuring the capacity consumed by the on-chain cap checks.

// Fee queries
function affiliateConfigs(address affiliate) external view returns (uint256 feeRate, uint256 operatorFee);
function collectedFees(address affiliate) external view returns (uint256);
function collectedOperatorFees(address affiliate) external view returns (uint256);
function pendingFees(address user, uint256 requestId) external view returns (uint256);
function pendingOperatorFees(address user, uint256 requestId) external view returns (uint256);

// Security getters
function securityWindow() external view returns (uint256);
function tolerancePeriod() external view returns (uint256);

// Validator queries
function minValidatorSignatures(address affiliate) external view returns (uint256);
function validatorApprovalTimeout(address affiliate) external view returns (uint256);
function isValidator(address affiliate, address validator) external view returns (bool);

// Access control (the role-first overload is retained for compatibility)
function hasRole(address user, bytes32 role) external view returns (bool);
function isRoleAdmin(address user, bytes32 role) external view returns (bool);

// Cap-change fee and quota
function capChangeFeeConfig() external view returns (address feeToken, uint256 feeAmount, address feeReceiver);
function capChangeQuotaConfig() external view returns (uint256 maxFreePerWindow, uint256 windowDuration);
function capChangeAffiliateState(
    address affiliate
) external view returns (uint256 count, uint256 epochStart, uint256 remainingFree, uint256 nextResetAt);

Credit line configuration setters live on ControlFacet (shown in section 12.1 admin functions). Debt lifecycle functions (reserveDebt, activate, settle, releaseReservation, coverLoss) are internal to LibCreditLine and called by other facets within the diamond: they are not externally callable. Credit line read functions live on ViewFacet (see Section 12.1 view functions).

13. Data types reference

// ExpressProvider enums
enum Status { NONE, ACCEPTED, LOCKED, PROCESSED, FINALIZED, CANCELLED, SUSPENDED }
enum OptionType { SAME_TX, WINDOWED, STANDARD }

// ExpressProvider structs
struct AffiliateConfig {
    uint256 feeRate;              // basis points (0-10000)
    uint256 operatorFee;          // fixed operator fee in collateral decimals (per-affiliate)
}

struct WithdrawInfo {
    Status  status;
    OptionType optionType;
    uint256 availableAt;          // Signed and stored; current processing logic does not read it.
    uint256 expressAmount;        // total amount from express provider (general + affiliate + credit)
    uint256 generalAmount;        // how much of expressAmount from general pool
    uint256 affiliateAmount;      // how much of expressAmount from affiliate pool
    uint256 creditAmount;         // how much of expressAmount from credit line (0 for STANDARD)
    address affiliate;            // which affiliate pool was used
    uint256 acceptedAt;           // block.timestamp when accepted
    uint256 finalizedAt;          // block.timestamp when onWithdrawComplete called (STANDARD only)
	uint256 cooldownEndTime;      // when the cooldown configured by SYMMIO expires
	bytes32 partsHash;            // keccak256(abi.encode(parts)) for integrity check
	uint256 fee;                  // base affiliate fee in collateral decimals
	uint256 maxAccelerationFee;   // max extra fee user authorized for STANDARD acceleration
    uint256 accelerationFee;      // extra fee charged only after successful STANDARD acceleration
}

struct WithdrawOffer {
    uint256 nonce;
    uint8   optionType;           // 0=SAME_TX, 1=WINDOWED, 2=STANDARD
    uint256 availableAt;          // Signed and stored; current processing logic does not read it.
    address affiliate;
    uint256 affiliateAmount;
    uint256 creditAmount;         // how much from credit line (must be 0 for STANDARD)
    uint256 fee;                  // affiliate fee in collateral decimals
    uint256 operatorFee;          // fixed operator fee in collateral decimals
    uint256 maxUserFee;           // max fee user pays (reverts if exceeded)
    uint256 maxAccelerationFee;   // max extra fee user authorizes if STANDARD is accelerated
    uint256 deadline;
    bytes   signature;
}

struct AccelerateOffer {
    uint256 nonce;
    uint256 affiliateAmount;
    uint256 creditAmount;
    uint256 accelerationFee;
    uint256 deadline;
    bytes   signature;
}

struct ComputedAmounts {
    uint256 expressAmount;        // total from express (general + affiliate + credit)
    uint256 generalAmount;        // expressAmount - affiliateAmount - creditAmount
}

// ExpressProvider roles
OPERATOR_ROLE        = keccak256("OPERATOR_ROLE")
LOCKER_ROLE          = keccak256("LOCKER_ROLE")
SIGNER_ROLE          = keccak256("SIGNER_ROLE")
SETTER_ROLE          = keccak256("SETTER_ROLE")
FEE_CLAIMER_ROLE     = keccak256("FEE_CLAIMER_ROLE")
UNLOCK_ROLE          = keccak256("UNLOCK_ROLE")
WITHDRAWER_ROLE      = keccak256("WITHDRAWER_ROLE")
PAUSER_ROLE          = keccak256("PAUSER_ROLE")

// Validators (per-affiliate, not a role)
// Tracked in: mapping(address => mapping(address => bool)) validators
// Registered via: setValidator(affiliate, validator, enabled)
// address(0) as affiliate = default validator for all affiliates
// A validator registered in either the affiliate-specific or address(0) slot is accepted

// Configurable parameters (defaults)
securityWindow    = 20 seconds   // delay before operator can process WINDOWED
tolerancePeriod   = 60 seconds   // extra delay for permissionless processing
operatorFee           = per-affiliate // fixed fee per withdrawal (collateral decimals), covers bot gas; set via setAffiliateConfig
minValidatorSignatures = mapping(address => uint256) // stored per-affiliate; runtime falls back to address(0) default when affiliate value is 0
validatorApprovalTimeout = mapping(address => uint256) // stored per-affiliate; runtime falls back to address(0) default when affiliate value is 0
validatorApprovalTimeout[address(0)] = 30 seconds       // initializer default
// SYMMIO types (must match perps-core ABI)
struct WithdrawReceiverPart {
    uint256 id;
    uint256 amount; // collateral decimals
    int256 chainId;
    bytes receiver; // 20 bytes
    address virtualProvider; // must be address(0) on a part routed through this ExpressProvider
    address expressProvider;
}

// Credit line types
struct CreditData {
    bytes reqId; // Muon request ID
    uint256 eligibleBase; // Muon-verified affiliate-level eligible balance
    uint256 timestamp; // Muon signature timestamp
    bytes gatewaySignature; // Gateway signature from Muon
    SchnorrSign sigs; // Schnorr signatures
}

struct WithdrawRequest {
    uint256 id;
    address user;
    WithdrawReceiverPart[] parts;
    uint256 timestamp;
    uint256 cooldownEndTime;
    WithdrawStatus status;
    bool speedUp;
    bool isCooldownModified;
    address provider;
    bool isPureVirtual;
    bytes providerData;
    uint256 totalAmount;
    uint256 totalVirtualAmount;
    uint256 advancedAmount; // amount already released early from SYMMIO via advanceWithdraw
}

14. Deployment

Prerequisites

  • SYMMIO core deployed with withdraw system enabled
  • Collateral token (e.g. USDC) address known
  • Core deployment report and PROVIDER_ADMIN_ROLE authority available for registration
  • Muon signature verifier address and app ID known (if using credit lines)

Deploy or patch via the repo operator

Run ./symmio and choose the guided deployment or patch action. The operator writes and reviews a deployment.symm.io/v1 recipe, then owns execution, checkpoints, verification, health checks, reports, and Safe actions. tasks/deploy/expressWithdrawLayerDiamond.ts is an internal adapter, not the operator entrypoint.

For a new provider, use expressProvider.mode: "deploy" and declare registerOnCore, timing, roles, credit-line config, and affiliates in the recipe. For reconciliation, use mode: "reuse" with the deployed address and only the sections to patch. Declared sections are authoritative, including role grants and revocations against the last applied component report, while omitted sections are untouched. Unauthorized mutations become Safe-ready manual actions. Removed affiliates are warning-only because setting both caps to zero would make them uncapped, not disabled.

The reviewed schema and starter live at deployment-tooling/deployment-recipe.schema.json and deployment-tooling/examples/arbitrum.v1.example.json. After deployment, any address may fund the pools:

# Fund pools after reviewing the deployed provider and collateral addresses
usdc.approve(expressProvider, amount)
expressProvider.depositToGeneral(amount)
expressProvider.depositToAffiliate(affiliateAddress, amount)

15. Known risks

15.1 Cancellation rules

Option Cancellable Condition Rationale
SAME_TX No Never Funds already transferred in same tx
WINDOWED Yes If status is ACCEPTED (not yet processed) Funds locked but not transferred; unlocking is safe
STANDARD Yes If status is ACCEPTED (before SYMMIO finalization) No capital fronted; Express just releases acceptance

15.2 Post-payout rollback

If onWithdrawSuspend fires after Express has already paid the user (status was PROCESSED), the provider records the unreplenished generalAmount in generalBadDebt, promotes pending fees, and invokes LibCreditLine.coverLoss for any advanced credit. coverLoss deducts up to the unlocked affiliate pool balance, records an uncovered deficit as affiliate badDebt, and settles the active debt. Suspension after block.timestamp >= cooldownEndTime is unusual but possible if SYMMIO operators delay finalization, so the rollback path must remain available.

generalBadDebt is a cumulative diagnostic counter: the current contract increments it for the unreplenished general-pool portion and exposes no function that decrements or resets it. repayCreditBadDebt applies only to an affiliate's credit-line badDebt and credits that affiliate pool.

Mixed-request boundary: Core now caps advanceWithdraw against only express, non-virtual parts, and ExpressProvider computes the same amount from parts whose expressProvider == address(this) and virtualProvider == address(0); classic parts cannot inflate the advance. The bot should still set virtualProvider to zero on every part assigned to this provider. Virtual-only parts mixed under an Express-master request are outside this provider's transfer loop and should not be offered.

15.3 Liquidity fragmentation

Affiliate pools are isolated. An affiliate user cannot access another affiliate's pool. The General Pool is the cross-affiliate fallback. Affiliate-specific pools reserve each affiliate's capital for its own users and leave that affiliate with the corresponding capital risk.

15.4 Bot failure

If the bot goes down, any caller can call processWithdraw after the route's processable timestamp plus tolerancePeriod. STANDARD finalization and payout are permissionless too. Provided the ExpressProvider is not globally paused, the system can therefore continue without the bot, with the added delay.