countersign-zcash
v0.1.0
Published
Zcash for web apps: a view-only server builds and proves, the browser signs only what the user approved
Maintainers
Readme
Countersign
Zcash payments for web apps without proving in the browser and without sending keys to a server. A view-only server builds and proves the transaction as a PCZT; the user's device checks it against what the user approved and signs only if it matches.
What each party learns
The service holds the account's UFVK (unified full viewing key), so it sees the account's full history, balances and amounts. It never sees a spending key or the seed. Anyone who wants to keep that history to themselves runs the service themselves.
The signer (the browser tab) holds the seed and sees only the PCZT it is asked to sign.
A chain observer sees a shielded Orchard or Ironwood transaction with the standard 40-block expiry and a payment amount hidden in the pool, the same fields any shielded wallet exposes.
The service also sees the IP address of every client that calls it, and the lightwalletd it syncs from sees the service's IP. Self-hosting removes the first; a lightwalletd you run removes the second.
A recipient reads the memo the user approved and nothing else: the signer refuses any memo the user did not approve, and refuses padding outputs that carry one.
A transparent exit is opt-in. For the one payment that must cross into the transparent pool, such as the one-time deposit address of a swap exit, the intent sets
allowTransparent. That hop reveals the amount and the transparent address on chain to everyone. The signer still refuses a second transparent output, a transparent input, a changed amount or address, a memo, and any change that is not a shielded output to your own key.
SDK: countersign-zcash
packages/sdk is a TypeScript package around the Rust signer in crates/countersign-wasm, compiled
to WebAssembly with wasm-pack (target web). The policy check and the RedPallas spend-auth signing
run on the device, in the same code as the native Rust signer: over the same PCZT and the same
randomness the wasm output equals the native output byte for byte.
Signing
import { init, Signer } from "countersign-zcash";
await init(); // fetches the .wasm file next to the module
const signer = new Signer({ seed, network: "test" }); // seed: 32-byte Uint8Array
const result = signer.approveAndSign(pczt, {
recipient: "utest1...", // unified address; its Orchard receiver must be paid
amount: 400_000n, // zatoshis, exact
maxFee: 20_000n, // zatoshis, upper bound
memo: "rent", // optional; omit it and the payment must carry the empty memo
});
if (result.ok) {
result.signatures; // [{ pool: "ironwood", actionIndex: 0, signature: Uint8Array(64) }]
result.records; // the same, as 69-byte wire records for the service
result.elapsedMs; // policy check plus signing time on this device
} else {
result.refusal.reason; // e.g. { kind: "WrongAmount", approved: 400000n, found: 500000n }
result.refusal.message; // "the recipient gets 500000 zatoshis, you approved 400000"
}approveAndSign(pczt, intent, key) is also exported as a plain function. In Node or a worker,
initSync(bytes) loads the module from the .wasm bytes.
A policy refusal is a value, not an exception: { ok: false, refusal }, where refusal is a
PolicyRefusal (an Error subclass) carrying a typed reason. Malformed arguments, such as a
seed that is not 32 bytes or an address without an Orchard receiver, throw.
The signer signs only when every output of the PCZT is either the approved payment or change to the signer's own key. Each refusal reason:
| reason.kind | The PCZT |
|---|---|
| UnapprovedOutput | pays an address that is neither the recipient nor the signer's own |
| WrongAmount | pays the recipient an amount other than the approved one |
| FeeTooHigh | leaves a fee above maxFee |
| UnownedSpend | spends a note that the signer's key does not own |
| WrongMemo | carries a memo other than the approved one |
| Unverifiable | has a committed field that cannot be recomputed from its parts, or does not parse |
| ForbiddenPool | touches the transparent or Sapling pool |
| DuplicatePayment | pays the recipient more than once |
| NoPayment | does not pay the recipient |
| NegativeFee | has outputs exceeding inputs |
| NothingToSign | has no spend left for this key to sign |
Service client
connect(baseUrl) returns a CountersignClient with typed fetch calls. Binary fields travel as base64,
the memo as hex.
import { connect, deriveAddress, deriveUfvk, Signer } from "countersign-zcash";
const service = connect("https://prover.example.org");
const ufvk = deriveUfvk(seed, "test"); // derived on the device; the seed never leaves it
const myAddress = deriveAddress(seed, "test"); // Orchard-only UA: where to fund, and a refund address
const { account, address } = await service.registerViewKey(ufvk, birthdayHeight); // POST /v1/accounts
await service.sync(account); // POST /v1/accounts/{id}/sync
const proposal = await service.propose({ account, ...intent }); // POST /v1/accounts/{id}/propose
const result = new Signer({ seed, network: "test" }).approveAndSign(proposal.pczt, intent);
if (result.ok) {
const { txid } = await service.finish(proposal.session, result); // POST /v1/sessions/{id}/finish
const status = await service.txStatus(txid); // GET /v1/tx/{txid}
}| Call | Request | Response |
|---|---|---|
| POST /v1/accounts | { ufvk, birthday_height? } | { account, address } |
| POST /v1/accounts/{id}/sync | | { total_zat, spendable_zat, fully_scanned_height, chain_tip } |
| POST /v1/accounts/{id}/propose | { recipient, amount_zat, memo_hex? } | { session, signer_pczt, fee_zat, proposed_ms } |
| POST /v1/sessions/{id}/finish | { signatures: [base64 record] } | { txid, proved_ms } |
| GET /v1/tx/{txid} | | { txid, mined, height } |
A non-2xx response throws a ServiceError with status and body. The maximum fee never leaves the
device: it is part of the intent the signer checks, not of the propose request.
Build and test
Needs Rust with the wasm32-unknown-unknown target, wasm-pack, Node 22, and an LLVM clang with the
wasm32 backend for secp256k1-sys (macOS: brew install llvm; Linux: the distro clang and
llvm-ar).
cd packages/sdk
npm install
npm run build:wasm # wasm-pack build --target web into src/wasm
npm test # vitest: honest PCZT signs, 8 tampered PCZTs refused, HTTP client
npm run build # dist/ with the .wasm file alongsideThe tests load the real wasm module and the PCZT fixtures in
crates/countersign-core/tests/fixtures, which cargo test -p countersign-core regenerates
deterministically. crates/countersign-wasm/a3.sh builds both wasm-pack targets (web and
nodejs) and checks each against the native signature.
Demo page
demo/ is a static Vite page that loads the wasm signer, takes a PCZT (one of the fixtures, pasted
hex or base64, or a file) and an intent, and shows "Signed on this device in Y ms" or the refusal
reason. The fixtures are bundled into the page, so that part makes no requests beyond its own files.
The second half of the page runs the whole payment against a service you point it at: it generates a seed
in the tab, derives the viewing key and funding address, registers, syncs, asks the service to build the
payment, checks and signs it on the device, has the service prove and broadcast, and waits for the block.
On testnet this page, a local service and a fresh seed produced a mined transaction
(6ebe174d203e49f5e582e96b821d1f5636b19ee8503d504d32fab3fe2b2b305f at height 4,436,025): built in 110 ms,
signed in the tab in 846 ms, proved on the server in 3.5 s.
cd demo
npm install
npm run build # static dist/: index.html, one JS file, one CSS file, the .wasm file