Antarctic USD
Antarctic USD

Protocol documentation

USD AW is a six-decimal, whitelist-restricted ERC-20 on Base. Issuance is bounded by an administrator-reported backing cap; redemption runs through an escrow that holds the user's tokens until a settler proves an off-chain payout. Every privileged action is either held by a multisig or delayed by 48 hours.

Symbol
USDAW
Decimals
6 — decimals() is pure and cannot be changed by an upgrade without changing the implementation. 1 USD AW = 1_000_000 units.
Network
Base (chain id 8453). Redemption payouts settle on any EVM network the administrator has approved.
Standard
ERC-20 plus ERC-2612 permit, via OpenZeppelin ERC20PermitUpgradeable 5.4.0.
Proxies
All five contracts are UUPS (ERC-1967). Implementations are upgradeable only by the ADMIN Safe.

Issuer and administrator

USD AW is issued by a licensed company that holds the backing, provides the liquidity and operates the exchange leg of every redemption. The ADMIN_ROLE these contracts refer to is that company, acting through its multisig; the administrator addresses named throughout this document are its representatives. Settlers are counterparties it appoints and can remove at will.

Architecture

Five contracts, one authority

The token holds balances and nothing else: it accepts mint only from the mint controller and burn/refund only from the redemption escrow, and it asks the whitelist registry about every counterparty. Policy lives in the controllers, so limits and caps can move without touching balances.

ADMIN Safe (multisig) ADMIN_ROLE and upgrade authority on all five contracts SupplyController reported backing cap MintController limits, daily usage RedemptionEscrow holds tokens in flight Token balances, 6 decimals WhitelistRegistry checked before every mint, transfer and redemption mint burn, refund reads cap reads reads reads
Solid arrows are privileged calls; dashed are reads. The Safe is the only address holding ADMIN_ROLE.
Access control

Roles

Every contract inherits SystemAccessUpgradeable, which grants DEFAULT_ADMIN_ROLE and ADMIN_ROLE to the Safe and EMERGENCY_ROLE to the emergency key in __SystemAccess_init(admin, emergency). Because DEFAULT_ADMIN_ROLE is the admin of every other role, the Safe is the only address that can grant or revoke anything.

RoleHeld byGrants the ability to
DEFAULT_ADMIN_ROLEADMIN SafeGrant and revoke every role on that contract, including its own.
ADMIN_ROLEADMIN SafeChange limits, caps, windows and network approvals; resolve disputes; unpause; authorise upgrades via _authorizeUpgrade.
SERVICE_ROLEOperations keys, settlersMutate the whitelist (add, remove, addBatch, removeBatch) and register payouts (markPaid). Cannot move funds.
EMERGENCY_ROLEEmergency key, held separatelyCall pause() on the token, whitelist, mint controller and escrow. Cannot unpause.
MINTER_ROLEMintController onlyCall mint on the token. Granted at initialisation to the controller address.
REDEMPTION_ROLERedemptionEscrow onlyCall burnEscrowed and refundEscrowed. Granted at initialisation to the escrow address.

MINTER_ROLE and REDEMPTION_ROLE are contract-to-contract capabilities, not human keys. Neither can be exercised directly: the token's only issuance path runs through the mint controller's checks, and its only burn path through the escrow's lifecycle.

Governance

The ADMIN Safe

Administrative authority is a Safe (Gnosis Safe) deployed by the same script that deploys the protocol, through the canonical SafeProxyFactory with a SafeL2 singleton and the standard CompatibilityFallbackHandler. The proxy address is deterministic: CREATE2 over the hash of the initialiser plus a salt nonce, so the same owner set and nonce always produce the same address.

  • Threshold. Configured at deployment. Every administrative call is an execTransaction carrying at least that many owner signatures; the Safe reverts the whole transaction if the inner call fails when safeTxGas and gasPrice are zero, so a partially applied administrative action is not possible.
  • Owner rotation. Owners are not fixed at deployment. addOwnerWithThreshold, removeOwner and swapOwner are ordinary Safe transactions and need the current threshold of signatures. A compromised or lost owner key is rotated without touching the protocol contracts.
  • Separation from the operational keys. The deployment script rejects a configuration where an owner address is also the service or emergency address, and where service equals emergency (InvalidRoleAddress). Governance, day-to-day operations and the emergency stop are three distinct key sets.
  • Threshold sanity. The script also rejects a threshold below a majority of owners or above the owner count (InvalidSafeThreshold), so a Safe that a single owner could control by attrition cannot be deployed by accident.

How the Safe is operated

Owner keys live on hardware devices held by separate officers of the issuing company, so no single officer can reach the threshold alone. Because rotation is an ordinary Safe transaction, a departing officer or a replaced device is handled without redeploying or upgrading anything, and without a gap in coverage.

Contract

Token

A standard ERC-20 with permit, plus a transfer hook. The whole of the token's non-standard behaviour is in _update, which classifies every balance change into one of four cases.

Transfer rules — _update(from, to, value)

CaseConditionEnforced
Mintfrom == address(0)Reverts TransferPaused if paused; recipient must be whitelisted.
Burnto == address(0)No checks. Redemption burns stay available while the token is paused, so a pause cannot trap tokens already in escrow.
Escrow outflowhasRole(REDEMPTION_ROLE, from)Reverts UnauthorizedEscrowTransfer unless a refund is in progress and the recipient is exactly the recorded original holder.
Ordinary transfereverything elseReverts TransferPaused if paused; both sender and recipient must be whitelisted.

The escrow case is the reason the escrow cannot be drained by an operator: tokens held by an address with REDEMPTION_ROLE can only leave by being burned, or by being returned to the exact address that deposited them during a refundEscrowed call. The recipient is pinned in transient storage for the duration of that one call.

mint(address to, uint256 amount)MINTER_ROLE

Issues tokens. Reachable only from the mint controller, after its limit and cap checks have passed.

burnEscrowed(uint256 amount)REDEMPTION_ROLE

Burns tokens from the caller's own balance. Called by the escrow when a redemption finalises.

refundEscrowed(address originalHolder, uint256 amount)REDEMPTION_ROLE

Returns escrowed tokens to the address that created the request. Bypasses the whitelist check by design, so a user removed from the whitelist while a request was in flight still gets their tokens back.

permit(owner, spender, value, deadline, v, r, s)Public

ERC-2612 approval by signature. Used by the escrow's createRequestWithPermit to make approve-and-redeem a single transaction.

decimals()View

Returns 6. Declared pure, not stored.

pause()ADMIN or EMERGENCY

Blocks minting and ordinary transfers. Burns remain open. Authority is checked by _checkPauseAuthority, which accepts either role.

unpause()ADMIN_ROLE

Resumes transfers. The emergency key can stop the system but cannot restart it — restarting is a multisig decision.

Contract

WhitelistRegistry

A single mapping of address to boolean, read by the token on every transfer, by the mint controller before issuance and by the escrow before a request is created or a payout registered. Every mutation emits WhitelistStatusChanged(account, whitelisted, operator), which records the operator, so the indexer can attribute each listing decision.

isWhitelisted(address account) → boolView

The only read the other contracts perform.

add(address account)SERVICE_ROLE

Lists an account. Takes effect in the same block — there is no delay on listing, because listing only ever widens access for one account at a time.

remove(address account)SERVICE_ROLE

Delists an account immediately. A delisted holder keeps their balance but cannot send or receive; an in-flight redemption still refunds to them.

addBatch(address[] accounts), removeBatch(address[] accounts)SERVICE_ROLE

Batch forms of the above, one event per account.

pause(), unpause()ADMIN or EMERGENCY ADMIN to resume

Freezes whitelist mutations. Reads keep working, so pausing the registry freezes the eligibility set rather than blocking the token.

Contract

SupplyController

Holds one number: the administrator-reported backing, expressed as a maximum total supply. The mint controller reads it on every mint and refuses to cross it. Raising or lowering it is always delayed by UPDATE_DELAY = 48 hours — there is no immediate path, in either direction.

supplyCap() → uint256View

The effective cap. If a scheduled update has matured, this returns the new value even before anyone has written it to storage.

syncSupplyCap() → uint256Public

Persists a matured update and returns the stored cap. Called by MintController.mint, so the cap is always current at the moment it is enforced; anyone may call it to settle the state explicitly.

scheduleSupplyCapUpdate(uint256 newCap) → bytes32 operationIdADMIN_ROLE

Schedules the cap to become effective 48 hours later, replacing any pending proposal. The returned id is derived from the contract address, chain id, an incrementing nonce and the new value, so two identical proposals are still distinguishable. Emits SupplyCapUpdateScheduled.

cancelSupplyCapUpdate()ADMIN_ROLE

Withdraws a proposal that has not yet matured. Reverts NoPendingUpdate if there is none.

Why the delay applies to decreases too

A cap that could be lowered instantly would let an administrator freeze issuance without notice; one that could be raised instantly would remove the only supply constraint. Both directions carry the same 48 hours, which makes the cap a public commitment for the length of that window.

Contract

MintController

The only path to issuance. mint runs five checks in order, and each has its own error, so a rejected mint is always attributable.

  1. Caller is the current mintAgent — otherwise UnauthorizedMintAgent. This is a single address, not a role.
  2. Contract is not paused, and any matured limit relaxation is applied first via _syncLimits.
  3. Recipient is whitelisted — otherwise AccountNotWhitelisted.
  4. Amount is within minAmount and maxAmount — otherwise MintAmountBelowMinimum or MintAmountAboveMaximum.
  5. Per-recipient and global UTC-day allowances are consumed — DailyLimitExceeded if either is exhausted — and the resulting total supply is compared against syncSupplyCap(), otherwise SupplyCapExceeded.

Daily usage is tracked by DailyUsageLib, which stores a day id (block.timestamp / 1 days) alongside the amount. A new day resets the counter on first use rather than by a scheduled job, so there is nothing to keep running.

mint(address recipient, uint256 amount)mintAgent

Issues tokens after the checks above and emits Minted(agent, recipient, amount, dayId).

setMintAgent(address newAgent)ADMIN_ROLE

Replaces the sole minting address immediately. Rejects the zero address (InvalidMintAgent). Rotating a compromised issuance key is one multisig transaction and does not require an upgrade.

setMintLimits(MintLimits newLimits) → bytes32 operationIdADMIN_ROLE

Asymmetric by design. A pure tightening — every bound at least as strict as the current one — applies in the same block. Anything that relaxes a bound is scheduled 48 hours out (RELAXATION_DELAY) and emits MintLimitsUpdateScheduled. The classification is _isRelaxation: a lower minimum, a higher maximum, or a higher daily allowance.

cancelMintLimits()ADMIN_ROLE

Withdraws a scheduled relaxation. Reverts NoPendingLimits if none is pending.

limits(), recipientUsage(address), globalUsage()View

Effective limits, including a matured relaxation, and today's consumed allowance per recipient and in total.

pause(), unpause()ADMIN or EMERGENCY ADMIN to resume

Stops issuance without touching transfers or redemption.

Contract

RedemptionEscrow

Redemption is a two-sided settlement: the user's tokens are locked on Base while a settler pays the user in an approved stablecoin on the destination network. The escrow is the referee. It never holds or moves the payout asset — it holds USD AW and decides, at the end of the lifecycle, whether that balance is burned or returned.

Request lifecycle

Active Paid Burned Disputed Refunded markPaid settler confirmReceived finalizeUndisputed user, or anyone after the window openDispute user resolveDisputeWithBurn ADMIN Safe resolveDisputeWithRefund ADMIN Safe refundExpired anyone, after the payment deadline
Burned and Refunded are terminal. RedemptionStatus also defines None for an id that was never issued; _requireStatus reverts InvalidRequestStatus(requestId, status) on any transition out of order.

Creating a request

_createRequest validates in this order: the user is whitelisted; the amount is inside the effective limits; the destination chain is in approvedNetworks; the payout token is approved for that chain. Only then does it assign requestId = nextRequestId++, stamp paymentDeadline = block.timestamp + paymentWindow, write the request and pull the tokens in with transferFrom. Ids start at 1, so id 0 never exists.

Note the ordering: daily allowances are not consumed here. They are consumed in markPaid, when a payout actually happens, so a user cannot exhaust the global daily allowance by opening requests that are never settled.

createRequest(uint256 amount, uint256 destinationChainId, address payoutToken, address payoutAddress) → uint256Token holder

Escrows amount and opens a request. Requires a prior approve. Reverts NetworkNotApproved, PayoutAssetNotApproved, RedemptionAmountBelowMinimum, RedemptionAmountAboveMaximum, AccountNotWhitelisted or EscrowTransferFailed.

createRequestWithPermit(…, deadline, v, r, s) → uint256Token holder

Same, with the approval supplied as an ERC-2612 signature so the user signs once and sends one transaction.

markPaid(uint256 requestId, bytes32 payoutTxHash)SERVICE_ROLE

Registers the settler's off-chain payout. Requires the request to be Active, the payment deadline not yet reached (PaymentWindowExpired), the user still whitelisted, and a non-zero hash (InvalidPayoutTxHash).

Replay is blocked by usedPayouts[keccak256(abi.encode(destinationChainId, payoutTxHash))] — one payout transaction can settle exactly one request, ever, and a second attempt reverts PayoutAlreadyUsed. This is what stops a settler from claiming several requests with a single transfer.

On success it consumes the user's and the global daily allowance, sets disputeDeadline = block.timestamp + disputeWindow, moves the request to Paid and emits RedemptionPaid.

confirmReceived(uint256 requestId)Request owner

The user acknowledges the payout. Burns the escrowed balance immediately, ending the request early rather than waiting out the dispute window.

openDispute(uint256 requestId)Request owner

Asserts that the registered payout did not arrive. Allowed strictly before disputeDeadline (DisputeWindowExpired). Freezes the request in Disputed so neither burn path can run.

finalizeUndisputed(uint256 requestId)Anyone

Burns a Paid request once its dispute window has closed. Permissionless on purpose: settlement does not depend on the operator staying online, and reverts DisputeWindowActive if called early.

refundExpired(uint256 requestId)Anyone

Returns the escrowed balance to the user when an Active request passed its payment deadline without a payout. Also permissionless, so a user whose request was ignored does not need anyone's cooperation to get their tokens back. Reverts PaymentWindowActive if called early.

resolveDisputeWithBurn(uint256 requestId), resolveDisputeWithRefund(uint256 requestId)ADMIN_ROLE

The two exits from Disputed. Burn accepts the settler's evidence; refund returns the tokens to the user. Both emit DisputeResolved(requestId, resolution, admin). There is no timeout on a dispute — an unresolved dispute holds the tokens indefinitely, which is a deliberate bias toward human review.

setNetworkApproved(uint256 chainId, bool approved), setPayoutAssetApproved(uint256 chainId, address payoutToken, bool approved)ADMIN_ROLE

Open or close a destination network and the assets payable on it. Immediate in both directions; closing a network stops new requests but does not affect requests already in flight.

setPaymentWindow(uint64), setDisputeWindow(uint64)ADMIN_ROLE

Change the windows for future requests and future payout registrations only. Deadlines are stamped into each request when it is created or paid, so no administrative action can shorten a window a user is already relying on.

setRedemptionLimits(RedemptionLimits) → bytes32, cancelRedemptionLimits()ADMIN_ROLE

Same asymmetry as the mint controller: tightening is immediate, relaxing waits 48 hours.

requests(uint256), limits(), userUsage(address), globalUsage(), isPayoutAssetApproved(chainId, token), usedPayouts(bytes32)View

The full request record, effective limits, today's consumed allowances, asset approval and payout-hash consumption.

Settlement

How redemption works

Redemption converts USD AW on Base into a stablecoin on another network. The two legs cannot be atomic — no contract on Base can move funds on another chain — so the protocol splits them: the escrow locks the tokens and records the outcome, while a settler performs the payout and the issuer reconciles the money afterwards.

The three parties

PartyHoldsDoes
UserUSD AW, and a whitelisted addressOpens the request, receives the payout, confirms it or disputes it.
SettlerSERVICE_ROLE on the escrow, plus working capital in the payout assetPays the user on the destination network from its own float and registers the payout on Base.
IssuerThe ADMIN SafeAppoints settlers, sets the limits, windows and approved networks, reimburses settlers, and resolves disputes.

The issuer is the licensed company behind USD AW: it holds the backing, provides the liquidity and operates the exchange leg. Settlers are its appointed counterparties — the role exists so that payouts can be funded from working capital on many networks at once, without the issuer having to pre-position float on each of them.

End to end

User Escrow Settler Issuer createRequest tokens locked in escrow enters the queue as Active payout in the approved asset markPaid(id, hash) confirmReceived or finalizeUndisputed later burn reimbursement and fee share transaction on Base happens off Base
Solid marks are transactions on Base. The payout itself, the queue read and the reimbursement happen off Base.

Step by step

  1. The user opens a request. createRequest(amount, destinationChainId, payoutToken, payoutAddress) — or createRequestWithPermit, which folds the approval into the same transaction. The escrow checks the whitelist, the limits, the destination network and the payout asset, then pulls the tokens into its own balance with transferFrom. From this moment the user cannot spend them; they are not burned either, and the user can still get them back.
  2. The request enters the queue. Its status is Active and its paymentDeadline is stamped at block.timestamp + paymentWindow. Settlers see it through the indexer at GET /api/v1/redemptions?status=Active, with the amount, destination chain, payout asset, payout address and the deadline they are working against.
  3. The settler pays. It transfers the net amount in the approved payout asset to payoutAddress on destinationChainId, out of its own funds. No protocol contract is involved in this leg — it is an ordinary transfer on the destination network.
  4. The settler registers the payout. markPaid(requestId, payoutTxHash) on Base, before the payment deadline. The escrow consumes the user's and the global daily redemption allowance, stamps disputeDeadline = block.timestamp + disputeWindow, moves the request to Paid and emits RedemptionPaid with the hash and the destination chain.
  5. The request finalises. The user calls confirmReceived as soon as the money lands, which burns the escrowed tokens immediately. If the user does nothing, anyone may call finalizeUndisputed once the dispute window has closed, with the same effect. The escrowed balance is burned in both cases — it never goes to the settler.
  6. The issuer reimburses the settler. Off-chain, against the burn and the registered hash: the amount the settler fronted, plus its share of the redemption fee.

Money and fees

The redemption fee is charged by the issuer on the redeemed amount and is the settler's compensation for providing the float. The settler receives half of it; the issuer retains the other half. Worked through, for a request of A tokens at a fee of F:

LegAmountWhere
User escrowsABase, into the escrow contract
Settler pays the userA − FDestination network, from the settler's own float
Escrow burnsABase, on finalisation
Issuer credits the settler(A − F) + F/2Off-chain settlement with the issuer
Issuer retainsF/2

Supply falls by the full A while the user receives A − F, so the fee accrues to the issuer and the settler as a reduction in circulating supply rather than as a transfer. Nothing about the fee is encoded in the escrow: it holds and burns the gross amount, and the split is settled between the issuer and the settler.

The two windows

Each request carries its own deadlines, stamped at the moment they start rather than read from configuration later. Changing paymentWindow or disputeWindow affects only requests opened or paid afterwards, so a user's window can never be shortened underneath them.

paymentWindow
Runs from createRequest. The settler must call markPaid inside it; afterwards the call reverts PaymentWindowExpired and anyone may return the tokens to the user with refundExpired.
disputeWindow
Runs from markPaid. The user may call openDispute inside it. Once it closes, finalizeUndisputed burns the escrow and the request is settled.

Assignment and replay

The escrow does not reserve a request for a settler — there is no claim or lock in the contract, and markPaid is first-come. Allocation is therefore an issuer function: its settlement service assigns request ids to settlers so that two of them never fund the same payout. The contract's own guarantee is narrower and absolute: usedPayouts[keccak256(abi.encode(destinationChainId, payoutTxHash))] marks each payout transaction consumed, so one transfer can settle exactly one request, and a second attempt reverts PayoutAlreadyUsed.

Becoming a settler, and ceasing to be one

A settler is an address the ADMIN Safe has granted SERVICE_ROLE on the escrow. The role is deliberately narrow — it can register a payout and nothing else. A settler cannot mint, cannot move escrowed tokens, cannot change limits, windows or approved networks, cannot resolve a dispute, and cannot mark a request paid after its deadline. Revocation is one Safe transaction and takes effect in the next block; requests the settler has already paid and registered are unaffected.

What happens when something goes wrong

SituationOutcomeMechanism
Nobody picks the request upThe user recovers the full amountrefundExpired is permissionless and needs no cooperation from the issuer or a settler.
The settler pays but misses the deadlineThe user keeps the tokens; the settler settles with the issuer out of bandmarkPaid reverts PaymentWindowExpired, so a late payout has no on-chain claim.
The payout does not arriveThe user freezes the request before it can be burnedopenDispute inside the dispute window, then resolveDisputeWithRefund returns the escrowed tokens.
The payout did arrive but is disputedThe issuer resolves against the destination-chain recordresolveDisputeWithBurn completes the redemption.
One transfer offered for two requestsRejectedusedPayouts, keyed on the destination chain and the payout hash.
The user is delisted mid-flightThe refund still reaches themrefundEscrowed bypasses the whitelist for the recorded original holder.

Why the escrow does not read the destination chain

A contract on Base cannot observe a transfer on another network without an oracle or a bridge, and either would add a dependency with its own trust and liveness properties. The protocol takes the simpler route: markPaid records the payout hash, the escrow enforces that no hash is used twice, and correctness of the payout itself is established the way it is established everywhere else in payments — by the recipient confirming, and by the issuer reconciling the destination-chain record against its own settlement ledger. The dispute window is the user's lever inside that process, and it should be set long enough for a real person to notice a missing payout.

Safety rails

What is delayed, what is immediate

The rule throughout: changes that reduce the protocol's freedom take effect at once, changes that increase it wait 48 hours. Delays are enforced by the contracts themselves and cannot be skipped by the Safe.

ChangeMethodEffect
Raise or lower the supply capscheduleSupplyCapUpdate48 h, both directions
Loosen a mint limitsetMintLimits48 h
Tighten a mint limitsetMintLimitsImmediate
Loosen a redemption limitsetRedemptionLimits48 h
Tighten a redemption limitsetRedemptionLimitsImmediate
Rotate the mint agentsetMintAgentImmediate
Add or remove a whitelisted accountadd / removeImmediate
Approve or close a destination network or assetsetNetworkApprovedImmediate, new requests only
Change payment or dispute windowsetPaymentWindowImmediate, future requests only
PausepauseImmediate
Upgrade an implementationupgradeToAndCallImmediate

A scheduled change is public the moment it is proposed: SupplyCapUpdateScheduled, MintLimitsUpdateScheduled and RedemptionLimitsUpdateScheduled all carry the new values and the activation timestamp, and the indexer surfaces the pending cap in the protocol snapshot. The 48 hours are notice, not just friction.

Incident response

Pause and emergency

Four contracts are pausable: the token, the whitelist registry, the mint controller and the redemption escrow. _checkPauseAuthority accepts either ADMIN_ROLE or EMERGENCY_ROLE; unpause is ADMIN_ROLE only. The emergency key is therefore a one-way switch — it can be held on a hotter, faster-to-reach device than the Safe owners without giving it the power to restart the system or to change anything.

PausedBlockedStill works
TokenMinting and ordinary transfersBurns, so redemptions already in escrow can still finalise
WhitelistRegistryListing and delistingisWhitelisted reads, so the token keeps operating on the frozen set
MintControllerAll issuanceTransfers and redemption
RedemptionEscrowNew requests and payout registrationconfirmReceived, finalizeUndisputed and refundExpired — every path that returns or settles funds already committed

The pattern is consistent: a pause stops new commitments, never the exits. No combination of pauses can strand tokens in escrow.

Lifecycle

Upgrades

All five contracts are UUPS proxies. Upgrade authority is a single line in SystemAccessUpgradeable:

function _authorizeUpgrade(address) internal override onlyRole(ADMIN_ROLE) {}

  • Implementations call _disableInitializers() in their constructor, so an implementation contract cannot be initialised directly and taken over.
  • Each contract reserves a storage gap sized to the fixed slot budget — uint256[47] on the token, [49] on the whitelist, [44] on the supply controller, [39] on the mint controller, [37] on the escrow — so new state can be appended without shifting existing slots.
  • There is no delay on an upgrade. This is the single largest trust assumption in the system and the reason the Safe's threshold and key custody matter more than any other parameter.
Off-chain

Indexer

The indexer is a Node service with a Postgres store. It reads logs from the five contract addresses, decodes them, projects them into an activity feed and a redemption table, and periodically snapshots protocol state read directly from the contracts. It is read-only with respect to the chain: it holds no keys and can sign nothing.

The sync loop — syncProtocol

  1. Lock. A conditional update on sync_state takes a 55-second lease (locked_until). If no row is updated, another tick is already running and this one returns locked. Overlapping invocations are safe by construction, so the loop interval does not have to exceed the run time.
  2. Target. The head is read at the configured confirmation tag — finalized by default, optionally safe. The indexer never reads unconfirmed blocks, which is why it needs no reorg-unwinding logic: a finalised block on Base does not roll back.
  3. Ranges. Logs are fetched in windows of LOG_BLOCK_RANGE blocks, at most MAX_RANGES_PER_RUN per tick, so a cold start catches up over several ticks instead of one unbounded request.
  4. Persist. Each range writes blocks, transactions, raw events and projected activity in a single transaction, then advances next_block and records indexed_block_hash.
  5. Snapshot. When the loop reaches the head, snapshotProtocol reads the live contract state — name, symbol, total supply, effective cap, any pending cap and its activation time, today's mint and redemption usage against their limits, the request counter and the pause flag — and stores it in protocol_state.
  6. Release. The lock is cleared in every path. A failure stores the error's class name in last_error and clears the lock, so a broken RPC endpoint degrades into a stalled cursor rather than a stuck lock.

Schema

TableKeyHolds
sync_statechain_idCursor, last indexed block and hash, lock lease, last error class.
blockschain_id, numberHash and timestamp for every block that produced an event.
transactionschain_id, hashBlock, position and event count.
raw_eventschain_id, tx_hash, log_indexDecoded arguments as JSON, verbatim. The projections are rebuildable from this table alone.
activitychain_id, tx_hash, log_indexThe human-readable feed: kind, title, detail, amount, account, request id.
activity_accounts+ accountEvery address touched by an event, so an address page is one indexed join rather than a scan.
redemptionschain_id, request_idCurrent status of each request, folded forward from its events.
protocol_statechain_id, keyThe latest contract snapshot with the block it was read at.

Writes are idempotent — inserts are ON CONFLICT DO NOTHING keyed on the log identity — so replaying a range cannot duplicate history. Amounts are stored as text, never as floats, and rendered at six decimals by the client.

Off-chain

Read API

Read-only JSON over HTTP. Every response is cacheable, no endpoint mutates state, and no endpoint accepts a signature — the API is a view onto the chain, not a way into it.

EndpointReturns
GET /api/v1/overviewChain, the protocol snapshot and the indexer cursor.
GET /api/v1/activityThe event feed, paged. Filters: kind, address, page, limit.
GET /api/v1/addresses/:addressAn account's events plus a summary: whitelist status and listing time, first and last seen, total received and sent.
GET /api/v1/transactions/:hashThe transaction, its decoded events and its projected activity rows.
GET /api/v1/redemptionsRequests, paged. Filters: status, user. This is the settler queue.
GET /api/v1/redemptions/:requestIdOne request and its full event history.
GET /api/v1/indexer/statusCursor, last indexed block and hash, timestamp, error class.
GET /api/v1/searchResolves a transaction hash or an address to its canonical route.

Errors are reported by class, never as raw messages — last_error surfaces as a fixed string — so an RPC provider's URL or key cannot leak through the API.

Security

Security model

Enforced on-chain

  • Only whitelisted addresses can hold or move the token; the check runs inside _update, so it cannot be bypassed by any transfer path.
  • Total supply can never exceed the reported cap: the check is inside mint, against a freshly synced cap.
  • Per-recipient and global daily issuance and redemption ceilings, reset by UTC day.
  • Escrowed tokens can only be burned or returned to the depositor. No operator path moves them anywhere else.
  • One payout transaction settles at most one request.
  • Users can always recover an unsettled request without anyone's permission, and can always freeze a settlement they dispute.
  • Every loosening of a limit is published 48 hours before it takes effect.

Operated by the issuer

The rest of the system is run by the issuing company, under its own licence and controls, with the contracts recording and constraining what it does.

  • Backing. The supply cap states the reserves the company holds against circulating USD AW. It moves only through scheduleSupplyCapUpdate, and every change is public 48 hours before it takes effect.
  • Payouts. The destination-chain leg is funded by settlers and reconciled by the company against its settlement ledger. On Base the escrow records the payout hash and guarantees no payout settles twice.
  • Eligibility. The whitelist is maintained under the company's compliance policy. The service key that operates it can list and delist, and cannot move funds.
  • Upgrades and disputes. Both run through the ADMIN Safe, so both need the multisig threshold rather than any single operator.

Key custody

Four key sets, deliberately distinct: Safe owners (hardware, ideally distributed across people and locations), the service key that runs whitelist and settlement operations, the mint agent, and the emergency pause key. The deployment script refuses configurations that collapse them together. Compromise of the service key or the mint agent is bounded and recoverable by one Safe transaction; compromise of the Safe threshold is not bounded by anything.