MultiCollateralVault
MultiCollateralVault is the contract that actually holds your collateral. It is
the on-chain half of Senticore's hybrid design: matching, risk, and sequencing
happen off-chain in a deterministic Rust engine, but the real ERC-20 tokens
(e.g. USDC, USDT) sit in this vault, and they only ever leave against a Merkle
proof of a withdraw root published on-chain. There is no "operator holds your
funds and you trust the operator" step.
If you are a market maker or an institutional desk deciding whether your collateral is safe here, this is the contract to read. The three questions that matter - can the operator take my money, can the operator trap my money, and what happens if the operator vanishes - are answered by mechanisms in this contract, not by a promise:
- Can't be taken. Withdrawals pay out only against a claim leaf proven under
the on-chain
withdrawRoot. The operator cannot invent a payout to itself; it can only finalize withdrawals that already exist in committed state. - Can't be trapped. A user can self-execute their own committed claim, and self-execution bypasses guardian pauses. If the exchange stops publishing at all, anyone can force the vault into exit mode and users withdraw directly against a frozen exit tree with a Merkle proof.
- Blast radius is bounded. Per-asset withdrawal rate limits and a circuit breaker cap how fast any single asset can drain, so a forged-root or compromised-executor scenario auto-pauses long before it empties the vault.
Senticore is in capped private beta. No public mainnet vault is deployed and there are no production addresses yet. Parameters below are the values encoded in the contract today; several are governance-tunable and may change before the audited mainnet release. See Contract Addresses for the authoritative (currently empty) deployment list and Trust Model for how this contract fits the wider verify-don't-trust design.
Design at a glance
- Immutable, non-upgradeable. There is no proxy and no upgrade path. A critical bug is handled by deploying a new vault and pausing the old one in a configuration that keeps existing claims redeemable - not by mutating live custody code under users.
- Append-only asset registry. Assets can be added (under multi-party consent) but never removed, replaced, remapped, or have their token/decimals changed. What you deposited against is exactly what you withdraw against.
- Native units only. The vault performs no decimal normalization. Amounts are always in the token's own units, and every transfer in and out is exact-balance checked.
- Self-custody accounting. The vault tracks per-asset deposits and withdrawals so on-chain balances can be reconciled against committed state; it is not a pooled fund with discretionary management.
Asset identity and the append-only registry
Every asset has a permanent identifier derived from the token, its decimals, and the chain:
assetId = keccak256(abi.encode(token, decimals, block.chainid))
This identity is frozen at activation and can never change. Concretely:
- Decimals must be in
[2, 18]and are cross-checked once at activation against the token's ownIERC20Metadata.decimals()value, then frozen. A token that misreports or later "changes" its decimals cannot be silently remapped - theassetIditself commits to the decimals used. - Existing assets are immutable. No function can remove an asset, swap its token address, or change its decimals. This is what lets off-chain accounting and on-chain custody stay in lock-step forever.
- Declared rebasing tokens are rejected as defense-in-depth: activation
static-calls
isRebasingToken()/rebasing()and refuses a token that returnstrue. The real allowlist is the governance registry - only assets explicitly added can ever be deposited - but this guard blocks an obvious class of supply-shifting tokens that would break the exact-balance model.
Adding an asset: 3-of-4 consent
Listing a new collateral asset is a deliberately multi-party action so that no single key can introduce a malicious or misconfigured token. It follows a schedule -> approve -> activate flow:
scheduleAssetAdd(assetId, token, decimals, permitSupported, minDeposit, maxVaultBalance)- the governor proposes the asset. A fresh proposal hash (with an incrementing nonce) is recorded, so approvals always bind to this exact proposal.
approveAssetAdd(assetId)- each of the four designated asset approvers independently signs off. Three of the four must approve.activateAssetAdd(assetId)- once the approval threshold is met and the timelock has elapsed, activation re-verifies decimals against the token, rejects declared rebasing tokens, and enables deposits immediately.
ASSET_ADD_TIMELOCK is currently 0The asset-add timelock constant is 0 in this build, so an asset with 3-of-4
approval can be activated in the same window it becomes eligible. The security
property here is the multi-party approval, not a waiting period. This may be
raised before mainnet.
The approver set is managed by setAssetAddApprovers(address[4]), which enforces
four distinct non-zero addresses. A pending proposal can be withdrawn with
cancelAssetAdd.
Deposits
Deposits are permissionless for any activated asset (subject to pauses):
| Function | Notes |
|---|---|
deposit(assetId, amount) | Standard transferFrom-based deposit. Requires prior ERC-20 approval. |
depositWithPermit(assetId, amount, deadline, v, r, s) | Single-transaction deposit using an EIP-2612 permit. Only for assets flagged permitSupported. |
Both paths:
- Exact-balance check the received amount (
balanceOfbefore/after must differ by exactlyamount), so fee-on-transfer or non-standard tokens revert rather than silently under-crediting you. - Enforce a per-asset
minDepositfloor and amaxVaultBalancecap (0means unlimited). The cap is measured against tracked net balance (totalDeposits - totalWithdrawals), giving governance a hard ceiling on per-asset exposure during the capped beta. - Revert if
depositsPaused, the asset's deposits are disabled, or the vault is in exit mode.
Withdrawals: request -> wait -> proof-backed claim
This is the core institutional story. A withdrawal is not an operator instruction that could be forged or front-run. It is a three-step flow where funds move only against a Merkle proof of a published withdraw root.
1. Request
requestWithdraw(assetId, amount, recipient, nonce, expiresAt) creates a request
bound to a deterministic id from computeWithdrawRequestId(...) - which commits
to the chain id, the vault address, the asset, the token, the user, the
recipient, the amount, and the nonce. That binding is what stops a
finalized claim from being redirected: the payout recipient is fixed at request
time and re-checked at execution.
Only one active request per (user, asset) is allowed. Timing is governed by three policy parameters:
| Parameter | Default | Meaning |
|---|---|---|
withdrawChallengeWindowSec | 10 minutes | Minimum delay before a request can execute. earliestExecuteAt = requestTime + challengeWindow. Gives watchers time to react before funds move. |
withdrawRequestTtlSec | 7 days | Upper bound on how far in the future expiresAt may be set. Requests can't linger indefinitely. |
withdrawMinBatchDelta | 1 | The claim must execute at a batch id >= requestBatchId + minBatchDelta, i.e. strictly after the batch that was latest when you requested. |
So a valid claim must land in a batch that is both new enough
(>= minExecutableBatchId) and already committed (<= latest committed batch), and only inside the [earliestExecuteAt, expiresAt] time window.
2. Batch commit (off-chain -> on-chain)
The off-chain engine includes your claim leaf in a batch, and the
StateCommitment contract publishes that batch's
withdrawRoot on-chain. Until a root containing your leaf exists, there is
nothing to prove against and execution reverts.
3. Execute with claim
executeWithdrawWithClaim(claim, merkleProof) finalizes the withdrawal:
- The claim leaf (
hashWithdrawalClaim) is verified againststateCommitment.getWithdrawRoot(claim.batchId). A bad proof or an unknown root reverts. - The claim's
requestId, asset, recipient, amount,expiresAt, and nonce must match the stored request exactly. - Double-spend is impossible:
claimExecuted[claimId]is set on execution and re-checked, and the request id is marked consumed. - Payout cannot exceed tracked remaining balance for the asset.
Who can submit. Either the user themselves, or an address holding
WITHDRAW_EXECUTOR_ROLE. In normal operation the exchange's executor worker
finalizes withdrawals for users (a smoother UX). But because the user can
always submit their own committed claim, finalization is never something the
operator can withhold once your leaf is in a published root.
Executor fee
When the executor worker finalizes on a user's behalf, it takes a small same-asset fee out of the gross claim:
| Parameter | Default | Max | Meaning |
|---|---|---|---|
withdrawExecutorFeeBps | 10 bps (0.10%) | 100 bps (1.00%) | Fee paid to the submitting executor, deducted from the claim amount. |
Self-execution pays no fee. If you submit your own claim (msg.sender == claim.user), you receive the full amount. The fee only ever applies to
executor-submitted withdrawals, and it is capped at 100 bps in the contract.
Cancellation
cancelWithdrawRequest(assetId) lets a user cancel their active request even
if the backend has already included its claim leaf in a published withdraw
root. Cancellation permanently consumes that exact requestId - you cannot
re-use it. To withdraw afterward you submit a fresh request with a new nonce
and wait for a later eligible batch. Expired requests are likewise cleared
automatically the next time you request against the same asset.
Rate limiting and the circuit breaker
Even with proof-backed withdrawals, a catastrophic failure - a forged root, a compromised executor key, a bug - should not be able to drain an asset before humans can respond. The vault enforces per-asset withdrawal rate limits with an automatic circuit breaker, all denominated in basis points of the asset's tracked TVL.
Each asset carries two fixed accumulator windows - a 1-hour bucket and a 24-hour bucket - that reset when their window fully elapses. On every withdrawal, the amount is added to both buckets, and execution reverts if either bucket would exceed its limit relative to the window's starting TVL basis:
| Parameter | Default | Meaning |
|---|---|---|
maxPerHourBps | 1000 (10%) | Max cumulative withdrawals per 1h window, as bps of tracked TVL. |
maxPerDayBps | 5000 (50%) | Max cumulative withdrawals per 24h window, as bps of tracked TVL. |
autoPauseThresholdBps | 8000 (80%) | Fraction of the day budget whose consumption trips the auto-pause. |
In plain terms with the defaults: an asset can bleed at most 10% of its TVL per hour and 50% per day, and when a day's withdrawals reach 80% of that day budget (i.e. ~40% of TVL) the breaker fires. The exact trigger the contract computes is:
trigger = dayTvlBasis * maxPerDayBps * autoPauseThresholdBps / (10000 * 10000)
When dayBucket >= trigger, the breaker:
- Auto-pauses that asset's withdrawals (
withdrawalsEnabled = false,withdrawAutoPaused = true), and - optionally cascades to a global withdraw-execution pause if
autoPauseGlobalOnTriggeris set (defaulttrue), stopping executor- driven withdrawals across all assets until governance reviews.
The breaker turns "the vault is being drained" from a race against block times into a bounded, self-halting event. An attacker who somehow produces a bad claim cannot quietly empty an asset - they hit the per-asset ceiling and trip a pause that a human guardian and governor then have to clear.
Recovery is deliberately a governor action, not automatic:
recoverFromAutoPause(assetId) atomically resets the buckets and re-enables the
asset - but it never clears the global pause flags. Governance can also tune
limits with setWithdrawRateLimit, reset buckets with
resetWithdrawRateLimitBuckets, or toggle the cascade with
setAutoPauseGlobalOnTrigger.
Roles and governance
Access control is intentionally minimal and split by blast radius:
| Role | Powers |
|---|---|
DEFAULT_ADMIN_ROLE | Grant/revoke roles. Nothing else. |
GOVERNOR_ROLE | Registry (schedule/cancel asset adds, approver set), all parameters (rate limits, caps, min deposits, fees, withdraw policy), unpause, circuit-breaker recovery, and one-time state-commitment initialization. |
GUARDIAN_ROLE | Emergency pause only - per-asset and global deposit/withdraw pauses, plus pauseAll() one-transaction shutdown, and guardianActivateExitMode(). Cannot unpause and cannot move funds. |
WITHDRAW_EXECUTOR_ROLE | Finalize user-requested withdrawals by submitting a valid claim + proof. All ownership, recipient, amount, nonce, batch, and proof checks still apply - the executor is a convenience relay, not a privileged spender. |
The contract does not self-impose a delay on governor parameter changes. The
production safety model is that GOVERNOR_ROLE and DEFAULT_ADMIN_ROLE are held
by a TimelockController, while GUARDIAN_ROLE is held by a Safe
multisig directly (no timelock) so emergency pauses are instant. In other
words: pausing is fast, changing the rules or moving power is slow and
publicly visible. Never point these roles at a plain EOA in production.
Guardian pauses split into independent flags - depositsPaused,
withdrawRequestsPaused, and withdrawExecutionPaused - which is what makes the
"deploy a new vault" bug-response viable: the old vault can be frozen with
depositsPaused = true, withdrawRequestsPaused = true, withdrawExecutionPaused = false so existing claims remain redeemable while new activity is stopped.
Self-custody and censorship resistance
This section is the strongest reason an institutional depositor can treat the vault as genuinely non-custodial. Two layers protect a user against an operator that turns hostile or simply goes dark.
Layer 1 - self-execution bypasses pauses (but not the breaker)
A user submitting their own committed claim bypasses guardian pauses (both the
global withdrawExecutionPaused and the per-asset withdrawalsEnabled flag). A
censored user can therefore always pull a claim that is already in a published
root, even while the operator has paused executor-driven withdrawals.
Self-execution deliberately does not bypass the abnormal-outflow circuit
breaker (withdrawAutoPaused). That distinction is the whole point: the breaker
exists to stop a forged-root drainer, who would otherwise self-execute to dodge
guardian pauses. So self-exit defeats censorship, while the breaker still defeats
a mass drain. A genuinely censored user whose asset is breaker-paused falls
through to Layer 2.
Layer 2 - forced withdrawal and exit mode
If the operator stops serving you entirely, you escalate through a committed-state-anchored complaint. Every trigger here is derived from a Merkle proof against on-chain committed state - never a user-asserted amount - so it cannot be abused to grief the exchange into a halt.
Forced withdrawal (censorship complaint). requestForcedWithdrawal(claim, merkleProof) files a complaint bound to a Merkle proof that you actually hold
claim.amount of the asset in the currently committed exit tree (StarkEx-style).
An account with no committed balance has no exit leaf and simply cannot file. The
operator has a grace period of FORCED_WITHDRAWAL_GRACE_SEC = 7 days to serve
you. Serving you (a normal withdrawal) reduces your committed balance below the
proven amount, so the later re-proof fails - the operator defuses a genuine
complaint by honoring it, and can only trip exit mode by actually censoring.
You can also cancelForcedWithdrawal yourself if the issue is resolved.
Exit mode freezes the vault and lets everyone withdraw directly against a frozen exit root. It can be entered three ways:
| Entry point | Who | Precondition |
|---|---|---|
activateExitMode() | Anyone (permissionless) | The latest checkpoint is older than EXIT_MODE_TIMEOUT_SEC = 72h - i.e. the operator has stopped committing state. |
guardianActivateExitMode() | Guardian | Immediate; for a guardian-declared emergency. |
activateForcedWithdrawalExitMode(claim, proof) | Anyone with a ripened complaint | After the 7-day grace, re-proving the user is still unserved in current committed state. |
Once in exit mode, deposits and new withdrawal requests are frozen, and each user
calls emergencyExit(claim, merkleProof) to withdraw against the frozen
exitRoot. The payout nets out any normal withdrawals already taken after the
checkpoint (normalWithdrawalsByUserAsset), so no one can double-dip by exiting
for a balance they already partially withdrew.
Exit mode is irreversible once any user has claimed (exitModeClaimed):
allowing a rollback after real exits would let committed state diverge. Before
the first claim - e.g. a mistaken or compromised guardian activation - the
governor can still recover with deactivateExitMode(). This is a best-effort
soft recovery, not a guarantee: in a real exit, any user can claim immediately and
lock it in.
If Senticore's operators disappear and simply stop publishing checkpoints, no special key is needed to free collateral. After 72 hours any address can put the vault into exit mode, and every user withdraws their committed balance with a Merkle proof. Your funds do not depend on the company continuing to exist.
State commitment wiring
The vault reads published roots (withdrawRoot, exitRoot) and checkpoint
metadata from a StateCommitment contract. It is
wired once:
initializeStateCommitment(sc)is a one-time governor call that validates the candidate (non-zero, has code, exposes a genesis root, and requires exit roots) and binds it. It cannot be called again.
The older "schedule -> wait 48h -> activate" state-commitment update flow is
disabled - scheduleStateCommitmentUpdate, cancelStateCommitmentUpdate, and
activateStateCommitmentUpdate all revert with LegacyFlowDisabled. Do not build
integrations that expect to re-point a live vault at a new commitment contract.
The STATE_COMMITMENT_UPDATE_DELAY = 48h constant remains in the source but is
not part of any callable path.
Parameters and constants reference
| Name | Value | Tunable? | Meaning |
|---|---|---|---|
MIN_SUPPORTED_DECIMALS / MAX_SUPPORTED_DECIMALS | 2 / 18 | No | Allowed decimals range, cross-checked against the token and frozen. |
ASSET_ADD_APPROVER_COUNT / ASSET_ADD_APPROVAL_THRESHOLD | 4 / 3 | No | 3-of-4 approver consent to list a new asset. |
ASSET_ADD_TIMELOCK | 0 | No (constant) | Delay between eligibility and activation. Currently none. |
minDeposit | Per-asset | Yes (governor) | Minimum deposit per transaction. |
maxVaultBalance | Per-asset (0 = unlimited) | Yes (governor) | Cap on tracked net balance per asset. |
withdrawChallengeWindowSec | 10 minutes | Yes (governor) | Minimum wait before a request can execute. |
withdrawRequestTtlSec | 7 days | Yes (governor) | Max lifetime of a request. |
withdrawMinBatchDelta | 1 | Yes (governor) | Minimum batch gap between request and executable claim. |
withdrawExecutorFeeBps | 10 (max 100) | Yes (governor) | Executor fee on non-self withdrawals. |
DEFAULT_WITHDRAW_HOUR_LIMIT_BPS | 1000 (10%) | Yes (governor) | Per-asset hourly withdrawal cap, bps of TVL. |
DEFAULT_WITHDRAW_DAY_LIMIT_BPS | 5000 (50%) | Yes (governor) | Per-asset daily withdrawal cap, bps of TVL. |
DEFAULT_AUTO_PAUSE_BPS | 8000 (80%) | Yes (governor) | Fraction of the day budget that trips auto-pause. |
autoPauseGlobalOnTrigger | true | Yes (governor) | Whether an asset auto-pause cascades to a global withdraw-execution pause. |
EXIT_MODE_TIMEOUT_SEC | 72 hours | No | Checkpoint staleness after which anyone can activate exit mode. |
FORCED_WITHDRAWAL_GRACE_SEC | 7 days | No | Operator's window to serve a forced-withdrawal complaint. |
BPS_DENOMINATOR | 10000 | No | Basis-point denominator for all rate math. |
Key events for integrators and indexers
| Event | Emitted when | Why you care |
|---|---|---|
Deposit(user, assetId, token, amount) | Collateral received. | Credit tracking; reconcile against committed balances. |
WithdrawRequested(user, assetId, requestId, amount, recipient, requestBatchId, minExecutableBatchId, earliestExecuteAt, expiresAt, nonce) | Request created. | Start the challenge-window clock; know the earliest executable batch. |
WithdrawExecuted(user, assetId, requestId, claimId, amount, recipient, claimBatchId, nonce) | Claim finalized. | Confirm payout and settlement batch. |
WithdrawExecutorFeePaid(claimId, executor, assetId, amount) | Executor took a fee. | Fee accounting on executor-relayed withdrawals. |
WithdrawRequestCancelled(user, assetId, requestId) | Request cancelled or expired. | The requestId is now permanently consumed. |
AssetWithdrawalAutoPaused(assetId, dayBucket, trigger) | Circuit breaker fired. | Abnormal-outflow alert; asset withdrawals frozen. |
VaultPauseFlagsSet(depositsPaused, withdrawRequestsPaused, withdrawExecutionPaused) | Any pause flag changed. | Track live pause posture. |
VaultEmergencyPaused(actor) | pauseAll() invoked. | Full guardian shutdown. |
ForcedWithdrawalRequested(user, assetId, amount, exitModeEligibleAt) | Censorship complaint filed. | Grace clock started; escalation possible after it elapses. |
ExitModeActivated(batchId, exitRoot) | Vault entered exit mode. | Deposits/requests frozen; users exit against exitRoot. |
EmergencyExitExecuted(user, assetId, amount, batchId) | User exited in exit mode. | Direct redemption against the frozen root. |
AssetActivated(assetId, token, decimals) | New asset listed. | New collateral available; record the immutable assetId. |
Testing surface
The vault is exercised by a broad Foundry suite under
test/MultiCollateralVault/, including behavior tests for deposits, withdrawals,
decimal precision, ERC-20 edge cases, cross-asset isolation, rate limiting,
pause/emergency governance, access control, DoS resistance, and adversarial
scenarios, plus emergency-exit and asset-registry coverage. A dedicated
invariant suite (Invariants.t.sol, mirrored by an Echidna harness) asserts
properties that matter to a depositor, among them:
- claims plus active requests never exceed deposits,
- the token balance always covers tracked liabilities (solvency),
- the active-request pointer always matches the stored request,
pauseAll()genuinely stops flow mutations,- exit mode permanently stops new user intake, and
- withdrawal accounting never underflows.
Related
- StateCommitment - the source of the withdraw, exit, and solvency roots this vault proves against.
- Contract Addresses - deployment list (empty during private beta).
- Trust Model - how custody, proofs, and exit fit the verify-don't-trust design.
- Integration - Getting Started - SDK and API entry points for building against deposits and proof-backed withdrawals.