Skip to Content
THULI

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.

WhatValue
Factory0xbD2c20D2A068fe6D5052efcA910D1Cba44638B56
Verified sourcebase-sepolia.blockscout.com
Factory deploy0x5019bb52...4df7f7, block 57247894
Proof vault0xAdb8aD5A8BC359fF17D81DBe41833bB6b26B2034
Reward tokentest USDC 0x036CbD53842c5426634e7929541eC2318f3dCF7e
Owner0x86D40838B9805FF63343B8F060EF3fC336f59E78
Agent0xBedcF312f0070295c73bff4De974F372aa03Dee6
Termscap of 1 USDC per wallet (1000000 base units), root delay 3,600 seconds, 2 USDC deposited
Root0x1d575779216ee7c9f5a6883fda38a3d538a05fc4bab059498ace8ba5cae221c1, 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.

WhatValue
Vault0x863342D22557De5E7f093F9351797291b18329CA, source verified on Basescan
Created in0xe3b41a48...71bc6c, block 58116678
Campaignthuli-demo, "Thuli demo drop", 24 slots of 0.5 test USDC
Owner0x86D40838B9805FF63343B8F060EF3fC336f59E78, Thuli's own test wallet
Agent0xBedcF312f0070295c73bff4De974F372aa03Dee6, the worker's key
Termscap 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.

WhatValue
Factory0x1d9b8CD9A5aF33287Bb96862d9BB0f563DC402De
Factory deploy0x2f008850...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

StepTransactionBlockSent by
createVault0xda041583...5bd13c57248098owner
approve0x8568fcfa...3fcfee57248111owner
deposit0x6ccc12d3...6100f257248123owner
postRoot0x3c4db865...bea0db57248143agent
claim0x67d821af...a9ed2857266597agent

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 -- verify

It 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 VaultCreated for the recorded vault, owner, agent, token and cap
  • the vault logged RootPosted for the recorded root and claimable time
  • the vault logged Claimed, paying the paid wallet the recorded amount
  • one USDC Transfer of 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 VaultCreated event for this vault, owner and agent
  • the post receipt holds the vault's RootPosted event 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 Claimed event 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.

FlagNeededWhat it sets
--agent ADDRESSyesThe key that will post lists, which is the worker's key. It must not be the owner.
--cap USDCyesThe most one wallet can ever receive, such as 1. Above zero.
--deposit USDCyesHow much reward money goes in, such as 2.5. Above zero.
--days Nno, 120How long the vault runs, a whole number of days from 1 to 365.
--delay-hours Nno, 1The wait before a posted list pays, a whole number of hours from 1 to 720 (30 days).
--sendnoSign 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;
  • --days is 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.

FlagNeededWhat it sets
--id IDyesThe campaign's id: 1 to 128 letters, digits, dots, dashes or underscores. It goes into the text a wallet signs.
--name NAMEyesPlain text, 1 to 80 characters.
--agent ADDRESSyesThe address of the worker that will serve the campaign. It must be the agent the vault names.
--vault-tx HASHyesThe vault's creation transaction.
--reward USDCyesThe full reward for one wallet. Above zero, and not above the vault's per-wallet cap.
--slots NyesHow many wallets can be paid, from 1 to 100,000.
--partial-share-bps Nno, 5000The partial reward as a share of the full one, in basis points, from 1 to 9,999.
--opens ISO, --closes ISOnoTimes with a zone, such as 2026-11-12T09:00:00Z. It opens now by default and closes at the latest allowed moment.
--settings JSONnoA 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.
--writenoStore 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 VaultCreated log 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;
  • --agent is not the vault's agent, so that worker could not post this campaign's lists;
  • --reward is 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;
  • --closes is 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;
  • --opens is not before the close;
  • --settings is not a JSON object of known settings with valid values, or it names a partial share that differs from --partial-share-bps;
  • the same --id is 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

CommandWhat it doesWhat makes it act
npm run vault:deployDeploys 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-moneyBase 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:verifyVerifies 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, verifyThe Base Sepolia proof, described above.Only verify is for strangers.
npm run prove:vault -- create, post, claim, withdraw, record, verify with --network base --real-moneyThe 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.