DAY 005 / CRYPTOPAL
CryptoPal: two blind-token hops from wallet to email to wallet
How I turned a 2022 PlantUML sketch into a local Solana demo that moves one cUSD from wallet to email to wallet through two independent blinded-token hops.
01 / THE PRODUCT IDEA
What if a wallet could pay an email address without learning the next wallet?
CryptoPal turns one local cUSD deposit into a one-time email claim, then lets the recipient withdraw to a different wallet. Two fresh blinded bearer credentials keep the sender wallet, email address, and receiver wallet out of one cryptographic identity chain.
The easiest product analogy is “PayPal with crypto”: the sender addresses a person by email instead of asking for a chain address. That analogy explains the interaction, not the product maturity. This milestone is a fixed-value, local-only privacy demonstrator. It does not implement PayPal’s accounts, buyer protection, disputes, compliance, recovery, risk system, or operational guarantees.
This is useful as a protocol laboratory for gifts, reimbursements, rewards, or onboarding flows where the sender knows an email address but should not need the receiver’s wallet in advance. The privacy goal is narrower: remove the direct cryptographic join at each handoff. It is not to make public chain activity or service metadata disappear.
02 / WHAT SHIPPED
The noun “anychain” became a boundary; Solana became the first implementation.
The build uses a disposable Agave validator, one classic SPL Token mint called cUSD, six decimals, and one denomination of 1_000_000 base units. A React wallet UI carries the flow. Rust compiled to WebAssembly owns client-side blinding, proof verification, unblinding, and bearer authorization. A TypeScript processor coordinates TurboPass, PostgreSQL, SMTP, and a custodial Solana vault.
git submodule update --init --recursive
docker compose up --build
# Browser UI http://localhost:3000
# Processor API http://127.0.0.1:3001
# Mailpit inbox http://localhost:8025
# Solana JSON-RPC http://127.0.0.1:889903 / FROM THE 2022 SPEC
The original PlantUML already contained the important two-hop insight.
The 2022 repository was an API and sequence sketch, not a complete cryptographic specification. Its overview named two separate “zero knowledge decoupling” groups: sender wallet to receiver email, then receiver email to receiver wallet. That is the key idea I preserved. I normalized spelling, wording, and PlantUML delimiters below while retaining every step in the original flow.
@startuml Overview
actor Sender
entity Chain
collections Cryptopals
actor Receiver
Sender -> Cryptopals: request a deposit with receipts
Cryptopals -> Sender: return deposit ID and chain address
Sender -> Chain: transfer assets and include deposit ID
Cryptopals -> Chain: listen and mark transaction ready
Sender -> Cryptopals: check whether deposit is ready
group Zero-knowledge decoupling: sender wallet ↛ receiver email
Sender -> Cryptopals: send blinded tokens
Cryptopals -> Sender: return signed transfer tokens
Sender -> Cryptopals: transfer slips + receiver email
end
Cryptopals -> Cryptopals: increase receiver balance
Receiver -> Cryptopals: check available balance by email
group Zero-knowledge decoupling: receiver email ↛ receiver wallet
Receiver -> Cryptopals: send address-transfer envelopes
Cryptopals -> Receiver: return redemption coupons
Receiver -> Cryptopals: redeem coupons to receiver wallet
end
Cryptopals -> Chain: send tokens to receiver wallet
@endumlThe object sketch also named Key, Issuer, and Redemption. The runnable system sharpens those nouns into two domain-separated issuers, browser-held bearer material, and an authoritative spent-token set inside TurboPass.
- Receipt
- The sender-side blinded point presented before the deposit; implemented as a slip issuance request.
- Slip
- The unblinded, one-use bearer that authorizes one exact transfer ID and normalized-email hash.
- Envelope
- The receiver-side fresh blinded point; implemented as a coupon issuance request behind the claim capability.
- Coupon
- The unblinded, one-use bearer bound to chain, genesis, mint, denomination, and destination wallet.
One blind credential could not satisfy both arrows. Reusing the sender’s slip at payout would join email handling to the receiver wallet. The implementation therefore makes the “envelope” a new preparation and the coupon a new bearer under a separate issuer domain.
04 / ARCHITECTURE
Four PlantUML boxes became a complete, inspectable local system.
@startuml
[HTTP API] as api
[Processor] as processor
[Chain Vault] as vault
[ZKP microservice] as zkp
api --> processor : registers requests
processor --> vault : manages wallets
processor --> zkp : processes tokens
@endumlThe React application and Rust/Wasm module are not cosmetic frontend details; they are a security boundary. The processor may see a blinded point during issuance and a bearer preimage during redemption, but the browser keeps the seed and blind that connect those moments. PostgreSQL owns the product state machines. TurboPass owns issuance and the authoritative spent-preimage record, backed by PostgreSQL and DynamoDB Local. Mailpit makes the email handoff visible without contacting a real mailbox.
Temporal is intentionally absent from CryptoPal’s request path. The pinned TurboPass project can use Temporal for issuer-key rotation, but this demo creates bounded 30-day issuer windows and does not run a rotation worker. “Built with TurboPass” must not be misread as “every TurboPass operational component runs here.”
05 / THE INTERACTION
The interface makes an invisible protocol legible.
The sender screen presents one fixed transfer as four concrete steps: fund a burner wallet, lock one cUSD in the pool, prepare and verify the private slip, then deliver the email. It also puts “Local Solana” and “Demo only · not for real funds” in the primary frame. That wording matters because a polished wallet screen can otherwise imply economic safety the protocol does not provide.

- 01
Fund a disposable sender
The local faucet adds SOL for fees and local cUSD with no real-world value.
- 02
Prepare before depositing
The browser creates a fresh slip seed and sends only its blinded point.
- 03
Prove the exact deposit
The wallet submits one checked token transfer plus the processor’s memo.
- 04
Hand off by email
The verified slip authorizes one email-bound transfer and one stable claim URL.
06 / HOP ONE · WALLET → EMAIL
A Solana signature is accepted only after the processor reconstructs its meaning.
The browser first creates a deposit intent with one blinded slip point. The response pins a pool token account, mint, amount, and memo. The wallet then constructs atransferChecked instruction and a Memo instruction in the same transaction.
const transfer = createTransferCheckedInstruction(
source,
mint,
destination,
wallet,
BigInt(config.asset.denominationBaseUnits),
config.asset.decimals,
[],
TOKEN_PROGRAM_ID,
);
const memo = new TransactionInstruction({
keys: [],
programId: memoProgramId,
data: Buffer.from(new TextEncoder().encode(deposit.memo)),
});
const transaction = new Transaction({
blockhash: latest.blockhash,
feePayer: wallet,
lastValidBlockHeight: latest.lastValidBlockHeight,
}).add(transfer, memo);The processor does not trust the browser’s “confirmed” message. It loads the transaction from Solana and checks success, confirmation, exact memo, exactly one checked transfer, expected mint and decimals, sender signer and source account, destination pool account, exact amount, and token-balance deltas. A previously used chain signature or blinded-token hash cannot be assigned to another deposit.
Only then does TurboPass evaluate the blinded point under the slip issuer. The browser reconstructs its prepared point, requires the returned issuer key to equal the configured key, verifies the batch-DLEQ proof, and unblinds locally.
export async function verifyAndUnblind(
seed: Uint8Array,
expectedBlindedToken: string,
issuance: Issuance,
pinnedPublicKey: string,
): Promise<string> {
if (issuance.publicKey !== pinnedPublicKey) {
throw new Error("The issuer key changed unexpectedly.");
}
const prepared = prepareTokenBatch(seed, 1);
try {
if (prepared.blindedTokens[0] !== expectedBlindedToken) {
throw new Error("Saved preparation data does not match this issuance.");
}
return prepared.finalizeIssuance(
issuance.signedTokens,
issuance.publicKey,
issuance.batchProof,
pinnedPublicKey,
)[0];
} finally {
prepared.free();
}
}To send the value, the processor normalizes and hashes the email address and returns an exact payload. The browser HMAC-authorizes that payload with the slip bearer, so changing the transfer ID or email hash invalidates the authorization. TurboPass's separate spent-preimage record prevents a second redemption.
export function depositMemo(depositId: string): string {
return `cryptopal:deposit:v1:${depositId}`;
}
export function slipPayload(transferId: string, emailHash: string): string {
return `cryptopal:slip:v1:${transferId}:${emailHash}`;
}
export function couponPayload(
genesisHash: string,
mint: string,
amountBaseUnits: bigint,
wallet: string,
): string {
return `cryptopal:coupon:v1:solana-local:${genesisHash}:${mint}:${amountBaseUnits}:${wallet}`;
}07 / HOP TWO · EMAIL → WALLET
The claim hands over capability, then a fresh coupon breaks the second join.
After the slip is spent, the processor derives an unpredictable 256-bit capability from a server secret, transfer ID, and current chain genesis. The derivation is deterministic so an email retry reproduces the same URL without storing the raw capability; PostgreSQL stores only its SHA-256 hash.
private deterministicClaimSecret(transferId: string, genesisHash: string): string {
return createHmac("sha256", this.claimSecretKey)
.update("cryptopal:claim-secret:v1\0")
.update(genesisHash)
.update("\0")
.update(transferId)
.digest("base64url");
}
private claimUrl(secret: string): string {
return `${publicWebUrl}/#/claim/${encodeURIComponent(secret)}`;
}The secret appears after # in the single-page application URL. Browsers do not include a fragment in the initial HTTP request, so the web server receives the page request without the capability. The application still uses it afterward, and the mailbox, browser, extensions, clipboard, or client telemetry can expose it. Possession of that URL is the claim authentication model.
The recipient creates new preparation material—never the sender’s slip—and asks for a coupon under the coupon issuer. After verifying and unblinding, the recipient authorizes a payload containing the exact Solana genesis, mint, amount, and destination wallet. The public redemption request carries only the wallet, bearer preimage, and HMAC. It does not carry the email, claim ID, claim secret, or transfer ID.
const payload = couponPayload(
binding.vault.genesisHash,
binding.vault.mint,
denominationBaseUnits,
wallet,
);
let payout = await repository.createOrGetPayout({
id: uuid(),
tokenHash: sha256Hex(preimageBytes),
wallet,
couponPayload: payload,
});
await turboPass.redeem(
binding.couponIssuerName,
payload,
preimage,
signature,
);08 / WHAT “ZKP” MEANS HERE
The proof checks one relationship; blinding provides the unlinkability.
The original sketch called the token processor a ZKP microservice. More precisely, TurboPass returns a non-interactive batch discrete-log equality proof. The browser uses it to check that the issuer evaluated the blinded point consistently with the expected Ristretto public key. It does not prove the processor has funds, followed a business rule, sent an email, or paid a recipient.
The privacy property comes from the client choosing a secret and blinding scalar, sending only a blinded group element, verifying the result, and unblinding locally. At later redemption, the issuer sees the bearer preimage and redemption authenticator but cannot use the cryptographic transcript alone to match them to the earlier blinded point. Repeating that construction with fresh randomness creates the second boundary.
09 / THE BROWSER BOUNDARY
Private preparation data stays in the tab, but browser storage is not a vault.
The Rust crate wraps the same Ristretto primitives used by TurboPass and compiles them to WebAssembly. Its batch object keeps derived preimages and blinds in Wasm memory and zeroizes secret Rust values on drop. TypeScript receives blinded points, final bearer strings, and authorizations—but it also creates and temporarily persists the sensitive 32-byte deterministic preparation seed. The page stores that state in tab-scoped sessionStorage so it can survive navigation through the flow.
The application compares the current local chain genesis before restoring a session. A validator reset invalidates old preparations, claim capabilities, issuer names, and payout payloads rather than letting credentials minted against one pool incarnation spend from a new one.
10 / STATE + IDEMPOTENCY
One-use money movement needs convergence at every retry boundary.
Three explicit state machines separate chain observation, email availability, coupon issuance, and payout submission. PostgreSQL row locks and unique hashes choose one owner for each transition. TurboPass remains the authoritative one-use gate for bearer preimages.
INSERT INTO cryptopal_payouts
(id, token_hash, wallet, coupon_payload, status)
VALUES ($1, $2, $3, $4, 'REDEEMING')
ON CONFLICT (token_hash) DO NOTHING;
SELECT ...
FROM cryptopal_payouts
WHERE token_hash = $1
FOR UPDATE;
-- A replay may reuse the exact same payout only.
if (row.wallet !== input.wallet || row.coupon_payload !== input.couponPayload) {
throw conflict("COUPON_ALREADY_REDEEMED", "coupon is bound to another payout");
}The payout path prepares and stores serialized, signed Solana transaction bytes before submission. A retry rebroadcasts the same bytes and signature. If the blockhash expires, a row-locked comparison replaces only the still-current prepared transaction. Once the chain confirms, later equivalent requests return the recorded signature; a changed wallet conflicts.
Email delivery is intentionally at-least-once. A retry may put another copy of the same message into Mailpit, but deterministic capability derivation makes the claim URL stable. Production still needs an outbox and delivery reconciliation because stable content does not make an SMTP side effect transactional with PostgreSQL.
11 / THE “ANYCHAIN” SEAM
The adapter is real; multi-chain support is future work.
The processor depends on a chain-vault interface for bootstrapping, health, faucet funds, independent deposit verification, payout preparation, and prepared-transaction submission. That is the right starting seam for another chain. It is not proof that another chain can be added by changing a URL.
export interface SolanaVaultService {
readonly memoProgramId: string;
bootstrap(): Promise<VaultBootstrap>;
currentGenesisHash(): Promise<string>;
health(): Promise<void>;
faucet(wallet: string): Promise<FaucetResult>;
verifyDeposit(input: {
signature: string;
expectedMemo: string;
expectedMint: string;
expectedPoolTokenAccount: string;
expectedAmountBaseUnits: bigint;
expectedDecimals: number;
}): Promise<DepositVerification>;
preparePayout(wallet: string): Promise<PreparedPayout>;
submitPreparedPayout(
prepared: PreparedPayout,
): Promise<"CONFIRMED" | "PENDING" | "EXPIRED">;
}A second implementation would need its own finality rule, asset and token-account policy, canonical address encoding, exact deposit interpretation, fee model, transaction replacement behavior, and recovery model. Chain identity, asset, denomination, protocol version, and hop must also enter distinct issuer names and payload domains so a pass from one network cannot be replayed on another.
12 / WHAT BROKE WHILE BUILDING
The difficult bugs sat at tool and trust boundaries.
Wasm was both generated and accidentally ignored
An initial wasm-pack attempt failed and a later attempt succeeded. Separately, a blanket ignore rule hid the generated browser package. The repository now explicitly allowlists the checked-in Wasm output so Docker and normal frontend builds do not require Rust.
Strict Rust checks exposed cleanup work
Clippy initially failed. The warnings were fixed instead of muted, then format, locked tests, and strict Clippy passed. The generated Wasm was ultimately verified against the Rust API.
A proof is meaningless against an untrusted key
The client originally needed a stronger expected-key check. The final flow compares the issuance key with configuration, reconstructs the original blinded point, then verifies before unblinding. The independent-manifest limitation remains explicit.
Interoperability language was too broad
Documentation was corrected to describe RFC 9497 components plus a custom TurboPass transcript—not a standards-compatible VOPRF or a generic zero-knowledge system.
Local orchestration still behaves like a system
Persistent volumes had to be reset while iterating, and port 3000 was already in use during the final walkthrough, so the browser run moved to port 3300. An initial automation step timed out; after inspecting the current page state, the walkthrough continued without weakening the protocol assertions.
13 / VERIFIED REPOSITORY RESULT
The pinned public commit builds and its committed checks pass.
$ npm --workspace apps/api test
# 3 files · 14 tests passed
$ npm --workspace apps/web test
# 2 files · 13 tests passed
$ cargo test --manifest-path crates/client-crypto/Cargo.toml --locked
# 6 tests passed
$ npm run check && npm run build
# both TypeScript workspaces passed; production build completedI independently cloned public commit e41f723. The API suite passed 14 tests in three files, the browser-helper suite passed 13 tests in two files, and the Rust/Wasm crate passed six tests: 33 non-load tests with zero failures. Both TypeScript workspaces typechecked and the production web build completed.
The build produced a 161.80 kB Wasm asset (61.25 kB gzip) and a 737.17 kB main JavaScript bundle (233.31 kB gzip). Vite’s over-500 kB warning is a real performance follow-up: the wallet and Solana dependency tree should be split or modernized before treating this UI as a production frontend.
14 / LOAD + REPLAY TESTING
The load harness exercises journeys and races, not a headline TPS number.
The repository commits three guarded Artillery profile files exposed through four run commands. Every target must be loopback; redirects, proxies, a non-loopback processor target, or a non-loopback Solana RPC returned by /config stop the run. Stateful users create independent wallets, obtain faucet funds, touch the real local validator, issue and redeem through TurboPass, send SMTP to Mailpit, use the browser-compatible Wasm, and verify the receiver balance on-chain.
npm run load:smoke # 20 read-only virtual users
npm run load:protocol:solo # one full journey for debugging
npm run load:protocol # 10 independent full journeys
npm run load:idempotency # concurrent replay assertions| Recorded profile | Observed result | What it establishes |
|---|---|---|
| Read smoke | 20 users; 40/40 requests; zero failures; aggregate p95 and p99 about 7 ms. | Local health/config shape and a quick dependency-read latency regression. |
| Full protocol | 10/10 independent users; 90/90 HTTP requests; zero failed users; aggregate p95 about 1.2 s and p99 about 1.3 s; final balances verified. | A conservative local end-to-end regression through chain, crypto, SMTP, and stores. |
| Idempotency | A four-way deposit reservation produced one HTTP 201 and three expected 409s; four post-success replays at each remaining critical boundary returned identical stable results. | Documented duplicate behavior after success plus one real reservation race. |
15 / CURRENT TRUTH
Private bearer handoffs do not make the whole payment private or production-ready.
| Boundary | What exists | What must not be inferred |
|---|---|---|
| Custody | The processor controls the pool and payout key. | No proof of reserves, solvency, escrow, or willingness to pay. |
| On-chain privacy | Two blind-token hops remove adjacent cryptographic joins. | Solana still publishes sender → pool and pool → receiver transfers; amount and timing can correlate them. |
| Metadata | The bearer payload omits the other side of each hop. | Processor, SMTP, reverse proxy, IP logs, and browser telemetry may correlate activity. |
| Issuer trust | The browser verifies a batch-DLEQ proof against the configured key. | That expected key comes from the same processor; no independent global key manifest prevents per-client tagging. |
| Value model | One pass authorizes exactly 1 cUSD. | No arbitrary amount, change, denomination hiding, refund, dispute, or recovery workflow. |
| Network scope | The chain boundary is isolated behind an interface and issuer domain. | Only a resettable local Solana/Agave ledger and classic SPL token are implemented. |
| Operational safety | State machines, row locks, unique constraints, and stable signed bytes make common retries converge. | The email and chain submission paths still need production outboxes, reconciliation, rate limits, and abuse controls. |
| Cryptographic assurance | The implementation reuses TurboPass and the pinned Ristretto crate. | It is unaudited demonstrator code, not a standardized Privacy Pass deployment or production security claim. |
The single-user demonstration is especially easy to correlate by amount and timing. Two cryptographic blind spots can coexist with obvious operational linkage. A production privacy design would need batching, randomized delay, relays, separated logs and duties, telemetry controls, and potentially an on-chain shielded or escrow construction—depending on the actual threat model.
16 / FILE GUIDE
Where to follow the implementation.
START WITH THE CONTRACT
Protocol and safety
FOLLOW THE REQUEST PATH
Processor and Solana
FOLLOW THE PRIVATE MATERIAL
Browser and Rust/Wasm
TRACE THE LINEAGE
Spec and token service
17 / WHAT IS NEXT
Move from a privacy demonstration to a threat-modelled payment system.
The best next milestone is not another chain logo. It is an independently authenticated issuer manifest, durable side-effect workers, custody and reconciliation controls, and retained end-to-end evidence. Once those contracts exist, a second chain adapter can test whether “anychain” is genuinely architectural rather than aspirational.
18 / EVIDENCE LEDGER
Three immutable source pins and one clearly labelled session record.
- CryptoPal implementation at e41f723 — the runnable local application, protocol, tests, and load harness.
- Original CryptoPal spec at de7c055 — the 2022 PlantUML and API sketches.
- TurboPass at f18da56 — the exact submodule commit providing issuance and spent-token enforcement.
- The owner-supplied demo screenshot documents the interface. The private implementation task documents the browser walkthrough and local load observations; because it contains no committed machine-readable report, this note labels those measurements as session evidence rather than repository verification.