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.
What it is
A live campaign is one reward campaign that anyone can enter. Any wallet may ask for a reward. The campaign has a fixed number of slots, from 1 to 100,000, and every slot is worth the same reward. Thuli screens each wallet that asks, puts the decision and its reason on a public board, and gives each approved wallet the next free slot. A vault contract holds the money and pays only the wallets on the list that Thuli's worker posts to it.
Today a campaign runs on Base Sepolia, Base's public test network, and pays test USDC that has no value. The campaign page shows the slots taken, the wallets that asked, every decision as it is made, and the lists posted to the vault.
The campaign on this site is "Thuli demo drop", id thuli-demo: 24 slots of 0.5 test USDC each, open until 28 December 2026. Its vault is 0x863342D22557De5E7f093F9351797291b18329CA, made by the factory in this transaction with 12 test USDC, a cap of 0.5 USDC a wallet and a one-hour delay, and its source is verified on Basescan. Its owner is Thuli's own test wallet. The site, a worker process on its own host and one hosted Postgres database run it.
The project that runs a campaign sets the reward per wallet, the number of slots, and when asks open and close. A wallet approved for a partial reward gets half of the full reward unless the project sets another share. The default is 5,000 of 10,000 basis points, where a basis point is one hundredth of a percent. A campaign stops taking asks at least one vault delay plus one hour before the vault ends, so a list posted at the last moment can still be paid.
What happens when you ask
Eight steps run from your signature to your payment. The same path is drawn as a sequence on Architecture.
1. You sign one message
Connect a wallet you control and press Sign and ask. Your wallet shows this text for you to sign. The words in angle brackets stand for the real values:
Thuli ask v1
Campaign: <campaign id>
Wallet: <your wallet address>
Signed at: <unix seconds>The campaign id is the name the campaign is known by. The address is yours, written with its usual mix of capital letters. The last line is the moment you signed, counted in seconds since 1 January 1970. Each of the four lines ends with a line break.
Signing costs nothing and moves nothing. It is a plain text message, not a transaction, so there is no gas to pay and no money leaves your wallet. The first line marks the text as an ask, so Thuli can never read your signature as a sign-in or an appeal. The campaign id and your address sit inside the text, so a signature made for one campaign does not work in another.
A signature stays valid for 10 minutes (600 seconds). One dated more than 60 seconds ahead of Thuli's clock is refused. A wallet that holds contract code on Ethereum or on the campaign's own chain, such as a multisig, cannot ask yet. An ordinary wallet that has delegated to a contract's code (EIP-7702) still counts as a wallet and can ask.
Each wallet asks once. If you ask again, you get your first ask back unchanged.
2. Your ask is checked, then queued
The route checks an ask in this order and stops at the first failure. The free checks come first, and the signature is checked before any chain read, so a request nobody signed never makes the server read a chain.
- The campaign is looked up. An unknown id answers 404 with
{"error": "no such campaign"}, whatever else was sent. - The body must be at most 1 KB (413).
- The caller's address is read (400 if it cannot be) and its requests are counted: 20 a minute, shared with the appeals route (429 with
{"busy": true}). - The body must be exactly
{"wallet": "0x...", "signedAt": 0, "signature": "0x..."}in shape (400). - The signed message is checked: a valid address, a whole-second time, not older than 600 seconds, not more than 60 seconds ahead, and a signature that matches (400 with the cause).
- The wallet's type is read on Ethereum, and on the campaign's own chain when that is another chain. A contract is refused (400). These reads are counted first against 600 a minute across every caller and server copy. Past that the answer is 503 with
{"busy": true}and nothing is read. - The caller's hour is counted: 5 signed asks (429 with
{"busy": true}). - A wallet that already asked gets its first ask back, 200 with
{"exists": true, "ask": ...}, and no more reads are made. - The door. Door reads are counted against 300 a minute, two for each chain read, across every caller (503 with
{"busy": true}past it). Then the route reads the wallet's sent count and its balance on Ethereum at the latest finalized block, the same block the worker screens at. A wallet with either above zero goes on, and Base is not read. A wallet empty on Ethereum is read the same way on Base mainnet, and those two reads are counted against the same ceiling first. Empty on both chains gives the never-used answer below. A read that fails, times out or is not a whole number, on a chain that had to be read, gives 503 with{"busy": true}. - The ask is stored. 202 with
{"queued": true}, or 409 with{"closed": true}when the campaign is closed or outside its opening times, or 409 with{"full": true}.
Nothing is screened while you wait. A stored ask joins a queue, and the worker takes the oldest waiting ask first.
The never-used answer
A wallet that has sent no transaction and holds no balance on Ethereum, and none on Base mainnet either, is answered 200 with {"neverUsed": true}. A wallet funded in the last few minutes can get this answer too, because both chains are read at their finalized block, about 13 minutes back on Ethereum and about 17 on Base. It can ask again once that block has passed. Nothing is stored or queued: no slot, no place in the queue, no use of the campaign's ask cap. The attempt still counts toward the visitor's 5 signed asks an hour. The campaign page says the wallet has never been used and asks the visitor to use a wallet that has some history. The reason for the door is cost. A wallet that costs nothing to make is not worth a screening, so a free supply of new keys cannot fill the queue or run up the model bill.
The worker keeps the same rule as a second check. A wallet that reaches it with no sent transaction, a plain account, no first funder in a history that was read, and a known age is held by code under rule A1 with the reason "A1: This wallet has never been used on Ethereum or Base, so there is nothing to screen." No model is called for it. A worker whose Base history is off (see step 3) decides no such wallet: its tries fail and the ask ends held for review.
When the answer is full
A campaign answers {"full": true}, status 409, and stores nothing, when any of three things holds:
- every slot is taken;
- 200 asks are waiting undecided, queued or being screened. Room comes back as asks are decided;
- the campaign has taken its lifetime cap of asks, the larger of 1,000 and 100 times its number of slots.
The campaign's counts carry asksLeft, how many more asks it takes before the lifetime cap, never below 0. When it reaches 0 the campaign page stops offering the button. There is no daily ceiling on accepted asks.
Named limit: a campaign can be filled by someone who funds enough wallets. A wallet that has sent a transaction or holds a balance on Ethereum or on Base is stored, so locking a campaign this way costs the lifetime cap times the cost of one funded wallet, at least 1,000 wallets, each with its own key and its own balance or sent transaction. The same funded wallets can fill the cap of every campaign.
3. The worker screens your wallet
The worker reads three things from public chain data:
- Your wallet's own history on one chain, Ethereum mainnet or Base mainnet, up to that chain's latest finalized block, a block the network will not undo. That gives its age in whole days since its first activity, how many transactions it has sent, and whether it is an ordinary wallet.
- Your first funder, the address that first sent you value, and what kind of sender it is, read from its code and its sent count: a contract, a busy exchange or service wallet (one that has sent 10,000 transactions or more), or an ordinary wallet.
- The other wallets that asked in this campaign, to see which of them share a funder with you.
The history is read on a main network even though the reward is test money on Base Sepolia.
Which chain is read
The worker reads the chain where your wallet was active first. It finds the wallet's first activity on Ethereum and on Base, by the same rule that sets the wallet's age (the first transfer that moved value in, or the first transaction the wallet sent), and screens the chain where that is older. Used on one chain only: that chain. An exact tie: Ethereum. The decision records the chain, the board shows it as "History read on", and the first funder and the cited transactions open on that chain's explorer. The funder's kind (ordinary wallet, exchange or contract) is read on the same chain. Two asking wallets are still linked by a shared funder address whichever chain each was read on.
If either chain cannot be read, the ask is not decided from the other one alone. That try fails with "the wallet's history could not be read on both chains" and is taken again later, like any failed try.
No explorer is used for this. The worker asks an RPC endpoint for the wallet's sent count and balance at past blocks, halving the range until it finds the first block where either is above zero, then reads that block's transfers from its traces. For the first funder it gathers every block that paid the wallet from then until the wallet's first own transaction that succeeded, and the same rule as everywhere else picks the largest. This takes about 220 RPC calls for one ask, and each of its three reads stops at 1,000. The transfers read for a block that paid the wallet, and in which the wallet itself sent nothing, must add up to the rise in its balance there, or the history is not used. It needs both endpoints to serve old state and traces. The worker checks that when it starts, along with each endpoint's chain id. If an endpoint answers and does not serve them, the worker says so in one log line. It then asks Base, with two plain calls, whether the wallet was used there, and decides only a wallet that was not, from its Ethereum history through the explorer; an ask from a wallet used on Base ends held for review. If an endpoint gives no answer to a plain read, a rate limit or a server fault included, the worker asks again four times over 52 seconds and then stops, so an outage at start never chooses the mode. An endpoint that answers plain reads and leaves its trace calls unanswered on two of its probes is read as serving no traces. The free public Ethereum endpoint in .env.example does that, and the public Base one refuses trace calls outright, so a fresh clone screens on Ethereum only.
You can see the chain and the decision for any wallet, with nothing stored, with npm run campaign:screen -- --wallets <address>.
Named limits of the two-chain read:
- Only native ETH counts. A wallet that has only ever held tokens such as USDC, and never sent a transaction, reads as never used.
- A wallet used mostly on a third chain looks new.
- Some wallets cannot be dated and are held for a person: one whose first money arrived with no transaction (a staking payout or a block reward), one that delegated its code (EIP-7702) before its first own transaction, one paid in more than 24 separate blocks before it first sent anything, and one with more than 4 blocks of failed transactions before its first that succeeded.
- The age and transaction thresholds, and the false-hold rate on the control sample, were all measured on Ethereum history. Nothing has been measured on Base history.
- A group whose wallets share a funder on one chain is not linked if each wallet happens to be older on the other chain, where its funder differs.
- If the RPC endpoint is down or refuses, every try fails until it is back, and an ask that fails ten times is held under A1 until an operator puts it back in the queue.
The rulebook's fixed checks, A1 to A9, then run in code and set the most generous result your wallet could get, a ceiling. SERV, the reasoning service Thuli uses, writes the reason. The model may move the code's starting result only with rule B1 or B2, and never above the ceiling. One screening stops after 120 seconds or 12 calls to SERV, whichever comes first. The full rules are on the rulebook.
4. The decision goes on the public board
The decision, the rule behind it and the reason are posted on the board for everyone to read. A board row also holds the transactions the reason cites, the facts the decision rested on (wallet age, transactions sent, first funder and its kind, how many asking wallets that funder paid, and the size of the linked group), the list of things the screening did not measure, and the appeal, if there is one. It never holds your signature or your network address.
Once the vault has paid a wallet, its row carries a PAID mark. Opened, the row says how much the vault has paid that wallet in all and links the claim transaction on the explorer. The mark comes from the worker's own record of a claim it submitted and saw confirmed. A wallet that submitted its own claim, with its own ticket, is paid just the same but has no such record, so its row shows no mark.
5. An approved wallet takes the next free slot
A wallet approved in full or for a partial reward takes the next free slot. Slots go in the order wallets are decided, not the order they asked. A held or excluded wallet never takes a slot. Once every slot is taken the campaign takes no new ask. An ask that was already waiting is still decided, but it takes no slot.
6. The list is posted to the vault
The worker builds a list of every wallet that holds a slot. A full wallet's entry is the campaign's reward. A partial wallet's entry is the partial share of it, rounded down. The list is saved as a text file first, and the worker posts the list's root from that saved text. A root is one short fingerprint of the whole list, explained on the vault page. Because the root comes from the saved file, anyone who downloads the file can rebuild it.
Before it posts, the worker checks that the vault was made by Thuli's factory with the terms it has on record, that its own key is the vault's agent, that the vault is not paused, and that the vault holds enough to pay every list that could still be owed. It posts nothing while another list is still waiting on the vault. A wallet approved during that wait goes on the next list, which the worker posts after the first list's delay ends, so the wait can be up to two delays.
Every amount in a list is a running total across all lists, so a wallet that appears on two lists is paid once. The vault pays only the difference from what it has already paid that wallet.
A list is sent once. The worker signs the post, writes down the transaction's hash and its place in the key's sequence of transactions (the nonce), and only then sends it. The list's status is sending from that moment, and nothing else is posted while one list is sending. A sending list is settled from what the chain shows, never from a guess:
- A receipt for the post that shows it succeeded: the list is
posted, with the claimable time the receipt itself holds. A receipt that shows it failed: the list is built again. - No receipt, but the key's mined nonce has passed the list's nonce, the vault does not show the list's root, and that stayed true on every read for 120 seconds: the post is taken never to have landed, and the list is built again. If the vault does show the root, the list is recorded as posted.
- No receipt, the nonce unused, and no node has shown the transaction for 600 seconds: the post is signed again at the same nonce, with fees taken fresh. One nonce carries only one mined transaction, so a resend can replace a lost post and never add a second. It happens at most 5 times per process, and after that nothing more is sent.
An owner's cancel is recorded only after a margin of 120 seconds, so a node a few blocks behind is never mistaken for a cancel. The same list is not posted again until the slot holders change. A step that runs out of its time does nothing more: it writes and sends nothing after the limit, and the vault is read again right before every signature.
Named limits: one worker process is the design. A resend, a restart, the earlier transaction landing, and the owner's cancel of that list before the worker's next run, all together, can post a cancelled list again. A resend at the same fee may be refused by a node that still holds the lost transaction.
7. The vault waits out its delay
The vault does not pay a new list at once. It waits a delay that is fixed when the vault is made. The contract allows no less than 1 hour and no more than 30 days. A vault made with the project's vault:create command waits 1 hour unless told otherwise.
The wait exists for the owner, the project that funds the vault. During it the owner can cancel the waiting list, or replace the worker's key, which also drops the waiting list. If Thuli's key were stolen and used to post a list that pays the wrong wallets, the delay is the owner's time to notice and cancel. Thuli cannot shorten it.
If the owner cancels a list, the worker records it as cancelled and does not post it again. A new list goes up once a different set of wallets holds slots.
8. The worker submits your claim
When the delay is over, the vault pays that list. The worker marks the list active, then submits one claim for each listed wallet the vault still owes, from its own key. What is owed is the listed amount less what the vault says the wallet has already claimed. The gas comes from the worker's key, not from your wallet. It runs each claim as a dry run first, and a wallet that has already been paid gets no claim. The vault pays only the wallet named in the list, whoever sends the claim. Once a claim is confirmed, the board marks that wallet PAID and links the transaction.
The worker sends one claim, pauses for about 5 seconds, then sends the next. A long list is therefore paid wallet by wallet over several minutes after the delay ends. A paused vault answers nothing to a claim, so while the owner has the vault paused the worker waits and looks again every 10 minutes, and those waits use up none of a claim's tries.
The worker's retries and budgets
An ask gets 10 tries. The worker waits 10, 30, 90 and 270 seconds between the first tries, then 600 seconds each time, about 57 minutes of waiting in all. When the output checks refuse the model's answer, the first two refusals are asked again, and the third is stored as the hold. Model screenings share one budget of 2,000 a UTC day across every campaign. A decision made by code takes none of it. When the day's budget is used up, asks wait until just after the next midnight UTC and the wait costs them no try. A try is also handed back when the cause was not the ask's: a used-up day for an ask, a paused vault for a claim, and no credit with the outside check for an appeal. Each worker step has a time limit, and a step cut at its limit writes and sends nothing afterwards. The budget protects the bill, not the queue. It is shared by every asker, so enough funded wallets can use a day's budget up, and later asks then wait until after midnight UTC.
The timings in one place
| What | Value |
|---|---|
| A signature is valid | 10 minutes (600 seconds), and not more than 60 seconds ahead of Thuli's clock |
| Asks and appeals from one visitor | 20 requests a minute together; 5 signed asks an hour; 2 signed appeals an hour |
| Reads from one visitor | 120 a minute across the four read routes |
| One screening | Stops after 120 seconds or 12 calls to SERV |
| Tries for one screening | 10, with waits of 10, 30, 90 and 270 seconds, then 600 seconds, about 57 minutes in all |
| Model screenings a UTC day, all campaigns | 2,000 |
| Undecided asks a campaign holds at once | 200 |
| The vault's wait | From 1 hour to 30 days, fixed when the vault is made |
| Tries for one claim | 10, with the same waits |
| Pause between worker runs | About 5 seconds |
| Unclear submits one appeal's check may have | 2 |
| A paid check is followed for | up to 1 day, looked up every 5 minutes |
| Appeals a UTC day | 10 for one campaign, 20 across all campaigns |
A run against Base Sepolia
On 11 November 2026 the whole path was run once with test money, from a local copy of the site and a worker on one machine, against the public test network. A wallet with history asked and was approved. The worker posted list 1 in this transaction to the vault at 0x866eDed69E7800d19c8D73f7Edf5c54081f8BA65. An hour later, when the vault's delay was over, the worker submitted the claim in this one, and the vault paid the wallet 0.5 test USDC. This was not a hosted campaign, and nothing in it was public beyond the chain.
On 13 November 2026 the hosted parts were checked together before the site went up. Thuli's own test wallet, which is also the demo campaign's owner, signed an ask and sent it through a production build of the site on one machine, set up exactly as the hosted site is. The ask was stored in the hosted database. The worker on its host read the wallet's history on Base, cut it to a partial reward under rule A8 (one day old, six transactions sent) and gave it slot 1, twelve seconds after the ask was stored. One second later it posted list 1 to the demo vault in this transaction. What this does not show: an ask sent through the hosted site itself.
What each outcome means
The reasons below use the same wording as the rest of the site. The default numbers are the rulebook's: a wallet is new or little used when it is under 30 days old or has sent fewer than 5 transactions, a linked group counts from 5 wallets, and a shared funder counts from 10 asking wallets. A project can change them for its campaign. The campaign routes do not publish those settings today, so if a project changed them you can read the facts and the decision, but not the changed line.
Approved in full
You get a slot and the campaign's full reward. This comes from rule A9: "Nothing in the record points to a farm." Rule B2 can also lead here: "The model raised a held wallet because its shared links look like ordinary infrastructure."
Approved for a partial reward
You get a slot and the partial share of the reward, half by default. This comes from rule A8: "A new or little-used wallet, or one funded in a same-day burst from an exchange. It gets a partial reward." A campaign cannot see funding bursts, so here it means a wallet under the age or transaction minimum. Rule B2, quoted above, can lead here too.
Held for review
No slot is taken and no reward is reserved. A hold means a person looks at the wallet. It is not a verdict that you are a farmer. A held wallet can appeal, described below. A hold can come from these rules:
- A1: "A fact about this wallet could not be read, so it waits for a person."
- A4: "The wallet belongs to a linked group of claim wallets."
- A5: "One ordinary wallet funded this wallet and many other claim wallets."
- B1: "The model held the wallet because a traced funding trail leads to a shared source."
A screening that could not finish is also a hold under A1, described below under "If something goes wrong".
Excluded
Rule A3: "This wallet's own transfers link it to a group of claim wallets that sweep to one collector." Seeing a sweep needs every asking wallet's outgoing transfers, which a campaign screening does not collect. So a campaign does not exclude a wallet today. The strongest call it makes is a hold.
Approved, but every slot was taken
The decision is an approval, under A8 or A9, but the campaign had no free slot left when the wallet was decided. The board row shows no slot. The wallet is not on any list and nothing is reserved for it. This happens to an ask that was already waiting when the last slot filled, and to a wallet whose appeal the owner reversed after every slot was taken.
Rules a campaign does not reach
Three more rules in the rulebook, A2, A6 and A7, do not come up in practice here. Contract wallets are refused when they ask, and a campaign does not measure shared deposit addresses or where a claim is paid. All the rules are on the rulebook.
What a campaign screening checks and what it does not
What it measures
- Wallet age in whole days, from the earliest successful transfer that moved value into the wallet or that the wallet sent itself. Failed transfers, and transfers of zero value sent to the wallet by others, do not count, so a stranger cannot make a wallet look older.
- How many transactions the wallet has sent.
- Whether the wallet is an ordinary wallet or a contract.
- The first funder, and what kind of sender it is.
- How many asking wallets that first funder paid.
- The linked group: how many asking wallets are connected through shared funders.
Two asking wallets are linked when the same ordinary wallet first funded both. The link follows a chain, so if one asking wallet was first funded by another asking wallet, they are in the same group. An exchange, a bridge or a contract as the first funder links nobody, because those pay strangers all day.
What it does not measure
Every decision carries this list, and the campaign page shows it under "Not checked". The code name of each item is in brackets.
- Shared exchange deposit address (
sharedDepositAddress). Spotting it needs the outgoing transfers of every asking wallet, and a campaign screening does not collect them. - Sweeps to a shared collector wallet (
sweptToSharedCollector). It needs the same outgoing transfers. - Claim paid to another address (
claimPaidToOtherAddress). When a wallet asks, no reward has been paid yet, so there is no claim that could have been redirected. - Same-day funding burst (
fundingBurst). It needs the amounts and times of funding for every asking wallet, and a link between two asks carries no funding time. - Funding passed through in-between wallets (
passThroughFunding). The first funder is the wallet's direct one. Funding that reached it through pass-through wallets, which the replay follows back to its source, is linked here by the last hop only.
As on the other pages, a farm that funds each wallet from its own exchange withdrawal, waits, and never sweeps will pass. The full list of limits is on Limits.
Check it yourself
Each route below is a read you can open in a browser. :id is the campaign's id. It is the second line of the message you sign, after "Campaign:", and it sits in the address of each list link on the campaign page. The four read routes share one allowance of 120 requests a minute per visitor, and a visitor past it gets 429 with {"busy": true}.
| Route | What it returns |
|---|---|
GET /api/campaigns/:id | The campaign: name, chain (84532 for Base Sepolia), vault, token, rewardPerWallet and perWalletCap (in the token's smallest unit, so 1 USDC is 1000000), maxSlots, status (open or closed), opensAt, closesAt, the counts (asked, queued, decided, held, slotsTaken, slotsLeft, asksLeft, and appeals with filed, checked, block and reversed), and lists, one entry per list with its version, root, status, claimableAt and postTx. |
GET /api/campaigns/:id/board | The 50 newest decided asks, newest decision first, in asks. Each holds the decision, rule, reason, cited transactions, the facts, the not-checked list, the slot and the appeal, which is null when there is none. payouts holds one entry for each wallet on the page that the vault has paid: the wallet, the total amount in base units and the claim transaction. Add ?before= and an ISO time such as 2026-11-11T12:00:00.000Z to read older ones. Use the last row's decidedAt and no ask is skipped, because a page never ends inside a tie. |
GET /api/campaigns/:id/asks/:wallet | One wallet's ask. Once the wallet is on a paying list, the answer also holds its claim ticket: cumulativeAmount, proof, root, and claimed and claimable when the vault can be read. |
GET /api/campaigns/:id/lists/:version | One posted list as a JSON array of account and cumulativeAmount, exactly as stored. |
A list's status is built, sending, posted, active, superseded or cancelled. Built means stored and not yet sent, and sending means the worker has signed the post and recorded it and is waiting for the network. Posted means the list is waiting out the vault's delay, and active means the vault is paying it now. Superseded means a later list replaced it, and cancelled means the owner cancelled it or it was replaced before it was ever posted.
An unknown campaign id answers 404 with {"error": "no such campaign"}, and a database that cannot be reached answers 503 with {"busy": true}. Amounts are whole token units written as text, times are ISO 8601, and no answer holds a signature or a network address. A ticket goes out without claimed and claimable when the vault cannot be read or when the read routes have caused 600 chain reads in the last minute across every server copy. A server copy keeps the last 8 active lists it parsed, and a wallet's vault amounts for 15 seconds.
Rebuild a list's root
Anyone can rebuild a list's root and compare it with the vault, so nobody has to take the worker's word for what it posted. The leaf formula is on the vault page. The steps:
- Download the list from
GET /api/campaigns/:id/lists/:version. - Make one leaf for each entry from the chain id (
84532), the vault address, the account and the amount, as the vault page shows. - Build the tree with OpenZeppelin's
StandardMerkleTree, from the@openzeppelin/merkle-treepackage, over the typesuint256,address,addressanduint256. It sorts the leaves itself, so the order in the file does not matter. Thuli's own version isbuildRootinpackages/worker/src/vault/root.ts. - Compare your root with the
rootof that list inGET /api/campaigns/:id, and with the vault. Itsstate()returnsactiveRoot, the list paying now, andpendingRoot, the list waiting out its delay.
Appeals
An appeal is a wallet's one line of disagreement with a decision. It goes to the campaign's owner, and an outside judge gives advice on the decision. The owner makes the call.
Who may file
A wallet whose ask was decided or failed may file one appeal per campaign if the decision is a hold, an exclusion or a partial reward, or if the screening could not finish. A full approval cannot be appealed, an ask still waiting cannot be appealed yet, and a contract wallet cannot appeal. A second appeal from the same wallet gets the first one back unchanged.
The one line
The text is one line of plain words: not empty, at most 1,000 characters at the route and 280 in the form on the campaign page, with no line break, tab or other control character, no link and no markup. The form on the campaign page, headed "DISAGREE?", counts the characters and says the words go to the owner only. They are shown on no public page and sent to no model.
The message you sign
The wallet signs this text, five lines, each ending in a line break:
Thuli appeal v1
Campaign: <campaign id>
Wallet: <your wallet address>
Appeal text SHA-256: <hash of your text>
Signed at: <unix seconds>The fourth line holds the SHA-256 hash of your exact text, 0x and 64 lowercase hex digits, so the signature covers exactly that text. The server rebuilds the hash from the text it receives and never takes one from the caller. The signature must be under 10 minutes old and not more than 60 seconds ahead, like an ask. The first line marks the text as an appeal, so it can never be read as an ask or a sign-in.
The body is exactly {"wallet": "0x...", "text": "...", "signedAt": 0, "signature": "0x..."}, at most 2 KB, sent to POST /api/campaigns/:id/appeals. The route checks in this order: the size (413), the caller and the shared minute (429), the shape (400), the text and the signature with no chain read (400 with the cause), then a look in Thuli's own database for this wallet's ask, and only for an ask that may be appealed the wallet type, the caller's hour, and the store. A wallet with no ask, an ask not decided yet, a full approval or an earlier appeal is answered from the database alone, with no chain read, so a key that never asked cannot make the server read a chain. The answers:
| Answer | Meaning |
|---|---|
202 {"queued": true} | The appeal is stored and waits for the outside check. |
200 {"exists": true, "appeal": ...} | This wallet already appealed. The first appeal stands. |
409 {"undecided": true} | The ask has no decision yet. |
409 {"approved": true} | The ask was approved in full. |
404 {"error": "no ask from this wallet"} | This wallet never asked. |
429 {"busy": true} | The caller has filed 2 appeals this hour, or the day's appeals are used. Only the day's answer carries Retry-After, the seconds until the next UTC midnight. |
400 {"error": "..."} | The check failed, with a fixed cause. |
The causes are: the wallet is not a valid address; the appeal text is empty; the appeal text is longer than 1000 characters; the appeal text contained a link, markup or control characters; the appeal text must be one line; the signing time is not a whole number of seconds; the signature is too old; the signature is dated in the future; the signature does not match this appeal; the wallet type could not be checked; contract wallets cannot appeal yet.
The ceilings
Each queued appeal buys an outside check that no sign-in or payment pays for, so three ceilings bound them: 2 signed appeals an hour per caller, 10 a UTC day for one campaign, and 20 a UTC day across every campaign. Appeals and asks share the 20 requests a minute per caller.
What is sent to Reppo, and what never is
Reppo is the outside evaluation service. The worker sends it Thuli's decision, not the appeal. The text is made from the ask's stored record with everything that could identify the wallet removed: the wallet's facts as words and counts (age in days, transactions sent, the kind of account and of first funder, group size, and so on), the outcome in words, and the reason with its rule code taken out. Every address, transaction hash, date, clock time and amount is removed, and a last check refuses to send a text that still holds one of those or a number of seven digits or more. In that case the appeal ends unchecked and nothing is sent.
Never sent: the wallet's address, any transaction hash, the appellant's own words, the text's hash and the signature. The appellant's words reach no model and no public page. The only place they appear is the operator's appeal:list command.
Reppo cannot recognise the same text sent twice, and each check costs credit, so sending is bounded. The worker writes a mark that it is about to send, then makes one request with no retry inside it. What happens next depends on whose fault the failure was:
| What came back | What the worker does |
|---|---|
| An evaluation id | Keeps it at once and follows that evaluation to its result. |
| Out of credit, the service busy (409 or 429), the key refused (401 or 403), or the request never left this machine | Nothing was charged. The mark is cleared, the appeal waits (3 minutes when busy, 10 minutes otherwise, the longest wait when out of credit) and none of its tries is used. It waits as long as it takes. |
| Reppo refused this appeal's own request (400 or 404) | Nothing was charged. The try counts, and 6 of them end the check. |
| The decision cannot be sent at all (empty, or too large) | The check ends at once. |
| Anything else: no reply, a timeout, a server error, an answer that cannot be read | The payment is unclear. The mark stays, and the next run counts one unclear submit. |
An appeal may have 2 unclear submits. After the first, the worker sends once more, so one dropped connection does not cost an appeal its check. After the second it stops for good, and the appeal ends unchecked with the note "the outside check may already have been paid for". So one appeal's check is paid for at most twice. If two worker processes ever held the same appeal after a stall, that same limit of two applies. An unchecked appeal can still be settled.
Once an evaluation id is kept, no count of tries ends the check: the worker looks it up every 5 minutes for up to a day. An evaluation Reppo marks as failed ends the check at once, and Reppo charges nothing for one.
The outside check and its route
When the check finishes, Reppo's route, a short note and the evaluation id are stored and shown on the board. Its score, decision and confidence are stored privately. The route values and what the campaign page says for each:
| Route | What it means |
|---|---|
ship | "The judges agreed with Thuli." |
review | "The judges were split." |
block | "The judges disagreed with Thuli." |
| none | "The judges answered, but not in a form Thuli could read." |
A check that could not be made ends with the status unchecked, and the page says "The check could not be made". The check status is queued, checking, checked or unchecked. With no Reppo key set on the worker, appeals wait in the queue.
The check is advice. Nothing in the code turns Reppo's route or note into a changed decision. The campaign page says so: "This is advice. The campaign's owner makes the final call."
Bound to the decision it disputes
An appeal records the action and rule it was filed against. If the ask changes after that, the outside check ends unchecked with the cause "the decision changed after the appeal", and a reversal is refused. A failed ask that has an appeal cannot be requeued, so the appeal and the check always judge the decision that was appealed.
The owner settles
Owner sign-in is not built, so the owner's call is made today by an operator's command, appeal:settle, described below. Two outcomes exist:
- Upheld: the decision stands, and only the appeal is marked settled.
- Reversed: the owner names a new action, full or partial, and it must be more generous than the one appealed, so a hold or an exclusion can become partial or full, and a partial can become full. The ask becomes an approval at that action's share, keeps its rule and reason as the record of what was appealed, and is marked reversed. If it holds no slot it takes the next free one, as long as fewer wallets hold slots than the campaign has. If every slot is taken it holds none, and it is an approval with no slot. A partial raised to full keeps its slot. The next list the worker posts carries the wallet like any slot holder.
Only a reversal changes an outcome, and only toward an approval. The command works whether or not the outside check finished, and never reads it. The board marks an appealed row, and a reversed one, and the campaign page counts how many were appealed, how many the outside judges disagreed with, and how many the owner reversed.
Checked appeals as input
appeal:export writes the checked appeals, with Reppo's whole verdict, to a private file as input for the next rulebook change. The scorecard does not read that file and does not score appeals yet.
Two appeals have been run live
On 13 November 2026 one appeal ran end to end, on a local copy of the site and the worker against the real services. A test wallet one day old on Base asked, was cut to a partial reward under rule A8 and took a slot. It then filed a signed appeal. The worker sent Thuli's decision to Reppo once, as evaluation cmwegxukf000508l98c92l3va. The check finished 86 seconds later with the route review: Reppo's note says its judges disagreed, one scoring the decision 60 and one 100. Realised: 440 Reppo credits, 22 cents, charged once. The campaign's owner then upheld the decision with npm run appeal:settle, and the ask stayed as it was. The board showed the route, the note, the evaluation id and the outcome beside the decision.
The same day a second appeal ran on this site's demo campaign, with the hosted database and the worker on its host. The same test wallet had been cut to a partial reward under A8 there too, and it appealed. The worker sent the decision once, as evaluation cmwezv7mt000007lbw9ii5xzp, and 59 seconds later Reppo answered ship: its judges agreed with Thuli, with a score of 97. Realised: another 440 credits, 22 cents, charged once. The owner upheld the decision. That row is on the campaign page, marked APPEALED.
That is two appeals, on one wallet and one kind of decision. The two answers differ, review and then ship, for decisions that say nearly the same thing, which is why one check is advice and not a measurement. No live appeal has come back block, none has been reversed live, and the failure paths in the table above (an unclear payment, the service busy, no credit) have run only in tests, against a stand-in for Reppo.
A decision stored by an earlier version of the worker has no record to send, so an appeal against it ends unchecked with the cause "the decision kept no record to send". One such appeal was filed the same day: nothing was sent and nothing was charged.
If something goes wrong
The screening could not finish
If your wallet's history cannot be read, SERV gives no answer, or the screening runs out of its 120 seconds, the worker puts your ask back in the queue and tries again later. While it does, your ask shows as waiting. It gets 10 tries in all, with the waits above, so an outage of up to about 57 minutes is waited out before any wallet is held on it.
If all ten tries fail, the ask ends as failed. The board shows it as a hold under rule A1 with the reason "Held for review: the screening could not finish." It has no facts attached and takes no slot. Doubt means hold. A failed ask can be appealed like any hold. After an outage is over, an operator can put a failed ask back in the queue with campaign:requeue. That works only for a failed ask with no appeal. A decided hold is never screened again except through an appeal.
Your ask was refused
The answer names the cause in fixed words. There are seven:
- the wallet is not a valid address
- the signing time is not a whole number of seconds
- the signature is too old
- the signature is dated in the future
- the signature does not match this ask
- contract wallets cannot ask yet
- the wallet type could not be checked
If the signature is too old, sign again. A closed campaign answers {"closed": true}, a full one answers {"full": true}, a wallet that was never used gets {"neverUsed": true}, and too many asks from one visitor, or the whole service at a ceiling, answers {"busy": true}.
The claim was not submitted for you
The worker tries each claim up to 10 times, with the same growing waits, which covers about an hour of trouble on the chain or the connection. If every try fails, it stops trying for that list. Your reward stays in the vault and is still yours. You can claim it yourself:
- Open
GET /api/campaigns/:id/asks/:wallet. Once your wallet is on a paying list, the answer holds aticketwithcumulativeAmount,proofandroot. - Call
claimon the campaign's vault with those values:
claim(address account, uint256 cumulativeAmount, bytes32[] calldata proof)account is your wallet. cumulativeAmount and proof come from the ticket. Anyone can send this call and the sender pays the gas, but the vault pays only account. To see what a claim would pay first, read claimable(address account, uint256 cumulativeAmount) on the vault. The ticket's claimed and claimable fields show the same numbers.
A call that reverts with NothingToClaim means the vault has already paid you everything the list owes. CampaignEnded means the vault's end time has passed.
Operator commands
These seven commands run from the repository root by the person who runs a campaign. Each one that can change something does nothing until told to: it prints what it would do, and one flag makes it act. Each reads its settings from .env, and each scrubs keys and database addresses from what it prints.
npm run vault:create
npm run vault:create -- --agent ADDRESS --cap USDC --deposit USDC [--days N] [--delay-hours N] [--send]Makes and funds one vault on Base Sepolia. --send signs and sends. Every flag and refusal is on Contracts and addresses.
npm run campaign:create
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]Registers a campaign for that vault. --agent must be the vault's agent, and the command refuses a reward times slots above what the vault still holds. --write stores the campaign. Every flag and refusal is on Contracts and addresses.
npm run worker
npm run worker
npm run worker -- --onceRuns the campaign worker. It makes one run every 5 seconds until Ctrl+C or SIGTERM, which finish the step under way, or one run with --once. It serves every open campaign in the database. It needs DATABASE_URL (migrated first with npm run db:migrate), WORKER_PRIVATE_KEY, SERV_API_KEY, SERV_BASE_URL, SERV_GRAPH_ID, ETH_RPC_URL, BLOCKSCOUT_ETH_API and BASE_SEPOLIA_RPC_URL. REPPO_API_KEY is optional. With it, and an https REPPO_BASE_URL, each appeal gets its outside check. Without it, appeals wait in the queue.
npm run campaign:requeue
npm run campaign:requeue -- --id CAMPAIGN --wallet ADDRESS [--write]Puts one failed ask back in the queue, from the start with no tries used, once an outage is over. It prints the ask's status and rule. Without --write it only says what it would do. Any ask that is not failed is refused and left alone, and so is a failed ask that has an appeal. Reads DATABASE_URL only and loads no private key.
npm run appeal:list
npm run appeal:list -- --id CAMPAIGNLists one campaign's appeals, one block each: the wallet, the ask's action and rule, the appeal's text, the outside check, Reppo's note and the outcome. It is the only place an appeal's text is shown, and it goes to the operator's terminal only. The text, the owner's note and Reppo's words each print as one quoted line after a fixed label, so none of them can pass for a line of the command's own output. It changes nothing.
npm run appeal:settle
npm run appeal:settle -- --id CAMPAIGN --wallet ADDRESS --outcome reversed|upheld [--action full|partial] [--note "..."] [--write]Settles one appeal on the owner's word. --outcome is reversed or upheld. A reversal needs --action full or --action partial, and an upheld appeal takes none. --note is plain text of 1 to 1,000 characters. Without --write it prints the appeal and the store's own answer: the cause it would be refused with, or what it would be settled as and the slot the wallet would hold. With --write it settles.
npm run appeal:export
npm run appeal:export -- --id CAMPAIGN [--file PATH] [--write]Writes one campaign's checked appeals to a private file, one line each, unless --file names another. A line holds the decision as appealed, the ask's stored record and Reppo's whole verdict, and never the appeal's text, its hash, its signature or the owner's note. An evaluation id already in the file is skipped, and a file with any line that is not an exported appeal is refused and left as it was. Without --write it only says how many lines it would add. The file holds evidence transaction hashes, so it is private data.
The reward vault
How one campaign's reward money is held on Base and who can do what.
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.