A passkey-secured smart wallet for Stellar with two real privacy rails — confidential transfers and a shielded pool — where the pool's idle liquidity earns yield in DeFindex. No seed phrase, no browser extension, real zero-knowledge proofs generated client-side.
Try it live (testnet): https://privacywallet.coderipper.xyz/
Built for the Stellar Privacy hackathon on top of OpenZeppelin's
smart-account-kit and Nethermind's stellar-private-payments — and where
those stopped short, we forked: a modified SPP SDK so the smart account
itself is the shielded signer, a modified pool contract that invests idle
deposits into a DeFindex vault (the yield funds the
service), and our own indexer + bootnode, currently the only live SPP
bootnode serving this pool's history anywhere.
- Passkey smart account. Onboard with Face ID / fingerprint — a
WebAuthn-secured Soroban smart account (
C…address) with fee-sponsored submission through a relayer. Nothing to back up for day-to-day use. - Confidential Transfers (CT). Register a shielded balance, deposit,
send, and withdraw with amounts encrypted on-chain. UltraHonk proofs via
@aztec/bb.js, proven entirely in the browser; the wallet decrypts its own activity. - Shielded pool (SPP). Shield XLM into a pool, send privately between participants, unshield back out. Only the shield/unshield boundary events are public — everything inside the pool, including who sent what to whom, stays hidden.
- Smart account as the shielded signer (our SDK fork). Stock SPP SDK
only supports classic ed25519
G…accounts, forcing a throwaway "session account" funded by friendbot. We forked it so the wallet's ownC…address shields, receives, and unshields directly — passkey-authorized, friendbot-free. Details below. - Yield-bearing shielded pool (our contract fork). Deposits sitting idle in the pool batch-invest into a DeFindex vault. A balance-sheet invariant guarantees the admin can only ever skim the surplus above what's owed to depositors — that surplus is the service fee. Live pool stats (liabilities, idle, invested, accrued yield) are shown right in the app's Shielded tab.
- Our own indexer + bootnode. The pool's history predates the public
RPC's retention window and Nethermind's public bootnode rejects every
request (
-32602, verified live) — so without our infrastructure the pool cannot be synced at all. We recovered the full history from stellar.expert's archive and serve it, kept current by our own indexer, over the standard bootnode protocol. Other builders can use it — see Bootnode usage. - A real compliance story. CT ciphertexts carry an auditor-decryptable component (ops-held key, demonstrable without either party's cooperation), and SPP participation is gated by the pool's Association Set Provider. See Compliance.
| Service | URL |
|---|---|
| Wallet app | https://privacywallet.coderipper.xyz/ |
| API (REST + bootnode) | https://api-production-35416.up.railway.app |
GET /health on the API returns the indexer's current synced ledger.
flowchart TB
subgraph Browser["Browser — app/ (React 19 SPA)"]
UI["Wallet UI<br/>(Onboarding · Wallet · Shielded · Activity tabs)"]
Passkey["WebAuthn passkey<br/>(smart-account-kit)"]
CT["CT rail<br/>UltraHonk proving (bb.js, in-browser)"]
SPP["SPP rail<br/>Groth16 proving (SDK's own worker)"]
end
subgraph Chain["Stellar Testnet"]
SA["Smart account contract (C…)"]
RPC["Soroban RPC"]
CTC["CT contracts<br/>token / verifier / auditor"]
SPPC["SPP contracts<br/>pool (our yield fork) / poolEurc / aspMembership / registry"]
Vault["DeFindex vault"]
end
Relayer["SDF Channels relayer proxy<br/>(fee-sponsored submission)"]
subgraph Backend["Privacy Wallet backend (Railway)"]
API["api — REST activity + JSON-RPC bootnode (POST /rpc)"]
Worker["worker — indexer, polls RPC on an interval"]
DB[("Postgres<br/>events · ct_activity · cursors")]
end
Passkey --> SA
UI -- sign & submit --> Relayer --> RPC
CT --> CTC
SPP --> SPPC
SPPC -- invests idle liquidity --> Vault
RPC --- SA
RPC --- CTC
RPC --- SPPC
UI -- "GET /accounts/:addr/activity" --> API
SPP -- "bootnodeUrl: API's /rpc (pool history)" --> API
API --> DB
Worker -- polls --> RPC
Worker --> DB
OtherBuilder["Other SPP builders"] -. "POST /rpc" .-> API
Everything below exists because the off-the-shelf stack couldn't do what the product needed. Each fork is small, auditable, and documented.
The stock SPP SDK is ed25519-native end to end: it parses the user address
as an ed25519 key, signs auth entries as ed25519 signature maps, needs a
classic G… source account with a sequence number, and derives the privacy
keys from an ed25519 signature. A passkey (WebAuthn/secp256r1) can satisfy
none of that — so every SPP wallet built on the stock SDK needs a separate
"session account", created and funded by friendbot on testnet.
But the contracts don't care: the pool's transact accepts any
sender: Address with require_auth(), and withdrawals pay out to any
address. The limitation was purely client-side. So we forked the SDK
(vendored at vendor/stellar-private-payments; the change is a single
116-line commit on top of the pristine upstream tree, adding a
txSource/identity split and an executeTransaction signer seam), and now:
- Shield pulls straight from the smart account's public XLM, authorized by the same passkey-signed Soroban auth entries the CT rail already uses.
- Unshield pays straight back to the smart account.
- Shielded recipients are addressed by wallet
C…address. - Friendbot is out of the flow entirely; privacy keys derive from deterministic bytes in the wallet's encrypted backup.
Verified live on testnet (wallet CCLBXDQJ…MGTPT3): register
d917470b…,
shield 352efbca…,
unshield e5e2a26a…,
shielded send b443e940…
— public balance 10,000 → 9,997 XLM exactly, pool balance 3 XLM across 2
notes. For teams on the stock SDK, the no-fork mainnet path (CAP-33
sponsored reserves instead of friendbot) is written up in
docs/modules/app.md.
Idle liquidity in a privacy pool earns nothing. Our pool fork
(contracts/pool-yield/, deep dive: docs/modules/pool-yield.md) changes
that: once idle deposits cross a threshold (1,000 XLM), they batch-invest
into a DeFindex vault, keeping a liquidity buffer (200 XLM) for
withdrawals. Withdrawals that exceed idle liquidity divest from the vault
in the same transaction. The ZK surface is byte-identical to upstream —
no circuit, proof, or event changes.
The balance-sheet invariant. The contract tracks a liability counter —
the sum of every deposit minus every withdrawal, i.e. exactly what it owes
note holders. Surplus is (idle + vault position) − liabilities, clamped
at zero, and that is the only number collect_yield can pay out. This is
arithmetic, not policy: note-backed funds are structurally excluded from
ever being skimmed, no matter how or how often the admin calls it. That
surplus is the service fee funding continued development.
Verified end to end on testnet:
- Shield 600 XLM ×2 — the second crosses the threshold and triggers a
batched 1,000 XLM invest into DeFindex:
544f08b8…,6ecb31c8… - Unshield 900 XLM, divesting from the vault in the same transaction:
959ad5e5… - Harvest the vault's strategy, then
collect_yieldpays the surplus (17,131 stroops — the demo ran minutes after deployment; the mechanism, not the amount, is the point) to the admin, verified via transfer event:ef1246eb…,8b24ecf3…
The app's Shielded tab shows the pool's public figures live — owed to depositors, accrued yield, idle liquidity, amount earning in DeFindex — read straight from chain via simulation.
Both rails hit the same wall: event history older than the public RPC's retention window is simply gone from public infrastructure.
- SPP: the SDK falls back to a bootnode for historical sync, but
Nethermind's public bootnode rejects every request with
-32602 unsupported filters(verified live — seedocs/modules/api.md). Without a working bootnode an SPP client cannot sync the pool at all. We recovered the pool's full history from stellar.expert's public archive (api/scripts/backfill-spp.ts, 621 events across 5 contracts), keep it current with our own indexer worker, and serve it over the standard bootnode protocol atPOST /rpc. - CT: our REST API (
GET /accounts/:address/activity) serves normalized confidential-token activity from Postgres, so the wallet's activity feed works past the RPC retention window too.
- Batching is not extra privacy. The invest step pools deposits into one vault call for gas and liquidity management. Every SPP deposit's amount is already public in its transaction; batching the investment timing reveals nothing new — and hides nothing new. The privacy comes from the shielded set, as designed.
- No per-user yield, on purpose. A note's value is sealed inside a commitment — the contract cannot know whose principal a slice of surplus "belongs" to, so yield is a pool-level aggregate collected as one lump sum. Real per-user attribution needs new circuits (e.g. fixed-denomination notes, discussed in design review, not built). Known gap, not a bug.
- The legacy pool stays live. Nethermind's original (non-yield) pool
remains indexed at
CAWCZ6EO…BX6XIpurely so pre-fork notes stay visible and spendable; new deposits go to the yield fork. - Testnet artifacts. Deposits are capped at 1,000 XLM, and the DeFindex
vault runs a fixed-APR demo strategy whose yield realizes on a
permissionless
harvest()call.
Confidential Token suite (contracts/deployments/testnet.json, deployed at
ledger 3,900,251):
| Contract | Address |
|---|---|
| Token | CBTEJFLW25UXIDAIWJ3KUJGI5CE2YLHM5GQM2VFU7JQZS53HE3HKGCLH |
| Verifier | CBDQQ75BKSAO2TG4D37PKVJZ64F4Y3AHKTSY3NXV6DPEACSDWO4TBGQH |
| Auditor | CB27W7M4PLVGC77X5LPNZEOX5UCUWYJ3CODSBR6JR2WJEO66E4BGBDKA |
| Underlying asset (native XLM SAC) | CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC |
Selective Privacy Pool (packages/shared/src/config.ts's TESTNET.spp; the
ASP membership, registry, and EURC pool are a shared testnet deployment we
don't control — the native-XLM pool is our own yield-bearing fork,
deployed at ledger 3,968,245, full record in
contracts/deployments/pool-yield-testnet.json):
| Contract | Address |
|---|---|
| Pool (native XLM, our yield fork) | CC3AVJZR5MSOLLNNP7DYSG3KR7MTBE4N4VMAT5ZX4NWIJTQL75RNI3F5 |
| Pool (native XLM, legacy — pre-fork notes only) | CAWCZ6EO4PM5EZOH5K7XSW3R46DGLOT3XSEH36OA5EOZUSJ5XS7BX6XI |
| Pool (EURC) | CAJJT5YV4BMFTHEOO5FGO2G56TEJKM4G4FW7FS4DYBLLLLHSAYUZWT74 |
| Public key registry | CDK75EQA2G4EDN34CWY7ALJ4EIQMNVBOFMHAVIF3BBY7IUDNHKHNDA36 |
| ASP membership | CDEFDJPNVWDWUUHGHGGZ56FEPSSJHQLGRKS6OWIRKGRYRWSBNMLW7J5K |
| DeFindex vault (idle liquidity invests here) | CAGNH456FTTMWEL26K7CGNVQABPB3SA5AV2YXU4R3XKUODEVU65ZN7Q7 |
If you're building an SPP client against this same testnet pool deployment and need historical event sync, point your client at our bootnode instead of standing up your own indexer:
https://api-production-35416.up.railway.app/rpc
It speaks the same JSON-RPC 2.0 bootnode protocol the SPP SDK expects
(getLatestLedger, getEvents) — with the official
stellar-private-payments JS SDK, pass it as bootnodeUrl:
import { Client } from "stellar-private-payments";
const client = await Client.new({
rpcUrl: "https://soroban-testnet.stellar.org",
storage /* ... */,
proverWorkerUrl /* ... */,
bootnodeUrl: "https://api-production-35416.up.railway.app/rpc",
});Constraints, so you don't hit a confusing error:
getEventsonly accepts one exact filter set — a singlecontractfilter withtopics: [["**"]]andcontractIdsset-equal (order doesn't matter) to this deployment's full 5-contract sync set:CC3AVJZR5MSOLLNNP7DYSG3KR7MTBE4N4VMAT5ZX4NWIJTQL75RNI3F5(pool, yield fork),CAWCZ6EO4PM5EZOH5K7XSW3R46DGLOT3XSEH36OA5EOZUSJ5XS7BX6XI(legacy pool),CAJJT5YV4BMFTHEOO5FGO2G56TEJKM4G4FW7FS4DYBLLLLHSAYUZWT74(poolEurc),CDEFDJPNVWDWUUHGHGGZ56FEPSSJHQLGRKS6OWIRKGRYRWSBNMLW7J5K(aspMembership),CDK75EQA2G4EDN34CWY7ALJ4EIQMNVBOFMHAVIF3BBY7IUDNHKHNDA36(publicKeyRegistry). Any other filter set returns-32602 unsupported filters— this is not a general-purpose bootnode; it serves exactly this pool deployment's contract set (verified live as of 2026-08-04: the 5-contract set returns events, anything else is rejected).- Provide exactly one of
startLedgerorpagination.cursor, never both. - Once you page past our indexed tip,
getEventsreturns-32002(a retention-handoff response withfromLedger) — that's the SDK's own signal to resume on the main RPC, not an error to work around.
Full protocol details (validation order, error codes, where our semantics
deliberately diverge from the reference bootnode): docs/modules/api.md.
The CT contracts are deployed with an on-chain auditor public key; every
confidential transfer's ciphertext includes an auditor-decryptable component
alongside the sender/recipient one. The auditor's private key is held
ops-side (never committed), and @ctd/sdk's src/auditor/ module can
decrypt any transfer's amount with it, independent of either party's
cooperation — script-demonstrable via packages/ctd-sdk/test/auditor.mjs.
On the SPP side, pool participants must be approved by the Association Set Provider before shielded transactions go through — an off-chain compliance gate the wallet surfaces when it applies. Both rails pair privacy with a real compliance story, not privacy at the expense of one.
Requirements: Node ≥ 22, pnpm ≥ 10, Docker (for local Postgres).
pnpm install
# 1. Database
docker compose up -d postgres # postgres:16-alpine on localhost:5433
cp .env.example .env # DATABASE_URL defaults match docker-compose.yml
pnpm --filter @privacy-wallet/api run db:migrate # schema
pnpm --filter @privacy-wallet/api run backfill:spp:load # loads the pool's recovered history (621 events) — REQUIRED
# 2. API + worker
pnpm --filter @privacy-wallet/api dev # REST + bootnode server, http://localhost:3000
pnpm --filter @privacy-wallet/api worker # indexer worker (separate process, same DB)
# 3. App
cp app/.env.example app/.env # VITE_API_URL defaults to http://localhost:3000
pnpm --filter app dev # http://localhost:5173The backfill step is required — without it the SPP rail has no history to sync from. Both DB commands are idempotent. The app needs the API running (activity feed + the Shielded tab's pool-history source), so start it after step 2.
pnpm build # builds packages/shared, packages/ctd-sdk, api, app
pnpm test # runs every workspace's test suiteapp/ # React 19 + Vite wallet SPA — passkey onboarding, CT rail, SPP rail, Activity view
api/ # @privacy-wallet/api — Postgres-backed indexer worker + REST/bootnode HTTP server
packages/shared/ # @privacy-wallet/shared — TESTNET network/contract config, shared CT invoke glue
packages/ctd-sdk/ # @ctd/sdk — vendored Confidential Token client SDK (witness/proving/chain/state)
contracts/ # pool-yield contract fork + CT deployment records + deploy procedures
vendor/ # stellar-private-payments — vendored SPP SDK with our C-address signer fork
railway/ # Config-as-code for the three Railway services (api/worker/app)
docs/modules/ # Living per-module documentation (read before touching a module)
scripts/ # Root-level scripts (smoke-ct.ts — smart-account/CT lifecycle smoke test)
app: 143 tests, plustypecheckandbuild, all green.api: 173 tests (needsDATABASE_URLagainst a running Postgres for the full count — DB-integration cases auto-skip otherwise),buildgreen.packages/shared: 7 tests,buildgreen.contracts/pool-yield: 37 unit tests (cargo test) covering the invest / divest / surplus invariants, authorization failures, and error paths.packages/ctd-sdk:parity.mjs(7 cases) andprove.mjs(3 real UltraHonk proofs) pass; the vendored suite'sdisclosure.mjsfails on a pre-existing upstream gap (a sibling@ctd/disclosurepackage was never vendored) — documented indocs/modules/ctd-sdk.md, unrelated to our changes.
See docs/modules/README.md for the per-module documentation index — every
module has a living doc with file maps, file:line references, and the
gotchas that took real debugging time to learn.
packages/ctd-sdk/(@ctd/sdk) is vendored frombrozorec/stellar-confidential-token-demo(commitac67499a617c084b80c0e0298180b2c4faf9e2fb,packages/sdk), MIT licensed. Provenance and local modifications:packages/ctd-sdk/ATTRIBUTION.md.vendor/stellar-private-paymentsis vendored from NethermindEth/stellar-private-payments; our C-address signer fork is the single commit on top of the pristine upstream tree.contracts/pool-yield/is our fork of its pool contract.- Passkey smart accounts via
smart-account-kit(OpenZeppelin), with fee-sponsored submission through SDF's public Channels relayer proxy.
No license file is published in this repository — this is a hackathon submission, not a released package. Vendored code retains its upstream licenses (see the attribution links above).