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.
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 repository
packages/agent/src/tool.tspackages/agent/src/validate.tsThe 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 repository
packages/contracts/src/ServiceRegistry.solpackages/sdk/src/registry.tsThe policy
Eight conditions, checked on chain, by the contract that holds the money.
NuntioAccount.paytakes 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 repository
packages/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.
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.
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.
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.
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.
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.
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.0x19861745c5292a6911CbF6535e396d4DD18fef0DOpen 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 forUnknownService(bytes32), followed by the id echoed back. Captured withcastagainst 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.
CallerProberecords 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.0xfddD1C5B54D5D70485FF640d8C7a75e5f13F7123Open 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
NuntioAccountandApprovalQueue: that key is bound to an ABI with nopayin 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
setLimitsis 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 nametest_KNOWN_setLimitsIsAFullOverwrite.setAllowanceis 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.