Consolidation and QuoteRegistry
Sources: contracts/src/vault/FeeVault.sol (additive IFeeVaultConsolidation surface),
contracts/src/consolidation/QuoteRegistry.sol and contracts/src/consolidation/executors/.
Tests: contracts/test/consolidation/** (registry, executors, vault paths, invariants, a
real-adapter template suite) and contracts/test/e2e/**.
Consolidation
Turns a beneficiary's balance in any registry-routed asset into USDT (BSC-USD
0x55d398326f99059fF775485246999027B3197955, 18 decimals), the only settlement asset, inside
the vault. The executor must pull exactly amountIn, and the vault credits the measured USDT
delta, which must be at least minOut.
| Entry point | Called by | Bound |
|---|---|---|
consolidate(id, assetIn, amountIn, minOut, deadline, signature) | anyone (a relayer), with the owner's Consolidate signature | the signed minOut |
creatorConsolidate(launchToken, assetIn, amountIn) | the launch's creator: ZkPadFactory.tokenDeploymentInfo(launchToken).deployer (the msg.sender of deployToken) | QuoteRegistry.creatorMinOut: Chainlink price × (1 − maxDeviationBps), amountIn ≤ maxSwapSize, feed younger than 1 hour; that minOut must be at least minCreatorShield (else CreatorAmountTooSmall); amountIn ≤ launchCredit(launchToken, assetIn) (else ExceedsLaunchCredit) |
creatorConsolidateAndShield(launchToken, assetIn, amountIn, shieldAmount) | the launch's creator | as above (skipped when amountIn == 0); then, if a template is waiting, shields shieldAmount USDT with it. 0 = the launch's whole USDT credit, or nothing while that is below minCreatorShield; an explicit amount must be ≥ minCreatorShield (ShieldAmountTooSmall) and ≤ the launch's USDT credit |
- The id is always the launch's own
beneficiaryId; the creator never chooses an id or a destination. Without a template the USDT stays in the vault. - Launch credit.
launchCredit(launchToken, asset)is the launch locker'sdepositFromLaunchcredits, plus the USDT its creator consolidations produced, less what its creator already consolidated or shielded, held as shares of a per-(id, asset) pool. Every owner-side debit (claim, signed consolidation, fallback sweep) shrinks the pool pro rata, so a launch's credit never exceeds the balance it draws on and never reaches funds credited after the owner withdrew. Donations and other launches' credits are never reachable by a creator. - Whole balance.
amountIn = 2^255 | quotedAmountInswaps the balance at execution and scalesminOutbybalance / quotedAmountInwhen less is left (the signed rate holds, so a creator consolidation landing first cannot make it revert).amountIn = type(uint256).maxalso swaps the whole balance but keepsminOutabsolute. The FeeVault of the superseded 2026-10-04 deployment (never funded) predates the quoted form: it accepts onlytype(uint256).maxand reads2^255 | quotedAmountInas a literal amount, so that request reverts there. - Trust. The creator path's bound (the registry's feeds and executors, and which registry the
vault uses) is owner configuration. A malicious owner with a colluding creator can drain
launch-credited balances through a bad feed or executor; the signed path is bounded by the
beneficiary's own
minOut. See trust assumptions. consolidatesharesnonces[id]with every owner action and counts as owner activity. A relayer batchesmulticall([registerStealth?, consolidate (nonce n), claim (nonce n + 1)]).- Creator calls are not owner activity: they never delay the fallback sweep.
- Tokens without a price feed (USDC, USD1, FDUSD and XAUt in the mainnet config) can only be
consolidated by the beneficiary.
MAX_ORACLE_AGEis 1 hour; a USDT/USD feed, when configured, gets its own 25-hour bound (MAX_USDT_ORACLE_AGE); without one USDT is priced at $1.
Shield templates
| Entry point | Description |
|---|---|
registerShieldTemplates(id, bytes[] templates, deadline, signature) | Owner-signed. Appends 1..32 templates (FIFO). Each must start with the account's id. Each is pinned to the current shieldAdapter, which must be set and allow-listed (ShieldAdapterNotSet); it is used only while that adapter is still configured and allow-listed, so a later change can block it, never redirect it. |
clearShieldTemplates(id, deadline, signature) | Owner-signed. Discards every unused template (e.g. after rotating Railgun wallets). rotateOwner and finalizeBind already discard them. |
shieldTemplateCount(id) / shieldTemplateAt(id, i) | Unused templates; index 0 is the next one used. |
A template is exactly the RailgunShieldAdapter payload abi.encode(bytes32 id, ShieldRequest)
with preimage.value = 0. The vault passes it unchanged with the gross amount (no relayer fee),
and the adapter sets value to that amount. A template that pins a non-zero value only works for
exactly that amount. Malformed templates (an npk outside the SNARK field, a wrong token) are
rejected by the adapter at use time. The whole call then reverts, so neither the USDT nor the
template is lost, and the owner can clear them. The same holds when the adapter's sanctions
screen refuses the submitter (SubmitterSanctioned).
Each template is used at most once. The SDK's buildShieldTemplates uses fresh note
randomness and a fresh shield key per template.
Typed data
Consolidate(bytes32 beneficiaryId,address assetIn,uint256 amountIn,uint256 minOut,uint256 nonce,uint256 deadline)
RegisterShieldTemplates(bytes32 beneficiaryId,bytes32 templatesHash,uint256 nonce,uint256 deadline)
ClearShieldTemplates(bytes32 beneficiaryId,uint256 nonce,uint256 deadline)
templatesHash = keccak256(keccak256(t_0) ‖ keccak256(t_1) ‖ … ‖ keccak256(t_n-1))
All three use the FeeVault domain and share nonces[id]. See EIP-712.
Events
| Event | When |
|---|---|
Consolidated(id, assetIn, executor, amountIn, amountOut, byCreator) | Every consolidation. byCreator separates the oracle-bounded path. |
ShieldTemplatesRegistered(id, added, available) | available is the unused count afterwards. |
ShieldTemplatesCleared(id, removed) | |
ShieldedFromTemplate(id, adapter, amount, templateIndex) | A creator-triggered shield. It is not a Claimed event, so indexers must debit USDT here. |
ConsolidationConfigSet(registry, usdt, factory), ShieldAdapterSet(adapter), MinCreatorShieldSet(amount) | Admin. |
QuoteRegistry
QuoteRegistry(owner, usdt, factory, guardian) is Ownable2Step (on mainnet, currently owned by
the deployer EOA; a multisig is planned).
It holds the factory's admin role (factory.setAdmin(registry, true)) and mirrors every quote's
{enabled, minStartingTick, maxStartingTick} into factory.setQuoteToken.
struct QuoteConfig {
uint8 tier; // 1 curated, 2 fast-track
bool enabled; // new launches may pair with it
int24 minStartingTick; // token0 orientation, multiples of 200
int24 maxStartingTick;
address executor; // IRouteExecutor; zero only for USDT
bytes route; // executor-specific
address priceFeed; // Chainlink token/USD; zero = no creator path
uint16 maxDeviationBps; // creator path bound; Tier-2 depth probe bound
uint256 maxSwapSize; // creator path cap; Tier 2 requires > 0
}
| Function | Who |
|---|---|
setQuote(token, config) | owner (Tier 1, or overwrite) |
setTier2Approval(token, configHash) then admitTier2(token, config) | owner pre-approves the exact hashConfig; anyone executes the admission checks with their own tokens |
disable(token) | owner or guardian (stops new launches; never re-enables) |
removeQuote(token), setFactory, setGuardian | owner |
quoteTokens(), quoteConfig(token), routeOf(token), creatorMinOut(token, amountIn), oracleQuote(token, amountIn), probeAmount(token) | views |
Tier-2 admission checks: decimals ∈ [6, 18]; a fee-on-transfer round trip of
10^(decimals−2); executor.routeOutput(token, route) == USDT; a small and a
maxSwapSize sale along the route whose rates differ by at most maxDeviationBps; and, when a
feed is set, both sales within the oracle bound. These are sanity rails. The owner's review is
the real gate, because KYC-gated, rebasing or upgradeable-to-fee-on-transfer tokens cannot be
detected on-chain.
Route executors
| Executor | Route encoding | Notes |
|---|---|---|
InfinityCLRouteExecutor(CLPoolManager, WBNB) | abi.encode(PoolKey[]), 1..4 hops | One vault.lock; native-BNB pools are unwrapped and wrapped transparently. No owner. |
PancakeV3RouteExecutor(SwapRouter, V3Factory) | V3 packed path token (20) ‖ fee (3) ‖ token (20) …, 1..4 hops | routeOutput checks that every hop's pool exists and has liquidity. No owner. |
Mainnet routes in contracts/config/56.json: WBNB, USDC, USD1, FDUSD and BTCB go straight to USDT
on the 0.01% V3 pools, CAKE on the 0.25% pool, and ETH and XAUt via WBNB.
SDK
readQuoteRegistry, readCreatorMinOut, readOracleQuote, consolidateAllAmountIn,
effectiveConsolidateMinOut, buildConsolidate, buildRegisterShieldTemplates,
buildClearShieldTemplates, encodeConsolidateCall, encodeCreatorConsolidateCall,
encodeCreatorConsolidateAndShieldCall, encodeRegisterShieldTemplatesCall,
encodeClearShieldTemplatesCall, readShieldTemplateCount, readLaunchCredit,
readMinCreatorShield. Beneficiary clients price a consolidation with readOracleQuote for one
whole unit of every configured quote (the same calls for every visitor) and scale locally, so
neither the balance nor the held asset reaches the RPC.