Skip to content
DocsSmart contracts

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.

ContractVERSIONHolds moneyWhat it does
NuntioAccount2Yes, the account's AUSDThe policy engine. One policy per spender, a global pause, and two ways to pay: pay within policy and paySettled for an approved exception
ApprovalQueue2NoWhere a refused payment asks the owner. Bound to one account, forever
ServiceRegistry1NoThe list of services: a service id maps to a name, a payee and a status
NuntioTeamFactory1NoCreates a team's account and queue in one call and offers ownership to the caller

#NuntioAccount

#Paying

  • pay(serviceId, amount, key, memo). The spender is msg.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

FunctionWhat 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

FunctionWhoWhat it does
bind(account)The address that deployed the queue, onceBinds 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 ActiveFiles 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 itWithdraws a pending or approved request. Works while paused
markSettled(id)The bound account only, from inside paySettledMarks an approved request settled, exactly once
pause() / unpause()The account's ownerStops 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 example keccak256("zg-chat").
  • resolve(serviceId) reverts for an unknown or suspended service rather than returning the zero address. A payee nobody registered cannot be expressed.
  • tryResolve returns zero instead, for screens only.
  • The registry's owner can register, setStatus (Active or Suspended, never back to unknown) and setMetadataHash.
  • setPayee can 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.