Docs/Engineering/Per-Request Flows
accepted

Per-Request Flows

Full design spec for ARUSubscriptionManager. Per-request submission bound by AP-specified price range, multisig approval within bounds, permissionless 48h expiry reclaim.

Per-Request Flows — AP-Bound Subscription & Redemption

Contract: ARUSubscriptionManager.sol — the active mint/redeem manager for ARU. Implemented in commit 02f533d.

The v1 epoch-batched manager (ARUMintRedeemManager) has been removed from the codebase (commit 1153227) and superseded by this per-request design. See Audit History for v1's review history and why v2 supersedes it.

Motivation

The current 24h epoch model has two failure modes that get worse under market stress:

Demand surge (premium on secondary): If ARU trades 2% above NAV mid-epoch, arbitrageurs can't mint new supply until the next settlement window. Premium persists, secondary-market discovery drifts from NAV.

Redemption pressure (discount on secondary): The more dangerous case. If MMs want to burn ARU at NAV but can't for up to 20 hours, the discount widens while fear compounds. Classic peg-break dynamic.

Ondo recognized this in the evolution from CashManager to RWAHub / USDY. Their solution: per-request subscription and redemption, decoupled from any fixed time window. Admin still approves each settlement (multisig gate preserved), but no batched 24h cycle.

This doc adapts that pattern for ARU, with two protections Ondo doesn't have:

  1. AP-specified price bound — each request carries a max-acceptable (mint) or min-acceptable (redeem) price, on-chain. Admin's discretion is bounded by the AP's request, not by admin's own rules.
  2. Permissionless expiry + reclaim — if multisig doesn't settle within N hours, the AP reclaims funds without admin cooperation.

Non-goals

  • Not letting APs self-mint or self-burn. The multisig remains the minting authority. APs only request.
  • Not removing the epoch-batched path entirely. Epoch flow stays as an opt-in for large blocks or when the per-request path is circuit-broken.
  • Not introducing any new oracle gating. The contract is oracle-independent by design. NAV enforcement lives in the AP bound and in the off-chain auto-signer (see Auto-Signer).

Core design

Three-role actors, two on-chain steps per request.

Roles

Role Holder Powers
AP Allowlisted entity requestSubscription, requestRedemption, cancelExpired (own requests)
MANAGER_ADMIN Multisig (incl. auto-signer) approveSubscription, approveRedemption, overwriteRequest, pause
PAUSER_ADMIN Paged on-call subset of multisig Fast-path pause of request/approval flows

State

soliditystruct Request {
    address ap;
    uint256 amount;          // USDC (subscription) or ARU (redemption)
    uint256 priceBound;      // max for subscription, min for redemption; 18-dec
    uint64 createdAt;
    uint64 expiresAt;        // createdAt + REQUEST_TTL
    bool fulfilled;          // true after approval OR cancel
}

uint256 public requestTTL = 48 hours;  // admin-settable, within [24h, 7d]
mapping(bytes32 => Request) public subscriptions;
mapping(bytes32 => Request) public redemptions;
uint256 public nextSubscriptionId;
uint256 public nextRedemptionId;

bytes32 IDs are keccak256(abi.encode(ap, nextId)) for predictable off-chain indexing without exposing the admin-side counter pattern to griefing.

Subscription flow

solidityfunction requestSubscription(uint256 usdcAmount, uint256 maxPricePerARU)
    external
    onlyAP
    nonReentrant
    notFrozen
{
    if (subscriptionsPaused) revert SubscriptionsArePaused();
    if (usdcAmount < minimumMintAmount) revert BelowMinimumMint();
    if (maxPricePerARU == 0) revert InvalidPriceBound();

    bytes32 id = keccak256(abi.encode(msg.sender, nextSubscriptionId++));
    subscriptions[id] = Request({
        ap: msg.sender,
        amount: usdcAmount,
        priceBound: maxPricePerARU,
        createdAt: uint64(block.timestamp),
        expiresAt: uint64(block.timestamp) + uint64(requestTTL),
        fulfilled: false
    });

    emit SubscriptionRequested(id, msg.sender, usdcAmount, maxPricePerARU, block.timestamp + requestTTL);

    // USDC held in THIS contract (not treasury) until approval.
    // This guarantees cancellable refunds never depend on treasury liquidity state —
    // treasury can freely convert its own balance to gold without racing a possible AP reclaim.
    usdc.safeTransferFrom(msg.sender, address(this), usdcAmount);
}

function approveSubscription(bytes32 id, uint256 pricePerARU)
    external
    onlyRole(MANAGER_ADMIN)
    nonReentrant
{
    Request storage r = subscriptions[id];
    if (r.ap == address(0)) revert RequestNotFound();
    if (r.fulfilled) revert AlreadyFulfilled();
    if (block.timestamp > r.expiresAt) revert RequestExpired();
    if (pricePerARU == 0) revert InvalidPrice();
    if (pricePerARU > r.priceBound) revert PriceExceedsBound();

    r.fulfilled = true;
    uint256 aruAmount = (r.amount * DECIMAL_MULTIPLIER * 1e18) / pricePerARU;

    emit SubscriptionApproved(id, r.ap, r.amount, aruAmount, pricePerARU);

    // Atomically: ARU issued to AP + USDC released to treasury.
    // This is the single on-chain moment where circulating supply grows AND treasury is funded.
    // Backing invariant is preserved by construction: usdcAmount = aruAmount × pricePerARU / 1e30.
    aru.mint(r.ap, aruAmount);
    usdc.safeTransfer(treasuryAddress, r.amount);
}

function cancelSubscription(bytes32 id) external nonReentrant {
    Request storage r = subscriptions[id];
    if (r.ap != msg.sender) revert NotYourRequest();
    if (r.fulfilled) revert AlreadyFulfilled();

    r.fulfilled = true;  // poisons the ID; approval now reverts
    emit SubscriptionCancelled(id, r.ap, r.amount, false /* byExpiry */);

    // Refund directly from contract — treasury is never touched pre-approval.
    usdc.safeTransfer(r.ap, r.amount);
}

function cancelExpiredSubscription(bytes32 id) external nonReentrant {
    Request storage r = subscriptions[id];
    if (r.ap == address(0)) revert RequestNotFound();
    if (r.fulfilled) revert AlreadyFulfilled();
    if (block.timestamp <= r.expiresAt) revert NotYetExpired();

    r.fulfilled = true;
    emit SubscriptionCancelled(id, r.ap, r.amount, true /* byExpiry */);
    usdc.safeTransfer(r.ap, r.amount);
}

Two cancel paths:

  1. cancelSubscription — AP-only, any time before fulfillment. Used for self-correction (typo in amount or bound → cancel and resubmit).
  2. cancelExpiredSubscription — anyone can call, after expiresAt. Used when the AP can't cancel themselves (key loss, compromised, legal freeze). Third-party keepers can trigger refunds.

The SubscriptionCancelled event's byExpiry flag distinguishes the two paths for indexers.

Redemption flow

Symmetric, with the flip: priceBound is now the minimum acceptable price, and ARU is escrowed in the contract (not burned at request time — that's intentionally different from Ondo's USDY).

solidityfunction requestRedemption(uint256 aruAmount, uint256 minPricePerARU)
    external
    onlyAP
    nonReentrant
    notFrozen
{
    if (redemptionsPaused) revert RedemptionsArePaused();
    if (aruAmount < minimumRedeemAmount) revert BelowMinimumRedeem();
    if (minPricePerARU == 0) revert InvalidPriceBound();

    bytes32 id = keccak256(abi.encode(msg.sender, nextRedemptionId++));
    redemptions[id] = Request({
        ap: msg.sender,
        amount: aruAmount,
        priceBound: minPricePerARU,
        createdAt: uint64(block.timestamp),
        expiresAt: uint64(block.timestamp) + uint64(requestTTL),
        fulfilled: false
    });

    emit RedemptionRequested(id, msg.sender, aruAmount, minPricePerARU, block.timestamp + requestTTL);

    IERC20(address(aru)).safeTransferFrom(msg.sender, address(this), aruAmount);  // CEI; ARU held in escrow
}

function approveRedemption(bytes32 id, uint256 pricePerARU)
    external
    onlyRole(MANAGER_ADMIN)
    nonReentrant
{
    Request storage r = redemptions[id];
    if (r.ap == address(0)) revert RequestNotFound();
    if (r.fulfilled) revert AlreadyFulfilled();
    if (block.timestamp > r.expiresAt) revert RequestExpired();
    if (pricePerARU == 0) revert InvalidPrice();
    if (pricePerARU < r.priceBound) revert PriceBelowBound();

    r.fulfilled = true;
    uint256 usdcOwed = (r.amount * pricePerARU) / (1e18 * DECIMAL_MULTIPLIER);

    emit RedemptionApproved(id, r.ap, r.amount, usdcOwed, pricePerARU);

    usdc.safeTransferFrom(treasuryAddress, r.ap, usdcOwed);
    aru.burn(r.amount);  // burns the escrowed ARU
}

function cancelRedemption(bytes32 id) external nonReentrant {
    Request storage r = redemptions[id];
    if (r.ap != msg.sender) revert NotYourRequest();
    if (r.fulfilled) revert AlreadyFulfilled();

    r.fulfilled = true;
    emit RedemptionCancelled(id, r.ap, r.amount, false /* byExpiry */);
    IERC20(address(aru)).safeTransfer(r.ap, r.amount);  // ARU returned intact
}

function cancelExpiredRedemption(bytes32 id) external nonReentrant {
    Request storage r = redemptions[id];
    if (r.ap == address(0)) revert RequestNotFound();
    if (r.fulfilled) revert AlreadyFulfilled();
    if (block.timestamp <= r.expiresAt) revert NotYetExpired();

    r.fulfilled = true;
    emit RedemptionCancelled(id, r.ap, r.amount, true /* byExpiry */);
    IERC20(address(aru)).safeTransfer(r.ap, r.amount);
}

No admin overwrite

Originally this doc included an Ondo-inspired overwriteRequest admin function to correct typos. On review it's dropped because:

Correction needed Would overwrite fix it? Cancel + resubmit path
AP amount too small No — AP must send more USDC anyway Clean two-tx fix
AP amount too large Yes (decrease + partial refund) Same result
priceBound too tight No — admin shouldn't loosen Cancel + resubmit
priceBound too loose Yes — admin tightens in AP's favor Cancel + resubmit
Wrong AP address No — amount already moved from old address Cancel + resubmit

Overwrite's real niche reduces to tightening — and if admin can unilaterally tighten, they can unilaterally change what the AP consented to. Even tightening-only is an attack surface if the threat model includes a partially-compromised admin.

cancelSubscription / cancelRedemption + resubmit covers every legitimate use case with strictly AP-side control. Legal story stays iron-clad: "the AP could have cancelled at any time until the approval tx landed."

Consequence: no RequestOverwritten event, no OverwriteRestricted error, no overwriteRequest function. Smaller contract, smaller attack surface, smaller audit scope.

Backing & NAV invariants

The single most important property: every ARU in circulation is 100% backed by assets the treasury can honor at NAV. The asymmetric custody pattern (USDC in contract during pending mint, treasury during pending burn) is designed around this.

State-by-state backing analysis

State Circulating ARU Treasury USDC/gold Contract holdings Backing
Steady state S S × NAV 0 100%
Pending subscription S S × NAV +pendingUSDC 100% (pending USDC is AP's, not counted)
Subscription approved S + Δ S × NAV + ΔUSDC 0 100% (ΔUSDC = Δ × price ≈ Δ × NAV)
Pending redemption S S × NAV +escrowed ARU 100% (escrowed ARU ≤ circulating; still backed)
Redemption approved S − Δ S × NAV − ΔUSDC 0 100% (ΔUSDC ≈ Δ × NAV flows out; Δ ARU burned)

Why the asymmetry

  • Pending mint: USDC sits in contract, not treasury. Circulating ARU unchanged. Treasury is only funded when ARU is issued — the two events are atomic. No backing gap ever exists.
  • Pending burn: ARU sits in contract (escrowed, not burned). Circulating supply formally includes the escrowed ARU, but the treasury still holds full backing. At approval, USDC flows out and ARU burns atomically — backing stays exact.

What this enables for treasury operations

Treasury can freely convert its USDC balance to gold at any time, because every USDC in treasury corresponds to already-issued ARU. There's no "pending" USDC in treasury that might need to be refunded.

This was the race condition that drove the design. If mint USDC went directly to treasury on request and treasury converted it to gold immediately, a 48h expiry reclaim would need USDC the treasury no longer has. By keeping pre-approval USDC in the contract, the treasury's working capital equals exactly what backs live ARU — nothing more, nothing less.

Observable invariants (for monitoring)

  1. aru.totalSupply() × NAV == treasuryValue + escrowedARU × NAV
  2. usdc.balanceOf(manager) == sum(subscriptions[*].amount where !fulfilled) — pending mint USDC
  3. IERC20(aru).balanceOf(manager) == sum(redemptions[*].amount where !fulfilled) — escrowed ARU
  4. Post-approval for any mint: usdcToTreasury == aruMinted × pricePerARU / 1e30
  5. Post-approval for any redemption: usdcFromTreasury == aruBurned × pricePerARU / 1e30

All computable off-chain from events + standard ERC20 views. No extra on-chain state needed.

On-chain evidence trail:

  1. AP consent. priceBound is set by the AP at request time and stored immutably. Contractually this is the AP saying "settle at any price within this bound or refund me."
  2. Bounded admin discretion. The contract enforces priceExceedsBound / priceBelowBound on approval. Multisig cannot settle outside the AP's stated range.
  3. Guaranteed refund. After expiry, cancelExpired* is permissionless. AP isn't dependent on admin cooperation. Removes one class of dispute.
  4. Admin override is evidenced. Every overwriteRequest emits before/after values. Auditors can reconstruct every change to every request.

This is cleaner than both naive two-step (unbounded admin discretion) and Ondo's three-step (stuck funds if AP refuses to claim).

What it does not protect against

  • Admin settling at exactly the bound. AP agreed to that price by setting the bound. Economically they should set tight bounds around NAV.
  • Admin letting requests expire to force cancel. If admin never approves, the AP reclaims at expiry — same as a bad-faith refusal. The REQUEST_TTL is a hard ceiling on admin slow-walking.
  • Oracle failure. The contract is oracle-independent. Admin's price input is not oracle-gated on-chain. Oracle integrity is enforced at the multisig level via the Auto-Signer.
  • Treasury insolvency during redemption approval. approveRedemption calls usdc.safeTransferFrom(treasuryAddress, ...). If treasury lacks USDC, this reverts — but the AP's ARU is still safely escrowed in the contract. After TTL expiry, cancelExpiredRedemption returns the ARU. The AP cannot lose their ARU; they just may not get redeemed promptly. Treasury liquidity policy is an external control.
  • Subscription refunds never touch treasury, so treasury insolvency cannot strand a pending mint. cancelSubscription and cancelExpiredSubscription transfer directly from the contract's own USDC balance.

Treasury operations

Continuous coverage requirement

Unlike v1's epoch model, admin can approve redemptions at any time. Treasury must be ready.

Required at all times:

$$\text{treasury USDC} \geq \sum_{\text{pending redemptions}} \text{aruAmount} \times \text{currentNAV} \times \text{safety margin}$$

Recommended safety margin: 110% of expected outflow at live NAV. Configurable off-chain per org risk appetite.

Operational flow:

  1. Mint request arrives → AP's USDC lands in the contract (not treasury).
  2. Admin approves mint → USDC moves contract → treasury; ARU minted to AP.
  3. Treasury can now allocate the newly-received USDC to gold reserves, because that USDC corresponds to already-issued ARU.
  4. Redemption request arrives → AP's ARU lands in the contract (escrowed).
  5. Admin checks treasury coverage before approving. If short, admin delays redemption approvals, optionally pauses new redemption requests, and instructs treasury to liquidate gold to USDC.
  6. Admin approves redemption → USDC from treasury → AP; escrowed ARU burned.
  7. Treasury reserves shrink by exactly the ARU-equivalent gold value.

Off-chain monitoring (no additional on-chain views required)

All observability is derivable from existing state + events:

Metric Source Contract change required
Pending mint USDC (total) usdc.balanceOf(manager) None
Pending mint USDC (per AP / per request) SubscriptionRequested − SubscriptionApproved − SubscriptionCancelled events None
Escrowed ARU (total) IERC20(aru).balanceOf(manager) None
Pending redemption ARU (total / per AP) RedemptionRequested − RedemptionApproved − RedemptionCancelled events None
Treasury USDC balance usdc.balanceOf(treasuryAddress) None
Treasury allowance to manager manager.treasuryAllowance() Already shipped (v1)
Coverage ratio Off-chain: treasury_usdc / (pending_redeem_aru × live_NAV) None
Live NAV Oracle + secondary sources (same inputs as AutoSigner) None

Zero new on-chain views needed for v2. The storage-free approach keeps the contract lean and the subgraph owns the indexing.

Condition Action
Coverage ratio < 115% Page oncall; prepare to liquidate gold
Coverage ratio < 110% Admin pauses new redemption requests via pauseRedemptions
Coverage ratio < 105% Admin delays in-flight approvals; emergency treasury top-up
Coverage ratio < 100% System is undercollateralized against pending burns at NAV. Emergency mode — freeze + public comms
Pending mint USDC > $X Only informational — USDC is still AP-owned until approved
Treasury allowance < expected Re-approve from new treasury Safe (happens after treasury rotation)

What pauseRedemptions does and doesn't do

  • Does: blocks new requestRedemption calls. Queue stops growing.
  • Does not: block approveRedemption for existing pending requests. Admin can still settle what's in the queue.
  • Does not: block cancelRedemption or cancelExpiredRedemption. APs can still reclaim their ARU.

This separation means pause is a safe back-pressure mechanism: it throttles inflow without stranding existing flow.

Relationship to the existing epoch-batched flow

Two contracts, two paths, one ARU token:

  • ARUMintRedeemManager (v1, shipped) — epoch-batched, for large institutional block settlements or when the v2 path is paused
  • ARUSubscriptionManager (v2, this doc) — per-request, for routine MM flows

Both are MANAGER_ADMIN-gated. Both hold the minter role on ARU (via the 48h timelocked rotation — we'd add the v2 manager alongside v1, not replace).

ARU token change required: ARU v1 has a single minter slot. To support two managers, we'd either:

  • Option A: add isMinter[address] mapping and swap onlyMinter to onlyMinter(msg.sender). Requires an ARU contract redeploy or upgrade. Breaking.
  • Option B: have v1 Manager wrap v2 — v2 calls v1's mint via a privileged bridge function. Ugly coupling.
  • Option C: replace ARU v1 with ARU v2 that supports multiple minters; migrate holders via a wrapper. Expensive.

Option A is the cleanest. Prepares for future concurrent flows too (e.g., a bridge minter for cross-chain). Requires upgrading ARU — a one-time migration event.

Migration plan

Phased rollout, each step independently deployable:

  1. Ship v1 fixes (completed — d827d85 on master includes L-01, I-01, I-02 follow-up).
  2. ARU multi-minter upgrade (new ARU v2). New timelock for rotation, migrate holders via a wrapper or L1 snapshot. ~2 weeks engineering + audit.
  3. Deploy ARUSubscriptionManager v2 alongside existing ARUMintRedeemManager v1. Grant both isMinter rights. Circuit-break v2 via admin flag initially.
  4. Gradual v2 enablement. Start with one AP, daily cap, tight bounds. Monitor SLA, adjust. Roll to all APs over 2–3 weeks.
  5. Deprecate v1 when stable. Once v2 has 6+ months clean operation, announce v1 sunset. Keep for ≥3 more months as fallback. Eventually revoke v1's isMinter role.

New roles / events / errors (this doc only)

New roles: same as v1 (MANAGER_ADMIN, PAUSER_ADMIN). No new roles.

New events:

  • SubscriptionRequested(bytes32 id, address ap, uint256 amount, uint256 priceBound, uint256 expiresAt)
  • SubscriptionApproved(bytes32 id, address ap, uint256 amountIn, uint256 amountOut, uint256 price)
  • SubscriptionCancelled(bytes32 id, address ap, uint256 amount, bool byExpiry)
  • RedemptionRequested, RedemptionApproved, RedemptionCancelled — mirror

New errors:

  • RequestNotFound(), AlreadyFulfilled(), RequestExpired(), NotYetExpired()
  • PriceExceedsBound(), PriceBelowBound(), InvalidPriceBound()
  • NotYourRequest() — guards user-cancel path

Tests required before v2 deploy

  1. Happy path — subscribe → approve. Verify: USDC moved AP → contract → treasury, ARU minted to AP. Redeem → approve. Verify: USDC moved treasury → AP, ARU burned from contract.
  2. Bound enforcement — approve outside bound reverts (both directions).
  3. Expiry — advance time past expiresAt, approve reverts, third party cancelExpired* succeeds.
  4. Reclaim permissionless — anyone can call cancelExpired*, not just the AP.
  5. User cancel — AP cancels own request before expiry, receives funds back, subsequent approval reverts with AlreadyFulfilled.
  6. User cancel authz — non-AP caller cannot cancel someone else's request (NotYourRequest).
  7. Cancel race — admin's approval tx mid-flight reverts cleanly if AP cancels first. No partial state.
  8. Pause gates — subscription/redemption pausing blocks new requests but allows approvals, user-cancels, and expiry-cancels.
  9. Freeze — unchanged semantics from v1; pending requests can still be settled or cancelled.
  10. Per-AP request cap — 51st open request from same AP reverts; cancelling or fulfilling an open request restores a slot.
  11. Subscription isolation — pending subscription USDC lives in contract. Even if treasury is drained, cancelSubscription and cancelExpiredSubscription still refund successfully.
  12. Treasury insolvency on redemption — approveRedemption reverts if treasury lacks USDC; AP's escrowed ARU remains intact; cancelExpiredRedemption returns ARU after TTL.
  13. Backing invariant fuzz — across any sequence of subscribe/approve/cancel/redeem, the invariant aru.totalSupply() × approvedPrice == usdcPaidToTreasury − usdcPaidOutOfTreasury (modulo rounding) holds.
  14. Fuzz — any (amount, bound, pricePerARU) respecting the bound produces consistent ARU↔USDC math.
  15. Invariant tests — Foundry handler covering:
    • usdc.balanceOf(manager) == Σ unfulfilled subscription amounts
    • IERC20(aru).balanceOf(manager) == Σ unfulfilled redemption amounts
    • no stuck funds after expiry + anyone-callable cancel
    • fulfilled flag never reverts to false
    • per-AP open request count never exceeds 50

Resolved decisions

  1. REQUEST_TTL default = 48h. Admin-settable within [24h, 7d]. Captures the common case while giving flexibility for slower markets.
  2. Per-AP open-request cap = 50. Enforced in requestSubscription / requestRedemption via a per-AP counter that increments on request and decrements on fulfillment or cancel. Prevents a compromised AP from bloating state; 50 is well above realistic concurrent flow.
  3. No on-chain oracle gating on priceBound. AP UI can warn on typos client-side; the contract stays oracle-independent. Availability beats paranoia for a peg-critical primary market.
  4. No admin overwrite. Replaced by user-triggered cancelSubscription / cancelRedemption. AP can cancel + resubmit any time before fulfillment; admin has zero unilateral power over request parameters. Iron-clad consent trail, smaller attack surface.
  5. Mint USDC held in contract until approval. USDC moves AP → contract on request, contract → treasury on approval. Treasury only ever holds USDC that corresponds to already-issued ARU, so it can freely convert to gold without worrying about 48h expiry reclaim races. Subscription refunds (user-cancel or expiry-cancel) come directly from the contract, not from treasury.
  6. No on-chain coverage enforcement (Option A). Treasury coverage for pending redemptions is monitored off-chain via existing views and events. Admin uses pauseRedemptions as the circuit breaker when coverage drops. Keeps the contract lean and the mechanism is the same discipline needed operationally anyway.

OZ base — no new primitives

v2 inherits from exactly the same OpenZeppelin primitives as v1:

  • AccessControlEnumerable — MANAGER_ADMIN, PAUSER_ADMIN roles
  • ReentrancyGuard — all state-mutating functions
  • SafeERC20 — all token transfers
  • IERC20 — usdc and aru interfaces

No Pausable (three independent flags: subs / redeems / freeze). No TimelockController. No ERC4626. No merkle allowlist. Per-request business logic is inherently custom — every major RWA project (Ondo, BUIDL, Paxos, Mountain) uses this same OZ stack with custom queue logic on top.

Summary

Property v1 (epoch) v2 (per-request)
Settlement latency 0–24h after request 0–48h (by SLA); typical 0–4h
Elasticity under demand spike Blocked 24h Continuous
Redemption elasticity under stress Blocked up to 20h Continuous
Admin discretion Unbounded within epoch Bounded by AP priceBound
Refund if admin unresponsive Requires admin removeAP Permissionless after expiresAt
Oracle dependency None (price admin-supplied) None (same)
Mint USDC custody pre-approval Treasury (race risk) Contract (safe to convert treasury balance)
Backing invariant Maintained at settlement Maintained continuously
Gas per AP request ~40k (request) + amortized batch ~60k (request) + ~90k (approve)
Multisig tx per settlement 1 (batched) 1 per request (amortized by auto-signer)
Contract size ~400 SLOC ~250 SLOC (smaller — no epoch state machine)

Roll v2 in addition to v1, not as replacement. Let v2 prove itself; deprecate v1 only after.