@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,387 @@
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
+ import { secp256k1 } from "@noble/curves/secp256k1";
34
+ import { keccak_256 } from "@noble/hashes/sha3";
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // Types
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ /** ERC-5564 scheme identifier. Only scheme 1 (secp256k1 + keccak256) is supported. */
39
+ export const STEALTH_SCHEME_ID = 1;
40
+ /** Default chain tag used in meta-address URIs ("st:<chain>:0x..."). */
41
+ export const DEFAULT_CHAIN_TAG = "base";
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+ // Provider-side: meta-address generation
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ /**
46
+ * Produce a fresh ERC-5564 meta-address from an optional seed.
47
+ *
48
+ * The seed is intentionally optional: passing a deterministic seed enables
49
+ * reproducible tests; omitting it pulls 64 bytes of OS randomness. Either
50
+ * way, both keys live for the life of the provider — they are NOT rotated
51
+ * per call.
52
+ *
53
+ * @param seed Optional 64-byte Uint8Array. First 32 bytes seed the spending
54
+ * key, last 32 bytes seed the viewing key. If omitted, secure random.
55
+ */
56
+ export function generateMetaAddress(seed, chainTag = DEFAULT_CHAIN_TAG) {
57
+ let spendingPrivateKey;
58
+ let viewingPrivateKey;
59
+ if (seed) {
60
+ if (seed.length !== 64) {
61
+ throw new Error(`stealth: seed must be exactly 64 bytes, got ${seed.length}`);
62
+ }
63
+ spendingPrivateKey = clampToCurve(seed.slice(0, 32));
64
+ viewingPrivateKey = clampToCurve(seed.slice(32, 64));
65
+ }
66
+ else {
67
+ spendingPrivateKey = secp256k1.utils.randomPrivateKey();
68
+ viewingPrivateKey = secp256k1.utils.randomPrivateKey();
69
+ }
70
+ const spendingPublicKey = secp256k1.getPublicKey(spendingPrivateKey, true);
71
+ const viewingPublicKey = secp256k1.getPublicKey(viewingPrivateKey, true);
72
+ const metaAddress = `st:${chainTag}:0x${bytesToHex(spendingPublicKey)}${bytesToHex(viewingPublicKey)}`;
73
+ return {
74
+ metaAddress,
75
+ spendingPublicKey: "0x" + bytesToHex(spendingPublicKey),
76
+ viewingPublicKey: "0x" + bytesToHex(viewingPublicKey),
77
+ spendingPrivateKey: "0x" + bytesToHex(spendingPrivateKey),
78
+ viewingPrivateKey: "0x" + bytesToHex(viewingPrivateKey),
79
+ };
80
+ }
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 function parseMetaAddress(metaAddress) {
86
+ let chainTag = DEFAULT_CHAIN_TAG;
87
+ let raw;
88
+ if (metaAddress.startsWith("st:")) {
89
+ const parts = metaAddress.split(":");
90
+ if (parts.length !== 3) {
91
+ throw new Error(`stealth: malformed meta-address URI: ${metaAddress}`);
92
+ }
93
+ chainTag = parts[1];
94
+ raw = parts[2];
95
+ }
96
+ else {
97
+ raw = metaAddress;
98
+ }
99
+ if (raw.startsWith("0x"))
100
+ raw = raw.slice(2);
101
+ if (raw.length !== 132) {
102
+ throw new Error(`stealth: meta-address payload must be 132 hex chars (got ${raw.length})`);
103
+ }
104
+ if (!/^[0-9a-fA-F]+$/.test(raw)) {
105
+ throw new Error("stealth: meta-address payload must be hex");
106
+ }
107
+ const spendingPublicKey = hexToBytes(raw.slice(0, 66));
108
+ const viewingPublicKey = hexToBytes(raw.slice(66));
109
+ if (!isValidCompressedPubKey(spendingPublicKey)) {
110
+ throw new Error("stealth: spendingPublicKey is not a valid compressed secp256k1 point");
111
+ }
112
+ if (!isValidCompressedPubKey(viewingPublicKey)) {
113
+ throw new Error("stealth: viewingPublicKey is not a valid compressed secp256k1 point");
114
+ }
115
+ return { chainTag, spendingPublicKey, viewingPublicKey };
116
+ }
117
+ /**
118
+ * Quick sniff test: is this string an ERC-5564 meta-address (vs a plain
119
+ * 0x-address)? Used by the facilitator to decide which path to take.
120
+ */
121
+ export function isStealthMetaAddress(s) {
122
+ if (typeof s !== "string")
123
+ return false;
124
+ if (s.startsWith("st:"))
125
+ return true;
126
+ // bare 0x form is allowed: 132 hex chars + 0x prefix
127
+ if (s.startsWith("0x") && s.length === 134 && /^0x[0-9a-fA-F]+$/.test(s))
128
+ return true;
129
+ return false;
130
+ }
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+ // Facilitator-side: per-payment stealth derivation
133
+ // ─────────────────────────────────────────────────────────────────────────────
134
+ /**
135
+ * Derive a fresh stealth address for a provider, given their meta-address
136
+ * and a 32-byte ephemeral nonce (= ephemeral private key r).
137
+ *
138
+ * The facilitator publishes `ephemeralPublicKey` on-chain alongside the
139
+ * payment; the provider scans for it and recovers the funds. The nonce
140
+ * MUST be unpredictable to anyone but the facilitator and MUST NOT repeat
141
+ * (a repeat collapses two payments to the same stealth address, leaking
142
+ * the link). Pass `undefined` to generate a fresh CSPRNG nonce.
143
+ *
144
+ * @param metaAddress provider's "st:base:0x..." meta-address
145
+ * @param ephemeralNonce 32-byte scalar, or undefined for fresh randomness
146
+ */
147
+ export function deriveStealthAddress(metaAddress, ephemeralNonce) {
148
+ const { spendingPublicKey, viewingPublicKey } = parseMetaAddress(metaAddress);
149
+ // F8: the stealth private key the recipient later derives is
150
+ // k = (k_s + keccak256(r·K_v)) mod n
151
+ // and is UNSPENDABLE if it lands on 0. Pre-fix, the sender announced the
152
+ // ephemeral key R first and only the recipient discovered the doomed key —
153
+ // funds permanently stuck. Here, on the sender side, we detect the degenerate
154
+ // case (the derived stealth POINT being the curve identity, which is exactly
155
+ // when k ≡ 0) BEFORE returning anything to announce. With a random nonce we
156
+ // simply redraw; with a caller-supplied nonce we throw so the caller picks
157
+ // another. The event is ~1-in-2^256, so this never loops in practice.
158
+ const PP = secp256k1
159
+ .ProjectivePoint;
160
+ const n = secp256k1.CURVE.n;
161
+ for (let attempt = 0;; attempt++) {
162
+ const r = attempt === 0 && ephemeralNonce
163
+ ? clampToCurve(ephemeralNonce)
164
+ : secp256k1.utils.randomPrivateKey();
165
+ if (r.length !== 32) {
166
+ throw new Error(`stealth: ephemeralNonce must be 32 bytes, got ${r.length}`);
167
+ }
168
+ const ephemeralPublicKey = secp256k1.getPublicKey(r, true);
169
+ // S = r · K_v, compressed (33B). Hash with keccak256 per scheme 1.
170
+ const sharedPointCompressed = secp256k1.getSharedSecret(r, viewingPublicKey);
171
+ const sharedSecretHash = keccak_256(sharedPointCompressed);
172
+ // Necessary condition: the additive scalar must be non-zero mod n.
173
+ const sScalar = bytesToBigInt(sharedSecretHash) % n;
174
+ let degenerate = sScalar === 0n;
175
+ let stealthAddress = "";
176
+ if (!degenerate) {
177
+ try {
178
+ // Throws if the resulting point is the identity (k ≡ 0) — the exact
179
+ // unspendable case we must avoid announcing.
180
+ const hashedPoint = PP.BASE.multiply(sScalar);
181
+ const stealthPoint = PP.fromHex(spendingPublicKey).add(hashedPoint);
182
+ const uncompressed = stealthPoint.toRawBytes(false);
183
+ const addressBytes = keccak_256(uncompressed.slice(1)).slice(12);
184
+ stealthAddress = "0x" + bytesToHex(addressBytes);
185
+ }
186
+ catch {
187
+ degenerate = true;
188
+ }
189
+ }
190
+ if (degenerate) {
191
+ if (ephemeralNonce && attempt === 0) {
192
+ throw new Error("stealth: supplied ephemeralNonce yields an unspendable (zero) stealth key — pick another");
193
+ }
194
+ continue; // random path: redraw (astronomically rare)
195
+ }
196
+ const viewTag = bytesToHex(sharedSecretHash.slice(0, 1));
197
+ return {
198
+ stealthAddress,
199
+ ephemeralPublicKey: "0x" + bytesToHex(ephemeralPublicKey),
200
+ viewTag: "0x" + viewTag,
201
+ };
202
+ }
203
+ }
204
+ // ─────────────────────────────────────────────────────────────────────────────
205
+ // Provider-side: scanning + private-key recovery
206
+ // ─────────────────────────────────────────────────────────────────────────────
207
+ /**
208
+ * Given the provider's viewing private key and a batch of recent ephemeral
209
+ * pubkeys (from on-chain Announcement events or facilitator settle logs),
210
+ * return the stealth addresses that belong to this provider.
211
+ *
212
+ * View-tag optimization: if `expectedViewTags` is supplied, each candidate
213
+ * is filtered on its 1-byte view tag before the (more expensive) point add.
214
+ * This reduces scan cost by ~256× when most announcements aren't ours.
215
+ *
216
+ * @param viewingPrivateKey provider's viewing private key (32 bytes or hex)
217
+ * @param recentEphemeralPubkeys array of compressed ephemeral pubkeys
218
+ * @param spendingPublicKey provider's spending public key (needed to derive
219
+ * the stealth address from the shared secret)
220
+ * @param expectedViewTags optional array (same length as recentEphemeralPubkeys);
221
+ * if a tag is provided and doesn't match, the entry is skipped without
222
+ * doing the point arithmetic
223
+ */
224
+ export function scanForPayments(viewingPrivateKey, recentEphemeralPubkeys, spendingPublicKey, expectedViewTags) {
225
+ const kv = toBytes32(viewingPrivateKey);
226
+ const Ks = toCompressedPubKey(spendingPublicKey);
227
+ const matches = [];
228
+ for (let i = 0; i < recentEphemeralPubkeys.length; i++) {
229
+ const R = toCompressedPubKey(recentEphemeralPubkeys[i]);
230
+ // S = k_v · R, hashed.
231
+ const sharedPointCompressed = secp256k1.getSharedSecret(kv, R);
232
+ const sharedSecretHash = keccak_256(sharedPointCompressed);
233
+ const observedTag = sharedSecretHash[0];
234
+ // View-tag pre-filter: skip the point add if the tag is provided and mismatches.
235
+ if (expectedViewTags && expectedViewTags[i] !== undefined) {
236
+ const expected = parseViewTagByte(expectedViewTags[i]);
237
+ if (expected !== observedTag)
238
+ continue;
239
+ }
240
+ const stealthAddress = stealthAddressFromHashed(Ks, sharedSecretHash);
241
+ matches.push({
242
+ index: i,
243
+ ephemeralPublicKey: "0x" + bytesToHex(R),
244
+ stealthAddress,
245
+ viewTag: "0x" + bytesToHex(Uint8Array.of(observedTag)),
246
+ });
247
+ }
248
+ return matches;
249
+ }
250
+ /**
251
+ * Given the provider's spending+viewing private keys and an ephemeral pubkey
252
+ * that's known to belong to this provider, derive the stealth address's
253
+ * private key. With this key the provider can sweep funds to a treasury.
254
+ *
255
+ * k_stealth = (k_s + keccak256(k_v · R)) mod n
256
+ *
257
+ * @returns 32-byte stealth private key (raw bytes; 0x-encode if you need hex)
258
+ */
259
+ export function computeStealthPrivateKey(spendingPrivateKey, viewingPrivateKey, ephemeralPublicKey) {
260
+ const ks = toBytes32(spendingPrivateKey);
261
+ const kv = toBytes32(viewingPrivateKey);
262
+ const R = toCompressedPubKey(ephemeralPublicKey);
263
+ const sharedPointCompressed = secp256k1.getSharedSecret(kv, R);
264
+ const sharedSecretHash = keccak_256(sharedPointCompressed);
265
+ // Field is the curve's scalar order n.
266
+ const n = secp256k1.CURVE.n;
267
+ const sum = (bytesToBigInt(ks) + bytesToBigInt(sharedSecretHash)) % n;
268
+ if (sum === 0n) {
269
+ // 1-in-2^256 event; refuse rather than emit the zero key.
270
+ throw new Error("stealth: derived stealth private key is zero (refusing)");
271
+ }
272
+ return bigIntToBytes32(sum);
273
+ }
274
+ // ─────────────────────────────────────────────────────────────────────────────
275
+ // Internal helpers
276
+ // ─────────────────────────────────────────────────────────────────────────────
277
+ /** P = K_s + s_h · G → address = keccak256(uncompressed(P)[1:])[-20:] */
278
+ function stealthAddressFromHashed(spendingPubKeyCompressed, sharedSecretHash) {
279
+ // ProjectivePoint is exposed on @noble/curves secp256k1 for back-compat.
280
+ const PP = secp256k1
281
+ .ProjectivePoint;
282
+ const hashedPoint = PP.BASE.multiply(bytesToBigInt(sharedSecretHash));
283
+ const spendingPoint = PP.fromHex(spendingPubKeyCompressed);
284
+ const stealthPoint = spendingPoint.add(hashedPoint);
285
+ const uncompressed = stealthPoint.toRawBytes(false); // 65 bytes, 0x04 || X || Y
286
+ const addressBytes = keccak_256(uncompressed.slice(1)).slice(12);
287
+ return "0x" + bytesToHex(addressBytes);
288
+ }
289
+ function clampToCurve(scalar) {
290
+ if (scalar.length !== 32) {
291
+ throw new Error(`stealth: scalar must be 32 bytes (got ${scalar.length})`);
292
+ }
293
+ const n = secp256k1.CURVE.n;
294
+ let v = bytesToBigInt(scalar) % n;
295
+ if (v === 0n)
296
+ v = 1n; // never emit the zero scalar
297
+ return bigIntToBytes32(v);
298
+ }
299
+ function isValidCompressedPubKey(bytes) {
300
+ if (bytes.length !== 33)
301
+ return false;
302
+ if (bytes[0] !== 0x02 && bytes[0] !== 0x03)
303
+ return false;
304
+ try {
305
+ const PP = secp256k1
306
+ .ProjectivePoint;
307
+ PP.fromHex(bytes);
308
+ return true;
309
+ }
310
+ catch {
311
+ return false;
312
+ }
313
+ }
314
+ function toBytes32(input) {
315
+ if (input instanceof Uint8Array) {
316
+ if (input.length !== 32)
317
+ throw new Error(`stealth: expected 32 bytes, got ${input.length}`);
318
+ return input;
319
+ }
320
+ let s = input.startsWith("0x") ? input.slice(2) : input;
321
+ if (s.length !== 64)
322
+ throw new Error(`stealth: expected 32-byte hex, got ${s.length / 2} bytes`);
323
+ return hexToBytes(s);
324
+ }
325
+ function toCompressedPubKey(input) {
326
+ if (input instanceof Uint8Array) {
327
+ if (input.length === 33)
328
+ return input;
329
+ if (input.length === 65) {
330
+ // Re-compress: just take parity bit + X.
331
+ const PP = secp256k1
332
+ .ProjectivePoint;
333
+ return PP.fromHex(input).toRawBytes(true);
334
+ }
335
+ throw new Error(`stealth: pubkey must be 33B (compressed) or 65B (uncompressed), got ${input.length}`);
336
+ }
337
+ let s = input.startsWith("0x") ? input.slice(2) : input;
338
+ if (s.length !== 66 && s.length !== 130) {
339
+ throw new Error(`stealth: pubkey hex must be 66 or 130 chars, got ${s.length}`);
340
+ }
341
+ return toCompressedPubKey(hexToBytes(s));
342
+ }
343
+ function parseViewTagByte(input) {
344
+ if (input instanceof Uint8Array) {
345
+ if (input.length === 0)
346
+ throw new Error("stealth: empty view tag");
347
+ return input[0];
348
+ }
349
+ let s = input.startsWith("0x") ? input.slice(2) : input;
350
+ if (s.length < 2)
351
+ throw new Error(`stealth: view tag too short: ${input}`);
352
+ return parseInt(s.slice(0, 2), 16);
353
+ }
354
+ function bytesToHex(bytes) {
355
+ let out = "";
356
+ for (const b of bytes)
357
+ out += b.toString(16).padStart(2, "0");
358
+ return out;
359
+ }
360
+ function hexToBytes(hex) {
361
+ const s = hex.startsWith("0x") ? hex.slice(2) : hex;
362
+ if (s.length % 2 !== 0)
363
+ throw new Error("stealth: odd-length hex");
364
+ const out = new Uint8Array(s.length / 2);
365
+ for (let i = 0; i < out.length; i++) {
366
+ out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
367
+ }
368
+ return out;
369
+ }
370
+ function bytesToBigInt(bytes) {
371
+ let v = 0n;
372
+ for (const b of bytes)
373
+ v = (v << 8n) | BigInt(b);
374
+ return v;
375
+ }
376
+ function bigIntToBytes32(v) {
377
+ if (v < 0n)
378
+ throw new Error("stealth: negative scalar");
379
+ const out = new Uint8Array(32);
380
+ for (let i = 31; i >= 0; i--) {
381
+ out[i] = Number(v & 0xffn);
382
+ v >>= 8n;
383
+ }
384
+ if (v !== 0n)
385
+ throw new Error("stealth: scalar > 2^256");
386
+ return out;
387
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Build a `CallPlan` for private swap-as-settlement: an agent pays the pool asset
3
+ * (USDC) and the seller receives a DIFFERENT token, unlinkably, in one settlement.
4
+ *
5
+ * This is NOT a private trading venue — it is private SETTLEMENT that happens to
6
+ * cross assets. The ExecutorProcessooor hides WHO funded the swap; the swap itself
7
+ * is public on-chain. See the zBase threat model + docs/strategy/xochi-competitive-
8
+ * analysis-2026-07-08.md §7.
9
+ *
10
+ * The executor whitelists a swap router `(target, selector)` at construction. This
11
+ * helper produces the exact `exactInputSingle` calldata for that router.
12
+ *
13
+ * ── The load-bearing constraint (audit F1) ───────────────────────────────────────
14
+ * The executor measures the produced output on ITS OWN balance and then sweeps to
15
+ * the final recipient. So the router's `recipient` field MUST be the executor, NOT
16
+ * the seller. If you point the router at the seller directly, the executor sees
17
+ * outDelta == 0 and reverts `NoOutputProduced`. This helper hardcodes
18
+ * `recipient = executor` so a caller cannot get it wrong — the seller is carried in
19
+ * the CallPlan's own `recipient` field, which the executor sweeps to afterward.
20
+ */
21
+ import type { CallPlan } from "./facilitatorClient.js";
22
+ /** Uniswap V3 SwapRouter02 `exactInputSingle` selector — the whitelisted selector. */
23
+ export declare const EXACT_INPUT_SINGLE_SELECTOR: "0x04e45aaf";
24
+ export interface BuildSwapCallPlanOpts {
25
+ /** The whitelisted Uniswap V3 SwapRouter02 address (executor's frozen whitelist). */
26
+ router: string;
27
+ /** The deployed ExecutorProcessooor — the swap recipient MUST be this (audit F1). */
28
+ executor: string;
29
+ /** Pool asset spent into the swap (USDC). Must equal the pool's ASSET. */
30
+ inputToken: string;
31
+ /** Token the seller wants to receive (e.g. EURC). Must differ from inputToken (F5). */
32
+ outputToken: string;
33
+ /** The Uniswap V3 pool fee tier (e.g. 500, 3000, 10000). */
34
+ poolFee: number;
35
+ /** Atomic amount of inputToken to swap (post-fee spend). */
36
+ amountIn: bigint;
37
+ /** Slippage floor on outputToken (atomic). MUST be > 0 (audit F2). */
38
+ minOut: bigint;
39
+ /** The final seller/recipient of outputToken — the executor sweeps here after the swap. */
40
+ recipient: string;
41
+ }
42
+ /**
43
+ * Build a well-formed `CallPlan` for a Uniswap-V3-style single-hop swap.
44
+ *
45
+ * Enforces the executor's invariants up front so a malformed plan is caught here,
46
+ * not at a wasted on-chain revert:
47
+ * - `recipient` in the router calldata = the executor (F1)
48
+ * - `inputToken != outputToken` (F5)
49
+ * - `minOut > 0` (F2)
50
+ * - `amountOutMinimum` in calldata == the CallPlan `minOut` (consistency)
51
+ */
52
+ export declare function buildSwapCallPlan(opts: BuildSwapCallPlanOpts): CallPlan;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Build a `CallPlan` for private swap-as-settlement: an agent pays the pool asset
3
+ * (USDC) and the seller receives a DIFFERENT token, unlinkably, in one settlement.
4
+ *
5
+ * This is NOT a private trading venue — it is private SETTLEMENT that happens to
6
+ * cross assets. The ExecutorProcessooor hides WHO funded the swap; the swap itself
7
+ * is public on-chain. See the zBase threat model + docs/strategy/xochi-competitive-
8
+ * analysis-2026-07-08.md §7.
9
+ *
10
+ * The executor whitelists a swap router `(target, selector)` at construction. This
11
+ * helper produces the exact `exactInputSingle` calldata for that router.
12
+ *
13
+ * ── The load-bearing constraint (audit F1) ───────────────────────────────────────
14
+ * The executor measures the produced output on ITS OWN balance and then sweeps to
15
+ * the final recipient. So the router's `recipient` field MUST be the executor, NOT
16
+ * the seller. If you point the router at the seller directly, the executor sees
17
+ * outDelta == 0 and reverts `NoOutputProduced`. This helper hardcodes
18
+ * `recipient = executor` so a caller cannot get it wrong — the seller is carried in
19
+ * the CallPlan's own `recipient` field, which the executor sweeps to afterward.
20
+ */
21
+ import { encodeFunctionData, getAddress } from "viem";
22
+ /** Uniswap V3 SwapRouter02 `exactInputSingle` selector — the whitelisted selector. */
23
+ export const EXACT_INPUT_SINGLE_SELECTOR = "0x04e45aaf";
24
+ /**
25
+ * Minimal ABI for Uniswap V3 SwapRouter02 `exactInputSingle`. SwapRouter02 drops the
26
+ * `deadline` field that the original SwapRouter carried, so the params tuple is 7
27
+ * fields. `amountOutMinimum` is the on-chain slippage floor; we set it equal to the
28
+ * CallPlan `minOut` the executor independently enforces (belt and suspenders).
29
+ */
30
+ const EXACT_INPUT_SINGLE_ABI = [
31
+ {
32
+ name: "exactInputSingle",
33
+ type: "function",
34
+ stateMutability: "payable",
35
+ inputs: [
36
+ {
37
+ name: "params",
38
+ type: "tuple",
39
+ components: [
40
+ { name: "tokenIn", type: "address" },
41
+ { name: "tokenOut", type: "address" },
42
+ { name: "fee", type: "uint24" },
43
+ { name: "recipient", type: "address" },
44
+ { name: "amountIn", type: "uint256" },
45
+ { name: "amountOutMinimum", type: "uint256" },
46
+ { name: "sqrtPriceLimitX96", type: "uint160" },
47
+ ],
48
+ },
49
+ ],
50
+ outputs: [{ name: "amountOut", type: "uint256" }],
51
+ },
52
+ ];
53
+ /**
54
+ * Build a well-formed `CallPlan` for a Uniswap-V3-style single-hop swap.
55
+ *
56
+ * Enforces the executor's invariants up front so a malformed plan is caught here,
57
+ * not at a wasted on-chain revert:
58
+ * - `recipient` in the router calldata = the executor (F1)
59
+ * - `inputToken != outputToken` (F5)
60
+ * - `minOut > 0` (F2)
61
+ * - `amountOutMinimum` in calldata == the CallPlan `minOut` (consistency)
62
+ */
63
+ export function buildSwapCallPlan(opts) {
64
+ const inputToken = getAddress(opts.inputToken);
65
+ const outputToken = getAddress(opts.outputToken);
66
+ const executor = getAddress(opts.executor);
67
+ const router = getAddress(opts.router);
68
+ const recipient = getAddress(opts.recipient);
69
+ if (inputToken === outputToken) {
70
+ throw new Error("buildSwapCallPlan: inputToken must differ from outputToken (executor audit F5)");
71
+ }
72
+ if (opts.minOut <= 0n) {
73
+ throw new Error("buildSwapCallPlan: minOut must be > 0 (executor audit F2)");
74
+ }
75
+ if (opts.amountIn <= 0n) {
76
+ throw new Error("buildSwapCallPlan: amountIn must be > 0");
77
+ }
78
+ const callData = encodeFunctionData({
79
+ abi: EXACT_INPUT_SINGLE_ABI,
80
+ functionName: "exactInputSingle",
81
+ args: [
82
+ {
83
+ tokenIn: inputToken,
84
+ tokenOut: outputToken,
85
+ fee: opts.poolFee,
86
+ // ★ audit F1: the swap output goes to the EXECUTOR, which then sweeps to
87
+ // the CallPlan `recipient` (the seller). Pointing this at the seller
88
+ // directly makes outDelta == 0 → NoOutputProduced revert.
89
+ recipient: executor,
90
+ amountIn: opts.amountIn,
91
+ amountOutMinimum: opts.minOut,
92
+ sqrtPriceLimitX96: 0n,
93
+ },
94
+ ],
95
+ });
96
+ return {
97
+ target: router,
98
+ inputToken,
99
+ outputToken,
100
+ minOut: opts.minOut.toString(),
101
+ recipient, // the seller — the executor sweeps outputToken here after the swap
102
+ callData,
103
+ };
104
+ }