@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.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * facilitatorClient.ts — the buildable-on-top-of surface for zBase as private-
3
+ * facilitator infrastructure (facilitator-infra-architecture-2026-06-24.md).
4
+ *
5
+ * Three primitives a builder (or a trading agent, or you dogfooding) needs to make
6
+ * a private payment through zBase, with NO knowledge of the internal routes:
7
+ *
8
+ * prepareDeposit(amount) → deposit calldata + the secrets to keep
9
+ * verifyPayment({ payTo, amount }) → can this settle?
10
+ * settlePrivately({ payTo, amount, deposit }) → execute the private payment
11
+ *
12
+ * This is a thin, typed HTTP client over the facilitator API. The heavy ZK work
13
+ * (proof generation) currently runs server-side inside /settle; a future revision
14
+ * can move proving client-side here (proofs.ts already generates Groth16 proofs)
15
+ * so the payer's secrets never leave their machine — see NOTE on settlePrivately.
16
+ *
17
+ * Chain-agnostic: pass the CAIP-2 network ("eip155:84532" Sepolia / "eip155:8453"
18
+ * mainnet). Zero new dependencies — fetch only.
19
+ */
20
+ export type FacilitatorNetwork = "eip155:84532" | "eip155:8453";
21
+ export interface FacilitatorClientConfig {
22
+ /** Base URL of the zBase deployment, e.g. "https://zbase.app" or "http://localhost:3009". */
23
+ baseUrl: string;
24
+ /** CAIP-2 network. Defaults to Base Sepolia. */
25
+ network?: FacilitatorNetwork;
26
+ /** Optional fetch override (for tests / non-browser runtimes). */
27
+ fetchImpl?: typeof fetch;
28
+ }
29
+ /** Deposit secrets the payer keeps locally; the input to every later settle. */
30
+ export interface DepositSecrets {
31
+ nullifier: string;
32
+ secret: string;
33
+ value: string;
34
+ label: string;
35
+ commitment: string;
36
+ }
37
+ export interface PreparedDeposit {
38
+ /** Precommitment to pass to the pool's deposit() call. */
39
+ precommitment: string;
40
+ /** Secrets to KEEP — value+label are filled in after the deposit tx from its event. */
41
+ secrets: Pick<DepositSecrets, "nullifier" | "secret">;
42
+ /** The on-chain amount to deposit (atomic USDC). */
43
+ amountAtomic: string;
44
+ }
45
+ export interface VerifyResult {
46
+ valid: boolean;
47
+ reason?: string;
48
+ privacy?: {
49
+ method: string;
50
+ pool: string;
51
+ anonymitySet: number;
52
+ };
53
+ }
54
+ export interface SettleResult {
55
+ settled: boolean;
56
+ txHash?: string;
57
+ network?: string;
58
+ error?: string;
59
+ pricing?: {
60
+ tier: string;
61
+ perSettleFeeAtomic: string;
62
+ feeModel: string;
63
+ };
64
+ /** Present when the recipient is a registered provider (stealth routing). */
65
+ stealth?: {
66
+ ephemeralPubkey: string;
67
+ viewTag: string;
68
+ };
69
+ /** Change note to use for the NEXT settle, if the deposit had leftover value. */
70
+ nextDeposit?: DepositSecrets;
71
+ }
72
+ /**
73
+ * Plan for callPrivately: release funds from the pool DIRECTLY into a whitelisted
74
+ * contract call (e.g. an ERC-4626 vault deposit) from an unlinked position. The
75
+ * server binds (target, callData, minOut, recipient) into the withdrawal `context`
76
+ * so neither the relayer nor anyone else can alter the plan. Privacy = unlinkability
77
+ * of WHO funded the call; the call itself is public on-chain. See the zBase threat model.
78
+ */
79
+ export interface CallPlan {
80
+ /** Whitelisted contract to call (must be on the executor's frozen whitelist). */
81
+ target: string;
82
+ /** The pool asset spent into the call (USDC). */
83
+ inputToken: string;
84
+ /** Token the call yields (vault shares / swap output). */
85
+ outputToken: string;
86
+ /** Slippage floor on the produced outputToken delta (atomic). */
87
+ minOut: string | bigint;
88
+ /** Who receives the outputToken. */
89
+ recipient: string;
90
+ /** Exact calldata for the target (0x-hex). Its selector must be whitelisted. */
91
+ callData: string;
92
+ }
93
+ export interface CallResult {
94
+ executed: boolean;
95
+ txHash?: string;
96
+ network?: string;
97
+ error?: string;
98
+ pricing?: {
99
+ tier: string;
100
+ perSettleFeeAtomic: string;
101
+ feeModel: string;
102
+ };
103
+ nextDeposit?: DepositSecrets;
104
+ }
105
+ export declare class FacilitatorClient {
106
+ private readonly base;
107
+ private readonly network;
108
+ private readonly doFetch;
109
+ constructor(cfg: FacilitatorClientConfig);
110
+ /**
111
+ * Step 1 (one-time): prepare a deposit. Returns the precommitment to send to the
112
+ * pool's on-chain deposit() and the secrets to keep. After the deposit tx mines,
113
+ * read its Deposited event to fill in `value` (post-fee) + `label`, producing a
114
+ * full DepositSecrets you pass to settlePrivately.
115
+ *
116
+ * Pure/local — no network call. Uses the same secret derivation the rest of the
117
+ * stack uses (account.ts), so the resulting commitment matches the circuit.
118
+ */
119
+ prepareDeposit(amountAtomic: string | bigint): PreparedDeposit;
120
+ /**
121
+ * Step 2 (per payment): can this payment settle? Mirrors POST /api/facilitator/verify.
122
+ */
123
+ verifyPayment(args: {
124
+ payTo: string;
125
+ amountAtomic: string | bigint;
126
+ deposit?: DepositSecrets;
127
+ }): Promise<VerifyResult>;
128
+ /**
129
+ * Step 3 (per payment): execute the private payment. Mirrors POST
130
+ * /api/facilitator/settle. The pool pays `payTo` (or a fresh stealth address if
131
+ * payTo is a registered provider) from the anonymity set; there is no on-chain
132
+ * link to the payer's wallet. Returns the tx + any change note for the next settle.
133
+ *
134
+ * NOTE (client-side proving roadmap): today the proof is generated server-side
135
+ * inside /settle, so the deposit secrets are sent to the facilitator. The
136
+ * privacy-maximal version generates the proof HERE with proofs.ts
137
+ * (generateWithdrawalProof) and sends only the proof + public signals, so secrets
138
+ * never leave the caller. That requires browser-portable wasm/zkey loading; until
139
+ * then this client uses the server-proving path. The API surface below does NOT
140
+ * change when that lands — callers keep calling settlePrivately().
141
+ */
142
+ settlePrivately(args: {
143
+ payTo: string;
144
+ amountAtomic: string | bigint;
145
+ deposit: DepositSecrets;
146
+ agentId?: string;
147
+ }): Promise<SettleResult>;
148
+ /**
149
+ * Private-funded DeFi access: release pool funds DIRECTLY into a whitelisted
150
+ * contract call from an unlinked position. Mirrors POST /api/facilitator/call.
151
+ *
152
+ * The server generates the ZK withdrawal proof, binds the ENTIRE call plan
153
+ * (target, callData, minOut, recipient) into the proof's `context`, and submits
154
+ * via the ExecutorProcessooor (NOT the plain relay path). The executor re-derives
155
+ * the context on-chain and reverts on any mismatch, so the relayer is trustless.
156
+ *
157
+ * Privacy note: this hides WHO funded the call, not WHAT the call does. The
158
+ * on-chain call (e.g. a swap or a vault deposit) is public. Do not market as
159
+ * "private trading" — see the threat model.
160
+ */
161
+ callPrivately(args: {
162
+ plan: CallPlan;
163
+ amountAtomic: string | bigint;
164
+ deposit: DepositSecrets;
165
+ agentId?: string;
166
+ }): Promise<CallResult>;
167
+ /** Discovery: what the facilitator accepts (networks, tokens, pricing). */
168
+ supported(): Promise<unknown>;
169
+ }
170
+ /** Convenience factory. */
171
+ export declare function createFacilitatorClient(cfg: FacilitatorClientConfig): FacilitatorClient;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * facilitatorClient.ts — the buildable-on-top-of surface for zBase as private-
3
+ * facilitator infrastructure (facilitator-infra-architecture-2026-06-24.md).
4
+ *
5
+ * Three primitives a builder (or a trading agent, or you dogfooding) needs to make
6
+ * a private payment through zBase, with NO knowledge of the internal routes:
7
+ *
8
+ * prepareDeposit(amount) → deposit calldata + the secrets to keep
9
+ * verifyPayment({ payTo, amount }) → can this settle?
10
+ * settlePrivately({ payTo, amount, deposit }) → execute the private payment
11
+ *
12
+ * This is a thin, typed HTTP client over the facilitator API. The heavy ZK work
13
+ * (proof generation) currently runs server-side inside /settle; a future revision
14
+ * can move proving client-side here (proofs.ts already generates Groth16 proofs)
15
+ * so the payer's secrets never leave their machine — see NOTE on settlePrivately.
16
+ *
17
+ * Chain-agnostic: pass the CAIP-2 network ("eip155:84532" Sepolia / "eip155:8453"
18
+ * mainnet). Zero new dependencies — fetch only.
19
+ */
20
+ import { generateDepositSecrets, computePrecommitment } from "./account.js";
21
+ export class FacilitatorClient {
22
+ base;
23
+ network;
24
+ doFetch;
25
+ constructor(cfg) {
26
+ this.base = cfg.baseUrl.replace(/\/$/, "");
27
+ this.network = cfg.network ?? "eip155:84532";
28
+ const f = cfg.fetchImpl ?? globalThis.fetch;
29
+ if (!f)
30
+ throw new Error("FacilitatorClient: no fetch available — pass fetchImpl.");
31
+ this.doFetch = f;
32
+ }
33
+ /**
34
+ * Step 1 (one-time): prepare a deposit. Returns the precommitment to send to the
35
+ * pool's on-chain deposit() and the secrets to keep. After the deposit tx mines,
36
+ * read its Deposited event to fill in `value` (post-fee) + `label`, producing a
37
+ * full DepositSecrets you pass to settlePrivately.
38
+ *
39
+ * Pure/local — no network call. Uses the same secret derivation the rest of the
40
+ * stack uses (account.ts), so the resulting commitment matches the circuit.
41
+ */
42
+ prepareDeposit(amountAtomic) {
43
+ // generateDepositSecrets() returns decimal strings; computePrecommitment takes
44
+ // + returns strings (account.ts). Keep everything as strings end-to-end.
45
+ const { nullifier, secret } = generateDepositSecrets();
46
+ const precommitment = computePrecommitment(nullifier, secret);
47
+ return {
48
+ precommitment,
49
+ secrets: { nullifier, secret },
50
+ amountAtomic: amountAtomic.toString(),
51
+ };
52
+ }
53
+ /**
54
+ * Step 2 (per payment): can this payment settle? Mirrors POST /api/facilitator/verify.
55
+ */
56
+ async verifyPayment(args) {
57
+ const res = await this.doFetch(`${this.base}/api/facilitator/verify`, {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify({
61
+ paymentDetails: {
62
+ scheme: "exact",
63
+ networkId: this.network,
64
+ payTo: args.payTo,
65
+ maxAmountRequired: args.amountAtomic.toString(),
66
+ },
67
+ ...(args.deposit ? { zbaseDeposit: args.deposit } : {}),
68
+ }),
69
+ });
70
+ return (await res.json());
71
+ }
72
+ /**
73
+ * Step 3 (per payment): execute the private payment. Mirrors POST
74
+ * /api/facilitator/settle. The pool pays `payTo` (or a fresh stealth address if
75
+ * payTo is a registered provider) from the anonymity set; there is no on-chain
76
+ * link to the payer's wallet. Returns the tx + any change note for the next settle.
77
+ *
78
+ * NOTE (client-side proving roadmap): today the proof is generated server-side
79
+ * inside /settle, so the deposit secrets are sent to the facilitator. The
80
+ * privacy-maximal version generates the proof HERE with proofs.ts
81
+ * (generateWithdrawalProof) and sends only the proof + public signals, so secrets
82
+ * never leave the caller. That requires browser-portable wasm/zkey loading; until
83
+ * then this client uses the server-proving path. The API surface below does NOT
84
+ * change when that lands — callers keep calling settlePrivately().
85
+ */
86
+ async settlePrivately(args) {
87
+ const res = await this.doFetch(`${this.base}/api/facilitator/settle`, {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({
91
+ paymentDetails: {
92
+ scheme: "exact",
93
+ networkId: this.network,
94
+ payTo: args.payTo,
95
+ maxAmountRequired: args.amountAtomic.toString(),
96
+ },
97
+ zbaseDeposit: args.deposit,
98
+ ...(args.agentId ? { agentId: args.agentId } : {}),
99
+ }),
100
+ });
101
+ return (await res.json());
102
+ }
103
+ /**
104
+ * Private-funded DeFi access: release pool funds DIRECTLY into a whitelisted
105
+ * contract call from an unlinked position. Mirrors POST /api/facilitator/call.
106
+ *
107
+ * The server generates the ZK withdrawal proof, binds the ENTIRE call plan
108
+ * (target, callData, minOut, recipient) into the proof's `context`, and submits
109
+ * via the ExecutorProcessooor (NOT the plain relay path). The executor re-derives
110
+ * the context on-chain and reverts on any mismatch, so the relayer is trustless.
111
+ *
112
+ * Privacy note: this hides WHO funded the call, not WHAT the call does. The
113
+ * on-chain call (e.g. a swap or a vault deposit) is public. Do not market as
114
+ * "private trading" — see the threat model.
115
+ */
116
+ async callPrivately(args) {
117
+ const res = await this.doFetch(`${this.base}/api/facilitator/call`, {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify({
121
+ networkId: this.network,
122
+ amountAtomic: args.amountAtomic.toString(),
123
+ callPlan: {
124
+ target: args.plan.target,
125
+ inputToken: args.plan.inputToken,
126
+ outputToken: args.plan.outputToken,
127
+ minOut: args.plan.minOut.toString(),
128
+ recipient: args.plan.recipient,
129
+ callData: args.plan.callData,
130
+ },
131
+ zbaseDeposit: args.deposit,
132
+ ...(args.agentId ? { agentId: args.agentId } : {}),
133
+ }),
134
+ });
135
+ return (await res.json());
136
+ }
137
+ /** Discovery: what the facilitator accepts (networks, tokens, pricing). */
138
+ async supported() {
139
+ const res = await this.doFetch(`${this.base}/api/facilitator/supported`);
140
+ return res.json();
141
+ }
142
+ }
143
+ /** Convenience factory. */
144
+ export function createFacilitatorClient(cfg) {
145
+ return new FacilitatorClient(cfg);
146
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @zbase-protocol/core — D3 deterministic forwarding notes (Phase 2 / forwarding rail)
3
+ *
4
+ * The forwarding rail lets a bounded relayer deposit inbound funds into the pool
5
+ * ON THE USER'S BEHALF, without ever being able to SPEND them. The mechanism:
6
+ *
7
+ * - The deposit's (nullifier, secret) are DERIVED DETERMINISTICALLY from the
8
+ * USER'S OWN HD seed at a per-deposit index — NOT randomly generated by the
9
+ * relayer. The relayer is given only the resulting `precommitment` (a public
10
+ * value) so it can call the pool's `deposit()`. It never sees, and cannot
11
+ * reconstruct, the spend secret — that requires the seed, which never leaves
12
+ * the user.
13
+ *
14
+ * - Recovery is SCAN-BY-COMMITMENT (the chosen design, 2026-07-09): the user
15
+ * re-derives candidate (nullifier, secret) for indices 0..N, computes each
16
+ * commitment, and matches against the pool's on-chain `Deposited` events.
17
+ * There is NO shared mutable index to corrupt — the relayer and the user only
18
+ * ever need to agree on the SEED. Even if the relayer's own index counter
19
+ * desyncs, the user still finds every note by scanning. This is the property
20
+ * that makes funds UNLOSABLE to a state desync (the failure class CLAUDE.md's
21
+ * "cleared localStorage = lost funds" gotcha is about).
22
+ *
23
+ * Circuit ground-truth (see account.ts / commitment.circom):
24
+ * precommitment = Poseidon(2)([nullifier, secret])
25
+ * commitment = Poseidon(3)([value, label, precommitment])
26
+ * Any deviation breaks proof verification, so this file reuses account.ts's
27
+ * `computePrecommitment` / `computeCommitment` rather than re-deriving.
28
+ *
29
+ * Derivation domain: a dedicated BIP44 sub-path SEPARATE from the viewing-key
30
+ * path (viewingKeyHD.ts uses m/44'/60'/0'/0'/i for X25519 viewing keys). The
31
+ * forwarding spend-secret path is m/44'/60'/0'/1'/i so a leaked forwarding note
32
+ * cannot be combined with a sibling viewing key. Each index yields TWO field
33
+ * elements (nullifier, secret) via domain-separated hashing of the node key.
34
+ */
35
+ /**
36
+ * BIP44 path prefix for forwarding SPEND secrets. Hardened fourth segment `1'`
37
+ * separates this domain from the viewing-key domain (`0'`) — a leaked forwarding
38
+ * note cannot be recombined with a viewing key derived from the same mnemonic.
39
+ *
40
+ * m / 44' / 60' / 0' / 1' / index
41
+ */
42
+ export declare const ZBASE_FORWARDING_PATH_PREFIX = "m/44'/60'/0'/1'";
43
+ /** A deterministically-derived forwarding note's spend secrets + public precommitment. */
44
+ export interface ForwardingNoteSecrets {
45
+ /** Per-deposit derivation index. */
46
+ index: number;
47
+ /** Spend nullifier (field element, decimal string). Derived from the seed. */
48
+ nullifier: string;
49
+ /** Spend secret (field element, decimal string). Derived from the seed. */
50
+ secret: string;
51
+ /**
52
+ * `precommitment = Poseidon(2)([nullifier, secret])` — the ONLY value handed to
53
+ * the relayer. Public; reveals nothing about the spend secret.
54
+ */
55
+ precommitment: string;
56
+ /** Full derivation path, e.g. "m/44'/60'/0'/1'/0". */
57
+ derivationPath: string;
58
+ }
59
+ /**
60
+ * Derive the forwarding note secrets for a given index from a BIP39 mnemonic.
61
+ *
62
+ * Deterministic and pure: same mnemonic + index → same (nullifier, secret,
63
+ * precommitment), always. This is what lets the user recover a note the relayer
64
+ * deposited, by re-deriving and scanning.
65
+ *
66
+ * @param mnemonic the user's BIP39 seed phrase (NEVER leaves the client / never sent to the relayer)
67
+ * @param index per-deposit index (0, 1, 2, …)
68
+ */
69
+ export declare function deriveForwardingNote(mnemonic: string, index: number): ForwardingNoteSecrets;
70
+ /**
71
+ * Relayer-side view: given ONLY the user's PUBLIC precommitment stream is not
72
+ * possible (precommitments require the seed to derive). Instead the relayer is
73
+ * handed a specific `precommitment` for the index it should deposit at — this
74
+ * helper is the USER/CLIENT producing that value to register. The relayer never
75
+ * calls `deriveForwardingNote` (it has no mnemonic); it only ever receives the
76
+ * `precommitment` field.
77
+ *
78
+ * Returned precommitment is safe to transmit to the relayer.
79
+ */
80
+ export declare function precommitmentForRelayer(mnemonic: string, index: number): string;
81
+ /**
82
+ * SCAN-BY-COMMITMENT recovery. Given the user's mnemonic and the set of on-chain
83
+ * `Deposited` events (each carrying commitment, label, value), find which
84
+ * derived notes actually landed on-chain and return the full recoverable notes.
85
+ *
86
+ * This is how the user reclaims funds the relayer deposited: derive candidate
87
+ * notes for indices [0, maxIndex], compute each commitment against the event's
88
+ * (value, label), and match. NO shared index state is needed — only the seed.
89
+ *
90
+ * @param mnemonic user's seed phrase
91
+ * @param depositedEvents on-chain Deposited events { commitment, label, value }
92
+ * @param maxIndex highest index to scan (inclusive). Scan a window past the
93
+ * last found note to tolerate gaps.
94
+ */
95
+ export declare function recoverForwardingNotes(mnemonic: string, depositedEvents: Array<{
96
+ commitment: string;
97
+ label: string;
98
+ value: string;
99
+ }>, maxIndex: number): Array<ForwardingNoteSecrets & {
100
+ value: string;
101
+ label: string;
102
+ commitment: string;
103
+ }>;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @zbase-protocol/core — D3 deterministic forwarding notes (Phase 2 / forwarding rail)
3
+ *
4
+ * The forwarding rail lets a bounded relayer deposit inbound funds into the pool
5
+ * ON THE USER'S BEHALF, without ever being able to SPEND them. The mechanism:
6
+ *
7
+ * - The deposit's (nullifier, secret) are DERIVED DETERMINISTICALLY from the
8
+ * USER'S OWN HD seed at a per-deposit index — NOT randomly generated by the
9
+ * relayer. The relayer is given only the resulting `precommitment` (a public
10
+ * value) so it can call the pool's `deposit()`. It never sees, and cannot
11
+ * reconstruct, the spend secret — that requires the seed, which never leaves
12
+ * the user.
13
+ *
14
+ * - Recovery is SCAN-BY-COMMITMENT (the chosen design, 2026-07-09): the user
15
+ * re-derives candidate (nullifier, secret) for indices 0..N, computes each
16
+ * commitment, and matches against the pool's on-chain `Deposited` events.
17
+ * There is NO shared mutable index to corrupt — the relayer and the user only
18
+ * ever need to agree on the SEED. Even if the relayer's own index counter
19
+ * desyncs, the user still finds every note by scanning. This is the property
20
+ * that makes funds UNLOSABLE to a state desync (the failure class CLAUDE.md's
21
+ * "cleared localStorage = lost funds" gotcha is about).
22
+ *
23
+ * Circuit ground-truth (see account.ts / commitment.circom):
24
+ * precommitment = Poseidon(2)([nullifier, secret])
25
+ * commitment = Poseidon(3)([value, label, precommitment])
26
+ * Any deviation breaks proof verification, so this file reuses account.ts's
27
+ * `computePrecommitment` / `computeCommitment` rather than re-deriving.
28
+ *
29
+ * Derivation domain: a dedicated BIP44 sub-path SEPARATE from the viewing-key
30
+ * path (viewingKeyHD.ts uses m/44'/60'/0'/0'/i for X25519 viewing keys). The
31
+ * forwarding spend-secret path is m/44'/60'/0'/1'/i so a leaked forwarding note
32
+ * cannot be combined with a sibling viewing key. Each index yields TWO field
33
+ * elements (nullifier, secret) via domain-separated hashing of the node key.
34
+ */
35
+ import { HDKey } from "@scure/bip32";
36
+ import { mnemonicToSeedSync } from "@scure/bip39";
37
+ import { keccak_256 } from "@noble/hashes/sha3";
38
+ import { SNARK_SCALAR_FIELD, computePrecommitment, computeCommitment, } from "./account.js";
39
+ /**
40
+ * BIP44 path prefix for forwarding SPEND secrets. Hardened fourth segment `1'`
41
+ * separates this domain from the viewing-key domain (`0'`) — a leaked forwarding
42
+ * note cannot be recombined with a viewing key derived from the same mnemonic.
43
+ *
44
+ * m / 44' / 60' / 0' / 1' / index
45
+ */
46
+ export const ZBASE_FORWARDING_PATH_PREFIX = "m/44'/60'/0'/1'";
47
+ /**
48
+ * Reduce a 32-byte hash to a BN254 field element via rejection-free modular
49
+ * reduction with domain tagging. The bias from a single `% r` is negligible for
50
+ * a keccak digest domain-separated per (node, tag); we do NOT need the CSPRNG
51
+ * rejection sampling used for random secrets because these values are
52
+ * DETERMINISTIC (reproducibility is the requirement, not perfect uniformity) and
53
+ * keccak output is already ~uniform over 2^256. Determinism is load-bearing:
54
+ * the user must re-derive the EXACT same value to recover the note.
55
+ */
56
+ function hashToField(nodeKey, tag) {
57
+ const tagBytes = new TextEncoder().encode(tag);
58
+ const buf = new Uint8Array(nodeKey.length + tagBytes.length);
59
+ buf.set(nodeKey, 0);
60
+ buf.set(tagBytes, nodeKey.length);
61
+ const h = keccak_256(buf);
62
+ let x = 0n;
63
+ for (const b of h)
64
+ x = (x << 8n) | BigInt(b);
65
+ return x % SNARK_SCALAR_FIELD;
66
+ }
67
+ /**
68
+ * Derive the forwarding note secrets for a given index from a BIP39 mnemonic.
69
+ *
70
+ * Deterministic and pure: same mnemonic + index → same (nullifier, secret,
71
+ * precommitment), always. This is what lets the user recover a note the relayer
72
+ * deposited, by re-deriving and scanning.
73
+ *
74
+ * @param mnemonic the user's BIP39 seed phrase (NEVER leaves the client / never sent to the relayer)
75
+ * @param index per-deposit index (0, 1, 2, …)
76
+ */
77
+ export function deriveForwardingNote(mnemonic, index) {
78
+ if (!Number.isInteger(index) || index < 0) {
79
+ throw new Error("deriveForwardingNote: index must be a non-negative integer");
80
+ }
81
+ const seed = mnemonicToSeedSync(mnemonic);
82
+ const root = HDKey.fromMasterSeed(seed);
83
+ const path = `${ZBASE_FORWARDING_PATH_PREFIX}/${index}`;
84
+ const node = root.derive(path);
85
+ if (!node.privateKey) {
86
+ throw new Error("deriveForwardingNote: derivation produced no private key");
87
+ }
88
+ // Two domain-separated field elements from the same node key.
89
+ const nullifier = hashToField(node.privateKey, "zbase/forwarding/nullifier");
90
+ const secret = hashToField(node.privateKey, "zbase/forwarding/secret");
91
+ const nullifierStr = nullifier.toString();
92
+ const secretStr = secret.toString();
93
+ return {
94
+ index,
95
+ nullifier: nullifierStr,
96
+ secret: secretStr,
97
+ precommitment: computePrecommitment(nullifierStr, secretStr),
98
+ derivationPath: path,
99
+ };
100
+ }
101
+ /**
102
+ * Relayer-side view: given ONLY the user's PUBLIC precommitment stream is not
103
+ * possible (precommitments require the seed to derive). Instead the relayer is
104
+ * handed a specific `precommitment` for the index it should deposit at — this
105
+ * helper is the USER/CLIENT producing that value to register. The relayer never
106
+ * calls `deriveForwardingNote` (it has no mnemonic); it only ever receives the
107
+ * `precommitment` field.
108
+ *
109
+ * Returned precommitment is safe to transmit to the relayer.
110
+ */
111
+ export function precommitmentForRelayer(mnemonic, index) {
112
+ return deriveForwardingNote(mnemonic, index).precommitment;
113
+ }
114
+ /**
115
+ * SCAN-BY-COMMITMENT recovery. Given the user's mnemonic and the set of on-chain
116
+ * `Deposited` events (each carrying commitment, label, value), find which
117
+ * derived notes actually landed on-chain and return the full recoverable notes.
118
+ *
119
+ * This is how the user reclaims funds the relayer deposited: derive candidate
120
+ * notes for indices [0, maxIndex], compute each commitment against the event's
121
+ * (value, label), and match. NO shared index state is needed — only the seed.
122
+ *
123
+ * @param mnemonic user's seed phrase
124
+ * @param depositedEvents on-chain Deposited events { commitment, label, value }
125
+ * @param maxIndex highest index to scan (inclusive). Scan a window past the
126
+ * last found note to tolerate gaps.
127
+ */
128
+ export function recoverForwardingNotes(mnemonic, depositedEvents, maxIndex) {
129
+ // Index on-chain commitments for O(1) lookup.
130
+ const byCommitment = new Map();
131
+ for (const ev of depositedEvents) {
132
+ byCommitment.set(String(ev.commitment), { label: String(ev.label), value: String(ev.value) });
133
+ }
134
+ const found = [];
135
+ for (let i = 0; i <= maxIndex; i++) {
136
+ const note = deriveForwardingNote(mnemonic, i);
137
+ // For each on-chain event, the commitment is Poseidon3(value, label, precommitment).
138
+ // We can't know (value,label) a priori, so test the note's precommitment against
139
+ // every event's (value,label). In practice the event set is small per user.
140
+ for (const [commitment, { label, value }] of byCommitment) {
141
+ const derivedCommitment = computeCommitment(value, label, note.precommitment);
142
+ if (derivedCommitment === commitment) {
143
+ found.push({ ...note, value, label, commitment });
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ return found;
149
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @zbase-protocol/core — Chain-agnostic ZK privacy for x402 agent payments
3
+ *
4
+ * This package contains universal logic shared across all chain implementations:
5
+ * - ZK proof generation (Groth16 via snarkjs)
6
+ * - Merkle tree operations (Poseidon + lean-imt)
7
+ * - Account/secrets management
8
+ * - Privacy scanner scoring
9
+ * - Abstract interfaces for Pool and Facilitator
10
+ *
11
+ * Chain-specific implementations:
12
+ * - @zbase-protocol/evm — Base, Ethereum, Arbitrum (Solidity + Morpho yield)
13
+ * - @zbase-protocol/svm — Solana (Anchor + Kamino yield)
14
+ */
15
+ export type { AccountSecrets, ZX402AccountData, ChainType, DepositResult, WithdrawResult, PayResult, PoolStats, ZX402Pool, VerifyRequest, VerifyResponse, SettleRequest, SettleResponse, ZX402Facilitator, AgentPermissions, AgentInfo, RegisterAgentRequest, ExposureReport, SupportedCapabilities, } from "./types.js";
16
+ export { generateDepositSecrets, computeCommitment, computePrecommitment, computeNullifierHash, computeLabel, feToBE32, SNARK_SCALAR_FIELD, createAccount, serializeAccount, deserializeAccount, } from "./account.js";
17
+ export type { ProofInput, Groth16Proof, ProofResult } from "./proofs.js";
18
+ export { generateWithdrawalProof, verifyProofLocally } from "./proofs.js";
19
+ export { buildMerkleTree, generateMerkleProof, getTreeDepth } from "./merkle.js";
20
+ export type { FacilitatorClientConfig, FacilitatorNetwork, DepositSecrets, PreparedDeposit, VerifyResult, SettleResult, } from "./facilitatorClient.js";
21
+ export { FacilitatorClient, createFacilitatorClient } from "./facilitatorClient.js";
22
+ export type { BuildSwapCallPlanOpts } from "./swapPlan.js";
23
+ export { buildSwapCallPlan, EXACT_INPUT_SINGLE_SELECTOR, } from "./swapPlan.js";
24
+ export type { ForwardingNoteSecrets } from "./forwardingNotes.js";
25
+ export { deriveForwardingNote, precommitmentForRelayer, recoverForwardingNotes, ZBASE_FORWARDING_PATH_PREFIX, } from "./forwardingNotes.js";
26
+ export type { StealthMetaAddress, StealthMetaAddressWithPrivate, DerivedStealthAddress, ScanMatch, } from "./stealth.js";
27
+ export { STEALTH_SCHEME_ID, DEFAULT_CHAIN_TAG, generateMetaAddress, parseMetaAddress, isStealthMetaAddress, deriveStealthAddress, scanForPayments, computeStealthPrivateKey, } from "./stealth.js";
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @zbase-protocol/core — Chain-agnostic ZK privacy for x402 agent payments
3
+ *
4
+ * This package contains universal logic shared across all chain implementations:
5
+ * - ZK proof generation (Groth16 via snarkjs)
6
+ * - Merkle tree operations (Poseidon + lean-imt)
7
+ * - Account/secrets management
8
+ * - Privacy scanner scoring
9
+ * - Abstract interfaces for Pool and Facilitator
10
+ *
11
+ * Chain-specific implementations:
12
+ * - @zbase-protocol/evm — Base, Ethereum, Arbitrum (Solidity + Morpho yield)
13
+ * - @zbase-protocol/svm — Solana (Anchor + Kamino yield)
14
+ */
15
+ // Account management
16
+ export { generateDepositSecrets, computeCommitment, computePrecommitment, computeNullifierHash, computeLabel, feToBE32, SNARK_SCALAR_FIELD, createAccount, serializeAccount, deserializeAccount, } from "./account.js";
17
+ export { generateWithdrawalProof, verifyProofLocally } from "./proofs.js";
18
+ // Merkle tree
19
+ export { buildMerkleTree, generateMerkleProof, getTreeDepth } from "./merkle.js";
20
+ export { FacilitatorClient, createFacilitatorClient } from "./facilitatorClient.js";
21
+ export { buildSwapCallPlan, EXACT_INPUT_SINGLE_SELECTOR, } from "./swapPlan.js";
22
+ export { deriveForwardingNote, precommitmentForRelayer, recoverForwardingNotes, ZBASE_FORWARDING_PATH_PREFIX, } from "./forwardingNotes.js";
23
+ export { STEALTH_SCHEME_ID, DEFAULT_CHAIN_TAG, generateMetaAddress, parseMetaAddress, isStealthMetaAddress, deriveStealthAddress, scanForPayments, computeStealthPrivateKey, } from "./stealth.js";
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @zbase-protocol/core — Chain-agnostic Merkle tree operations
3
+ *
4
+ * Uses Lean Incremental Merkle Tree (lean-imt) with Poseidon hashing.
5
+ * Same tree structure works for both EVM and Solana verifiers.
6
+ */
7
+ import { LeanIMT } from "@zk-kit/lean-imt";
8
+ /**
9
+ * Build a Merkle tree from leaf values (commitments).
10
+ * The hash function (Poseidon2) is identical across chains.
11
+ */
12
+ export declare function buildMerkleTree(leaves: bigint[]): LeanIMT;
13
+ /**
14
+ * Generate a Merkle proof for a leaf, shaped for `note_spend.circom`'s
15
+ * `LeanIMTInclusionProof(levels=TREE_DEPTH)`.
16
+ *
17
+ * B3 fix (audit-sweep-2026-06-17): zk-kit's `generateProof` returns a
18
+ * **compacted** proof — levels where the leaf has no right sibling are DROPPED
19
+ * from `siblings`, and `proof.index` is **recomputed** over only the surviving
20
+ * path bits (it is NOT the raw `leafIndex`). The circuit walks `actualDepth`
21
+ * real levels, decomposing `index` into bits and pairing bit `lvl` with
22
+ * `siblings[lvl]` — exactly the compacted shape. The previous implementation
23
+ * fed the RAW `leafIndex` bits alongside the compacted-then-zero-padded
24
+ * siblings; for any leaf whose path crosses a dropped level the index bits and
25
+ * siblings misaligned, producing a wrong root and an unsatisfiable witness.
26
+ * (It only worked for left-spine leaves where `proof.index === leafIndex` and
27
+ * no level is dropped — which is why the original committed fixture passed.)
28
+ *
29
+ * Correct mapping, matching zk-kit's own `verifyProof`:
30
+ * - `index` = proof.index (compacted path, NOT leafIndex)
31
+ * - `actualDepth` = siblings.length (number of real levels)
32
+ * - `siblings` = proof.siblings, zero-padded to TREE_DEPTH
33
+ * - `pathIndices` = bits of proof.index over TREE_DEPTH levels
34
+ *
35
+ * `actualDepth` and `index` are returned alongside so the witness builder can
36
+ * feed the circuit's `actualDepth`/`index` inputs directly.
37
+ */
38
+ export declare function generateMerkleProof(tree: LeanIMT, leafIndex: number): {
39
+ pathElements: bigint[];
40
+ pathIndices: number[];
41
+ index: number;
42
+ actualDepth: number;
43
+ root: bigint;
44
+ };
45
+ /**
46
+ * Get the tree depth constant.
47
+ */
48
+ export declare function getTreeDepth(): number;