Skip to content

#430 Feature: proof-of-uniqueness leaderboard-safe attestation for ai… - #497

Open
felladaniel36-hash wants to merge 2 commits into
ToluLabs:mainfrom
felladaniel36-hash:#430-Feature--proof-of-uniqueness-leaderboard-safe-attestation-for-airdrops/quotas-FIX
Open

#430 Feature: proof-of-uniqueness leaderboard-safe attestation for ai…#497
felladaniel36-hash wants to merge 2 commits into
ToluLabs:mainfrom
felladaniel36-hash:#430-Feature--proof-of-uniqueness-leaderboard-safe-attestation-for-airdrops/quotas-FIX

Conversation

@felladaniel36-hash

Copy link
Copy Markdown

feat(contracts,sdk): verified-human-once claims for airdrops/quotas FIXED

What does this PR do?

Turns the per-app nullifier idea into a working, plug-and-play anti-Sybil distribution
pattern — and supplies the primitive it was supposed to build on, which did not actually
exist in the repo yet (grep -ri nullifier returned zero hits; every gate was
address-scoped, so one person could pass the same credential from N wallets N times).
ProofRegistry now records the proof's identity commitment (public-input field 0 —
wallet-independent, stable per human) under a new additive DataKey::Subject key and
exposes identity_commitment() and app_nullifier() = sha256(commitment ‖ app_scope),
which returns None unless the claim is currently valid. On top of that sits
contracts/human_airdrop, a small reference distribution contract: campaigns carry a
scope, credential rule, threshold, issuer allowlist, amount, budget, claim cap and window,
and a Spent(campaign, nullifier) entry enforces one claim per human per campaign.
Distributors integrate either by calling claim() (reference payout) or consume()
which burns the human's one-shot claim under the consumer contract's own auth and returns
the nullifier, leaving payout economics entirely to the caller. Ships with the
createHumanClaim() SDK helper (plus a pure, isomorphic deriveNullifier() pinned to the
contract by a shared sha256 test vector), a worked HumanDrop demo in the apps gallery
at /apps/humandrop, and docs/ANTI_SYBIL.md documenting the guarantees and their
limits.

Closes #430

Type of change

  • Bug fix
  • New feature / credential type
  • Refactor / cleanup
  • Docs
  • CI / tooling

Merge requirements

  • CI is greencargo test (contracts) 112 passed / 0 failed; pnpm tsc --noEmit (frontend + SDK) clean; pnpm build (frontend) green, 20/20 static pages; cargo clippy --workspace --all-targets -- -D warnings clean; cargo build --release --target wasm32v1-none produces human_airdrop.wasm (32 742 B) and proof_registry.wasm (46 338 B); frontend vitest run 325 passed / 0 failed
  • Greptile confidence ≥ 4/5 — all review comments addressed, no unresolved threads
  • Circuit changes: fixtures/<type>/ artifacts updated — N/A, no circuit changed. No new artifacts needed; the new tests consume the existing fixtures/kyc/ and fixtures/funds/ proofs unmodified
  • No NEXT_PUBLIC_ prefix on server-only env vars — new SDK config (contractId, registryId) is passed as an argument to createHumanClaim(); no new env var is introduced anywhere
  • No identity fields stored or logged after KYC provider call — nothing in this PR touches the issuance path. The only new on-chain datum is the ZK commitment (a Poseidon2 hash already present in the proof's public inputs), and events emit the nullifier only — never an attribute, never a credential
  • prehash:false preserved on any issuer signing path touched — no issuer signing path touched (packages/issuer untouched)
  • Issuer private key never referenced from client-bundled code — no key material referenced; airdrop.ts and HumanDropPanel.tsx are read-only/simulation and derivation only, the SDK never signs or submits

✅ Merge requirements

  • All CI checks pass (contracts / frontend / circuits as applicable)
  • Every Greptile review comment is addressed — no unresolved review threads
  • Greptile confidence score is 4/5 or higher

Notes for reviewers

1. The ProofRegistry change is deliberately additive — please sanity-check that call.
The commitment goes into a new DataKey::Subject(Address, Symbol) entry rather than a
7th field on ProofRecord. Adding a field would break decoding of every deployed record
and force another migrate_record path; a separate key means existing records decode
unchanged, no migration runs, and no existing function signature moves. Cost: one extra
persistent write per submit_proof (TTL-bumped in lockstep with the proof entry).
revoke_proof / revoke_all now also delete the Subject entry for storage hygiene —
harmless for the gate, since a claim already requires a live proof.

2. Why sha256 and not Poseidon2 for the nullifier. sha256 is a host function on
Soroban (cheap, no in-contract circuit arithmetic) and is trivially reproducible in the
browser via WebCrypto, which is what makes the SDK's offline deriveNullifier() possible.
The nullifier is a domain-separated hash of an already-hiding commitment, so it needs
preimage resistance, not algebraic friendliness. If you'd prefer Poseidon2 for
circuit-composability later (e.g. proving nullifier correctness in-circuit), say so — it's
a one-function change, but it would break the current off-chain derivation.

3. Contract ⇄ SDK derivation is pinned by a shared test vector. The same
sha256(commitment ‖ scope) value ac90ac63…93a091 (derived from the real fixtures/kyc
commitment) is asserted independently in contracts/human_airdrop/src/test.rs,
contracts/proof_registry/src/test.rs, frontend/packages/sdk/src/airdrop.test.ts, and
rendered by the gallery demo. If either side of the derivation drifts, at least two suites
go red rather than the two silently disagreeing in production.

4. consume() auth model. consumer.require_auth() means only the contract being
consumed for can burn a claim — a third party can't grief a human by spending their
nullifier. Covered by consume_requires_consumer_auth and by the in-test
DemoDistributor contract, which is the exact integration shape documented in
ANTI_SYBIL.md. Worth a second pair of eyes on whether we also want an admin allowlist of
consumer addresses; I left it out because require_auth already binds the burn to the
caller and an allowlist adds admin surface without adding a guarantee.

5. Read the limits table before approving the claim in the title. The pattern is
one-claim-per-credential; "per human" is inherited from the issuer's own de-duplication.
docs/ANTI_SYBIL.md §4 enumerates this honestly: multiple registered issuers, salted
re-issuance, weak KYC, credential sale/transfer, and issuer key compromise each break
uniqueness, with mitigations (trusted_issuers narrowing, deterministic salts, campaign
windows, key rotation). I'd rather ship the caveats loudly than let an integrator assume
proof-of-personhood.

6. Demo is a faithful simulation, not a live deployment. HumanDropPanel derives real
nullifiers with the SDK (same bytes the contract computes) but keeps the "spent" set in
React state, so /apps/humandrop works against any deployment or none. Note the second
wallet's Claim button stays enabled on purpose — the point of the demo is watching the
rejection happen (Error #8 AlreadyClaimed), not hiding it behind a disabled control.
Contract IDs land in DEPLOYMENTS.md (placeholder rows added) once deployed.

7. Balance ledger is a demo, flagged as such. claim() credits an internal i128 ledger
exactly like gated_pool, not a token transfer. Production integrators should use
consume() from their own distributor, or swap set_balance for a token::Client
transfer — called out in the module docs and in ANTI_SYBIL.md §4.

8. Pre-existing failures I did not touch. packages/sdk/test/integration.test.ts has
2 failing tests (buildBadgeEmbedCode is not defined — a missing import in that test file).
Verified via git stash that they fail identically on a clean checkout; out of scope here,
happy to fix in a follow-up. Likewise cargo fmt --all --check reports diffs in files this
PR doesn't touch (the repo isn't fmt-clean at the pinned 1.93.1 toolchain); all new and
changed code is fmt-clean.

9. Test snapshots. 19 new human_airdrop snapshots plus refreshed snapshots for
proof_registry / gated_pool / credential_verifier — the refreshes are the expected
consequence of the new Subject ledger entry, all machine-generated by cargo test, none
hand-edited.

Files

Created (9 hand-written, 2 659 LOC): contracts/human_airdrop/{Cargo.toml,Makefile,src/lib.rs,src/test.rs},
docs/ANTI_SYBIL.md, frontend/packages/sdk/src/{airdrop.ts,airdrop.test.ts},
frontend/components/{HumanDropPanel.tsx,HumanDropPanel.test.tsx}

Modified (12, +512/−8): contracts/proof_registry/src/{lib.rs,test.rs},
frontend/packages/sdk/{src/index.ts,README.md}, frontend/lib/protocols.tsx,
frontend/app/apps/[id]/page.tsx, README.md, DEPLOYMENTS.md, docs/EVENTS.md,
docs/contract-error-codes.md, .github/workflows/reproducible-build.yml, Cargo.lock

@drips-wave

drips-wave Bot commented Sep 1, 2026

Copy link
Copy Markdown

@felladaniel36-hash Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@felladaniel36-hash
felladaniel36-hash force-pushed the #430-Feature--proof-of-uniqueness-leaderboard-safe-attestation-for-airdrops/quotas-FIX branch from b74ee8d to b4ee387 Compare September 1, 2026 17:49
@felladaniel36-hash

Copy link
Copy Markdown
Author

@Psalmuel01 PLEASE REVIEW

…aderboard-safe-attestation-for-airdrops/quotas-FIX
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: proof-of-uniqueness leaderboard-safe attestation for airdrops/quotas

1 participant