Quickstart
Verify the published testnet round and run a fresh round yourself, from a clean clone, with the exact commands and the output to expect.
This page takes you from a clean clone to two results:
- You verify a round that Tally has already published on testnet (
pnpm verify:evidence). This takes about two minutes. - You run a complete new round on testnet yourself (
pnpm demo). This takes several minutes.
Everything runs against Stellar testnet. Testnet tokens have no value.
To check the published round without cloning anything, use Verify this round in your browser. It runs the same verification module as tally verify. See Verifying in the browser.
Prerequisites
| You need | Why |
|---|---|
git | To clone the repository and its submodule |
| Node.js | To run the TypeScript tools through tsx. CI uses Node 24 |
pnpm | To install dependencies and run the scripts. CI uses pnpm 9 |
| Network access to Stellar testnet | verify reads the round and its transfers from the public testnet RPC; demo also funds new accounts through friendbot |
You do not need the Noir toolchain to verify a round. The compiled circuits (circuit.json) and the pinned verification keys (vk.zk.bin) are committed to the repository. You need nargo 1.0.0-beta.11 only if you want to rebuild the circuits, and Rust only if you want to run the contract tests.
The repository pins OpenZeppelin stellar-contracts v0.9.0 (df602b6) as a git submodule at vendor/stellar-contracts. Clone with --recurse-submodules, or run git submodule update --init after cloning.
1. Verify the published round
git clone --recurse-submodules https://github.com/Tally-Network/Tally
cd Tally
pnpm install
pnpm verify:evidencepnpm verify:evidence reads evidence/latest.json to find the currently published round (now round-005). It then runs tally verify with that round's funder address, round id, challenge and bundle.
Expected output
This is the output recorded in evidence/README.md:
✓ round found: 5 lanes, window [5033961, 5033976] ✓ 16 transfers from the declared lanes inside the window ✓ public inputs reconstructed from chain state only ✓ verification key matches the pinned artifact (1760 B) ✓ proof verified (16224 B, zero-knowledge) TOTAL DISBURSED: 3160 (stroops of the wrapped asset) over 16 transfers from 5 declared lanes, ledgers 5033961–5033976 no individual amount was revealed.
The tool also prints informational lines marked · between these, such as the registry and token addresses, how many days remain in the retention window, which circuit size it used, and how many public inputs it built.
What each line means
| Line | What verify did |
|---|---|
round found | Read the lane set and the ledger window from the round registry contract, using the funder address and round id |
16 transfers from the declared lanes inside the window | Listed every transfer event sent from a declared lane between the opening and closing ledgers |
public inputs reconstructed from chain state only | Built every input to the proof check from chain data and from the donor's challenge. From the funder's bundle it reads only the proof, r_disc and the sealed total |
verification key matches the pinned artifact | Checked that the key derived from the committed circuit equals the committed vk.zk.bin. It refuses to verify on a mismatch |
proof verified | Checked the zero-knowledge proof |
TOTAL DISBURSED | Decrypted the total with the donor's own key and nonce |
The donor's secret key for this round is published on purpose in the round's challenge.json. That makes everyone the donor for this one demonstration round. It reveals the total and nothing about any individual amount or about the funder. A real donor keeps that key private, so only they learn the total.
Exit codes
| Exit | Meaning |
|---|---|
0 | Verified. The total is printed |
1 | Could not verify. For example: a missing argument, no round with that id under that funder, a round with fewer than 5 transfers, or a verification key that does not match the pinned one |
2 | The proof was rejected (PROOF FAILED) |
3 | The round has aged out of the RPC's event retention window. This is not a proof failure |
The exit codes reference has the full list.
The ~7-day retention caveat
round-005 was published on 2026-10-05 and is verifiable until ledger 5154939, about seven days later. A scheduled job publishes the next round before then. After that, pnpm verify:evidence exits with code 3.
Soroban RPC serves only a rolling window of events, about 120,960 ledgers or roughly seven days on testnet. verify lists a round's transfers from chain events on purpose, so that the funder cannot choose which transfers the total covers. Once the round's ledgers leave the window, the RPC no longer serves those events.
verify checks the RPC's oldest ledger before it lists anything. If the round is too old, it says so and exits with 3:
✗ this round has aged out of the RPC's event retention window.
round opened at ledger 5033961
RPC serves from ledger 5044310 (10,349 ledgers ≈ 0.6 days too old)
This is not a proof failure. The proof is untouched and would still verify.If that happens, this command runs a fresh round and publishes it as the next evidence/round-NNN:
pnpm evidence:refreshThis keeps the current evidence verifiable. It does not make an old round verifiable again. That needs a persistent event archive, which Tally has not built. See known limits.
Try to break it
The verifier should reject both of these with PROOF FAILED and exit code 2. These commands come from evidence/README.md:
# inflate the sealed total by one
python3 -c "import json;b=json.load(open('evidence/round-005/bundle.json'));b['v_tilde_disc']=hex(int(b['v_tilde_disc'],16)+1);json.dump(b,open('/tmp/t.json','w'))"
npx tsx cli/tally.ts verify --funder GBNYNIHC6ZFH5D2Z5KPVWRFUG76L2NWHUKAX2KTOEJRAM2WPSUSB3PQY \
--round 030a11181f262d343b424950575e656c737a81888f969da4abb2b9c0c7ced5dc \
--challenge evidence/round-005/challenge.json --bundle /tmp/t.json # expect PROOF FAILED, exit 2
# replay the bundle against a challenge it was not built for
npx tsx cli/tally.ts challenge --out /tmp/c2.json
npx tsx cli/tally.ts verify --funder GBNYNIHC6ZFH5D2Z5KPVWRFUG76L2NWHUKAX2KTOEJRAM2WPSUSB3PQY \
--round 030a11181f262d343b424950575e656c737a81888f969da4abb2b9c0c7ced5dc \
--challenge /tmp/c2.json --bundle evidence/round-005/bundle.json # expect PROOF FAILED, exit 2If either one passes, something is wrong. Please open an issue.
2. Run a round yourself
git clone --recurse-submodules https://github.com/Tally-Network/Tally
cd Tally
git submodule update --init # OpenZeppelin stellar-contracts v0.9.0 @ df602b6
pnpm install
pnpm demopnpm demo runs demo/run-round.ts against the testnet deployment named in demo/deployment.testnet.json. That file holds public data only. It creates fresh accounts on every run, so you can run it repeatedly.
You can point it elsewhere with three environment variables: TALLY_REGISTRY, TALLY_DEPLOYMENT and TALLY_RPC.
What it does
[pre-round] one transfer BEFORE open_round ← must be excluded
[1] open_round(funder, round_id, lanes[]) ← opened_at stamped by the contract
[2] 16 transfers across 5 lanes ← amounts encrypted, addresses public
[3] close_round(funder, round_id) ← closed_at stamped
[4] DONOR: given only the funder address + round id
· resolves lane set and window FROM CHAIN
· enumerates transfers from declared lanes
· discards anything outside the window
[5] ONE aggregate proof over 16 events / 5 lanes → donor verifies the totalBefore step 1 the script creates and funds a funder account, 5 lane accounts and 17 recipient accounts, registers the lanes and recipients as confidential accounts, and deposits into each lane.
The pre-round transfer is the negative case, and it runs every time. Lane 0 sends 999 to a recipient before the round is opened. The donor's total must exclude it and come out as 3160, not 4159. If that ever flips, the window has stopped working.
What to expect
The amounts in the demo are fixed, so the counts and the total are the same on every run. Ledger numbers and timings change. This is the donor part of the run that published round-005 (from demo/README.md):
[4] DONOR — given only the funder address and round id resolved from chain: 5 lanes, window [5033961, 5033976] transfers from declared lanes (all time) : 17 inside the declared window : 16 excluded by the window : 1 <- the pre-round transfer [5] aggregate proof + donor verification proof 16224B in 4723ms, spans 5 sender accounts verified yes donor total = 3160 expected 3160 MATCH pre-round 999 correctly NOT counted: yes === ROUND VERIFIED ===
If any step fails, the script prints FAILED: with the reason and exits with code 1. It also exits with 1 if it does not find exactly 16 transfers inside the window, if the proof does not verify, or if the donor's total does not match.
The file it leaves behind
The demo writes demo/last-round.json. It holds the funder address, the round id, the registry and the lane spending keys, so you can run tally prove against the round afterwards. Spending keys grant both viewing and spending. The file is for testnet only and is ignored by git. A real funder would keep these keys in a signer service.
3. Run the tests
pnpm test # secret guard + its negative tests, typecheck, OZ v0.9.0 conformance
# vectors, local register/transfer proving, retention logic, contract tests
pnpm test:registration # live testnet: non-custodial key derivation and registrationpnpm test includes the contract tests, which run cargo test and need a Rust toolchain.
Next steps
- Walkthrough: the same round as a story, step by step, with the
challenge,proveandverifycommands. - Verifying a round: verify any round, not only the published one.
- Measurements: costs and limits on testnet.
Introduction
What Tally is, who it is for, the problem it addresses, and what works today on Stellar testnet compared with what is only designed.
Confidential tokens on Stellar
What OpenZeppelin Confidential Tokens give Tally: private balances and amounts, public addresses, the key hierarchy, the auditor key and the transfer event.