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)
-
Reserve (on acceptance): Validates the Muon attestation, checks exposure caps, records the request amount
as
reservedDebt -
Activate (on processing): Moves debt from "reserved" to "active", calls
advanceWithdrawto pull tokens from SYMMIO -
Settle (on finalization): Removes the amount from
activeDebtand deletes request accounting; core has completed the withdrawal and retains the already-advanced amount as part of that payout - Cancel (if withdrawal is cancelled pre-processing): Releases the reservation, no tokens were moved
Caps and controls:
-
Protocol caps:
protocolMaxDebt(absolute cap) andprotocolMaxDebtBps(percentage of eligible base). Set by admin. Cannot be loosened by affiliates. -
Affiliate caps:
affiliateMaxDebtandaffiliateMaxDebtBps. 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:
- Affiliate Pool (lowest system risk: affiliate's own capital)
- Credit Line (backed by Muon-attested eligible balances, not supported for STANDARD)
- 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 (
creditAmountmust be 0) onWithdrawCompletesets status to FINALIZED (tokens arrive from SYMMIO)processWithdrawrequires FINALIZED status (not ACCEPTED)-
processWithdrawforwards 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,
suspendis no longer valid. A LOCKED STANDARD can be resolved byunlockAndProcessonly afteronWithdrawCompletehas setfinalizedAt, or byprocessWithdrawatcooldownEndTimefor 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:
processWithdrawby operator: allowed afteracceptedAt + securityWindow(default 20s)-
processWithdrawby anyone (permissionless fallback): allowed afteracceptedAt + 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:
ValidatorsRequiredForSameTxerror ifminValidatorSignatures(affiliate) == 0 - Status goes directly NONE -> PROCESSED (skips ACCEPTED)
processWithdrawcannot 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)
nonReentrantguard ononWithdrawRequestprevents reentrancy during transfers- User pays gas for the transfer (included in their
initiateWithdrawtx)
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):
info.status == ACCEPTEDinfo.optionType == STANDARDinfo.finalizedAt == 0(core hasn't finalized yet)-
block.timestamp < info.cooldownEndTime(after that, the normal STANDARD finalization path takes over, avoiding a race with core) block.timestamp <= offer.deadlineaccelerateNonces[user][requestId] == offer.noncekeccak256(abi.encode(parts)) == info.partsHash- Offer signature recovers to a
SIGNER_ROLEholder -
If
minValidatorSignatures(affiliate) > 0: a quorum ofValidatorAccelerateApprovalsignatures over(user, requestId, partsHash), each withinvalidatorApprovalTimeoutand each signed strictly afterwithdrawCooldownOf(user)(see 7.6) offer.affiliateAmount + offer.creditAmount <= info.expressAmountoffer.accelerationFee <= info.maxAccelerationFeeinfo.fee + operatorFee + offer.accelerationFee <= info.expressAmount- Pool balances cover the requested split
- Credit cap accommodates
offer.creditAmount(handled byLibCreditLine.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
accelerateNoncescounter 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 byWithdrawProcessed(user, requestId) -
No longer acceleratable:
WithdrawFinalized,WithdrawCancelled, orWithdrawSuspended. For unaccelerated STANDARD,WithdrawFinalizedstill precedes the ExpressprocessWithdrawpayout.
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
0as "uncapped = infinity" when comparing). Tightening risk is unconditional and delay-free. -
Increases use a per-window quota. Each affiliate gets
maxFreePerWindowfree increases perwindowDuration-second epoch. The epoch resets the first time a call lands afterblock.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
feeAmountof the fee token from the affiliate to the configured receiver viasafeTransferFrom. -
No-ops revert. Calling with unchanged values reverts
NoOpCapChangeso 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, withfeePaid == 0for 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 configCapChangeQuotaConfigUpdated(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:
- Query anomaly detection API during the
securityWindow(~20s) - If LOW RISK: call
processWithdraw -
If HIGH RISK:
LOCKER_ROLEholder callslockWithdrawto prevent permissionless processing, then notifies admin -
An authorized reviewer resolves the alert:
- An
UNLOCK_ROLEholder callsunlockAndProcess(false alarm) -
A core
SUSPENDER_ROLEholder calls SYMMIO'ssuspendWithdrawRequest(confirmed bad actor)
- An
STANDARD: risk check during the configured cooldown:
-
A
LOCKER_ROLEholder can flag anACCEPTEDrequest withlockWithdraw; the contract does not impose a separate timestamp bound, although the intended review occurs during the configured cooldown -
When SYMMIO finalizes,
onWithdrawCompletepreserves the LOCKED status: tokens arrive but can't be forwarded - No risk check is required after finalization because the configured cooldown has already supplied the security window
-
Before finalization, admin may still suspend via SYMMIO. After cooldown,
processWithdrawcan resolve the lock (operator immediately, any caller aftertolerancePeriod);unlockAndProcessremains theUNLOCK_ROLEearly-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:
- Unlocks pool counter locks for pre-payout ACCEPTED/LOCKED withdrawals
- Releases credit line reservation (if
creditAmount > 0) - 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:
| 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. -
partsHashis 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 maing.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.
-
accelerationFeeis bound into the signature and must be no greater than the original STANDARD offer's storedmaxAccelerationFee. It is charged only when acceleration succeeds. -
operatorFee,expressAmount, andcooldownEndTimeare 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):
-
Check
signatures.length >= minValidatorSignatures(affiliate)(falls back tominValidatorSignatures(address(0))if affiliate-specific not set) -
For each signature:
- Reject future timestamps (
timestamp > block.timestamp) -
Verify
block.timestamp - timestamp <= validatorApprovalTimeout(affiliate)(falls back tovalidatorApprovalTimeout(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 revertStaleValidatorApproval - Recover signer from EIP-712 digest
-
Verify signer via
isValidator(affiliate, signer), which accepts either affiliate-specific registration or registration under theaddress(0)default - Verify signer address is strictly greater than the previous (ascending order = no duplicates)
- Reject future timestamps (
-
If any check fails, the callback and surrounding
initiateWithdrawtransaction 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);
feeRateis in basis points (e.g., 50 = 0.50%).-
operatorFeeis 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 + accelerationFeeis deducted from the user's withdrawal amount. -
Fees are deducted from the collateral transfers by cascading across parts: the
userFeeis subtracted from the first part(s) until exhausted. The user receivespartAmount - deductionfor 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 <= maxUserFeeat 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 bySymmioHookFacet,AccelerateFacet, andOperatorFacet -
CreditLineStorage: diamond storage with per-affiliate mappings (AffiliateCreditstruct). TheAffiliateCreditstruct is defined intypes/CreditTypes.soland held inCreditLineStorage.
9.2 Muon verification
When reserveDebt is called, LibCreditLine validates the Muon oracle attestation:
-
Freshness: reject future timestamps, then require
block.timestamp <= data.timestamp + muonFreshnessWindow. Initialization leaves this value at zero; deployment must set the intended window withsetCreditLineMuonConfigbefore credit is used. -
Signature: The hash
keccak256(abi.encodePacked(muonAppId, reqId, affiliate, eligibleBase, timestamp, chainId, address(this), symmio))is verified with the four-argumentIMuonSignatureVerifier.verifyoverload anduint8(MuonFunction.ExpressCredit)(category ID8). 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 bothaddress(this)(the express provider diamond) andsymmio(the SYMMIO core address) so an attestation signed for one deployment cannot be replayed against another. -
Caps: New used capacity (
reservedDebt + activeDebt + badDebt + creditAmount) must not exceedeffectiveMaxDebt(absolute) oreffectiveMaxBps(BPS ofeligibleBase)
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 0–7 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_ROLEcontrols 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 throughsetMyCreditLineConfig, 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, orAFFILIATE_ADMIN_ROLEto 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
ControlFacetsetters /LibCreditLine), so there is no standalone credit-manager contract; activation still calls SYMMIO core and reservation still calls the configured signature verifier LibCreditLineverifies Muon oracle attestations to validate credit eligibility-
A typical deployment grants the service an
OPERATOR_ROLEhot key and aSIGNER_ROLEsigning key -
Validators are registered per-affiliate on ExpressProvider via
setValidator(affiliate, validator, enabled); their EIP-712 signatures are verified during onWithdrawRequest. Validators registered foraddress(0)serve as defaults for all affiliates
11. The bot
11.1 Options API
When a user requests withdrawal options:
- Read
expressProvider.nonces(user)for current nonce - Check anomaly detection API for user risk
- Calculate available liquidity across pools
- 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.
-
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 withdrawaltotalAmountand must be signed strictly after core'swithdrawCooldownOf(user)timestamp. - Read
affiliateConfigs(affiliate)to getfeeRateandoperatorFee - Compute
fee = expressAmount * feeRate / 10000 -
For STANDARD candidates that may be upgraded later, compute and sign
maxAccelerationFeeas the user's upper bound for a future acceleration premium. -
If using credit line: obtain Muon attestation (
CreditData) for the affiliate's aggregate eligible balance. Credit is not supported for STANDARD. -
Construct
WithdrawReceiverPart[]with the correctexpressProvider(setvirtualProvidertoaddress(0)) -
Sign EIP-712 typed data (including
creditAmount,fee,operatorFee,maxUserFee, andmaxAccelerationFeefields) -
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:
-
getWithdrawInfo(user, requestId).status == ACCEPTEDandoptionType == STANDARD: else drop from queue because it is no longer acceleratable; an unaccelerated FINALIZED STANDARD may still need Express payout processing. -
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. -
creditLineTotalDebt(affiliate) + creditLineBadDebt(affiliate) + desiredCreditAmount <= effectiveMaxDebt; if still full, leave in queue. -
If capacity exists, price the acceleration from current timing (for example, remaining cooldown), sign a fresh
AccelerateOfferwith the currentaccelerateNonce(user, requestId)andaccelerationFee, request a current MuoneligibleBaseattestation, and submitaccelerateWithdraw.
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
AccelerateOfferfrom the bot's API for a specific request and submitaccelerateWithdrawdirectly 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/WithdrawProcessedemitted (success).-
WithdrawFinalized/WithdrawCancelled/WithdrawSuspendedemitted (stop acceleration; an unaccelerated STANDARDWithdrawFinalizedstill needs its Express payout). -
block.timestamp + safetyMargin >= cooldownEndTime(bot policy: stop before the race boundary). The on-chainAccelerateCooldownElapsedrevert begins only atblock.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_ROLEauthority 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.