Turn provable STRK20 pool activity into a one-time capability that any other wallet can redeem, with gas sponsored and no on-chain link back to the wallet that qualified.
Live demo · Contract on Starkscan · Demo video · strk20.json
Prova Pass is a capability layer. It takes a fact that can be proven about a wallet's public STRK20 pool activity, such as a holding period, a deposit total or a number of deposits. It then turns that fact into a bearer token. The token can be copied, sent in a DM or shown as a QR code, and it can be redeemed exactly once by a different wallet. That wallet can be brand new and empty.
The ProvaPass Cairo contract never inspects the predicate. It only checks that Prova's attester signed (campaign_id, nullifier, recipient). It consumes the nullifier, and it can pay out an ERC20 reward in the same transaction.
Built for the STRK20 Private Sprint against the live mainnet privacy pool at 0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a.
Important
The predicate check is a signed server attestation, not a zero-knowledge proof. Whoever holds the attester key (today, only Prova) could sign a pass for a predicate that does not hold. The rest is enforced on-chain or can be checked by anyone: replay protection, binding to a campaign and recipient, and payout limits. See Trust model for exactly what is and isn't private.
- Try it
- How it works
- Live campaigns
- Trust model
- Verify a claim yourself
- Architecture
- Running locally
- Mainnet admin actions
- On-chain record
- Known limitations
- License and credits
Prova only reads a wallet's existing public STRK20 deposit history. It cannot deposit into the pool or shield funds for you: the mainnet prover endpoint the pool requires is unpublished (see The prover gap).
Path 1: no deposit needed (about 2 minutes)
- Open provah.vercel.app and pick Capability Smoke Test. It is the default for first-time visitors and is satisfied by any address.
- Connect wallet A and click Generate pass.
- Connect wallet B (any other wallet, even one with no funds) and click Claim. Prova's operating account pays the gas.
- Click Verify on-chain. This reads
is_nullifier_consumeddirectly from public RPC, without going through Prova's backend.
Path 2: a real predicate, optionally with a reward
- Shield some STRK into the STRK20 pool once, using a privacy-enabled wallet (Ready or Braavos).
- Pick a real campaign and connect wallet A. The browser runs its own eligibility check against public RPC before anything is sent to the server.
- Generate the pass, then claim it from wallet B. You can also paste a token into Redeem a pass someone gave you.
- Optionally tick Lock this pass to one destination wallet before generating.
/api/claimthen refuses any other recipient with403before signing.
If a wallet doesn't qualify for a real campaign, the app shows instructions and a link back to the smoke test.
- Campaign. An operator creates a campaign on
ProvaPasswithcreate_campaign. The predicate type and claim kind live in the backend, so adding a campaign never needs a contract redeploy. - Pass.
/api/passreads the pool's publicDepositevents for the caller's address and evaluates the predicate. If the predicate holds, it stores a pass bound to a fresh nullifier,pedersen(campaign_id, prover_address, salt). - Hand-off. The pass token
(campaignId, nullifier)is a bearer token. It is not tied to any wallet unless it was locked to one destination. - Claim.
/api/claimsigns(campaign_id, nullifier, recipient)with the attester key and relaysclaim_with_prova_passfrom the operating account, so the claiming wallet needs no STRK. - On-chain. The contract checks the ECDSA signature, checks that the nullifier is unused and consumes it, and pays
reward_amountofreward_tokenif the amount is above zero.
The chain only ever records (campaign_id, nullifier, recipient, signature), so nothing on-chain links the recipient to the qualifying address.
All three read the same public data, the pool's Deposit events, and aggregate it differently. They are implemented in src/lib/predicate.ts.
| Predicate | Checks |
|---|---|
held_since |
Deposited ≥ X of asset Y in total, at least N days ago |
balance_threshold |
Deposited ≥ X of asset Y in total, at any time |
deposit_count |
Made ≥ N separate deposits of asset Y |
Note
"In total" means lifetime deposits, not current balance. The pool's Withdrawal event encrypts the withdrawing user (enc_user_addr), so withdrawals can't be linked back to a depositor from public data.
capability: the nullifier is consumed and nothing is transferred. Use it for allowlists or one-time actions.reward_token: the contract also transfersreward_amountofreward_tokento the recipient. There is one entrypoint for both kinds;reward_amount = 0makes a claim capability-only.
- Reward campaigns: one pass per wallet.
/api/passstores a deterministicaddress_commitment = pedersen(address, campaignId)and returns409on a repeat. The code comments describe a unique index on(campaign_id, address_commitment)as a backstop, but the schema isn't in the repo. - Non-reward campaigns: a wallet can generate any number of passes, each with its own nullifier.
- All campaigns: a nullifier can only ever be claimed once.
Five campaigns run on the single deployed contract. This list matches GET https://provah.vercel.app/api/campaigns and strk20.json.
| Campaign | Predicate | Claim kind |
|---|---|---|
| STRK Loyalty Drop | held_since (≥ 1 STRK in total, ≥ 7 days ago) |
capability |
| STRK Holder Badge | balance_threshold (≥ 1 STRK in total) |
capability |
| Active Depositor | deposit_count (≥ 1 deposit) |
capability |
| STRK Welcome Reward | balance_threshold (≥ 1 STRK in total) |
reward_token: 0.05 STRK on redeem |
| Capability Smoke Test | deposit_count, minimum 0 (any address) |
capability |
| Status | |
|---|---|
| Shielded balances and note-to-note transfers inside the pool | Private by the pool's own design. Prova doesn't touch them. |
| That a deposit happened, its amount, and the depositor's address | Public. The pool emits a public Deposit event, and Prova's predicates read only that. |
| The claim transaction and recipient | Public. They aren't linkable to the depositor on-chain, because the contract only sees (campaign_id, nullifier, recipient, signature). |
| The predicate check | A trusted server attestation. This is the one real trust boundary (see below). |
| What Prova's server sees | The prover's address for the length of the /api/pass request. It stores one-way Pedersen commitments only, never the raw address (src/lib/attestation.ts). |
Warning
For reward campaigns, address_commitment has no salt, so the same wallet always maps to the same value. Anyone with database access could therefore check whether a candidate address already holds a pass for that campaign. This is a deliberate choice so that one wallet can't drain the reward pool. Non-reward campaigns never compute it.
What limits a dishonest or compromised attester
- The browser re-checks eligibility independently.
clientEvaluatePredicateinsrc/app/ProvaApp.tsxruns the same predicate maths against public RPC before/api/passis called. - A reward campaign can never pay out more than
ProvaPassactually holds. Each reward campaign card reads that balance live. - Every claim consumes a nullifier exactly once, and anyone can check this.
Replacing the attestation with a client-side ZK proof needs STRK20's mainnet transaction prover (starknet_proveTransaction), and no public endpoint for it has been found. The hackathon repo has open issues from several teams asking for it: #204, #147 and #121.
A hand-built apply_actions fee estimate for EmitViewingKeySet reverted with the pool's own EMPTY_PROOF_FACTS error. That confirms every pool state change needs proof artifacts. The full transcript is in the archived build log.
If a prover becomes reachable, the swap is backend-only:
ProvaPass.cairostays as it is.- The
evaluatePredicatefunctions insrc/lib/predicate.tsare replaced. - The bearer-token and nullifier mechanics stay the same.
From the command line, using only public RPC (no Prova backend, database or secrets):
node scripts/verify-claim.mjs <nullifier>
# also confirm a reward payout:
node scripts/verify-claim.mjs <nullifier> <recipientAddress> <beforeBalanceWei>Run npm install first, because the script imports starknet. The live app shows this exact command, with the real nullifier filled in, next to its Verify on-chain button.
In the browser, the app also:
- reads the recipient's STRK
balanceOfbefore and after a reward claim and shows the difference - shows how many passes have been claimed per campaign, from
ProvaPass'sPassClaimedevents
flowchart LR
A["Wallet A"] -->|"address + salt"| Pass["/api/pass<br/>reads public Deposit events,<br/>evaluates predicate, stores pass"]
Pass --> PG[("Postgres")]
Pass -->|"bearer token<br/>campaignId, nullifier"| B["Wallet B"]
B -->|recipient| Claim["/api/claim<br/>signs, relays tx (gasless for B)"]
Claim --> C["ProvaPass.cairo<br/>Starknet mainnet<br/>verify ECDSA, consume nullifier,<br/>pay reward if set"]
Text version
[Wallet A] --address+salt--> /api/pass --reads public Deposit events, evaluates predicate,
derives nullifier, stores pass (Postgres)
|
v
bearer token (campaignId, nullifier) -- copy / QR / DM
|
[Wallet B] --recipient--------> /api/claim --signs (campaignId, nullifier, recipient),
relays tx from the operating account (gasless for B)
|
v
ProvaPass.cairo (Starknet mainnet)
verify ECDSA -> consume nullifier -> pay reward if reward_amount > 0
| Path | What's there |
|---|---|
contracts/prova_pass/ |
The ProvaPass Cairo contract (Scarb, starknet = "2.9.2"). Compiled Sierra and CASM files are vendored in src/contracts/. |
src/app/ |
Next.js 16 App Router UI. The main flow is in ProvaApp.tsx; the icon and OG image are generated in code. |
src/app/api/ |
campaigns, pass and claim, plus admin/deploy and admin/create-campaign, which are gated by PROVA_ADMIN_TOKEN. |
src/lib/ |
Predicates, attestation, pass tokens, claim and pass decisions, the Starknet client and Postgres (pg). |
vendor/ |
StarkWare's @starkware-libs/starknet-privacy-sdk and starknet-privacy-client, built from source and vendored. They are currently used for Pedersen, viewing-key and ECDSA primitives. |
scripts/ |
mainnet-admin.mjs (operator CLI), verify-claim.mjs, and the tsx test scripts. |
Requirements: Node.js 22, which is what CI uses. You also need a Postgres database, a funded Starknet mainnet account to act as the operator, and an attester keypair.
npm install
cp .env.example .env.local
npm run dev # http://localhost:3000| Variable | Purpose |
|---|---|
STARKNET_RPC_URL |
Starknet mainnet RPC (the example uses public Lava RPC) |
STARKNET_CHAIN_ID |
0x534e5f4d41494e (SN_MAIN) |
STRK20_POOL_ADDRESS |
The STRK20 privacy pool contract |
STARKNET_ACCOUNT_ADDRESS, STARKNET_PRIVATE_KEY |
The operating account, which deploys contracts, creates campaigns and relays every claim. If either is missing, /api/claim returns 503 operator_not_configured. |
PROVA_PASS_CONTRACT_ADDRESS, PROVA_PASS_CLASS_HASH |
Filled in after deploy |
PROVA_ATTESTER_PRIVATE_KEY, PROVA_ATTESTER_PUBLIC_KEY |
The attester keypair. The public key is baked into ProvaPass at deploy time. |
DATABASE_URL |
Postgres for campaigns, passes, claims and the activity log (public metadata only) |
PROVA_ADMIN_TOKEN |
Shared token for the /api/admin/* routes |
Note
The repo doesn't include a SQL schema or migration for the Postgres tables (campaigns, prova_passes, claims, mainnet_activity_log). A fresh database has to be set up to match the queries in src/lib/db.ts and the API routes.
Other commands
npm test # tsx test scripts: typed-data, ownership-fallback, predicate maths,
# pass token, pass and claim decisions, claim copy
npm run build # next build
scarb build # run from contracts/prova_pass/Real mainnet transactions run either from the deployed app or from the manual Mainnet admin action workflow (.github/workflows/mainnet-admin.yml). That workflow calls scripts/mainnet-admin.mjs on a GitHub-hosted runner, using repo secrets.
Its actions are:
deploy-account,deploycreate-campaign,claim,fund-contractbalance,check-owner,check-class,get-campaign,tx-statuspool-abi,pool-registergenerate-attester-key,get-attester,set-attester
They can also be run locally, with the environment variables exported:
node scripts/mainnet-admin.mjs deploy
node scripts/mainnet-admin.mjs create-campaign "STRK Loyalty Drop" \
0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d \
1000000000000000000 7 "" "" "" held_sinceWarning
These commands send real transactions on Starknet mainnet from the operating account, and they spend real STRK.
ProvaPass is at 0x74614e0cd54af7e59987a5d74fdd028209feff01fc20eca2934fe80b94db402, with class hash 0x7adfeaf0d075cda33b3128fd9cc255e34e7b778e907cbb64216d76bd7cf89e6.
strk20.json records 15 mainnet transactions: 11 involving ProvaPass and 4 made directly against the STRK20 pool.
ProvaPass transactions (11)
| # | Type | Tx |
|---|---|---|
| 1 | deploy_account: operating account |
0x266ff3…c010 |
| 2 | declare: ProvaPass class |
0x1d57d6…1153 |
| 3 | deploy: ProvaPass instance |
0x79375d…c70a |
| 4 | create_campaign: STRK Loyalty Drop |
0x758de9…26e6 |
| 5 | claim_with_prova_pass: claim from a different wallet |
0x5ebf46…9dce |
| 6 | create_campaign: STRK Holder Badge |
0x41c168…e9b3 |
| 7 | create_campaign: Active Depositor |
0x12aa67…4496 |
| 8 | STRK transfer: fund ProvaPass with 1 STRK |
0x76eeb4…7625 |
| 9 | create_campaign: STRK Welcome Reward |
0x1a64f5…32c0 |
| 10 | claim_with_prova_pass: 0.05 STRK payout to a brand-new address |
0x45f6b0…514c |
| 11 | create_campaign: Capability Smoke Test |
0x4e09d6…ffee |
Transaction 10 was triggered by the operator through mainnet-admin.mjs to test the payout end to end. The recipient's STRK balance went from 0 to 50000000000000000 wei (0.05 STRK).
Direct STRK20 pool transactions (4)
These four pool transactions were made by a person using a privacy-enabled wallet through the wallet-mediated route, not by Prova's backend. Each was checked with scripts/mainnet-admin.mjs tx-status <hash>: the pool contract emits Deposit (10 STRK) and EncNoteCreated, and the first one also emits ViewingKeySet.
0x0684bdad…fc385: block 13929673, viewing-key registration plus a 10 STRK shield0x0043faa1…7adb6: block 139559230x0613601d…04421: block 139559650x003c4835…dd9473: block 13956048
Selector hashes and a note about an extra relay call that couldn't be decoded are in the archived build log.
- The predicate check is a trusted server attestation, not a zero-knowledge proof. Whoever holds the attester key (today, only Prova) could sign a pass for a predicate that does not hold.
- No public STRK20 mainnet prover was found (
starknet_proveTransaction), so the ZK swap described in The prover gap is not done. - Reward-campaign commitments are unsalted, so anyone with database access could check whether a known address already holds a pass for that campaign.
- The database schema is not in the repo. The code comments describe a unique index on
(campaign_id, address_commitment)as a backstop, but it can't be checked from here. - Predicates read only public
Depositevents, not shielded balances.
Prova Pass is released under the MIT license; see LICENSE.
The vendored packages under vendor/ are StarkWare's starknet-privacy SDK and client. Each package keeps its own LICENSE file, which is Apache-2.0. Note that the package.json of each says ISC.
The STRK20 privacy pool is StarkWare's contract; Prova Pass only reads its public events.