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.
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:
Three-role actors, two on-chain steps per request.
| 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 |
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.
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:
cancelSubscription — AP-only, any time before fulfillment. Used for self-correction (typo in amount or bound → cancel and resubmit).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.
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);
}
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.
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 | 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) |
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.
aru.totalSupply() × NAV == treasuryValue + escrowedARU × NAVusdc.balanceOf(manager) == sum(subscriptions[*].amount where !fulfilled) — pending mint USDCIERC20(aru).balanceOf(manager) == sum(redemptions[*].amount where !fulfilled) — escrowed ARUusdcToTreasury == aruMinted × pricePerARU / 1e30usdcFromTreasury == aruBurned × pricePerARU / 1e30All computable off-chain from events + standard ERC20 views. No extra on-chain state needed.
On-chain evidence trail:
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."priceExceedsBound / priceBelowBound on approval. Multisig cannot settle outside the AP's stated range.cancelExpired* is permissionless. AP isn't dependent on admin cooperation. Removes one class of dispute.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).
REQUEST_TTL is a hard ceiling on admin slow-walking.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.cancelSubscription and cancelExpiredSubscription transfer directly from the contract's own USDC balance.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:
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) |
pauseRedemptions does and doesn't dorequestRedemption calls. Queue stops growing.approveRedemption for existing pending requests. Admin can still settle what's in the queue.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.
Two contracts, two paths, one ARU token:
ARUMintRedeemManager (v1, shipped) — epoch-batched, for large institutional block settlements or when the v2 path is pausedARUSubscriptionManager (v2, this doc) — per-request, for routine MM flowsBoth 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:
isMinter[address] mapping and swap onlyMinter to onlyMinter(msg.sender). Requires an ARU contract redeploy or upgrade. Breaking.mint via a privileged bridge function. Ugly coupling.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.
Phased rollout, each step independently deployable:
d827d85 on master includes L-01, I-01, I-02 follow-up).ARU v2). New timelock for rotation, migrate holders via a wrapper or L1 snapshot. ~2 weeks engineering + audit.ARUSubscriptionManager v2 alongside existing ARUMintRedeemManager v1. Grant both isMinter rights. Circuit-break v2 via admin flag initially.isMinter role.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 — mirrorNew errors:
RequestNotFound(), AlreadyFulfilled(), RequestExpired(), NotYetExpired()PriceExceedsBound(), PriceBelowBound(), InvalidPriceBound()NotYourRequest() — guards user-cancel pathexpiresAt, approve reverts, third party cancelExpired* succeeds.cancelExpired*, not just the AP.AlreadyFulfilled.NotYourRequest).cancelSubscription and cancelExpiredSubscription still refund successfully.approveRedemption reverts if treasury lacks USDC; AP's escrowed ARU remains intact; cancelExpiredRedemption returns ARU after TTL.aru.totalSupply() × approvedPrice == usdcPaidToTreasury − usdcPaidOutOfTreasury (modulo rounding) holds.amount, bound, pricePerARU) respecting the bound produces consistent ARU↔USDC math.usdc.balanceOf(manager) == Σ unfulfilled subscription amountsIERC20(aru).balanceOf(manager) == Σ unfulfilled redemption amountsREQUEST_TTL default = 48h. Admin-settable within [24h, 7d]. Captures the common case while giving flexibility for slower markets.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.priceBound. AP UI can warn on typos client-side; the contract stays oracle-independent. Availability beats paranoia for a peg-critical primary market.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.pauseRedemptions as the circuit breaker when coverage drops. Keeps the contract lean and the mechanism is the same discipline needed operationally anyway.v2 inherits from exactly the same OpenZeppelin primitives as v1:
AccessControlEnumerable — MANAGER_ADMIN, PAUSER_ADMIN rolesReentrancyGuard — all state-mutating functionsSafeERC20 — all token transfersIERC20 — usdc and aru interfacesNo 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.
| 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.