Skip to content
DocsSecurity model

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.

AccountNetworkOwnerWho signs an owner action
A teambothThe person who created itTheir own embedded wallet, sponsored, only after their browser approves that exact request. No server key can act for a team
Nuntio's main accountbothThat network's protocol admin keyThe 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 Sandboxtestnet onlyA Privy server walletThe 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 pay on its own account up to its cap, and request on 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.

#InvariantStatus
1Only the contract authorizes. The assistant only requestsHeld
2Missing, malformed, expired, paused or revoked permissions fail closed. An account that is not sealed cannot payHeld
3Cached database balances are display-only and never authorize spendingHeld. No contract caches a balance, and the app's money paths read the chain
4Receipts record the amount actually transferred, measured as balance before minus balance after, never a number someone suppliedHeld
5Merchant-reported usage is not trustedNot applicable: metered charging is not built, and no contract accepts a usage figure
6Relayer, operations and upgrade keys are separatePartly. 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
7Payments 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 migrationHeld
8Every function that moves money checks the pauseHeld: 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.