@zbase-protocol/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/npk.js ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @zbase-protocol/core — Recipient NPK derivation (audit C3).
3
+ *
4
+ * The v1 UTXO commitment is `Poseidon3(amount, NPK, secret)` where
5
+ * `NPK = Poseidon2(spendingPK, viewingPKBlind)` (matches `note_spend.circom`
6
+ * NpkBuilder + CommitmentHasher — see `notes.ts`). Phase 1A pinned the *hash
7
+ * structure*; this module closes the remaining "auditor-scoped gap" noted in
8
+ * `notes.ts`: HOW `spendingPK` / `viewingPKBlind` are DERIVED from a recipient's
9
+ * published key material.
10
+ *
11
+ * ## Recipe (Railgun-style note-public-key)
12
+ *
13
+ * spendingPK = field(keccak("zBase/npk/spendingPK/v1" || recipientSpendPub))
14
+ * ephemeral = fresh X25519 keypair (per note)
15
+ * shared = X25519(ephemeralPriv, recipientViewingPub)
16
+ * viewingPKBlind = field(keccak("zBase/npk/viewingPKBlind/v1" || shared))
17
+ * NPK = Poseidon2(spendingPK, viewingPKBlind)
18
+ *
19
+ * Two properties this buys:
20
+ *
21
+ * 1. **Unlinkability.** `viewingPKBlind` is a fresh ECDH blind per note, so two
22
+ * notes to the same recipient produce different NPKs → different commitments.
23
+ * An on-chain observer cannot cluster a recipient's incoming notes.
24
+ * 2. **Recoverability.** The recipient publishes `ephemeralPublicKey` alongside
25
+ * the note (it already rides in the `encryptNote` blob — see `notes.ts`
26
+ * EncryptedNote layout, bytes [0..32)). With their viewing PRIVATE key they
27
+ * recompute the same `shared` → the same `viewingPKBlind` → the same NPK →
28
+ * the same commitment, confirming the note is theirs and that they can later
29
+ * prove `NPK = Poseidon2(spendingPK, viewingPKBlind)` in-circuit.
30
+ *
31
+ * `spendingPK` is long-lived (it identifies the recipient's spend authority and
32
+ * is constant across their notes); only `viewingPKBlind` is per-note. This is the
33
+ * standard Railgun split: the spending key authorizes movement, the blinded
34
+ * viewing key provides per-note privacy.
35
+ *
36
+ * ## What this is NOT
37
+ *
38
+ * The recipient's `spendingPublicKey` is a field element the recipient publishes
39
+ * (e.g., a Poseidon/Baby-Jubjub spend pubkey in a full wallet). zBase does not
40
+ * yet ship a spending-key HD scheme (only the X25519 viewing key — see
41
+ * `viewingKeyHD.ts`), so callers supply `recipientSpendingPubKey` explicitly.
42
+ * Until a spending-key tree lands, a recipient MAY reuse a stable field element
43
+ * (e.g., a hash of their viewing pubkey) as their spendingPK — at the cost of the
44
+ * spend-authority separation, NOT of the per-note unlinkability (which comes from
45
+ * the viewing blind). The derivation here is agnostic to that choice.
46
+ *
47
+ * ⚠️ AUDITOR NOTE: this is the SDK-side NPK derivation the circuit comment in
48
+ * `note_spend.circom` (NpkBuilder) and `notes.ts` defer to the external audit.
49
+ * The *circuit* only sees the resulting `NPK` field element; this module decides
50
+ * how it is built off-chain. It must be reviewed before the UTXO pool goes live.
51
+ */
52
+ import { x25519 } from "@noble/curves/ed25519";
53
+ import { keccak_256 } from "@noble/hashes/sha3";
54
+ import { randomBytes } from "@noble/hashes/utils";
55
+ import { SNARK_SCALAR_FIELD } from "./account.js";
56
+ // Domain-separation tags — keep each derivation distinct so no two keccak uses of
57
+ // the same input can collide (mirrors notes.ts's view-tag domain separation).
58
+ const DST_SPENDING_PK = "zBase/npk/spendingPK/v1";
59
+ const DST_VIEWING_BLIND = "zBase/npk/viewingPKBlind/v1";
60
+ /**
61
+ * Reduce arbitrary bytes to a BN254 scalar-field element via a domain-separated
62
+ * keccak, using DETERMINISTIC rejection sampling for a uniform result.
63
+ *
64
+ * audit-sweep-2026-06-17 (was a latent MED): the previous version did a naive
65
+ * `keccak(...) % SNARK_SCALAR_FIELD` and a comment claimed the bias was
66
+ * "< 2^-253". That was wrong by ~250 bits: r ≈ 2^253.6, so 2^256 / r = 5 with a
67
+ * large remainder — the lowest ~29% of the field is over-represented, giving a
68
+ * statistical distance of ~2^-4.2 (~5.4%), not 2^-253. For `spendingPK` (public
69
+ * input) the bias is cosmetic, but `viewingPKBlind` is derived from a SECRET
70
+ * (the X25519 shared secret), so a biased reduction on a secret-derived witness
71
+ * is a real (if bounded) uniformity weakness. We now reject any digest in the
72
+ * "extra" top band [LIMIT, 2^256) and re-hash with an incrementing counter byte,
73
+ * so every accepted value is uniform over [0, r). The counter keeps it fully
74
+ * deterministic — the recipient recomputes the identical element. Expected
75
+ * iterations ≈ 1.25; the loop is bounded defensively.
76
+ */
77
+ export function bytesToField(domain, bytes) {
78
+ const tag = new TextEncoder().encode(domain);
79
+ // Largest multiple of r that fits in 256 bits; digests >= LIMIT are rejected
80
+ // so the accepted range maps uniformly onto [0, r).
81
+ const TWO_256 = 1n << 256n;
82
+ const LIMIT = TWO_256 - (TWO_256 % SNARK_SCALAR_FIELD);
83
+ // 256 counter values is astronomically more than ever needed (P(reject) < 1/5
84
+ // per try); throw rather than bias if somehow exhausted.
85
+ for (let counter = 0; counter < 256; counter++) {
86
+ const input = new Uint8Array(tag.length + bytes.length + 1);
87
+ input.set(tag, 0);
88
+ input.set(bytes, tag.length);
89
+ input[tag.length + bytes.length] = counter; // domain ⊕ bytes ⊕ counter
90
+ const h = keccak_256(input);
91
+ let v = 0n;
92
+ for (const byte of h)
93
+ v = (v << 8n) | BigInt(byte);
94
+ if (v < LIMIT)
95
+ return v % SNARK_SCALAR_FIELD;
96
+ }
97
+ throw new Error("bytesToField: rejection sampling exhausted (unreachable)");
98
+ }
99
+ /**
100
+ * Derive a recipient's NPK inputs for a fresh outbound note.
101
+ *
102
+ * Deterministic given (recipientViewingPubKey, recipientSpendingPubKey,
103
+ * ephemeralPrivateKey): pass a fixed ephemeral key to get reproducible output
104
+ * (used by tests + by the witness fixture builder). With no ephemeral key it is
105
+ * non-deterministic (fresh per-note blind), which is the production default.
106
+ */
107
+ export function deriveRecipientNPK(params) {
108
+ const { recipientViewingPubKey, recipientSpendingPubKey } = params;
109
+ if (recipientViewingPubKey.length !== 32) {
110
+ throw new Error("deriveRecipientNPK: recipientViewingPubKey must be 32 bytes (X25519)");
111
+ }
112
+ if (recipientSpendingPubKey < 0n ||
113
+ recipientSpendingPubKey >= SNARK_SCALAR_FIELD) {
114
+ throw new Error("deriveRecipientNPK: recipientSpendingPubKey must be a field element in [0, r)");
115
+ }
116
+ const ephPriv = params.ephemeralPrivateKey ?? randomBytes(32);
117
+ if (ephPriv.length !== 32) {
118
+ throw new Error("deriveRecipientNPK: ephemeralPrivateKey must be 32 bytes");
119
+ }
120
+ const ephemeralPublicKey = x25519.getPublicKey(ephPriv);
121
+ // spendingPK: long-lived, domain-separated reduction of the recipient's spend
122
+ // pubkey into the field. Constant across the recipient's notes.
123
+ const spendingPK = bytesToField(DST_SPENDING_PK, feToBE32(recipientSpendingPubKey));
124
+ // viewingPKBlind: per-note ECDH blind. shared = X25519(ephPriv, recipientView).
125
+ const shared = x25519.getSharedSecret(ephPriv, recipientViewingPubKey);
126
+ const viewingPKBlind = bytesToField(DST_VIEWING_BLIND, shared);
127
+ return { spendingPK, viewingPKBlind, ephemeralPublicKey };
128
+ }
129
+ /**
130
+ * Recipient-side recovery of `viewingPKBlind` from the published ephemeral
131
+ * pubkey + the recipient's viewing PRIVATE key. Recomputes the same ECDH shared
132
+ * secret the sender used, so the recipient can reconstruct the NPK (and hence the
133
+ * commitment) and confirm the note is addressed to them.
134
+ *
135
+ * shared = X25519(viewingPriv, ephemeralPub) // == sender's X25519(ephPriv, viewingPub)
136
+ * viewingPKBlind = field(keccak(DST || shared))
137
+ */
138
+ export function recoverViewingPKBlind(viewingPrivateKey, ephemeralPublicKey) {
139
+ if (viewingPrivateKey.length !== 32) {
140
+ throw new Error("recoverViewingPKBlind: viewingPrivateKey must be 32 bytes");
141
+ }
142
+ if (ephemeralPublicKey.length !== 32) {
143
+ throw new Error("recoverViewingPKBlind: ephemeralPublicKey must be 32 bytes");
144
+ }
145
+ const shared = x25519.getSharedSecret(viewingPrivateKey, ephemeralPublicKey);
146
+ return bytesToField(DST_VIEWING_BLIND, shared);
147
+ }
148
+ /**
149
+ * Big-endian 32-byte encoding of a field element. Local copy of `account.ts`'s
150
+ * `feToBE32` value semantics (kept here to avoid a string round-trip — account's
151
+ * takes string|bigint and returns via a different path) so the spendingPK
152
+ * reduction has a fixed-width, canonical input.
153
+ */
154
+ function feToBE32(value) {
155
+ if (value < 0n)
156
+ throw new Error("feToBE32: value must be non-negative");
157
+ const out = new Uint8Array(32);
158
+ let v = value;
159
+ for (let i = 31; i >= 0; i--) {
160
+ out[i] = Number(v & 0xffn);
161
+ v >>= 8n;
162
+ }
163
+ if (v !== 0n)
164
+ throw new Error("feToBE32: value exceeds 32 bytes");
165
+ return out;
166
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @zbase-protocol/core — Chain-agnostic ZK proof generation
3
+ *
4
+ * Uses snarkjs for Groth16 proof generation. The circuits are universal:
5
+ * same WASM + zkey files work regardless of which chain the proof is verified on.
6
+ *
7
+ * snarkjs is an OPTIONAL dependency (SDK audit 2026-07-09): it is ~9 MB and only
8
+ * needed by consumers who actually GENERATE a proof. It is loaded LAZILY inside
9
+ * the two functions below, so a consumer using only the Poseidon/Merkle/stealth/
10
+ * facilitator primitives never pulls it in. If snarkjs isn't installed, the lazy
11
+ * import throws a clear message pointing the consumer to add it.
12
+ */
13
+ /**
14
+ * Inputs to the upstream withdraw.circom (template `Withdraw(maxTreeDepth=32)`).
15
+ * Order and names mirror the circuit's signal declarations exactly. Padding:
16
+ * `stateSiblings` and `ASPSiblings` MUST be padded with zeros to length 32.
17
+ *
18
+ * Public signals returned by snarkjs (in order):
19
+ * [0] newCommitmentHash (output)
20
+ * [1] existingNullifierHash (output)
21
+ * [2] withdrawnValue
22
+ * [3] stateRoot
23
+ * [4] stateTreeDepth
24
+ * [5] ASPRoot
25
+ * [6] ASPTreeDepth
26
+ * [7] context
27
+ */
28
+ export interface ProofInput {
29
+ withdrawnValue: string;
30
+ stateRoot: string;
31
+ stateTreeDepth: string;
32
+ ASPRoot: string;
33
+ ASPTreeDepth: string;
34
+ context: string;
35
+ label: string;
36
+ existingValue: string;
37
+ existingNullifier: string;
38
+ existingSecret: string;
39
+ newNullifier: string;
40
+ newSecret: string;
41
+ stateSiblings: string[];
42
+ stateIndex: string;
43
+ ASPSiblings: string[];
44
+ ASPIndex: string;
45
+ }
46
+ export interface Groth16Proof {
47
+ pi_a: [string, string];
48
+ pi_b: [[string, string], [string, string]];
49
+ pi_c: [string, string];
50
+ protocol: "groth16";
51
+ curve: "bn128";
52
+ }
53
+ export interface ProofResult {
54
+ proof: Groth16Proof;
55
+ publicSignals: string[];
56
+ proofTimeMs: number;
57
+ }
58
+ /**
59
+ * Generate a Groth16 withdrawal proof.
60
+ * Chain-agnostic: works for both EVM and Solana verifiers.
61
+ */
62
+ export declare function generateWithdrawalProof(input: ProofInput, wasmPath: string, zkeyPath: string): Promise<ProofResult>;
63
+ /**
64
+ * Verify a Groth16 proof locally (for testing, not on-chain verification).
65
+ */
66
+ export declare function verifyProofLocally(proof: Groth16Proof, publicSignals: string[], vkeyPath: string): Promise<boolean>;
package/dist/proofs.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @zbase-protocol/core — Chain-agnostic ZK proof generation
3
+ *
4
+ * Uses snarkjs for Groth16 proof generation. The circuits are universal:
5
+ * same WASM + zkey files work regardless of which chain the proof is verified on.
6
+ *
7
+ * snarkjs is an OPTIONAL dependency (SDK audit 2026-07-09): it is ~9 MB and only
8
+ * needed by consumers who actually GENERATE a proof. It is loaded LAZILY inside
9
+ * the two functions below, so a consumer using only the Poseidon/Merkle/stealth/
10
+ * facilitator primitives never pulls it in. If snarkjs isn't installed, the lazy
11
+ * import throws a clear message pointing the consumer to add it.
12
+ */
13
+ import { createRequire } from "node:module";
14
+ async function loadSnarkjs() {
15
+ try {
16
+ const requireCjs = createRequire(import.meta.url);
17
+ return requireCjs("snarkjs");
18
+ }
19
+ catch {
20
+ throw new Error("snarkjs is required for proof generation but is not installed. " +
21
+ "It is an optional dependency of @zbase-protocol/core — run `npm i snarkjs` to enable generateWithdrawalProof/verifyProofLocally.");
22
+ }
23
+ }
24
+ /**
25
+ * Generate a Groth16 withdrawal proof.
26
+ * Chain-agnostic: works for both EVM and Solana verifiers.
27
+ */
28
+ export async function generateWithdrawalProof(input, wasmPath, zkeyPath) {
29
+ const startTime = Date.now();
30
+ const snarkjs = await loadSnarkjs();
31
+ const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, wasmPath, zkeyPath);
32
+ return {
33
+ proof: proof,
34
+ publicSignals: publicSignals,
35
+ proofTimeMs: Date.now() - startTime,
36
+ };
37
+ }
38
+ /**
39
+ * Verify a Groth16 proof locally (for testing, not on-chain verification).
40
+ */
41
+ export async function verifyProofLocally(proof, publicSignals, vkeyPath) {
42
+ const snarkjs = await loadSnarkjs();
43
+ const vkey = await fetch(vkeyPath).then((r) => r.json());
44
+ return snarkjs.groth16.verify(vkey, publicSignals, proof);
45
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @zbase-protocol/core — ERC-5564 stealth addresses (scheme id 1, secp256k1)
3
+ *
4
+ * Implements the canonical ERC-5564 scheme 1 ("secp256k1-1") as deployed by
5
+ * ScopeLift / Umbra / Fluidkey, the same scheme audited by Trail of Bits.
6
+ * Spec: https://eips.ethereum.org/EIPS/eip-5564
7
+ *
8
+ * The scheme:
9
+ * - Provider holds two secp256k1 keypairs: spending (k_s, K_s) and viewing (k_v, K_v).
10
+ * - Meta-address encodes the two compressed public keys as
11
+ * st:<chain>:0x<spendingPubKey:33B><viewingPubKey:33B> // 132 hex chars
12
+ * - For each payment the facilitator (sender) generates a fresh ephemeral
13
+ * keypair (r, R = r·G), computes the ECDH shared point S = r · K_v,
14
+ * hashes it: s_h = keccak256(S_compressed). The view tag is s_h[0].
15
+ * - The stealth public key is P = K_s + s_h · G. The stealth Ethereum
16
+ * address is the standard keccak256(uncompressed-pub-key-without-prefix)[12:].
17
+ * - The provider scans Announcement events, recomputes S = k_v · R for
18
+ * each ephemeral pubkey, checks the view tag, and (on match) derives the
19
+ * stealth private key as k = (k_s + s_h) mod n.
20
+ *
21
+ * Why this matters: today every payment to OpenAI's x402 endpoint pays the
22
+ * same address; an outside observer maps "wallet X pays Nansen every 60s".
23
+ * After this ship, every payment to the same provider lands on a different
24
+ * stealth address derived from their registered meta-address — the buyer
25
+ * sees a single endpoint, the chain sees N unrelated recipients.
26
+ *
27
+ * Security boundary: the viewing private key never leaves the provider.
28
+ * An attacker who controls the facilitator AND knows the viewing key can
29
+ * correlate ephemeralPubkey → stealth recipient. Without the viewing key,
30
+ * the link is computationally hidden by the discrete-log assumption on
31
+ * secp256k1 (same assumption Ethereum signatures rely on).
32
+ */
33
+ /** ERC-5564 scheme identifier. Only scheme 1 (secp256k1 + keccak256) is supported. */
34
+ export declare const STEALTH_SCHEME_ID: 1;
35
+ /** Default chain tag used in meta-address URIs ("st:<chain>:0x..."). */
36
+ export declare const DEFAULT_CHAIN_TAG = "base";
37
+ export interface StealthMetaAddress {
38
+ /** "st:base:0x<spendingPubKey:33B><viewingPubKey:33B>" */
39
+ metaAddress: string;
40
+ /** Hex-encoded compressed public key (66 hex chars, with 0x prefix). */
41
+ spendingPublicKey: string;
42
+ /** Hex-encoded compressed public key (66 hex chars, with 0x prefix). */
43
+ viewingPublicKey: string;
44
+ }
45
+ export interface StealthMetaAddressWithPrivate extends StealthMetaAddress {
46
+ /** Provider-only. Required to compute stealth private keys; NEVER share. */
47
+ spendingPrivateKey: string;
48
+ /** Provider-only. Required to scan; can be delegated to a view-only scanner. */
49
+ viewingPrivateKey: string;
50
+ }
51
+ export interface DerivedStealthAddress {
52
+ /** 0x-prefixed 20-byte Ethereum address. */
53
+ stealthAddress: string;
54
+ /** Hex-encoded compressed ephemeral public key R = r·G (66 hex chars). */
55
+ ephemeralPublicKey: string;
56
+ /** 1 byte (2 hex chars), most-significant byte of the hashed shared secret. */
57
+ viewTag: string;
58
+ }
59
+ export interface ScanMatch {
60
+ /** Index into the input `recentEphemeralPubkeys` array. */
61
+ index: number;
62
+ /** The ephemeral pubkey that produced this match (echoed back for convenience). */
63
+ ephemeralPublicKey: string;
64
+ /** The stealth Ethereum address derived from this ephemeral pubkey. */
65
+ stealthAddress: string;
66
+ /** The view tag observed for this ephemeral pubkey. */
67
+ viewTag: string;
68
+ }
69
+ /**
70
+ * Produce a fresh ERC-5564 meta-address from an optional seed.
71
+ *
72
+ * The seed is intentionally optional: passing a deterministic seed enables
73
+ * reproducible tests; omitting it pulls 64 bytes of OS randomness. Either
74
+ * way, both keys live for the life of the provider — they are NOT rotated
75
+ * per call.
76
+ *
77
+ * @param seed Optional 64-byte Uint8Array. First 32 bytes seed the spending
78
+ * key, last 32 bytes seed the viewing key. If omitted, secure random.
79
+ */
80
+ export declare function generateMetaAddress(seed?: Uint8Array, chainTag?: string): StealthMetaAddressWithPrivate;
81
+ /**
82
+ * Parse a "st:<chain>:0x<spending:33B><viewing:33B>" meta-address URI.
83
+ * Accepts either the full URI or a bare 0x-prefixed 132-hex-char string.
84
+ */
85
+ export declare function parseMetaAddress(metaAddress: string): {
86
+ chainTag: string;
87
+ spendingPublicKey: Uint8Array;
88
+ viewingPublicKey: Uint8Array;
89
+ };
90
+ /**
91
+ * Quick sniff test: is this string an ERC-5564 meta-address (vs a plain
92
+ * 0x-address)? Used by the facilitator to decide which path to take.
93
+ */
94
+ export declare function isStealthMetaAddress(s: unknown): s is string;
95
+ /**
96
+ * Derive a fresh stealth address for a provider, given their meta-address
97
+ * and a 32-byte ephemeral nonce (= ephemeral private key r).
98
+ *
99
+ * The facilitator publishes `ephemeralPublicKey` on-chain alongside the
100
+ * payment; the provider scans for it and recovers the funds. The nonce
101
+ * MUST be unpredictable to anyone but the facilitator and MUST NOT repeat
102
+ * (a repeat collapses two payments to the same stealth address, leaking
103
+ * the link). Pass `undefined` to generate a fresh CSPRNG nonce.
104
+ *
105
+ * @param metaAddress provider's "st:base:0x..." meta-address
106
+ * @param ephemeralNonce 32-byte scalar, or undefined for fresh randomness
107
+ */
108
+ export declare function deriveStealthAddress(metaAddress: string, ephemeralNonce?: Uint8Array): DerivedStealthAddress;
109
+ /**
110
+ * Given the provider's viewing private key and a batch of recent ephemeral
111
+ * pubkeys (from on-chain Announcement events or facilitator settle logs),
112
+ * return the stealth addresses that belong to this provider.
113
+ *
114
+ * View-tag optimization: if `expectedViewTags` is supplied, each candidate
115
+ * is filtered on its 1-byte view tag before the (more expensive) point add.
116
+ * This reduces scan cost by ~256× when most announcements aren't ours.
117
+ *
118
+ * @param viewingPrivateKey provider's viewing private key (32 bytes or hex)
119
+ * @param recentEphemeralPubkeys array of compressed ephemeral pubkeys
120
+ * @param spendingPublicKey provider's spending public key (needed to derive
121
+ * the stealth address from the shared secret)
122
+ * @param expectedViewTags optional array (same length as recentEphemeralPubkeys);
123
+ * if a tag is provided and doesn't match, the entry is skipped without
124
+ * doing the point arithmetic
125
+ */
126
+ export declare function scanForPayments(viewingPrivateKey: Uint8Array | string, recentEphemeralPubkeys: Array<Uint8Array | string>, spendingPublicKey: Uint8Array | string, expectedViewTags?: Array<Uint8Array | string | undefined>): ScanMatch[];
127
+ /**
128
+ * Given the provider's spending+viewing private keys and an ephemeral pubkey
129
+ * that's known to belong to this provider, derive the stealth address's
130
+ * private key. With this key the provider can sweep funds to a treasury.
131
+ *
132
+ * k_stealth = (k_s + keccak256(k_v · R)) mod n
133
+ *
134
+ * @returns 32-byte stealth private key (raw bytes; 0x-encode if you need hex)
135
+ */
136
+ export declare function computeStealthPrivateKey(spendingPrivateKey: Uint8Array | string, viewingPrivateKey: Uint8Array | string, ephemeralPublicKey: Uint8Array | string): Uint8Array;