Skip to Content
THULI

Security

How Thuli's security work was done, what the tests count, what stops the five main attacks, and why it is not externally audited.

The threat model came before the code. On 27 October 2026 an independent reader, working only from a plain description of the system, wrote down what kind of system this is, what an attacker would go after, and the first 44 numbered rules. Three more were added on 6 November, after the replays and the outside feedback showed what was missing: C45 on funding bursts, C46 on public addresses and C47 on a shared funder alone. Fourteen more were added from 11 to 13 November 2026, C48 to C59 from the build of the live campaign and appeals, C60 with the two-chain history read and C61 with the board's paid mark: a deadline on every paid request, ceilings on every outbound call a stranger can cause, a door for wallets that cost nothing to make, how the worker records its own chain writes, and how an appeal is bound to its decision, kept off the models and settled. Each rule is an invariant, a statement that must always hold, and each names the test or attack that shows it. A change that touches a rule must carry its test. Three internal read-throughs then checked the backend against the list, and every finding was fixed or closed with a written reason. The full 61 rules are on the threat model page.

What the contract tests hold

On 11 November 2026 forge test reported 77 tests passed, none failed and 2 skipped with no Base RPC set, and 79 passed with BASE_RPC_URL set. The 79 are, counted from the files in packages/contracts/test and the settings in packages/contracts/foundry.toml:

  • 64 unit tests of each vault function and each way it can fail.
  • 1 demo test: a list of 30 farm wallets cannot take more than 20 USDC from the demo vault, and no wallet gets more than 1 USDC.
  • 1 fixture test that pins one list and its proofs, which the TypeScript tree builder is checked against.
  • 11 fuzz tests. The Foundry setting runs each on 10,000 random cases.
  • 1 fork test: a full campaign on real USDC.
  • 1 invariant suite holding 7 invariants. The setting runs 256 random call sequences, up to 64 calls deep, and fails the run on any revert the test did not expect.

The demo test and the fork test run on a local copy of Base mainnet at block 55,661,700 with real USDC. They skip when no Base RPC address is set, so the 79 counts them only when BASE_RPC_URL is set. npm run contracts:test repeats the run. A mutation pass was also run on the vault, but the repository holds no output from it, so this page gives no count for it. The TypeScript packages have their own tests in packages/*/test.

Attack it yourself

/break runs ten of these attacks live from your browser. Five go against the proof vault on Base Sepolia as simulated calls that send nothing. Four go against this site's own routes: a mistyped address, a paid answer without paying, a forged payment and an oversized request. One tests that a reason holding markup shows as plain text. The page also lists what it cannot show: a replayed payment, which needs a funded wallet, a forged appeal, and talking the model into paying a farm.

No external audit

No outside firm has audited Thuli. It is self-audited: the threat model, the three read-throughs of the backend and the tests above. The vault holds only test USDC today. What the tests do not cover is on limits.

The five attacker goals

Getting farm wallets paid

The attacker injects instructions through token names, calldata or appeal text, or funds each wallet from a separate exchange withdrawal. Code sets a ceiling for every wallet before the model runs, and the final decision is never more generous (C12). The model's input holds only numbers, booleans, fixed words, addresses and hashes, so a stranger's text has no way in (C14). An appeal's text goes to no model and to no public page (C58, C59). A reply that cites a hash Thuli never fetched ends in a hold (C13). A farm that funds each wallet separately and never sweeps still passes.

Taking money directly

In the vault, the agent key can only post lists, each list waits a delay the owner can use to cancel, a wallet is capped, and total paid cannot pass the deposit (C27, C29, C30). A stolen key can still direct what the deposit has not paid out, up to the cap per wallet, if nobody cancels in time; no watcher yet alerts the owner. On the paid route, the exact price, network, token and payee are checked and a replayed payment fails (C25). A replayed payment for another wallet fails, and the same payment for the same wallet only fetches its stored result, so it settles and screens nothing twice; one request answers within 240 seconds and a payment buys at most two screenings (C48). One real payment and ten replays of it were tried with test USDC on Base Sepolia, and the replays bought nothing: see the paid API. The free check has per-caller and daily ceilings (C24), and so does every campaign route that can cause a chain read or a model call (C49).

Taking over the worker or stealing keys

The API refuses to start if any setting named like a private key is present, and inside the site every API answer becomes a 503 (C39). Chain reads follow no off-chain lookup and no redirect (C38). Error messages name a setting, never its value (C40). The rule against shell injection through the paid-data tool (C37) has no code yet, because nothing calls that tool, and no worker host is deployed. The worker's key can only post lists and submit claims (C30, C39), and the worker writes each post to the store, with its hash and nonce, before it sends it (C51).

Reading or changing another project's campaign

The rules say owners act only on their own campaigns, unpublished data stays unreachable, and a sign-in works once (C4, C5, C6). Owner routes and sign-in are not built yet, and the one kind of campaign that runs today is public from its first ask, so this goal has no private data to defend today. Two checks are built, and a campaign route uses each. The ask check ties a signature to the campaign, wallet and time. The appeal check ties a signature to the exact text, campaign, wallet and time (C8). Both refuse a contract wallet and a wallet whose type could not be read (C9). An appeal is bound to the decision it disputes, the outside check's answer is advice, and only the owner's settlement changes an outcome, and only toward an approval. Owner sign-in is not built, so an operator's command settles appeals (C17, C57).

Turning Thuli's pages against users, or framing a wallet

A reason holding a link or markup is refused by the decision checks, and the replay shows reasons as inert text (C33). A forged Transfer event cannot get a wallet excluded, because only movements the wallet itself authorised count as sweeps, and evidence it did not author can lead to a hold, never an exclude (C15).

The full list, with the known gaps, is on the threat model page. What Thuli does not catch, defend against, build or measure is on limits.