@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/LICENSE +201 -0
- package/NOTICE +20 -0
- package/README.md +118 -0
- package/dist/account.d.ts +79 -0
- package/dist/account.js +140 -0
- package/dist/experimental.d.ts +27 -0
- package/dist/experimental.js +23 -0
- package/dist/facilitatorClient.d.ts +171 -0
- package/dist/facilitatorClient.js +146 -0
- package/dist/forwardingNotes.d.ts +103 -0
- package/dist/forwardingNotes.js +149 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +23 -0
- package/dist/merkle.d.ts +48 -0
- package/dist/merkle.js +84 -0
- package/dist/noteScanner.d.ts +206 -0
- package/dist/noteScanner.js +422 -0
- package/dist/notes.d.ts +305 -0
- package/dist/notes.js +547 -0
- package/dist/npk.d.ts +121 -0
- package/dist/npk.js +166 -0
- package/dist/proofs.d.ts +66 -0
- package/dist/proofs.js +45 -0
- package/dist/stealth.d.ts +136 -0
- package/dist/stealth.js +387 -0
- package/dist/swapPlan.d.ts +52 -0
- package/dist/swapPlan.js +104 -0
- package/dist/types.d.ts +172 -0
- package/dist/types.js +4 -0
- package/dist/viewingKeyHD.d.ts +108 -0
- package/dist/viewingKeyHD.js +176 -0
- package/package.json +71 -0
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/merkle.d.ts
ADDED
|
@@ -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;
|