viewkey-watch

June 4, 2026 · View on GitHub

Watch a Monero or Zcash view key and get an HMAC‑signed webhook the moment funds land — without running a full node and without ever touching a spend key.

A view key (Monero private view key, or a Zcash Unified Full Viewing Key) is read‑only: it can see incoming transactions but can never move funds. That makes it safe to hand to a watcher service. viewkey-watch turns that capability into a small, well‑tested toolkit:

  • Balance‑change webhooks — register {chain, address, viewKey, webhookUrl}; receive a signed POST whenever the watched balance changes.
  • Prepaid credit meter — optional per‑day + per‑delivery billing with surge pricing, so a hosted deployment can charge for the service. Ignore it entirely if you just want webhooks.
  • Accept XMR/ZEC payments — a second loop watches your own receiving wallet's view key, matches inbound payments to quotes (unique‑amount for Monero, memo token for Zcash) and credits them. You dogfood the same mechanism you expose to customers.
  • Historical lookups & UFVK derivation — one‑shot spendable/spent breakdowns, and (Zcash) derive a UFVK from a seed phrase.

Everything is plain ESM, dependency‑injected (nothing reads process.env or holds shared mutable state), and ships with a full unit‑test suite.

Status: powers the Private Watch product in production at api.seneschal.space — Monero/Zcash view‑key payment webhooks plus XMR/ZEC credit top‑ups. 0.x while the public API settles.

How it works

                 register watch                  ┌────────────────────────┐
   your app  ───────────────────────────────────▶│  viewkey-watch          │
   (HTTP API) ◀──── { id, watchToken, secret } ──│   • watch store (SQLite)│
                                                  │   • credit meter        │
   ┌──────────────┐   start/poll scan job         │   • poll loop           │
   │ wallet‑scanner│◀──── x-api-key ──────────────│   • receive loop        │
   │  backend      │────▶ balances / notes ───────▶│                        │
   │ (NFPT, nodes) │                               └───────────┬────────────┘
   └──────────────┘                                            │ on balance change

                                            POST webhookUrl  +  x-viewkey-signature
                                            (HMAC‑SHA256 of the body)

viewkey-watch does not scan chains itself — it talks to a wallet‑scanner backend over HTTP (see Backend). The reference backend is the open‑source NFPT API, which fronts monerod/monero-lws and a Zcash lightwalletd/zebra node.

Install

npm install viewkey-watch

Requires Node ≥ 20 (uses the global fetch). The only runtime dependency is better-sqlite3.

Quickstart

A complete, runnable HTTP server lives in examples/basic-server.mjs. The essentials:

import {
  createNfptClient, openWatchDb, createWatch,
  parseMasterKey, encryptViewKey, generateWebhookSecret,
  runPollerTick, WATCH_CONSTANTS
} from 'viewkey-watch';

const masterKey = parseMasterKey(process.env.PRIVATE_WATCH_ENCRYPTION_KEY); // 32 bytes (hex/base64)
const db = openWatchDb('./watches.db');
const nfpt = createNfptClient({ baseUrl: process.env.NFPT_BASE_URL, apiKey: process.env.NFPT_API_KEY });

// Register a watch (view key is encrypted at rest):
const secret = generateWebhookSecret();
const watch = createWatch(db, {
  chain: 'zcash',
  address: 'u1...',
  viewKeyCiphertext: encryptViewKey('uview1...', masterKey),
  webhookUrl: 'https://example.com/hook',
  webhookSecret: secret,
  creditAtomic: WATCH_CONSTANTS.STARTER_CREDIT_ATOMIC,
  dayRateAtomic: WATCH_CONSTANTS.DAY_RATE_ATOMIC,
  callRateAtomic: WATCH_CONSTANTS.CALL_RATE_ATOMIC
});
// → { id, token, expiresAt }  — return token+secret to the caller ONCE.

// Drive the poll loop from your own timer / cron / systemd unit:
setInterval(() => runPollerTick({ db, masterKey, nfptClient: nfpt }), 60_000);

Verifying webhooks

Each delivery carries x-viewkey-signature: sha256=<hex> — an HMAC‑SHA256 over the exact body bytes, keyed by the per‑watch webhookSecret (decoded from hex). Recompute and compare:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, signatureHeader, webhookSecretHex) {
  const mac = createHmac('sha256', Buffer.from(webhookSecretHex, 'hex'));
  const expected = `sha256=${mac.update(rawBody).digest('hex')}`;
  const a = Buffer.from(signatureHeader ?? '');
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

Other headers: x-viewkey-watch-id and x-viewkey-event (balance_change, scan_complete, status_change, or a low‑credit warning). The header prefix and user‑agent are configurable on deliverWebhook({ headerPrefix, userAgent }).

Backend

The scanner client (createNfptClient) speaks this HTTP contract. Any service implementing it works; NFPT is the reference. All requests send x-api-key: <apiKey>; job reads/cancels also send x-job-token: <jobToken>.

Method & pathBodySuccess → returns
POST /api/wallet-scanner/monero/scan/job{ address, viewKey, fromHeight }202 { data: { jobId, jobToken } }
GET /api/wallet-scanner/monero/scan/job/:jobId200 { data: { job } } · 404 gone · 403 bad token
DELETE /api/wallet-scanner/monero/scan/job/:jobId200
POST /api/wallet-scanner/orchard/scan-ufvk/job{ ufvk, birthdayHeight, endHeight, autoDetect }202 { data: { jobId, jobToken } }
GET /api/wallet-scanner/orchard/scan-ufvk/job/:jobId200 { data: { job } }
DELETE /api/wallet-scanner/orchard/scan-ufvk/job/:jobId200
GET /api/wallet-scanner/lightwallet/status200 { data: { lightwallet } } (health)
POST /api/wallet-scanner/orchard/export-ufvk{ mnemonic, network }200 { data: { ufvk } } (optional)

The returned job object is normalised into a chain‑agnostic snapshot (status, scannedHeight, chainHeight, balances) by scanner.normaliseMonero / scanner.normaliseOrchard.

Scanning is asynchronous job‑based: a watch starts a job, then each poll GETs it. Backends are expected to age idle jobs out (the poller transparently restarts a job on 404).

Accepting XMR/ZEC payments

runCryptoRecvTick watches a receiving wallet's view key and credits matched payments:

import {
  createPriceOracle, ensureCryptoTopupSchema, createQuote,
  runCryptoRecvTick, makeWatchCreditApplier
} from 'viewkey-watch';

ensureCryptoTopupSchema(db);
const price = createPriceOracle();              // CoinGecko by default
// createQuote(...) locks a USD↔coin rate and an attribution token
//   (unique low‑digit amount for Monero, memo for Zcash).
await runCryptoRecvTick({ db, nfptClient: nfpt, /* receiving wallet view key + addr */ });

See crypto-price.js, crypto-topup-store.js and crypto-recv-poller.js for the full options.

API surface

The package root re‑exports both grouped namespaces and the common factories flat:

NamespaceModuleWhat it covers
scannerprivate-watch-nfpt.jswallet‑scanner client: start/poll/cancel jobs, health, derive UFVK, historical + receive scans
watchprivate-watch.jsrequest validation, SSRF guards, credit‑meter math, webhook payload builders
storeprivate-watch-store.jswatch persistence (SQLite), credit top‑ups, stats
cryptoKeysprivate-watch-crypto.jsAES‑256‑GCM view‑key encryption, webhook HMAC signing
pollerprivate-watch-poller.jsrunPollerTick + deliverWebhook
pricingprivate-watch-pricing.jsactive‑load surge pricing for the credit meter
pricecrypto-price.jsUSD↔coin conversion + price oracle
receiveStorecrypto-topup-store.jsinbound payment quote persistence
receivePollercrypto-recv-poller.jsrunCryptoRecvTick inbound detection loop
import { scanner, watch, store } from 'viewkey-watch'; // grouped
import { createNfptClient, openWatchDb } from 'viewkey-watch'; // flat

Security model

  • View keys cannot spend. A leaked view key exposes transaction visibility, not funds.
  • Encrypted at rest. View keys are stored AES‑256‑GCM‑encrypted under an operator master key (parseMasterKey); the plaintext only exists in memory during a scan.
  • Signed webhooks. Per‑watch HMAC‑SHA256 secret; verify before trusting a delivery.
  • SSRF‑guarded webhook URLs. watch.assertWebhookUrlSafe / resolveAndValidateWatchRequest reject loopback, RFC1918/CGNAT, link‑local and cloud‑metadata targets, then re‑check after DNS.
  • UFVK derivation from a seed is a convenience, not a default. Prefer deriving client‑side / offline; transmitting a mnemonic to any server means trusting that server and the transport.

Testing

npm install
npm test        # jest, ESM (node --experimental-vm-modules)

License

MIT