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()ispureand cannot be changed by an upgrade without changing the implementation. 1 USD AW =1_000_000units. - Network
- Base (chain id 8453). Redemption payouts settle on any EVM network the administrator has approved.
- Standard
- ERC-20 plus ERC-2612
permit, via OpenZeppelinERC20PermitUpgradeable5.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.
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_ROLE.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.
| Role | Held by | Grants the ability to |
|---|---|---|
| DEFAULT_ADMIN_ROLE | ADMIN Safe | Grant and revoke every role on that contract, including its own. |
| ADMIN_ROLE | ADMIN Safe | Change limits, caps, windows and network approvals; resolve disputes; unpause; authorise upgrades via _authorizeUpgrade. |
| SERVICE_ROLE | Operations keys, settlers | Mutate the whitelist (add, remove, addBatch, removeBatch) and register payouts (markPaid). Cannot move funds. |
| EMERGENCY_ROLE | Emergency key, held separately | Call pause() on the token, whitelist, mint controller and escrow. Cannot unpause. |
| MINTER_ROLE | MintController only | Call mint on the token. Granted at initialisation to the controller address. |
| REDEMPTION_ROLE | RedemptionEscrow only | Call 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.
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
execTransactioncarrying at least that many owner signatures; the Safe reverts the whole transaction if the inner call fails whensafeTxGasandgasPriceare zero, so a partially applied administrative action is not possible. - Owner rotation. Owners are not fixed at deployment.
addOwnerWithThreshold,removeOwnerandswapOwnerare 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.
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)
| Case | Condition | Enforced |
|---|---|---|
| Mint | from == address(0) | Reverts TransferPaused if paused; recipient must be whitelisted. |
| Burn | to == address(0) | No checks. Redemption burns stay available while the token is paused, so a pause cannot trap tokens already in escrow. |
| Escrow outflow | hasRole(REDEMPTION_ROLE, from) | Reverts UnauthorizedEscrowTransfer unless a refund is in progress and the recipient is exactly the recorded original holder. |
| Ordinary transfer | everything else | Reverts 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_ROLEIssues tokens. Reachable only from the mint controller, after its limit and cap checks have passed.
burnEscrowed(uint256 amount)REDEMPTION_ROLEBurns tokens from the caller's own balance. Called by the escrow when a redemption finalises.
refundEscrowed(address originalHolder, uint256 amount)REDEMPTION_ROLEReturns 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)PublicERC-2612 approval by signature. Used by the escrow's createRequestWithPermit to make approve-and-redeem a single transaction.
decimals()ViewReturns 6. Declared pure, not stored.
pause()ADMIN or EMERGENCYBlocks minting and ordinary transfers. Burns remain open. Authority is checked by _checkPauseAuthority, which accepts either role.
unpause()ADMIN_ROLEResumes transfers. The emergency key can stop the system but cannot restart it — restarting is a multisig decision.
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) → boolViewThe only read the other contracts perform.
add(address account)SERVICE_ROLELists 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_ROLEDelists 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_ROLEBatch forms of the above, one event per account.
pause(), unpause()ADMIN or EMERGENCY ADMIN to resumeFreezes whitelist mutations. Reads keep working, so pausing the registry freezes the eligibility set rather than blocking the token.
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() → uint256ViewThe effective cap. If a scheduled update has matured, this returns the new value even before anyone has written it to storage.
syncSupplyCap() → uint256PublicPersists 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_ROLESchedules 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_ROLEWithdraws 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.
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.
- Caller is the current
mintAgent— otherwiseUnauthorizedMintAgent. This is a single address, not a role. - Contract is not paused, and any matured limit relaxation is applied first via
_syncLimits. - Recipient is whitelisted — otherwise
AccountNotWhitelisted. - Amount is within
minAmountandmaxAmount— otherwiseMintAmountBelowMinimumorMintAmountAboveMaximum. - Per-recipient and global UTC-day allowances are consumed —
DailyLimitExceededif either is exhausted — and the resulting total supply is compared againstsyncSupplyCap(), otherwiseSupplyCapExceeded.
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)mintAgentIssues tokens after the checks above and emits Minted(agent, recipient, amount, dayId).
setMintAgent(address newAgent)ADMIN_ROLEReplaces 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_ROLEAsymmetric 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_ROLEWithdraws a scheduled relaxation. Reverts NoPendingLimits if none is pending.
limits(), recipientUsage(address), globalUsage()ViewEffective limits, including a matured relaxation, and today's consumed allowance per recipient and in total.
pause(), unpause()ADMIN or EMERGENCY ADMIN to resumeStops issuance without touching transfers or redemption.
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
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 holderEscrows amount and opens a request. Requires a prior approve. Reverts NetworkNotApproved, PayoutAssetNotApproved, RedemptionAmountBelowMinimum, RedemptionAmountAboveMaximum, AccountNotWhitelisted or EscrowTransferFailed.
createRequestWithPermit(…, deadline, v, r, s) → uint256Token holderSame, with the approval supplied as an ERC-2612 signature so the user signs once and sends one transaction.
markPaid(uint256 requestId, bytes32 payoutTxHash)SERVICE_ROLERegisters 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 ownerThe user acknowledges the payout. Burns the escrowed balance immediately, ending the request early rather than waiting out the dispute window.
openDispute(uint256 requestId)Request ownerAsserts 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)AnyoneBurns 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)AnyoneReturns 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_ROLEThe 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_ROLEOpen 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_ROLEChange 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_ROLESame asymmetry as the mint controller: tightening is immediate, relaxing waits 48 hours.
requests(uint256), limits(), userUsage(address), globalUsage(), isPayoutAssetApproved(chainId, token), usedPayouts(bytes32)ViewThe full request record, effective limits, today's consumed allowances, asset approval and payout-hash consumption.
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
| Party | Holds | Does |
|---|---|---|
| User | USD AW, and a whitelisted address | Opens the request, receives the payout, confirms it or disputes it. |
| Settler | SERVICE_ROLE on the escrow, plus working capital in the payout asset | Pays the user on the destination network from its own float and registers the payout on Base. |
| Issuer | The ADMIN Safe | Appoints 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
Step by step
- The user opens a request.
createRequest(amount, destinationChainId, payoutToken, payoutAddress)— orcreateRequestWithPermit, 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 withtransferFrom. From this moment the user cannot spend them; they are not burned either, and the user can still get them back. - The request enters the queue. Its status is
Activeand itspaymentDeadlineis stamped atblock.timestamp + paymentWindow. Settlers see it through the indexer atGET /api/v1/redemptions?status=Active, with the amount, destination chain, payout asset, payout address and the deadline they are working against. - The settler pays. It transfers the net amount in the approved payout asset to
payoutAddressondestinationChainId, out of its own funds. No protocol contract is involved in this leg — it is an ordinary transfer on the destination network. - 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, stampsdisputeDeadline = block.timestamp + disputeWindow, moves the request toPaidand emitsRedemptionPaidwith the hash and the destination chain. - The request finalises. The user calls
confirmReceivedas soon as the money lands, which burns the escrowed tokens immediately. If the user does nothing, anyone may callfinalizeUndisputedonce the dispute window has closed, with the same effect. The escrowed balance is burned in both cases — it never goes to the settler. - 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:
| Leg | Amount | Where |
|---|---|---|
| User escrows | A | Base, into the escrow contract |
| Settler pays the user | A − F | Destination network, from the settler's own float |
| Escrow burns | A | Base, on finalisation |
| Issuer credits the settler | (A − F) + F/2 | Off-chain settlement with the issuer |
| Issuer retains | F/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 callmarkPaidinside it; afterwards the call revertsPaymentWindowExpiredand anyone may return the tokens to the user withrefundExpired. - disputeWindow
- Runs from
markPaid. The user may callopenDisputeinside it. Once it closes,finalizeUndisputedburns 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
| Situation | Outcome | Mechanism |
|---|---|---|
| Nobody picks the request up | The user recovers the full amount | refundExpired is permissionless and needs no cooperation from the issuer or a settler. |
| The settler pays but misses the deadline | The user keeps the tokens; the settler settles with the issuer out of band | markPaid reverts PaymentWindowExpired, so a late payout has no on-chain claim. |
| The payout does not arrive | The user freezes the request before it can be burned | openDispute inside the dispute window, then resolveDisputeWithRefund returns the escrowed tokens. |
| The payout did arrive but is disputed | The issuer resolves against the destination-chain record | resolveDisputeWithBurn completes the redemption. |
| One transfer offered for two requests | Rejected | usedPayouts, keyed on the destination chain and the payout hash. |
| The user is delisted mid-flight | The refund still reaches them | refundEscrowed 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.
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.
| Change | Method | Effect |
|---|---|---|
| Raise or lower the supply cap | scheduleSupplyCapUpdate | 48 h, both directions |
| Loosen a mint limit | setMintLimits | 48 h |
| Tighten a mint limit | setMintLimits | Immediate |
| Loosen a redemption limit | setRedemptionLimits | 48 h |
| Tighten a redemption limit | setRedemptionLimits | Immediate |
| Rotate the mint agent | setMintAgent | Immediate |
| Add or remove a whitelisted account | add / remove | Immediate |
| Approve or close a destination network or asset | setNetworkApproved | Immediate, new requests only |
| Change payment or dispute window | setPaymentWindow | Immediate, future requests only |
| Pause | pause | Immediate |
| Upgrade an implementation | upgradeToAndCall | Immediate |
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.
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.
| Paused | Blocked | Still works |
|---|---|---|
| Token | Minting and ordinary transfers | Burns, so redemptions already in escrow can still finalise |
| WhitelistRegistry | Listing and delisting | isWhitelisted reads, so the token keeps operating on the frozen set |
| MintController | All issuance | Transfers and redemption |
| RedemptionEscrow | New requests and payout registration | confirmReceived, 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.
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.
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
- Lock. A conditional update on
sync_statetakes a 55-second lease (locked_until). If no row is updated, another tick is already running and this one returnslocked. Overlapping invocations are safe by construction, so the loop interval does not have to exceed the run time. - Target. The head is read at the configured confirmation tag —
finalizedby default, optionallysafe. The indexer never reads unconfirmed blocks, which is why it needs no reorg-unwinding logic: a finalised block on Base does not roll back. - Ranges. Logs are fetched in windows of
LOG_BLOCK_RANGEblocks, at mostMAX_RANGES_PER_RUNper tick, so a cold start catches up over several ticks instead of one unbounded request. - Persist. Each range writes blocks, transactions, raw events and projected activity in a single transaction, then advances
next_blockand recordsindexed_block_hash. - Snapshot. When the loop reaches the head,
snapshotProtocolreads 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 inprotocol_state. - Release. The lock is cleared in every path. A failure stores the error's class name in
last_errorand clears the lock, so a broken RPC endpoint degrades into a stalled cursor rather than a stuck lock.
Schema
| Table | Key | Holds |
|---|---|---|
| sync_state | chain_id | Cursor, last indexed block and hash, lock lease, last error class. |
| blocks | chain_id, number | Hash and timestamp for every block that produced an event. |
| transactions | chain_id, hash | Block, position and event count. |
| raw_events | chain_id, tx_hash, log_index | Decoded arguments as JSON, verbatim. The projections are rebuildable from this table alone. |
| activity | chain_id, tx_hash, log_index | The human-readable feed: kind, title, detail, amount, account, request id. |
| activity_accounts | + account | Every address touched by an event, so an address page is one indexed join rather than a scan. |
| redemptions | chain_id, request_id | Current status of each request, folded forward from its events. |
| protocol_state | chain_id, key | The 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.
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.
| Endpoint | Returns |
|---|---|
| GET /api/v1/overview | Chain, the protocol snapshot and the indexer cursor. |
| GET /api/v1/activity | The event feed, paged. Filters: kind, address, page, limit. |
| GET /api/v1/addresses/:address | An account's events plus a summary: whitelist status and listing time, first and last seen, total received and sent. |
| GET /api/v1/transactions/:hash | The transaction, its decoded events and its projected activity rows. |
| GET /api/v1/redemptions | Requests, paged. Filters: status, user. This is the settler queue. |
| GET /api/v1/redemptions/:requestId | One request and its full event history. |
| GET /api/v1/indexer/status | Cursor, last indexed block and hash, timestamp, error class. |
| GET /api/v1/search | Resolves 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 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.