Contracts and addresses
The deployed factory and proof vault on Base Sepolia, the factory on Base mainnet, the five proof transactions, how to check them, and the commands that deploy, verify, make a vault and register a campaign.
Every vault, the proof and the campaign are on Base Sepolia, Base's public test network, chain id 84532. The vaults pay test USDC, which has no value. On Base mainnet there is the factory contract and one small proof vault that was run once with real USDC, both described in the section after the proof tables.
| What | Value |
|---|---|
| Factory | 0xbD2c20D2A068fe6D5052efcA910D1Cba44638B56 |
| Verified source | base-sepolia.blockscout.com |
| Factory deploy | 0x5019bb52...4df7f7, block 57247894 |
| Proof vault | 0xAdb8aD5A8BC359fF17D81DBe41833bB6b26B2034 |
| Reward token | test USDC 0x036CbD53842c5426634e7929541eC2318f3dCF7e |
| Owner | 0x86D40838B9805FF63343B8F060EF3fC336f59E78 |
| Agent | 0xBedcF312f0070295c73bff4De974F372aa03Dee6 |
| Terms | cap of 1 USDC per wallet (1000000 base units), root delay 3,600 seconds, 2 USDC deposited |
| Root | 0x1d575779216ee7c9f5a6883fda38a3d538a05fc4bab059498ace8ba5cae221c1, claimable from unix 1794375097 (11 Nov 2026, 05:31:37 UTC) |
The deploy record writes the factory address in lowercase and the proof record in checksum case. They are the same address.
The demo campaign's vault
The campaign on this site pays from its own vault, made by the same factory on 13 November 2026.
| What | Value |
|---|---|
| Vault | 0x863342D22557De5E7f093F9351797291b18329CA, source verified on Basescan |
| Created in | 0xe3b41a48...71bc6c, block 58116678 |
| Campaign | thuli-demo, "Thuli demo drop", 24 slots of 0.5 test USDC |
| Owner | 0x86D40838B9805FF63343B8F060EF3fC336f59E78, Thuli's own test wallet |
| Agent | 0xBedcF312f0070295c73bff4De974F372aa03Dee6, the worker's key |
| Terms | cap of 0.5 USDC per wallet (500000 base units), root delay 3,600 seconds, 12 test USDC deposited, ends 28 Dec 2026, 04:46:41 UTC |
Its lists and payouts are on the campaign page as they happen.
The factory on Base mainnet
The factory contract was deployed on Base mainnet, chain id 8453, on 11 November 2026. The record is packages/contracts/deployments/base.json.
| What | Value |
|---|---|
| Factory | 0x1d9b8CD9A5aF33287Bb96862d9BB0f563DC402De |
| Factory deploy | 0x2f008850...e9bff3, block 56121245 |
Its runtime code is identical to the Base Sepolia factory's, and its source is verified on Basescan (npm run vault:verify -- --network base checks it).
Realised, with real USDC: on 12 November 2026 that factory made one vault, 0x52e9F0D7b48a7bbAf5700433810c41C96CfBC40C, with a cap of 1 USDC, a deposit of 2 USDC and a one-hour delay. Thuli's worker key posted a list of one wallet. When the hour was over the worker submitted the claim and the vault paid that wallet exactly 1 USDC, in this transaction, with the worker paying the gas. A static call of the claim for a wallet that is not on the list, with the paid wallet's proof, reverts with InvalidProof. After the vault ended, the owner took back the 1 USDC that was left, in this transaction. The vault's source is verified on Basescan too.
The run is recorded in packages/contracts/deployments/base-proof.json. npm run prove:vault -- verify --network base --real-money checks that record against Base in 17 checks: the six transactions and who sent them, the factory's and the vault's events, the USDC transfer, the vault's terms, what was paid and to whom, and that the vault is empty. It reads the factory's logs, so it needs BASE_RPC_URL to be an endpoint that serves log queries. Base's own public endpoint refuses them.
What this proof does not show: a campaign on mainnet. It is one vault, one list and one wallet. Every campaign and every other address on this page stay on Base Sepolia with test money.
The five proof transactions
| Step | Transaction | Block | Sent by |
|---|---|---|---|
createVault | 0xda041583...5bd13c | 57248098 | owner |
approve | 0x8568fcfa...3fcfee | 57248111 | owner |
deposit | 0x6ccc12d3...6100f2 | 57248123 | owner |
postRoot | 0x3c4db865...bea0db | 57248143 | agent |
claim | 0x67d821af...a9ed28 | 57266597 | agent |
The root waited its one hour before the claim, which is why the claim block is much later than the others. Gas for each step is on the vault page.
Who was paid and who was refused
Wallet 0xFE2B7D1707A725E2D8D6040506D26a28A98a5CdD was on the list at 1 USDC and was paid exactly 1 USDC. The agent key sent the claim, not the wallet: the vault pays the wallet named in the list whoever submits. After the claim the vault reported 2 USDC deposited and 1 USDC paid.
Wallet 0xb2864c5C992f22E6d8A6A47945c0E897C440DAf7 was left off the list. A call to claim for it, using the first wallet's proof, reverts with InvalidProof. That refusal is a simulated call, not a transaction, so it has no hash on the explorer. The record keeps only the error name. Both wallets were made for this proof.
Check the record yourself
Anyone with the repository can check the committed record against the chain. It needs Node 26 or newer, npm install, and the two committed files in packages/contracts/deployments. It loads no key and sends nothing:
npm run prove:vault -- verifyIt reads Base Sepolia through the address in BASE_SEPOLIA_RPC_URL when that is set to an https address, and through the public endpoint https://sepolia.base.org when it is not. The public endpoint is rate limited, so if it refuses, set an https address of your own. The command prints one line per check, 14 in all, and exits with an error if any check fails:
- the record names the factory in
base-sepolia.json - each of the five transactions succeeded in the recorded block, was sent by the recorded owner (create, approve, deposit) or agent (post, claim), and used the recorded gas
- the factory logged
VaultCreatedfor the recorded vault, owner, agent, token and cap - the vault logged
RootPostedfor the recorded root and claimable time - the vault logged
Claimed, paying the paid wallet the recorded amount - one USDC
Transferof that amount went from the vault to the paid wallet, and the record's token is Base Sepolia USDC - the vault's
state()today shows the recorded token, owner, agent, cap and delay - total paid and what the paid wallet has claimed are each at least the recorded amount, because later claims can raise them
- the refused wallet has claimed nothing
verify does not check that the refused wallet's claim reverts. That check needs the private claim list behind the root, so only record repeats it. You can watch a refusal like it on /break, where a wallet that is not on the list asks the vault for the 1 USDC.
Rebuild the record from the chain
npm run prove:vault -- record reads the saved proof run back from Base Sepolia. It loads no key and sends nothing. It writes the public record only when every check agrees:
- all five transactions succeeded, each sent by the owner (create, approve, deposit) or the agent (post, claim), to the factory, USDC or the vault as expected
- the create receipt holds the factory's
VaultCreatedevent for this vault, owner and agent - the post receipt holds the vault's
RootPostedevent for the saved root and time - the saved list holds only the paid wallet and rebuilds to the saved root
- the vault's token, owner, agent, cap and delay match, and the saved root is active
- the claim receipt holds one
Claimedevent and one USDC transfer of 1 USDC from the vault to the paid wallet - the vault's total paid and the paid wallet's claimed amount both equal that 1 USDC
- a fresh simulated claim for the refused wallet, with the paid wallet's proof, reverts with
InvalidProof
record needs the saved run file from the setup step. That file is private and not in the repository, so only the machine that ran the proof can repeat record, and it is not for strangers. Anyone else can run verify above, or check the same facts from the tables on the explorer.
Make a vault and register a campaign
A project that runs a campaign uses two commands, one after the other. Both do nothing until told to: run on their own, they read the chain, print what they would do, and stop. Both work on Base Sepolia only, and each refuses a connection that reports any other chain. What a campaign does once it is registered is on Live campaign.
Make and fund the vault
npm run vault:create -- --agent ADDRESS --cap USDC --deposit USDC [--days N] [--delay-hours N] [--send]It makes one vault through the factory above and puts the reward money in. Without --send it simulates the vault's creation, prints the plan and signs nothing. With --send the owner's test key sends exactly three transactions: createVault, an approve of exactly the deposit, and the deposit. Each is simulated first and each receipt is awaited.
| Flag | Needed | What it sets |
|---|---|---|
--agent ADDRESS | yes | The key that will post lists, which is the worker's key. It must not be the owner. |
--cap USDC | yes | The most one wallet can ever receive, such as 1. Above zero. |
--deposit USDC | yes | How much reward money goes in, such as 2.5. Above zero. |
--days N | no, 120 | How long the vault runs, a whole number of days from 1 to 365. |
--delay-hours N | no, 1 | The wait before a posted list pays, a whole number of hours from 1 to 720 (30 days). |
--send | no | Sign and send. Left out, nothing is sent. |
The owner's key goes in PAYER_PRIVATE_KEY, in .env or .env.payer at the repository root. Even the dry run needs it, because it simulates the call from the owner's address, but it signs nothing. The command reads BASE_SEPOLIA_RPC_URL, and uses the public https://sepolia.base.org when that is not set.
It refuses, and sends nothing, when:
- a flag is missing or outside the limits in the table;
- the connection reports a chain other than Base Sepolia (84532);
- there is no contract at the factory address in the deployment record;
- the reward token is not Base Sepolia USDC with six decimals;
- the agent is the owner, because the owner's cancel is the check on the agent;
- the owner holds less USDC than
--deposit; --daysis too short for the vault to run for at least the delay plus the one-hour minimum claim window;- with
--send, the owner has no Base Sepolia ETH for gas.
When it finishes it prints the vault's address and creation transaction, and the exact campaign:create command to run next. If the vault is created but the funding step fails, it says which vault was made and that it is not funded, so a second run does not make another.
Register the campaign
npm run campaign:create -- --id ID --name NAME --agent ADDRESS --vault-tx HASH --reward USDC --slots N [--partial-share-bps N] [--opens ISO] [--closes ISO] [--settings JSON] [--write]It registers a campaign for a vault that vault:create made, and no vault term is typed in by hand. It reads the creation transaction named by --vault-tx and the vault's own state() from Base Sepolia, checks that the two agree and that --agent is the vault's agent, and prints the campaign. It signs nothing and reads no private key. Without --write it stores nothing. With --write it stores the campaign in the database named by DATABASE_URL, a postgres:// or postgresql:// address. If the tables are missing it says to run npm run db:migrate first.
| Flag | Needed | What it sets |
|---|---|---|
--id ID | yes | The campaign's id: 1 to 128 letters, digits, dots, dashes or underscores. It goes into the text a wallet signs. |
--name NAME | yes | Plain text, 1 to 80 characters. |
--agent ADDRESS | yes | The address of the worker that will serve the campaign. It must be the agent the vault names. |
--vault-tx HASH | yes | The vault's creation transaction. |
--reward USDC | yes | The full reward for one wallet. Above zero, and not above the vault's per-wallet cap. |
--slots N | yes | How many wallets can be paid, from 1 to 100,000. |
--partial-share-bps N | no, 5000 | The partial reward as a share of the full one, in basis points, from 1 to 9,999. |
--opens ISO, --closes ISO | no | Times with a zone, such as 2026-11-12T09:00:00Z. It opens now by default and closes at the latest allowed moment. |
--settings JSON | no | A JSON object that changes the screening numbers for this campaign, such as minWalletAgeDays (30 by default), minTxCount (5), groupMinSize (5) and funderFanoutMin (10). allowPartial set to false turns partial rewards into holds. |
--write | no | Store the campaign. Left out, nothing is written. |
It refuses, and writes nothing, when:
- a flag is missing or outside the limits in the table;
- the transaction did not succeed, was not sent to the factory in the deployment record, or does not hold exactly one
VaultCreatedlog from that factory; - the log holds terms the contract could never have allowed;
- the vault's
state()disagrees with its creation log on the token, owner, agent, cap or delay; - the vault's token is not Base Sepolia USDC;
--agentis not the vault's agent, so that worker could not post this campaign's lists;--rewardis above the vault's per-wallet cap;- the reward times the slots is more than the vault still holds, because the vault refuses a list it cannot cover. Deposit more or register fewer slots;
- the vault has already ended;
--closesis later than the vault's end minus its delay minus the one-hour minimum claim window, which is the latest moment a list posted then could still pay;--opensis not before the close;--settingsis not a JSON object of known settings with valid values, or it names a partial share that differs from--partial-share-bps;- the same
--idis already stored with different terms. Running the command again with exactly the same terms changes nothing.
The worker then serves the campaign. npm run worker handles every open campaign in the database with the key the vault names as its agent, so a new campaign needs no change there. The worker and the commands that settle appeals and requeue failed asks are described on Live campaign.
Deploy, verify and prove commands
| Command | What it does | What makes it act |
|---|---|---|
npm run vault:deploy | Deploys the factory to Base Sepolia from the worker key, verifies it, and writes the record base-sepolia.json. -- --redeploy deploys a second factory even though one is recorded. | It runs live as typed. It needs WORKER_PRIVATE_KEY, BASE_SEPOLIA_RPC_URL and EXPLORER_API_KEY in .env. |
npm run vault:deploy -- --network base --real-money | Base mainnet. Simulates the factory's creation from the owner key and signs nothing. | --send deploys, tries to verify on Basescan and writes base.json. It needs PAYER_PRIVATE_KEY and BASE_RPC_URL. |
npm run vault:verify | Verifies the factory and the proof vault on Basescan from the two committed Base Sepolia records. It sends no transaction. | It needs EXPLORER_API_KEY. -- --vault-tx HASH also verifies the vault that creation transaction made, and can be repeated. -- --network base verifies the Base mainnet factory, and its proof vault once a mainnet proof record exists, and also needs BASE_RPC_URL. |
npm run prove:vault -- setup, claim, record, verify | The Base Sepolia proof, described above. | Only verify is for strangers. |
npm run prove:vault -- create, post, claim, withdraw, record, verify with --network base --real-money | The one small Base mainnet proof: the owner creates a vault, the agent posts a list and claims after the delay, and the owner withdraws after the end. | --send signs, in the first four phases. record and verify only read. Each phase reads the chain first and does only what is left. |
On Base mainnet the proof is fixed in code: a cap of 1 USDC, 2 USDC deposited, the contract's minimum delay of one hour, and an end three hours after creation. It pays the address in PAYTO_ADDRESS and reads Base mainnet through BASE_RPC_URL. Each phase loads only the key it signs with: the owner key PAYER_PRIVATE_KEY for create and withdraw, and the agent key WORKER_PRIVATE_KEY for post and claim.
Both mainnet paths also take --fork. With --fork --rpc-url http://127.0.0.1:PORT they run against a local copy of Base mainnet made with anvil, and never reach a public RPC. vault:deploy then needs --out FILE outside packages/contracts/deployments. prove:vault needs --state FILE and --deployment FILE, and --record FILE for the record phase, all outside the public records, so a rehearsal never writes them. The mainnet proof was run for real on 12 November 2026, after a rehearsal against a fork. Its record is base-proof.json.
Live campaign
How an open reward campaign works, what happens to your wallet when you ask, why the money arrives later, how an appeal works, and how to check each step yourself.
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.