Skip to content

Testnet: free test dollars. The real Nuntio runs on Monad mainnet at nuntio.finance.

Security

What stops a payment. And what we have not done.

Three separate checks have to agree before money moves, and each one refuses for a different reason. Everything below names the file or the transaction behind it, so you can go and disagree with us.

The three checks

Three refusals, and no two of them are the same check.

One check that fails closed is one bug away from failing open. These three sit at different distances from the money, share no code, and refuse for different reasons. Passing the first earns nothing at the second.

  1. The tool schema

    A recipient the model invented is unrepresentable, not rejected.

    The assistant is handed exactly one tool, with four string fields: a service id, an amount, a currency and a memo. There is no address field, so there is nowhere for a model to put an address. The service id is an enum generated from this spender’s own approved list, and the schema refuses any field it was not given, so the set of payments the assistant can even describe is a set you already approved.

    In the repositorypackages/agent/src/tool.tspackages/agent/src/validate.ts

  2. The registry

    A merchant nobody registered has nowhere to resolve to.

    ServiceRegistry.resolve() reverts on an id nobody registered rather than answering with the zero address. That catches the case the schema structurally cannot: a name sitting on a spender’s approved list that was never registered on chain. The revert carries the id back out with it, so the refusal is still checkable an hour later.

    In the repositorypackages/contracts/src/ServiceRegistry.solpackages/sdk/src/registry.ts

  3. The policy

    Eight conditions, checked on chain, by the contract that holds the money.

    NuntioAccount.pay takes a service id, an amount, a key and a memo. It takes no address. Before anything moves it checks the account pause, the spender’s status, the expiry, the merchant allowlist, the replay key, the per-payment cap, the allowance left and the frequency limit. Every setter that could loosen one of those is owner-only.

    In the repositorypackages/contracts/src/NuntioAccount.sol

No layer trusts the answer of the one before it. The server resolving a merchant to an address is a convenience that makes a clearer screen, never a permission: the contract resolves the id again when the payment is actually made.

The request path

Five checks between a sentence and a payment.

The assistant names a merchant and states an amount. That is the whole of its power. Everything after it is a check the assistant cannot reach, and three of them can refuse on their own. Refusing is not an error, which is why one of the two endings is amber and neither is red.

Five stages in order, then two endings. The third, fourth and fifth stages can each refuse on their own, and no stage trusts the answer of the one before it.

  1. The sentence

    “Top up our inference credits by $120.” Typed by you, or sent by your assistant.

    Refuses nothing. Nothing has been looked at yet.

  2. The assistant

    It names one of six approved services and states an amount. It never sees an address, and its form has no address field to fill in.

    Refuses nothing. It proposes, and that is all it does.

  3. The schema

    The call is read or it is refused. A name outside the six stops here, before anything touches the network.

    Refuses a name that is not on the list. No money moved.

  4. The registry

    The name becomes an address, on chain. A name nobody registered has nowhere to resolve to, so there is no one to pay.

    Refuses a merchant nobody registered. No money moved.

  5. The policy

    The allowance left, the cap on one payment, how often, the end date, and the merchant list. Checked on chain, not in the app.

    Refuses anything over those five. Held, and no money moved.

Two endings

  • Paid

    $120.00 to Acme Inference.

    The receipt records the balance before minus the balance after, so the figure written down is the figure that moved.

  • Held for you

    Nothing moved, and nothing failed.

    The request reaches you carrying the rule that caught it and the amount it went over by. Approve it once, or decline it. Neither answer leaves a payment half done.

A diagram of the path a request takes, not a screenshot. No check here trusts the answer of the one before it.

Checked, not asserted

What has been run against the chain.

Each of these was captured from a live network and written down at the time, with the raw output kept. Where a capture exists, the file holding it is named so you can read the output rather than this summary of it.

The registry is live
Deployed to Monad testnet, chain id 10143, at block 61,434,750. Bytecode present, VERSION() reads 1, owner correct. 0x19861745c5292a6911CbF6535e396d4DD18fef0D Open on the explorerdocs/hackathon/evidence/01-unknown-service-reverts.md
An invented merchant reverts
Asking the live registry to resolve a name nobody registered returns 0xcab75967, the selector for UnknownService(bytes32), followed by the id echoed back. Captured with cast against the deployed contract, not in a local test.docs/hackathon/evidence/01-unknown-service-reverts.md
All six services in the product resolve
Every merchant the app can show was resolved against the chain one at a time, and each on-chain name matches the name in the app exactly. This check was added after the two lists were found to have no entry in common, which would have made every proposal revert for a reason that had nothing to do with the assistant.docs/hackathon/evidence/03-every-fixture-merchant-resolves.md
Both refusal layers fired end to end
A service name outside the spender’s catalog was refused before any network call. The same name, placed inside the catalog but never registered on chain, was refused by the chain instead. The id the client decoded is byte-identical to the id the contract echoed back.docs/hackathon/evidence/04-two-layers-refuse-a-hallucination.md
The caller is measured, not assumed
A policy that authorizes whoever the contract sees as its caller is only safe if somebody has looked. CallerProbe records the sender, the origin, the sender’s code size and whether that code carries a delegation prefix, during a real transaction rather than a simulated call. The plain-account baseline and a sponsored call are both recorded: with the fee paid by a sponsor, the contract still sees the paying wallet as the sender. 0xfddD1C5B54D5D70485FF640d8C7a75e5f13F7123 Open on the explorerdocs/hackathon/evidence/09-sponsored-server-wallet.md
The suites, and what they are for
185 tests on the account contract, 108 on the approval queue, 23 on the registry, 13 on the team factory and 9 on the caller probe. The account suite ships every critical rule twice: one test proving the allowed path works, and one proving the disallowed path reverts with the encoded error and its arguments, so a test cannot pass by reverting for the wrong reason.packages/contracts/test

Stated plainly

What is not true yet.

This is the half that usually gets left off. It is here at the same size as everything above it, because a page about what stops a payment is not worth reading if it is quiet about what does not.

  • Nobody outside this project has reviewed it

    There has been no third-party security review and none is booked. The tests were written beside the code by the people who wrote the code, which is the arrangement least likely to find the thing the author did not think of. Read the contracts as unaudited work, because that is what they are.

  • Testnet only, and the money is not money

    Everything runs on Monad testnet, chain id 10143. The payments are real transactions against deployed contracts, and the balance behind them is test AUSD. There is no funding path, no custody, and nothing here should ever receive real funds.

  • The owner's key lives on a server

    On testnet the account-owner actions are signed by a scoped server key: approving an exception, granting a policy, pausing a spender. Somebody who signed in with Google does not hold a key of their own. Spend authorization still lives entirely in NuntioAccount and ApprovalQueue: that key is bound to an ABI with no pay in it, and the one function it has that moves money takes an approval id rather than a recipient. Moving owner keys to the user’s own wallet is a hardening step we have not taken.

  • Paying from your own browser wallet, fee paid, is not yet observed

    The invisible-chain claim has two halves. The policy half is demonstrated: payments settle, refusals refuse, and an approved exception pays. The fee half is only partly demonstrated: a Privy server wallet and the Sandbox operator have sent with no funds of their own for fees (evidence 09 and 10), but a person’s own embedded wallet paying from the browser has not been observed yet. Read any screenshot of this product accordingly.

  • One sharp edge is kept on purpose

    setLimits is a full overwrite, so a zeroed field removes that limit rather than leaving it alone. The alternative was to give zero two opposite meanings depending on which field it sits in, which is a worse trap than the edge, so the behaviour is held and tested under the name test_KNOWN_setLimitsIsAFullOverwrite. setAllowance is the narrow door for the case that made it dangerous. Thirteen other defects were carried the same way, as tests that asserted what the contract did rather than what it should do; every one has been rewritten into a test of the fixed behaviour.

If a claim on this page is not backed by a file you can open or a transaction you can look up, it should not be here. Report a security issue to the 71Labs team where you found this launch.