DocsSecurity
Security model
The contract is the only authority. Who signs as owner, what each layer refuses, and the invariants the code holds.
#The model in one paragraph
A spender, a person or an AI agent, starts a payment. The account contract checks it against that spender's onchain policy and either pays or reverts. Every other layer, the app's own checks and Privy's policy engine, refuses earlier with a better message. They are defence in depth, never the authority, and each one refuses on its own. No function that spends takes a destination address: the payee is resolved from the service registry at the moment of payment.
#The gas payer cannot spend
Privy sponsors a transaction by upgrading the spender's own wallet (EIP-7702) and submitting it through an ERC-4337 bundler. The spender's wallet stays the caller the contract authorizes. Who the contract sees as the caller has been observed on chain for a person paying directly, a raw-key agent, a sponsored Privy server wallet, an agent's sponsored payment on mainnet whose Paid.spender is the agent's wallet and not the bundler, and a person's own embedded wallet creating and accepting a team on mainnet, sponsored.
#Who signs as the owner
The owner is whoever owner() names on the account contract. Spending authorization lives entirely in NuntioAccount and ApprovalQueue.
| Account | Network | Owner | Who signs an owner action |
|---|---|---|---|
| A team | both | The person who created it | Their own embedded wallet, sponsored, only after their browser approves that exact request. No server key can act for a team |
| Nuntio's main account | both | That network's protocol admin key | The protocol admin key. When the app acts as this account's owner, the web server signs with that key, scoped as below. On mainnet the main account holds a few dollars for testing |
| The Sandbox | testnet only | A Privy server wallet | The web server, through that wallet's Privy policy: grants of at most $25 allowance and $10 a payment with an expiry, six services, status changes, approve and decline |
The main account's server key is scoped by ABI. It can encode only the owner functions (the policy setters, setPaused and withdraw) and paySettled. It cannot encode pay. A compromised server could widen a policy, approve or settle an exception, pause or unpause, revoke a spender, and withdraw the main account's balance to its owner. It could not send money to an address of its choosing, pick the amount or payee of a settlement, or pay as a spender.
#Agents and the assistant
- Nini holds no key and builds no transaction. It returns a typed draft that names a registered service. Code reads the amount, the registry resolves the payee, and the person confirms.
- An agent signs with its own Privy server wallet. Its authorization key lives only in the worker's environment, never in the web app. Its Privy policy allows only
payon its own account up to its cap, andrequeston its own queue naming itself. - The worker records an intent before it signs, and the contract records each payment's key as used, so a crash after sending cannot pay twice. The worker has a kill switch that stops it before it touches anything.
- The testnet grant wallet can only send AUSD, at most $20 a transfer. Mainnet has no grant wallet.
#Invariants
These are the invariants the project set for itself. Six are held by the code, each with a passing test and a test proving the invalid case reverts. Two are stated with how they stand today.
| # | Invariant | Status |
|---|---|---|
| 1 | Only the contract authorizes. The assistant only requests | Held |
| 2 | Missing, malformed, expired, paused or revoked permissions fail closed. An account that is not sealed cannot pay | Held |
| 3 | Cached database balances are display-only and never authorize spending | Held. No contract caches a balance, and the app's money paths read the chain |
| 4 | Receipts record the amount actually transferred, measured as balance before minus balance after, never a number someone supplied | Held |
| 5 | Merchant-reported usage is not trusted | Not applicable: metered charging is not built, and no contract accepts a usage figure |
| 6 | Relayer, operations and upgrade keys are separate | Partly. There is no relayer key and no upgrade path. On mainnet the deployer holds no role and the protocol admin is a separate key. On testnet the protocol admin is still the deployer key |
| 7 | Payments carry idempotency and replay protection. A spent key cannot be paid again, through either way of paying, and across the VERSION 1 to VERSION 2 migration | Held |
| 8 | Every function that moves money checks the pause | Held: pay, paySettled and withdraw |
Two more rules the code holds:
- A failed read is unknown, never zero. An unreadable spend total is never read as "nothing spent".
- An approval waives limits, never safety. A paused account, a revoked spender, an expired policy, a suspended service or a changed payee still refuse settlement. See Approvals.
#Fail closed, everywhere
- An address with no policy is refused before any limit is read.
- An unset owner allowlist on the server grants nobody, not everybody.
- A feature switch is on only when it is set to exactly
"true". - When an outcome is unclear, the app says pending, never failed, and never sends again on a guess.
#Secrets
- Every module that handles a secret is marked server-only, so a leak into the browser bundle is a build error.
- CI scans the full history for secrets, including bare 64-hex strings that match no vendor pattern.
- Production secrets are set without being printed. The agent signing key is on the worker only.
#Reporting a vulnerability
Use GitHub's private vulnerability reporting on the Nuntio repository: the Security tab, then "Report a vulnerability". If that option is not shown to you, open an issue that asks for a private channel and contains no details. Please do not put anything that could move funds in a public issue.