Skip to Content
THULI

The reward vault

How one campaign's reward money is held on Base and who can do what.

Each campaign gets its own vault contract from a factory. The project running it is the owner: it funds the vault, can cancel a waiting list, can pause, and takes back what is left after the end. Thuli's worker key is the agent, and the only thing the vault lets the agent do is post a list. The worker builds each list from the wallets that hold a slot, saves its text, and posts the root from that saved text, so the file anyone can download rebuilds to the root on chain. After the vault's delay, the worker submits each listed wallet's claim from the same key and pays the gas. That is allowed because anyone can submit a claim, and the money goes only to the wallet named in the list. If the owner cancels a waiting list, the worker marks it cancelled and posts nothing new until a different set of wallets holds slots. How this runs for a live campaign is on Live campaign.

How the money is protected

  • The agent key can only post a list. It cannot withdraw, cancel, pause or change anything else.
  • Every list waits a delay the owner set at creation, from 1 hour to 30 days, and the owner can cancel it meanwhile.
  • A wallet is paid its approved total minus what it already received, never above the per-wallet cap fixed at creation, and total paid never passes the deposit.
  • The owner withdraws only after the end, and the end never moves earlier.
  • A pause moves the end, and a waiting list's start, later by exactly the time paused. No withdrawal runs while paused.

Stated loss: if nobody cancels a bad list in time, a stolen agent key can direct whatever the deposit has not yet paid out, at most the cap per wallet. Known gap: no watcher yet rebuilds each posted list and alerts the owner inside the delay.

The leaf and the proof

The approved list becomes one Merkle root, a single fingerprint of the whole list. Each wallet's entry, its leaf, says that on this chain, in this vault, this wallet is owed this total. To claim, anyone submits the entry and a proof, the short run of hashes tying it to the root. If the vault rebuilds the root, it pays.

leaf = keccak256(bytes.concat(keccak256(abi.encode(uint256 chainId, address vault, address account, uint256 cumulativeAmount))))

Hashing twice stops an inner tree node passing as a leaf. The chain id and vault address sit inside the leaf, so a proof for one vault or chain is worthless on another. Amounts are totals across every list so far. A later list that lowers or drops a wallet pays it nothing more and takes nothing back.

Every external function

FunctionWho may callWhat it does
createVault (factory)anyoneMakes a vault; the factory has no owner and no settings
depositowner, before the endPulls reward tokens in; refuses a token that skims a fee
postRootagent, not while pausedPosts a list root that pays after the delay; refuses a total above the deposit
cancelRootownerCancels the waiting root, paused or not
setAgentownerReplaces the agent key and drops any root still waiting
extendEndowner, before the endMoves the end later, at most 365 days out
pauseownerStops claims, withdrawal, new roots and the root clock
unpauseownerResumes; moves the end and any waiting root later by the time paused
claimanyonePays the wallet in the leaf what it is owed, and only that wallet
withdrawAfterEndowner, from the endSends everything left to an address the owner names
rescueownerReturns a stray token, never the reward token
transferOwnership, acceptOwnershipowner, then new ownerTwo-step ownership change; the agent can never become owner
renounceOwnershipnobodyAlways reverts; a vault with no owner could never cancel a bad root
state, claimable, gettersanyoneRead-only: full state, what a claim pays, stored values

Gas, from the proof run

Gas units for the proof vault's five transactions. The record has no gas price, so no dollar cost.

TransactionSent byGas usedIn plain words
createVaultowner1,686,371builds the vault
approveowner55,437lets the vault pull 2 test USDC
depositowner100,114moves the 2 test USDC in
postRootagent75,112posts a list of one wallet
claimagent145,907pays 1 test USDC to that wallet

What the vault does not do

The vault protects claimants from a hacked Thuli server and from early withdrawal. It does not protect them from the project that funds it. The owner may name a new agent, and a list that agent posts replaces the old one after the delay. Every agent change and every list is public on chain, so claimants get that delay as notice.

There is one vault per campaign and no upgrade path. A fresh vault keeps each deposit in its own account, so claims draw only on their own campaign's money. A code change means a new factory for new campaigns, not an admin swapping code under a live vault. Addresses are on contracts; what is not built is on limits.

Attack it yourself

/break runs five attacks against the proof vault on Base Sepolia from your browser. A wallet that is not on the list asks for the 1 USDC. The wallet that was already paid asks again. The listed wallet asks for double its amount. Thuli's own agent key tries to withdraw the vault's money. A stranger posts a new list that pays itself. Each one is a simulated call that sends nothing and needs no wallet, and the page shows the vault's answer beside it. The page also rebuilds the list root in your browser and checks it against the root in the proof record, and switches the vault rows off if the two differ. After the proof vault's campaign has ended, the vault refuses every claim with CampaignEnded, and the page says so beside the answer.

To check the proof record itself against the chain, run npm run prove:vault -- verify. The steps are on contracts.