Privacy-preserving, on-chain-enforced compliance for tokenized RWAs on Casper — with an honest on-chain/off-chain split.
Writ's claim, stated precisely: browser proof generation and attestation verification happen off-chain; Casper stores signed credentials and enforces transfer eligibility through a recipient-aware CEP-78 filter; the challenge path can re-verify a credential's own published proof on-chain (Groth16) for dispute resolution. Casper has joined the ERC-3643 Association, and Writ is built in alignment with that compliance model.
Live app: https://writ-app-production.up.railway.app · reviewer page: /docs/whats-real
All contracts are live on casper-test (Casper 2.x). Every hash is verifiable
on testnet.cspr.live, and
scripts/verify_live.sh re-checks every claim below against the public node RPC
from any machine (no keys needed).
V5 is the hardened redeploy: the verifier ships checked deserialization, and the
registry pins the canonical public inputs on-chain (issuer key + jurisdiction
root) via set_canonical_inputs — so a proof carrying a forged issuer or root is
rejected at attest, on-chain, not merely by the onboarding service.
| Contract | Source | Role | Package hash |
|---|---|---|---|
| groth16-verifier | contracts/groth16-verifier |
On-chain Groth16 pairing verify (checked deserialization) — fraud-challenge path only | 1785d5a3 |
| credential-registry | contracts/credential-registry |
Credentials + on-chain canonical input pinning; attest, revoke, freeze, status | 74148da7 |
| challenge | contracts/challenge |
Fraud disputes: bond → challenge → resolve (slash + treasury transfer) | 8cddad30 |
| writ_registry_filter | contracts/writ-cep78/fork/contracts/test-contracts/writ_registry_filter |
The CEP-78 hook — can_transfer (sender AND recipient) + mint_allowed; fail-safe deny |
0b1f806b |
| transfer-filter (Odra) | contracts/transfer-filter |
Odra adapter used by writ-token (not the CEP-78 hook) | 30cca9f1 |
| writ-cep78 | contracts/writ-cep78/fork |
RWA bond NFT (patched CEP-78), wired to writ_registry_filter | 2ce2ff55 |
| writ-token | contracts/writ-token |
Odra demo token + its filter (integration-test model) | 200cd183 |
The superseded V4 set remains on-chain and its transactions stay valid history; see DEPLOYMENT_v2.md.
| What it proves | Deploy hash |
|---|---|
| Canonical inputs pinned on-chain — issuer key + jurisdiction root written to the registry | 09975872 |
| Holder attest — real Poseidon commitment + the holder's own proof bytes, accepted only because its public inputs match the pinned canon | a2dc0c8a |
| Mint to an ineligible recipient reverts (filter error 159) | 7f685f23 |
| Eligible transfer proceeds | 4df6736c |
| Recipient-aware deny — transfer to ineligible recipient reverts (159) | af706a71 |
The kicker — the same route proceeds 65d81a5a, then reverts (159) after a sanctions revoke 29ad4113 |
1af2d7e6 |
Fraud slash — resolve → on-chain Groth16 FALSE → slash 500, challenger paid 640, 110 CSPR transferred to treasury (95.1 CSPR gas for the pairing verify) |
79cce54a |
| Live self-onboarding through the deployed app — wallet bind → demo-issuer claims → in-browser proof → screening → attest accepted against the pinned canon | 930a89f9 |
| Post-fraud — transfer to the RevokedFraud holder reverts (159) | 0013547b |
The fraud fixture is honest by construction: holder X was attested with another holder's valid proof against X's own public inputs. It deserializes (so the checked verifier accepts the encoding) but fails the pairing — exactly what a forged credential looks like, and exactly what the challenge path is for.
Full manifest with install txs, gas, and the payable-call workaround: scripts/deploy/DEPLOYMENT.md.
| Suite | Command | Coverage |
|---|---|---|
| credential-registry — 57 tests | cd contracts/credential-registry && cargo test |
RBAC, sig validation, nullifier reuse/replay, public-input binding incl. on-chain canonical issuer/asset/root pinning, expiry, state machine, transfer matrix, officer paths |
| challenge — 18 tests | cd contracts/challenge && cargo test |
bonding, withdraw guard, fraud/frivolous resolve, idempotency, effects-before-interactions, self-slash deterrence |
| groth16-verifier — 8 tests | cd contracts/groth16-verifier && cargo test |
valid/tampered proof + inputs, checked deserialization: malformed proof, off-curve point, out-of-subgroup G2, non-canonical field element all rejected; embedded VK passes checked deserialization |
| CEP-78 ⇄ writ_registry_filter ⇄ registry E2E | cd contracts/writ-cep78/fork && cargo test -p tests --lib writ |
real-EE gating: eligible/ineligible mint + transfer, revoked sender, expired credential, operator-path no-bypass, missing-registry fail-closed |
| integration lifecycle | cd contracts/integration && cargo test |
onboard → gated transfer → revoke → refresh → fraud challenge → slash → officer overrides |
| frontend — 28 tests | cd frontend && npm test |
wallet-bind (replay/expiry/domain/ownership), fail-closed issuer, screening (hit/clean/stale/unavailable), proof serde byte-exact vs arkworks, full in-node prove + public-input binding |
| disclosure — 14 tests | cd disclosure && npm test |
Poseidon recompute vs live on-chain commitment, tamper detection, compelled-disclosure round-trip |
- The claim issuer is a demo issuer key (
ISSUER_EDDSA_KEY, no default, fails closed) — no external KYC provider is integrated. - The attestation "quorum" is one trust domain: two signatures produced by env keys held by one server process. The registry's on-chain check (2 valid ed25519 sigs from its registered 3-key set, bonded signers only) is real; signer independence is not.
- Sanctions screening scope: the live OFAC SDN digital-currency list (ETH addresses) is fetched with content-hash + timestamp and screened against an optional linked ETH address — an identifier that can actually match. Casper-account matching uses a labeled demo denylist (no official Casper-account SDN mapping exists). Stale/unavailable data blocks attestation.
- The officer role is a single demo key (Casper weighted-key multisig is the
documented production path;
scripts/officer_multisig/demonstrates it). - The trusted setup is a single-contribution dev ceremony (demo-grade).
- The landing-page terminal is a scripted replay of the real on-chain
outcomes, labeled
SCRIPTED DEMO.
Independent verifier services holding one quorum key each (the agent/ directory
implements the N-verifier shape); a real external KYC issuer; a multi-party
trusted-setup ceremony; circuit v3 with in-circuit claim expiry; redeploy of the
hardened verifier (checked deserialization); officer role behind Casper weighted
multisig; a provably unspendable slash sink.
Off-chain (browser): the identity secret and salt are derived from a wallet
signature and never leave the browser; the witness is assembled locally; snarkjs
generates the Groth16 proof (~1s).
Off-chain (server): verifies the proof, binds all six public inputs to the
pinned issuer key / asset / jurisdiction root, verifies the nonce-bound wallet
signature (blocking, single-use), screens sanctions data (fail-closed), then
co-signs.
On-chain: the registry verifies the attestation signatures against its
registered key set, enforces the public-input ↔ credential binding
(pi[0..32] == nullifier, pi[32..64] == commitment), stores commitment,
nullifier, expiry, and the holder's own proof bytes, and gates every CEP-78
transfer through the recipient-aware filter (fail-safe deny). Groth16 runs
on-chain only in challenge.resolve.
There is no on-chain SNARK verification at onboarding, and we don't claim one.
A holder proves in zero-knowledge: the configured issuer signed my claim set
(accredited=1, jurisdiction ∈ allowed Merkle set, sanctioned=0) against my
identity commitment, and my nullifier/commitment are correctly formed from my
wallet-derived secret. Public inputs: [nullifier, commitment, issuerAx, issuerAy, assetId, allowedRoot]. The proof, its public inputs, the nullifier,
and the commitment are all stored on-chain (not "only the commitment").
Constraint system: 12,254 constraints, EdDSA-Poseidon issuer signature +
jurisdiction Merkle inclusion + Poseidon nullifier/commitment
(circuits/src/eligibility.circom).
Demo: one operator (this server) holds 2 of the 3 registered quorum keys — a 2-signature attestation from a single trust domain. On-chain reality: the registry accepts an attest only with ≥2 valid signatures from its registered, bonded signer set; unbonded/unknown/duplicate signers and sub-threshold signature sets are rejected (unit-tested). Economic backstop: any watcher can challenge any credential; the stored proof adjudicates.
challenge.resolve reads the credential's own stored proof and public inputs
(never caller-supplied) and calls the on-chain verifier. Proof invalid → signers'
bonds slashed (2 × 250 demo), challenger paid gas allowance + reward + bond
refund (640 CSPR), remainder (110 CSPR) transferred to the treasury account
— a spendable account, so we call it a treasury transfer, not a burn. Proof
valid → challenger's bond compensates the signers. Resolve is idempotent and
effects precede interactions (unit-tested). Because every credential stores the
holder's real proof (no placeholders), an honest holder's credential resolves
VALID under challenge — slashing an honest holder via a stored-proof mismatch is
not possible.
Odra packages (registry, challenge, verifier, both filters, token) are installed
locked (odra_cfg_is_upgradable: false) — no key can swap their logic; the
verifier's verifying key is compiled in. The CEP-78 NFT package is upgradable
by the installer key (standard CEP-78 install) — disclosed; enforcement logic
it calls is locked, and production would lock the package or move its access
URef to a multisig. Registry admin: deployer holds DEFAULT_ADMIN_ROLE +
QUORUM_ROLE at init; challenge/officer roles are granted by recorded txs;
renounce paths are implemented and unit-tested.
# clean clone
git clone https://github.com/winsznx/writ && cd writ
# contracts (Rust; each crate standalone)
(cd contracts/credential-registry && cargo test)
(cd contracts/challenge && cargo test)
(cd contracts/groth16-verifier && cargo test)
(cd contracts/integration && cargo test)
# CEP-78 fork E2E (pinned nightly toolchain; see fork/Makefile)
(cd contracts/writ-cep78/fork && make setup-test && cargo test -p tests --lib writ)
# frontend (typecheck, lint, tests incl. full in-node proving, build)
(cd frontend && npm install && npm run typecheck && npm run lint && npm test && npm run build)
# circuits deps FIRST — disclosure reuses circuits/commitment.js (circomlibjs)
(cd circuits && npm install) # full prove/verify: circuits/README.md "Reproduce"
# disclosure (requires the circuits install above)
(cd disclosure && npm install && npm test)
# verify every live-tx claim in this README against the public node (no keys)
./scripts/verify_live.shNo /Users/...-style machine paths remain (remaining /tmp/... defaults are documented conventions with env overrides); secrets exist only as env vars
(frontend/.env.example has placeholders only, and signing endpoints fail closed
without them).
Sections 1 above and scripts/deploy/DEPLOYMENT.md (canonical manifest: install txs, wiring txs, gas figures, treasury account, payable-call workaround).
- No in-circuit claim expiry (registry-level expiry is enforced on-chain; circuit
v3 planned — see
circuits/README.md). - Identity secret is derived from a deterministic wallet signature — any app that obtains a signature over the exact derivation message could recompute it (standard signature-derived-key caveat; production: issuer-held credentials).
- Canonical binding covers issuer key and jurisdiction root, not asset id.
V5 pins
issuerAx/issuerAy/allowedRooton-chain and rejects a mismatching attest; the asset is bound by the quorum-signed message and the registry's per-asset credential key rather than by a public-input comparison. The challenge path itself still only answers "is this proof valid for these stored inputs" — it is the pinning, not the challenge, that stops a forged issuer. set_canonical_inputsis admin-gated: the deployer key can re-pin the canon. Production would renounce admin after wiring (the renounce path is implemented and unit-tested) or hold it behind a multisig.- Bind nonces / rate limits are in-memory (single replica).
- The challenge "treasury" is a spendable account, not an unspendable sink — we call it a treasury transfer, never a burn. Verified live: +110 CSPR.
- Live self-onboarding depends on the demo signers holding bonds. A successful
fraud challenge slashes them (that is the mechanism working), and onboarding
stays blocked — the registry rejects
SignerNotBonded— until the operator re-bonds. Both signers are bonded now, and the app awaits on-chain execution and reports any such failure honestly instead of claiming "attested". - The attestation quorum is two signatures from one operator (single trust domain), and the claim issuer is a demo issuer — no external KYC provider is integrated. Both are stated wherever the demo is presented.
- The CEP-78 NFT package is upgradable by the installer key (the compliance logic it calls is locked); disclosed in §9.
| Layer | Technology |
|---|---|
| Smart contracts | Odra 2.8.x + patched CEP-78 fork (Apache-2.0, see NOTICE) |
| Wallet | CSPR.click |
| Chain reads | CSPR.cloud (server-side; roster curation is counted and disclosed in the UI) |
| ZK | Circom v2 + snarkjs (browser + server verify), arkworks (on-chain + agent verify), Groth16-BN254 |
| Frontend | Next.js 16 / React 19, TypeScript, Tailwind — Railway |
| Network | Casper testnet (casper-test, Casper 2.x) |
contracts/ six Casper contracts + the patched CEP-78 fork (fork = Apache-2.0)
agent/ N-verifier quorum + re-screen agent (CLI path; production shape)
circuits/ Circom eligibility circuit, dev ceremony, arkworks verifier crate
disclosure/ selective-disclosure suite (Poseidon commitment verification)
frontend/ Next.js app + API routes (bind / claims / onboard / registry)
scripts/ deploy scripts, verify_live.sh, officer multisig demo
docs/ PRD, frontend guide, final-round-hardening audit tracker
- ARCHITECTURE.md — system design, trust model, data flow
- ADVERSARIAL_TESTING.md — attack surface + economics
- scripts/deploy/DEPLOYMENT.md — live manifest
- docs/final-round-hardening.md — audit finding tracker
- LICENSES.md — per-directory licensing (MIT / Apache-2.0 / GPL artifacts)