@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.
- package/package.json +11 -8
- package/src/agent.ts +829 -0
- package/src/browser.ts +126 -0
- package/src/constants.ts +20 -0
- package/src/contacts.ts +174 -0
- package/src/errors.ts +205 -0
- package/src/factory.ts +51 -0
- package/src/index.ts +57 -0
- package/src/types.ts +176 -0
- package/tsconfig.json +9 -0
- package/dist/cjs/agent.d.ts.map +0 -1
- package/dist/cjs/agent.js.map +0 -1
- package/dist/cjs/browser.d.ts.map +0 -1
- package/dist/cjs/browser.js.map +0 -1
- package/dist/cjs/constants.d.ts.map +0 -1
- package/dist/cjs/constants.js.map +0 -1
- package/dist/cjs/contacts.d.ts.map +0 -1
- package/dist/cjs/contacts.js.map +0 -1
- package/dist/cjs/errors.d.ts.map +0 -1
- package/dist/cjs/errors.js.map +0 -1
- package/dist/cjs/factory.d.ts.map +0 -1
- package/dist/cjs/factory.js.map +0 -1
- package/dist/cjs/index.d.ts.map +0 -1
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/types.d.ts.map +0 -1
- package/dist/cjs/types.js.map +0 -1
- package/dist/esm/agent.d.ts.map +0 -1
- package/dist/esm/agent.js.map +0 -1
- package/dist/esm/browser.d.ts.map +0 -1
- package/dist/esm/browser.js.map +0 -1
- package/dist/esm/constants.d.ts.map +0 -1
- package/dist/esm/constants.js.map +0 -1
- package/dist/esm/contacts.d.ts.map +0 -1
- package/dist/esm/contacts.js.map +0 -1
- package/dist/esm/errors.d.ts.map +0 -1
- package/dist/esm/errors.js.map +0 -1
- package/dist/esm/factory.d.ts.map +0 -1
- package/dist/esm/factory.js.map +0 -1
- package/dist/esm/index.d.ts.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/types.d.ts.map +0 -1
- package/dist/esm/types.js.map +0 -1
- package/dist/tsconfig.cjs.tsbuildinfo +0 -1
- package/dist/tsconfig.esm.tsbuildinfo +0 -1
- 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;
|