@shroud-fi/agent-runtime 0.1.4 → 0.1.6

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.
Files changed (45) hide show
  1. package/package.json +11 -8
  2. package/src/agent.ts +829 -0
  3. package/src/browser.ts +126 -0
  4. package/src/constants.ts +20 -0
  5. package/src/contacts.ts +174 -0
  6. package/src/errors.ts +205 -0
  7. package/src/factory.ts +51 -0
  8. package/src/index.ts +57 -0
  9. package/src/types.ts +176 -0
  10. package/tsconfig.json +9 -0
  11. package/dist/cjs/agent.d.ts.map +0 -1
  12. package/dist/cjs/agent.js.map +0 -1
  13. package/dist/cjs/browser.d.ts.map +0 -1
  14. package/dist/cjs/browser.js.map +0 -1
  15. package/dist/cjs/constants.d.ts.map +0 -1
  16. package/dist/cjs/constants.js.map +0 -1
  17. package/dist/cjs/contacts.d.ts.map +0 -1
  18. package/dist/cjs/contacts.js.map +0 -1
  19. package/dist/cjs/errors.d.ts.map +0 -1
  20. package/dist/cjs/errors.js.map +0 -1
  21. package/dist/cjs/factory.d.ts.map +0 -1
  22. package/dist/cjs/factory.js.map +0 -1
  23. package/dist/cjs/index.d.ts.map +0 -1
  24. package/dist/cjs/index.js.map +0 -1
  25. package/dist/cjs/types.d.ts.map +0 -1
  26. package/dist/cjs/types.js.map +0 -1
  27. package/dist/esm/agent.d.ts.map +0 -1
  28. package/dist/esm/agent.js.map +0 -1
  29. package/dist/esm/browser.d.ts.map +0 -1
  30. package/dist/esm/browser.js.map +0 -1
  31. package/dist/esm/constants.d.ts.map +0 -1
  32. package/dist/esm/constants.js.map +0 -1
  33. package/dist/esm/contacts.d.ts.map +0 -1
  34. package/dist/esm/contacts.js.map +0 -1
  35. package/dist/esm/errors.d.ts.map +0 -1
  36. package/dist/esm/errors.js.map +0 -1
  37. package/dist/esm/factory.d.ts.map +0 -1
  38. package/dist/esm/factory.js.map +0 -1
  39. package/dist/esm/index.d.ts.map +0 -1
  40. package/dist/esm/index.js.map +0 -1
  41. package/dist/esm/types.d.ts.map +0 -1
  42. package/dist/esm/types.js.map +0 -1
  43. package/dist/tsconfig.cjs.tsbuildinfo +0 -1
  44. package/dist/tsconfig.esm.tsbuildinfo +0 -1
  45. package/dist/tsconfig.tsbuildinfo +0 -1
package/src/agent.ts ADDED
@@ -0,0 +1,829 @@
1
+ /**
2
+ * ShroudAgent — the ergonomic runtime that composes core + payments + scanning
3
+ * + transport into a single class.
4
+ *
5
+ * Privacy invariants (binding — code-reviewer will check):
6
+ * - No console.* anywhere in this file.
7
+ * - No JSON.stringify of keys, identity, detection, signature, or masterSeed.
8
+ * - No key bytes / signature bytes in error messages.
9
+ * - No amount values in error messages.
10
+ * - The spend key never leaves runtime; the scanning module needs it for
11
+ * ECDH derivation only.
12
+ */
13
+
14
+ import type { Address, Hash, Hex } from 'viem';
15
+ import { bytesToHex, concatBytes } from 'viem';
16
+ import {
17
+ encodeMetaAddress,
18
+ decodeMetaAddress,
19
+ } from '@shroud-fi/core';
20
+ import type {
21
+ AgentIdentity,
22
+ StealthMetaAddress,
23
+ } from '@shroud-fi/core';
24
+ import {
25
+ sendETHPayment,
26
+ sendERC20Payment,
27
+ sendToWallet,
28
+ sweepETH,
29
+ sweepERC20,
30
+ ETH_SENTINEL,
31
+ } from '@shroud-fi/payments';
32
+ import type {
33
+ PaymentReceipt,
34
+ SendOptions,
35
+ SendToWalletAsset,
36
+ } from '@shroud-fi/payments';
37
+ import { createScanner } from '@shroud-fi/scanning';
38
+ import type {
39
+ DetectedPayment,
40
+ FinalityLevel,
41
+ Scanner,
42
+ ScannerBackend,
43
+ } from '@shroud-fi/scanning';
44
+ import type { ShroudFiTransport } from '@shroud-fi/transport';
45
+ import {
46
+ ERC6538_REGISTRY,
47
+ ERC6538RegistryAbi,
48
+ getRelayer,
49
+ getEthRelayer,
50
+ } from '@shroud-fi/transport';
51
+ import {
52
+ AgentAlreadyStartedError,
53
+ AlreadyRegisteredError,
54
+ AutoRegistrationFailedError,
55
+ EthRelayerContractNotConfiguredError,
56
+ EthRelayerEndpointRequiredError,
57
+ RegistrationRequiresWalletError,
58
+ RelayerContractNotConfiguredError,
59
+ } from './errors.js';
60
+ import { ContactBook } from './contacts.js';
61
+ import type {
62
+ AgentRelayerEthSweepOptions,
63
+ AgentRelayerEthSweepResult,
64
+ AgentRelayerSweepOptions,
65
+ AgentRelayerSweepResult,
66
+ AgentSweepResult,
67
+ ShroudAgentStartOptions,
68
+ } from './types.js';
69
+
70
+ /**
71
+ * Internal options that construct a ShroudAgent. The factory + browser factory
72
+ * funnel all configuration into this shape so the class itself stays simple.
73
+ *
74
+ * @internal — not exported via the public barrel. Use createShroudAgent.
75
+ */
76
+ export interface ShroudAgentInternalOptions {
77
+ readonly identity: AgentIdentity;
78
+ readonly transport: ShroudFiTransport;
79
+ readonly stealthContract: Address;
80
+ /** Override of the ERC-5564 announcer contract; defaults to canonical. */
81
+ readonly announcer?: Address;
82
+ readonly startBlock: bigint;
83
+ readonly finality?: FinalityLevel;
84
+ /** Test-only override of the scanner backend. Never set in production paths. */
85
+ readonly backend?: ScannerBackend;
86
+ /**
87
+ * M3 — when true, `sendToWallet` ensures the agent is registered in the
88
+ * canonical ERC-6538 registry before dispatching the payment.
89
+ */
90
+ readonly autoRegister?: boolean;
91
+ /**
92
+ * v0.1.1 — optional override of the address-book JSON path. Defaults to
93
+ * `~/.shroudfi/contacts.json`. Tests / sandboxed agents may want a tmpdir.
94
+ */
95
+ readonly contactsFilePath?: string;
96
+ }
97
+
98
+ /**
99
+ * ETH zero-address sentinel — some callers may pass this to indicate "ETH" on
100
+ * the sweep dispatch. Kept beside the canonical ETH_SENTINEL constant.
101
+ */
102
+ const ZERO_ADDRESS_ETH: Address =
103
+ '0x0000000000000000000000000000000000000000';
104
+
105
+ /**
106
+ * The ShroudAgent class. Wraps an EIP-5564 identity + the four ShroudFi
107
+ * packages behind a single ergonomic surface.
108
+ *
109
+ * Constructor is exported but typically reached through `createShroudAgent` or
110
+ * `createShroudAgentFromBrowserWallet`. Direct construction is supported for
111
+ * tests that need to inject a mock scanner backend.
112
+ */
113
+ export class ShroudAgent {
114
+ readonly identity: AgentIdentity;
115
+ readonly metaAddress: StealthMetaAddress;
116
+ readonly metaAddressEncoded: string;
117
+ readonly transport: ShroudFiTransport;
118
+ readonly stealthContract: Address;
119
+ /**
120
+ * v0.1.1 — local-only address book. Pure persistence layer. ShroudFi never
121
+ * uses contact data on the network; resolving a label to an address is the
122
+ * caller's job. See `ContactBook` in @shroud-fi/agent-runtime.
123
+ *
124
+ * Built LAZILY on first access (see the getter below). ContactBook's
125
+ * constructor touches os.homedir()/fs to resolve + read its JSON file; doing
126
+ * that eagerly in the ShroudAgent constructor crashed browser bundles where
127
+ * webpack stubs fs/os/path to {} → "Connection failed (TypeError)" the moment
128
+ * a wallet connected. Browser agents that never call `.contacts` now never
129
+ * reach the filesystem. See test/browser-contacts-lazy.test.ts.
130
+ */
131
+ private _contacts: ContactBook | undefined;
132
+ private readonly contactsFilePath: string | undefined;
133
+
134
+ private readonly finality: FinalityLevel;
135
+ private readonly startBlock: bigint;
136
+ private readonly announcer: Address | undefined;
137
+ private readonly backendOverride: ScannerBackend | undefined;
138
+ private readonly autoRegister: boolean;
139
+ private scanner: Scanner | undefined;
140
+ private isStarted = false;
141
+ private controller: AbortController | undefined;
142
+
143
+ constructor(opts: ShroudAgentInternalOptions) {
144
+ this.identity = opts.identity;
145
+ this.metaAddress = opts.identity.metaAddress;
146
+ this.metaAddressEncoded = encodeMetaAddress(opts.identity.metaAddress);
147
+ this.transport = opts.transport;
148
+ this.stealthContract = opts.stealthContract;
149
+ this.startBlock = opts.startBlock;
150
+ this.finality = opts.finality ?? 'safe';
151
+ this.announcer = opts.announcer;
152
+ this.backendOverride = opts.backend;
153
+ this.autoRegister = opts.autoRegister ?? false;
154
+ // Stash the path only — the ContactBook (and its os/fs access) is deferred
155
+ // to the `contacts` getter so plain connect/scan flows never touch disk.
156
+ this.contactsFilePath = opts.contactsFilePath;
157
+ // Scanner is built lazily in start() — see H8 in docs/debug/P4-STEP3-STALL.md.
158
+ // Reusing one Scanner across start/stop/start cycles silently dropped events
159
+ // because Scanner.close() permanently sets its `closed` flag.
160
+ }
161
+
162
+ /**
163
+ * Local-only address book, constructed on first access. Node consumers use
164
+ * `agent.contacts.add(...)` exactly as before; browser consumers that never
165
+ * touch it never construct a ContactBook and so never reach os/fs.
166
+ */
167
+ get contacts(): ContactBook {
168
+ if (this._contacts === undefined) {
169
+ this._contacts =
170
+ this.contactsFilePath !== undefined
171
+ ? new ContactBook({ filePath: this.contactsFilePath })
172
+ : new ContactBook();
173
+ }
174
+ return this._contacts;
175
+ }
176
+
177
+ /**
178
+ * Build a fresh Scanner with this agent's config. Called from start() so
179
+ * each lifecycle gets its own scanner instance (a closed scanner can't be
180
+ * reopened — see H8).
181
+ */
182
+ private buildScanner(): Scanner {
183
+ return createScanner({
184
+ transport: this.transport,
185
+ scanningKey: this.identity.keys.scanningKey,
186
+ spendingKey: this.identity.keys.spendingKey,
187
+ startBlock: this.startBlock,
188
+ finality: this.finality,
189
+ ...(this.announcer !== undefined ? { contractAddress: this.announcer } : {}),
190
+ ...(this.backendOverride !== undefined ? { backend: this.backendOverride } : {}),
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Start receiving detected payments.
196
+ *
197
+ * Returns an AsyncIterable; consumer drives via `for await`. If
198
+ * `opts.backfillFrom` is set, the first phase yields scanRange results,
199
+ * then transitions to live watch().
200
+ *
201
+ * @throws AgentAlreadyStartedError if called twice without stop().
202
+ */
203
+ start(opts?: ShroudAgentStartOptions): AsyncIterable<DetectedPayment> {
204
+ if (this.isStarted) {
205
+ throw new AgentAlreadyStartedError();
206
+ }
207
+ this.isStarted = true;
208
+
209
+ const controller = new AbortController();
210
+ this.controller = controller;
211
+
212
+ // Forward external signal to internal controller.
213
+ const externalSignal = opts?.signal;
214
+ if (externalSignal !== undefined) {
215
+ if (externalSignal.aborted) {
216
+ controller.abort();
217
+ } else {
218
+ externalSignal.addEventListener(
219
+ 'abort',
220
+ () => controller.abort(),
221
+ { once: true },
222
+ );
223
+ }
224
+ }
225
+
226
+ // Build a fresh scanner for THIS start() cycle. Reusing the same scanner
227
+ // across start/stop/start would silently drop events because Scanner.close()
228
+ // sets a permanent `closed` flag inside detector.ts. See H8.
229
+ const scanner = this.buildScanner();
230
+ this.scanner = scanner;
231
+
232
+ const finality = this.finality;
233
+ const transport = this.transport;
234
+ const backfillFrom = opts?.backfillFrom;
235
+ const resetStarted = (): void => {
236
+ this.isStarted = false;
237
+ };
238
+
239
+ // EAGERLY open the live watch subscription before this method returns.
240
+ // `scanner.watch()` is lazy — the backend.watchAnnouncements registration
241
+ // only fires when [Symbol.asyncIterator]() is called. If we left this to
242
+ // the generator body below (which runs on first iter.next()), a caller
243
+ // pattern like:
244
+ // const iter = agent.start();
245
+ // await agent.send(...) // tx mines before we subscribe
246
+ // for await (const d of iter) ... // never sees the event
247
+ // would race and miss the announcement. Subscribing here closes the gap.
248
+ const watchIterator: AsyncIterator<DetectedPayment> =
249
+ scanner.watch(controller.signal)[Symbol.asyncIterator]();
250
+
251
+ return {
252
+ [Symbol.asyncIterator]: async function* () {
253
+ try {
254
+ if (backfillFrom !== undefined) {
255
+ // Determine the latest block we can backfill to. The tag mirrors
256
+ // the configured finality so unfinalized history is not yielded
257
+ // for 'safe' / 'finalized' callers. 'unsafe' callers (and chains
258
+ // that don't track 'safe'/'finalized' — e.g. Anvil) use 'latest'
259
+ // so the backfill reaches the actual head.
260
+ const tag: 'finalized' | 'safe' | 'latest' =
261
+ finality === 'finalized'
262
+ ? 'finalized'
263
+ : finality === 'safe'
264
+ ? 'safe'
265
+ : 'latest';
266
+ const latestBlock = await transport.publicClient.getBlock({
267
+ blockTag: tag,
268
+ });
269
+ const latestSafe = latestBlock.number ?? backfillFrom;
270
+
271
+ if (latestSafe >= backfillFrom) {
272
+ for await (const detection of scanner.scanRange(
273
+ backfillFrom,
274
+ latestSafe,
275
+ controller.signal,
276
+ )) {
277
+ if (controller.signal.aborted) return;
278
+ yield detection;
279
+ }
280
+ }
281
+ }
282
+
283
+ // Drain the live watch iterator we eagerly opened above.
284
+ while (true) {
285
+ if (controller.signal.aborted) return;
286
+ const r = await watchIterator.next();
287
+ if (r.done) return;
288
+ yield r.value;
289
+ }
290
+ } finally {
291
+ // Tear down the eagerly-opened watch iterator so scanner.watchActive
292
+ // resets and a follow-up start() can resubscribe.
293
+ try {
294
+ await watchIterator.return?.({
295
+ value: undefined,
296
+ done: true,
297
+ } as IteratorResult<DetectedPayment>);
298
+ } catch {
299
+ // close path must never throw out of finally.
300
+ }
301
+ resetStarted();
302
+ }
303
+ },
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Stop the agent. Aborts the internal signal, closes the current scanner,
309
+ * and clears the reference so the next start() builds a fresh one.
310
+ * Idempotent — safe to call multiple times or before start().
311
+ */
312
+ stop(): void {
313
+ if (this.controller !== undefined) {
314
+ try {
315
+ this.controller.abort();
316
+ } catch {
317
+ // Swallow — stop must not throw.
318
+ }
319
+ this.controller = undefined;
320
+ }
321
+ if (this.scanner !== undefined) {
322
+ try {
323
+ this.scanner.close();
324
+ } catch {
325
+ // Swallow — stop must not throw.
326
+ }
327
+ this.scanner = undefined;
328
+ }
329
+ this.isStarted = false;
330
+ }
331
+
332
+ /**
333
+ * Send native ETH to a recipient stealth meta-address.
334
+ *
335
+ * Accepts either a parsed StealthMetaAddress or the encoded string form
336
+ * (`st:base:0x...`). String form is decoded via decodeMetaAddress before use.
337
+ */
338
+ async send(
339
+ to: StealthMetaAddress | string,
340
+ valueWei: bigint,
341
+ ): Promise<PaymentReceipt> {
342
+ const meta = typeof to === 'string' ? decodeMetaAddress(to) : to;
343
+ return sendETHPayment(this.transport, this.stealthContract, meta, valueWei);
344
+ }
345
+
346
+ /**
347
+ * Send an ERC-20 token to a recipient stealth meta-address.
348
+ *
349
+ * Caller MUST have already approved the stealth contract to spend the token
350
+ * — v1 does not include an approve flow.
351
+ */
352
+ async sendERC20(
353
+ to: StealthMetaAddress | string,
354
+ token: Address,
355
+ amount: bigint,
356
+ ): Promise<PaymentReceipt> {
357
+ const meta = typeof to === 'string' ? decodeMetaAddress(to) : to;
358
+ return sendERC20Payment(
359
+ this.transport,
360
+ this.stealthContract,
361
+ meta,
362
+ token,
363
+ amount,
364
+ );
365
+ }
366
+
367
+ /**
368
+ * M3 — read the canonical ERC-6538 registry and return whether the agent's
369
+ * registrant wallet has a non-empty stealth meta-address on file for
370
+ * scheme 1 (the ShroudFi default).
371
+ *
372
+ * The registrant is keyed by `transport.walletClient.account.address` —
373
+ * the EOA that would sign the `registerKeys` transaction. Read-only
374
+ * transports (no walletClient or no account) cannot be registered and
375
+ * therefore return `false`.
376
+ *
377
+ * No side effects, no gas. Safe to call before every send.
378
+ */
379
+ async isRegistered(): Promise<boolean> {
380
+ const walletClient = this.transport.walletClient;
381
+ if (walletClient === undefined) return false;
382
+ const account = walletClient.account;
383
+ if (account === undefined) return false;
384
+ const registrant = account.address;
385
+
386
+ const raw = (await this.transport.publicClient.readContract({
387
+ address: ERC6538_REGISTRY,
388
+ abi: ERC6538RegistryAbi,
389
+ functionName: 'stealthMetaAddressOf',
390
+ args: [registrant, REGISTRY_SCHEME_ID],
391
+ })) as Hex;
392
+
393
+ // viem returns '0x' for empty bytes. Anything longer is a registration.
394
+ return raw !== '0x' && raw.length > 2;
395
+ }
396
+
397
+ /**
398
+ * M3 — publish the agent's stealth meta-address (33-byte spending pubkey
399
+ * concatenated with 33-byte viewing pubkey) into the canonical ERC-6538
400
+ * registry under scheme 1.
401
+ *
402
+ * Throws `AlreadyRegisteredError` if the registrant wallet already has a
403
+ * non-empty entry. Throws `RegistrationRequiresWalletError` if the
404
+ * transport has no walletClient with an account.
405
+ *
406
+ * Privacy: only the agent's PUBLIC keys cross the wire (these are the
407
+ * same bytes that already live in `metaAddressEncoded`). The spending
408
+ * private key is never touched by this path.
409
+ *
410
+ * @returns the transaction hash of the `registerKeys` call.
411
+ */
412
+ async register(): Promise<Hash> {
413
+ const walletClient = this.transport.walletClient;
414
+ if (walletClient === undefined) {
415
+ throw new RegistrationRequiresWalletError();
416
+ }
417
+ const account = walletClient.account;
418
+ if (account === undefined) {
419
+ throw new RegistrationRequiresWalletError();
420
+ }
421
+
422
+ // Fail fast if already registered. This both saves the operator a gas
423
+ // payment and keeps `register()` honest: the only way to overwrite an
424
+ // existing entry on the canonical registry is to bump the per-scheme
425
+ // nonce, which is out of scope for v1.
426
+ if (await this.isRegistered()) {
427
+ throw new AlreadyRegisteredError();
428
+ }
429
+
430
+ // Build the 66-byte payload: 33 spending pubkey || 33 viewing pubkey.
431
+ // The registrar contract layout is confirmed by
432
+ // contracts/src/ShroudFiRegistrar.sol (each pubkey is a compressed
433
+ // secp256k1 point — 33 bytes, prefix 0x02 / 0x03).
434
+ const payload = concatBytes([
435
+ this.metaAddress.spendingPubKey,
436
+ this.metaAddress.viewingPubKey,
437
+ ]);
438
+ const stealthMetaAddress = bytesToHex(payload);
439
+
440
+ const writeArgs: Record<string, unknown> = {
441
+ address: ERC6538_REGISTRY,
442
+ abi: ERC6538RegistryAbi,
443
+ functionName: 'registerKeys',
444
+ args: [REGISTRY_SCHEME_ID, stealthMetaAddress],
445
+ account,
446
+ chain: this.transport.chain,
447
+ };
448
+
449
+ // viem's writeContract has a complex union signature — cast at the
450
+ // boundary, mirroring the pattern used in @shroud-fi/payments.
451
+ const txHash = (await (
452
+ walletClient.writeContract as (a: unknown) => Promise<Hash>
453
+ )(writeArgs)) as Hash;
454
+
455
+ return txHash;
456
+ }
457
+
458
+ /**
459
+ * M3 — composite, idempotent registration helper.
460
+ *
461
+ * Returns `{ registered: false }` if the agent is already registered
462
+ * (no tx broadcast). Otherwise returns `{ registered: true, txHash }`
463
+ * with the registration tx hash.
464
+ *
465
+ * Safe to call before every send — the check is a single eth_call.
466
+ */
467
+ async ensureRegistered(): Promise<{
468
+ registered: boolean;
469
+ txHash?: Hash;
470
+ }> {
471
+ if (await this.isRegistered()) {
472
+ return { registered: false };
473
+ }
474
+ const txHash = await this.register();
475
+ return { registered: true, txHash };
476
+ }
477
+
478
+ /**
479
+ * M4 — ergonomic helper: pay an EVM wallet by its plain address, no
480
+ * meta-address handling on the caller side.
481
+ *
482
+ * Delegates to `sendToWallet` from `@shroud-fi/payments` which does the
483
+ * ERC-6538 registry lookup, decodes the recipient's stealth meta-address,
484
+ * and dispatches `sendETHPayment` or `sendERC20Payment` as appropriate.
485
+ *
486
+ * If the agent was constructed with `autoRegister: true`, this method
487
+ * calls `ensureRegistered` BEFORE dispatching the payment so the agent
488
+ * itself becomes reachable as a stealth-payment recipient the first time
489
+ * it pays someone. A failure inside `ensureRegistered` is wrapped in
490
+ * `AutoRegistrationFailedError` so callers can distinguish setup failures
491
+ * from payment failures via the `cause` chain.
492
+ *
493
+ * Privacy: the recipient wallet address never appears in error messages
494
+ * — `RecipientNotOnboardedError.wallet` exposes it on a structured field
495
+ * for callers that need to show a UI hint.
496
+ */
497
+ async sendToWallet(
498
+ recipientWallet: Address,
499
+ asset: SendToWalletAsset,
500
+ amount: bigint,
501
+ options?: SendOptions,
502
+ ): Promise<PaymentReceipt> {
503
+ if (this.autoRegister) {
504
+ try {
505
+ await this.ensureRegistered();
506
+ } catch (err) {
507
+ // Preserve the underlying cause so callers can introspect (e.g.,
508
+ // tell apart a missing walletClient from an RPC error). The wrapper
509
+ // message itself is privacy-safe — see AutoRegistrationFailedError.
510
+ throw new AutoRegistrationFailedError(err);
511
+ }
512
+ }
513
+ return sendToWallet(this.transport, recipientWallet, asset, amount, options);
514
+ }
515
+
516
+ /**
517
+ * Sweep funds from a detected stealth address to a destination.
518
+ *
519
+ * If `opts.token` is undefined or matches the ETH sentinel (or zero address)
520
+ * the agent dispatches to sweepETH; otherwise it dispatches to sweepERC20
521
+ * with the given token.
522
+ *
523
+ * Direct sweep (the default path) requires the stealth address to hold
524
+ * enough ETH to pay its own gas. When the stealth holds zero ETH set
525
+ * `opts.viaRelayer = true`:
526
+ * - ERC-20 → routed to {@link sweepViaRelayer} (Gelato or self-host).
527
+ * - ETH → routed to {@link sweepEthViaRelayer} via EIP-7702 (P5.1).
528
+ * Requires `opts.ethRelayerOptions.selfHostEndpoint` since there's no
529
+ * third-party fallback for the 7702 path.
530
+ */
531
+ async sweep(
532
+ detection: DetectedPayment,
533
+ destination: Address,
534
+ opts?: {
535
+ token?: Address;
536
+ viaRelayer?: boolean;
537
+ relayerContract?: Address;
538
+ relayerOptions?: AgentRelayerSweepOptions;
539
+ ethRelayerContract?: Address;
540
+ ethRelayerOptions?: AgentRelayerEthSweepOptions;
541
+ },
542
+ ): Promise<
543
+ AgentSweepResult | AgentRelayerSweepResult | AgentRelayerEthSweepResult
544
+ > {
545
+ const token = opts?.token;
546
+ const isEth =
547
+ token === undefined ||
548
+ token === ZERO_ADDRESS_ETH ||
549
+ token.toLowerCase() === ETH_SENTINEL.toLowerCase();
550
+
551
+ if (opts?.viaRelayer === true) {
552
+ if (isEth) {
553
+ if (opts.ethRelayerOptions === undefined) {
554
+ throw new EthRelayerEndpointRequiredError();
555
+ }
556
+ return this.sweepEthViaRelayer(detection, destination, {
557
+ ...(opts.ethRelayerContract !== undefined
558
+ ? { ethRelayerContract: opts.ethRelayerContract }
559
+ : {}),
560
+ relayerOptions: opts.ethRelayerOptions,
561
+ });
562
+ }
563
+ return this.sweepViaRelayer(detection, destination, {
564
+ token,
565
+ ...(opts.relayerContract !== undefined ? { relayerContract: opts.relayerContract } : {}),
566
+ ...(opts.relayerOptions !== undefined ? { relayerOptions: opts.relayerOptions } : {}),
567
+ });
568
+ }
569
+
570
+ if (isEth) {
571
+ const swept = await sweepETH(
572
+ this.transport,
573
+ detection.stealthPrivateKey,
574
+ destination,
575
+ );
576
+ return { swept, detection };
577
+ }
578
+ const swept = await sweepERC20(
579
+ this.transport,
580
+ detection.stealthPrivateKey,
581
+ token,
582
+ destination,
583
+ );
584
+ return { swept, detection };
585
+ }
586
+
587
+ /**
588
+ * Gasless ERC-20 sweep via the ShroudFiRelayer + Gelato 1Balance.
589
+ *
590
+ * Lazy-imports `@shroud-fi/relayer` so the agent-runtime bundle stays small
591
+ * for callers that never use the relayer path. The Gelato SDK + axios + ws
592
+ * deps only land in the bundle when this method is actually called.
593
+ *
594
+ * Relayer contract resolution order:
595
+ * 1. `opts.relayerContract` if provided
596
+ * 2. `getRelayer(transport.chain.id)` lookup from `@shroud-fi/transport`
597
+ * 3. Throw `RelayerContractNotConfiguredError`
598
+ *
599
+ * @throws RelayerContractNotConfiguredError if no relayer is resolvable.
600
+ * @throws RelayerNotAvailableForETHError if the agent itself ever invokes
601
+ * this with an ETH-like token (the dispatch in `sweep()` already
602
+ * guards, this guards direct callers too).
603
+ */
604
+ async sweepViaRelayer(
605
+ detection: DetectedPayment,
606
+ destination: Address,
607
+ opts: {
608
+ token: Address;
609
+ relayerContract?: Address;
610
+ relayerOptions?: AgentRelayerSweepOptions;
611
+ },
612
+ ): Promise<AgentRelayerSweepResult> {
613
+ const { token } = opts;
614
+ const isEth =
615
+ token === ZERO_ADDRESS_ETH ||
616
+ token.toLowerCase() === ETH_SENTINEL.toLowerCase();
617
+ if (isEth) {
618
+ // Direct callers asking for ERC-20 relayer with the ETH sentinel need
619
+ // to use sweepEthViaRelayer instead.
620
+ throw new EthRelayerEndpointRequiredError();
621
+ }
622
+
623
+ const chainId = this.transport.chain.id;
624
+ const relayerContract =
625
+ opts.relayerContract ?? getRelayer(chainId);
626
+ if (relayerContract === undefined) {
627
+ throw new RelayerContractNotConfiguredError();
628
+ }
629
+
630
+ // Lazy dynamic import — keeps the agent-runtime ESM bundle small for
631
+ // consumers that never call sweepViaRelayer. The Gelato SDK + transitive
632
+ // deps (axios, ws, isomorphic-ws) only enter the bundle on demand.
633
+ const relayerMod = await import('@shroud-fi/relayer');
634
+
635
+ // Self-host path: skip Gelato. Sign the permit locally, POST the request
636
+ // to the operator-controlled relayer service, return a receipt shaped
637
+ // like the Gelato one.
638
+ const selfHostEndpoint = opts.relayerOptions?.selfHostEndpoint;
639
+ if (typeof selfHostEndpoint === 'string' && selfHostEndpoint.length > 0) {
640
+ const swept = await this.sweepViaSelfHost(
641
+ relayerMod.signEip2612Permit,
642
+ detection,
643
+ destination,
644
+ token,
645
+ relayerContract,
646
+ selfHostEndpoint,
647
+ opts.relayerOptions,
648
+ );
649
+ return { swept, detection };
650
+ }
651
+
652
+ const swept = await relayerMod.relayedSweepERC20(
653
+ this.transport,
654
+ detection.stealthPrivateKey,
655
+ token,
656
+ destination,
657
+ relayerContract,
658
+ opts.relayerOptions,
659
+ );
660
+
661
+ return { swept, detection };
662
+ }
663
+
664
+ /**
665
+ * Self-host dispatch: sign the EIP-2612 permit, POST to the operator's
666
+ * service, return a Gelato-shaped receipt.
667
+ *
668
+ * Privacy: only the (public) permit signature + stealth address + token
669
+ * + destination cross the wire. The stealth private key never leaves the
670
+ * client.
671
+ */
672
+ private async sweepViaSelfHost(
673
+ signEip2612Permit: typeof import('@shroud-fi/relayer').signEip2612Permit,
674
+ detection: DetectedPayment,
675
+ destination: Address,
676
+ token: Address,
677
+ relayerContract: Address,
678
+ endpoint: string,
679
+ options: AgentRelayerSweepOptions | undefined,
680
+ ): Promise<AgentRelayerSweepResult['swept']> {
681
+ // 1. Read the stealth balance.
682
+ const stealthAddress = detection.stealthAddress;
683
+ const balance = (await this.transport.publicClient.readContract({
684
+ address: token,
685
+ abi: BALANCE_OF_ABI,
686
+ functionName: 'balanceOf',
687
+ args: [stealthAddress],
688
+ })) as bigint;
689
+
690
+ // 2. Compute the permit deadline.
691
+ const lifetime = options?.deadlineSecs ?? 1800n;
692
+ const latestBlock = await this.transport.publicClient.getBlock();
693
+ const deadline = latestBlock.timestamp + lifetime;
694
+
695
+ // 3. Sign the permit (the only place the stealth private key is consumed).
696
+ const permit = await signEip2612Permit(
697
+ this.transport,
698
+ detection.stealthPrivateKey,
699
+ token,
700
+ relayerContract,
701
+ balance,
702
+ deadline,
703
+ );
704
+
705
+ // 4. POST to the self-host service.
706
+ const body = {
707
+ token,
708
+ destination,
709
+ stealthAddress,
710
+ deadline: permit.deadline.toString(),
711
+ v: permit.v,
712
+ r: permit.r,
713
+ s: permit.s,
714
+ };
715
+
716
+ const fetchInit: RequestInit = {
717
+ method: 'POST',
718
+ headers: { 'content-type': 'application/json' },
719
+ body: JSON.stringify(body),
720
+ };
721
+ if (options?.signal !== undefined) {
722
+ fetchInit.signal = options.signal;
723
+ }
724
+ const response = await fetch(
725
+ `${endpoint.replace(/\/$/, '')}/relay`,
726
+ fetchInit,
727
+ );
728
+
729
+ const json = (await response.json()) as
730
+ | { ok: true; txHash: Hex; blockNumber: string }
731
+ | { ok: false; error: string };
732
+
733
+ if (!json.ok) {
734
+ throw new Error(json.error);
735
+ }
736
+
737
+ return {
738
+ taskId: `self-host:${json.txHash}`,
739
+ txHash: json.txHash,
740
+ status: 'success' as const,
741
+ relayerContract,
742
+ token,
743
+ destination,
744
+ blockNumber: BigInt(json.blockNumber),
745
+ };
746
+ }
747
+
748
+ /**
749
+ * P5.1 — gasless ETH sweep via EIP-7702 + self-host relayer service.
750
+ *
751
+ * Dispatch order for the ShroudFiEthRelayer contract address:
752
+ * 1. `opts.ethRelayerContract` if provided
753
+ * 2. `getEthRelayer(transport.chain.id)` lookup
754
+ * 3. Throw EthRelayerContractNotConfiguredError
755
+ *
756
+ * Lazy-imports @shroud-fi/relayer so the agent-runtime bundle stays small
757
+ * for callers that never touch the gasless path.
758
+ *
759
+ * @throws EthRelayerContractNotConfiguredError if no contract is resolvable.
760
+ * @throws EthRelayerEndpointRequiredError if no self-host endpoint is provided.
761
+ */
762
+ async sweepEthViaRelayer(
763
+ detection: DetectedPayment,
764
+ destination: Address,
765
+ opts: {
766
+ ethRelayerContract?: Address;
767
+ relayerOptions: AgentRelayerEthSweepOptions;
768
+ },
769
+ ): Promise<AgentRelayerEthSweepResult> {
770
+ const chainId = this.transport.chain.id;
771
+ const ethRelayerContract =
772
+ opts.ethRelayerContract ?? getEthRelayer(chainId);
773
+ if (ethRelayerContract === undefined) {
774
+ throw new EthRelayerContractNotConfiguredError();
775
+ }
776
+
777
+ const endpoint = opts.relayerOptions.selfHostEndpoint;
778
+ if (typeof endpoint !== 'string' || endpoint.length === 0) {
779
+ throw new EthRelayerEndpointRequiredError();
780
+ }
781
+
782
+ // Lazy dynamic import keeps the agent-runtime ESM bundle small.
783
+ const relayerMod = await import('@shroud-fi/relayer');
784
+
785
+ const passOptions: {
786
+ deadlineSecs?: bigint;
787
+ pollTimeoutMs?: number;
788
+ signal?: AbortSignal;
789
+ } = {};
790
+ if (opts.relayerOptions.deadlineSecs !== undefined) {
791
+ passOptions.deadlineSecs = opts.relayerOptions.deadlineSecs;
792
+ }
793
+ if (opts.relayerOptions.pollTimeoutMs !== undefined) {
794
+ passOptions.pollTimeoutMs = opts.relayerOptions.pollTimeoutMs;
795
+ }
796
+ if (opts.relayerOptions.signal !== undefined) {
797
+ passOptions.signal = opts.relayerOptions.signal;
798
+ }
799
+
800
+ const swept = await relayerMod.relayedSweepETH(
801
+ this.transport,
802
+ detection.stealthPrivateKey,
803
+ destination,
804
+ ethRelayerContract,
805
+ endpoint,
806
+ passOptions,
807
+ );
808
+
809
+ return { swept, detection };
810
+ }
811
+ }
812
+
813
+ // Minimal balanceOf ABI for the self-host pre-flight balance read.
814
+ const BALANCE_OF_ABI = [
815
+ {
816
+ type: 'function',
817
+ name: 'balanceOf',
818
+ stateMutability: 'view',
819
+ inputs: [{ name: 'account', type: 'address' }],
820
+ outputs: [{ name: '', type: 'uint256' }],
821
+ },
822
+ ] as const;
823
+
824
+ /**
825
+ * ERC-6538 scheme identifier for secp256k1 stealth addresses with view-tag
826
+ * filtering (per EIP-5564). Kept as a `bigint` because the registry signature
827
+ * is `uint256 schemeId`. Mirrors `SCHEME_ID` in @shroud-fi/payments.
828
+ */
829
+ const REGISTRY_SCHEME_ID = 1n;