@totalreclaw/totalreclaw 3.3.1-rc.2 → 3.3.1-rc.21

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 (70) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/SKILL.md +50 -83
  3. package/api-client.ts +18 -11
  4. package/config.ts +117 -3
  5. package/crypto.ts +10 -2
  6. package/dist/api-client.js +226 -0
  7. package/dist/billing-cache.js +100 -0
  8. package/dist/claims-helper.js +606 -0
  9. package/dist/config.js +280 -0
  10. package/dist/consolidation.js +258 -0
  11. package/dist/contradiction-sync.js +1034 -0
  12. package/dist/crypto.js +138 -0
  13. package/dist/digest-sync.js +361 -0
  14. package/dist/download-ux.js +63 -0
  15. package/dist/embedding.js +86 -0
  16. package/dist/extractor.js +1225 -0
  17. package/dist/first-run.js +103 -0
  18. package/dist/fs-helpers.js +563 -0
  19. package/dist/gateway-url.js +197 -0
  20. package/dist/generate-mnemonic.js +13 -0
  21. package/dist/hot-cache-wrapper.js +101 -0
  22. package/dist/import-adapters/base-adapter.js +64 -0
  23. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  24. package/dist/import-adapters/claude-adapter.js +114 -0
  25. package/dist/import-adapters/gemini-adapter.js +201 -0
  26. package/dist/import-adapters/index.js +26 -0
  27. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  28. package/dist/import-adapters/mem0-adapter.js +158 -0
  29. package/dist/import-adapters/types.js +1 -0
  30. package/dist/index.js +5348 -0
  31. package/dist/llm-client.js +686 -0
  32. package/dist/llm-profile-reader.js +346 -0
  33. package/dist/lsh.js +62 -0
  34. package/dist/onboarding-cli.js +750 -0
  35. package/dist/pair-cli.js +344 -0
  36. package/dist/pair-crypto.js +359 -0
  37. package/dist/pair-http.js +404 -0
  38. package/dist/pair-page.js +826 -0
  39. package/dist/pair-qr.js +107 -0
  40. package/dist/pair-remote-client.js +410 -0
  41. package/dist/pair-session-store.js +566 -0
  42. package/dist/pin.js +542 -0
  43. package/dist/qa-bug-report.js +301 -0
  44. package/dist/relay-headers.js +44 -0
  45. package/dist/reranker.js +442 -0
  46. package/dist/retype-setscope.js +348 -0
  47. package/dist/semantic-dedup.js +75 -0
  48. package/dist/subgraph-search.js +289 -0
  49. package/dist/subgraph-store.js +694 -0
  50. package/dist/tool-gating.js +58 -0
  51. package/download-ux.ts +91 -0
  52. package/embedding.ts +32 -9
  53. package/fs-helpers.ts +124 -0
  54. package/gateway-url.ts +57 -9
  55. package/index.ts +586 -357
  56. package/llm-client.ts +211 -23
  57. package/lsh.ts +7 -2
  58. package/onboarding-cli.ts +114 -1
  59. package/package.json +19 -5
  60. package/pair-cli.ts +76 -8
  61. package/pair-crypto.ts +34 -24
  62. package/pair-page.ts +28 -17
  63. package/pair-qr.ts +152 -0
  64. package/pair-remote-client.ts +540 -0
  65. package/qa-bug-report.ts +381 -0
  66. package/relay-headers.ts +50 -0
  67. package/reranker.ts +73 -0
  68. package/retype-setscope.ts +12 -0
  69. package/subgraph-search.ts +4 -3
  70. package/subgraph-store.ts +109 -16
@@ -0,0 +1,694 @@
1
+ /**
2
+ * Subgraph store path — writes facts on-chain via ERC-4337 UserOps.
3
+ *
4
+ * Used when the managed service is active (TOTALRECLAW_SELF_HOSTED is not
5
+ * "true"). Replaces the HTTP POST to /v1/store with an on-chain transaction
6
+ * flow.
7
+ *
8
+ * Uses @totalreclaw/core WASM for calldata encoding, UserOp hashing, and
9
+ * ECDSA signing. Raw fetch() for all JSON-RPC calls to the relay bundler
10
+ * and chain RPCs. No viem, no permissionless.
11
+ */
12
+ // Lazy-load WASM via createRequire — the shipped bundle is ESM-only and
13
+ // the bare `require` global is undefined there (issue #124). Same pattern
14
+ // as crypto / lsh / claims-helper / consolidation / digest-sync.
15
+ import { createRequire } from 'node:module';
16
+ const requireWasm = createRequire(import.meta.url);
17
+ let _wasm = null;
18
+ function getWasm() {
19
+ if (!_wasm)
20
+ _wasm = requireWasm('@totalreclaw/core');
21
+ return _wasm;
22
+ }
23
+ import { CONFIG } from './config.js';
24
+ import { buildRelayHeaders } from './relay-headers.js';
25
+ // ---------------------------------------------------------------------------
26
+ // Pimlico 429 retry helper
27
+ // ---------------------------------------------------------------------------
28
+ /**
29
+ * Wrap a fetch-based JSON-RPC call with exponential backoff for HTTP 429
30
+ * (rate limit) responses from Pimlico. Max 5 retries with 5s base delay,
31
+ * doubling each attempt, capped at 60s, plus random jitter (0-1000ms).
32
+ * Total retry window: ~135s (5+10+20+40+60 plus jitter).
33
+ * All other HTTP errors throw immediately.
34
+ */
35
+ async function rpcWithRetry(url, headers, method, params) {
36
+ const maxRetries = 5;
37
+ const baseDelay = 5000; // 5 seconds
38
+ const maxDelay = 60_000; // 60 seconds cap
39
+ const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
40
+ for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
41
+ const resp = await fetch(url, { method: 'POST', headers, body });
42
+ if (resp.ok) {
43
+ const json = await resp.json();
44
+ if (json.error) {
45
+ // Check if the RPC-level error message indicates a rate limit
46
+ if (attempt <= maxRetries && /429|rate limit/i.test(json.error.message)) {
47
+ const delay = Math.min(Math.pow(2, attempt - 1) * baseDelay, maxDelay) + Math.floor(Math.random() * 1000);
48
+ console.error(`Pimlico rate limited, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})...`);
49
+ await new Promise(r => setTimeout(r, delay));
50
+ continue;
51
+ }
52
+ throw new Error(`RPC ${method}: ${json.error.message}`);
53
+ }
54
+ return json.result;
55
+ }
56
+ // HTTP-level 429 — retry with backoff
57
+ if (resp.status === 429 && attempt <= maxRetries) {
58
+ const delay = Math.min(Math.pow(2, attempt - 1) * baseDelay, maxDelay) + Math.floor(Math.random() * 1000);
59
+ console.error(`Pimlico rate limited, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})...`);
60
+ await new Promise(r => setTimeout(r, delay));
61
+ continue;
62
+ }
63
+ throw new Error(`Relay returned HTTP ${resp.status} for ${method}`);
64
+ }
65
+ // Should not be reached, but satisfies TypeScript
66
+ throw new Error(`RPC ${method}: max retries exceeded`);
67
+ }
68
+ /** Legacy protobuf wrapper schema version (v0/v1-binary inner blob). */
69
+ export const PROTOBUF_VERSION_LEGACY = 3;
70
+ /** Memory Taxonomy v1 protobuf wrapper schema version. */
71
+ export const PROTOBUF_VERSION_V4 = 4;
72
+ // Stub 65-byte signature for gas estimation (pm_sponsorUserOperation).
73
+ // Must be a structurally valid ECDSA signature (r,s,v) so that ecrecover does
74
+ // NOT revert inside SimpleAccount._validateSignature. All-zeros causes
75
+ // OpenZeppelin ECDSA.recover() to revert with ECDSAInvalidSignature() (0xf645eedf),
76
+ // which the EntryPoint surfaces as AA23.
77
+ // This matches the stub used by permissionless/viem — ecrecover returns a
78
+ // non-owner address, so validateUserOp returns SIG_VALIDATION_FAILED (1)
79
+ // instead of reverting, which is what bundlers expect during simulation.
80
+ const DUMMY_SIGNATURE = '0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c';
81
+ // ---------------------------------------------------------------------------
82
+ // Protobuf encoding (WASM)
83
+ // ---------------------------------------------------------------------------
84
+ /**
85
+ * Encode a fact payload as a minimal Protobuf wire format via WASM core.
86
+ *
87
+ * Field numbers match server/proto/totalreclaw.proto.
88
+ *
89
+ * As of plugin v3.0.0 the outer protobuf `version` field is written as 4
90
+ * when the caller passes `version: PROTOBUF_VERSION_V4`. Omitting the field
91
+ * preserves legacy v3 semantics (e.g. for tombstone tombstone rows that
92
+ * should round-trip through pre-v3 readers).
93
+ */
94
+ export function encodeFactProtobuf(fact) {
95
+ const json = JSON.stringify({
96
+ id: fact.id,
97
+ timestamp: fact.timestamp,
98
+ owner: fact.owner,
99
+ encrypted_blob_hex: fact.encryptedBlob,
100
+ blind_indices: fact.blindIndices,
101
+ decay_score: fact.decayScore,
102
+ source: fact.source,
103
+ content_fp: fact.contentFp,
104
+ agent_id: fact.agentId,
105
+ encrypted_embedding: fact.encryptedEmbedding || null,
106
+ version: fact.version ?? PROTOBUF_VERSION_LEGACY,
107
+ });
108
+ return Buffer.from(getWasm().encodeFactProtobuf(json));
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // Chain helpers
112
+ // ---------------------------------------------------------------------------
113
+ /** Get the default public RPC URL for a chain ID */
114
+ function getDefaultRpcUrl(chainId) {
115
+ switch (chainId) {
116
+ case 100:
117
+ return 'https://rpc.gnosischain.com';
118
+ case 84532:
119
+ return 'https://sepolia.base.org';
120
+ default:
121
+ return 'https://sepolia.base.org';
122
+ }
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // Smart Account address derivation
126
+ // ---------------------------------------------------------------------------
127
+ /**
128
+ * Derive the Smart Account address from a BIP-39 mnemonic.
129
+ *
130
+ * Uses the SimpleAccountFactory's getAddress(owner, salt=0) view function
131
+ * via a raw eth_call to the chain RPC. The address is deterministic (CREATE2).
132
+ */
133
+ export async function deriveSmartAccountAddress(mnemonic, chainId) {
134
+ const eoa = getWasm().deriveEoa(mnemonic);
135
+ const resolvedChainId = chainId ?? 84532;
136
+ // SimpleAccountFactory.getAddress(address owner, uint256 salt) — view function
137
+ // Selector: 0x8cb84e18 = keccak256("getAddress(address,uint256)")[0:4]
138
+ const factoryAddress = getWasm().getSimpleAccountFactory();
139
+ const ownerPadded = eoa.address.slice(2).toLowerCase().padStart(64, '0');
140
+ const saltPadded = '0'.repeat(64);
141
+ const selector = '8cb84e18';
142
+ const calldata = `0x${selector}${ownerPadded}${saltPadded}`;
143
+ const rpcUrl = CONFIG.rpcUrl || getDefaultRpcUrl(resolvedChainId);
144
+ const response = await fetch(rpcUrl, {
145
+ method: 'POST',
146
+ headers: { 'Content-Type': 'application/json' },
147
+ body: JSON.stringify({
148
+ jsonrpc: '2.0',
149
+ id: 1,
150
+ method: 'eth_call',
151
+ params: [{ to: factoryAddress, data: calldata }, 'latest'],
152
+ }),
153
+ });
154
+ const json = await response.json();
155
+ if (json.error) {
156
+ throw new Error(`Failed to resolve Smart Account address: ${json.error.message}`);
157
+ }
158
+ if (!json.result || json.result === '0x') {
159
+ throw new Error('Failed to resolve Smart Account address: empty result');
160
+ }
161
+ // Result is a 32-byte ABI-encoded address — take last 20 bytes
162
+ return `0x${json.result.slice(-40)}`.toLowerCase();
163
+ }
164
+ // ---------------------------------------------------------------------------
165
+ // Smart Account deployment check (with session cache)
166
+ // ---------------------------------------------------------------------------
167
+ /**
168
+ * Session-level cache for account deployment status.
169
+ * Once an account is deployed (first successful UserOp), we skip the
170
+ * eth_getCode check and omit factory/factoryData for all subsequent calls.
171
+ * This prevents AA10 "duplicate deployment" errors when multiple facts
172
+ * are stored in rapid succession for a first-time user.
173
+ */
174
+ const deployedAccounts = new Set();
175
+ // ---------------------------------------------------------------------------
176
+ // Per-account submission mutex — 3.3.1-rc.3 AA25 serialization
177
+ // ---------------------------------------------------------------------------
178
+ //
179
+ // Concurrent `submitFactOnChain` / `submitFactBatchOnChain` calls for the
180
+ // SAME Smart Account used to race at the nonce-fetch step:
181
+ // - Call A: getNonce()=5, build UserOp, submit, wait for receipt.
182
+ // - Call B: getNonce()=5 (A not mined yet), build UserOp, submit → AA25.
183
+ //
184
+ // The fix: chain submissions per `sender` address through a single promise.
185
+ // Each call awaits the previous in-flight submission before starting its
186
+ // own nonce fetch. Fallback to public RPC for getNonce continues to work
187
+ // because by the time B fetches, A's UserOp has been bundled AND mined.
188
+ //
189
+ // 16 AA25 occurrences were logged in rc.2 QA; this lock eliminates the
190
+ // race condition at the plugin layer. Subsequent AA25s would indicate
191
+ // nonce rot from another process (e.g. relay retrying the same UserOp)
192
+ // and are handled by the existing single-retry with fresh-nonce path.
193
+ const _senderSubmissionLocks = new Map();
194
+ async function withSenderLock(sender, fn) {
195
+ const key = sender.toLowerCase();
196
+ const prev = _senderSubmissionLocks.get(key) ?? Promise.resolve();
197
+ let release = () => { };
198
+ const thisCallGate = new Promise((resolve) => { release = resolve; });
199
+ _senderSubmissionLocks.set(key, prev.then(() => thisCallGate));
200
+ try {
201
+ await prev; // wait for previous submission to settle (success OR failure)
202
+ }
203
+ catch {
204
+ // Prior submission threw — that's the caller's problem, not ours.
205
+ // The lock is still released below; we re-enter the chain.
206
+ }
207
+ try {
208
+ return await fn();
209
+ }
210
+ finally {
211
+ release();
212
+ // If we're the tail of the chain, clean up to avoid unbounded memory.
213
+ // Use `===` to ensure we don't clobber a newer lock that joined while
214
+ // we were running.
215
+ const current = _senderSubmissionLocks.get(key);
216
+ // The lock we set above was `prev.then(() => thisCallGate)` — when
217
+ // `thisCallGate` resolves, the whole promise resolves. If nothing
218
+ // queued behind us, remove the entry.
219
+ if (current) {
220
+ current.then(() => {
221
+ if (_senderSubmissionLocks.get(key) === current) {
222
+ _senderSubmissionLocks.delete(key);
223
+ }
224
+ }).catch(() => {
225
+ if (_senderSubmissionLocks.get(key) === current) {
226
+ _senderSubmissionLocks.delete(key);
227
+ }
228
+ });
229
+ }
230
+ }
231
+ }
232
+ /** Exposed for tests — reset the per-account lock map. */
233
+ export function __resetSenderLocksForTests() {
234
+ _senderSubmissionLocks.clear();
235
+ }
236
+ /**
237
+ * Check if a Smart Account is deployed and return factory/factoryData if not.
238
+ *
239
+ * For ERC-4337 v0.7, undeployed accounts need `factory` and `factoryData`
240
+ * in the UserOp so the EntryPoint can deploy them during the first transaction.
241
+ */
242
+ async function getInitCode(sender, eoaAddress, rpcUrl) {
243
+ // Session cache: if we already deployed this account, skip the RPC check
244
+ if (deployedAccounts.has(sender.toLowerCase())) {
245
+ return { factory: null, factoryData: null };
246
+ }
247
+ // Check if the Smart Account contract is deployed
248
+ const codeResp = await fetch(rpcUrl, {
249
+ method: 'POST',
250
+ headers: { 'Content-Type': 'application/json' },
251
+ body: JSON.stringify({
252
+ jsonrpc: '2.0', id: 1, method: 'eth_getCode',
253
+ params: [sender, 'latest'],
254
+ }),
255
+ });
256
+ const codeJson = await codeResp.json();
257
+ const isDeployed = codeJson.result && codeJson.result !== '0x' && codeJson.result !== '0x0';
258
+ if (isDeployed) {
259
+ deployedAccounts.add(sender.toLowerCase());
260
+ return { factory: null, factoryData: null };
261
+ }
262
+ // Account not deployed — build factory + factoryData for first-time deployment.
263
+ // createAccount(address owner, uint256 salt) — state-changing function
264
+ // Selector: 0x5fbfb9cf = keccak256("createAccount(address,uint256)")[0:4]
265
+ const factory = getWasm().getSimpleAccountFactory();
266
+ const ownerPadded = eoaAddress.slice(2).toLowerCase().padStart(64, '0');
267
+ const saltPadded = '0'.repeat(64);
268
+ const selector = '5fbfb9cf';
269
+ const factoryData = `0x${selector}${ownerPadded}${saltPadded}`;
270
+ return { factory, factoryData };
271
+ }
272
+ // ---------------------------------------------------------------------------
273
+ // On-chain submission (ERC-4337 UserOps via raw fetch)
274
+ // ---------------------------------------------------------------------------
275
+ /**
276
+ * Submit a fact on-chain via ERC-4337 UserOp through the relay server.
277
+ *
278
+ * Uses @totalreclaw/core WASM for:
279
+ * 1. EOA derivation from mnemonic (BIP-39 + BIP-44)
280
+ * 2. Calldata encoding (SimpleAccount.execute)
281
+ * 3. UserOp hashing (ERC-4337 v0.7)
282
+ * 4. ECDSA signing (EIP-191 prefixed)
283
+ *
284
+ * All JSON-RPC calls go through raw fetch() to the relay bundler endpoint.
285
+ */
286
+ export async function submitFactOnChain(protobufPayload, config) {
287
+ if (!config.relayUrl) {
288
+ throw new Error('Relay URL (TOTALRECLAW_SERVER_URL) is required for on-chain submission');
289
+ }
290
+ if (!config.mnemonic) {
291
+ throw new Error('Recovery phrase (TOTALRECLAW_RECOVERY_PHRASE) is required for on-chain submission');
292
+ }
293
+ // Resolve sender up-front so we can serialize concurrent submissions for
294
+ // the SAME Smart Account (rc.3 AA25 fix). Derivation is CREATE2, so we
295
+ // don't need to hit the chain — WASM does it.
296
+ const eoa = getWasm().deriveEoa(config.mnemonic);
297
+ const sender = config.walletAddress || await deriveSmartAccountAddress(config.mnemonic, config.chainId);
298
+ return withSenderLock(sender, () => submitFactOnChainLocked(protobufPayload, config, eoa, sender));
299
+ }
300
+ async function submitFactOnChainLocked(protobufPayload, config, eoa, sender) {
301
+ const bundlerUrl = `${config.relayUrl}/v1/bundler`;
302
+ const overrides = {
303
+ 'Content-Type': 'application/json',
304
+ };
305
+ if (config.authKeyHex)
306
+ overrides['Authorization'] = `Bearer ${config.authKeyHex}`;
307
+ if (config.walletAddress)
308
+ overrides['X-Wallet-Address'] = config.walletAddress;
309
+ const headers = buildRelayHeaders(overrides);
310
+ // Helper for JSON-RPC calls to relay bundler (with 429 retry)
311
+ async function rpc(method, params) {
312
+ return rpcWithRetry(bundlerUrl, headers, method, params);
313
+ }
314
+ const entryPoint = config.entryPointAddress || getWasm().getEntryPointAddress();
315
+ // 2. Encode calldata (SimpleAccount.execute → DataEdge fallback)
316
+ const calldataBytes = getWasm().encodeSingleCall(protobufPayload);
317
+ const callData = `0x${Buffer.from(calldataBytes).toString('hex')}`;
318
+ // 3. Get gas prices from Pimlico
319
+ const gasPrices = await rpc('pimlico_getUserOperationGasPrice', []);
320
+ const fast = gasPrices.fast;
321
+ const rpcUrl = config.rpcUrl || CONFIG.rpcUrl || getDefaultRpcUrl(config.chainId);
322
+ // 4. Check if Smart Account is deployed (needed for factory/factoryData)
323
+ const { factory, factoryData } = await getInitCode(sender, eoa.address, rpcUrl);
324
+ // 5. Get nonce from EntryPoint via bundler RPC.
325
+ // Routing through the bundler lets Pimlico account for pending mempool
326
+ // UserOps, preventing AA25 nonce conflicts on rapid submissions.
327
+ // Requires relay allowlist to include eth_call (added in relay v1.x).
328
+ // Fallback: if bundler rejects eth_call (403/method_not_allowed), use public RPC.
329
+ // getNonce(address sender, uint192 key) — selector 0x35567e1a
330
+ const senderPadded = sender.slice(2).toLowerCase().padStart(64, '0');
331
+ const keyPadded = '0'.repeat(64);
332
+ const nonceCalldata = `0x35567e1a${senderPadded}${keyPadded}`;
333
+ let nonce;
334
+ try {
335
+ const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
336
+ nonce = nonceResult || '0x0';
337
+ }
338
+ catch {
339
+ // Fallback to public RPC if bundler doesn't support eth_call
340
+ const nonceResp = await fetch(rpcUrl, {
341
+ method: 'POST',
342
+ headers: { 'Content-Type': 'application/json' },
343
+ body: JSON.stringify({
344
+ jsonrpc: '2.0', id: 1, method: 'eth_call',
345
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
346
+ }),
347
+ });
348
+ const nonceJson = await nonceResp.json();
349
+ nonce = nonceJson.result || '0x0';
350
+ }
351
+ // 6. Build unsigned UserOp (v0.7 fields, camelCase for Rust JSON serde)
352
+ const unsignedOp = {
353
+ sender,
354
+ nonce,
355
+ callData,
356
+ callGasLimit: '0x0',
357
+ verificationGasLimit: '0x0',
358
+ preVerificationGas: '0x0',
359
+ maxFeePerGas: fast.maxFeePerGas,
360
+ maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
361
+ signature: DUMMY_SIGNATURE,
362
+ };
363
+ if (factory) {
364
+ unsignedOp.factory = factory;
365
+ unsignedOp.factoryData = factoryData;
366
+ }
367
+ // 7. Get paymaster sponsorship (fills gas limits + paymaster fields)
368
+ const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
369
+ Object.assign(unsignedOp, sponsorResult);
370
+ // 8. Hash and sign the UserOp via WASM
371
+ const opJson = JSON.stringify(unsignedOp);
372
+ const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
373
+ const sigHex = getWasm().signUserOp(hashHex, eoa.private_key);
374
+ unsignedOp.signature = `0x${sigHex}`;
375
+ // 9. Submit the signed UserOp (with AA25 nonce conflict retry)
376
+ let userOpHash;
377
+ try {
378
+ userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
379
+ }
380
+ catch (err) {
381
+ const msg = err?.message || '';
382
+ if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
383
+ console.error('AA25/AA10 nonce conflict detected, rebuilding UserOp with fresh nonce...');
384
+ // Bust deployment cache so getInitCode re-checks on-chain
385
+ deployedAccounts.delete(sender.toLowerCase());
386
+ // Wait for previous UserOp to mine before retrying with fresh nonce.
387
+ // Public RPC won't reflect the new nonce until the tx is on-chain.
388
+ await new Promise(r => setTimeout(r, 15000));
389
+ // Re-fetch initCode and nonce
390
+ const { factory: retryFactory, factoryData: retryFactoryData } = await getInitCode(sender, eoa.address, rpcUrl);
391
+ let retryNonce;
392
+ try {
393
+ const retryNonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
394
+ retryNonce = retryNonceResult || '0x0';
395
+ }
396
+ catch {
397
+ const retryNonceResp = await fetch(rpcUrl, {
398
+ method: 'POST',
399
+ headers: { 'Content-Type': 'application/json' },
400
+ body: JSON.stringify({
401
+ jsonrpc: '2.0', id: 1, method: 'eth_call',
402
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
403
+ }),
404
+ });
405
+ const retryNonceJson = await retryNonceResp.json();
406
+ retryNonce = retryNonceJson.result || '0x0';
407
+ }
408
+ // Rebuild unsigned UserOp with fresh nonce and initCode
409
+ const retryOp = {
410
+ sender,
411
+ nonce: retryNonce,
412
+ callData,
413
+ callGasLimit: '0x0',
414
+ verificationGasLimit: '0x0',
415
+ preVerificationGas: '0x0',
416
+ maxFeePerGas: fast.maxFeePerGas,
417
+ maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
418
+ signature: DUMMY_SIGNATURE,
419
+ };
420
+ if (retryFactory) {
421
+ retryOp.factory = retryFactory;
422
+ retryOp.factoryData = retryFactoryData;
423
+ }
424
+ // Re-sponsor and re-sign
425
+ const retrySponsor = await rpc('pm_sponsorUserOperation', [retryOp, entryPoint]);
426
+ Object.assign(retryOp, retrySponsor);
427
+ const retryOpJson = JSON.stringify(retryOp);
428
+ const retryHashHex = getWasm().hashUserOp(retryOpJson, entryPoint, BigInt(config.chainId));
429
+ const retrySigHex = getWasm().signUserOp(retryHashHex, eoa.private_key);
430
+ retryOp.signature = `0x${retrySigHex}`;
431
+ userOpHash = await rpc('eth_sendUserOperation', [retryOp, entryPoint]);
432
+ }
433
+ else {
434
+ throw err;
435
+ }
436
+ }
437
+ // 10. Wait for receipt (poll up to 120s)
438
+ let receipt = null;
439
+ for (let i = 0; i < 60; i++) {
440
+ await new Promise(r => setTimeout(r, 2000));
441
+ try {
442
+ receipt = await rpc('eth_getUserOperationReceipt', [userOpHash]);
443
+ if (receipt)
444
+ break;
445
+ }
446
+ catch { /* not mined yet */ }
447
+ }
448
+ const success = receipt?.success ?? false;
449
+ // Mark account as deployed after first successful submission
450
+ if (success) {
451
+ deployedAccounts.add(sender.toLowerCase());
452
+ }
453
+ return {
454
+ txHash: receipt?.receipt?.transactionHash || '',
455
+ userOpHash,
456
+ success,
457
+ };
458
+ }
459
+ /**
460
+ * Submit multiple facts on-chain in a single ERC-4337 UserOp (batched).
461
+ *
462
+ * Each protobuf payload becomes one call in a multi-call UserOp. The
463
+ * DataEdge contract emits a separate Log(bytes) event per call, and the
464
+ * subgraph indexes each event independently (by txHash + logIndex).
465
+ *
466
+ * Falls back to single-fact path for batches of 1 (no multicall overhead).
467
+ */
468
+ export async function submitFactBatchOnChain(protobufPayloads, config) {
469
+ if (!protobufPayloads.length) {
470
+ return { txHash: '', userOpHash: '', success: true, batchSize: 0 };
471
+ }
472
+ // Single fact — use standard path (avoids multicall overhead)
473
+ if (protobufPayloads.length === 1) {
474
+ const result = await submitFactOnChain(protobufPayloads[0], config);
475
+ return { ...result, batchSize: 1 };
476
+ }
477
+ if (!config.relayUrl) {
478
+ throw new Error('Relay URL (TOTALRECLAW_SERVER_URL) is required for on-chain submission');
479
+ }
480
+ if (!config.mnemonic) {
481
+ throw new Error('Recovery phrase (TOTALRECLAW_RECOVERY_PHRASE) is required for on-chain submission');
482
+ }
483
+ // Resolve sender up-front for the per-account mutex (rc.3 AA25 fix).
484
+ const eoa = getWasm().deriveEoa(config.mnemonic);
485
+ const sender = config.walletAddress || await deriveSmartAccountAddress(config.mnemonic, config.chainId);
486
+ return withSenderLock(sender, () => submitFactBatchOnChainLocked(protobufPayloads, config, eoa, sender));
487
+ }
488
+ async function submitFactBatchOnChainLocked(protobufPayloads, config, eoa, sender) {
489
+ const bundlerUrl = `${config.relayUrl}/v1/bundler`;
490
+ const overrides = {
491
+ 'Content-Type': 'application/json',
492
+ };
493
+ if (config.authKeyHex)
494
+ overrides['Authorization'] = `Bearer ${config.authKeyHex}`;
495
+ if (config.walletAddress)
496
+ overrides['X-Wallet-Address'] = config.walletAddress;
497
+ const headers = buildRelayHeaders(overrides);
498
+ // Helper for JSON-RPC calls to relay bundler (with 429 retry)
499
+ async function rpc(method, params) {
500
+ return rpcWithRetry(bundlerUrl, headers, method, params);
501
+ }
502
+ const entryPoint = config.entryPointAddress || getWasm().getEntryPointAddress();
503
+ // Encode batch calldata (SimpleAccount.executeBatch)
504
+ // encodeBatchCall expects a JSON array of hex-encoded payload strings
505
+ const payloadsHex = protobufPayloads.map(p => p.toString('hex'));
506
+ const calldataBytes = getWasm().encodeBatchCall(JSON.stringify(payloadsHex));
507
+ const callData = `0x${Buffer.from(calldataBytes).toString('hex')}`;
508
+ // Get gas prices
509
+ const gasPrices = await rpc('pimlico_getUserOperationGasPrice', []);
510
+ const fast = gasPrices.fast;
511
+ const rpcUrl = config.rpcUrl || CONFIG.rpcUrl || getDefaultRpcUrl(config.chainId);
512
+ // Check if Smart Account is deployed (needed for factory/factoryData)
513
+ const { factory, factoryData } = await getInitCode(sender, eoa.address, rpcUrl);
514
+ // Get nonce via bundler (accounts for pending mempool UserOps) with public RPC fallback
515
+ const senderPadded = sender.slice(2).toLowerCase().padStart(64, '0');
516
+ const keyPadded = '0'.repeat(64);
517
+ const nonceCalldata = `0x35567e1a${senderPadded}${keyPadded}`;
518
+ let nonce;
519
+ try {
520
+ const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
521
+ nonce = nonceResult || '0x0';
522
+ }
523
+ catch {
524
+ const nonceResp = await fetch(rpcUrl, {
525
+ method: 'POST',
526
+ headers: { 'Content-Type': 'application/json' },
527
+ body: JSON.stringify({
528
+ jsonrpc: '2.0', id: 1, method: 'eth_call',
529
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
530
+ }),
531
+ });
532
+ const nonceJson = await nonceResp.json();
533
+ nonce = nonceJson.result || '0x0';
534
+ }
535
+ // Build unsigned UserOp
536
+ const unsignedOp = {
537
+ sender,
538
+ nonce,
539
+ callData,
540
+ callGasLimit: '0x0',
541
+ verificationGasLimit: '0x0',
542
+ preVerificationGas: '0x0',
543
+ maxFeePerGas: fast.maxFeePerGas,
544
+ maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
545
+ signature: DUMMY_SIGNATURE,
546
+ };
547
+ if (factory) {
548
+ unsignedOp.factory = factory;
549
+ unsignedOp.factoryData = factoryData;
550
+ }
551
+ // Gas estimation for batch operations — get accurate gas limits from Pimlico
552
+ // before paymaster sponsorship (can't bump after sponsorship as it invalidates
553
+ // the paymaster's signature, causing AA34).
554
+ if (protobufPayloads.length > 1) {
555
+ try {
556
+ const gasEstimate = await rpc('eth_estimateUserOperationGas', [unsignedOp, entryPoint]);
557
+ if (gasEstimate.callGasLimit)
558
+ unsignedOp.callGasLimit = gasEstimate.callGasLimit;
559
+ if (gasEstimate.verificationGasLimit)
560
+ unsignedOp.verificationGasLimit = gasEstimate.verificationGasLimit;
561
+ if (gasEstimate.preVerificationGas)
562
+ unsignedOp.preVerificationGas = gasEstimate.preVerificationGas;
563
+ }
564
+ catch {
565
+ // If estimation fails, let the paymaster handle it (default behavior)
566
+ }
567
+ }
568
+ // Paymaster sponsorship (uses gas limits from estimation above for batches)
569
+ const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
570
+ Object.assign(unsignedOp, sponsorResult);
571
+ // Hash and sign via WASM
572
+ const opJson = JSON.stringify(unsignedOp);
573
+ const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
574
+ const sigHex = getWasm().signUserOp(hashHex, eoa.private_key);
575
+ unsignedOp.signature = `0x${sigHex}`;
576
+ // Submit (with AA25 nonce conflict retry)
577
+ let userOpHash;
578
+ try {
579
+ userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
580
+ }
581
+ catch (err) {
582
+ const msg = err?.message || '';
583
+ if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
584
+ console.error('AA25/AA10 nonce conflict detected (batch), rebuilding UserOp with fresh nonce...');
585
+ // Bust deployment cache so getInitCode re-checks on-chain
586
+ deployedAccounts.delete(sender.toLowerCase());
587
+ // Wait for previous UserOp to mine before retrying with fresh nonce.
588
+ // Public RPC won't reflect the new nonce until the tx is on-chain.
589
+ await new Promise(r => setTimeout(r, 15000));
590
+ // Re-fetch initCode and nonce
591
+ const { factory: retryFactory, factoryData: retryFactoryData } = await getInitCode(sender, eoa.address, rpcUrl);
592
+ let retryNonce;
593
+ try {
594
+ const retryNonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
595
+ retryNonce = retryNonceResult || '0x0';
596
+ }
597
+ catch {
598
+ const retryNonceResp = await fetch(rpcUrl, {
599
+ method: 'POST',
600
+ headers: { 'Content-Type': 'application/json' },
601
+ body: JSON.stringify({
602
+ jsonrpc: '2.0', id: 1, method: 'eth_call',
603
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
604
+ }),
605
+ });
606
+ const retryNonceJson = await retryNonceResp.json();
607
+ retryNonce = retryNonceJson.result || '0x0';
608
+ }
609
+ // Rebuild unsigned UserOp with fresh nonce and initCode
610
+ const retryOp = {
611
+ sender,
612
+ nonce: retryNonce,
613
+ callData,
614
+ callGasLimit: '0x0',
615
+ verificationGasLimit: '0x0',
616
+ preVerificationGas: '0x0',
617
+ maxFeePerGas: fast.maxFeePerGas,
618
+ maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
619
+ signature: DUMMY_SIGNATURE,
620
+ };
621
+ if (retryFactory) {
622
+ retryOp.factory = retryFactory;
623
+ retryOp.factoryData = retryFactoryData;
624
+ }
625
+ // Re-sponsor and re-sign
626
+ const retrySponsor = await rpc('pm_sponsorUserOperation', [retryOp, entryPoint]);
627
+ Object.assign(retryOp, retrySponsor);
628
+ const retryOpJson = JSON.stringify(retryOp);
629
+ const retryHashHex = getWasm().hashUserOp(retryOpJson, entryPoint, BigInt(config.chainId));
630
+ const retrySigHex = getWasm().signUserOp(retryHashHex, eoa.private_key);
631
+ retryOp.signature = `0x${retrySigHex}`;
632
+ userOpHash = await rpc('eth_sendUserOperation', [retryOp, entryPoint]);
633
+ }
634
+ else {
635
+ throw err;
636
+ }
637
+ }
638
+ // Wait for receipt (poll up to 120s)
639
+ let receipt = null;
640
+ for (let i = 0; i < 60; i++) {
641
+ await new Promise(r => setTimeout(r, 2000));
642
+ try {
643
+ receipt = await rpc('eth_getUserOperationReceipt', [userOpHash]);
644
+ if (receipt)
645
+ break;
646
+ }
647
+ catch { /* not mined yet */ }
648
+ }
649
+ const batchSuccess = receipt?.success ?? false;
650
+ // Mark account as deployed after first successful submission
651
+ if (batchSuccess) {
652
+ deployedAccounts.add(sender.toLowerCase());
653
+ }
654
+ return {
655
+ txHash: receipt?.receipt?.transactionHash || '',
656
+ userOpHash,
657
+ success: batchSuccess,
658
+ batchSize: protobufPayloads.length,
659
+ };
660
+ }
661
+ // ---------------------------------------------------------------------------
662
+ // Configuration
663
+ // ---------------------------------------------------------------------------
664
+ /**
665
+ * Check if subgraph mode is enabled (i.e. using the managed service).
666
+ *
667
+ * Returns true when TOTALRECLAW_SELF_HOSTED is NOT set to "true".
668
+ * The managed service (subgraph mode) is the default.
669
+ */
670
+ export function isSubgraphMode() {
671
+ return !CONFIG.selfHosted;
672
+ }
673
+ /**
674
+ * Get subgraph configuration from environment variables.
675
+ *
676
+ * After the v1 env var cleanup, clients only need:
677
+ * - TOTALRECLAW_RECOVERY_PHRASE -- BIP-39 mnemonic
678
+ * - TOTALRECLAW_SERVER_URL -- relay server URL (default: https://api.totalreclaw.xyz)
679
+ * - TOTALRECLAW_SELF_HOSTED -- set "true" to use self-hosted server (default: managed service)
680
+ *
681
+ * Chain ID is no longer configurable via env — it is auto-detected from the
682
+ * relay billing response (free = Base Sepolia, Pro = Gnosis mainnet).
683
+ */
684
+ export function getSubgraphConfig() {
685
+ return {
686
+ relayUrl: CONFIG.serverUrl || 'https://api.totalreclaw.xyz',
687
+ mnemonic: CONFIG.recoveryPhrase,
688
+ cachePath: CONFIG.cachePath,
689
+ chainId: CONFIG.chainId,
690
+ dataEdgeAddress: CONFIG.dataEdgeAddress || getWasm().getDataEdgeAddress(),
691
+ entryPointAddress: CONFIG.entryPointAddress || getWasm().getEntryPointAddress(),
692
+ rpcUrl: CONFIG.rpcUrl || undefined,
693
+ };
694
+ }