Scope: the order validator, OrderDatum, and BatchProcess redeemer on vault_batcher (the standalone keeper-authorized staking validator that owns the 4 fold loops + OrderDatum/OrderRedeemer decode + list.unique + anti-leak invariants) — how deposits and withdrawals are queued and processed in batches.
Cardano's eUTXO model means the vault UTXO can only be consumed by one transaction per block. If two users try to deposit simultaneously and both directly consume the vault UTXO, one transaction will fail. The batch queue decouples user action (creating an Order UTXO at the order script address) from vault state mutation (keeper consumes N orders at once in a single BatchProcess transaction).
Properties the batch queue provides:
- Concurrency: arbitrary number of users can submit orders in parallel
- Fee amortization: one vault-UTXO-mutation per batch amortizes vault-touching gas across N orders
- Flash-loan resistance: the 24h order expiry combined with batch-processing latency prevents atomic deposit-withdraw attacks
- Keeper independence for refund: orders that are never processed are refundable by anyone after 24h (see
Expireredeemer) - Explicit pricing rule: all orders in a batch are priced against the pre-batch snapshot, avoiding ordering-dependent outcomes
type OrderAction {
DepositOrder { min_shares: Int } // User minimum vUSDCx to accept
WithdrawOrder { min_receive: Int, receiver: Address } // User minimum USDCx + payout address
}
type OrderDatum {
owner: VerificationKeyHash, // Signer allowed to Cancel; recipient of Expire refunds
action: OrderAction,
expires_at: Int, // POSIX ms; 24 hours from creation
max_batcher_tip: Int, // Lovelace; upper bound on keeper tip per order
}Order UTXO contents:
- For
DepositOrder: the user's USDCx deposit + a small ADA deposit (covers min-UTXO +max_batcher_tip+ refund margin, ~2.5 ADA typical) - For
WithdrawOrder: the user's vUSDCx shares + the same small ADA deposit
The max_batcher_tip is user-specified at order creation. Users submitting low-priority orders may set tip low (or zero) and risk keeper skipping the order; users wanting rapid processing set tip higher. Keepers choose which orders to process based on tip economics.
User creates Order UTXO
│
▼
Order sits at `order` script address with expires_at = creation_time + 24h
│
├──── Keeper processes ────────▶ Process redeemer (batch)
│
├──── Owner cancels ──────────▶ Cancel redeemer
│
└──── Expiry passes ──────────▶ Expire redeemer (anyone-can-trigger)
Only one of Process / Cancel / Expire can consume a given Order UTXO. Subsequent attempts fail because the UTXO is already spent.
Triggered when the keeper includes the Order UTXO in a BatchProcess transaction on the vault side.
Process {
vault_ref_input_idx: Int, // Index of vault reference input (for reading pre-batch snapshot)
payout_output_index: Int, // For Withdraw: index of output paying user
tip_output_idx: Option<Int>, // Index of output paying keeper tip; None = keeper declines tip
}Order-side validation:
tx.validity_range.upper < ord.expires_at(order not yet expired)- Vault input must be present in the transaction (keeper is consuming vault UTXO + this order UTXO simultaneously)
- Vault input must be at the
order_script_hash-bound vault address (i.e., the "correct" vault, verified via Vault NFT) - Keeper authorization check (via
keeper_stake_scriptwithdrawal entry) - For DepositOrder: user's USDCx amount must flow into the vault's output; keeper mints and routes vUSDCx to
owner's address atpayout_output_index; mint ratio computed pervault_batcherBatchProcess invariant - For WithdrawOrder: user's vUSDCx is burned; user's requested
min_receiveUSDCx must flow toWithdrawOrder.receiveratpayout_output_index min_shares/min_receiveslippage floors enforced (order fails if vault's pricing yields less than the user-specified minimum)- Tip extraction (if
tip_output_idx = Some(idx)):- Output at
idxreceives ≥ 0 lovelace and ≤max_batcher_tiplovelace - Tip paid from the Order UTXO's own ADA balance (not from user's USDCx deposit)
- Recipient address not constrained by contract (keeper freely chooses their own wallet)
- Output at
- Refund: any ADA remaining in the Order UTXO (i.e., deposit minus min-UTXO minus tip) returns to
owner's address
CancelValidation:
tx.extra_signatoriescontainsord.owner- Full Order UTXO value (USDCx / vUSDCx + all ADA) refunded to owner's address
- No vault involvement
Cancel is always available regardless of expires_at — owner retains full control over their order until processed.
ExpireValidation:
tx.validity_range.lower >= ord.expires_at- Full Order UTXO value refunded to
ord.owner's address - No vault involvement
- No signer requirement — anyone can submit this transaction. The full order value goes to the owner, not to whoever triggers Expire, so there's no incentive for Expire to be weaponized (the submitter pays the transaction fee; they receive nothing in return).
Expire is the "keeper downtime" failsafe: even if the keeper is completely offline, pending orders are refundable to users after 24 hours through any other Cardano user submitting the Expire transaction. This means a user's principal is never trapped in an unprocessed order — the worst case is "wait 24 hours for auto-refund".
The vault-side counterpart to order.Process. Triggered on vault_batcher (the standalone home of BatchProcess) as a staking-validator redeemer when the keeper includes a batch of orders in a transaction.
BatchProcess {
order_inputs: List<OrderInput>, // Per-order metadata: input index, payout index, tip index, action
mint_total: Int, // Total vUSDCx to mint (sum across deposit orders)
burn_total: Int, // Total vUSDCx to burn (sum across withdraw orders)
new_idle_buffer: Int, // Computed post-batch idle_buffer
}Validation highlights (full detail in implementation):
- All
order_inputsreference UTXOs at theorderscript address; each has a matchingProcessredeemer in the transaction mint_total == Σ deposit_order_shares(computed from each deposit's amount × pre-batch share price)burn_total == Σ withdraw_order_sharesnew_total_deposited = old_total_deposited + Σ deposit_amounts − Σ withdraw_amountsnew_total_shares = old_total_shares + mint_total − burn_totalnew_idle_buffer = old_idle_buffer + Σ deposit_amounts − Σ withdraw_amounts- Mint invariant:
tx.mint[vusdcx] == mint_total − burn_total(net mint = mint − burn; can be positive or negative) - Pre-batch snapshot pricing: every order's fair share / fair withdrawal is computed against
old_total_deposited / old_total_shares, not a running accumulator - Payout index uniqueness: each
payout_output_indexin the batch is distinct (prevents keeper from pointing two orders to the same output, double-counting) - No vUSDCx leak: minted vUSDCx can only flow to deposit-order owners or be burned against withdraw orders; no net vUSDCx to keeper's address or any third-party output
- Not frozen:
old.frozen == 0 - Keeper authorization:
keeper_stake_scriptzero-withdraw present
All orders in a single batch are priced against the pre-batch exchange rate snapshot. That is:
share_price_in_batch = old_total_deposited / old_total_shares
This rate is computed once at the start of the batch, and all orders use it. A deposit of X USDCx always mints X / share_price_in_batch vUSDCx regardless of what other orders do within the same batch.
Why this rule (vs running-accumulator):
The vault enforces a per-batch aggregate invariant: Σ minted_shares_per_deposit_order + Σ burned_shares_per_withdraw_order == mint_total - burn_total. For this sum to be exactly enforceable with integer arithmetic, all orders need to use the same share price. A running accumulator (where order N uses the state after orders 1..N-1 are applied) breaks this invariant in edge cases and reintroduces an ordering-dependent fee extraction vector that was closed by an internal-verification finding.
Consequence: if a user submits a Withdraw order while a large Deposit order is in the same batch, the user's Withdraw is priced against the pre-batch state. Conversely, if Deposit is submitted alongside a large Withdraw in the same batch, the Deposit is priced against pre-batch. Either way, the user gets an unambiguous pricing rule they can verify after-the-fact; there is no keeper ordering-manipulation advantage.
Bound on pricing drift: at mature TVL, max_single_order / total_deposited is in the basis-point range and pre-batch-vs-post-batch pricing drift is negligible. At the pre-audit 100K TVL cap, a $5K order in a batch against a $100K vault yields a ~5% drift window. Users submitting large single orders at low TVL should prefer Direct Deposit or Direct Withdraw to avoid this drift.
The max_batcher_tip field creates a market for keeper processing priority. Keepers observe the order queue and choose which orders to process based on:
- Their own tip-collection strategy (claim full
max_batcher_tip, claim less to win against other keepers, decline to claim and service public-good mode) - Gas cost of including the order (each order adds transaction size and computation cost)
- Deadline proximity (orders near their 24h expiry get processed; otherwise they're refunded via Expire)
Minimum viable tip (frontend-enforced, not contract-enforced): the frontend rejects orders with max_batcher_tip < 100_000 lovelace (0.1 ADA) to prevent spam. Users submitting directly via Cardano CLI can bypass this, but such orders are unlikely to be picked up by any keeper — the economic incentive is absent.
Tip range at V1 launch:
- Low-priority orders: 100,000–200,000 lovelace (0.1–0.2 ADA)
- Standard orders: 500,000 lovelace (0.5 ADA)
- High-priority: 1,000,000+ lovelace (1+ ADA)
Users should calibrate against observed keeper behavior post-launch.
Because V1 launches with a 100K TVL cap, any single user submitting an order near 5% of that cap (i.e., $5K) will experience notable pre-batch-vs-post-batch drift if their order is in a batch with other large orders. Small orders (<$500) don't experience meaningful drift. Practical implication:
- Users submitting small orders (<$500): Queue Deposit/Withdraw works perfectly; drift is sub-bp
- Users submitting medium orders ($500–$2500): Queue is still reasonable; drift is single-digit bp
- Users submitting large orders ($2500+): Prefer Direct Deposit / Direct Withdraw to avoid the drift window. Direct operations don't batch, so they always price against live vault state.
The frontend will surface this recommendation in the Deposit/Withdraw UX when order amounts approach 5% of current TVL.
Reading this specification, a user can rely on the following properties being contract-enforced:
- Principal safety during queue: your deposit USDCx (or your vUSDCx for withdraw) sits in an Order UTXO at the
orderscript address; only you (via Cancel) or the keeper (via Process) or — after 24 hours — anyone (via Expire) can move it. Nobody else. - Cancellation rights: you can Cancel at any time before Process, full refund guaranteed.
- 24-hour recovery guarantee: if the keeper never processes your order, after 24 hours it auto-refunds via Expire; you are never trapped.
- Slippage protection:
DepositOrder.min_sharesandWithdrawOrder.min_receiveset minimum acceptance thresholds; if the batch price would give you less than you specified, the order fails (does not execute). - No hidden fees in batching: the
max_batcher_tipyou specify is the only non-refundable ADA cost; no USDCx protocol fee applies to batched operations. - Payout address guaranteed: WithdrawOrder specifies
receiver: Address; the contract verifies USDCx flows to exactly that address. - Keeper cannot skim: the batch's mint / burn / payout logic is entirely validator-enforced; no keeper-visible mechanism exists to siphon value from the batch to the keeper's own wallet beyond the user-authorized
max_batcher_tip.
spec/architecture.md§5 — how Order / BatchProcess fits into the 12 transaction patternsspec/vault-datum.md§4 — BatchProcess column in the state-transition matrixspec/keeper-auth.md— keeper authorization mechanism used by BatchProcessdocs/economics.md§1 — fee structure for Queue Deposit / Queue Withdraw vs Direct