@winbit32/wallet-kit
v0.3.0
Published
Embeddable Zcash + Monero wallet primitives extracted from Winbit32: scanner API clients, the WB32COSIGN FROST/Orchard cosign client with a headless initiator pipeline, a seed-phrase wallet with injected local signing, and an embeddable wallet bar UI. Fra
Readme
@winbit32/wallet-kit
Embeddable Zcash + Monero wallet primitives extracted from Winbit32. The goal: anyone who wants to add a Zcash/Monero wallet (scanning, shielded sends, FROST co-signing) to their site installs this package and calls functions — no Winbit32 UI required.
Status / roadmap
Extraction happens in slices (each slice moves source here and leaves a re-export shim at the old Winbit32 path, so the app keeps working):
| Slice | Contents | Status |
| --- | --- | --- |
| Scanner clients | Zcash Orchard scan-job/UTXO/broadcast client, Monero LWS scan-job client, base-URL resolution | Done |
| Cosign client | WB32COSIGN relay transports, .wult share handling, FROST/Orchard ceremony, headless initiator pipeline | Done |
| MCP payment tools | make_payment tools ship in winbit32MCP (the public payments-gateway home), which consumes this kit | Done (lives in winbit32MCP) |
| MCP tool descriptors | Framework-free view-key tool descriptors (createWalletKitToolDescriptors): zec/xmr scan jobs, UTXOs, broadcast — mount on any MCP server | Done (v0.2.0) |
| x402 rail | Signer-agnostic vault x402 payer + probe + createVaultAccount (EIP-3009 digest seam); headless apart from the injected co-sign | Done (v0.2.0) |
| NFPT commerce | Signer-agnostic secresea/NFPT buy orchestration (buyNfpt: prepare → cap → injected sign → record); the one backend-specific module, apiBase injectable | Done (v0.2.0) |
| Phrase wallet | PhraseWalletService: connect with a BIP-39 phrase, scan, PCZT build → local sign (host-injected WebZjs pczt_sign) → broadcast; unblocks direct-phrase MCP sends | Done |
| Note cache / full client | Local note cache + the rest of shielded-transaction-client (transparent inputs, auto-shielding) | Planned |
What's in the box today
import {
resolveWalletScannerBaseUrl,
createWalletScannerClient, // Zcash: Orchard scan jobs, UTXOs, tx broadcast
createMoneroWalletScannerClient, // Monero: LWS scan jobs
mapScannerBalanceToSnapshot, // wire → bigint balance snapshot
} from '@winbit32/wallet-kit';
const zec = createWalletScannerClient({ baseUrl: 'https://api.zcash.winbit32.com/api' });
const { data } = await zec.startOrchardScanJob({ ufvk, autoDetect: true });
const xmr = createMoneroWalletScannerClient();
const job = await xmr.startMoneroScanJob({ address, viewKey });- All clients take an optional
baseUrl/apiKey; without them the publicapi.zcash.winbit32.comhost is used (its nginx injects the upstream API key, so browsers need none). resolveWalletScannerBaseUrl()honoursREACT_APP_WALLET_SCANNER_URLand falls back to a same-origin/apiproxy in development and the public host in production. Non-CRA embedders should passbaseUrlexplicitly.- No runtime dependencies; browser-first (uses
fetch); works in Node ≥ 18.
Co-signing (FROST/Orchard, 2-of-2)
The canonical WB32COSIGN client lives here (src/cosign/). It powers both
browser initiators (Secresea-style "send from a .wult share" flows) and
fully headless Node hosts such as the seneschal.space payments-gateway:
import {
unwrapVaultShare, toOrchardBundle, // .wult → key bundle
deriveOrchardAddressFromBundle, // bundle → UFVK + u1… address
runHeadlessCosignSend, // scan → build → ceremony → broadcast
resolveCosignConfig,
} from '@winbit32/wallet-kit';
const share = await unwrapVaultShare(wultBytes, password);
const bundle = toOrchardBundle(share);
const { txid } = await runHeadlessCosignSend({
config: resolveCosignConfig({ relayBaseUrl, pcztApiBaseUrl, fetchImpl: fetch }),
wasm, bundle, ufvk, unifiedAddress, scanner,
toAddress: 'u1…', amountZat: 25_000, // zatoshis
onQrReady: (qr) => showToHuman(qr), // WB32COSIGN:1:… pairing payload
});- The host holds one share; the human's cosigner (winbit32.com
#cosign) holds the other. Neither side ever has the full spending key. - Transports are pluggable (
BroadcastChannelsame-device, HTTPS relay cross-device); messages are AES-GCM encrypted with the QR session key. - The
orchard-frostWASM is loaded by the host: browsers fetch it frompublic/orchard-frost/, Node hosts read the same artefacts from disk (seeloadOrchardFrostWasmNodein the payments-gateway for the pattern).
Phrase wallet (seed-phrase connect + local sign)
PhraseWalletService (src/zcash/) is the "connect with a seed phrase"
sibling of the cosign service, sharing the same scan/build/broadcast plumbing.
Key operations are injected so the kit ships no WASM — the host wires WebZjs
(or equivalent) and the phrase never leaves the service's tab memory:
import { PhraseWalletService, type PhraseWalletAdapter } from '@winbit32/wallet-kit';
const adapter: PhraseWalletAdapter = {
deriveFromPhrase: async (phrase, account) => ({ ufvk, unifiedAddress, seedFingerprintHex, changeAddress }),
signPczt: async (pcztB64, phrase, account) => signedPcztB64, // WebZjs pczt_sign
};
const wallet = new PhraseWalletService({ adapter, config: { pcztApiBaseUrl: '/api/pczt' } });
await wallet.connect('twelve or twenty four words …');
const { txid } = await wallet.send({ toAddress: 'u1…', amountZec: 0.1, memo: 'thanks' });Wallet bar (embeddable UI)
mountWinbit32WalletBar (src/ui/) is the vanilla-DOM bottom bar + modal
used by ziving.org — Secresea's wallet-bar experience without React. It
accepts a .wult share / locket out of the box and, when the host passes a
phraseAdapter, a pasted seed phrase too (phrase → signs locally in the tab;
.wult → WB32COSIGN co-sign QR). See mountDonorWalletBar in ziving's
wallet/index.mjs for a complete host wiring.
MCP tool descriptors
Mount the kit's view-key capabilities on any MCP server without taking a dependency on any particular SDK:
import { createWalletKitToolDescriptors } from '@winbit32/wallet-kit';
const tools = createWalletKitToolDescriptors(); // public scanner defaults
for (const tool of tools) {
// adapt `tool.params` (flat primitive spec) to your schema flavour
server.registerTool(`myprefix_${tool.name}`, { /* … */ },
async (input) => ({ content: [{ type: 'text', text: JSON.stringify(await tool.handler(input)) }] }));
}Tools: zec_scan_start/status/cancel, zec_utxos, zec_broadcast,
xmr_scan_start/status/cancel. All view-key-only — they can observe funds
but never move them. Signing tools (e.g. make_payment) deliberately live
with the host's payment state machine (see winbit32MCP), which uses the
kit's headless cosign pipeline underneath.
x402 rail (USDC on Base/EVM)
The kit's second payment rail, sibling to the Orchard cosign send — so a host gets BOTH headless rails from one package (the only interactive step in either is the co-sign):
import { createVaultAccount, createVaultX402Payer, probeX402 } from '@winbit32/wallet-kit';
// signDigest routes the EIP-712 digest to a local vault MPC sign or WB32COSIGN.
const account = createVaultAccount({ address, hashTypedData, signDigest });
// makeFetchWithPayment wires @x402/fetch + @x402/evm (ExactEvmScheme(account)).
const payer = createVaultX402Payer({ account, deps: { makeFetchWithPayment } });
const preview = await probeX402({ url }); // price, no payment
const res = await payer.pay({ url, maxAmountUsd: 1 }); // probe → cap → settle- Framework-free + dependency-free at import:
fetchImpl,makeFetchWithPaymentandsignDigestare all INJECTED, so it unit-tests without a vault, a network, viem or the @x402 packages. - An x402 "exact" EVM payment is an EIP-3009
TransferWithAuthorization— one 32-byte EIP-712 digest. Signing that digest is the ONE human touch-point: route it to a local vault MPC sign or a WB32COSIGN ceremony (the same co-sign seam the Orchard pipeline uses). The spend key never leaves the vault shares. pay()/probeX402()never throw for expected failures — they return{ ok:false, reason }(unsafe_target,not_x402,price_exceeds_max,asset_not_accepted,settlement_failed, …) so UI and MCP callers branch cleanly. A successfulpay()returns{ ok:true, paidAtomic, txHash, … }.
NFPT marketplace commerce (secresea/NFPT-scoped)
The one backend-specific module in the otherwise brand-neutral kit: it speaks the secresea NFPT marketplace API so the desktop Gopher client and a Node MCP tool share ONE "buy a listed NFPT" implementation. It's the Orchard-rail counterpart of the x402 payer — discover, then pay for an NFPT as a shielded-ZEC send.
import { buyNfpt, NFPT_DEFAULTS } from '@winbit32/wallet-kit';
const receipt = await buyNfpt({
nfptCid, buyerZAddress,
maxZec: 0.5, // cap enforced BEFORE any signing
apiBase: NFPT_DEFAULTS.apiBase, // injectable; defaults to https://secresea.com
fetchImpl: fetch,
// the ONE env-specific seam: build + sign + broadcast ONE shielded tx
// carrying every output → { txid }. Desktop signs with the loaded vault;
// MCP signs with the gateway FROST share + human cosign.
signAndSend: async ({ outputs }) => ({ txid: await sendShielded(outputs) }),
onStatus: (m) => console.log(m),
});
// receipt: { txid, recorded, totalPayableZec, seller, outputs, recordError, … }- Both
fetchImplandsignAndSendare INJECTED, so the whole module unit-tests with no wallet, chain or network. - Cap-before-sign:
assertWithinCapthrowsprice_exceeds_capbefore the signer is ever called. - Record is non-fatal: once a valid txid is in hand, a failed
record-purchaseis reported viarecorded:false+recordError— never discarding a real on-chain transfer (the scanner reconciles ownership anyway).
Building
cd packages/wallet-kit
npm run build # tsc → dist/ (CommonJS + .d.ts; ESM hosts use src/ via bundler)The compiled dist/ is CommonJS so plain Node hosts (e.g. the
payments-gateway via a file: dependency) can require/import it without
a bundler. Browser consumers (Winbit32 itself) compile src/ directly
through their own bundler via the alias seam described below.
Tests
Kit tests run under the host repo's jest (CRA 27) in a Node environment:
CI=true npx react-app-rewired test --watchAll=false --testPathPattern='packages/wallet-kit'src/cosign/__tests__/ceremony.integration.test.ts runs a real
FROST/Orchard ceremony against the staged WASM — initiator and an
independently-implemented cosigner stand-in — plus the whole
runHeadlessCosignSend pipeline against fake PCZT/scanner endpoints. It
skips (not fails) if public/orchard-frost/ artefacts are missing.
How Winbit32 consumes it
The app imports @winbit32/wallet-kit directly from packages/wallet-kit/src
via a webpack alias + jest moduleNameMapper + tsconfig paths (see
config-overrides.js). npm workspaces are deliberately NOT used yet: the
CRA + WASM webpack setup is sensitive to node_modules hoisting, so the
alias seam gives us the package boundary without the install churn. When
the kit is published, the alias is simply dropped and the app depends on
the npm package like everyone else.
Old Winbit32 paths (src/components/toolbox/walletScannerBaseUrl.ts,
.../zcash-extensions/api/walletScannerClient.ts,
.../monero-extensions/api/moneroWalletScannerClient.ts) remain as
re-export shims; new code should import from @winbit32/wallet-kit.
