How to use this runbook
Read the Express Withdrawal System Design first for the capital and trust model. This companion is the operator's implementation reference: use the page outline to jump from a live event or revert to the action, timer, invariant, or recovery path that governs it.
- Sign offers only from fresh on-chain fee, liquidity, credit, validator, nonce, and timing state.
- Persist work by
(chainId, ExpressProvider, user, requestId)and make every event handler idempotent. -
Schedule from stored timestamps, especially
acceptedAt,cooldownEndTime, andfinalizedAt. - Re-read
getWithdrawInfoand pool or credit state immediately before every state-changing call. - Alert on invariant failure, post-payout suspension, bad debt, role revocation, and repeatedly stale signatures.
1. Overview
The Express Provider bot reads on-chain state, computes fee and liquidity inputs, signs EIP-712 withdrawal options, processes withdrawals after their security windows, and schedules SYMMIO finalization so pools are replenished. It supports three option types with different timing, capital, and validator requirements.
Option types
| Option Type | Value | User Experience | Capital Fronted? | processWithdraw Needed? |
|---|---|---|---|---|
| SAME_TX | 0 | Transfer inside the initiation transaction. | Yes (same-tx) | No |
| WINDOWED | 1 | ~20 seconds. Capital fronted from pools. | Yes (pools locked) | Yes |
| STANDARD | 2 | At cooldownEndTime. No capital fronting; ExpressProvider acts as intermediary. |
No | Yes (after finalization) |
2. State machine
ExpressProvider internal status
| Status | Value | Meaning |
|---|---|---|
NONE |
0 | No withdrawal exists for this (user, requestId) |
ACCEPTED |
1 | Withdrawal accepted. WINDOWED reserves provider funds; STANDARD waits for core finalization. SAME_TX bypasses this state. |
LOCKED |
2 | Risk-flagged by LOCKER_ROLE. Processing blocked until resolved |
PROCESSED |
3 | Funds transferred to user. For STANDARD, this is the post-FINALIZED payout state |
FINALIZED |
4 | Terminal for WINDOWED/SAME_TX; for STANDARD, tokens arrived and await processing |
CANCELLED |
5 | Terminal. All locks released |
SUSPENDED |
6 | Terminal. All locks released |
State transition diagrams
Use the following role and timing labels when reading the diagrams:
OPERATOR_ROLEruns routine processing when a request reaches its configured processing time.LOCKER_ROLEcan hold an accepted request for review by moving it toLOCKED.UNLOCK_ROLEcan release a reviewed locked request and process it in one call.-
securityWindowis the wait after WINDOWED acceptance.tolerancePeriodis the additional wait before callers withoutOPERATOR_ROLEmay use the permissionless processing fallback. See Section 3.2 for the full timing rules and Section 12.2 for role separation.
SAME_TX state machine
stateDiagram-v2
[*] --> NONE
NONE --> PROCESSED : onWithdrawRequest\n(same-tx transfer)
PROCESSED --> FINALIZED : onWithdrawComplete\n(at cooldown end, pools replenished)
FINALIZED --> [*]
note right of PROCESSED
Funds transferred to user
in the same transaction.
No processWithdraw needed.
end note
WINDOWED state machine
stateDiagram-v2
[*] --> NONE
NONE --> ACCEPTED : onWithdrawRequest
ACCEPTED --> PROCESSED : processWithdraw\n(after securityWindow)
ACCEPTED --> LOCKED : lockWithdraw\n(LOCKER_ROLE)
ACCEPTED --> CANCELLED : onWithdrawCancelRequest
ACCEPTED --> SUSPENDED : onWithdrawSuspend
LOCKED --> PROCESSED : unlockAndProcess\n(UNLOCK_ROLE)
LOCKED --> PROCESSED : processWithdraw\n(operator at cooldownEndTime; anyone after cooldown + tolerance)
LOCKED --> SUSPENDED : onWithdrawSuspend
PROCESSED --> SUSPENDED : onWithdrawSuspend\n(post-payout rollback)
PROCESSED --> FINALIZED : onWithdrawComplete\n(at cooldown end)
FINALIZED --> [*]
CANCELLED --> [*]
SUSPENDED --> [*]
STANDARD state machine
stateDiagram-v2
[*] --> NONE
NONE --> ACCEPTED : onWithdrawRequest
ACCEPTED --> FINALIZED : onWithdrawComplete\n(tokens arrive from SYMMIO)
ACCEPTED --> LOCKED : lockWithdraw\n(LOCKER_ROLE)
ACCEPTED --> CANCELLED : onWithdrawCancelRequest
ACCEPTED --> SUSPENDED : onWithdrawSuspend
FINALIZED --> PROCESSED : processWithdraw\n(forward tokens to user)
LOCKED --> LOCKED : onWithdrawComplete\n(tokens arrive, finalizedAt set,\nstatus STAYS LOCKED)
LOCKED --> PROCESSED : unlockAndProcess\n(UNLOCK_ROLE, requires finalizedAt!=0)
LOCKED --> PROCESSED : processWithdraw\n(operator at cooldownEndTime; anyone after cooldown + tolerance)
LOCKED --> SUSPENDED : onWithdrawSuspend\n(only if finalizedAt==0)
PROCESSED --> [*]
CANCELLED --> [*]
SUSPENDED --> [*]
Transition triggers
| From | To | Trigger | Who |
|---|---|---|---|
| NONE | ACCEPTED | onWithdrawRequest (WINDOWED/STANDARD) |
SYMMIO callback |
| NONE | PROCESSED | onWithdrawRequest (SAME_TX, same-tx transfer) |
SYMMIO callback |
| ACCEPTED | PROCESSED | processWithdraw (WINDOWED) |
OPERATOR_ROLE (or anyone after tolerancePeriod) |
| ACCEPTED | LOCKED | lockWithdraw |
LOCKER_ROLE |
| ACCEPTED | CANCELLED | onWithdrawCancelRequest |
SYMMIO callback |
| ACCEPTED | SUSPENDED | onWithdrawSuspend |
SYMMIO callback |
| LOCKED | PROCESSED | unlockAndProcess (STANDARD also requires finalizedAt != 0) |
UNLOCK_ROLE |
| LOCKED | PROCESSED | processWithdraw (at or after cooldownEndTime) |
OPERATOR_ROLE at cooldownEndTime; anyone after the additional tolerancePeriod |
| LOCKED | SUSPENDED | onWithdrawSuspend (STANDARD only if finalizedAt == 0) |
SYMMIO callback |
| LOCKED | LOCKED (finalizedAt set) | onWithdrawComplete (STANDARD only) |
SYMMIO callback |
| PROCESSED | SUSPENDED |
onWithdrawSuspend (post-payout rollback: records general and credit losses; no pool
replenishment)
|
SYMMIO callback |
| PROCESSED | FINALIZED | onWithdrawComplete |
SYMMIO callback |
| ACCEPTED | FINALIZED | onWithdrawComplete (STANDARD only) |
SYMMIO callback |
Note: onWithdrawSuspend handles suspension from non-terminal states. Suspend can happen at any point
(ACCEPTED, LOCKED, or PROCESSED) and triggers a rollback (covering credit loss,
recording general-pool loss, and promoting pending fees, but not replenishing pools) when called from PROCESSED.
The contract callback list is exactly: onWithdrawRequest, onWithdrawComplete,
onWithdrawCancelRequest, onWithdrawSuspend.
Numeric example: state transitions for a 500 USDC WINDOWED withdrawal
Scenario: Single-part WINDOWED withdrawal, no credit
Setup:
generalBalance = 10,000 USDC lockedGeneralBalance = 0
affiliateBalances[aff] = 5,000 USDC lockedAffiliateBalances[aff] = 0
affiliateConfigs[aff] = { feeRate: 50 (0.5%), operatorFee: 1e6 (1 USDC) }
securityWindow = 20s tolerancePeriod = 60s
nonces[user] = 7
Step 1: Bot sees: user requests a 500 USDC express withdrawal quote
Bot reads on-chain:
- nonces[user] = 7
- generalBalance - lockedGeneralBalance = 10,000 - 0 = 10,000 USDC available
- affiliateBalances[aff] - lockedAffiliateBalances[aff] = 5,000 - 0 = 5,000 USDC available
- affiliateConfigs[aff].feeRate = 50 bps
- affiliateConfigs[aff].operatorFee = 1e6 (1 USDC)
Bot computes:
- 1 part: { amount: 500e6, expressProvider: EP, virtualProvider: 0x0 }
- expressAmount = 500e6
- creditAmount = 0
- affiliateAmount = 200e6 (bot decides how much to draw from affiliate pool)
- generalAmount = expressAmount - affiliateAmount = 500e6 - 200e6 = 300e6
- feeBasis = expressAmount = 500e6
- fee = 500e6 * 50 / 10000 = 2,500,000 (2.5 USDC)
- operatorFee = 1e6 (must match on-chain config exactly; reverts OperatorFeeMismatch otherwise)
- totalFee = 2.5 + 1 = 3.5 USDC
- maxUserFee = 3.5 USDC
Bot checks:
- generalBalance - lockedGeneralBalance >= generalAmount? 10,000 >= 300? YES
- affiliateBalances[aff] - lockedAffiliateBalances[aff] >= affiliateAmount? 5,000 >= 200? YES
- fee + operatorFee <= feeBasis? 3.5e6 <= 500e6? YES
Decision: Offer WINDOWED (optionType=1). Sign EIP-712 option with nonce=7, deadline=now+60s.
What if unlocked general liquidity were only 200 USDC?
The chosen 300-general / 200-affiliate split would fail. The bot can increase
affiliateAmount if that pool has room, use eligible credit, or fall back to STANDARD.
What if feeRate were 10000 (100%) and operatorFee were 1e6?
fee = 500e6, fee + operatorFee = 501e6 > 500e6: reverts FeesExceedExpressAmount.
Bot must detect this before offering and refuse the quote.
Step 2: Bot sees: SYMMIO calls onWithdrawRequest (acceptance tx)
User submits the signed option to SYMMIO. SYMMIO calls onWithdrawRequest on ExpressProvider.
Bot reads (contract verifies automatically):
- EIP-712 signer has SIGNER_ROLE? YES
- nonces[user] == offer.nonce? 7 == 7? YES (nonce increments to 8)
- block.timestamp <= offer.deadline? YES
- offer.fee == feeBasis * feeRate / 10000? 2.5e6 == 2.5e6? YES (reverts FeeMismatch otherwise)
- offer.operatorFee == affiliateConfigs[aff].operatorFee? 1e6 == 1e6? YES (reverts OperatorFeeMismatch otherwise)
- generalBalance - lockedGeneralBalance >= generalAmount? 10,000 >= 300? YES (reverts InsufficientGeneralBalance otherwise)
- affiliateBalances[aff] - lockedAffiliateBalances[aff] >= affiliateAmount?
5,000 >= 200? YES (reverts InsufficientAffiliateBalance otherwise)
Contract locks funds:
- lockedGeneralBalance: 0 + 300 = 300
- lockedAffiliateBalances[aff]: 0 + 200 = 200
Contract stores WithdrawInfo:
- status = ACCEPTED, acceptedAt = T0, cooldownEndTime = T0 + 12h
Decision: Request accepted. Bot starts the securityWindow countdown (20 seconds).
Step 3: Bot sees: securityWindow has elapsed (T0 + 20s)
Bot reads on-chain:
- withdrawInfos[user][reqId].status = ACCEPTED
- withdrawInfos[user][reqId].acceptedAt = T0
- block.timestamp = T0 + 20s
Bot checks:
- Is status == ACCEPTED? YES
- Is block.timestamp >= acceptedAt + securityWindow? T0+20 >= T0+20? YES
- Did the risk-detection service flag this withdrawal? NO
Decision: Process now. Call processWithdraw(user, reqId, parts).
What if the bot tried at T0 + 15s?
T0+15 < T0+20: contract reverts TooEarly. Bot must wait.
What if the risk service flagged this withdrawal at T0 + 10s?
Bot (LOCKER_ROLE) calls lockWithdraw(user, reqId), setting status = LOCKED.
processWithdraw then reverts NotAccepted.
Resolution requires either:
(a) UNLOCK_ROLE calls unlockAndProcess: processes the withdrawal, or
(b) Bot waits until cooldownEndTime (T0+12h), at which point processWithdraw
becomes callable even for LOCKED requests (risk window is over).
What if a non-operator (anyone) tried processWithdraw at T0 + 25s?
Non-operators must wait securityWindow + tolerancePeriod = 20 + 60 = 80s.
T0+25 < T0+80: reverts TooEarly. Permissionless fallback activates at T0+80.
Contract executes processWithdraw:
- Fee cascading across parts:
feeRemaining = 3.5e6
Part 1 (500 express-only): deduction = min(3.5e6, 500e6) = 3.5e6
Transfer to receiver: 500e6 - 3.5e6 = 496.5e6 (496.5 USDC)
feeRemaining = 0
- Pool updates:
lockedGeneralBalance: 300 - 300 = 0
lockedAffiliateBalances[aff]: 200 - 200 = 0
generalBalance: 10,000 - 300 = 9,700
affiliateBalances[aff]: 5,000 - 200 = 4,800
pendingFees[user, requestId] = 2.5 USDC
pendingOperatorFees[user, requestId] = 1 USDC
- status = PROCESSED
Step 4: Bot sees: cooldownEndTime reached (T0 + 12h)
Bot reads on-chain:
- withdrawInfos[user][reqId].status = PROCESSED
- withdrawInfos[user][reqId].cooldownEndTime = T0 + 12h
- block.timestamp >= T0 + 12h
Bot checks:
- Is status == PROCESSED? YES (required for non-STANDARD finalization)
- Has cooldown elapsed? YES
Decision: Finalize. Call ISymmio(symmio).finalizeWithdrawRequest(user, reqId).
SYMMIO sends 500 USDC (expressAmount) to ExpressProvider, then calls onWithdrawComplete.
Contract replenishes pools:
- generalBalance: 9,700 + 300 = 10,000 (restored by generalAmount)
- affiliateBalances[aff]: 4,800 + 200 = 5,000 (restored by affiliateAmount)
- pending fees are promoted into collectedFees[aff] and collectedOperatorFees[aff]
- status = FINALIZED
Result:
User received 496.5 USDC after a ~20s wait.
Pools are fully restored to pre-withdrawal levels.
The 2.5 USDC affiliate fee and 1 USDC operator fee are now claimable by the configured fee claimer.
Net capital at risk for 12 hours: 500 USDC (fronted from pools, repaid by SYMMIO).
3. Timing
3.1 processableAt lookup table
The processableAt timestamp determines when processWithdraw can be called. It varies by option type,
status, and caller role. The table maps each combination to its earliest allowed timestamp.
| Option Type | Status | processableAt (OPERATOR_ROLE) | processableAt (Anyone) |
|---|---|---|---|
| WINDOWED | ACCEPTED | acceptedAt + securityWindow |
acceptedAt + securityWindow + tolerancePeriod |
| WINDOWED | LOCKED | cooldownEndTime (only if block.timestamp >= cooldownEndTime) |
cooldownEndTime + tolerancePeriod |
| STANDARD | FINALIZED | finalizedAt |
finalizedAt + tolerancePeriod |
| STANDARD | LOCKED | cooldownEndTime (only if block.timestamp >= cooldownEndTime) |
cooldownEndTime + tolerancePeriod |
| SAME_TX | N/A | N/A (processed atomically inside onWithdrawRequest) |
N/A |
Notes:
-
For LOCKED withdrawals,
processWithdrawonly becomes callable onceblock.timestamp >= cooldownEndTime. At that point the risk window is over and the lock becomes ineffective. -
For LOCKED STANDARD withdrawals with
finalizedAt == 0,processWithdrawcallsfinalizeWithdrawRequeston SYMMIO first to retrieve tokens before processing. -
The
tolerancePeriodis the permissionless fallback window. If the bot goes down, anyone can process after an additionaltolerancePerioddelay.
3.2 Security window & tolerance period
The security window is the mandatory delay after acceptance before the operator can process a WINDOWED withdrawal. The tolerance period is the additional delay after which anyone (not just the operator) can process permissionlessly.
gantt
title Processing Windows (WINDOWED example)
dateFormat X
axisFormat %s
section Operator Window
securityWindow (20s) :crit, 0, 20
Operator can process :active, 20, 80
section Anyone Window
tolerancePeriod (60s) :crit, 20, 80
Anyone can process :active, 80, 120
3.3 Complete timing diagram
gantt
title Withdrawal Lifecycle Timing
dateFormat X
axisFormat %Hh
section SAME_TX
Accept+Transfer (same tx) :done, 0, 1
SYMMIO Cooldown (12h) :active, 0, 43200
Finalization :milestone, 43200, 43200
section WINDOWED
Accept :done, 0, 1
Security Window (20s) :crit, 0, 20
Process :milestone, 20, 20
SYMMIO Cooldown (12h) :active, 0, 43200
Finalization :milestone, 43200, 43200
section STANDARD
Accept :done, 0, 1
SYMMIO Cooldown (12h) :active, 0, 43200
Finalization :milestone, 43200, 43200
Process :milestone, 43201, 43201
3.4 Configurable parameters
| Parameter | Default | Setter | Description |
|---|---|---|---|
securityWindow |
20s | SETTER_ROLE | Min delay before operator processWithdraw for WINDOWED |
tolerancePeriod |
60s | SETTER_ROLE | Extra delay for permissionless processing |
validatorApprovalTimeout(affiliate) |
30s | SETTER_ROLE |
Max age of validator signatures. Effective getter falls back to address(0) when the
affiliate-specific value is zero
|
minValidatorSignatures(affiliate) |
0 | SETTER_ROLE |
Required validator attestation count. Effective getter falls back to address(0) when the
affiliate-specific value is zero
|
3.5 Core-derived timing
| Timing | Value | Source |
|---|---|---|
| SYMMIO withdrawal cooldown | withdrawCooldownPeriod |
Current SYMMIO core configuration |
cooldownEndTime |
max(deallocateTimestamp + withdrawCooldownPeriod, block.timestamp) |
Copied from the SYMMIO withdrawal request |
3.6 Special timing cases
| Case | Behavior |
|---|---|
securityWindow < 10 |
Setter reverts SecurityWindowTooLow |
tolerancePeriod < 10 |
Setter reverts TolerancePeriodTooLow |
Configured cooldown already elapsed (getWithdrawableTime(user) <= now) |
cooldownEndTime = block.timestamp, finalization possible right away |
| LOCKED after cooldown |
processableAt = cooldownEndTime for OPERATOR_ROLE; non-operators add tolerancePeriod
|
Numeric scenarios: bot computes processableAt
Scenario: Bot computes processableAt for various withdrawals
Shared parameters:
securityWindow = 20
tolerancePeriod = 60
────────────────────────────────────────────────────────────
Withdrawal A (WINDOWED, ACCEPTED):
acceptedAt = 1700000000
Bot (OPERATOR_ROLE):
processableAt = acceptedAt + securityWindow
= 1700000000 + 20
= 1700000020
Wait until block.timestamp >= 1700000020, then call processWithdraw.
Anyone (no OPERATOR_ROLE):
processableAt = acceptedAt + securityWindow + tolerancePeriod
= 1700000000 + 20 + 60
= 1700000080
Permissionless fallback available 80s after acceptance.
────────────────────────────────────────────────────────────
Withdrawal B (STANDARD, FINALIZED):
finalizedAt = 1700043200 (12h after acceptance, SYMMIO sent tokens)
Bot (OPERATOR_ROLE):
processableAt = finalizedAt
= 1700043200
the cooldown configured by SYMMIO already served as the security window.
Call processWithdraw right after finalization.
Anyone (no OPERATOR_ROLE):
processableAt = finalizedAt + tolerancePeriod
= 1700043200 + 60
= 1700043260
────────────────────────────────────────────────────────────
Withdrawal C (WINDOWED, LOCKED: cooldown expired):
acceptedAt = 1700000000
cooldownEndTime = 1700043200 (at cooldown end)
block.timestamp = 1700050000 (well past cooldown)
Status is LOCKED but block.timestamp >= cooldownEndTime, so the
risk window is over. processWithdraw treats it as processable.
Bot (OPERATOR_ROLE):
processableAt = cooldownEndTime
= 1700043200
Already past -> call processWithdraw now.
Anyone (no OPERATOR_ROLE):
processableAt = cooldownEndTime + tolerancePeriod
= 1700043200 + 60
= 1700043260
Already past -> anyone can call processWithdraw now.
────────────────────────────────────────────────────────────
Withdrawal D (STANDARD, LOCKED: cooldown expired, not yet finalized):
acceptedAt = 1700000000
cooldownEndTime = 1700043200
finalizedAt = 0 (SYMMIO hasn't finalized yet)
block.timestamp = 1700050000
Bot (OPERATOR_ROLE):
processableAt = cooldownEndTime = 1700043200 (already past)
processWithdraw detects isLockedAfterCooldown && finalizedAt == 0,
so it calls finalizeWithdrawRequest(user, requestId) on SYMMIO first.
SYMMIO sends tokens -> then processWithdraw forwards them to the user.
Anyone (no OPERATOR_ROLE):
processableAt = cooldownEndTime + tolerancePeriod
= 1700043200 + 60
= 1700043260
Same auto-finalize behavior applies.
────────────────────────────────────────────────────────────
Withdrawal E (SAME_TX):
Not applicable. SAME_TX withdrawals are processed atomically inside
onWithdrawRequest. The status goes directly to PROCESSED. There is no
processWithdraw call and no processableAt computation.
Numeric example: cooldown already elapsed
Scenario: Bot detects pre-expired cooldown and fast-tracks a STANDARD withdrawal
Setup:
block.timestamp = 1_700_000_000
securityWindow = 20s
tolerancePeriod = 60s
Alice deallocateTimestamp = 1_699_913_600 (24 hours ago)
cooldownEndTime = max(1_699_913_600 + 43_200, 1_700_000_000)
= max(1_699_956_800, 1_700_000_000)
= 1_700_000_000 (cooldown already passed)
generalBalance = 10_000 USDC
Status for (Alice, req#42) = NONE
Step 1: Bot sees: WithdrawAccepted event for Alice, 500 USDC STANDARD, req#42
Bot reads on-chain:
withdrawInfos[Alice][42].status = ACCEPTED
withdrawInfos[Alice][42].cooldownEndTime = 1_700_000_000
Bot checks: cooldownEndTime <= block.timestamp?
1_700_000_000 <= 1_700_000_000 --> yes, cooldown already expired
Decision: call finalizeWithdrawRequest(Alice, 42) right away
(no need to schedule another cooldown-length timer)
Step 2: Bot sees: WithdrawFinalized event for (Alice, req#42)
Bot reads on-chain:
withdrawInfos[Alice][42].status = FINALIZED
withdrawInfos[Alice][42].finalizedAt = 1_700_000_000
Bot checks: for STANDARD, processableAt = finalizedAt = 1_700_000_000
Is block.timestamp >= 1_700_000_000? --> yes
Decision: call processWithdraw(Alice, 42, parts) in the next block
Total user wait: ~2 blocks (a few seconds)
What-if: same withdrawal as WINDOWED instead of STANDARD?
Bot checks: processableAt = acceptedAt + securityWindow
= 1_700_000_000 + 20 = 1_700_000_020
Decision: wait 20s for the risk-check window, then call processWithdraw
But finalization can happen almost right away after processing
Pool replenishment: ~20s instead of the usual at cooldown end
4. Options API
4.1 Decision tree
The bot is choosing one or more withdrawal offers here. Each offer contains parts, the
WithdrawReceiverPart[] entries that route and fund the payout, and providerData, the encoded signed
offer plus any required validator approvals and credit attestation. Their exact construction appears in
Section 4.3 and Section 4.4. The decision also
depends on the validator gate in Section 9.2, the complete credit-cap check in
Section 10.5, and the optional STANDARD acceleration path in
Section 10.6.
flowchart TD
A[User requests withdrawal options] --> B[Read on-chain state]
B --> B1["nonces(user)"]
B --> B2["affiliateConfigs(affiliate)"]
B --> B4["generalBalance, lockedGeneralBalance"]
B --> B5["affiliateBalances, lockedAffiliateBalances"]
B --> B6["creditLineTotalDebt + creditLineBadDebt + caps"]
B1 & B2 & B4 & B5 & B6 --> C{Compute available liquidity}
C --> D{minValidatorSignatures(affiliate) > 0\nAND sufficient liquidity?}
D -->|Yes| D1[Gather validator sigs]
D1 --> D2[Offer SAME_TX]
C --> E{Unlocked liquidity\n>= amount?}
E -->|Yes| E1[Offer WINDOWED]
C --> G[Always offer STANDARD]
D2 & E1 & G --> H[Compute fees]
H --> I[Sign EIP-712 WithdrawOption]
I --> J[Return options to user]
flowchart LR
subgraph "For each user withdrawal request"
A{Validators enabled\n& liquidity OK?} -->|Yes| IM[SAME_TX]
B{Unlocked liquidity\n>= amount?} -->|Yes| IN[WINDOWED]
D[Always] --> ST[STANDARD]
end
4.2 On user withdrawal request
- [ ] Read
expressProvider.nonces(user)for current nonce - [ ] Read
affiliateConfigs(affiliate)forfeeRateandoperatorFee - [ ] Compute fee:
fee = expressAmount * feeRate / 10000 - [ ] Set
maxUserFee >= fee + operatorFee -
[ ] If signing STANDARD as an accelerate candidate, compute
maxAccelerationFeeas the user's upper bound for a later acceleration premium; follow the eligibility and retry rules in Section 10.6 - [ ] Check
fee + operatorFee <= expressAmount(else revertsFeesExceedExpressAmount) - [ ] Check available general pool:
generalBalance - lockedGeneralBalance >= generalAmount -
[ ] Check available affiliate pool:
affiliateBalances[affiliate] - lockedAffiliateBalances[affiliate] >= affiliateAmount -
[ ] If
creditAmount > 0, verify the affiliate is neither paused nor the user blacklisted, then check thatcreditLineTotalDebt + creditLineBadDebt + creditAmountfits both effective cap axes: the absolute-debt cap and the MuoneligibleBasepercentage cap. See Section 10.5. -
[ ] For SAME_TX: verify
minValidatorSignatures(affiliate) > 0(falls back toaddress(0)default) -
[ ] If validators required: gather >=
minValidatorSignatures(affiliate)attestations from validators registered for this affiliate (oraddress(0)default); follow the exact option-type rules in Section 9.2 - [ ] Construct
WithdrawReceiverPart[]array - [ ] Compute
partsHash = keccak256(abi.encode(parts)) - [ ] Sign EIP-712
WithdrawOptionwith SIGNER_ROLE key - [ ] Return
{ parts, providerData, fee, operatorFee, maxUserFee, maxAccelerationFee, estimatedTime }
4.3 Parts construction
Each WithdrawReceiverPart:
| Field | Express part | Non-express / classic |
|---|---|---|
expressProvider |
ExpressProvider address | address(0) or another provider |
virtualProvider |
address(0) (DEPRECATED, must be zero) |
varies |
amount |
collateral decimals | collateral decimals |
receiver |
user's receiver address | user's receiver address |
Amount classification:
expressAmount= sum of parts whereexpressProvider == address(this)generalAmount = expressAmount - affiliateAmount - creditAmountfeeBasis = expressAmount
Note: virtualProvider must be address(0) on every part routed through this ExpressProvider. A
nonzero value on such a part reverts VirtualProviderMustBeZero; non-Express parts are skipped.
Mixed-request boundary: core caps advanceWithdraw by express, non-virtual parts, and this provider computes its
amount from parts assigned to its own address with virtualProvider == address(0). Classic parts therefore cannot
inflate this provider's advance. Do not put virtual-only parts under an Express-master request because this provider's
transfer loop intentionally skips them.
flowchart TD
P[Parts Array] --> C1{expressProvider == this?}
C1 -->|No| SKIP[Skipped by ExpressProvider]
C1 -->|Yes| EO[Adds to expressAmount]
EO --> GA["generalAmount = expressAmount - affiliateAmount - creditAmount"]
EO --> FB["feeBasis = expressAmount"]
4.4 providerData encoding
providerData = abi.encode(offerData, validatorData, creditDataRaw)
where:
offerData = abi.encode(WithdrawOffer struct: includes creditAmount field)
validatorData = abi.encode(bytes[] signatures, uint256[] timestamps)
creditDataRaw = abi.encode(CreditData) if creditAmount > 0, else empty bytes
If no validators needed, validatorData = abi.encode(new bytes[](0), new uint256[](0)). If no credit used,
creditDataRaw is empty bytes ("").
4.5 Nonce management
- [ ] Nonces are per-user and sequential
- [ ] Read
nonces(user)before each option signing - [ ] Each acceptance increments the nonce by 1
- [ ] Concurrent options signed against the same user nonce: only one can succeed
- [ ] Different users have independent nonce counters and can be signed in parallel
4.6 Signing requirements
WithdrawOption EIP-712 fields (all must be exact):
- [ ]
user: the withdrawing user address - [ ]
nonce: must matchnonces[user]at execution time - [ ]
optionType: 0-2 -
[ ]
availableAt: signed and stored but not read by current processing logic; use a canonical value such as 0 and persist the exact signed offer - [ ]
affiliate: affiliate address - [ ]
affiliateAmount: amount from affiliate pool - [ ]
creditAmount: amount drawn from credit line (0 unless credit-backed; must be 0 for STANDARD) - [ ]
fee: must equal(feeBasis * feeRate) / 10000on-chain - [ ]
operatorFee: must matchaffiliateConfigs[affiliate].operatorFeeexactly - [ ]
maxUserFee: max affiliate plus operator fee the user authorizes - [ ]
maxAccelerationFee: max extra fee user authorizes if STANDARD is accelerated - [ ]
partsHash:keccak256(abi.encode(parts)) - [ ]
deadline: signature expiry timestamp (future) - [ ]
signature: signed by SIGNER_ROLE holder
Domain: name="ExpressProvider", version="1", chainId,
verifyingContract=diamond address
Numeric example: parts construction for credit-backed withdrawal
Scenario: User wants to withdraw 1,500 USDC, sent to two different receivers.
Bot must decide how to split across pools and credit line,
construct the parts array, and compute all derived amounts.
Setup:
generalBalance = 5,000e6 USDC
lockedGeneralBalance = 4,600e6 USDC (heavy utilization)
affiliateBalances[0xAff] = 1,000e6 USDC
lockedAffiliateBalances = 0e6 USDC
expressProvider.creditLineTotalDebt(0xAff) = 200e6 USDC (existing debt)
expressProvider.creditLineBadDebt(0xAff) = 0
credit line protocolMaxDebt = 5,000e6 (plenty of headroom)
percentage cap also passes against the fresh Muon eligibleBase
affiliateConfigs(0xAff) = { feeRate: 100 bps, operatorFee: 2e6 }
----------------------------------------------------------------------
Step 1: Bot sees: User requests options for 1,500 USDC withdrawal
Receivers: 1,000 USDC to 0xReceiverA, 500 USDC to 0xReceiverB
Bot reads on-chain:
Unlocked general = generalBalance - lockedGeneralBalance = 5,000 - 4,600 = 400e6
Unlocked affiliate = affiliateBalances[0xAff] - lockedAffiliateBalances[0xAff] = 1,000 - 0 = 1,000e6
Bot checks: "Can I cover 1,500 USDC from pools alone?"
Total unlocked = 400 (general) + 1,000 (affiliate) = 1,400e6
1,400 < 1,500: NO, not enough from pools alone.
Bot checks: "Can I cover the gap using the credit line?"
Shortfall = 1,500 - 1,400 = 100e6 minimum from credit
Absolute headroom = protocolMaxDebt - (totalDebt + badDebt) = 5,000 - (200 + 0) = 4,800e6
100 <= 4,800: YES, credit line has headroom.
Bot also obtains Muon attestation for the affiliate's aggregate eligibleBase.
Decision: Use pools + credit line. creditAmount = 100e6 to cover the gap.
----------------------------------------------------------------------
Step 2: Bot decides: How to split into parts and funding sources
Strategy: Maximize affiliate pool usage, use credit for the shortfall.
affiliateAmount = 1,000e6 (use full unlocked affiliate pool)
creditAmount = 100e6 (cover the shortfall via credit line)
generalAmount = expressAmount - affiliateAmount - creditAmount
= 1,500 - 1,000 - 100 = 400e6
Unlocked general (400e6) >= generalAmount (400e6)? YES, exactly enough.
Bot checks: affiliateAmount + creditAmount <= expressAmount?
1,000 + 100 = 1,100 <= 1,500? YES (else reverts FundingSplitExceedsExpress)
Final parts array (all virtualProvider = address(0)):
[
{ id: 0, amount: 1000e6, receiver: 0xReceiverA,
expressProvider: 0xEP, virtualProvider: 0x0 },
{ id: 1, amount: 500e6, receiver: 0xReceiverB,
expressProvider: 0xEP, virtualProvider: 0x0 },
]
----------------------------------------------------------------------
Step 3: Bot computes: Fee computation
From the parts array, computeAmounts will derive:
expressAmount = 1,000 + 500 = 1,500e6
generalAmount = 1,500 - 1,000 - 100 = 400e6
feeBasis = expressAmount = 1,500e6
fee = 1,500e6 * 100 / 10000 = 15e6 (15 USDC, at 1% rate)
operatorFee = 2e6 (2 USDC)
totalFee = 15 + 2 = 17e6
Bot checks: fee + operatorFee (17e6) <= feeBasis (1,500e6)? YES
maxUserFee = 17e6 (user pays the full 17 USDC fee)
partsHash = keccak256(abi.encode(parts)) : bot MUST store this
Decision: Sign the option (including creditAmount=100e6) and return to user.
----------------------------------------------------------------------
Step 4: What happens on-chain at acceptance (for reference)
onWithdrawRequest will:
1. Verify EIP-712 signature and nonce (includes creditAmount in struct hash)
2. Call computeAmounts -> expressAmount=1500, generalAmount=400
3. Verify fee = (1500e6 * 100) / 10000 = 15e6 (matches signed fee)
4. Verify operatorFee = 2e6 (matches on-chain config)
5. Lock general pool: lockedGeneralBalance += 400e6 (now 5,000e6)
6. Lock affiliate pool: lockedAffiliateBalances[0xAff] += 1,000e6
7. Reserve credit: LibCreditLine.reserveDebt(affiliate, user, reqId, 100e6, creditData)
creditLineReservedDebt(affiliate) += 100e6
8. Store WithdrawInfo with partsHash, creditAmount=100
9. Status = ACCEPTED
At processWithdraw:
1. Activate credit: LibCreditLine.activate -> reservedDebt -= 100, activeDebt += 100
2. Advance from core: SYMMIO.advanceWithdraw(user, reqId, 100e6)
3. Fee deduction cascades across parts in order:
userFee = fee + operatorFee = 17e6
Part 0 (1,000e6): deduction = min(17e6, 1,000e6) = 17e6
Transfer 1,000 - 17 = 983e6 USDC to 0xReceiverA
feeRemaining = 0
Part 1 (500e6): deduction = 0
Transfer 500e6 USDC to 0xReceiverB
Net result:
0xReceiverA gets 983 USDC
0xReceiverB gets 500 USDC
pendingFees[user, requestId] = 15e6
pendingOperatorFees[user, requestId] = 2e6
User requested 1,500; the receivers got 1,500 - 17 = 1,483.
Credit line: 100 USDC active debt, settled on finalization.
Fee escrow: 17 USDC remains pending until finalization promotes it to collected balances.
Numeric example: bot action timeline for a 500 USDC WINDOWED
Scenario: User requests 500 USDC WINDOWED withdrawal; bot shepherds it through
sign -> accept -> risk-check -> process -> finalize.
Setup:
generalBalance = 10,000e6 USDC
lockedGeneralBalance = 2,000e6 USDC (from other pending withdrawals)
affiliateBalances[0xAff] = 3,000e6 USDC
lockedAffiliateBalances = 0e6 USDC
nonces(0xUser) = 3
affiliateConfigs(0xAff) = { feeRate: 50 bps, operatorFee: 1e6 }
securityWindow = 20s
tolerancePeriod = 60s
cooldownEndTime will be = T+12h (set by SYMMIO at acceptance)
----------------------------------------------------------------------
Step 1: Bot sees: User requests withdrawal options for 500 USDC
(T=0s, off-chain API call)
Bot reads on-chain:
nonces(0xUser) = 3
affiliateConfigs(0xAff).feeRate = 50, .operatorFee = 1e6
generalBalance - lockedGeneralBalance = 10,000 - 2,000 = 8,000e6 unlocked
affiliateBalances[0xAff] - lockedAffiliateBalances[0xAff] = 3,000 - 0 = 3,000e6 unlocked
minValidatorSignatures(0xAff) = 0
Bot decides the parts split:
1 part, express-only: 500e6 to 0xReceiver, expressProvider=0xEP, virtualProvider=0x0
expressAmount = 500e6, creditAmount = 0
affiliateAmount = 200e6 (bot chooses to draw 200 from affiliate pool)
generalAmount = 500 - 200 = 300e6
Bot checks: "Can I offer WINDOWED?"
Unlocked general (8,000e6) >= generalAmount (300e6)? YES
Unlocked affiliate (3,000e6) >= affiliateAmount (200e6)? YES
WINDOWED is feasible.
Bot checks: "Can I offer SAME_TX?"
minValidatorSignatures(0xAff) = 0: NO (validators required for SAME_TX)
Bot computes fee:
feeBasis = expressAmount = 500e6
fee = 500e6 * 50 / 10000 = 2.5e6 (2.50 USDC)
operatorFee = 1e6 (1.00 USDC)
maxUserFee = 2.5e6 + 1e6 = 3.5e6
Check: fee + operatorFee (3.5e6) <= feeBasis (500e6)? YES
Bot computes partsHash = keccak256(abi.encode(parts))
Bot signs EIP-712 WithdrawOption:
{ user: 0xUser, nonce: 3, optionType: 1 (WINDOWED), availableAt: 0,
affiliate: 0xAff, affiliateAmount: 200e6, fee: 2.5e6, operatorFee: 1e6,
maxUserFee: 3.5e6, maxAccelerationFee: 0, partsHash: <hash>, deadline: now+3600 }
Decision: Return signed WINDOWED option to user.
What if unlocked general were only 100e6?
generalAmount (300e6) > 100e6: this split is not feasible.
Increase affiliateAmount, add eligible credit, or fall back to STANDARD.
----------------------------------------------------------------------
Step 2: Bot sees: WithdrawAccepted(0xUser, 7, WINDOWED)
(T=5s, on-chain event from ExpressProvider)
Bot reads on-chain:
withdrawInfos(0xUser, 7).status = ACCEPTED
withdrawInfos(0xUser, 7).acceptedAt = T=5s
withdrawInfos(0xUser, 7).cooldownEndTime = T+12h
lockedGeneralBalance is now 2,300e6 (+300 locked)
lockedAffiliateBalances[0xAff] is now 200e6 (+200 locked)
Bot checks: "When can I call processWithdraw?"
Earliest = acceptedAt + securityWindow = T+5s + 20s = T+25s
Decision: Schedule processWithdraw(0xUser, 7, parts) for T=25s.
Start risk check at acceptance (20s security window).
What if bot detects suspicious activity during risk check?
Bot calls lockWithdraw(0xUser, 7) using LOCKER_ROLE.
Status becomes LOCKED, processWithdraw is blocked.
UNLOCK_ROLE can call unlockAndProcess immediately for WINDOWED, OR
OPERATOR_ROLE can process at cooldownEndTime; anyone else waits through tolerancePeriod.
----------------------------------------------------------------------
Step 3: Bot sees: securityWindow elapsed, time to process
(T=25s, bot's scheduled action fires)
Bot reads on-chain:
withdrawInfos(0xUser, 7).status = ACCEPTED (still: not locked or cancelled)
block.timestamp (T=25s) >= acceptedAt + securityWindow (T=25s)? YES
Bot checks: "Is the risk check clean?"
Risk check result = CLEAN
Decision: Call processWithdraw(0xUser, 7, parts) with OPERATOR_ROLE.
On-chain effect:
_collectAndTransfer runs:
totalFee = 2.5e6 + 1e6 = 3.5e6
userFee = 3.5e6
pendingFees[0xUser, 7] = 2.5e6
pendingOperatorFees[0xUser, 7] = 1e6
Part 0 (express-only, 500e6): deduction = min(3.5e6, 500e6) = 3.5e6
Transfer 500 - 3.5 = 496.5e6 USDC to 0xReceiver
Pool balance updates:
lockedGeneralBalance -= 300e6 (back to 2,000e6)
lockedAffiliateBalances[0xAff] -= 200e6 (back to 0)
generalBalance -= 300e6 (now 9,700e6)
affiliateBalances[0xAff] -= 200e6 (now 2,800e6)
Status = PROCESSED
Emits WithdrawProcessed(0xUser, 7)
Bot sees: WithdrawProcessed(0xUser, 7)
Decision: Schedule finalizeWithdrawRequest at cooldownEndTime (T+12h).
What if status were LOCKED when the schedule fires?
processWithdraw would revert (NotAccepted).
Bot cancels the scheduled action, waits for resolution.
What if someone else already called processWithdraw (permissionless)?
Bot sees WithdrawProcessed event for a request it didn't process.
Bot cancels its own scheduled processWithdraw.
Bot still schedules finalizeWithdrawRequest at cooldownEndTime.
----------------------------------------------------------------------
Step 4: Bot sees: cooldownEndTime reached
(T=12h, bot's scheduled action fires)
Bot reads on-chain:
withdrawInfos(0xUser, 7).status = PROCESSED
block.timestamp >= cooldownEndTime? YES
Decision: Call SYMMIO.finalizeWithdrawRequest(0xUser, 7).
On-chain effect (SYMMIO side):
SYMMIO transfers 500e6 USDC (expressAmount) to ExpressProvider.
SYMMIO calls onWithdrawComplete on ExpressProvider:
status == PROCESSED: replenish pools:
generalBalance += 300e6 (back to 10,000e6)
affiliateBalances[0xAff] += 200e6 (back to 3,000e6)
promote pendingFees and pendingOperatorFees into collected balances
Status = FINALIZED
Emits WithdrawFinalized(0xUser, 7)
Bot sees: WithdrawFinalized(0xUser, 7)
Decision: Cycle complete. Remove from active tracking. Update liquidity cache.
What if SYMMIO suspended the withdrawal before T=12h?
Status was already PROCESSED, so the post-payout rollback path runs.
Pending fees are promoted, generalAmount is recorded as generalBadDebt, credit loss is
covered from the unlocked affiliate pool where possible, and the request becomes SUSPENDED.
5. Withdrawal flows
5.1 SAME_TX (optionType = 0)
User experience: Transfer inside the initiation transaction.
sequenceDiagram
participant U as User
participant S as SYMMIO
participant EP as ExpressProvider (Diamond)
U->>S: initiateWithdraw(parts, providerData)
S->>EP: onWithdrawRequest(req, collateral)
Note over EP: Verify bot EIP-712 signature
Note over EP: Validate validator signatures
Note over EP: Verify fee matches on-chain config
Note over EP: Lock general + affiliate pools
Note over EP: LibCreditLine.reserveDebt(affiliate, user, reqId, creditAmount, creditData)
EP->>S: acceptWithdrawRequest(user, reqId)
Note over EP: LibCreditLine.activate(symmio, user, reqId, info)
EP->>S: advanceWithdraw(user, reqId, creditAmount)
Note over EP: ═══ SAME TX ═══
Note over EP: Deduct fees
EP->>U: transfer(receiver, amount - fee)
Note over EP: Status = PROCESSED
Note over EP,S: ═══ 12 HOURS LATER ═══
S->>EP: onWithdrawComplete(req)
Note over EP: Replenish pools
Note over EP: LibCreditLine.settle(user, reqId, info)
Note over EP: Status = FINALIZED
Prerequisites:
-
[ ]
minValidatorSignatures(affiliate) > 0(REQUIRED, revertsValidatorsRequiredForSameTxotherwise; falls back tominValidatorSignatures(address(0))default) - [ ] Sufficient general pool liquidity
- [ ] Sufficient affiliate pool liquidity (if affiliateAmount > 0)
- [ ] Sufficient credit line capacity (if creditAmount > 0)
-
[ ] Valid validator attestations gathered from validators registered for this affiliate (or
address(0)default)
Bot checklist:
-
[ ] Gather >=
minValidatorSignatures(affiliate)validator sigs before offering SAME_TX (from validators registered for this affiliate oraddress(0)default) - [ ] Validator sigs must be address-sorted ascending (dedup check)
- [ ] Each validator timestamp must be within
validatorApprovalTimeout(affiliate)of current time -
[ ] Each validator timestamp must be strictly greater than
withdrawCooldownOf(user)(the user's last balance credit) - [ ] Schedule
finalizeWithdrawRequestatcooldownEndTime - [ ] No
processWithdrawneeded (funds already sent) - [ ] Cannot be cancelled once accepted (funds already transferred)
- [ ] Cannot be locked (already PROCESSED)
Numeric example: SAME_TX 1,000 USDC
Scenario: Credit-backed SAME_TX withdrawal with express + credit line
Setup:
generalBalance = 10,000 USDC lockedGeneralBalance = 0
affiliateBalances[aff] = 5,000 USDC lockedAffiliateBalances[aff] = 0
affiliateConfigs[aff] = { feeRate: 100 (1%), operatorFee: 2e6 (2 USDC) }
minValidatorSignatures(aff) = 2 validatorApprovalTimeout(aff) = 30s
creditLine availableCredit = 3,000 USDC creditLine outstandingDebt = 0
nonces[user] = 3
Step 1: Bot sees: user requests an express withdrawal for 1,000 USDC
Bot reads on-chain:
- minValidatorSignatures(aff) = 2 (must be > 0 for SAME_TX; reverts
ValidatorsRequiredForSameTx otherwise)
- generalBalance - lockedGeneralBalance = 10,000 - 0 = 10,000 USDC available
- affiliateBalances[aff] - lockedAffiliateBalances[aff] = 5,000 - 0 = 5,000 USDC available
- creditLine availableCredit = 3,000 USDC available
- nonces[user] = 3
- affiliateConfigs[aff].feeRate = 100 bps (1%)
- affiliateConfigs[aff].operatorFee = 2e6 (2 USDC)
Bot decides: This user qualifies for SAME_TX. Validators are configured, and pools
plus credit line have sufficient liquidity.
Bot constructs parts:
Part 1: { amount: 1000e6, expressProvider: EP, virtualProvider: 0x0, receiver: 0xUser }
Bot computes fee parameters:
- expressAmount = 1000e6 (total withdrawal amount)
- affiliateAmount = 300e6 (bot chooses how much of expressAmount to draw from affiliate pool)
- creditAmount = 200e6 (portion backed by credit line)
- generalAmount = expressAmount - affiliateAmount - creditAmount = 1000e6 - 300e6 - 200e6 = 500e6
- feeBasis = expressAmount = 1000e6
- fee = feeBasis * feeRate / 10000 = 1,000e6 * 100 / 10000 = 10e6 (10 USDC)
- operatorFee = 2e6 (must match on-chain config exactly)
- totalFee = 10 + 2 = 12 USDC
- maxUserFee = 12 USDC
Bot checks before signing:
- generalBalance - lockedGeneralBalance >= generalAmount? 10,000 >= 500? YES
- affiliateBalances[aff] - lockedAffiliateBalances[aff] >= affiliateAmount? 5,000 >= 300? YES
- creditLine availableCredit >= creditAmount? 3,000 >= 200? YES
- fee + operatorFee <= feeBasis? 12e6 <= 1,000e6? YES
Decision: Offer SAME_TX (optionType=0). Proceed to gather validator attestations.
What if creditLine availableCredit were only 150 USDC?
The chosen 200 credit amount would exceed the configured cap and revert
DebtExceedsAbsoluteCap or DebtExceedsPercentCap during reserveDebt.
Bot must reduce creditAmount to 150 (and increase generalAmount to 550),
or fall back to WINDOWED without credit.
What if minValidatorSignatures(aff) were 0?
Contract reverts ValidatorsRequiredForSameTx. Bot cannot offer SAME_TX.
Must fall back to WINDOWED (optionType=1), which adds a securityWindow delay.
Step 2: Bot sees: validator attestations gathered
Bot reads on-chain:
- ISymmio(symmio).withdrawCooldownOf(user) = T_credit (user's last balance credit)
- block.timestamp = T_now
Bot requests ValidatorApproval signatures from 2 independent validators registered for aff (or address(0) default):
Each validator signs: ValidatorApproval(user, nonce=3, amount=totalAmount, timestamp=T_now, symmio=<SYMMIO core address>)
The `symmio` field binds each signature to a specific deployment, preventing replay across deployments.
Bot checks:
- Received 2 signatures? YES
- Each signature timestamp within validatorApprovalTimeout(aff) (30s) of current time? YES
- Each timestamp > withdrawCooldownOf(user) (no credit landed since signing)? YES
- Validator addresses sorted ascending (contract enforces DuplicateValidator)? YES
- Each signer is a registered validator (isValidator(aff, signer))? YES
Decision: All attestations valid. Sign the EIP-712 option with nonce=3, optionType=0,
deadline=T_now+30s, then send both option + validator data to the user.
What if one validator timestamp were 45s old?
45 > 30 (validatorApprovalTimeout(aff)): contract reverts ValidatorApprovalExpired.
Bot must request a fresh signature from that validator.
What if both validators had the same address (or unsorted)?
Contract reverts DuplicateValidator. Bot must sort signer addresses ascending.
Step 3: Bot sees: user submits withdrawal (SYMMIO calls onWithdrawRequest: entire step in ONE tx)
Contract executes all of the following atomically:
a. Decode + verify EIP-712 option signature:
- Recovered signer has SIGNER_ROLE? YES
- nonces[user] == offer.nonce? 3 == 3? YES (increments to 4)
- block.timestamp <= offer.deadline? YES
b. Verify fees match on-chain config:
- offer.fee == feeBasis * feeRate / 10000? 10e6 == 10e6? YES (reverts FeeMismatch otherwise)
- offer.operatorFee == affiliateConfigs[aff].operatorFee? 2e6 == 2e6? YES (reverts OperatorFeeMismatch otherwise)
- fee + operatorFee <= feeBasis? 12e6 <= 1,000e6? YES (reverts FeesExceedExpressAmount otherwise)
c. Validate 2 validator signatures:
- Each signer is a registered validator (isValidator(aff, signer))? YES
- Each timestamp within 30s? YES
- Addresses sorted ascending? YES
- Each timestamp > withdrawCooldownOf(user)? T_now > T_credit? YES (reverts StaleValidatorApproval otherwise)
d. Lock general + affiliate pools (_lockFunds, SAME_TX path):
- lockedGeneralBalance: 0 + 500 = 500
- lockedAffiliateBalances[aff]: 0 + 300 = 300
e. Reserve credit line debt:
- LibCreditLine.reserveDebt(affiliate, user, reqId, 200e6, creditData)
- creditLine availableCredit: 3,000 - 200 = 2,800
- creditLine reservedDebt: 0 + 200 = 200
f. Store WithdrawInfo and validate maxUserFee / maxAccelerationFee.
g. Call acceptWithdrawRequest on SYMMIO.
h. SAME_TX path unlocks and deducts the 500 general / 300 affiliate pool portions.
i. Activate the credit debt and advance it from core:
- LibCreditLine.activate(symmio, user, reqId, info)
- reservedDebt: 200 -> 0; activeDebt: 0 -> 200
- SYMMIO.advanceWithdraw(user, reqId, 200e6)
j. _collectAndTransfer runs IN THE SAME TX:
Fee deduction from the single part (feeRemaining = 12e6):
Part 1 (1000 express):
deduction = min(12e6, 1000e6) = 12e6
Transfer to receiver: 1000e6 - 12e6 = 988e6 (988 USDC)
feeRemaining = 0
Pool and fee state after the inline payout:
lockedGeneralBalance: 500 - 500 = 0
lockedAffiliateBalances[aff]: 300 - 300 = 0
generalBalance: 10,000 - 500 = 9,500
affiliateBalances[aff]: 5,000 - 300 = 4,700
pendingFees[user, requestId] = 10 USDC
pendingOperatorFees[user, requestId] = 2 USDC
k. status = PROCESSED (skips ACCEPTED: funds already sent)
Decision: No further bot action needed until finalization. User received funds in this tx.
Result: User receives 988 USDC in the same transaction as initiateWithdraw.
What if the bot had offered WINDOWED (optionType=1) instead?
Step (i) would NOT execute. Status would be ACCEPTED, not PROCESSED.
The user would wait securityWindow (20s) before the bot calls processWithdraw.
SAME_TX skips that wait by requiring validators upfront as a substitute for
the post-acceptance risk window.
Step 4: Bot sees: cooldownEndTime reached (T0 + 12h)
Bot reads on-chain:
- withdrawInfos[user][reqId].status = PROCESSED
- withdrawInfos[user][reqId].cooldownEndTime = T0 + 12h
- block.timestamp >= T0 + 12h
Bot checks:
- Is status == PROCESSED? YES (required: Express reverts InvalidStatusForComplete otherwise)
- Has cooldown elapsed? YES
Decision: Finalize. Call ISymmio(symmio).finalizeWithdrawRequest(user, reqId).
SYMMIO sends 1000 USDC (expressAmount) to ExpressProvider.
SYMMIO calls onWithdrawComplete(req).
Contract replenishes pools and settles credit debt:
- generalBalance: 9,500 + 500 = 10,000 (restored by generalAmount)
- affiliateBalances[aff]: 4,700 + 300 = 5,000 (restored by affiliateAmount)
- LibCreditLine.settle(user, reqId, info)
- creditLine outstandingDebt: 200 - 200 = 0 (debt fully settled)
- creditLine availableCredit: 2,800 + 200 = 3,000 (credit capacity restored)
- pendingFees and pendingOperatorFees are promoted into collected balances
- status = FINALIZED
What if the bot forgot to call finalizeWithdrawRequest?
Pools remain depleted (generalBalance = 9,500, affiliateBalances = 4,700).
Credit debt remains outstanding (creditLine outstandingDebt = 200).
The 1000 USDC stays locked in SYMMIO until finalization. Anyone can trigger
finalization after cooldown, so the bot should schedule the call and monitor the permissionless fallback.
Result:
User received 988 USDC in the same transaction (same transaction, zero wait).
Pools are fully restored to pre-withdrawal levels after 12h.
Credit line debt is fully settled after 12h (no outstanding debt remains).
The 10 USDC affiliate fee and 2 USDC operator fee are claimable after finalization.
Net capital at risk for 12 hours: 800 USDC (fronted from general + affiliate pools)
+ 200 USDC credit line debt (settled on finalization).
5.2 WINDOWED (optionType = 1)
User experience: ~20 seconds. Capital fronted from pools plus any optional credit advance.
sequenceDiagram
participant U as User
participant S as SYMMIO
participant EP as ExpressProvider
participant Bot as Bot
U->>S: initiateWithdraw(parts, providerData)
S->>EP: onWithdrawRequest(req, collateral)
Note over EP: Verify sig, lock pools, reserve credit (if any)
EP->>S: acceptWithdrawRequest(user, reqId)
EP-->>Bot: emit WithdrawAccepted
Note over EP: Status = ACCEPTED
Note over Bot: Wait securityWindow (20s)
Note over Bot: Risk check: CLEAN
Bot->>EP: processWithdraw(user, reqId, parts)
Note over EP: Verify partsHash, deduct fees
EP->>U: transfer(receiver, amount - fee)
EP-->>Bot: emit WithdrawProcessed
Note over EP: Status = PROCESSED
Note over EP,S: at example cooldownEndTime
Bot->>S: finalizeWithdrawRequest(user, reqId)
S->>EP: onWithdrawComplete(req)
Note over EP: Replenish pools
Note over EP: Status = FINALIZED
Bot checklist:
-
[ ] Wait at least
securityWindowafteracceptedAtbefore callingprocessWithdraw - [ ] Perform risk check during security window
- [ ] If risky: call
lockWithdraw(LOCKER_ROLE), do NOT callprocessWithdraw - [ ] Provide exact same
partsarray toprocessWithdraw(verified by partsHash) - [ ] Schedule
finalizeWithdrawRequestatcooldownEndTime - [ ] Monitor for user-initiated cancel (status goes CANCELLED, cancel scheduled processing)
- [ ] Cancellable while ACCEPTED (before processing)
Numeric example: WINDOWED 500 USDC
Scenario: User requests 500 USDC express withdrawal; bot fronts capital from pools
Setup:
generalBalance = 10,000 USDC
lockedGeneralBalance = 0
affiliateBalances[affiliate] = 5,000 USDC
lockedAffiliateBalances[affiliate] = 0
affiliateConfigs[affiliate] = { feeRate: 50 bps (0.5%), operatorFee: 1 USDC }
securityWindow = 20s
tolerancePeriod = 60s
nonces[user] = 5
Step 1: Bot sees: User requests a 500 USDC withdrawal through this affiliate.
Bot reads on-chain:
- unlocked general = generalBalance - lockedGeneralBalance = 10,000 - 0 = 10,000
- unlocked affiliate = affiliateBalances - lockedAffiliateBalances = 5,000 - 0 = 5,000
Bot decides pool split:
- affiliateAmount = 200 (bot chooses how much to draw from affiliate pool)
- generalAmount = 500 - 200 = 300
Bot checks: Can WINDOWED work?
- unlocked general (10,000) >= generalAmount (300)? YES
- unlocked affiliate (5,000) >= affiliateAmount (200)? YES
--> Both pools have enough. WINDOWED is feasible.
Bot computes fees (must match on-chain affiliateConfigs exactly):
- feeBasis = expressAmount = 500
- fee = 500 * 50 / 10,000 = 2.50 USDC
- operatorFee = 1 USDC
- totalFee = 2.50 + 1 = 3.50 USDC
- userFee = fee + operatorFee = 3.50 USDC
Decision: Sign WINDOWED option (optionType=1), nonce=5, maxUserFee=3.50.
What if unlocked general was only 200 instead of 10,000?
200 < generalAmount (300) --> the chosen split would revert InsufficientGeneralBalance.
Bot can increase affiliateAmount, use eligible credit, or fall back to STANDARD.
Step 2: Bot sees: WithdrawAccepted event (SYMMIO called onWithdrawRequest).
What happened on-chain during acceptance:
Contract verified the bot's EIP-712 signature and nonce.
Contract verified fee == feeBasis * feeRate / 10000 and operatorFee == affiliateConfigs.operatorFee.
_lockFunds (WINDOWED path):
- lockedGeneralBalance: 0 --> 300 (300 locked from general pool)
- lockedAffiliateBalances: 0 --> 200 (200 locked from affiliate pool)
userFee = 3.50 <= maxUserFee (3.50)? YES
nonces[user] = 5 --> 6
Status = ACCEPTED
Bot reads on-chain:
- withdrawInfos[user][reqId].status = ACCEPTED (1)
- withdrawInfos[user][reqId].acceptedAt = T0
Step 3: Bot decides: Process or lock? (security window decision)
Bot reads on-chain:
- status = ACCEPTED
- acceptedAt = T0
- block.timestamp = T0 + 5s (still within 20s securityWindow)
Bot runs risk check (off-chain anomaly detection):
- Check user history, funding source, transaction patterns...
- Result: CLEAN. No anomalies.
Decision: Wait until T0+20s, then call processWithdraw.
BRANCH: What if risk was detected at T0+5s?
Bot (using LOCKER_ROLE key, separate from OPERATOR_ROLE) calls:
lockWithdraw(user, reqId)
Status changes: ACCEPTED --> LOCKED
Effect: processWithdraw now reverts with NotAccepted.
Before cooldown, resolution requires UNLOCK_ROLE to call unlockAndProcess
(false alarm) or SYMMIO admin to suspend (confirmed bad actor). After
cooldownEndTime, processWithdraw can resolve the lock.
This OPERATOR_ROLE key cannot unlock because it was not granted UNLOCK_ROLE. The contract permits
role overlap, so key separation is a deployment policy that operations must preserve.
Step 4: Bot sees: block.timestamp >= T0 + 20s (securityWindow elapsed).
Bot reads on-chain:
- status = ACCEPTED (still: not locked, not cancelled)
- block.timestamp = T0 + 20s
- processableAt = acceptedAt + securityWindow = T0 + 20s
Bot checks: block.timestamp (T0+20) >= processableAt (T0+20)? YES
Decision: Process now. Call processWithdraw(user, reqId, parts).
Contract executes _collectAndTransfer:
- feeRemaining = 3.50
- Part 1 (500 express-only): deduction = min(3.50, 500e6) = 3.50
collateral.safeTransfer(receiver, 496.50e6)
Contract updates pools:
- lockedGeneralBalance: 300 --> 0
- lockedAffiliateBalances: 200 --> 0
- generalBalance: 10,000 --> 9,700 (deducted 300: capital fronted)
- affiliateBalances: 5,000 --> 4,800 (deducted 200: capital fronted)
- pendingFees[user, requestId] = 2.50
- pendingOperatorFees[user, requestId] = 1
Status = PROCESSED
BRANCH: What if a non-operator tried to process at T0+20s?
processableAt = acceptedAt + securityWindow + tolerancePeriod = T0 + 20 + 60 = T0 + 80s
At T0+20s: block.timestamp < processableAt --> REVERT TooEarly.
Non-operators must wait until T0+80s (permissionless fallback if bot goes down).
Step 5: Bot sees: block.timestamp >= cooldownEndTime (~T0 + 12h).
Bot reads on-chain:
- withdrawInfos[user][reqId].status = PROCESSED
- withdrawInfos[user][reqId].cooldownEndTime = T0 + 12h
Decision: Finalize. Call SYMMIO.finalizeWithdrawRequest(user, reqId).
SYMMIO transfers 500 USDC (expressAmount) to ExpressProvider, then calls onWithdrawComplete.
Contract replenishes pools:
- generalBalance: 9,700 + 300 = 10,000 (restored)
- pending fees are promoted to collectedFees and collectedOperatorFees
- affiliateBalances: 4,800 + 200 = 5,000 (restored)
Status = FINALIZED
BRANCH: What if user had cancelled at T0+10s (before processing)?
SYMMIO calls onWithdrawCancelRequest.
Contract checks: WINDOWED --> cancellable.
Contract checks: status == ACCEPTED? YES --> allowed.
_releaseWithdraw:
- lockedGeneralBalance: 300 --> 0
- lockedAffiliateBalances: 200 --> 0
Status = CANCELLED. User receives nothing. All pools fully restored.
5.3 STANDARD (optionType = 2)
User experience: until cooldownEndTime. No capital fronting. ExpressProvider acts as intermediary.
sequenceDiagram
participant U as User
participant S as SYMMIO
participant EP as ExpressProvider
participant Bot as Bot
U->>S: initiateWithdraw(parts, providerData)
S->>EP: onWithdrawRequest(req, collateral)
Note over EP: Verify sig, verify fees
Note over EP: NO pool locking (STANDARD)
EP->>S: acceptWithdrawRequest(user, reqId)
EP-->>Bot: emit WithdrawAccepted
Note over EP: Status = ACCEPTED
Note over EP,S: at example cooldownEndTime
Bot->>S: finalizeWithdrawRequest(user, reqId)
S-->>EP: transfer 1,000 USDC (express tokens)
S->>EP: onWithdrawComplete(req)
Note over EP: finalizedAt = now
EP-->>Bot: emit WithdrawFinalized
Note over EP: Status = FINALIZED
Bot->>EP: processWithdraw(user, reqId, parts)
Note over EP: Forward tokens to user
EP->>U: transfer(receiver, amount - fee)
Note over EP: Status = PROCESSED
Bot checklist:
- [ ] Do NOT call
processWithdrawbefore finalization (revertsNotFinalized) - [ ] Operator can process right after finalization
- [ ] Anyone can process after finalization +
tolerancePeriod - [ ] Schedule
finalizeWithdrawRequeston SYMMIO atcooldownEndTime - [ ] After
onWithdrawComplete, callprocessWithdraw - [ ] Cancellable while ACCEPTED (before finalization)
-
[ ] Once finalized: the request cannot be cancelled or suspended, but a globally suspended user remains blocked from
processWithdrawuntil the user is unsuspended -
[ ] LOCKED + finalized STANDARD:
unlockAndProcessmay resolve it, orprocessWithdrawmay process at cooldown end (operator immediately, any caller aftertolerancePeriod)
Numeric example: STANDARD 1,000 USDC
Scenario: Simple STANDARD withdrawal: no pool capital and no credit
Setup:
generalBalance = 10,000 USDC, lockedGeneralBalance = 8,000
affiliateBalances[affiliate] = 5,000, lockedAffiliateBalances[affiliate] = 4,600
affiliateConfigs[affiliate] = { feeRate: 50 bps (0.5%), operatorFee: 0 }
NOTE: Credit is NOT supported for STANDARD (CreditNotSupportedForStandard error).
Step 1: Bot sees: User requests withdrawal of 1,000 USDC
Bot reads on-chain:
- generalBalance - lockedGeneralBalance = 10,000 - 8,000 = 2,000 (unlocked general)
- affiliateBalances - lockedAffiliateBalances = 5,000 - 4,600 = 400 (unlocked affiliate)
- affiliateConfigs[affiliate] = { feeRate: 50, operatorFee: 0 }
Bot checks: Can I offer WINDOWED?
A 400-affiliate / 600-general WINDOWED split is feasible, but the user selected STANDARD.
Decision: Offer STANDARD. No capital fronting, no pool locks.
Step 2: Bot constructs parts and signs
Bot constructs receiver parts (all Express-routed parts have virtualProvider = 0x0):
Part 0: { amount: 400, expressProvider: EP, virtualProvider: 0x0 }
Part 1: { amount: 600, expressProvider: EP, virtualProvider: 0x0 }
Bot computes:
expressAmount = 1,000 (sum of parts routed through EP)
creditAmount = 0 (credit NOT supported for STANDARD)
affiliateAmount = 0 (no pool split is needed for STANDARD)
generalAmount = expressAmount - affiliateAmount = 1,000 (stored but no pool is debited)
feeBasis = expressAmount = 1,000
fee = 1,000 * 50 / 10,000 = 5 USDC
Decision: Sign STANDARD option with nonce=0, affiliateAmount=0, fee=5, availableAt=0
Step 3: Bot sees: WithdrawAccepted event (SYMMIO called onWithdrawRequest)
Bot reads on-chain:
- status = ACCEPTED (1), nonces[user] = 1
- lockedGeneralBalance = 8,000 (unchanged: STANDARD skips _lockFunds)
- lockedAffiliateBalances[affiliate] = 4,600 (unchanged: STANDARD skips _lockFunds)
NOTE: Pools are NOT locked for STANDARD.
The 1,000 USDC will arrive from SYMMIO only after the configured cooldown.
Decision: Schedule finalizeWithdrawRequest call at cooldownEndTime (T+12h). Wait.
What if risk is detected at T=30s?
LOCKER_ROLE calls lockWithdraw → Status = LOCKED.
processWithdraw now reverts for any caller.
Before finalization, UNLOCK_ROLE would get NotFinalized. Resolution is SYMMIO suspension before finalization, or
processing at cooldownEndTime (operator immediately, anyone after tolerance); after finalization UNLOCK_ROLE may
also call unlockAndProcess.
What if user cancels at T=5min?
STANDARD + ACCEPTED is cancellable. onWithdrawCancelRequest triggers _releaseWithdraw:
No pool unlocking needed (pools were never locked for STANDARD).
Status = CANCELLED. No pool changes.
Step 4: Bot sees: block.timestamp >= cooldownEndTime (T=12h)
Bot reads on-chain:
- status == ACCEPTED (still: no lock or cancel happened)
- cooldownEndTime = T+12h, block.timestamp >= cooldownEndTime
Bot checks: Is status still ACCEPTED or FINALIZED? ACCEPTED: need to finalize first.
Decision: Call SYMMIO.finalizeWithdrawRequest(user, reqId).
On-chain result:
SYMMIO transfers 1,000 USDC (expressAmount) to ExpressProvider.
SYMMIO calls onWithdrawComplete(req):
optionType == STANDARD, status == ACCEPTED → status = FINALIZED (4)
finalizedAt = block.timestamp
generalBalance: unchanged (STANDARD does NOT replenish: tokens are forwarded)
Step 5: Bot sees: WithdrawFinalized event
Bot reads on-chain:
- status = FINALIZED (4), finalizedAt = T+12h
- optionType = STANDARD → processableAt = finalizedAt
Bot checks: Am I OPERATOR_ROLE? YES → processableAt = finalizedAt (no tolerancePeriod).
block.timestamp >= processableAt? YES.
Decision: Call processWithdraw(user, reqId, parts) right away.
What if I'm NOT OPERATOR_ROLE?
processableAt = finalizedAt + tolerancePeriod = T+12h + 60s.
At T+12h: REVERT TooEarly. Must wait until T+12h+60s.
Fee cascading in transferToReceivers:
feeRemaining = 5
Part 0 (400, first receiver part):
deduction = min(5, 400) = 5
feeRemaining = 0
collateral.safeTransfer(receiver, 400 - 5 = 395)
Part 1 (600, second receiver part):
deduction = min(0, 600) = 0
collateral.safeTransfer(receiver, 600 - 0 = 600)
Pool updates: optionType == STANDARD → no generalBalance or affiliateBalance deduction.
collectedFees[affiliate] += 5
Status = PROCESSED (terminal for STANDARD: no further FINALIZED step)
Final accounting:
User receives: 395 + 600 = 995 USDC
Fees collected: 5 USDC
Total: 995 + 5 = 1,000 ✓
generalBalance = 10,000 (unchanged: STANDARD doesn't touch it)
affiliateBalances[affiliate] = 5,000 (unchanged: STANDARD doesn't touch it)
5.4 Credit-backed withdrawal
For SAME_TX and WINDOWED options, the bot can include a creditAmount in the signed option to draw from the
affiliate's credit line (configured on ControlFacet and processed by LibCreditLine within the
ExpressProvider diamond). This supplements pool liquidity:
generalAmount = expressAmount - affiliateAmount - creditAmount- Credit requires a valid Muon oracle attestation (
CreditData) - Credit is NOT supported for STANDARD (
CreditNotSupportedForStandarderror) - Credit debt follows the lifecycle: reserved → activated → settled (see Section 10)
When pool liquidity alone is insufficient for WINDOWED but the affiliate has eligible credit capacity, the bot can offer a credit-backed option:
Example: 500 USDC withdrawal
- affiliateAmount = 100 (from affiliate pool)
- creditAmount = 200 (from credit line)
- generalAmount = 200 (from general pool)
- expressAmount = 500 (total)
The bot MUST verify:
-
[ ] Credit line is configured:
creditLineSignatureVerifier()is nonzero and the intended Muon app ID and freshness window have been set -
[ ]
supportsMuonFunction(8)returnstruefor the configured verifier, and the intended registered TSS key and gateway signer are both authorized forMuonFunction.ExpressCredit. Capability and authorization are separate checks - [ ] Credit line is not paused:
expressProvider.creditLinePaused(affiliate) == false - [ ] User is not blacklisted:
expressProvider.creditLineBlacklisted(affiliate, user) == false -
[ ]
affiliateAmount + creditAmount <= expressAmount(else revertsFundingSplitExceedsExpress)
6. Processing & finalization
6.1 After acceptance
| Option | Schedule processWithdraw at | Schedule finalizeWithdrawRequest at |
|---|---|---|
| SAME_TX | N/A (already processed) | cooldownEndTime |
| WINDOWED | acceptedAt + securityWindow |
cooldownEndTime |
| STANDARD | After onWithdrawComplete |
cooldownEndTime |
6.2 Processing (processWithdraw)
-
[ ] Verify status is correct for the option type:
- WINDOWED: must be ACCEPTED (or LOCKED after cooldown)
- STANDARD: must be FINALIZED (or LOCKED after cooldown)
- [ ] Provide exact
partsarray (verified against storedpartsHash) -
[ ] Check timing:
- WINDOWED:
block.timestamp >= acceptedAt + securityWindow -
STANDARD:
block.timestamp >= finalizedAt(operator) or+ tolerancePeriod(anyone) -
LOCKED after cooldown:
block.timestamp >= cooldownEndTimefor OPERATOR_ROLE, orcooldownEndTime + tolerancePeriodfor anyone
- WINDOWED:
-
[ ] For LOCKED STANDARD without finalization:
processWithdrawcallsfinalizeWithdrawRequeston SYMMIO first - [ ] After successful processing: schedule
finalizeWithdrawRequestatcooldownEndTime
6.3 Finalization (finalizeWithdrawRequest on SYMMIO)
- [ ] Call on SYMMIO (not ExpressProvider)
- [ ] Must wait until
block.timestamp >= cooldownEndTime - [ ] SYMMIO transfers express-only token amounts to ExpressProvider
- [ ] SYMMIO calls
onWithdrawCompleteon ExpressProvider - [ ] Pools are replenished (WINDOWED/SAME_TX) or tokens arrive (STANDARD)
- [ ] Verify status becomes FINALIZED (or stays LOCKED for STANDARD)
6.4 Permissionless fallback
Processing timeline
gantt
title Processing Windows (WINDOWED example)
dateFormat X
axisFormat %s
section Operator Window
securityWindow (20s) :crit, 0, 20
Operator can process :active, 20, 80
section Anyone Window
tolerancePeriod (60s) :crit, 20, 80
Anyone can process :active, 80, 120
When anyone can process
| Option | Operator processableAt | Anyone processableAt |
|---|---|---|
| WINDOWED | acceptedAt + securityWindow |
acceptedAt + securityWindow + tolerancePeriod |
| STANDARD | finalizedAt |
finalizedAt + tolerancePeriod |
| LOCKED (after cooldown) | cooldownEndTime |
cooldownEndTime + tolerancePeriod |
Bot must handle
-
[ ] If a user calls
processWithdrawpermissionlessly, detectWithdrawProcessedevent and cancel bot's scheduled processing -
[ ] Anyone can call
finalizeWithdrawRequeston SYMMIO: bot should still schedule it but handle the case where it's already finalized - [ ] State sync: always check on-chain status before attempting actions
Numeric example: permissionless processing timeline
Scenario: Bot races against permissionless window across three option types
Setup:
securityWindow = 20s, tolerancePeriod = 60s
--- Sub-scenario 1: WINDOWED ---
Step 1: Bot sees: WithdrawAccepted(user, reqId, WINDOWED) at T=100
Bot reads on-chain: acceptedAt = 100, status = ACCEPTED
Bot checks: When can I (OPERATOR_ROLE) call processWithdraw?
- processableAt = acceptedAt + securityWindow = 100 + 20 = 120
- At T=119: too early (block.timestamp 119 < 120) -> would REVERT TooEarly
Decision: Schedule processWithdraw for T=120.
Step 2: Bot decides at T=120: Call processWithdraw now.
Bot checks: Am I still within operator exclusivity?
- Anyone's processableAt = 100 + 20 + 60 = 180
- Current time 120 < 180 -> yes, only OPERATOR_ROLE can process right now
Decision: Execute processWithdraw(user, reqId, parts). Bot has 60s of
exclusivity (T=120 to T=180) before anyone else can call it.
What if bot fails to process by T=180?
Any address can call processWithdraw at T=180.
Bot should monitor for WithdrawProcessed event and cancel its own task
if someone else processes it first.
--- Sub-scenario 2: STANDARD ---
Step 1: Bot sees: WithdrawFinalized(user, reqId) at T=43200
Bot reads on-chain: status = FINALIZED, finalizedAt = 43200
Bot checks: When can I call processWithdraw?
- processableAt = finalizedAt = 43200 for OPERATOR_ROLE
- STANDARD has no additional securityWindow (the configured core cooldown was the safety window)
Decision: Call processWithdraw right after: operator can process right now.
Step 2: Bot checks: What is the permissionless deadline?
- Anyone's processableAt = finalizedAt + tolerancePeriod = 43200 + 60 = 43260
- At T=43259: user would REVERT TooEarly
- At T=43260: anyone can process
Decision: Execute processWithdraw now. If bot misses T=43260, any user can
step in and complete the withdrawal permissionlessly.
--- Sub-scenario 3: LOCKED WINDOWED after cooldown ---
Step 1: Bot sees: block.timestamp approaching T=43200 (cooldownEndTime)
Bot reads on-chain: status = LOCKED, cooldownEndTime = 43200
Bot checks: isLockedAfterCooldown?
- At T=43199: now (43199) < cooldownEndTime (43200) -> no, still locked
processWithdraw would REVERT (not ACCEPTED and not isLockedAfterCooldown)
- At T=43200: now (43200) >= cooldownEndTime (43200) -> yes
processableAt = cooldownEndTime = 43200 for OPERATOR_ROLE
Decision: Schedule processWithdraw for T=43200.
Step 2: Bot checks: When does exclusivity end?
- Anyone's processableAt = cooldownEndTime + tolerancePeriod = 43200 + 60 = 43260
Decision: Execute at T=43200. Bot has 60s before permissionless fallback
opens at T=43260. If another party processes first, bot detects
WithdrawProcessed event and cancels its task.
7. Event monitoring
7.1 Event handling flow
flowchart TD
E[Event Received] --> T{Event Type?}
T -->|WithdrawAccepted| A1{optionType?}
A1 -->|SAME_TX| A2[No action needed\nAlready PROCESSED]
A1 -->|WINDOWED| A3["Schedule processWithdraw\nat acceptedAt + securityWindow"]
A1 -->|STANDARD| A5["Schedule core finalization at cooldownEndTime;\nafter WithdrawFinalized, process payout"]
T -->|WithdrawProcessed| B1["Schedule finalizeWithdrawRequest\nat cooldownEndTime"]
B1 --> B2[Cancel any pending\nprocessWithdraw schedule]
T -->|WithdrawLocked| C1[Cancel scheduled processWithdraw]
C1 --> C2[Alert admin/security team]
T -->|WithdrawUnlockedAndProcessed| D1["Schedule finalizeWithdrawRequest\nClear lock alert"]
T -->|WithdrawFinalized| E1{Was STANDARD?}
E1 -->|Yes| E2[Trigger processWithdraw]
E1 -->|No| E3[Cycle complete]
T -->|WithdrawCancelled| F1[Cancel ALL scheduled actions]
T -->|WithdrawSuspended| G1[Cancel ALL scheduled actions]
T -->|AffiliateConfigUpdated| H1[Update cached fee config]
H1 --> H2[Invalidate pending\nunsigned options]
T -->|MinValidatorSignaturesUpdated(affiliate)| I1[Update validator logic]
I1 --> I2[Check if pending\nsigs sufficient]
7.2 Events to monitor
| Event | Source | Bot Action |
|---|---|---|
WithdrawAccepted(user, requestId, optionType) |
ExpressProvider | Schedule processWithdraw at appropriate time (skip for SAME_TX) |
WithdrawProcessed(user, requestId) |
ExpressProvider |
Schedule finalizeWithdrawRequest on SYMMIO at cooldownEndTime. Cancel any pending
processWithdraw schedule
|
WithdrawLocked(user, requestId) |
ExpressProvider | Cancel scheduled processWithdraw. Alert admin. Monitor for resolution |
WithdrawUnlockedAndProcessed(user, requestId) |
ExpressProvider | Schedule finalizeWithdrawRequest. Clear lock alert |
WithdrawFinalized(user, requestId) |
ExpressProvider | Cycle complete. Update internal tracking. For STANDARD: trigger processWithdraw |
WithdrawCancelled(user, requestId) |
ExpressProvider | Cancel all scheduled actions for this withdrawal |
WithdrawSuspended(user, requestId) |
ExpressProvider | Cancel all scheduled actions for this withdrawal |
AffiliateConfigUpdated(affiliate, feeRate, operatorFee) |
ExpressProvider | Update cached fee config. Re-sign any pending options |
MinValidatorSignaturesUpdated(affiliate, min) |
ExpressProvider | Update validator gathering logic. If raised, unsubmitted SAME_TX/WINDOWED validator bundles may fail |
ValidatorApprovalTimeoutUpdated(affiliate, timeout) |
ExpressProvider | Update timestamp freshness check for this affiliate. Pending sigs may expire |
ValidatorUpdated(affiliate, validator, enabled) |
ExpressProvider | Update tracked validators for this affiliate. If disabled, pending sigs from that validator become invalid |
GeneralDeposit(depositor, amount) |
ExpressProvider | Update available liquidity tracking and attribute the funding address |
GeneralWithdraw(recipient, amount) |
ExpressProvider | Update available liquidity tracking and reconcile the payout recipient |
AffiliateDeposit(affiliate, depositor, amount) |
ExpressProvider | Update per-affiliate liquidity tracking and attribute the funding address |
AffiliateWithdraw(affiliate, recipient, amount) |
ExpressProvider | Update per-affiliate liquidity tracking and reconcile the payout recipient |
FeesClaimed(affiliate, recipient, amount) |
ExpressProvider | Reconcile the affiliate-fee payout with its actual recipient |
OperatorFeesClaimed(affiliate, recipient, amount) |
ExpressProvider | Reconcile the operator-fee payout with its actual recipient |
7.3 Idempotency requirements
- [ ] Handle duplicate events (same event emitted in re-org scenarios)
- [ ] Do not schedule duplicate
processWithdrawcalls for same (user, requestId) - [ ] Do not schedule duplicate
finalizeWithdrawRequestcalls - [ ] Detect if someone else (permissionless user) already processed the withdrawal
- [ ] If
WithdrawProcessedreceived for a withdrawal bot didn't process, cancel bot's scheduled processing
Numeric example: event sequence for WINDOWED with lock
Scenario: WINDOWED withdrawal accepted, then risk-locked before processing
Setup:
generalBalance = 5,000 USDC
lockedGeneralBalance = 1,200 USDC
securityWindow = 20s
tolerancePeriod = 60s
cooldownEndTime = T + 43,200s (12h)
User: 0xAlice, requestId: 5, generalAmount: 800 USDC
Step 1: Bot sees: WithdrawAccepted(0xAlice, 5, WINDOWED) at T=0s (block 100)
Bot reads on-chain:
withdrawInfos[Alice][5].status = ACCEPTED
withdrawInfos[Alice][5].acceptedAt = T
withdrawInfos[Alice][5].optionType = WINDOWED
Bot checks:
processableAt = acceptedAt + securityWindow = T + 20s
Current time T=0s < T+20s: too early to process
Decision: Schedule processWithdraw(Alice, 5, parts) for T+20s (~block 102)
Step 2: Bot sees: WithdrawLocked(0xAlice, 5) at T=10s (block 101)
Bot reads on-chain:
withdrawInfos[Alice][5].status = LOCKED
Bot checks:
Status is LOCKED: processWithdraw requires ACCEPTED or LOCKED-after-cooldown
T=10s is far before cooldownEndTime (T+43,200s): LOCKED-after-cooldown path unavailable
Decision: CANCEL scheduled processWithdraw for (Alice, 5)
Alert admin: "Withdrawal 5 for 0xAlice locked by LOCKER_ROLE at block 101"
What if bot ignores the lock and calls processWithdraw at T=20s?
status is LOCKED and block.timestamp (T+20s) < cooldownEndTime (T+43,200s)
-> Reverts: NotAccepted: bot wastes gas
Step 3: Bot sees: WithdrawUnlockedAndProcessed(0xAlice, 5) at T=1,000s (block 200)
Bot reads on-chain:
withdrawInfos[Alice][5].status = PROCESSED
Bot checks:
Status is PROCESSED: UNLOCK_ROLE already called unlockAndProcess (funds sent to user)
Bot does NOT need to call processWithdraw
cooldownEndTime = T + 43,200s: finalization still pending
Decision: Schedule finalizeWithdrawRequest(Alice, 5) at T+43,200s
Clear lock alert
What if UNLOCK_ROLE never acts?
Bot monitors: if block.timestamp >= cooldownEndTime and status is still LOCKED,
bot (OPERATOR_ROLE) can call processWithdraw: LOCKED-after-cooldown path applies
processableAt = cooldownEndTime; non-operators wait + tolerancePeriod (60s) extra
Step 4: Bot sees: cooldownEndTime reached at T=43,200s (~block 43400)
Bot reads on-chain:
withdrawInfos[Alice][5].status = PROCESSED
Bot checks:
Status is PROCESSED: eligible for finalization on SYMMIO
Decision: Call SYMMIO.finalizeWithdrawRequest(Alice, 5)
-> SYMMIO sends 800 USDC to ExpressProvider
-> onWithdrawComplete: generalBalance += 800 (5,000 - 800 + 800 = 5,000 restored)
-> Status becomes FINALIZED
Mark withdrawal cycle complete
8. Fee system
8.1 Fee calculation flow
flowchart TD
A["feeBasis = expressAmount"] --> B["fee = (feeBasis × feeRate) / 10,000"]
B --> C["operatorFee = affiliateConfigs[affiliate].operatorFee"]
C --> D["totalFee = fee + operatorFee"]
D --> E{On-chain validation}
E --> F["fee == (feeBasis × feeRate) / 10,000 ?"]
F -->|No| F1["REVERT: FeeMismatch"]
F -->|Yes| G["operatorFee == config.operatorFee ?"]
G -->|No| G1["REVERT: OperatorFeeMismatch"]
G -->|Yes| H["fee + operatorFee <= feeBasis ?"]
H -->|No| H1["REVERT: FeesExceedExpressAmount"]
H -->|Yes| K["totalFee <= maxUserFee ?"]
K -->|No| K1["REVERT: UserFeeExceedsMaximum"]
K -->|Yes| L[Fee accepted ✓]
STANDARD acceleration fee: when STANDARD is signed as an accelerate candidate, include
maxAccelerationFee in the original WithdrawOption. If the request is later accelerated, the fresh
AccelerateOffer includes the actual accelerationFee. The contract checks
accelerationFee <= maxAccelerationFee and
fee + operatorFee + accelerationFee <= expressAmount, then stores the fee as
info.accelerationFee before transfer. If the request is never accelerated, maxAccelerationFee is
never charged.
8.2 Fee deduction order (in transferToReceivers)
flowchart TD
A["feeRemaining = userFee"] --> B{Next express part?}
B -->|Yes| C["deduction = min(feeRemaining, part.amount)"]
C --> D["feeRemaining -= deduction"]
D --> E["netTransfer = part.amount - deduction"]
E --> G["Transfer(receiver, netTransfer)"]
G --> I{netTransfer == 0?}
I -->|Yes| J[Skip transfer]
I -->|No| B
B -->|No more parts| K[Done]
8.3 Bot must read before signing
- [ ]
affiliateConfigs(affiliate).feeRate: current fee rate - [ ]
affiliateConfigs(affiliate).operatorFee: current operator fee - [ ] If these change between signing and execution, the tx reverts
Numeric example: fee cascading across 3 parts
Scenario: Bot pre-computes fee distribution across 3 parts before signing
Setup:
affiliate = 0xFrontend
feeRate = 150 bps (1.5%)
operatorFee = 0 USDC
expressAmount = 1,000 USDC (all 3 parts combined, express-only)
Parts (order matters for fee cascading):
Part 0: { amount: 100 USDC, express-only, virtualProvider: 0x0, receiver: 0xA }
Part 1: { amount: 400 USDC, express-only, virtualProvider: 0x0, receiver: 0xB }
Part 2: { amount: 500 USDC, express-only, virtualProvider: 0x0, receiver: 0xC }
Step 1: Bot sees: withdraw request from Alice for 1,000 USDC across 3 parts
Bot reads on-chain:
affiliateConfigs[0xFrontend].feeRate = 150
affiliateConfigs[0xFrontend].operatorFee = 0
Bot checks:
feeBasis = expressAmount = 1,000 USDC
fee = (1,000 * 150) / 10,000 = 15 USDC
operatorFee = 0 USDC
totalFee = 15 + 0 = 15 USDC
fee + operatorFee (15) <= feeBasis (1,000)? YES
userFee = 15 - 0 = 15 USDC
Decision: Sign option with fee=15, operatorFee=0, maxUserFee=15
Step 2: Bot pre-computes: how will 15 USDC fee cascade across parts?
(Bot simulates transferToReceivers to verify receivers get expected amounts)
feeRemaining = 15
Part 0 (100 USDC, express-only, receiver 0xA):
deduction = min(15, 100) = 15
feeRemaining = 15 - 15 = 0
netTransfer = 100 - 15 = 85 USDC
-> collateral.safeTransfer(0xA, 85)
Receiver A gets: 85 USDC
Part 1 (400 USDC, express-only, receiver 0xB):
deduction = min(0, 400) = 0
feeRemaining = 0
netTransfer = 400 - 0 = 400 USDC
-> collateral.safeTransfer(0xB, 400)
Receiver B gets: 400 USDC
Part 2 (500 USDC, express-only, receiver 0xC):
deduction = min(0, 500) = 0
feeRemaining = 0
netTransfer = 500 - 0 = 500 USDC
-> collateral.safeTransfer(0xC, 500)
Receiver C gets: 500 USDC
Decision: Proceed: total received = 85 + 400 + 500 = 985 USDC (out of 1,000; 15 fee).
At payout, the 15 USDC is collected directly for unaccelerated STANDARD;
SAME_TX, WINDOWED, and accelerated payouts hold it in pendingFees until settlement.
What if feeRate were 1,000 bps (10%) instead?
fee = (1,000 * 1,000) / 10,000 = 100 USDC, userFee = 100
Part 0: deduction = min(100, 100) = 100, netTransfer = 0 -> skip (zero-amount)
Part 1: deduction = min(0, 400) = 0, netTransfer = 400 -> 0xB gets 400
Part 2: deduction = min(0, 500) = 0, netTransfer = 500 -> 0xC gets 500
Receiver A gets NOTHING: bot should warn user that small parts may be fully consumed by fees
What if feeRate changed on-chain between signing and tx execution?
Contract recomputes: fee == (feeBasis * newFeeRate) / 10,000
If different from signed fee -> Reverts: FeeMismatch
Bot's signed option becomes invalid: must re-sign with current rate
8.4 Fee escrow and claimability
Do not treat every retained fee as immediately claimable. Unaccelerated STANDARD writes its affiliate and operator fees
directly to collectedFees(affiliate) and collectedOperatorFees(affiliate) during payout. SAME_TX,
WINDOWED, and accelerated STANDARD write per-request pendingFees(user, requestId) and
pendingOperatorFees(user, requestId). onWithdrawComplete promotes those pending balances to the
collected balances; a processed suspension also promotes them before recording the rollback losses. Only collected balances
can be claimed.
9. Validator system
9.1 Validator attestation flow
This flow applies when the selected option invokes validator validation: always for SAME_TX, conditionally for WINDOWED, and never for STANDARD acceptance.
sequenceDiagram
participant Bot as Bot Service
participant V1 as Validator 1
participant V2 as Validator 2
participant U as User
participant S as SYMMIO
participant EP as ExpressProvider
Bot->>V1: Request attestation for (user, nonce, amount)
Bot->>V2: Request attestation for (user, nonce, amount)
V1->>V1: Check user legitimacy
V2->>V2: Check user legitimacy
V1-->>Bot: ValidatorApproval sig + timestamp
V2-->>Bot: ValidatorApproval sig + timestamp
Note over Bot: Sort sigs by signer address (ascending)
Note over Bot: Verify each timestamp postdates withdrawCooldownOf(user)
Bot->>U: Return option with validatorData
U->>S: initiateWithdraw(providerData)
S->>EP: onWithdrawRequest(req)
Note over EP: Decode validatorData
Note over EP: Check sig count >= minValidatorSignatures(affiliate)
Note over EP: Read withdrawCooldownOf(user) as the freshness floor
Note over EP: For each sig: check freshness, isValidator, no duplicates
9.2 When validators are required
| Condition | Validators Checked? |
|---|---|
| SAME_TX | Required, and the effective minimum must be greater than 0; otherwise acceptance reverts |
| WINDOWED | Checked only when the effective minValidatorSignatures(affiliate) > 0 |
| STANDARD | Never checked during acceptance, even when the configured minimum is greater than 0 |
9.3 ValidatorApproval EIP-712 structure
The on-chain VALIDATOR_APPROVAL_TYPEHASH (defined in LibAccessControl.sol) is the keccak256 of the
exact string:
ValidatorApproval(address user,uint256 nonce,uint256 amount,uint256 timestamp,address symmio)
user: withdrawing usernonce: same nonce as the WithdrawOption (the user's current nonce on ExpressProvider)-
amount:withdrawRequest.totalAmount(the full SYMMIO withdrawal request total, NOT the express portion: a suspend rolls back the entire request, so validators must sign the total amount) -
timestamp: when the validator signed; must be <=block.timestamp, withinvalidatorApprovalTimeout(affiliate), and strictly greater thanwithdrawCooldownOf(user)(the user's last balance credit) -
symmio: the SYMMIO core address bound into the signature; together with the EIP-712 domain separator (which bindsaddress(this)ExpressProvider) this prevents replay across deployments
9.4 Validation rules
- [ ]
signatures.length == timestamps.length(elseArrayLengthMismatch) -
[ ]
signatures.length >= minValidatorSignatures(affiliate)(elseInsufficientValidatorSignatures; falls back tominValidatorSignatures(address(0))default) - [ ] Read
ISymmio(symmio).withdrawCooldownOf(user)once as the freshness floor for every signature -
[ ] For each signature:
-
[ ]
timestamps[i] <= block.timestamp(no future-dating, elseValidatorApprovalExpired) -
[ ]
block.timestamp - timestamps[i] <= validatorApprovalTimeout(affiliate)(not stale, elseValidatorApprovalExpired; falls back tovalidatorApprovalTimeout(address(0))default) -
[ ]
timestamps[i] > ISymmio(symmio).withdrawCooldownOf(user): the approval must postdate the user's last balance credit (elseStaleValidatorApproval) -
[ ] Recovered signer passes
isValidator(affiliate, signer): accepts registration in either the affiliate-specific slot or theaddress(0)default slot (elseInvalidValidator) - [ ] Signers are sorted ascending by address (else
DuplicateValidator) - [ ] No duplicate signers
-
[ ]
9.5 Bot checklist for validators
-
[ ] Gather >=
minValidatorSignatures(affiliate)from distinct registered validators for this affiliate (oraddress(0)default) - [ ] Sort signatures by signer address (ascending) before encoding
- [ ] Verify each timestamp is recent (within
validatorApprovalTimeout(affiliate)of expected block time) - [ ] Verify the option still uses the current ExpressProvider
nonces(user)value signed by validators -
[ ] If
withdrawCooldownOf(user)advances because new funds enter the withdrawable balance: re-gather all attestations - [ ] If admin raises
minValidatorSignatures(affiliate): previously gathered sets may be insufficient - [ ] If admin reduces
validatorApprovalTimeout(affiliate): previously valid sigs may expire - [ ] Monitor
ValidatorUpdatedevents: disabled validators' pending sigs become invalid
9.6 Edge cases
| Edge Case | Result |
|---|---|
Exact expiry boundary (timestamp + timeout == block.timestamp) |
VALID (strict > check) |
| Future-dated timestamp | REJECTED (ValidatorApprovalExpired) |
| Same validator signs twice | REJECTED (DuplicateValidator) |
| Wrong amount in validator sig | REJECTED (InvalidValidator: recovered address not a registered validator) |
| Wrong nonce in validator sig | REJECTED (InvalidValidator: recovered address not a registered validator) |
| Validator disabled after signing | REJECTED at submission time |
| More sigs than minimum | All validated, extras must also be valid |
| Balance credit lands after signing | REJECTED (StaleValidatorApproval) |
| Affiliate has no per-affiliate validator config |
Zero numeric settings fall back to the address(0) minimum/timeout;
isValidator accepts the union of affiliate-specific and address(0) registrations
|
Wrong symmio address in signed payload |
REJECTED (InvalidValidator: recovered address mismatches due to typehash binding) |
Per-affiliate validator config: Validator policy is not one global value. The bot must read validator status,
signature threshold, and approval timeout for the withdrawal's affiliate; if that affiliate has no custom value, the contract
falls back to the address(0) defaults. This lets one affiliate require stricter validator coverage without
forcing the same rule on every integration.
Numeric example: validator attestation for SAME_TX
Scenario: Bot gathers validator sigs, checks timestamps, decides if enough valid sigs
Setup:
minValidatorSignatures(affiliate) = 2
validatorApprovalTimeout(affiliate) = 30s
Validators registered for this affiliate (or address(0) default):
V1 at 0xAAA...1
V2 at 0xBBB...2
V3 at 0xCCC...3
User: 0xAlice
nonces[Alice] = 5 (ExpressProvider nonce)
withdrawCooldownOf(Alice) = 80 (timestamp of Alice's last balance credit)
totalAmount = 1,000e6 (1,000 USDC: the full SYMMIO request total; validators sign this, NOT the express portion)
Step 1: Bot sees: Alice requests SAME_TX withdrawal of 1,000 USDC
Bot reads on-chain:
minValidatorSignatures(affiliate) = 2
validatorApprovalTimeout(affiliate) = 30s
nonces[Alice] = 5
withdrawCooldownOf(Alice) = 80
Bot checks:
SAME_TX requires minValidatorSignatures(affiliate) > 0? YES (2 > 0): validators enabled
Need >= 2 valid signatures from distinct registered validators
Decision: Request attestations from V1, V2 (and optionally V3 as backup)
Step 2: Bot sees: validator responses arrive
V1 responds at T=100: signs ValidatorApproval(Alice, 5, 1000e6, 100, symmio) -> sig_V1
V2 responds at T=102: signs ValidatorApproval(Alice, 5, 1000e6, 102, symmio) -> sig_V2
Bot checks each response:
V1 timestamp 100: is it in the past? YES (current time ~102)
V2 timestamp 102: is it in the past? YES
Both used nonce=5 matching nonces[Alice]? YES
Both timestamps > withdrawCooldownOf(Alice) (80)? YES: 100 and 102 both postdate the last credit
Both used amount=1000e6 matching withdrawRequest.totalAmount (full request)? YES
Both used symmio = ExpressProvider's configured SYMMIO address? YES
Count: 2 valid sigs >= minValidatorSignatures(affiliate) (2)? YES
Decision: Sort by signer address for encoding
0xAAA...1 < 0xBBB...2 -> order: [sig_V1, sig_V2], timestamps: [100, 102]
Encode: validatorData = abi.encode([sig_V1, sig_V2], [100, 102])
Step 3: Bot checks: will sigs still be valid when user submits?
Bot estimates user will submit at ~T=115 (13s from now)
Bot checks freshness for each sig at T=115:
V1: 115 - 100 = 15s <= 30s (timeout)? YES: still valid
V2: 115 - 102 = 13s <= 30s (timeout)? YES: still valid
Decision: Return signed option + validatorData to Alice
What if user delays until T=135?
V1: 135 - 100 = 35s > 30s -> EXPIRED
-> Reverts: ValidatorApprovalExpired
-> Bot should warn user: "submit within 28s or sigs expire"
Step 4: Bot considers: what if new funds reach Alice's withdrawable balance before submission?
Bot reads on-chain (just before returning option to Alice):
withdrawCooldownOf(Alice) = 80 (unchanged) -> safe to proceed
What if Alice deallocates realized PnL from a virtual account after receiving the option?
internalTransferToBalance stamps deallocateTimestamp[Alice] = 110
On submission: contract requires each timestamp > withdrawCooldownOf(Alice) (110)
100 <= 110 and 102 <= 110 -> Reverts: StaleValidatorApproval
-> Bot must re-gather ALL attestations so validators can vet the newly added funds
-> Previous validator sigs are permanently invalidated (they predate the credit)
What if V2 is disabled via setValidator(affiliate, V2, false) between T=102 and submission?
On submission: contract recovers signer from sig_V2, checks isValidator(affiliate, V2)
V2 is no longer a registered validator -> Reverts: InvalidValidator
-> Bot monitors ValidatorUpdated events; if V2 disabled, re-gather with V3 replacing V2
What if bot sends only 1 sig (from V1)?
signatures.length (1) < minValidatorSignatures(affiliate) (2)
-> Reverts: InsufficientValidatorSignatures
-> Bot must always gather at least minValidatorSignatures(affiliate) before returning option
10. Credit line system
10.1 VirtualProvider is replaced by credit line
Earlier fast-withdrawal wiring used virtualProvider inside each withdrawal part. v0.8.6 does not route Express
liquidity through that field. The funding split is signed explicitly as affiliateAmount and
creditAmount, and LibParts rejects any Express-owned part whose virtualProvider is
nonzero.
For the bot, every part routed through this ExpressProvider must set virtualProvider to address(0).
Credit capacity is handled by the signed option and LibCreditLine, not by deploying or monitoring VirtualProvider
contracts.
// In LibParts.computeAmounts and transferToReceivers:
if (parts[i].virtualProvider != address(0)) revert LibErrors.VirtualProviderMustBeZero();
-
[ ] Never set
virtualProvideron an ExpressProvider-owned part: useaddress(0) - [ ] Do not use VirtualProvider monitoring to account for ExpressProvider liquidity or credit
- [ ] Treat core virtual-provider routes as a separate withdrawal mechanism, not an Express funding source
10.2 Credit debt lifecycle
Each credit-backed withdrawal tracks a debt through five possible lifecycle operations. All credit operations live in
LibCreditLine; the internal library operations are reserveDebt, activate,
settle, releaseReservation, and coverLoss.
stateDiagram-v2
[*] --> Reserved : reserveDebt\nreservedDebt += amount
Reserved --> Active : activate\nreservedDebt -= amount\nactiveDebt += amount\nISymmio.advanceWithdraw
Active --> Settled : settle\nactiveDebt -= amount\ndelete requestDebt
Reserved --> Released : releaseReservation\nreservedDebt -= amount\ndelete requestDebt
Active --> LossCovered : coverLoss\naffiliatePool -= covered\nbadDebt += uncovered\nactiveDebt -= amount
Settled --> [*]
Released --> [*]
LossCovered --> [*]
reserveDebt: reservedDebt += creditAmount (on acceptance, before payout)
activate: reservedDebt -= amount, activeDebt += amount, calls ISymmio.advanceWithdraw (on processing, funds advanced)
settle: removes from active (or reserved if not yet activated) and deletes requestDebt[key] (on finalization, debt cleared)
releaseReservation: reservedDebt -= amount, delete requestDebt[key] (pre-payout cancel)
coverLoss: affiliateBalances[affiliate] -= covered amount, badDebt += uncovered amount, then internal _settleDebt (post-payout rollback)
Who calls what:
| Lifecycle event | Trigger | Called by |
|---|---|---|
reserveDebt |
onWithdrawRequest (acceptance): validates Muon, checks caps, increments reservedDebt |
SymmioHookFacet via LibCreditLine.reserveDebt |
activate |
processWithdraw / unlockAndProcess / inline SAME_TX in
onWithdrawRequest: moves reserved -> active and calls ISymmio.advanceWithdraw
|
OperatorFacet / SymmioHookFacet via LibCreditLine.activate |
settle |
onWithdrawComplete (finalization): removes from active and clears request state |
SymmioHookFacet via LibCreditLine.settle |
releaseReservation |
onWithdrawCancelRequest (pre-payout, ACCEPTED only): removes from reservedDebt |
SymmioHookFacet via LibCreditLine.releaseReservation |
coverLoss |
onWithdrawSuspend when status was PROCESSED (post-payout rollback): deducts from affiliate pool
to absorb the loss and then settles
|
SymmioHookFacet via LibCreditLine.coverLoss |
Key invariant:
expressProvider.creditLineTotalDebt(affiliate) == creditLineReservedDebt(affiliate) +
creditLineActiveDebt(affiliate)
Credit is NOT supported for STANDARD withdrawals. The contract reverts
CreditNotSupportedForStandard if offer.creditAmount > 0 and
offer.optionType == STANDARD.
10.3 Bot monitoring for credit lines
All credit line logic lives inside the ExpressProvider diamond via LibCreditLine and is invoked from
SymmioHookFacet, AccelerateFacet, and OperatorFacet. Credit line state is stored in
CreditLineStorage (diamond storage, per-affiliate via mappings). There is no separate deployment per affiliate;
all config is done on the diamond with affiliate-keyed functions.
ControlFacet exposes seven SETTER_ROLE credit/cap functions: five credit-line setters (including
pause and blacklist) plus setCapChangeFeeConfig and setCapChangeQuotaConfig. Affiliate self-service
and bad-debt repayment have separate caller rules described in Section 17.3. All credit line read functions
(creditLineTotalDebt, creditLineReservedDebt, creditLineActiveDebt,
creditLineSignatureVerifier, creditLineMuonAppId, creditLineMuonFreshnessWindow,
creditLineProtocolMaxDebt, creditLineProtocolMaxDebtBps, creditLineAffiliateMaxDebt,
creditLineAffiliateMaxDebtBps, creditLineBadDebt, creditLineRequestDebt,
creditLineRequestActivated, creditLinePaused, creditLineBlacklisted) live on
ViewFacet. All are called on the diamond address.
-
[ ] Check
creditLineTotalDebt(affiliate)andcreditLineBadDebt(affiliate):creditLineTotalDebtis reserved + active debt only. Cap checks also include bad debt, so usereserved + active + badDebtwhen estimating remaining capacity. -
[ ] Check
creditLinePaused(affiliate): iftrue, allreserveDebtcalls and later credit activation attempts revertCreditLinePaused. The bot must not sign or process options withcreditAmount > 0for this affiliate. -
[ ] Check
creditLineBlacklisted(affiliate, user): iftruefor the requesting user,reserveDebtrevertsUserBlacklisted. The bot must reject credit for blacklisted users. -
[ ] Monitor debt cap headroom:
-
protocolMaxDebtandaffiliateMaxDebt: absolute caps (0 = no limit). The effective cap is the tighter (non-zero minimum) of the two. -
protocolMaxDebtBpsandaffiliateMaxDebtBps: percentage caps as basis points of MuoneligibleBase(0 = no limit). Same tighter-of-two logic. -
New debt is allowed only if
creditLineTotalDebt(affiliate) + creditLineBadDebt(affiliate) + creditAmount <= effectiveMaxDebtANDcreditLineTotalDebt(affiliate) + creditLineBadDebt(affiliate) + creditAmount <= eligibleBase * effectiveMaxBps / 10000.
-
-
[ ]
Monitor
creditLineReservedDebt(affiliate)vscreditLineActiveDebt(affiliate)ratio A high reserved debt means many accepted-but-not-yet-processed credit withdrawals. This is normal during the security window but may indicate processing delays if it persists. -
[ ] Listen for credit line events on the ExpressProvider diamond to maintain an accurate local state:
DebtReserved(affiliate, user, requestId, amount): new credit acceptedDebtActivated(affiliate, user, requestId, amount): credit advanced to userDebtSettled(affiliate, user, requestId, amount): active credit debt cleared on finalizationDebtCancelled(affiliate, user, requestId, amount): credit released on cancelBadDebtAccrued(affiliate, user, requestId, amount): uncovered credit loss recordedCreditBadDebtRepaid(affiliate, payer, amount): affiliate credit bad debt funded and reducedCreditLinePausedUpdated(affiliate, bool): credit line paused/unpausedCreditLineUserBlacklistUpdated(affiliate, user, bool): user blacklist change
-
[ ] Alert on approaching caps: when
creditLineTotalDebt(affiliate) + creditLineBadDebt(affiliate)exceeds 80% ofeffectiveMaxDebt, alert the affiliate operator
10.4 How credit lines work
Credit lines let the ExpressProvider pay more than its general and affiliate pool portions. The credit portion is advanced
from SYMMIO core via advanceWithdraw. The affiliate pool is a ledger loss absorber on post-payout suspension, but
the contract creates no lien or bond over it.
Muon oracle attestation: Before accepting a credit-backed withdrawal, the contract verifies a Muon-signed
CreditData struct:
struct CreditData {
bytes reqId; // Muon request identifier
uint256 eligibleBase; // Muon-computed aggregate eligible balance for the affiliate
uint256 timestamp; // when the Muon oracle produced this attestation
bytes gatewaySignature; // Muon gateway signature
IMuonSignatureVerifier.SchnorrSign sigs; // Schnorr signature for verification
}
Muon supplies the off-chain-computed eligibleBase; haircut policy stays off-chain. The contract verifies:
-
Freshness: reject a future timestamp, then require
block.timestamp <= data.timestamp + muonFreshnessWindow. Initialization leaves the window at zero; deployment must configure it. Invalid timestamps revertMuonSignatureExpired. -
Schnorr signature: The hash bound for verification is:
keccak256(abi.encodePacked(muonAppId, reqId, affiliate, eligibleBase, timestamp, chainId, address(this), symmio))whereaddress(this)is the ExpressProvider diamond andsymmiois the configured SYMMIO core address. Binding both addresses prevents cross-deployment replay. Invalid signatures revert in theMuonSignatureVerifier. -
Debt caps: Both absolute and percentage caps are checked against
totalDebt + badDebt + creditAmount.
Flow during acceptance (onWithdrawRequest):
-
Bot signs option with
creditAmount > 0and provides encodedCreditDataascreditDataRaw. SymmioHookFacetcallsLibCreditLine.reserveDebt.-
reserveDebtverifies pause/blacklist, Muon signature, and caps. RecordsrequestDebt[key] = creditAmount, incrementsreservedDebtinCreditLineStorage.
Flow during processing (processWithdraw, unlockAndProcess, or SAME_TX inline):
LibCreditLine.activatemoves debt from reserved to active withinCreditLineStorage.-
LibCreditLine.activatecallsSYMMIO.advanceWithdraw(user, requestId, creditAmount): SYMMIO transferscreditAmountof collateral to the ExpressProvider, which can then pay the user.
Flow during finalization (onWithdrawComplete):
- SYMMIO sends back the non-credit portion of the withdrawal.
LibCreditLine.settleclears active debt and deletes the record inCreditLineStorage.
Flow on cancellation (before payout, ACCEPTED only):
-
LibCreditLine.releaseReservationdecrementsreservedDebtand deletes the record inCreditLineStorage.
Credit loss on post-payout rollback: If a withdrawal is suspended after processing (Status = PROCESSED), the
credit amount has already been advanced and paid to the user. LibCreditLine.coverLoss deducts up to the unlocked
affiliate balance, records any uncovered deficit as badDebt, and then internally settles the active debt to clear
the request record. Only onWithdrawSuspend reaches this path: forceCancelWithdraw /
onForceWithdrawCancel are not part of the current interface.
10.5 Bot decision logic for credit
On withdrawal request with creditAmount > 0:
1. Verify: optionType != STANDARD (credit not supported)
2. Read credit line state on the diamond:
- expressProvider.creditLinePaused(affiliate) == false
- expressProvider.creditLineBlacklisted(affiliate, user) == false
- currentDebt = expressProvider.creditLineTotalDebt(affiliate) + expressProvider.creditLineBadDebt(affiliate)
3. Estimate cap headroom (requires knowing eligibleBase from Muon):
- effectiveMaxDebt = tighter_of(protocolMaxDebt, affiliateMaxDebt)
- effectiveMaxBps = tighter_of(protocolMaxDebtBps, affiliateMaxDebtBps)
- absoluteOk = effectiveMaxDebt == 0 || currentDebt + creditAmount <= effectiveMaxDebt
- percentOk = effectiveMaxBps == 0 || currentDebt + creditAmount <= eligibleBase * effectiveMaxBps / 10000
4. If all checks pass: sign the option and include fresh CreditData
5. If any check fails: reject credit, or sign with creditAmount = 0
Numeric example: WINDOWED withdrawal with credit line
Scenario: 500 USDC WINDOWED withdrawal, 200 USDC backed by credit line
Setup:
Affiliate: 0xAffiliate
affiliateConfigs[0xAffiliate] = { feeRate: 100 (1%), operatorFee: 0 }
ExpressProvider pools:
generalBalance = 2,000 USDC
affiliateBalances[0xAffiliate] = 500 USDC
Credit line state (read via ViewFacet getters on the diamond, keyed by 0xAffiliate):
creditLineReservedDebt(0xAffiliate) = 0, creditLineActiveDebt(0xAffiliate) = 0
creditLineBadDebt(0xAffiliate) = 0, muonFreshnessWindow = 60s
protocolMaxDebt = 10,000 USDC, affiliateMaxDebt = 5,000 USDC
creditLinePaused(0xAffiliate) = false, creditLineBlacklisted(0xAffiliate, 0xAlice) = false
Step 1: Bot sees: withdrawal request for 500 USDC from 0xAlice
Part 0: { amount: 500e6, expressProvider: EP, virtualProvider: address(0), receiver: 0xAlice }
Bot decides to use credit for 200 USDC of the 500 USDC total.
Bot computes funding split:
creditAmount = 200e6
affiliateAmount = 100e6 (from affiliate pool)
generalAmount = 200e6 (from general pool, computed as 500 - 100 - 200)
expressAmount = 500e6 (total across all parts for this EP)
Bot computes fee:
fee = 500 * 100 / 10,000 = 5 USDC (1% of 500)
operatorFee = 0
userFee = 5 USDC
Bot reads credit line state on the diamond:
expressProvider.creditLinePaused(0xAffiliate) = false -> OK
expressProvider.creditLineBlacklisted(0xAffiliate, 0xAlice) = false -> OK
expressProvider.creditLineTotalDebt(0xAffiliate) = 0 -> headroom = 5,000 (affiliateMaxDebt)
0 + 200 = 200 <= 5,000 -> within absolute cap -> OK
Bot obtains fresh CreditData from Muon oracle:
eligibleBase = 50,000 USDC, timestamp = now - 10s
effectiveMaxBps = min(protocolMaxDebtBps, affiliateMaxDebtBps): say 1000 (10%)
200 <= 50,000 * 1000 / 10,000 = 5,000 -> within percentage cap -> OK
Decision: Sign WINDOWED option with creditAmount = 200e6 and include CreditData.
Step 2: On-chain acceptance (SymmioHookFacet.onWithdrawRequest):
Contract validates parts: virtualProvider == address(0) -> OK
Contract checks: optionType == WINDOWED, creditAmount > 0 -> not STANDARD -> OK
Contract locks pools before reserving credit:
generalBalance remains 2,000; lockedGeneralBalance: 0 -> 200
affiliateBalances[0xAffiliate] remains 500; lockedAffiliateBalances[0xAffiliate]: 0 -> 100
LibCreditLine.reserveDebt(0xAffiliate, 0xAlice, reqId=7, 200e6, creditData):
Verifies: not paused, not blacklisted -> OK
Verifies: timestamp is not future-dated and timestamp + muonFreshnessWindow >= block.timestamp -> fresh -> OK
Verifies: Muon Schnorr signature (hash binds affiliate, address(this), and symmio) -> valid -> OK
Verifies: 0 + 200e6 <= 5,000e6 (effective absolute cap) -> OK
Verifies: 0 + 200e6 <= 50,000e6 * 1000 / 10,000 = 5,000e6 -> OK
CreditLineStorage state after:
requestDebt[key] = 200e6
reservedDebt(0xAffiliate) = 200e6
activeDebt(0xAffiliate) = 0
Emits WithdrawAccepted(0xAlice, 7, WINDOWED)
Step 3: Bot processes after security window (20s):
Bot checks: block.timestamp >= acceptedAt + 20s -> YES
Bot checks: withdrawInfos[0xAlice][7].status == ACCEPTED -> YES
Decision: Call processWithdraw(0xAlice, 7, parts).
Contract executes:
a) Unlock and deduct pools:
lockedGeneralBalance: 200 -> 0; generalBalance: 2,000 -> 1,800
lockedAffiliateBalances: 100 -> 0; affiliateBalances: 500 -> 400
b) LibCreditLine.activate(symmio, 0xAlice, 7, info):
Internal _activateDebt(0xAffiliate, 0xAlice, 7):
requestActivated[key] = true
reservedDebt(0xAffiliate): 200e6 -> 0
activeDebt(0xAffiliate): 0 -> 200e6
Then ISymmio(symmio).advanceWithdraw(0xAlice, 7, 200e6):
SYMMIO transfers 200 USDC to ExpressProvider
(these are locked funds released early from SYMMIO's withdrawal escrow)
c) transferToReceivers with userFee = 5e6:
Part 0 (500e6, EP, receiver 0xAlice):
deduction = min(5e6, 500e6) = 5e6
feeRemaining = 0
5 USDC stays in EP because it is deducted from the outgoing transfer and recorded as pending
collateral.transfer(0xAlice, 495e6): 495 USDC to user
ExpressProvider state after processing:
generalBalance = 1,800 (the 200 general portion was spent)
affiliateBalances[0xAffiliate] = 400
pendingFees[0xAlice, 7] = 5e6
Credit line: reservedDebt(0xAffiliate) = 0, activeDebt(0xAffiliate) = 200e6
Status -> PROCESSED
Where did the 500 USDC come from?
200 USDC from general pool
100 USDC from affiliate pool
200 USDC from SYMMIO advance (credit)
Total: 500 USDC paid to user (minus 5 USDC fee = 495 USDC received)
Step 4a: Finalization (happy path, until cooldownEndTime later):
SYMMIO finalizes: sends back the non-advanced portion (500 - 200 = 300 USDC)
to the ExpressProvider via onWithdrawComplete.
Contract replenishes pools:
generalBalance: 1,800 + 200 = 2,000 (restored)
affiliateBalances[0xAffiliate]: 400 + 100 = 500 (restored)
pendingFees[0xAlice, 7] is promoted into collectedFees[0xAffiliate]
LibCreditLine.settle(0xAlice, 7, info):
Internal _settleDebt(0xAffiliate, 0xAlice, 7):
activeDebt(0xAffiliate): 200e6 -> 0
delete requestDebt[key]
delete requestActivated[key]
Final credit line state: reservedDebt(0xAffiliate) = 0, activeDebt(0xAffiliate) = 0: fully cleared.
Status -> FINALIZED
Step 4b: Cancellation before processing (alternative to step 3):
Bot sees: onWithdrawCancelRequest for 0xAlice, request 7 (only valid from ACCEPTED)
Contract calls LibCreditLine.releaseReservation(0xAlice, 7, info):
Internal _cancelReservation(0xAffiliate, 0xAlice, 7):
reservedDebt(0xAffiliate): 200e6 -> 0
delete requestDebt[key]
Contract unlocks pools:
lockedGeneralBalance: 200 -> 0; generalBalance remains 2,000
lockedAffiliateBalances[0xAffiliate]: 100 -> 0; affiliateBalances remains 500
Credit line state: reservedDebt(0xAffiliate) = 0, activeDebt(0xAffiliate) = 0: fully cleared, no loss.
Status -> CANCELLED
Step 4c: Post-payout rollback (suspend after PROCESSED, rare):
If SYMMIO calls onWithdrawSuspend AFTER processing (status was PROCESSED):
The 200 USDC credit advance was already paid to 0xAlice.
SYMMIO will not send those funds on finalization (they were already advanced).
LibCreditLine.coverLoss(collateral, symmio, 0xAlice, 7, info):
affiliateBalances[0xAffiliate] -= 200e6 (affiliate pool absorbs the loss)
Internal _settleDebt(0xAffiliate, 0xAlice, 7):
activeDebt(0xAffiliate): 200e6 -> 0
delete requestDebt[key]
pendingFees is promoted to collectedFees; generalBadDebt increases by generalAmount (200 USDC).
The 200 USDC credit loss comes from the affiliate pool; neither pool is replenished.
Note: forceCancelWithdraw / onForceWithdrawCancel are not part of the current interface;
only onWithdrawSuspend can reach this rollback path.
10.6 Cap-aware accelerate polling (STANDARD → WINDOWED)
When the credit cap is full at options-API time, the bot can sign a STANDARD offer to avoid Express pool-liquidity and
credit-cap rejection; all other initiateWithdraw and Express acceptance checks still apply. The UI tells the user
they may have to wait until cooldownEndTime. The request is kept alive for promotion: every ~30 minutes the bot
re-checks cap headroom and, if possible, submits accelerateWithdraw with a fresh bot signature to pay the user
without waiting for cooldown.
See expressWithdrawLayer/facets/Accelerate/AccelerateFacet.sol and design doc §5.4 for the on-chain contract.
Bot responsibilities:
- Decision at sign time. If the user would have qualified for WINDOWED but the cap is full, sign STANDARD and mark the request as an accelerate candidate in the bot's state store.
if user-requested-speed == STANDARD:
sign STANDARD (normal path)
elif reservedDebt + activeDebt + badDebt + desiredCreditAmount > effectiveMaxDebt
or similar bps-cap check fails:
sign STANDARD (creditAmount = 0)
enqueue(user, requestId) for accelerate retry
else:
sign WINDOWED (normal path)
-
Retry loop (every ~30 minutes). For each enqueued
(user, requestId):-
Read
ViewFacet.getWithdrawInfo(user, requestId). Ifstatus != ACCEPTEDoroptionType != STANDARD, remove from the acceleration queue (the request may still need other lifecycle handling). -
Check
block.timestamp + safetyMargin < info.cooldownEndTime(e.g., 10 minutes). Otherwise remove from queue and let STANDARD finalize normally; addition avoids subtraction underflow after the cooldown. -
Compute current headroom via
ViewFacet.creditLineReservedDebt(affiliate) + creditLineActiveDebt(affiliate) + creditLineBadDebt(affiliate)vs.creditLineProtocolMaxDebt/creditLineAffiliateMaxDebt/ bps equivalents. If still insufficient, leave in queue. - Obtain a fresh Muon
eligibleBaseattestation. - Read
ViewFacet.accelerateNonce(user, requestId). -
Price
accelerationFeefrom the current acceleration timing, usually based on remaining cooldown (info.cooldownEndTime - block.timestamp), and make sure it does not exceedinfo.maxAccelerationFee. -
Sign a new
AccelerateOffer(see design doc §7.5) with the current nonce andaccelerationFee. -
If
minValidatorSignatures(affiliate) > 0, collect a fresh quorum ofValidatorAccelerateApprovalsignatures over(user, requestId, partsHash)and encode them asvalidatorData. Each must postdatewithdrawCooldownOf(user), so re-collect them if the user deallocates between attempts. -
Submit
accelerateWithdraw(user, requestId, parts, offerData, validatorData, creditDataRaw)from the bot account (or the affiliate's relay).
-
Read
-
Affiliate manual trigger. When the affiliate address raises
affiliateMaxDebtby callingControlFacet.setMyCreditLineConfig(maxDebt, maxDebtBps), its backend can either:- Send a webhook to the bot to trigger an on-demand re-check instead of waiting for the next 30-minute tick, or
-
Fetch a fresh
AccelerateOfferfrom the bot's API and have its own backend submitaccelerateWithdraw. The contract is permissionless, so any sender works: but when the affiliate requires validators, the submission must carry a freshvalidatorDataquorum alongside the bot signature.
-
Stop conditions. Remove from the retry queue on any of:
WithdrawAccelerated(user, requestId, ...)emittedWithdrawFinalized/WithdrawCancelled/WithdrawSuspendedemittedblock.timestamp + safetyMargin >= info.cooldownEndTime
-
Nonce handling.
accelerateNonces[user][requestId]is incremented only on successful acceleration. A stale offer (old nonce after a successful prior acceleration) reverts withInvalidAccelerateNonce; re-fetch and re-sign. Failed retries (cap still full, pool short) do NOT consume the nonce, so the same signature can be retried while its deadline and request preconditions remain valid. -
Idempotency. The retry loop must be safe to run concurrently across multiple bot workers. Use a per-request lock (database-level or in-memory) keyed by
(user, requestId)before signing and submitting, and treatInvalidAccelerateNonce/AccelerateOnlyFromStandardAcceptedas benign (another worker beat us to it).
Failure modes:
| Revert reason | What happened | Bot action |
|---|---|---|
AccelerateOnlyFromStandardAccepted |
Request already accelerated, cancelled, suspended, or finalized | Remove from queue |
StandardAlreadyFinalized |
Core finalized the STANDARD path first | Remove from queue |
AccelerateCooldownElapsed |
On-chain cooldown boundary reached; acceleration is no longer allowed | Remove from queue |
AccelerateOfferExpired |
Offer deadline passed | Re-sign with a longer deadline |
DebtExceedsAbsoluteCap / DebtExceedsPercentCap |
Cap still full | Keep in queue, retry next cycle |
InsufficientGeneralBalance / InsufficientAffiliateBalance |
Pools drained | Either re-sign with a smaller pool split (more credit) or wait |
AccelerationFeeExceedsMaximum |
Current acceleration price is above the user's original STANDARD authorization | Do not accelerate at that price; wait for a cheaper window or let STANDARD finalize normally |
FeesExceedExpressAmount |
Base fee, operator fee, and acceleration fee exceed the express amount | Re-price below the withdrawal amount or let STANDARD finalize normally |
InvalidAccelerateNonce |
Stale nonce | Re-read accelerateNonce and re-sign |
Observability: emit metrics for:
- Queue size and per-request age
- Retry attempt count per request
- Success rate (accelerations / accelerate candidates signed)
- Time-from-accept-to-acceleration distribution
- Requests that timed out without acceleration (hit cooldown safety margin)
10.7 Affiliate cap self-service & fee
Cap changes matter to the bot because they decide whether queued STANDARD withdrawals can be accelerated. When a credit cap is
full, the bot may accept the request as STANDARD so the user transaction succeeds; if the affiliate raises capacity before
cooldown ends, that same request can be promoted through accelerateWithdraw. v0.8.6 lets affiliates adjust their
own caps with setMyCreditLineConfig, while a quota and fee throttle discourages repeated cap churn.
Decreases are always free. When a frontend needs to tighten risk (e.g., an anomaly spike, a pool running low,
a governance decision), it should call setMyCreditLineConfig with lower values right away. The on-chain
classifier treats "neither dimension loosened" as a decrease: no fee, no counter bump, no delay.
Increases are throttled and can cost fees. Each affiliate gets maxFreePerWindow free increases
per windowDuration. Once the quota is exhausted in the current window, the next increase pulls
feeAmount of the protocol-configured fee token from the affiliate's wallet.
Before submitting a cap change, a frontend should:
- Read
capChangeQuotaConfig()→(maxFreePerWindow, windowDuration). - Read
capChangeAffiliateState(affiliate)→(count, epochStart, remainingFree, nextResetAt). - If
windowDuration == 0, the throttle is disabled and every increase is free. - If the window is active and
remainingFree > 0, the call is free. -
If the window is active and
remainingFree == 0, readcapChangeFeeConfig()→(feeToken, feeAmount, feeReceiver)and show the user the fee they'll pay. Confirm, then approvefeeTokenfor the diamond and submitsetMyCreditLineConfig. -
Classifier sanity: if the user is tightening caps, the call is always free regardless of
remainingFree. No fee is pulled even if the quota is exhausted.
Admin changes bypass throttle. A protocol admin with SETTER_ROLE can still call
setCreditLineAffiliateConfig(affiliate, ...) for emergencies. Indexers monitoring cap state should subscribe to
both:
-
CreditLineAffiliateConfigSelfUpdated(affiliate, maxDebt, maxDebtBps, wasDecrease, feePaid): affiliate self-service calls CreditLineAffiliateConfigUpdated(affiliate, maxDebt, maxDebtBps): emitted by both paths
The second event is a superset; the first carries the extra classification and fee-paid context. For pure cap tracking, the second is sufficient.
Failure modes:
| Revert reason | Meaning | Frontend action |
|---|---|---|
NoOpCapChange |
Values unchanged | Don't submit a no-op |
AffiliateLimitExceedsProtocol |
Requested cap above protocol ceiling | Show user the protocol ceiling and let them pick a smaller value |
CapChangeFeeNotConfigured |
Admin set quota but not fee config | Wait for admin to finish configuration; show "cap changes temporarily unavailable" |
ERC20InsufficientAllowance / generic SafeERC20 revert |
Fee token not approved for diamond | Prompt user to approve the fee token, then retry |
ERC20InsufficientBalance |
Affiliate wallet is out of fee token | Surface balance requirement to user |
Fee configuration is protocol-wide and mutable. The admin can change the fee token address, amount, and receiver at any time. Frontends should read config fresh before each submission rather than caching values.
Recommended UX copy for the fee path: "You've used your N free cap changes this window. This change will cost
{feeAmount} {feeTokenSymbol}. Window resets at {nextResetAt}. Need to tighten your cap
instead? That's always free."
11. Multi-part withdrawals
11.1 Part classification
flowchart TD
A[WithdrawReceiverPart] --> B{"expressProvider\n== address(this)?"}
B -->|No| C[Non-express\nSkipped entirely]
B -->|Yes| D["Express\nAdds to expressAmount\nPool, credit, or STANDARD payout"]
| Part Type | Condition | Contributes to |
|---|---|---|
| Express | expressProvider == this (virtualProvider must be address(0)) |
expressAmount |
| Non-express | expressProvider != this |
Skipped entirely |
Classic/non-Express parts can coexist because core and this provider both limit the advance to Express non-virtual parts.
Still keep virtualProvider == address(0) on each Express part, and do not offer virtual-only parts under an
Express-master request because this provider skips them.
11.2 Multi-part fee cascading
flowchart LR
subgraph "Fee = 150 cascading across 3 parts"
P1["Part 1: 100\nDeduction: 100\nReceiver gets: 0"] --> P2["Part 2: 400\nDeduction: 50\nReceiver gets: 350"]
P2 --> P3["Part 3: 500\nDeduction: 0\nReceiver gets: 500"]
end
FEE["feeRemaining"] -.->|"150 → 50"| P1
FEE -.->|"50 → 0"| P2
FEE -.->|"0"| P3
11.3 Credit line in multi-part withdrawals
When a withdrawal uses credit (creditAmount > 0), the credit line applies to the total withdrawal, not per-part:
- At acceptance:
LibCreditLine.reserveDebt(creditAmount)reserves the total credit across all parts -
At processing:
LibCreditLine.activate(creditAmount)moves debt reserved -> active and callsISymmio.advanceWithdraw - At finalization:
LibCreditLine.settle(creditAmount)settles when SYMMIO reimburses -
On cancel pre-payout (ACCEPTED only):
LibCreditLine.releaseReservation(creditAmount)releases the reservation -
On suspend after payout (PROCESSED):
LibCreditLine.coverLoss(creditAmount)deducts up to the unlocked affiliate balance, records any deficit asbadDebt, and settles
11.4 partsHash integrity
- [ ]
partsHash = keccak256(abi.encode(parts))stored at acceptance - [ ]
processWithdrawandunlockAndProcessverify provided parts match - [ ] ANY difference (amounts, receivers, order, count) causes
PartsMismatchrevert - [ ] Bot MUST store and replay the exact same parts array
Numeric example: 3-part express withdrawal with credit
Scenario: Bot handles a multi-part WINDOWED withdrawal with credit line
Setup:
generalBalance = 5,000 USDC, lockedGeneralBalance = 4,500 USDC (500 unlocked)
affiliateBalances[0xAffiliate] = 200 USDC, lockedAffiliateBalances[0xAffiliate] = 0
creditLine available capacity = 1,000 USDC
affiliateConfigs[0xAffiliate] = { feeRate: 100 (1%), operatorFee: 2e6 }
Step 1: Bot sees: 0xAlice requests withdrawal of 1,300 USDC total, split into 3 parts:
Part 0: { amount: 300, expressProvider: EP, virtualProvider: 0x0, receiver: 0xA }
Part 1: { amount: 600, expressProvider: EP, virtualProvider: 0x0, receiver: 0xA }
Part 2: { amount: 400, expressProvider: EP, virtualProvider: 0x0, receiver: 0xB }
Step 2: Bot classifies parts and computes amounts:
Bot reads each part's expressProvider:
Part 0: expressProvider == EP -> express
Part 1: expressProvider == EP -> express
Part 2: expressProvider == EP -> express
Bot computes:
expressAmount = 300 + 600 + 400 = 1,300 (sum of all express parts)
Step 3: Bot decides the affiliate/general/credit split:
Bot checks: affiliateBalances[0xAffiliate] - lockedAffiliateBalances[0xAffiliate]
= 200 - 0 = 200 unlocked affiliate USDC
Bot checks: generalBalance - lockedGeneralBalance = 500 unlocked general USDC
Bot checks: creditLine available capacity = 1,000 USDC
Bot decides: allocate affiliateAmount = 200, generalAmount = 500, creditAmount = 600
-> expressAmount = affiliateAmount + generalAmount + creditAmount = 200 + 500 + 600 = 1,300
Bot checks: 500 <= 500 (unlocked general) -> OK
200 <= 200 (unlocked affiliate) -> OK
600 <= 1,000 (available credit) -> OK
Step 4: Bot computes fees:
Bot reads on-chain: affiliateConfigs[0xAffiliate].feeRate = 100 (1%)
Bot reads on-chain: affiliateConfigs[0xAffiliate].operatorFee = 2e6
Bot computes:
feeBasis = expressAmount = 1,300
fee = 1,300 * 100 / 10,000 = 13 USDC
operatorFee = 2 USDC
totalFee = 13 + 2 = 15 USDC
Bot checks: totalFee (15) <= feeBasis (1,300) -> OK (fees do not exceed amount)
Decision: Sign WINDOWED option with these fee values.
Step 5: Bot checks credit line capacity before signing:
Bot reads on-chain:
creditLine available capacity = 1,000 >= 600 (creditAmount) -> OK
Decision: Credit line has sufficient capacity. Sign and offer.
At acceptance: reserveDebt(600) reserves 600 USDC on the credit line.
Step 6: Bot observes: WithdrawAccepted(0xAlice, 99, WINDOWED) event
Bot reads on-chain (what the contract did during acceptance):
lockedGeneralBalance: 4,500 -> 5,000 (+500 generalAmount)
lockedAffiliateBalances[0xAffiliate]: 0 -> 200 (+200 affiliateAmount)
LibCreditLine.reserveDebt(0xAffiliate, 0xAlice, 99, 600, creditData): reservedDebt += 600
partsHash stored for integrity check
Bot checks: withdrawInfos[0xAlice][99].status == ACCEPTED
Bot stores: the exact parts array (needed for processWithdraw)
Decision: Schedule processWithdraw after securityWindow (20s).
Step 7: Bot processes after security window:
Bot checks: block.timestamp >= acceptedAt + 20s (securityWindow)
Bot checks: status still ACCEPTED (not LOCKED, not CANCELLED)
Decision: Call processWithdraw(0xAlice, 99, parts).
Contract executes fee cascading through transferToReceivers:
feeRemaining = 15 (userFee)
Part 0 (300, express-only, receiver 0xA):
deduction = min(15, 300) = 15
feeRemaining = 15 - 15 = 0
EP transfers 300 - 15 = 285 USDC to 0xA
Part 1 (600, express-only, receiver 0xA):
deduction = min(0, 600) = 0 (fee already exhausted)
EP transfers 600 USDC to 0xA
Part 2 (400, express-only, receiver 0xB):
deduction = 0
EP transfers 400 USDC to 0xB
Pool balance updates:
lockedGeneralBalance -= 500, lockedAffiliateBalances[0xAffiliate] -= 200
generalBalance -= 500, affiliateBalances[0xAffiliate] -= 200
Credit line: LibCreditLine.activate(600) -> ISymmio.advanceWithdraw(600)
pendingFees[0xAlice, 99] = 13, pendingOperatorFees[0xAlice, 99] = 2
Results:
0xA receives: 285 + 600 = 885 USDC
0xB receives: 400 USDC
Total disbursed: 1,285 out of 1,300 (15 USDC fee retained)
Status -> PROCESSED
The 15 USDC remains pending until finalization or a processed suspension promotes it.
What if the user had cancelled before processWithdraw?
Contract calls _releaseWithdraw:
lockedGeneralBalance -= 500, lockedAffiliateBalances -= 200
Credit line reservation released via LibCreditLine.releaseReservation(600)
All locks released, no funds transferred, Status -> CANCELLED
12. Risk lock / unlock
12.1 Lock resolution paths
flowchart TD
A["ACCEPTED"] -->|"lockWithdraw\n(LOCKER_ROLE)"| B["LOCKED"]
B --> C{Resolution path?}
C -->|"unlockAndProcess\n(UNLOCK_ROLE, false alarm)"| D["PROCESSED"]
C -->|"processWithdraw\n(operator at cooldown; anyone after cooldown + tolerance)"| E["PROCESSED\n(lock expired)"]
C -->|"onWithdrawSuspend\n(SYMMIO, admin decision)"| F["SUSPENDED"]
subgraph "STANDARD special case"
B -->|"onWithdrawComplete\n(tokens arrive)"| H["LOCKED\n(finalizedAt set)"]
H -->|"unlockAndProcess\n(UNLOCK_ROLE)"| D
H -->|"processWithdraw\n(operator at cooldown; anyone after cooldown + tolerance)"| E
H -.->|"suspend\nBLOCKED"| X["REVERT: tokens\nalready on contract"]
end
style D fill:#9f9
style E fill:#9f9
style F fill:#f99
style X fill:#f66
12.2 Role separation
flowchart LR
subgraph "Role Isolation"
OP["OPERATOR_ROLE\n(Bot)"] -->|"processWithdraw"| PROC["Process\nWithdrawals"]
LK["LOCKER_ROLE\n(Risk Service)"] -->|"lockWithdraw"| LOCK["Lock\nWithdrawals"]
UL["UNLOCK_ROLE\n(Security Team)"] -->|"unlockAndProcess"| UNLOCK["Unlock &\nProcess"]
end
OP -.->|"separate role check"| LOCK
OP -.->|"separate role check"| UNLOCK
LK -.->|"separate role check"| PROC
LK -.->|"separate role check"| UNLOCK
UL -.->|"separate role check"| LOCK
style OP fill:#9cf
style LK fill:#fc9
style UL fill:#9f9
| Role | Can Lock? | Can Unlock? | Can Process? |
|---|---|---|---|
| OPERATOR_ROLE | NO | NO | YES |
| LOCKER_ROLE | YES | NO | NO |
| UNLOCK_ROLE | NO | YES | NO (only via unlockAndProcess) |
Deployment requirement: each function checks its own role, but the owner may grant multiple roles to one address. Preventing one bot key from both freezing and releasing funds therefore requires distinct operational role holders.
12.3 Bot checklist for risk
- [ ] LOCKER_ROLE: call
lockWithdrawwhen risk detected (during security window) - [ ] After locking: notify admin/security team
- [ ] Monitor
WithdrawLockedevents: cancel any scheduledprocessWithdraw - [ ] Do NOT attempt
processWithdrawon LOCKED status before cooldown (revertsNotAccepted) - [ ] After cooldown expires: LOCKED withdrawals become processable (the lock is no longer effective)
-
[ ] For LOCKED STANDARD after finalization: UNLOCK_ROLE may use
unlockAndProcess, or the normalprocessWithdrawcaller rules apply after cooldown
Numeric example: LOCKED WINDOWED: all resolution paths
Scenario: WINDOWED withdrawal gets risk-locked: bot navigates the possible outcomes
Setup:
WINDOWED 500 USDC (generalAmount = 250, affiliateAmount = 150, creditAmount = 100)
acceptedAt = T=0, cooldownEndTime = T+43200 (T+12h)
securityWindow = 20s, tolerancePeriod = 60s
creditAmount = 100 (credit line reservation active in CreditLineStorage)
lockedGeneralBalance includes 250, lockedAffiliateBalances[aff] includes 150
Step 1: Bot sees: WithdrawLocked(user, reqId) event at T=5s
Bot reads on-chain: withdrawInfos[user][reqId].status == LOCKED
Bot checks: Was this lock triggered by my LOCKER service or an external party?
Decision: Cancel the scheduled processWithdraw task for (user, reqId).
This withdrawal cannot be processed through processWithdraw while LOCKED before cooldown.
Monitor for one of the resolution paths below.
--- Path A: unlockAndProcess (false alarm) ---
Step 2A: Bot sees: WithdrawUnlockedAndProcessed(user, reqId) at T=300s
Bot reads on-chain: status == PROCESSED
Bot checks: Were pool deductions applied?
- lockedGeneralBalance decreased by 250
- lockedAffiliateBalances[aff] decreased by 150
- generalBalance decreased by 250, affiliateBalances[aff] decreased by 150
- Credit line debt activated and advanced from SYMMIO (100 USDC)
Decision: No further action needed on ExpressProvider side.
Schedule finalizeWithdrawRequest on SYMMIO at T+43200 (cooldown end)
so pools get replenished when SYMMIO reimburses.
--- Path B: processWithdraw after cooldown (lock expired) ---
Step 2B: Bot sees: block.timestamp reaches T+43200 (cooldown expired)
Bot reads on-chain: status == LOCKED, cooldownEndTime = T+43200
Bot checks: isLockedAfterCooldown?
- status == LOCKED: yes
- now (T+43200) >= cooldownEndTime (T+43200): yes
- processableAt = cooldownEndTime = T+43200 for OPERATOR_ROLE
- No suspension was issued during the 12h window -> risk window elapsed
Decision: Call processWithdraw(user, reqId, parts) as OPERATOR_ROLE.
The lock is bypassed because the full SYMMIO cooldown elapsed
without any suspension. Contract treats this as safe to release.
What if bot misses this window?
At T+43260 (cooldownEndTime + tolerancePeriod = 43200 + 60), anyone can
call processWithdraw permissionlessly. Bot should act before T+43260.
--- Path C: Suspended by SYMMIO ---
Step 2C: Bot sees: WithdrawSuspended(user, reqId) at T=600s
Bot reads on-chain: status == SUSPENDED
Bot checks: What did _releaseWithdraw clean up?
- lockedGeneralBalance decreased by 250 (WINDOWED unlocks general lock)
- lockedAffiliateBalances[aff] decreased by 150 (WINDOWED unlocks affiliate lock)
- Credit line reservation released (if creditAmount > 0)
Decision: Withdrawal is terminated. Cancel ALL scheduled actions for
(user, reqId). Do not attempt processWithdraw (reverts NotAccepted)
or finalizeWithdrawRequest (withdrawal is dead on SYMMIO side).
Update internal pool tracking: all balances restored to pre-lock state.
--- Path D: LOCKED STANDARD with finalization (for comparison) ---
Step 2D: Bot sees: WithdrawFinalized(user, reqId) at T+43200
Bot reads on-chain: status == LOCKED (still!), finalizedAt = T+43200
Bot checks: Why is status still LOCKED after finalization?
- onWithdrawComplete found status == LOCKED, so it set finalizedAt but
did NOT transition to FINALIZED (lock is preserved)
- 500 USDC tokens are sitting on ExpressProvider, held by the lock
- suspend -> would REVERT (finalizedAt != 0, InvalidStatusForSuspend)
- (forceCancelWithdraw / onForceWithdrawCancel no longer exist)
Decision: Two resolution paths remain:
- UNLOCK_ROLE can call unlockAndProcess.
- Because cooldownEndTime has passed, OPERATOR_ROLE can call processWithdraw
immediately; any caller can do so after the additional tolerancePeriod.
Part IV: safety & risk (continued)
13. Cancellation & suspension
13.1 Cancellation decision tree
flowchart TD
A{Who is cancelling?} -->|User<br/>onWithdrawCancelRequest| B{Status?}
A -->|SYMMIO suspend<br/>onWithdrawSuspend| D{Status?}
B -->|ACCEPTED| OK1["CANCELLED ✓\nrelease locks/reservation"]
B -->|LOCKED| FAIL1["REVERT: NotAccepted\n(only ACCEPTED accepted)"]
B -->|PROCESSED| FAIL2["REVERT: NotAccepted"]
B -->|FINALIZED/CANCELLED/SUSPENDED| FAIL3["REVERT (terminal)"]
D -->|ACCEPTED/LOCKED| OK3["SUSPENDED ✓\nrelease locks/reservation"]
D -->|PROCESSED| OK4["SUSPENDED ✓\npromote fees; record generalBadDebt;\ncover credit loss"]
D -->|FINALIZED/CANCELLED/SUSPENDED| FAIL5["REVERT:\nInvalidStatusForSuspend (terminal)"]
13.2 Cancellation matrix
| Status | onWithdrawCancelRequest |
onWithdrawSuspend |
Outcome |
|---|---|---|---|
| NONE | revert | revert | No withdrawal exists |
| ACCEPTED | CANCELLED | SUSPENDED | Release WINDOWED locks and any credit reservation; pool balances do not change |
| LOCKED | revert (NotAccepted) |
SUSPENDED, except finalized STANDARD | Release WINDOWED locks/reservation; STANDARD requires finalizedAt == 0 |
| PROCESSED | revert (NotAccepted) |
SUSPENDED |
Post-payout rollback promotes fees, increments generalBadDebt, and covers credit from unlocked
affiliate balance before recording any deficit as bad debt; pools are not replenished
|
| FINALIZED | revert | revert (InvalidStatusForSuspend) |
No request cancel/suspend action; STANDARD can be forwarded via processWithdraw only while the
user is not globally suspended
|
| CANCELLED | revert | revert | Terminal |
| SUSPENDED | revert | revert | Terminal |
Notes:
-
SAME_TX transitions directly from NONE to PROCESSED in
onWithdrawRequest, so it can never be cancelled (status is never ACCEPTED). It CAN be suspended (PROCESSED is a valid suspend state) and triggers the post-payout rollback. - STANDARD at FINALIZED cannot be suspended (raises
InvalidStatusForSuspend).
13.3 What gets released on cancel/suspend
On user cancel from ACCEPTED (onWithdrawCancelRequest):
- [ ]
lockedGeneralBalance -= generalAmount(WINDOWED/SAME_TX only) - [ ]
lockedAffiliateBalances[affiliate] -= affiliateAmount(WINDOWED/SAME_TX only) -
[ ]
generalBalanceandaffiliateBalancesvalues unchanged (locks are released, not balances) - [ ] Credit line reservation released via
LibCreditLine.releaseReservation(if creditAmount > 0) - [ ] Status set to CANCELLED
On suspend from ACCEPTED or LOCKED (onWithdrawSuspend):
- [ ] General and affiliate counter-locks released for WINDOWED withdrawals
- [ ] Credit reservation released, if present
- [ ] Pool balances unchanged because no payout occurred
- [ ] Status set to SUSPENDED
On suspend from PROCESSED (post-payout rollback path):
- [ ] Pending affiliate and operator fees promoted to claimable balances
- [ ]
generalBadDebt += generalAmount -
[ ]
LibCreditLine.coverLossdeducts the covered amount from unlocked affiliate balance and records uncovered credit loss asbadDebt - [ ] Pools were already deducted at processing time; nothing is unlocked or replenished
- [ ] Status set to SUSPENDED
Library function names: releaseReservation (NOT cancelReservation), coverLoss (NOT
coverDebt).
13.4 Bot reactions to cancel/suspend
WithdrawCancelled and WithdrawSuspended share the same terminal scheduling actions, but a suspension
from PROCESSED also requires loss and fee reconciliation. There is no WithdrawForceCancelled event.
-
[ ] Cancel ALL scheduled
processWithdrawandfinalizeWithdrawRequestactions for the affected (user, requestId) - [ ] Do not attempt
processWithdraw(will revert) - [ ] Do not attempt
finalizeWithdrawRequeston SYMMIO (withdrawal is terminated) - [ ] Update internal liquidity tracking (locks released on cancel/ACCEPTED-suspend/LOCKED-suspend; no pool replenishment on PROCESSED-suspend rollback)
-
[ ] For a PROCESSED suspension, reconcile
generalBadDebt, affiliate creditbadDebt, the affiliate balance deduction, and promoted affiliate/operator fees
14. Error catalog
14.1 Error decision tree
flowchart TD
E[Transaction Reverted] --> A{Error type?}
A -->|Signature| S{Which?}
S --> S1["InvalidSigner → Check SIGNER_ROLE"]
S --> S2["OfferExpired → Re-sign with later deadline"]
S --> S3["InvalidNonce → Re-read nonces(user)"]
S --> S4["InvalidValidator → Re-gather from registered validators"]
S --> S5["DuplicateValidator → Sort & deduplicate"]
S --> S6["ValidatorApprovalExpired → Re-gather fresh sigs"]
A -->|Liquidity| L{Which?}
L --> L1["InsufficientGeneralBalance → Reduce or wait"]
L --> L2["InsufficientAffiliateBalance → Reduce affiliateAmount"]
A -->|Fee| F{Which?}
F --> F1["FeeMismatch → Re-read feeRate"]
F --> F2["OperatorFeeMismatch → Re-read operatorFee"]
F --> F3["FeesExceedExpressAmount → Reduce fees"]
F --> F4["UserFeeExceedsMaximum → Increase maxUserFee"]
A -->|State| ST{Which?}
ST --> ST1["NotAccepted → Check status first"]
ST --> ST2["NotFinalized → Wait for onWithdrawComplete"]
ST --> ST3["TooEarly → Wait for correct timestamp"]
ST --> ST4["PartsMismatch → Use stored parts"]
ST --> ST5["NotLocked → Check status is LOCKED"]
ST --> ST6["InvalidStatusForStandard → Check status is ACCEPTED or LOCKED"]
ST --> ST7["InvalidStatusForSuspend → STANDARD already FINALIZED"]
A -->|Validation| V{Which?}
V --> V1["InvalidOptionType → Use optionType 0-2"]
V --> V2["ValidatorsRequiredForSameTx → Register validators for affiliate"]
V --> V3["InvalidAddressBytesLength → Fix receiver encoding"]
V --> V4["CreditNotSupportedForStandard → creditAmount must be 0 for STANDARD"]
V --> V5["VirtualProviderMustBeZero → set virtualProvider = address(0)"]
V --> V6["FundingSplitExceedsExpress → affiliate+credit ≤ expressAmount"]
14.2 Signature & auth errors
| Error | Cause | Bot Action |
|---|---|---|
InvalidSigner |
Recovered signer lacks SIGNER_ROLE | Check signing key has SIGNER_ROLE |
OfferExpired |
block.timestamp > offer.deadline |
Extend deadline or re-sign |
InvalidNonce |
Option nonce != nonces[user] |
Re-read nonce, re-sign |
OnlySymmio |
Non-SYMMIO calling callback | N/A (contract architecture issue) |
InvalidValidator |
Recovered validator not registered for this affiliate (or address(0) default) |
Re-gather from registered validators |
DuplicateValidator |
Same validator signed twice | Sort and deduplicate |
InsufficientValidatorSignatures |
Fewer sigs than minValidatorSignatures(affiliate) |
Gather more |
ValidatorApprovalExpired |
Timestamp too old or future-dated | Re-gather with fresh timestamps |
ArrayLengthMismatch |
signatures.length != timestamps.length | Fix encoding |
14.3 Liquidity errors
| Error | Cause | Bot Action |
|---|---|---|
InsufficientGeneralBalance |
WINDOWED/SAME_TX generalAmount > available | Reduce amount or wait for pool refill |
InsufficientAffiliateBalance |
affiliateAmount > available affiliate balance | Reduce affiliateAmount |
InsufficientUnlockedGeneralBalance |
Withdraw attempt touches locked funds | Wait for withdrawals to complete |
InsufficientUnlockedAffiliateBalance |
Same for affiliate pool | Wait for withdrawals to complete |
14.4 Fee errors
| Error | Cause | Bot Action |
|---|---|---|
FeeMismatch |
Signed fee != on-chain computed fee | Re-read feeRate, recompute |
OperatorFeeMismatch |
Signed operatorFee != on-chain config | Re-read operatorFee |
FeesExceedExpressAmount |
fee + operatorFee > feeBasis |
Reduce fees or increase amount |
FeeRateExceeds100Percent |
feeRate > 10000 on config | Admin error |
NoFeesToClaim |
collectedFees == 0 |
No action needed |
NoOperatorFeesToClaim |
collectedOperatorFees == 0 |
No action needed |
14.5 State errors
| Error | Cause | Bot Action |
|---|---|---|
UserSuspended |
accelerateWithdraw, processWithdraw, or unlockAndProcess while the user
is globally suspended in SYMMIO
|
Do not retry until the user is globally unsuspended; the withdrawal state and payout remain unchanged |
NotAccepted |
processWithdraw, lockWithdraw, or onWithdrawCancelRequest on
non-ACCEPTED status
|
Check status first; only ACCEPTED is accepted |
NotFinalized |
processWithdraw / unlockAndProcess on STANDARD before SYMMIO
onWithdrawComplete set finalizedAt
|
Wait for onWithdrawComplete |
NotLocked |
unlockAndProcess on non-LOCKED status |
Check status first |
InvalidStatusForStandard |
onWithdrawComplete on STANDARD when status is not ACCEPTED or LOCKED |
Check status: may already be CANCELLED or SUSPENDED |
InvalidStatusForComplete |
Non-STANDARD completion when status is not PROCESSED, or duplicate STANDARD completion | Reconcile core/provider state; do not retry an already completed callback |
TooEarly |
Processing before allowed time | Wait for correct timestamp (see processableAt lookup table) |
PartsMismatch |
Parts array doesn't match stored hash | Use exact same parts |
InvalidStatusForSuspend |
onWithdrawSuspend on a terminal state, or on STANDARD after finalizedAt was set |
Cannot suspend at this stage |
InvalidPostPayoutRollback |
A PROCESSED rollback was reached with optionType == STANDARD, an invalid lifecycle combination
|
Investigate corrupted or inconsistent request state |
14.6 Validation errors
| Error | Cause | Bot Action |
|---|---|---|
InvalidOptionType |
offer.optionType > 2 in onWithdrawRequest |
Use only 0 (SAME_TX), 1 (WINDOWED), or 2 (STANDARD) |
ValidatorsRequiredForSameTx |
SAME_TX option when minValidatorSignatures(affiliate) == 0 |
Do not offer SAME_TX unless validators are configured for this affiliate (or address(0) default);
fall back to WINDOWED
|
InvalidAddressBytesLength |
parts[i].receiver is not exactly 20 bytes |
Ensure all receiver fields are valid 20-byte Ethereum addresses |
CreditNotSupportedForStandard |
STANDARD option signed with creditAmount > 0 |
Always set creditAmount = 0 for STANDARD options |
VirtualProviderMustBeZero |
An ExpressProvider-owned part has virtualProvider != address(0) |
Set virtualProvider to address(0) on each Express part |
FundingSplitExceedsExpress |
affiliateAmount + creditAmount > expressAmount |
Ensure the funding split sums to at most expressAmount |
Credit line errors (LibCreditLine)
| Error | Cause | Bot Action |
|---|---|---|
CreditLinePaused |
setCreditLinePaused(affiliate, true) was called |
Wait for unpause or omit credit |
UserBlacklisted |
User on the affiliate's per-user blacklist | Do not offer credit-backed withdrawals to that user |
MuonSignatureExpired |
Muon attestation is future-dated or older than muonFreshnessWindow |
Re-fetch a fresh Muon attestation |
DebtExceedsAbsoluteCap |
reservedDebt + activeDebt + badDebt + creditAmount > effectiveMaxDebt |
Reduce creditAmount, repay bad debt, or wait for debt to settle |
DebtExceedsPercentCap |
Sum exceeds (eligibleBase * effectiveMaxBps / 10000) |
Reduce creditAmount |
NoDebtForRequest |
Activate/settle for a request with no reserved debt | Check the bot's per-request debt tracking |
DebtAlreadyActivated |
Activate called twice for the same request | Do not repeat activation; reconcile the request's already-active state |
AffiliateLimitExceedsProtocol |
Setter tried to set affiliate cap above protocol cap | Admin error |
CreditLineNotConfigured |
Muon signatureVerifier unset |
Admin must call setCreditLineMuonConfig first |
Access control error (LibAccessControl)
| Error | Cause | Bot Action |
|---|---|---|
AccessDenied(bytes32 role) |
Caller lacks the required role for the function | Check role membership via hasRole(account, role) |
14.7 Bot scenarios for key errors
InvalidOptionType
Bot sees: User requests a withdrawal. Bot has option type value from config/logic.
Bot checks:
optionType must be 0 (SAME_TX), 1 (WINDOWED), or 2 (STANDARD).
Any value > 2 will cause onWithdrawRequest to revert InvalidOptionType.
Decision:
Validate optionType before signing the EIP-712 option. If the bot's logic
produces an out-of-range value (e.g., from a misconfigured enum), fix the
configuration. Never sign an option with optionType > 2.
Scenario:
Bot computes optionType = 3 (bug in routing logic)
-> Signs option with optionType = 3
-> User submits to SYMMIO -> onWithdrawRequest called
-> Contract checks: 3 > 2 -> REVERT InvalidOptionType
-> Bot detects revert, fixes routing logic, re-signs with correct type
ValidatorsRequiredForSameTx
Bot sees: User requests fastest possible withdrawal. Bot considers SAME_TX.
Bot checks:
Read minValidatorSignatures(affiliate) on-chain (falls back to address(0) default).
If minValidatorSignatures(affiliate) == 0, SAME_TX is not available.
Contract enforces: if optionType == SAME_TX && minValidatorSignatures(affiliate) == 0,
revert ValidatorsRequiredForSameTx.
Decision:
If minValidatorSignatures(affiliate) == 0:
Do NOT offer SAME_TX. Fall back to WINDOWED (next fastest option).
WINDOWED provides funds after securityWindow (default 20s), which is still fast.
If minValidatorSignatures(affiliate) > 0:
Gather at least minValidatorSignatures(affiliate) validator attestations from
validators registered for this affiliate (or address(0) default), then sign SAME_TX.
Scenario:
minValidatorSignatures(affiliate) = 0 (validators not yet configured for this affiliate)
Bot signs SAME_TX option for Alice, 1000 USDC
-> onWithdrawRequest checks: ot == SAME_TX && minValidatorSignatures(affiliate) == 0
-> REVERT ValidatorsRequiredForSameTx
-> Bot reads minValidatorSignatures(affiliate) = 0
-> Bot re-signs as WINDOWED instead. User gets funds after 20s security window.
FeesExceedExpressAmount
Bot sees: User requests withdrawal. Bot computes fee and operatorFee from on-chain config.
Bot checks:
feeBasis = expressAmount
fee = (feeBasis * feeRate) / 10000
operatorFee = affiliateConfigs[affiliate].operatorFee
Is fee + operatorFee <= feeBasis?
If not, the contract will revert FeesExceedExpressAmount.
Decision:
If fee + operatorFee > feeBasis:
The withdrawal amount is too small to cover fees. Bot should NOT sign the option.
Inform the user that the withdrawal amount is below the minimum viable amount.
Minimum viable amount = operatorFee / (1 - feeRate/10000), rounded up.
Scenario:
feeRate = 50 bps (0.5%), operatorFee = 5 USDC (5e6)
User requests 5 USDC express withdrawal (feeBasis = 5e6)
fee = (5e6 * 50) / 10000 = 25000 (0.025 USDC)
fee + operatorFee = 25000 + 5e6 = 5025000
feeBasis = 5e6
5025000 > 5000000 -> REVERT FeesExceedExpressAmount
Bot should reject: minimum viable amount ~ 5.03 USDC for this config.
InvalidAddressBytesLength
Bot sees: processWithdraw or onWithdrawRequest reverts with InvalidAddressBytesLength.
Bot checks:
Each parts[i].receiver must be exactly 20 bytes (a valid Ethereum address).
The contract calls bytesToAddress(parts[i].receiver) which reverts if
data.length != 20.
Decision:
This is a data encoding error. Check the WithdrawReceiverPart[] construction.
Ensure every receiver is encoded as abi.encodePacked(address) = 20 bytes.
If the receiver field comes from user input, validate its length before signing.
Scenario:
parts[0].receiver = hex"abcdef" (3 bytes, not 20)
-> bytesToAddress checks: data.length = 3 != 20
-> REVERT InvalidAddressBytesLength
-> Bot validates receiver byte lengths before accepting the withdrawal request.
InvalidStatusForStandard
Bot sees: onWithdrawComplete callback reverts on a STANDARD withdrawal.
Bot checks:
InvalidStatusForStandard: For STANDARD, onWithdrawComplete requires
status == ACCEPTED or LOCKED. If the withdrawal was already CANCELLED or
SUSPENDED, the finalization will revert.
Note: there is no `NotProcessed` error. For WINDOWED/SAME_TX,
onWithdrawComplete explicitly requires PROCESSED and reverts
InvalidStatusForComplete for every other provider status.
Decision:
InvalidStatusForStandard: No bot action needed: the withdrawal was
already cancelled/suspended. The bot should have cleaned up its tracking.
15. Edge cases & race conditions
15.1 Liquidity race conditions
sequenceDiagram
participant U1 as User 1
participant U2 as User 2
participant EP as ExpressProvider
Note over EP: generalBalance = 10,000\nlockedGeneralBalance = 0
U1->>EP: WINDOWED 8,000 USDC
Note over EP: Lock 8,000\nlockedGeneral = 8,000\navailable = 2,000
U2->>EP: WINDOWED 8,000 USDC
Note over EP: 8,000 > available 2,000
EP-->>U2: REVERT: InsufficientGeneralBalance
Note over EP: User1 cancels
U1->>EP: cancel → lockedGeneral = 0\navailable = 10,000
U2->>EP: WINDOWED 8,000 USDC (retry)
Note over EP: 8,000 <= 10,000 ✓
EP-->>U2: ACCEPTED
| Scenario | Behavior |
|---|---|
| Two WINDOWED withdrawals for more than half the pool | Second reverts InsufficientGeneralBalance |
| First withdrawal cancelled, second retried | Succeeds (pool freed) |
| Two SAME_TX withdrawals racing | Second reverts (funds transferred atomically in first's tx) |
15.2 Config changes mid-flight
sequenceDiagram
participant Bot
participant Admin
participant EP as ExpressProvider
participant User
Bot->>EP: Read feeRate = 50 bps
Bot->>Bot: Sign option with fee = 25 USDC
Admin->>EP: setAffiliateConfig(feeRate = 100)
Note over EP: feeRate now 100 bps
User->>EP: initiateWithdraw (with bot's signed option)
Note over EP: On-chain: fee should be 50 USDC\nBot signed: 25 USDC
EP-->>User: REVERT: FeeMismatch
| Config Change | Impact on In-Flight Withdrawals |
|---|---|
feeRate changed |
Options signed with old rate will revert FeeMismatch |
operatorFee changed |
Options signed with old value will revert OperatorFeeMismatch |
minValidatorSignatures(affiliate) raised |
Pending options with insufficient sigs for this affiliate will revert |
validatorApprovalTimeout(affiliate) reduced |
Previously valid sigs for this affiliate may expire |
securityWindow changed |
Affects timing for unprocessed WINDOWED withdrawals |
tolerancePeriod changed |
Affects permissionless processing window |
| SIGNER_ROLE revoked | All options signed by that key become invalid |
Validator disabled (via setValidator) |
Pending validator sigs from that key become invalid |
15.3 Nonce edge cases
| Scenario | Behavior |
|---|---|
| Nonce replay (same nonce used twice) | Reverts InvalidNonce |
| Nonce skip (nonce 0, then nonce 2) | Reverts InvalidNonce |
| Nonce read at sign time, but another withdrawal consumed it | Reverts InvalidNonce: re-read and re-sign |
| Concurrent options signed against the same user nonce | Only one can succeed; later nonces can be accepted while earlier requests remain active |
Numeric example: nonce race condition
Scenario: Bot detects stale nonce, aborts a signature, and retries
Setup:
nonces(Alice) = 5
Alice has two pending withdrawal requests queued in bot's inbox:
Request A: 500 USDC WINDOWED
Request B: 300 USDC STANDARD
Step 1: Bot sees: two withdrawal requests from Alice arrive nearly simultaneously
Bot reads on-chain:
nonces(Alice) = 5
Bot checks: can I sign both with nonce=5?
No: nonces are sequential; each acceptance increments the nonce by 1
Decision: serialize. Sign request A with nonce=5 first, hold request B
Step 2: Bot sees: WithdrawAccepted event for request A (nonce=5 consumed)
Bot reads on-chain:
nonces(Alice) = 6 (incremented by A's acceptance)
Bot checks: nonce for B must be 6, not 5
Decision: sign request B with nonce=6, send to Alice
Step 3: Bot sees: WithdrawAccepted event for request B (nonce=6 consumed)
Bot reads on-chain:
nonces(Alice) = 7
Both withdrawals accepted successfully
What-if: bot mistakenly signs both A and B with nonce=5?
A lands first --> nonces(Alice) incremented to 6
B arrives with nonce=5, but on-chain nonce is now 6
Contract reverts: InvalidNonce
Bot detects the revert, reads nonces(Alice) = 6, re-signs B with nonce=6
Wasted gas on one failed tx: serialization avoids this
What-if: Alice submits A herself before the bot sends B?
Same outcome: A consumes nonce=5, bot must read fresh nonce (6) before signing B
Numeric example: nonce serialization: multiple users vs same user
Scenario: Bot handles concurrent requests from different users and the same user
Setup:
nonces(Alice) = 5
nonces(Bob) = 3
Bot receives three withdrawal requests nearly simultaneously:
Request 1: Alice, 500 USDC WINDOWED
Request 2: Bob, 300 USDC WINDOWED
Request 3: Alice, 200 USDC STANDARD
--- Different users CAN be signed in parallel (independent nonces) ---
Step 1: Bot sees: Request 1 (Alice) and Request 2 (Bob)
Bot reads on-chain:
nonces(Alice) = 5
nonces(Bob) = 3
Bot checks: Alice and Bob have independent nonce counters
Decision: sign both in parallel
Sign Request 1 with nonce=5 (for Alice)
Sign Request 2 with nonce=3 (for Bob)
Step 2: Both txs submitted concurrently:
Request 1 mines: nonces(Alice) = 5 → 6 ✓
Request 2 mines: nonces(Bob) = 3 → 4 ✓
Both succeed: no conflict because nonces are per-user
--- Same user MUST be serialized (shared nonce counter) ---
Step 3: Bot sees: Request 3 (Alice, 200 USDC STANDARD) still pending
Bot reads on-chain:
nonces(Alice) = 6 (incremented by Request 1)
Decision: sign Request 3 with nonce=6
Step 4: Request 3 tx mines:
nonces(Alice) = 6 → 7 ✓
--- What-if: bot signs Request 1 and Request 3 for Alice in parallel? ---
Bot signs Request 1 with nonce=5, Request 3 with nonce=5
Request 1 lands first: nonces(Alice) = 5 → 6 ✓
Request 3 arrives with nonce=5, but on-chain nonce is now 6
Contract reverts: InvalidNonce
Wasted gas + user experience degradation
Even if bot guesses nonce=6 for Request 3:
Bot signs Request 1 with nonce=5, Request 3 with nonce=6
If Request 3 lands BEFORE Request 1 (due to gas price / mempool ordering):
Request 3 expects nonce=6, but on-chain nonce is still 5 → REVERT InvalidNonce
Nonce ordering is NOT guaranteed to match tx landing order
Correct strategy:
- Maintain a per-user queue in the bot
- Different users: sign and submit in parallel (independent nonces)
- Same user: strictly serialize: wait for WithdrawAccepted event (nonce consumed)
before signing the next option for that user
- Never pre-sign multiple options for the same user with speculative nonces
15.4 Timing edge cases
| Scenario | Behavior |
|---|---|
processWithdraw at exact securityWindow boundary |
Succeeds (uses >= check) |
Finalization at exact cooldownEndTime |
Succeeds |
| LOCKED after cooldown + tolerancePeriod | Anyone can process |
| cooldownEndTime in the past (user deallocated long ago) | Cooldown already expired, finalization possible right away |
securityWindow < 10 |
Setter reverts SecurityWindowTooLow |
tolerancePeriod < 10 |
Setter reverts TolerancePeriodTooLow |
15.5 Credit line edge cases
| Scenario | Behavior |
|---|---|
| Cancel with active credit reservation | LibCreditLine.releaseReservation releases reserved debt |
| Credit line paused between signing and acceptance | reserveDebt reverts (paused) |
| User blacklisted between signing and acceptance | reserveDebt reverts (blacklisted) |
| Credit amount exceeds debt cap | reserveDebt reverts (cap exceeded) |
| Muon attestation expired | reserveDebt reverts (freshness check fails) |
| Credit used with STANDARD | Reverts CreditNotSupportedForStandard |
| Post-payout rollback with credit | Affiliate pool absorbs credit loss via coverLoss |
15.6 STANDARD-specific edge cases
| Scenario | Behavior |
|---|---|
| Process before finalization | Reverts NotFinalized |
| LOCKED then finalized | Status stays LOCKED, finalizedAt set |
| LOCKED + finalized then suspend | Reverts InvalidStatusForSuspend because STANDARD has a nonzero finalizedAt |
| LOCKED + finalized: resolution |
unlockAndProcess (UNLOCK_ROLE), or processWithdraw at cooldown end under the
operator and permissionless timing rules
|
| LOCKED + NOT finalized + cooldown expired | processWithdraw calls finalizeWithdrawRequest on SYMMIO first, then processes |
affiliateAmount + creditAmount > expressAmount |
Reverts FundingSplitExceedsExpress. Bot must ensure the sum never exceeds expressAmount |
| Credit with STANDARD | Reverts CreditNotSupportedForStandard. Bot must set creditAmount = 0 for STANDARD |
15.7 Admin / configuration change scenarios
Configuration changes can overlap with in-flight withdrawals, pending options, and scheduled bot actions.
Scenario 1: fee config change race condition
Scenario: Admin changes affiliate fee rate while bot's signed option is in the mempool
Setup:
affiliateConfigs[0xAffiliate].feeRate = 50 bps
affiliateConfigs[0xAffiliate].operatorFee = 1 USDC
User: Alice, withdrawal amount: 5,000 USDC WINDOWED
Bot signed fee = 5,000 * 50 / 10,000 = 25 USDC, operatorFee = 1 USDC
Step 1: Bot sees: Alice requests a withdrawal option
Bot reads on-chain:
affiliateConfigs[0xAffiliate].feeRate = 50
affiliateConfigs[0xAffiliate].operatorFee = 1 USDC
Bot checks: fee = 5,000 * 50 / 10,000 = 25 USDC
Decision: sign option with fee=25 USDC, operatorFee=1 USDC, send to Alice
Step 2: Admin calls: setAffiliateConfig(0xAffiliate, 100, 2_000000)
On-chain state changes:
affiliateConfigs[0xAffiliate].feeRate = 100 bps (was 50)
affiliateConfigs[0xAffiliate].operatorFee = 2 USDC (was 1)
Event emitted: AffiliateConfigUpdated(0xAffiliate, 100, 2_000000)
Alice's tx is still in the mempool
Step 3: Alice's tx mines: initiateWithdraw with bot's signed option (fee=25 USDC, operatorFee=1 USDC)
On-chain validation:
expected fee = 5,000 * 100 / 10,000 = 50 USDC
signed fee = 25 USDC
50 != 25 --> REVERT: FeeMismatch
Alice's withdrawal fails
Step 4: Bot sees: AffiliateConfigUpdated(0xAffiliate, 100, 2_000000)
Bot reads on-chain:
affiliateConfigs[0xAffiliate].feeRate = 100
affiliateConfigs[0xAffiliate].operatorFee = 2 USDC
Bot checks:
Any pending (unsigned or signed-but-not-yet-mined) options for 0xAffiliate?
Yes: Alice's option was signed with feeRate=50, now stale
Decision:
1. Invalidate ALL pending options for affiliate 0xAffiliate
2. Re-compute fee: 5,000 * 100 / 10,000 = 50 USDC
3. Re-sign option with fee=50 USDC, operatorFee=2 USDC
4. Send new signed option to Alice (she must re-submit her tx)
What if bot does NOT invalidate pending options?
Every pending option signed with the old feeRate will revert FeeMismatch
Users experience unexplained failures and must request new options manually
Bot wastes gas if it is the one submitting the txs
What if operatorFee also changed?
Old operatorFee=1, new operatorFee=2
Even if feeRate matched, the option would revert OperatorFeeMismatch
Bot must re-sign with BOTH updated values
Scenario 2: security window change impact
Scenario: Admin increases securityWindow while bot has a scheduled processWithdraw
Setup:
securityWindow = 20s
User: Alice, requestId: 42, WINDOWED withdrawal, 3,000 USDC
withdrawInfos[Alice][42].status = ACCEPTED
withdrawInfos[Alice][42].acceptedAt = T
Step 1: Bot sees: WithdrawAccepted(Alice, 42, WINDOWED) at T=0s
Bot reads on-chain:
securityWindow = 20s
acceptedAt = T
Bot checks:
processableAt = acceptedAt + securityWindow = T + 20s
Decision: schedule processWithdraw(Alice, 42, parts) for T+20s
Step 2: Admin calls: setSecurityWindow(60) at T=5s
On-chain state changes:
securityWindow = 60 (was 20)
NOTE: No event is emitted for setSecurityWindow
Step 3: Bot's scheduled processWithdraw fires at T=20s
On-chain validation:
processableAt = acceptedAt + securityWindow = T + 60s
block.timestamp = T + 20s
T+20s < T+60s --> REVERT: TooEarly
Bot's processWithdraw fails
Step 4: Bot sees: processWithdraw reverted with TooEarly
Bot reads on-chain:
securityWindow = 60 (changed since bot last read it)
Bot checks:
new processableAt = T + 60s
current time T+20s < T+60s: still too early
Decision: reschedule processWithdraw(Alice, 42, parts) for T+60s
Correct strategy: re-read securityWindow before EVERY processWithdraw call:
Before calling processWithdraw, bot always does:
1. Read securityWindow from contract
2. Read withdrawInfos[user][requestId].acceptedAt
3. Compute processableAt = acceptedAt + securityWindow
4. If block.timestamp < processableAt, reschedule for processableAt
5. Only call processWithdraw if block.timestamp >= processableAt
What if securityWindow is DECREASED (e.g., 60s -> 10s)?
Bot's scheduled processWithdraw at T+60s is unnecessarily late but still succeeds
No revert: just delayed processing (T+10s would have been enough)
Not harmful, but suboptimal for user experience
Re-reading before every call also helps here: bot can process earlier
What if an admin tries to set securityWindow below 10 seconds?
setSecurityWindow reverts SecurityWindowTooLow.
The enforced minimum is 10 seconds.
Scenario 3: credit line config change
Scenario: Admin changes credit line config on the diamond while bot has signed options using credit
Setup:
Credit line state for 0xAffiliate (on the ExpressProvider diamond via ControlFacet setters / ViewFacet reads):
creditLineTotalDebt(0xAffiliate) = 500 USDC
protocolMaxDebt = 10,000 USDC, affiliateMaxDebt = 5,000 USDC
Bot has signed 2 pending options for affiliate 0xAffiliate using credit:
Option A: Alice, 2,000 USDC (creditAmount=500)
Option B: Bob, 3,000 USDC (creditAmount=1,000)
Step 1: Admin calls: setCreditLinePaused(0xAffiliate, true) or
setCreditLineAffiliateConfig(0xAffiliate, ...) with reduced caps
On-chain state changes:
Credit line for 0xAffiliate is now paused or has lower caps
Bot detects: CreditLinePausedUpdated or config change event on the diamond
Step 2: Alice submits her withdrawal tx (Option A, signed with creditAmount=500)
On-chain:
LibCreditLine.reserveDebt checks pause/caps for 0xAffiliate
Outcome depends on new config:
- If paused: REVERT CreditLinePaused
- If caps reduced below current debt + 500: REVERT (cap exceeded)
- If still within caps and not paused: tx succeeds
Step 3: Bot sees: Option A reverted or config change event
Bot reads on-chain:
expressProvider.creditLinePaused(0xAffiliate) or updated caps
Bot checks:
Option B was signed based on old capacity/config
New config may reject Option B
Decision:
1. Invalidate pending option B
2. Read updated credit line state: creditLineTotalDebt(0xAffiliate), caps, paused
3. Re-sign options with creditAmounts that respect updated config
4. Update cached credit line state for 0xAffiliate
Correct strategy: monitor credit line events and poll state:
Bot should:
- Listen for CreditLinePausedUpdated, config change events on the diamond
- Before signing any option with creditAmount > 0, read credit line state fresh
- If config changed, invalidate all pending credit-referencing options
- Read updated state to adjust capacity estimates
Scenario 4: role grant / revoke
Scenario: Bot's SIGNER_ROLE key is revoked while options signed by that key are pending
Setup:
Bot signing key: 0xBotSigner (has SIGNER_ROLE)
Bot has 5 pending signed options (not yet submitted or in mempool):
Options for Alice, Bob, Carol, Dave, Eve: all signed by 0xBotSigner
Step 1: Admin calls: revokeRole(0xBotSigner, SIGNER_ROLE)
No role-change event is emitted by the current ControlFacet/LibAccessControl implementation.
On-chain: hasRole(0xBotSigner, SIGNER_ROLE) = false
Step 2: Alice submits her withdrawal tx with option signed by 0xBotSigner
On-chain validation:
EIP-712 signature recovery -> recovers 0xBotSigner
hasRole(0xBotSigner, SIGNER_ROLE) = false
--> REVERT: InvalidSigner
All 5 pending options become unusable
Step 3: Bot observes the owner transaction or its scheduled hasRole poll detects the change
Bot reads on-chain:
hasRole(0xBotSigner, SIGNER_ROLE) = false
Bot checks:
0xBotSigner is the bot's own signing key: ALL options signed by this key are now invalid
Decision:
1. Invalidate ALL pending options signed by 0xBotSigner (all 5)
2. Alert operations team: "SIGNER_ROLE revoked for 0xBotSigner"
3. If a new signer key (0xNewSigner) has been granted SIGNER_ROLE:
- Switch to 0xNewSigner for future option signing
- Re-sign all 5 invalidated options with 0xNewSigner
4. If no new signer key is available:
- STOP signing new options (all sign requests return error)
- Continue processing already-ACCEPTED withdrawals (OPERATOR_ROLE is separate)
What if OPERATOR_ROLE is revoked instead?
Bot can still sign options (SIGNER_ROLE unaffected)
Bot CANNOT call processWithdraw: ACCEPTED withdrawals will wait for:
a) OPERATOR_ROLE to be re-granted, or
b) Permissionless fallback after securityWindow + tolerancePeriod
Alert: "OPERATOR_ROLE revoked: processWithdraw disabled"
What if LOCKER_ROLE is revoked?
Bot cannot call lockWithdraw for risk detection
ACCEPTED withdrawals proceed normally (no risk-lock capability)
Alert: "LOCKER_ROLE revoked: risk lock disabled"
Critical roles to monitor through RoleGranted/RoleRevoked events and periodic
hasRole(address user, bytes32 role) reconciliation:
- SIGNER_ROLE: affects option signing: invalidates all pending options
- OPERATOR_ROLE: affects processWithdraw: delays user fund delivery
- LOCKER_ROLE: affects risk detection: reduces security capabilities
- UNLOCK_ROLE: affects locked withdrawal resolution
Validator config monitoring (per-affiliate, with address(0) fallback):
Validators are configured PER AFFILIATE. setValidator(affiliate, validator, enabled),
setMinValidatorSignatures(affiliate, n), and setValidatorApprovalTimeout(affiliate, t)
all take an affiliate address. address(0) is the default for any affiliate
that has no explicit configuration. _isValidator(affiliate, signer) returns true
if the signer is registered for that affiliate OR for address(0).
Monitor these events for the affiliates the bot serves:
- ValidatorUpdated(affiliate, validator, enabled): a validator was added/removed
for affiliate (or address(0) default). Re-gather validator sets if affected.
- MinValidatorSignaturesUpdated(affiliate, n): the threshold changed; bot may
need to gather more signatures, or SAME_TX may newly become unavailable.
- ValidatorApprovalTimeoutUpdated(affiliate, t): freshness window changed;
re-gather sigs if existing ones may now be expired.
Bot must read the per-affiliate value first; if zero, fall back to address(0)
default. Always re-read before signing SAME_TX options.
15.8 LOCKED scenario
15.9 STANDARD processWithdraw too early
Scenario: Bot accidentally calls processWithdraw on STANDARD before finalization
Setup:
STANDARD 1,000 USDC, status = ACCEPTED, finalizedAt = 0
Step 1: Bot sees: WithdrawAccepted(user, reqId, STANDARD) at T=0
Bot reads: status = ACCEPTED, optionType = STANDARD
Bot INCORRECTLY schedules processWithdraw at T+20s (confusing with WINDOWED logic)
Step 2: Bot calls processWithdraw at T+20s
Contract checks: optionType == STANDARD, status != FINALIZED, not isLockedAfterCooldown
→ Reverts: NotFinalized
Correct behavior:
For STANDARD, bot should NOT schedule processWithdraw at acceptance.
Instead, wait for WithdrawFinalized event (onWithdrawComplete callback at ~T+12h).
Then call processWithdraw right after finalization.
15.10 Non-existent withdrawal
Scenario: Bot references a (user, requestId) that was never accepted
Setup: No withdrawal exists for (Alice, requestId=99)
Step 1: Bot calls processWithdraw(Alice, 99, parts)
Contract reads: withdrawInfos[Alice][99]: all fields zero-initialized
status = 0 (NONE), optionType = 0 (SAME_TX), generalAmount = 0, etc.
Contract checks: status != ACCEPTED → reverts NotAccepted
Bot mitigation: Always track accepted withdrawals via WithdrawAccepted events.
Never call processWithdraw/lockWithdraw for IDs not in the bot's accepted set.
15.11 Zero-amount parts
Scenario: A part with amount = 0 in the parts array
Setup: parts = [{amount: 500, vp: 0x0}, {amount: 0, vp: 0x0}, {amount: 300, vp: 0x0}]
On-chain behavior:
computeAmounts: part[1] contributes 0 to expressAmount. Harmless.
transferToReceivers: toSend for part[1] = 0. Skipped by if-guard.
Bot consideration: Zero-amount parts waste gas but don't cause reverts.
Decision: Filter out zero-amount parts before signing. No benefit to including them.
15.12 SAME_TX cannot be cancelled (already PROCESSED), but CAN trigger suspend with post-payout rollback
Scenario: SAME_TX withdrawal: state transitions documented
Setup: SAME_TX 1,000 USDC. onWithdrawRequest transitions directly NONE → PROCESSED
(atomic accept + payout in the same tx). Per the Status enum state-transition
comments: "ACCEPTED → PROCESSED (onWithdrawRequest for SAME_TX mode)": there
is no ACCEPTED state visible to other callbacks for SAME_TX.
Impossible actions (revert):
lockWithdraw: status is PROCESSED (not ACCEPTED) → NotAccepted
onWithdrawCancelRequest: status is PROCESSED (not ACCEPTED) → NotAccepted
processWithdraw: status is PROCESSED (not ACCEPTED) → NotAccepted
Possible action:
onWithdrawSuspend: status PROCESSED is a valid suspend state. This triggers the
post-payout rollback path: pending fees become collected, generalBadDebt increases
by generalAmount, and LibCreditLine.coverLoss deducts credit loss from unlocked
affiliate balance before recording any uncovered amount as badDebt. No pool is replenished.
Bot implication: Risk for SAME_TX must be caught BEFORE acceptance.
This is why minValidatorSignatures(affiliate) > 0 is required for SAME_TX.
Validators serve as the pre-acceptance risk check since lock/cancel are
impossible after acceptance. The only post-acceptance lever is SYMMIO calling
onWithdrawSuspend, which records general-pool loss and assigns credit loss to the
affiliate pool/badDebt ledger.
If validators are unavailable, offer WINDOWED only when its effective validator
minimum is zero; otherwise fall back to STANDARD, which skips validation at acceptance.
15.13 Duplicate finalization callback
Scenario: onWithdrawComplete called twice for same withdrawal
Setup: WINDOWED 500 USDC, status = PROCESSED after normal processing
Step 1: onWithdrawComplete called (T+12h):
Status: PROCESSED → FINALIZED. Pools replenished, credit settled.
Step 2: onWithdrawComplete called again (hypothetical):
- For STANDARD: status is no longer ACCEPTED/LOCKED, so it reverts
InvalidStatusForStandard.
- For WINDOWED/SAME_TX: status is no longer PROCESSED, so it reverts
InvalidStatusForComplete.
Bot implication: treat a duplicate callback or duplicate finalization attempt as
already handled, re-read both core and provider state, and do not retry blindly.
Part V: operations
16. Pool management
16.1 Pool lifecycle diagram
flowchart TD
subgraph "WINDOWED/SAME_TX Lifecycle"
A1["depositToGeneral\n+10,000"] --> B1["Lock on accept\nlockedGeneral += 500"]
B1 --> C1["Process: deduct\ngeneralBalance -= 500\nUser gets 500"]
C1 --> D1["Finalize: replenish\ngeneralBalance += 500\n(SYMMIO sends tokens back)"]
end
subgraph "STANDARD Lifecycle"
A2["No pool lock\n(pools untouched)"] --> B2["Finalize: tokens arrive\nfrom SYMMIO (500)"]
B2 --> C2["Process: forward\ntokens to user (500)"]
end
subgraph "Pre-payout Cancel/Suspend"
X["Unlock all\nlockedGeneral -= 500\nPools restored"] --> Y["No capital loss"]
end
subgraph "Post-payout Suspend"
P["No pool replenishment"] --> Q["generalBadDebt += generalAmount\ncredit charged to affiliate balance/badDebt"]
end
16.2 Pool types
| Pool | Funded By | Used For | Locked During |
|---|---|---|---|
General (generalBalance) |
depositToGeneral |
WINDOWED/SAME_TX general portion | lockedGeneralBalance |
Affiliate (affiliateBalances[affiliate]) |
depositToAffiliate |
Express affiliate portion | lockedAffiliateBalances[affiliate] |
Credit Line (CreditLineStorage) |
SYMMIO core advance; Muon attests the cap input | Credit-backed portions (non-STANDARD) | creditLineReservedDebt / creditLineActiveDebt |
16.3 Available liquidity formulas
availableGeneral = generalBalance - lockedGeneralBalance
availableAffiliate = affiliateBalances[affiliate] - lockedAffiliateBalances[affiliate]
usedCredit = expressProvider.creditLineTotalDebt(affiliate) + expressProvider.creditLineBadDebt(affiliate)
effectiveAbsoluteCap = tighter nonzero protocol/affiliate maxDebt; both zero means uncapped
absoluteHeadroom = effectiveAbsoluteCap == 0 ? unbounded : max(effectiveAbsoluteCap - usedCredit, 0)
percentageHeadroom = effectiveMaxBps == 0 ? unbounded : max(eligibleBase * effectiveMaxBps / 10000 - usedCredit, 0)
availableCredit = min of the bounded headrooms, subject to pause, blacklist, and a fresh Muon attestation
// Evaluate the proposed affiliate/general/credit split independently; a scalar total can hide a per-pool shortage.
16.4 Bot pool monitoring
- [ ] Track available liquidity across all pools
- [ ] Alert when available liquidity drops below threshold
- [ ] Do not offer WINDOWED/SAME_TX if insufficient liquidity
-
[ ] Monitor
GeneralDeposit/GeneralWithdrawandAffiliateDeposit/AffiliateWithdrawevents -
[ ] Verify
withdrawFromGeneral/withdrawFromAffiliatecannot touch locked funds (enforced on-chain) -
[ ] Monitor
creditLineTotalDebt(affiliate),creditLineBadDebt(affiliate),creditLinePaused(affiliate), and debt cap headroom via the diamond -
[ ] Monitor
GeneralBadDebtAccruedandgeneralBadDebt(); any increase means a processed payout was suspended without replenishing the general pool. This counter is cumulative; the current contract exposes no decrement or reset function.repayCreditBadDebtonly reduces affiliate credit bad debt.
Numeric example: pool utilization tracking
Scenario: Bot monitors pool health under load and picks the right option type
Setup:
generalBalance = 10_000 USDC
lockedGeneralBalance = 0
affiliateBalances[FrontA] = 5_000 USDC
lockedAffiliateBalances[FrontA] = 0
Available general = 10_000 - 0 = 10_000
Available affiliate = 5_000 - 0 = 5_000
Total available = 15_000
Step 1: Bot sees: 3 WINDOWED withdrawals accepted in rapid succession
W1: 3_000 USDC (general=2_000, affiliate=1_000)
W2: 4_000 USDC (general=3_000, affiliate=1_000)
W3: 2_000 USDC (general=1_500, affiliate=500)
Bot reads on-chain after all three lock:
lockedGeneralBalance = 6_500
lockedAffiliateBalances[FrontA] = 2_500
Bot checks remaining capacity:
Available general = 10_000 - 6_500 = 3_500
Available affiliate = 5_000 - 2_500 = 2_500
Total available = 6_000
Step 2: Bot sees: new request from Bob for 5_000 USDC via FrontA
Bot reads on-chain (same state as above):
Available general = 3_500
Available affiliate = 2_500
Bot checks: Can WINDOWED work?
If affiliateAmount = 1_500, generalAmount = 3_500
3_500 <= 3_500 available general --> fits (barely)
If affiliateAmount = 1_000, generalAmount = 4_000
4_000 > 3_500 available general --> would revert InsufficientGeneralBalance
Decision tree:
WINDOWED with affiliateAmount=1_500 --> sign it (tight but feasible)
WINDOWED with affiliateAmount=1_000 --> reject (insufficient general)
STANDARD --> always available (no pool lock)
Step 3: Bot sees: WithdrawFinalized event for W1 (pools replenished)
Bot reads on-chain:
generalBalance = 10_000 (unchanged: replenished the 2_000 deducted at process)
lockedGeneralBalance = 3_500 (W2 + W3 locks remain)
affiliateBalances[FrontA] = 5_000 (replenished the 1_000 deducted at process)
lockedAffiliateBalances[FrontA] = 1_500 (W2 + W3 locks remain)
Bot checks:
Available general = 10_000 - 3_500 = 6_500
Available affiliate = 5_000 - 1_500 = 3_500
Total available = 10_000
Decision: capacity restored; resume offering WINDOWED for larger requests
17. Access control
17.1 Role interaction diagram
flowchart TD
subgraph "Admin Roles"
ADMIN["Diamond Owner\n(Multisig)"]
SETTER["SETTER_ROLE"]
WITHDRAWER["WITHDRAWER_ROLE"]
FEE_CLAIMER["FEE_CLAIMER_ROLE"]
PAUSER["PAUSER_ROLE"]
end
subgraph "Operational Roles"
OPERATOR["OPERATOR_ROLE\n(Bot)"]
SIGNER["SIGNER_ROLE\n(Bot key)"]
LOCKER["LOCKER_ROLE\n(Risk service)"]
UNLOCKER["UNLOCK_ROLE\n(Security team)"]
end
subgraph "Per-Affiliate Validators"
VALIDATORS["Validators\n(Per-affiliate, not a role)\nRegistered via setValidator"]
end
ADMIN -->|grants/revokes| SETTER & WITHDRAWER & FEE_CLAIMER & PAUSER & OPERATOR & SIGNER & LOCKER & UNLOCKER
ADMIN -->|diamondCut| EP["ExpressProvider"]
SETTER -->|setValidator| VALIDATORS
OPERATOR -->|processWithdraw| EP
SIGNER -->|signs options| EP
LOCKER -->|lockWithdraw| EP
UNLOCKER -->|unlockAndProcess| EP
VALIDATORS -->|signs attestations| EP
SETTER -->|set* config| EP
WITHDRAWER -->|withdraw pools| EP
FEE_CLAIMER -->|claim fees| EP
PAUSER -->|setPaused| EP
17.2 ExpressProvider roles
| Role | Constant | Holder | Functions |
|---|---|---|---|
| Diamond Owner | N/A | Admin/multisig | Diamond cuts, role management, ownership transfer, token rescue, and stuck request-debt clearing |
SETTER_ROLE |
keccak256("SETTER_ROLE") |
Admin | Timing, fee, validator, credit-line, and cap-throttle configuration setters |
OPERATOR_ROLE |
keccak256("OPERATOR_ROLE") |
Bot service | processWithdraw (preferred caller) |
LOCKER_ROLE |
keccak256("LOCKER_ROLE") |
Risk service | lockWithdraw |
UNLOCK_ROLE |
keccak256("UNLOCK_ROLE") |
Security team | unlockAndProcess |
SIGNER_ROLE |
keccak256("SIGNER_ROLE") |
Bot signing key | EIP-712 option signatures (verified on-chain) |
| Validators (per-affiliate) | N/A (not a role, tracked in mapping(address => mapping(address => bool))) |
Monitoring services | EIP-712 validator attestations. Managed via setValidator(affiliate, validator, enabled) |
WITHDRAWER_ROLE |
keccak256("WITHDRAWER_ROLE") |
Admin | withdrawFromGeneral, withdrawFromAffiliate |
FEE_CLAIMER_ROLE |
keccak256("FEE_CLAIMER_ROLE") |
Admin | claimFees, claimOperatorFees |
PAUSER_ROLE |
keccak256("PAUSER_ROLE") |
Emergency operator/multisig | setPaused |
17.3 Credit line functions (on the diamond)
Credit line mutations live on ControlFacet; all credit line view functions are on
ViewFacet. Protocol configuration uses SETTER_ROLE, while affiliate self-service, permissionless
debt repayment, and owner-only recovery use the caller rules shown below.
Mutating functions (ControlFacet):
| Function | Caller | Description |
|---|---|---|
setCreditLineMuonConfig(...) |
SETTER_ROLE |
Set Muon app ID, signature verifier, freshness window |
setCreditLineProtocolConfig(affiliate, ...) |
SETTER_ROLE |
Set protocol-level debt caps for an affiliate |
setCreditLineAffiliateConfig(affiliate, ...) |
SETTER_ROLE |
Set affiliate-level debt caps for an affiliate |
setCreditLinePaused(affiliate, paused) |
SETTER_ROLE |
Pause/unpause credit line reservations and activation for an affiliate |
setCreditLineBlacklisted(affiliate, user, status) |
SETTER_ROLE |
Blacklist/unblacklist a user for an affiliate |
setCapChangeFeeConfig(...) |
SETTER_ROLE |
Configure the token, amount, and receiver for paid affiliate cap increases |
setCapChangeQuotaConfig(...) |
SETTER_ROLE |
Configure the free-increase quota and its window |
setMyCreditLineConfig(maxDebt, maxDebtBps) |
Affiliate address itself | Change the caller affiliate's limits within protocol caps; an increase may consume quota or charge a fee |
repayCreditBadDebt(affiliate, amount) |
Any payer | Transfer collateral into the affiliate pool and reduce its credit bad debt |
clearRequestDebt(affiliate, user, requestId) |
Diamond owner | Emergency ledger repair for one stuck request; no collateral transfer |
View functions (ViewFacet):
| Function | Description |
|---|---|
creditLineSignatureVerifier() |
Muon signature verifier address |
creditLineMuonAppId() |
Muon app ID |
creditLineMuonFreshnessWindow() |
Max age of Muon signatures |
creditLineProtocolMaxDebt(affiliate) |
Protocol-level absolute debt cap |
creditLineProtocolMaxDebtBps(affiliate) |
Protocol-level percentage cap (bps of eligibleBase) |
creditLineAffiliateMaxDebt(affiliate) |
Affiliate-chosen absolute cap |
creditLineAffiliateMaxDebtBps(affiliate) |
Affiliate-chosen percentage cap (bps) |
creditLineReservedDebt(affiliate) |
Reserved but not yet activated debt |
creditLineActiveDebt(affiliate) |
Activated (advanced) debt |
creditLineTotalDebt(affiliate) |
Reserved + active debt, excluding bad debt |
creditLineBadDebt(affiliate) |
Credit loss not yet repaid |
creditLineRequestDebt(affiliate, user, requestId) |
Per-request debt amount |
creditLineRequestActivated(affiliate, user, requestId) |
Whether per-request debt has been activated |
creditLinePaused(affiliate) |
Whether credit line is paused for this affiliate |
creditLineBlacklisted(affiliate, user) |
Whether user is blacklisted for this affiliate |
Internal credit line operations (reserveDebt, activate, settle,
releaseReservation, coverLoss) are called internally by the diamond's facets (e.g.,
SymmioHookFacet, AccelerateFacet, and OperatorFacet) via LibCreditLine:
they are not externally callable.
17.4 SYMMIO-gated functions (no role, msg.sender == symmio)
onWithdrawRequest, onWithdrawComplete, onWithdrawCancelRequest,
onWithdrawSuspend
Note: onForceWithdrawCancel is not part of the current interface. Suspend handles all post-cancel paths (with the
rollback path triggered when suspending from PROCESSED).
18. Operational invariants
18.1 Invariant check diagram
flowchart TD
C["Credit line invariant"] --> D["creditLineTotalDebt(aff) ==\ncreditLineReservedDebt(aff) +\ncreditLineActiveDebt(aff)"]
C --> E["Cap usage includes\ncreditLineTotalDebt(aff) +\ncreditLineBadDebt(aff)"]
F["Lock invariants"] --> G["lockedGeneralBalance <= generalBalance"]
F --> H["lockedAffiliateBalances[a] <= affiliateBalances[a]"]
J["State invariant"] --> K["Each (user, reqId) has\nexactly one status"]
18.2 Accounting invariants
-
[ ]
creditLineTotalDebt(affiliate) == creditLineReservedDebt(affiliate) + creditLineActiveDebt(affiliate)(credit line accounting, per-affiliate) -
[ ] Credit cap usage is
creditLineTotalDebt(affiliate) + creditLineBadDebt(affiliate), notcreditLineTotalDebtalone - [ ]
lockedGeneralBalance <= generalBalance - [ ]
lockedAffiliateBalances[a] <= affiliateBalances[a]for all affiliates - [ ] Each
(user, requestId)pair has exactly one status and follows valid transitions
18.3 Bot reliability requirements
- [ ] Idempotent event handling: Duplicate events must not cause duplicate actions
- [ ] State sync on restart: Read on-chain state to rebuild pending action queue
- [ ] Permissionless detection: Monitor for user-triggered processing and cancel own schedule
- [ ] Config change detection: Monitor available config events, poll eventless timing and role state, and invalidate stale signed options
- [ ] Nonce serialization: Serialize signing and acceptance submission against the user's current nonce. Once an acceptance consumes it, the next request may be accepted even while earlier request IDs remain in flight.
-
[ ] Gas management: Ensure sufficient gas for
processWithdrawandfinalizeWithdrawRequest - [ ] Block time awareness: Account for block time when computing deadlines (validator timeouts, security windows)
- [ ] Re-org handling: Handle chain re-orgs that may reverse accepted/processed states
18.4 Graceful degradation
| Failure Mode | System Behavior |
|---|---|
| Bot offline |
Any caller can processWithdraw after the route's processable timestamp plus
tolerancePeriod; STANDARD finalization is permissionless too, provided the provider is not
globally paused
|
| Bot fails to finalize | Anyone can call finalizeWithdrawRequest on SYMMIO after cooldown |
| Validator service offline | SAME_TX is unavailable. WINDOWED remains available only when its effective validator minimum is 0; STANDARD remains available without validator approvals |
| Insufficient pool liquidity | Only STANDARD available (no capital fronting) |
| Config change invalidates pending sigs | Re-sign options with updated config |
| ExpressProvider globally paused | New acceptance and user/operator mutations stop; SYMMIO completion/cancel/suspend callbacks and admin recovery remain available |
Numeric example: invariant verification after multiple operations
Scenario: Bot runs periodic sanity checks and detects a broken invariant
Setup:
ExpressProvider has processed several withdrawals across two affiliates.
Bot runs its invariant-check loop every 60 seconds.
Step 1: Bot runs: scheduled invariant check (all passing)
Bot reads on-chain:
collateral.balanceOf(ExpressProvider) = 15_500 USDC
generalBalance = 10_000
affiliateBalances[FrontendA] = 3_000
affiliateBalances[FrontendB] = 1_000
collectedFees[FrontendA] = 50
collectedFees[FrontendB] = 20
collectedOperatorFees[FrontendA] = 10
collectedOperatorFees[FrontendB] = 5
Sum of all pendingFees = 200
Sum of all pendingOperatorFees = 0
Finalized STANDARD awaiting processing = 1_215
Bot checks: token balance invariant
Expected minimum = 10_000 + 3_000 + 1_000 + 50 + 20 + 10 + 5
+ 200 (pending affiliate) + 0 (pending operator) + 1_215
= 15_500
Actual balance = 15_500
15_500 >= 15_500 --> PASS
Bot checks: lock invariants
lockedGeneralBalance = 2_000 <= generalBalance (10_000) --> PASS
lockedAffiliateBalances[A] = 500 <= affiliateBalances[A] (3_000) --> PASS
lockedAffiliateBalances[B] = 0 <= affiliateBalances[B] (1_000) --> PASS
Bot checks: Credit line invariant
expressProvider.creditLineTotalDebt(FrontendA) = 500
expressProvider.creditLineReservedDebt(FrontendA) = 200
expressProvider.creditLineActiveDebt(FrontendA) = 300
200 + 300 = 500 = totalDebt --> PASS
Decision: all invariants hold; continue normal operations
Step 2: Bot runs: next invariant check (failure detected)
Bot reads on-chain (after an unexpected external token transfer out):
collateral.balanceOf(ExpressProvider) = 14_200 USDC
(all accounting variables unchanged from Step 1)
Bot checks: token balance invariant
Expected minimum = 15_500 (same sum as before)
Actual balance = 14_200
14_200 < 15_500 --> FAIL (shortfall of 1_300 USDC)
Decision:
1. Emit critical alert to ops channel
2. Stop signing new withdrawal options (prevent further outflows)
3. Do NOT call processWithdraw for any pending withdrawals
4. Log the shortfall amount (1_300) and last-known-good block number
What-if: lock invariant fails instead (lockedGeneralBalance > generalBalance)?
This should be impossible via contract logic: it indicates a bug or
corrupted state read. Bot treats this as a critical alert and halts
all operations until manual investigation confirms root cause.
What-if: credit line invariant fails (totalDebt != reservedDebt + activeDebt)?
Bot stops offering credit-backed withdrawals for that affiliate.
Pool-only withdrawals from the general and affiliate pools can continue.
Bot alerts ops to investigate the credit line state on the diamond.
What-if: SYMMIO core pauses withdraw advances?
Core pauseControl.pauseWithdrawAdvance() makes every provider-side
advanceWithdraw call revert. Bot stops processing credit-backed WINDOWED,
SAME_TX, and acceleration paths, but may continue non-credit recovery
actions such as cancellation, suspension handling, and finalization.
Quick reference: complete bot action timeline
sequenceDiagram
participant User
participant Bot
participant SYMMIO
participant EP as ExpressProvider
User->>Bot: Request withdrawal options
Bot->>Bot: Compute fees, gather validators
Bot->>Bot: Sign EIP-712 option
Bot-->>User: Return options
User->>SYMMIO: initiateWithdraw(parts, providerData)
SYMMIO->>EP: onWithdrawRequest
EP->>SYMMIO: acceptWithdrawRequest
EP-->>Bot: WithdrawAccepted event
alt SAME_TX
Note over EP: Funds already transferred
Note over EP: Status = PROCESSED
else WINDOWED
Note over Bot: Wait 20s (securityWindow)
Bot->>EP: processWithdraw
Note over EP: Status = PROCESSED
else STANDARD
Note over Bot: Wait until cooldownEndTime
end
Bot->>SYMMIO: finalizeWithdrawRequest (at cooldownEndTime)
SYMMIO->>EP: onWithdrawComplete
Note over EP: Pools replenished / tokens arrive
alt STANDARD
Bot->>EP: processWithdraw (forward tokens)
Note over EP: Status = PROCESSED
end
Note over EP: Cycle complete
Part VI: reference
19. Complete state x option type decision matrix
This section covers every combination of ExpressProvider Status (7 values: NONE, ACCEPTED, LOCKED, PROCESSED, FINALIZED, CANCELLED, SUSPENDED) and OptionType (3 values: SAME_TX, WINDOWED, STANDARD), for 21 combinations in total.
Each cell lists the bot action, events, available callers, expected reverts, and timers. Combinations that the state machine cannot reach are marked as impossible and include an explanation.
Default contract parameters used in numeric scenarios:
securityWindow = 20stolerancePeriod = 60svalidatorApprovalTimeout(affiliate) = 30s
SAME_TX x NONE
Bot situation: No withdrawal exists yet for this (user, requestId). The bot may be about to sign a SAME_TX option.
Bot should:
- Pre-sign the EIP-712 WithdrawOption with
optionType = 0 (SAME_TX) -
Ensure
minValidatorSignatures(affiliate) > 0(contract reverts otherwise:ValidatorsRequiredForSameTx) -
Gather validator signatures (each from a registered validator for this affiliate or
address(0)default, withinvalidatorApprovalTimeout(affiliate)) - Verify sufficient general + affiliate pool liquidity for the express amount
- Verify credit line capacity (if creditAmount > 0)
Available actions:
- User: calls
initiateWithdrawon SYMMIO, which triggersonWithdrawRequeston ExpressProvider -
No Express processing call is needed; after
WithdrawProcessed, schedule core finalization atcooldownEndTime
Will revert:
-
processWithdraw: the zero-initialized option is SAME_TX and status isNONE, so it revertsNotAccepted lockWithdraw: reverts withNotAcceptedunlockAndProcess: reverts withNotLocked
Timers: None until acceptance; the emitted WithdrawProcessed starts the finalization timer.
SAME_TX x ACCEPTED
Impossible. SAME_TX withdrawals skip ACCEPTED entirely. In onWithdrawRequest, when
optionType == SAME_TX, the contract sets info.status = Status.PROCESSED directly after the same-tx
transfer. The else branch that sets Status.ACCEPTED is not reached for SAME_TX.
SAME_TX x LOCKED
Impossible. lockWithdraw requires info.status == ACCEPTED (reverts with
NotAccepted otherwise). Since SAME_TX never enters ACCEPTED, it can never be locked.
SAME_TX x PROCESSED
Bot situation: SAME_TX withdrawal completed in one transaction. Funds already transferred to the user.
Awaiting SYMMIO's core finalization to replenish pools. SYMMIO may also suspend the withdrawal during the cooldown, in which
case the post-payout rollback promotes pending fees, increments generalBadDebt, and covers credit loss from the
unlocked affiliate balance before recording any shortfall as badDebt; it does not replenish pools.
Bot should:
- Timer set: call
finalizeWithdrawRequest(user, reqId)on SYMMIO atcooldownEndTime -
Monitor for:
WithdrawFinalizedevent (fromonWithdrawComplete) andWithdrawSuspendedevent (fromonWithdrawSuspendrollback path) - No further user-facing action needed: the user already has their funds
Available actions:
- Bot or any address: call
finalizeWithdrawRequeston SYMMIO atcooldownEndTime - SYMMIO (callback):
onWithdrawCompletetransitions to FINALIZED and replenishes pools -
SYMMIO (callback):
onWithdrawSuspendtriggers_handleProcessedRollback: promotes pending fees, incrementsgeneralBadDebt, covers credit from unlocked affiliate balance where possible, records any deficit asbadDebt, settles credit, and sets status to SUSPENDED
Will revert:
processWithdraw:NotAccepted(status is PROCESSED, not ACCEPTED)lockWithdraw:NotAcceptedonWithdrawCancelRequest:NotAccepted(cancel callback only works from ACCEPTED)onWithdrawCompletebefore SYMMIO cooldown: SYMMIO-side revert (not an ExpressProvider check)
Timers:
- Finalization timer: the exact
cooldownEndTimecopied from the SYMMIO request
Numeric scenario:
acceptedAt = 1700000000, cooldownEndTime = 1700043200
Bot reads: status = PROCESSED, block.timestamp = 1700040000
Decision: Wait until cooldownEndTime = 1700043200
At T=1700043200:
Bot calls: SYMMIO.finalizeWithdrawRequest(user, reqId)
SYMMIO calls: EP.onWithdrawComplete(...)
Result: status -> FINALIZED, generalBalance += generalAmount, affiliateBalances[aff] += affiliateAmount
SAME_TX x FINALIZED
Bot situation: Terminal state. SYMMIO has finalized and pools are replenished. No further action needed.
Bot should:
- Archive this withdrawal in the database
- Remove all timers for this (user, requestId)
Available actions: None. All state-changing functions will revert.
Will revert:
processWithdraw:NotAcceptedlockWithdraw:NotAccepted-
onWithdrawComplete: status is FINALIZED, no longer in a callable state; SYMMIO will not call the callback again on a finalized record onWithdrawCancelRequest:NotAcceptedonWithdrawSuspend:InvalidStatusForSuspend
Timers: None.
SAME_TX x CANCELLED
Impossible. SAME_TX goes directly from NONE to PROCESSED in onWithdrawRequest. The
onWithdrawCancelRequest callback requires status == ACCEPTED. Since SAME_TX never enters ACCEPTED,
cancellation is unreachable. onForceWithdrawCancel is not part of the current interface.
SAME_TX x SUSPENDED
Reachable only via post-payout rollback from PROCESSED. onWithdrawSuspend accepts
status == ACCEPTED, status == LOCKED, or status == PROCESSED. Because SAME_TX skips
ACCEPTED and LOCKED, the only way a SAME_TX withdrawal can land in SUSPENDED is via onWithdrawSuspend called from
PROCESSED (during the SYMMIO cooldown). That path runs _handleProcessedRollback: it promotes pending fees,
increments generalBadDebt, deducts up to the unlocked affiliate balance for credit loss, records any credit
deficit as badDebt, settles the debt, and transitions to SUSPENDED without replenishing pools.
Bot situation when seen: Terminal state. The bot should archive the withdrawal, log the rollback for
compliance review, and reconcile generalBadDebt, promoted fees, and how much credit loss was covered by the
affiliate pool versus recorded as badDebt.
WINDOWED x NONE
Bot situation: No withdrawal exists yet. The bot may be about to sign a WINDOWED option.
Bot should:
-
Check that
generalBalance - lockedGeneralBalance >= generalAmount(elseInsufficientGeneralBalance) -
Check that
affiliateBalances[aff] - lockedAffiliateBalances[aff] >= affiliateAmount(elseInsufficientAffiliateBalance) - Check credit line capacity (if creditAmount > 0)
-
Sign the EIP-712 WithdrawOption with
optionType = 1 (WINDOWED); use canonicalavailableAt = 0while the stored field remains unused by processing - Gather validator signatures only if
minValidatorSignatures(affiliate) > 0
Available actions:
- User: calls
initiateWithdrawon SYMMIO
Will revert:
- All ExpressProvider functions targeting this (user, requestId): no info exists yet
Timers: None yet.
WINDOWED x ACCEPTED
Bot situation: WINDOWED withdrawal accepted, funds locked in pools. Waiting for securityWindow before processing.
Bot should:
- Run the risk check
- Timer set:
processWithdrawatacceptedAt + securityWindow - Monitoring for:
WithdrawLocked,WithdrawCancelled,WithdrawSuspended
Available actions:
- OPERATOR_ROLE:
processWithdraw(user, reqId, parts)afteracceptedAt + securityWindow - LOCKER_ROLE:
lockWithdraw(user, reqId)anytime while ACCEPTED -
Anyone:
processWithdraw(user, reqId, parts)afteracceptedAt + securityWindow + tolerancePeriod - SYMMIO (callback):
onWithdrawCancelRequesttransitions to CANCELLED - SYMMIO (callback):
onWithdrawSuspendtransitions to SUSPENDED
Will revert:
processWithdrawbeforeacceptedAt + securityWindow:TooEarly-
processWithdrawby non-operator beforeacceptedAt + securityWindow + tolerancePeriod:TooEarly lockWithdrawif status already changed:NotAcceptedunlockAndProcess:NotLocked(status is ACCEPTED, not LOCKED)onWithdrawComplete:InvalidStatusForComplete(not yet processed)
Numeric scenario:
acceptedAt = 1700000000, securityWindow = 20s, tolerancePeriod = 60s
Bot reads: status = ACCEPTED, block.timestamp = 1700000015
Bot checks: 1700000015 < 1700000020? YES: too early
Decision: Wait 5 more seconds
At T=1700000021:
Bot reads: status still ACCEPTED
Bot checks: 1700000021 >= 1700000020? YES
Decision: Call processWithdraw(user, reqId, parts)
Non-operator at T=1700000075:
Checks: 1700000075 >= 1700000020 + 60 = 1700000080? NO: too early
Must wait until T=1700000080
At T=1700000081:
Anyone can call processWithdraw(user, reqId, parts)
WINDOWED x LOCKED
Bot situation: WINDOWED withdrawal was risk-flagged by the LOCKER_ROLE. Funds remain locked in pools. Processing is blocked.
Bot should:
- Investigate the risk flag
- If false alarm: request UNLOCK_ROLE holder to call
unlockAndProcess - If confirmed threat: request a core
SUSPENDER_ROLEholder to callsuspendWithdrawRequest -
Monitor for:
WithdrawUnlockedAndProcessed,WithdrawSuspended(note:WithdrawCancelledis not reachable from LOCKED: the cancel callback only fires from ACCEPTED) -
Fallback: if investigation takes too long, OPERATOR_ROLE can call
processWithdrawatcooldownEndTime; anyone can use the sameisLockedAfterCooldownpath after the additionaltolerancePeriod
Available actions:
- UNLOCK_ROLE:
unlockAndProcess(user, reqId, parts)(no-delay, no time gate) -
OPERATOR_ROLE:
processWithdraw(user, reqId, parts)aftercooldownEndTime(locked-after-cooldown path) - Anyone:
processWithdraw(user, reqId, parts)aftercooldownEndTime + tolerancePeriod -
SYMMIO (callback):
onWithdrawSuspendtransitions to SUSPENDED (releases pool locks and credit reservation)
Will revert:
-
processWithdrawbeforecooldownEndTime:NotAccepted(status is LOCKED,isLockedAfterCooldownis false) lockWithdraw:NotAccepted(already LOCKED)onWithdrawCancelRequest:NotAccepted(cancel callback only works from ACCEPTED)onWithdrawComplete:InvalidStatusForComplete
Numeric scenario:
acceptedAt = 1700000000, cooldownEndTime = 1700043200
Bot reads: status = LOCKED, block.timestamp = 1700000100
isLockedAfterCooldown = (LOCKED && 1700000100 >= 1700043200)? NO
Decision: Cannot process. Await UNLOCK_ROLE or SYMMIO suspend.
At T=1700043201 (cooldown expired, still LOCKED):
isLockedAfterCooldown = (LOCKED && 1700043201 >= 1700043200)? YES
OPERATOR_ROLE can call processWithdraw now.
processableAt = cooldownEndTime = 1700043200
1700043201 >= 1700043200? YES: proceed
Non-operator at T=1700043261:
processableAt = 1700043200 + 60 = 1700043260
1700043261 >= 1700043260? YES: permissionless process
WINDOWED x PROCESSED
Bot situation: Funds have been transferred to the user. Awaiting SYMMIO finalization to replenish pools.
SYMMIO may also suspend the withdrawal during the cooldown, in which case the contract takes the post-payout rollback path
(promotes fees, increments generalBadDebt, covers credit from unlocked affiliate balance, records any credit
shortfall as badDebt, and does not replenish pools).
Bot should:
- Timer set: call
finalizeWithdrawRequest(user, reqId)on SYMMIO atcooldownEndTime -
Monitor for:
WithdrawFinalizedevent andWithdrawSuspendedevent (rollback path)
Available actions:
- Bot or any address: call
finalizeWithdrawRequeston SYMMIO atcooldownEndTime - SYMMIO (callback):
onWithdrawCompletereplenishes pools and transitions to FINALIZED -
SYMMIO (callback):
onWithdrawSuspendtriggers_handleProcessedRollback: promotes pending fees, incrementsgeneralBadDebt, covers credit from unlocked affiliate balance, records any deficit asbadDebt, settles credit, and sets status to SUSPENDED without replenishing pools
Will revert:
processWithdraw:NotAcceptedlockWithdraw:NotAcceptedonWithdrawCancelRequest:NotAccepted(cancel callback only works from ACCEPTED)
Timers:
- Finalization timer:
cooldownEndTime
Numeric scenario:
acceptedAt = 1700000000, cooldownEndTime = 1700043200
Bot reads: status = PROCESSED, block.timestamp = 1700042000
Decision: Wait until 1700043200, then call SYMMIO.finalizeWithdrawRequest(user, reqId)
At T=1700043200:
Bot calls: SYMMIO.finalizeWithdrawRequest(user, reqId)
SYMMIO calls: EP.onWithdrawComplete(...)
Result: status -> FINALIZED
generalBalance += generalAmount
affiliateBalances[affiliate] += affiliateAmount
WINDOWED x FINALIZED
Bot situation: Terminal state. SYMMIO finalized, pools replenished. No further action.
Bot should:
- Archive this withdrawal
- Remove all timers
Available actions: None.
Will revert: All state-changing functions revert (same as SAME_TX x FINALIZED).
Timers: None.
WINDOWED x CANCELLED
Bot should:
- Archive this withdrawal
- Remove all timers
- Note: counter-locks were released and pool balances never changed: no reimbursement is needed
Available actions: None.
Will revert: All state-changing functions revert.
Timers: None.
WINDOWED x SUSPENDED
Bot situation: Terminal state. A core SUSPENDER_ROLE holder suspended this withdrawal, causing
onWithdrawSuspend. Reachable from ACCEPTED, LOCKED, or PROCESSED:
- From ACCEPTED/LOCKED (pre-payout): pool counter-locks and any credit reservation are released; balances do not change.
-
From PROCESSED (post-payout):
_handleProcessedRollbackdeducts up to the unlocked affiliate balance to absorb the credit loss, records any deficit asbadDebt, promotes pending fees, incrementsgeneralBadDebt, and settles the debt without replenishing pools.
Bot should:
- Archive this withdrawal
- Remove all timers
- Log the suspension for compliance review
Available actions: None.
Will revert: All state-changing functions revert.
Timers: None.
STANDARD x NONE
Bot situation: No withdrawal exists yet. The bot may be about to sign a STANDARD option. STANDARD means no capital fronting: Express acts as an intermediary and forwards tokens after the cooldown configured by SYMMIO.
Bot should:
- No pool liquidity check needed (no capital is fronted)
-
Sign the EIP-712 WithdrawOption with
optionType = 2 (STANDARD); use canonicalavailableAt = 0while the stored field remains unused by processing - Do not gather validator signatures; STANDARD acceptance skips validator validation
Available actions:
- User: calls
initiateWithdrawon SYMMIO
Will revert:
- All ExpressProvider functions targeting this (user, requestId): no info exists yet
Timers: None yet.
STANDARD x ACCEPTED
Bot situation: STANDARD withdrawal accepted. No pools locked, no capital fronted. Waiting for SYMMIO's
configured cooldown to complete, at which point onWithdrawComplete will deliver the tokens and transition to
FINALIZED.
Bot should:
- Timer set: call
finalizeWithdrawRequest(user, reqId)on SYMMIO atcooldownEndTime -
Run the risk check.
LOCKER_ROLEcan lock whenever the request remains ACCEPTED; the intended risk window is beforecooldownEndTime, but the contract imposes no timestamp limit. -
Monitoring for:
WithdrawLocked,WithdrawCancelled,WithdrawSuspended,WithdrawFinalized
Available actions:
- LOCKER_ROLE:
lockWithdraw(user, reqId)anytime while ACCEPTED - SYMMIO (callback):
onWithdrawCompletetransitions to FINALIZED (tokens arrive) - SYMMIO (callback):
onWithdrawCancelRequesttransitions to CANCELLED - SYMMIO (callback):
onWithdrawSuspendtransitions to SUSPENDED
Will revert:
-
processWithdraw:NotFinalized(status is ACCEPTED, not FINALIZED, and not locked-after-cooldown) unlockAndProcess:NotLockedonWithdrawCompletebefore SYMMIO cooldown: SYMMIO-side revert
Timers:
- Finalization timer:
cooldownEndTime
Numeric scenario:
acceptedAt = 1700000000, cooldownEndTime = 1700043200
Bot reads: status = ACCEPTED, block.timestamp = 1700020000
Decision: Wait until cooldownEndTime to finalize
At T=1700043200:
Bot calls: SYMMIO.finalizeWithdrawRequest(user, reqId)
SYMMIO calls: EP.onWithdrawComplete(...)
status == ACCEPTED? YES -> status = FINALIZED
finalizedAt = block.timestamp
Tokens are now held by ExpressProvider
Bot calls processWithdraw right away(user, reqId, parts)
status == FINALIZED? YES
processableAt = finalizedAt (STANDARD, operator)
block.timestamp >= finalizedAt? YES
Decision: Transfer tokens to receivers, status -> PROCESSED
STANDARD x LOCKED
Bot situation: STANDARD withdrawal was risk-flagged during the configured cooldown. When
onWithdrawComplete is called by SYMMIO, the contract preserves the LOCKED status (does NOT transition to
FINALIZED), but sets finalizedAt so that tokens are known to have arrived.
Bot should:
- Investigate the risk flag
-
If false alarm AND
finalizedAt != 0(tokens arrived): request UNLOCK_ROLE holder to callunlockAndProcess -
If false alarm AND
finalizedAt == 0(tokens not yet arrived): wait foronWithdrawCompletefirst, thenunlockAndProcess -
If confirmed threat: request a core
SUSPENDER_ROLEholder to suspend (only iffinalizedAt == 0, elseInvalidStatusForSuspend) -
Fallback: after
cooldownEndTime, OPERATOR_ROLE can callprocessWithdrawwhich will auto-finalize from SYMMIO if needed
Available actions:
-
UNLOCK_ROLE:
unlockAndProcess(user, reqId, parts)(requiresfinalizedAt != 0, elseNotFinalized) -
OPERATOR_ROLE:
processWithdraw(user, reqId, parts)aftercooldownEndTime-
If
finalizedAt == 0: auto-callsSYMMIO.finalizeWithdrawRequestfirst (theisLockedAfterCooldownpath) - Then processes normally
-
If
- Anyone:
processWithdraw(user, reqId, parts)aftercooldownEndTime + tolerancePeriod - SYMMIO (callback):
onWithdrawCompletesetsfinalizedAtbut keeps LOCKED - SYMMIO (callback):
onWithdrawSuspendtransitions to SUSPENDED (only iffinalizedAt == 0)
Will revert:
lockWithdraw:NotAccepted(already LOCKED)onWithdrawCancelRequest:NotAccepted(cancel callback only works from ACCEPTED)unlockAndProcesswhenfinalizedAt == 0:NotFinalizedonWithdrawSuspendwhenfinalizedAt != 0:InvalidStatusForSuspend-
processWithdrawbeforecooldownEndTime(while locked):NotFinalizedandisLockedAfterCooldownis false
Numeric scenario:
acceptedAt = 1700000000, cooldownEndTime = 1700043200
Scenario A: LOCKED before finalization:
Bot reads: status = LOCKED, finalizedAt = 0, block.timestamp = 1700020000
unlockAndProcess? Reverts: NotFinalized (finalizedAt == 0)
Decision: Wait for onWithdrawComplete or request SYMMIO suspend
At T=1700043200:
Bot calls: SYMMIO.finalizeWithdrawRequest(user, reqId)
SYMMIO calls: EP.onWithdrawComplete(...)
status == LOCKED -> stays LOCKED, finalizedAt = 1700043200
Now UNLOCK_ROLE can call unlockAndProcess:
status == LOCKED? YES
finalizedAt != 0? YES (= 1700043200)
Transfers tokens to receivers, status -> PROCESSED
Scenario B: locked-after-cooldown path:
At T=1700043201 (cooldown passed, still LOCKED, finalizedAt may be 0):
OPERATOR_ROLE calls processWithdraw:
isLockedAfterCooldown = (LOCKED && 1700043201 >= 1700043200)? YES
STANDARD path: finalizedAt == 0? Calls SYMMIO.finalizeWithdrawRequest first
Then processes normally, status -> PROCESSED
STANDARD x PROCESSED
Bot situation: Tokens have been forwarded to the user. For STANDARD, this is the effective terminal state from the user's perspective. The FINALIZED transition already happened before PROCESSED.
Note: Unlike WINDOWED/SAME_TX where PROCESSED -> FINALIZED, for STANDARD the flow is ACCEPTED -> FINALIZED ->
PROCESSED (or LOCKED -> PROCESSED via unlockAndProcess/locked-after-cooldown). The onWithdrawComplete callback
that would transition PROCESSED -> FINALIZED does not apply here because STANDARD's
onWithdrawComplete requires status == ACCEPTED || status == LOCKED.
Bot should:
- Archive this withdrawal
- Remove all timers
- No pool replenishment step: STANDARD never fronted capital from pools
Available actions: None meaningful. The withdrawal lifecycle is complete.
Will revert:
processWithdraw:NotFinalized(status is PROCESSED, not FINALIZED)lockWithdraw:NotAcceptedonWithdrawComplete:InvalidStatusForStandard(expects ACCEPTED or LOCKED)onWithdrawCancelRequest:NotAccepted-
onWithdrawSuspend:InvalidStatusForSuspend(STANDARD does not support post-finalization rollback; it never fronts capital so there is nothing to roll back)
Timers: None.
STANDARD x FINALIZED
Bot situation: SYMMIO has sent the tokens to ExpressProvider via onWithdrawComplete. The
contract holds the tokens and is ready for the bot to forward them to the user via processWithdraw.
Bot should:
- Call
processWithdraw(user, reqId, parts)to forward tokens to receivers -
For STANDARD,
processableAt = finalizedAt(operator) orfinalizedAt + tolerancePeriod(anyone) - No additional delay applies to OPERATOR_ROLE after finalizedAt is set
Available actions:
-
OPERATOR_ROLE:
processWithdraw(user, reqId, parts)right away (processableAt = finalizedAt, which is now) - Anyone:
processWithdraw(user, reqId, parts)afterfinalizedAt + tolerancePeriod
Will revert:
processWithdrawby non-operator beforefinalizedAt + tolerancePeriod:TooEarlylockWithdraw:NotAcceptedunlockAndProcess:NotLockedonWithdrawComplete:InvalidStatusForStandardonWithdrawCancelRequest:NotAcceptedonWithdrawSuspend:InvalidStatusForSuspend(STANDARD at FINALIZED cannot be suspended)
Numeric scenario:
acceptedAt = 1700000000, cooldownEndTime = 1700043200
At T=1700043200:
Bot calls: SYMMIO.finalizeWithdrawRequest(user, reqId)
SYMMIO calls: EP.onWithdrawComplete(...)
status = FINALIZED, finalizedAt = 1700043200
Bot calls processWithdraw right away(user, reqId, parts)
status == FINALIZED? YES
processableAt = finalizedAt = 1700043200
hasRole(OPERATOR_ROLE)? YES -> no tolerancePeriod added
block.timestamp = 1700043200 >= 1700043200? YES
Decision: Transfer tokens to receivers, status -> PROCESSED
Non-operator at T=1700043250:
processableAt = 1700043200 + 60 = 1700043260
1700043250 >= 1700043260? NO: TooEarly, wait 10s
STANDARD x CANCELLED
Bot should:
- Archive this withdrawal
- Remove all timers
Available actions: None.
Will revert: All state-changing functions revert.
Timers: None.
STANDARD x SUSPENDED
Bot should:
- Archive this withdrawal
- Remove all timers
- Log for compliance review
Available actions: None.
Will revert: All state-changing functions revert.
Timers: None.
Summary matrix
The table below provides a quick-reference view. "I" = Impossible combination. Terminal states (FINALIZED, CANCELLED, SUSPENDED) are marked with their nature.
| Status \ OptionType | SAME_TX | WINDOWED | STANDARD |
|---|---|---|---|
| NONE | Sign option, gather validators (per-affiliate) | Sign option, check pool liquidity | Sign option, no liquidity needed |
| ACCEPTED | I: skips to PROCESSED | Wait securityWindow, then processWithdraw | Wait for onWithdrawComplete at cooldown end |
| LOCKED | I: never ACCEPTED | Await UNLOCK_ROLE or cooldown expiry | Await UNLOCK_ROLE or cooldown expiry + finalize |
| PROCESSED | Await finalization at cooldown end; suspend remains possible (rollback) | Await finalization at cooldown end; suspend remains possible (rollback) | Terminal (no pool replenishment) |
| FINALIZED | Terminal (pools replenished) | Terminal (pools replenished) | Forward tokens via processWithdraw |
| CANCELLED | I: never ACCEPTED | Terminal (locks released, from ACCEPTED only) | Terminal (cancel from ACCEPTED only) |
| SUSPENDED | Terminal: only via PROCESSED rollback path | Terminal (from ACCEPTED/LOCKED: locks released; from PROCESSED: rollback) | Terminal (from ACCEPTED/LOCKED with finalizedAt == 0) |
Key differences by option type
| Aspect | SAME_TX | WINDOWED | STANDARD |
|---|---|---|---|
| Capital fronted? | Yes (same-tx) | Yes (pools locked) | No |
| Validators required? | Always | Only if minValidatorSignatures(affiliate) > 0 |
No; STANDARD acceptance skips validator validation |
| processWithdraw needed? | No | Yes | Yes (after finalization) |
| processableAt (operator) | N/A | acceptedAt + securityWindow |
finalizedAt |
| processableAt (anyone) | N/A | acceptedAt + securityWindow + tolerancePeriod |
finalizedAt + tolerancePeriod |
| User-cancellable? | No (already processed) | Yes (while ACCEPTED) | Yes (while ACCEPTED) |
| Force-cancellable? | N/A: onForceWithdrawCancel is not exposed; use suspend |
N/A: onForceWithdrawCancel removed; use suspend |
N/A: onForceWithdrawCancel removed; use suspend |
| Suspendable? | Yes: only from PROCESSED (post-payout rollback path) | Yes (ACCEPTED, LOCKED, or PROCESSED: PROCESSED triggers rollback) | Yes (ACCEPTED or LOCKED, if finalizedAt == 0) |
| Pool replenishment | onWithdrawComplete |
onWithdrawComplete |
N/A (no pool capital used) |
| Lifecycle order | NONE->PROCESSED->FINALIZED | NONE->ACCEPTED->PROCESSED->FINALIZED | NONE->ACCEPTED->FINALIZED->PROCESSED |