npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 alongside

The 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