DocsHow it works
Smart contracts
NuntioAccount, ApprovalQueue, ServiceRegistry and NuntioTeamFactory. What each holds, what each enforces, and the functions that matter.
Four contracts, written in Solidity 0.8.28 with Foundry and OpenZeppelin Contracts v5.7.0. The same contracts run on Monad mainnet and Monad testnet. Addresses are in Networks and contract addresses.
| Contract | VERSION | Holds money | What it does |
|---|---|---|---|
NuntioAccount | 2 | Yes, the account's AUSD | The policy engine. One policy per spender, a global pause, and two ways to pay: pay within policy and paySettled for an approved exception |
ApprovalQueue | 2 | No | Where a refused payment asks the owner. Bound to one account, forever |
ServiceRegistry | 1 | No | The list of services: a service id maps to a name, a payee and a status |
NuntioTeamFactory | 1 | No | Creates a team's account and queue in one call and offers ownership to the caller |
#NuntioAccount
#Paying
pay(serviceId, amount, key, memo). The spender ismsg.sender. There is no address parameter. The checks run cheapest first: pause, seal, amount and memo, spender status, expiry, approved service, unused key, period roll, cap, remaining allowance, payment count, then the registry resolves the payee. The key is recorded as used and the counters move before the transfer, and the function is guarded against re-entry.paySettled(approvalId). Pays an approved request. Anyone may call it, because the amount, the spender and the payee were fixed when the request was filed. It refuses if the registry now resolves the service to a different payee from the one the owner approved.
Both record the measured amount: the account's AUSD balance before the transfer minus its balance after. If a transfer moved nothing, or moved more than authorized, the payment reverts.
Both emit Paid(spender, serviceId, payee, amount, idempotencyKey, memo).
#Owner functions
| Function | What it does |
|---|---|
grant(spender, allowance, periodSeconds, perPaymentCap, frequencyLimit, expiresAt) | Writes a whole policy and sets the spender Active. Starts a fresh period, so spent and payment count go to zero. Refuses a period of 0 and an expiry already past |
setAllowance(spender, allowance) | Changes the allowance only |
setLimits(spender, allowance, perPaymentCap, frequencyLimit) | Overwrites all three. A zero removes that limit |
setStatus(spender, status) | Active, Paused or Revoked. Revoked is final: only a fresh grant brings the spender back |
approveMerchant(spender, serviceId) / removeMerchant(...) | Adds or removes a service on one spender's list. Approving checks the service is registered and active |
setPaused(bool) | Stops or restarts every money-moving function |
withdraw(amount) | Sends AUSD to the owner. Respects the pause |
importSpentKeys(spender, keys) / seal() | Used by a migration before the account can pay. See below |
renounceOwnership always reverts, so an account always has an owner. Ownership moves in two steps: transferOwnership, then acceptOwnership by the new owner.
#Views
getPolicy, isApproved, isKeyUsed, remainingAllowance, spenderState, generationOf, isSealed. Views answer questions. Nothing authorizes on them. getPolicy reverts for an address with no policy rather than returning an empty one.
#Design rules
- Zero means no limit, the same way for the cap, the payment count and the expiry. Stopping a spender is
setStatus(Paused). - Every time comparison is
>=. Monad can give several blocks in a row the same timestamp, so a policy is dead at its expiry, and a request is live at its deadline. - Periods roll on schedule, by whole periods from the grant time, never to "now".
- Unordered keys. A payment's key is any 32 bytes, used once per spender. A dropped transaction never blocks later ones.
- A generation counter retires authority.
setStatus(Revoked)increases the spender's generation. The approved-services list is stored per generation, and every request stores the generation it was filed under, so one revocation retires the list and every outstanding request at once, even if the spender is granted again.
#ApprovalQueue
| Function | Who | What it does |
|---|---|---|
bind(account) | The address that deployed the queue, once | Binds the queue to one account that names it back. It can never be pointed elsewhere |
request(spender, serviceId, amount, intentKey, memo) | The spender itself, while Active | Files a request. Stores the payee the registry gives now, the spender's generation and a deadline one day ahead. One open request per intent key |
approve(id) / decline(id) | The bound account's current owner() | Decides a pending request. Approving moves no money |
cancel(id) | The owner or the spender who filed it | Withdraws a pending or approved request. Works while paused |
markSettled(id) | The bound account only, from inside paySettled | Marks an approved request settled, exactly once |
pause() / unpause() | The account's owner | Stops filing, deciding and settling |
A request's states are Pending, Approved, Declined, Settled, Expired, Cancelled and Retired. Expired is never written: a request past its deadline reads as Expired. Retired means a later request for the same intent replaced one that could no longer settle.
The VERSION 2 queue has no owner of its own. Its authority is always the bound account's current owner, read at every call.
#ServiceRegistry
- A service id is
keccak256(slug), for examplekeccak256("zg-chat"). resolve(serviceId)reverts for an unknown or suspended service rather than returning the zero address. A payee nobody registered cannot be expressed.tryResolvereturns zero instead, for screens only.- The registry's owner can
register,setStatus(Active or Suspended, never back to unknown) andsetMetadataHash. setPayeecan be called by the owner or by the service's current payee, so a merchant can rotate its own payout address.
#NuntioTeamFactory
createTeam() can be called by anyone. In one transaction it deploys a queue, deploys an account owned by the factory, binds them, seals the account and offers ownership to the caller. The caller becomes the owner with acceptOwnership(). Until then the factory is the owner, and it has no function that acts as one.
isTeamContract(address) is true for every account and queue the factory made. The app's gas sponsorship uses it to decide which contracts are Nuntio's. teamCount() and teamAt(index) list every team.
#VERSION 2, sealed and bound
- Sealed. An account is born unable to pay. A migration first imports every payment key the previous account used, so an old payment cannot be paid again on the new account, then calls
seal(). A fresh account seals as its last deploy step. There is no unseal. - Bound. A queue is bound once to one account that names it back, and the account checks that the queue uses the same registry.
Nuntio's testnet main account was migrated from VERSION 1 to VERSION 2 on 27 September 2026, with its spenders, services, spent keys and funds. Every account and queue in use runs VERSION 2. The earlier testnet deployments remain on chain and were emptied. The most recent of them is paused.
#Source and tests
- The runtime bytecode of every recorded deployment matches what the source compiles to. The repository has a read-only check for this (
pnpm check:deployed). Source verification on the block explorers is not recorded yet. - As of 12 October 2026 there are 506 Foundry tests across 15 suites: unit, adversarial, fuzz, invariant, migration and deploy-script tests. Every critical rule has a test that the valid path works and a test that the invalid path reverts with the exact error.
See Limits and known risks for the contracts' known limitations.