@totalreclaw/totalreclaw 3.3.12-rc.9 → 3.3.12

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 (79) hide show
  1. package/SKILL.md +33 -27
  2. package/api-client.ts +17 -9
  3. package/batch-gate.ts +42 -0
  4. package/billing-cache.ts +72 -23
  5. package/claims-helper.ts +2 -1
  6. package/config.ts +95 -13
  7. package/consolidation.ts +6 -13
  8. package/contradiction-sync.ts +19 -14
  9. package/credential-provider.ts +184 -0
  10. package/crypto.ts +27 -160
  11. package/dist/api-client.js +17 -9
  12. package/dist/batch-gate.js +40 -0
  13. package/dist/billing-cache.js +54 -24
  14. package/dist/claims-helper.js +2 -1
  15. package/dist/config.js +91 -13
  16. package/dist/consolidation.js +6 -15
  17. package/dist/contradiction-sync.js +15 -15
  18. package/dist/credential-provider.js +145 -0
  19. package/dist/crypto.js +17 -137
  20. package/dist/download-ux.js +11 -7
  21. package/dist/embedder-loader.js +266 -0
  22. package/dist/embedding.js +36 -3
  23. package/dist/entry.js +123 -0
  24. package/dist/fs-helpers.js +75 -379
  25. package/dist/import-adapters/gemini-adapter.js +29 -159
  26. package/dist/index.js +864 -2631
  27. package/dist/memory-runtime.js +459 -0
  28. package/dist/native-memory.js +123 -0
  29. package/dist/onboarding-cli.js +4 -8
  30. package/dist/pair-cli-relay.js +1 -8
  31. package/dist/pair-cli.js +1 -1
  32. package/dist/pair-crypto.js +16 -358
  33. package/dist/pair-http.js +147 -4
  34. package/dist/relay.js +140 -0
  35. package/dist/reranker.js +13 -8
  36. package/dist/semantic-dedup.js +5 -7
  37. package/dist/skill-register.js +97 -0
  38. package/dist/subgraph-search.js +3 -1
  39. package/dist/subgraph-store.js +348 -290
  40. package/dist/tool-gating.js +39 -26
  41. package/dist/tools.js +367 -0
  42. package/dist/tr-cli-export-helper.js +3 -3
  43. package/dist/tr-cli.js +65 -156
  44. package/dist/trajectory-poller.js +155 -9
  45. package/dist/vault-crypto.js +551 -0
  46. package/download-ux.ts +12 -6
  47. package/embedder-loader.ts +293 -1
  48. package/embedding.ts +43 -3
  49. package/entry.ts +132 -0
  50. package/fs-helpers.ts +93 -458
  51. package/import-adapters/gemini-adapter.ts +38 -183
  52. package/index.ts +912 -2917
  53. package/memory-runtime.ts +723 -0
  54. package/native-memory.ts +196 -0
  55. package/onboarding-cli.ts +3 -9
  56. package/openclaw.plugin.json +5 -17
  57. package/package.json +6 -3
  58. package/pair-cli-relay.ts +0 -9
  59. package/pair-cli.ts +1 -1
  60. package/pair-crypto.ts +41 -483
  61. package/pair-http.ts +194 -5
  62. package/postinstall.mjs +138 -0
  63. package/relay.ts +172 -0
  64. package/reranker.ts +13 -8
  65. package/semantic-dedup.ts +5 -6
  66. package/skill-register.ts +146 -0
  67. package/skill.json +1 -1
  68. package/subgraph-search.ts +3 -1
  69. package/subgraph-store.ts +367 -307
  70. package/tool-gating.ts +39 -26
  71. package/tools.ts +499 -0
  72. package/tr-cli-export-helper.ts +3 -3
  73. package/tr-cli.ts +69 -182
  74. package/trajectory-poller.ts +162 -10
  75. package/vault-crypto.ts +705 -0
  76. package/auto-pair-on-load.ts +0 -308
  77. package/dist/auto-pair-on-load.js +0 -197
  78. package/dist/pair-pending-injection.js +0 -125
  79. package/pair-pending-injection.ts +0 -205
package/subgraph-store.ts CHANGED
@@ -2,12 +2,13 @@
2
2
  * Subgraph store path — writes facts on-chain via ERC-4337 UserOps.
3
3
  *
4
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
5
+ * "true"). Replaces the HTTP request to /v1/store with an on-chain transaction
6
6
  * flow.
7
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.
8
+ * Uses @totalreclaw/core WASM for calldata encoding and UserOp hashing;
9
+ * `signUserOp` (ECDSA) lives in `vault-crypto.ts`. All JSON-RPC calls to
10
+ * the relay bundler and chain RPCs go through `relay.ts` (the plugin's
11
+ * single network site). No viem, no permissionless.
11
12
  */
12
13
 
13
14
  // Lazy-load WASM via createRequire — the shipped bundle is ESM-only and
@@ -20,63 +21,10 @@ function getWasm() {
20
21
  if (!_wasm) _wasm = requireWasm('@totalreclaw/core');
21
22
  return _wasm;
22
23
  }
23
- import { CONFIG } from './config.js';
24
+ import { CONFIG, getDataEdgeAddressOverride } from './config.js';
24
25
  import { buildRelayHeaders } from './relay-headers.js';
25
-
26
- // ---------------------------------------------------------------------------
27
- // Pimlico 429 retry helper
28
- // ---------------------------------------------------------------------------
29
-
30
- /**
31
- * Wrap a fetch-based JSON-RPC call with exponential backoff for HTTP 429
32
- * (rate limit) responses from Pimlico. Max 5 retries with 5s base delay,
33
- * doubling each attempt, capped at 60s, plus random jitter (0-1000ms).
34
- * Total retry window: ~135s (5+10+20+40+60 plus jitter).
35
- * All other HTTP errors throw immediately.
36
- */
37
- async function rpcWithRetry(
38
- url: string,
39
- headers: Record<string, string>,
40
- method: string,
41
- params: unknown[],
42
- ): Promise<any> {
43
- const maxRetries = 5;
44
- const baseDelay = 5000; // 5 seconds
45
- const maxDelay = 60_000; // 60 seconds cap
46
- const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
47
-
48
- for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
49
- const resp = await fetch(url, { method: 'POST', headers, body });
50
-
51
- if (resp.ok) {
52
- const json = await resp.json() as { result?: any; error?: { message: string } };
53
- if (json.error) {
54
- // Check if the RPC-level error message indicates a rate limit
55
- if (attempt <= maxRetries && /429|rate limit/i.test(json.error.message)) {
56
- const delay = Math.min(Math.pow(2, attempt - 1) * baseDelay, maxDelay) + Math.floor(Math.random() * 1000);
57
- console.error(`Pimlico rate limited, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})...`);
58
- await new Promise(r => setTimeout(r, delay));
59
- continue;
60
- }
61
- throw new Error(`RPC ${method}: ${json.error.message}`);
62
- }
63
- return json.result;
64
- }
65
-
66
- // HTTP-level 429 — retry with backoff
67
- if (resp.status === 429 && attempt <= maxRetries) {
68
- const delay = Math.min(Math.pow(2, attempt - 1) * baseDelay, maxDelay) + Math.floor(Math.random() * 1000);
69
- console.error(`Pimlico rate limited, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})...`);
70
- await new Promise(r => setTimeout(r, delay));
71
- continue;
72
- }
73
-
74
- throw new Error(`Relay returned HTTP ${resp.status} for ${method}`);
75
- }
76
-
77
- // Should not be reached, but satisfies TypeScript
78
- throw new Error(`RPC ${method}: max retries exceeded`);
79
- }
26
+ import { rpcRequest, rpcWithRetry } from './relay.js';
27
+ import { signUserOp } from './vault-crypto.js';
80
28
 
81
29
  // ---------------------------------------------------------------------------
82
30
  // Types
@@ -86,7 +34,7 @@ export interface SubgraphStoreConfig {
86
34
  relayUrl: string; // TotalReclaw relay server URL (proxies bundler + subgraph)
87
35
  mnemonic: string; // BIP-39 mnemonic for key derivation
88
36
  cachePath: string; // Hot cache file path
89
- chainId: number; // 100 for Gnosis mainnet, 84532 for Base Sepolia
37
+ chainId: number; // Gnosis mainnet (100) after ops-1; from relay chain_id (#402)
90
38
  dataEdgeAddress: string; // EventfulDataEdge contract address
91
39
  entryPointAddress: string; // ERC-4337 EntryPoint v0.7
92
40
  authKeyHex?: string; // HKDF auth key for relay server Authorization header
@@ -173,9 +121,12 @@ function getDefaultRpcUrl(chainId: number): string {
173
121
  case 100:
174
122
  return 'https://rpc.gnosischain.com';
175
123
  case 84532:
124
+ // Retained for the legacy Base Sepolia chain id, but after ops-1 nothing
125
+ // should resolve here — the relay's authoritative chain_id is 100 (#402).
176
126
  return 'https://sepolia.base.org';
177
127
  default:
178
- return 'https://sepolia.base.org';
128
+ // Unknown chain id → Gnosis mainnet (after ops-1 default), NOT Base Sepolia.
129
+ return 'https://rpc.gnosischain.com';
179
130
  }
180
131
  }
181
132
 
@@ -191,7 +142,9 @@ function getDefaultRpcUrl(chainId: number): string {
191
142
  */
192
143
  export async function deriveSmartAccountAddress(mnemonic: string, chainId?: number): Promise<string> {
193
144
  const eoa = getWasm().deriveEoa(mnemonic) as { private_key: string; address: string };
194
- const resolvedChainId = chainId ?? 84532;
145
+ // Default to Gnosis mainnet (100) after ops-1 — the SA address is CREATE2 and
146
+ // byte-equal across chains, but the RPC we query must be the live one (#402).
147
+ const resolvedChainId = chainId ?? 100;
195
148
 
196
149
  // SimpleAccountFactory.getAddress(address owner, uint256 salt) — view function
197
150
  // Selector: 0x8cb84e18 = keccak256("getAddress(address,uint256)")[0:4]
@@ -202,17 +155,12 @@ export async function deriveSmartAccountAddress(mnemonic: string, chainId?: numb
202
155
  const calldata = `0x${selector}${ownerPadded}${saltPadded}`;
203
156
 
204
157
  const rpcUrl = CONFIG.rpcUrl || getDefaultRpcUrl(resolvedChainId);
205
- const response = await fetch(rpcUrl, {
206
- method: 'POST',
158
+ const json = await rpcRequest({
159
+ url: rpcUrl,
207
160
  headers: { 'Content-Type': 'application/json' },
208
- body: JSON.stringify({
209
- jsonrpc: '2.0',
210
- id: 1,
211
- method: 'eth_call',
212
- params: [{ to: factoryAddress, data: calldata }, 'latest'],
213
- }),
161
+ method: 'eth_call',
162
+ params: [{ to: factoryAddress, data: calldata }, 'latest'],
214
163
  });
215
- const json = await response.json() as { result?: string; error?: { message: string } };
216
164
  if (json.error) {
217
165
  throw new Error(`Failed to resolve Smart Account address: ${json.error.message}`);
218
166
  }
@@ -220,21 +168,74 @@ export async function deriveSmartAccountAddress(mnemonic: string, chainId?: numb
220
168
  throw new Error('Failed to resolve Smart Account address: empty result');
221
169
  }
222
170
  // Result is a 32-byte ABI-encoded address — take last 20 bytes
223
- return `0x${json.result.slice(-40)}`.toLowerCase();
171
+ return `0x${(json.result as string).slice(-40)}`.toLowerCase();
224
172
  }
225
173
 
226
174
  // ---------------------------------------------------------------------------
227
- // Smart Account deployment check (with session cache)
175
+ // Smart Account deployment check
228
176
  // ---------------------------------------------------------------------------
177
+ //
178
+ // NOTE on the removed session cache (2026-06-28, AA10 fix):
179
+ //
180
+ // A module-level `Set<string>` of "already deployed" accounts used to skip
181
+ // the `eth_getCode` RPC after the first successful submission. That cache
182
+ // was the single largest source of AA10 "sender already constructed"
183
+ // failures in production:
184
+ //
185
+ // - A process restart emptied the cache → the next submission relied on
186
+ // ONE `eth_getCode` read that could return stale `0x` from a lagging
187
+ // RPC node (the deploy tx was mined but the node hadn't caught up) →
188
+ // initCode was re-added → the EntryPoint rejected with AA10.
189
+ // - A receipt poll that timed out (success undetected within the 120s
190
+ // window) left the cache unpopulated → the next submission hit the
191
+ // same stale-`eth_getCode` path → AA10.
192
+ // - The AA25 retry path (`deployedAccounts.delete(...)`) existed ONLY to
193
+ // paper over the cache; with no cache there is nothing to invalidate.
194
+ //
195
+ // Fix: `getInitCode` calls `eth_getCode` on EVERY invocation. The
196
+ // per-sender submission mutex (`withSenderLock`) already serializes
197
+ // submissions per account, so the extra RPC cannot introduce a nonce
198
+ // race. The cost is one extra RPC read per submission — negligible
199
+ // against a relay round-trip — and the behavior is correct by
200
+ // construction: the plugin asserts on-chain state at submit time rather
201
+ // than trusting an in-memory guess that can be invalidated by anything
202
+ // the plugin doesn't observe (relayer retries, parallel clients, node
203
+ // reorgs, process restarts).
229
204
 
230
205
  /**
231
- * Session-level cache for account deployment status.
232
- * Once an account is deployed (first successful UserOp), we skip the
233
- * eth_getCode check and omit factory/factoryData for all subsequent calls.
234
- * This prevents AA10 "duplicate deployment" errors when multiple facts
235
- * are stored in rapid succession for a first-time user.
206
+ * Test-only RPC probe counter. Incremented each time `getInitCode` issues
207
+ * an `eth_getCode` read. The lifecycle test uses this to assert the cache
208
+ * is truly gone (every call hits the wire). Not part of the public API.
236
209
  */
237
- const deployedAccounts = new Set<string>();
210
+ let _ethGetCodeProbeCount = 0;
211
+
212
+ /** Test-only seam: drive the private `getInitCode` logic. */
213
+ export async function __getInitCodeForTests(
214
+ sender: string,
215
+ eoaAddress: string,
216
+ rpcUrl: string,
217
+ ): Promise<{ factory: string | null; factoryData: string | null }> {
218
+ return getInitCode(sender, eoaAddress, rpcUrl);
219
+ }
220
+
221
+ /** Test-only seam: read the RPC probe counter. */
222
+ export function __getRpcProbeCountForTests(): number {
223
+ return _ethGetCodeProbeCount;
224
+ }
225
+
226
+ /** Test-only seam: reset the RPC probe counter. */
227
+ export function __resetRpcProbeCountForTests(): void {
228
+ _ethGetCodeProbeCount = 0;
229
+ }
230
+
231
+ /**
232
+ * Test-only seam: previously reset the deployment cache. The cache was
233
+ * removed in the AA10 fix; this function is retained as a no-op so the
234
+ * lifecycle test (and any future test that imports it) doesn't break.
235
+ */
236
+ export function __resetDeployedAccountsForTests(): void {
237
+ /* no-op: session cache removed; getInitCode always re-checks eth_getCode */
238
+ }
238
239
 
239
240
  // ---------------------------------------------------------------------------
240
241
  // Per-account submission mutex — 3.3.1-rc.3 AA25 serialization
@@ -298,36 +299,80 @@ export function __resetSenderLocksForTests(): void {
298
299
  _senderSubmissionLocks.clear();
299
300
  }
300
301
 
302
+ // ---------------------------------------------------------------------------
303
+ // Test-only WASM mock seams (AA10 submit-path retry test)
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /**
307
+ * Test-only seam: inject a mock WASM module.
308
+ *
309
+ * The AA10 submit-path retry test needs to drive the real submitFactBatchOnChain
310
+ * while controlling WASM behavior (deriveEoa, encodeBatchCall, hashUserOp,
311
+ * signUserOp, getEntryPointAddress). This seam swaps the module-level _wasm
312
+ * reference so the test can provide stubs.
313
+ *
314
+ * MUST be followed by __clearWasmForTests() in teardown.
315
+ */
316
+ export function __setWasmForTests(mock: any): void {
317
+ _wasm = mock;
318
+ }
319
+
320
+ /**
321
+ * Test-only seam: restore the real WASM module.
322
+ *
323
+ * Clears a test-injected mock WASM and resets the module to null so the next
324
+ * getWasm() call reloads the real @totalreclaw/core.
325
+ */
326
+ export function __clearWasmForTests(): void {
327
+ _wasm = null;
328
+ }
329
+
301
330
  /**
302
331
  * Check if a Smart Account is deployed and return factory/factoryData if not.
303
332
  *
304
- * For ERC-4337 v0.7, undeployed accounts need `factory` and `factoryData`
305
- * in the UserOp so the EntryPoint can deploy them during the first transaction.
333
+ * For ERC-4337 v0.7, undeployed (counterfactual) accounts need `factory`
334
+ * and `factoryData` in the UserOp so the EntryPoint deploys the SA + runs
335
+ * signature validation in one transaction.
336
+ *
337
+ * Re-checks `eth_getCode` on EVERY call — no session cache. The previous
338
+ * in-memory cache was the source of AA10 "sender already constructed"
339
+ * errors: it could be stale (process restart, missed receipt, lagging RPC
340
+ * node) and re-add initCode to a UserOp whose sender was already
341
+ * constructed. Each submission now pays one extra RPC read to assert the
342
+ * on-chain deployment state at submit time, which is the only source of
343
+ * truth the EntryPoint will enforce. See the note above on the removed
344
+ * cache for the full failure taxonomy.
306
345
  */
307
346
  async function getInitCode(
308
347
  sender: string,
309
348
  eoaAddress: string,
310
349
  rpcUrl: string,
311
350
  ): Promise<{ factory: string | null; factoryData: string | null }> {
312
- // Session cache: if we already deployed this account, skip the RPC check
313
- if (deployedAccounts.has(sender.toLowerCase())) {
314
- return { factory: null, factoryData: null };
315
- }
316
-
317
- // Check if the Smart Account contract is deployed
318
- const codeResp = await fetch(rpcUrl, {
319
- method: 'POST',
351
+ // Check if the Smart Account contract is deployed. Always re-read — never
352
+ // cache. The per-sender submission mutex serializes calls for the same
353
+ // account, so this cannot race a nonce fetch.
354
+ _ethGetCodeProbeCount++;
355
+ const codeJson = await rpcRequest({
356
+ url: rpcUrl,
320
357
  headers: { 'Content-Type': 'application/json' },
321
- body: JSON.stringify({
322
- jsonrpc: '2.0', id: 1, method: 'eth_getCode',
323
- params: [sender, 'latest'],
324
- }),
358
+ method: 'eth_getCode',
359
+ params: [sender, 'latest'],
325
360
  });
326
- const codeJson = await codeResp.json() as { result?: string };
327
- const isDeployed = codeJson.result && codeJson.result !== '0x' && codeJson.result !== '0x0';
361
+ // A JSON-RPC error envelope (e.g. a rate-limited public RPC) or a
362
+ // non-string result is NOT evidence the account is undeployed. Treat it as
363
+ // a hard failure and throw — otherwise we would attach initCode to a
364
+ // possibly-deployed sender, guaranteeing AA10 "sender already constructed"
365
+ // at the paymaster. The thrown error propagates to the submit retry-loop
366
+ // catch; it does not match the AA25/AA10 regex, so it fails fast with an
367
+ // accurate message rather than poisoning a UserOp. Only a literal '0x' /
368
+ // '0x0' result means "not deployed" (#402).
369
+ const codeResult = codeJson.result;
370
+ if (codeJson.error || typeof codeResult !== 'string') {
371
+ throw new Error('eth_getCode failed: ' + (codeJson.error?.message || 'no result'));
372
+ }
373
+ const isDeployed = codeResult !== '0x' && codeResult !== '0x0';
328
374
 
329
375
  if (isDeployed) {
330
- deployedAccounts.add(sender.toLowerCase());
331
376
  return { factory: null, factoryData: null };
332
377
  }
333
378
 
@@ -344,7 +389,7 @@ async function getInitCode(
344
389
  }
345
390
 
346
391
  // ---------------------------------------------------------------------------
347
- // On-chain submission (ERC-4337 UserOps via raw fetch)
392
+ // On-chain submission (ERC-4337 UserOps via relay.ts network site)
348
393
  // ---------------------------------------------------------------------------
349
394
 
350
395
  /**
@@ -356,7 +401,7 @@ async function getInitCode(
356
401
  * 3. UserOp hashing (ERC-4337 v0.7)
357
402
  * 4. ECDSA signing (EIP-191 prefixed)
358
403
  *
359
- * All JSON-RPC calls go through raw fetch() to the relay bundler endpoint.
404
+ * All JSON-RPC calls go through `relay.ts` to the relay bundler endpoint.
360
405
  */
361
406
  export async function submitFactOnChain(
362
407
  protobufPayload: Buffer,
@@ -397,13 +442,22 @@ async function submitFactOnChainLocked(
397
442
 
398
443
  // Helper for JSON-RPC calls to relay bundler (with 429 retry)
399
444
  async function rpc(method: string, params: unknown[]): Promise<any> {
400
- return rpcWithRetry(bundlerUrl, headers, method, params);
445
+ return rpcWithRetry({ url: bundlerUrl, headers, method, params });
401
446
  }
402
447
 
403
448
  const entryPoint = config.entryPointAddress || getWasm().getEntryPointAddress();
404
449
 
405
- // 2. Encode calldata (SimpleAccount.execute → DataEdge fallback)
406
- const calldataBytes = getWasm().encodeSingleCall(protobufPayload);
450
+ // 2. Encode calldata (SimpleAccount.execute → DataEdge).
451
+ // Target the resolved DataEdge address (env → relay billing → WASM default,
452
+ // per getSubgraphConfig / #462) so writes land on the SAME contract the
453
+ // relay reads from. The legacy `encodeSingleCall` bakes the PROD DataEdge,
454
+ // which stranded staging writes on prod (#460). Guard: an empty address
455
+ // (config built without getSubgraphConfig) falls back to the legacy encoder
456
+ // — behavior-identical to before, and avoids `encodeSingleCallTo` throwing
457
+ // on a bad address.
458
+ const calldataBytes = config.dataEdgeAddress
459
+ ? getWasm().encodeSingleCallTo(protobufPayload, config.dataEdgeAddress)
460
+ : getWasm().encodeSingleCall(protobufPayload);
407
461
  const callData = `0x${Buffer.from(calldataBytes).toString('hex')}`;
408
462
 
409
463
  // 3. Get gas prices from Pimlico
@@ -412,9 +466,6 @@ async function submitFactOnChainLocked(
412
466
 
413
467
  const rpcUrl = config.rpcUrl || CONFIG.rpcUrl || getDefaultRpcUrl(config.chainId);
414
468
 
415
- // 4. Check if Smart Account is deployed (needed for factory/factoryData)
416
- const { factory, factoryData } = await getInitCode(sender, eoa.address, rpcUrl);
417
-
418
469
  // 5. Get nonce from EntryPoint via bundler RPC.
419
470
  // Routing through the bundler lets Pimlico account for pending mempool
420
471
  // UserOps, preventing AA25 nonce conflicts on rapid submissions.
@@ -425,89 +476,56 @@ async function submitFactOnChainLocked(
425
476
  const keyPadded = '0'.repeat(64);
426
477
  const nonceCalldata = `0x35567e1a${senderPadded}${keyPadded}`;
427
478
 
428
- let nonce: string;
429
- try {
430
- const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
431
- nonce = nonceResult || '0x0';
432
- } catch {
433
- // Fallback to public RPC if bundler doesn't support eth_call
434
- const nonceResp = await fetch(rpcUrl, {
435
- method: 'POST',
436
- headers: { 'Content-Type': 'application/json' },
437
- body: JSON.stringify({
438
- jsonrpc: '2.0', id: 1, method: 'eth_call',
479
+ // Helper to fetch nonce (with bundler fallback)
480
+ async function fetchNonce(): Promise<string> {
481
+ try {
482
+ const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
483
+ return nonceResult || '0x0';
484
+ } catch {
485
+ // Fallback to public RPC if bundler doesn't support eth_call
486
+ const nonceJson = await rpcRequest({
487
+ url: rpcUrl,
488
+ headers: { 'Content-Type': 'application/json' },
489
+ method: 'eth_call',
439
490
  params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
440
- }),
441
- });
442
- const nonceJson = await nonceResp.json() as { result?: string };
443
- nonce = nonceJson.result || '0x0';
491
+ });
492
+ return (nonceJson.result as string) || '0x0';
493
+ }
444
494
  }
445
495
 
446
- // 6. Build unsigned UserOp (v0.7 fields, camelCase for Rust JSON serde)
447
- const unsignedOp: Record<string, any> = {
448
- sender,
449
- nonce,
450
- callData,
451
- callGasLimit: '0x0',
452
- verificationGasLimit: '0x0',
453
- preVerificationGas: '0x0',
454
- maxFeePerGas: fast.maxFeePerGas,
455
- maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
456
- signature: DUMMY_SIGNATURE,
457
- };
458
- if (factory) {
459
- unsignedOp.factory = factory;
460
- unsignedOp.factoryData = factoryData;
461
- }
496
+ // Track force-deployed senders for AA10 retry (local to this submission attempt)
497
+ const forceDeployed = new Set<string>();
462
498
 
463
- // 7. Get paymaster sponsorship (fills gas limits + paymaster fields)
464
- const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
465
- Object.assign(unsignedOp, sponsorResult);
499
+ // Single retry loop: getInitCode build UserOp sponsor → sign → send
500
+ // On AA10 "sender already constructed", mark sender as force-deployed and retry.
501
+ // AA10 can occur at pm_sponsorUserOperation (initCode present on deployed sender)
502
+ // or eth_sendUserOperation (same root cause). This loop handles both.
503
+ let userOpHash: string | undefined;
504
+ let lastErr: any;
505
+ let attempt = 0;
506
+ const maxAttempts = 2;
466
507
 
467
- // 8. Hash and sign the UserOp via WASM
468
- const opJson = JSON.stringify(unsignedOp);
469
- const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
470
- const sigHex = getWasm().signUserOp(hashHex, eoa.private_key);
471
- unsignedOp.signature = `0x${sigHex}`;
508
+ while (attempt < maxAttempts) {
509
+ attempt++;
472
510
 
473
- // 9. Submit the signed UserOp (with AA25 nonce conflict retry)
474
- let userOpHash: string;
475
- try {
476
- userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
477
- } catch (err: any) {
478
- const msg = err?.message || '';
479
- if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
480
- console.error('AA25/AA10 nonce conflict detected, rebuilding UserOp with fresh nonce...');
481
- // Bust deployment cache so getInitCode re-checks on-chain
482
- deployedAccounts.delete(sender.toLowerCase());
483
-
484
- // Wait for previous UserOp to mine before retrying with fresh nonce.
485
- // Public RPC won't reflect the new nonce until the tx is on-chain.
486
- await new Promise(r => setTimeout(r, 15000));
487
-
488
- // Re-fetch initCode and nonce
489
- const { factory: retryFactory, factoryData: retryFactoryData } = await getInitCode(sender, eoa.address, rpcUrl);
490
- let retryNonce: string;
491
- try {
492
- const retryNonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
493
- retryNonce = retryNonceResult || '0x0';
494
- } catch {
495
- const retryNonceResp = await fetch(rpcUrl, {
496
- method: 'POST',
497
- headers: { 'Content-Type': 'application/json' },
498
- body: JSON.stringify({
499
- jsonrpc: '2.0', id: 1, method: 'eth_call',
500
- params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
501
- }),
502
- });
503
- const retryNonceJson = await retryNonceResp.json() as { result?: string };
504
- retryNonce = retryNonceJson.result || '0x0';
511
+ try {
512
+ // 4. Check if Smart Account is deployed (needed for factory/factoryData)
513
+ // If force-deployed, skip eth_getCode and return null initCode.
514
+ let factory: string | null = null;
515
+ let factoryData: string | null = null;
516
+ if (!forceDeployed.has(sender.toLowerCase())) {
517
+ const initCode = await getInitCode(sender, eoa.address, rpcUrl);
518
+ factory = initCode.factory;
519
+ factoryData = initCode.factoryData;
505
520
  }
506
521
 
507
- // Rebuild unsigned UserOp with fresh nonce and initCode
508
- const retryOp: Record<string, any> = {
522
+ // Fetch fresh nonce for each attempt
523
+ const nonce = await fetchNonce();
524
+
525
+ // 6. Build unsigned UserOp (v0.7 fields, camelCase for Rust JSON serde)
526
+ const unsignedOp: Record<string, any> = {
509
527
  sender,
510
- nonce: retryNonce,
528
+ nonce,
511
529
  callData,
512
530
  callGasLimit: '0x0',
513
531
  verificationGasLimit: '0x0',
@@ -516,25 +534,57 @@ async function submitFactOnChainLocked(
516
534
  maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
517
535
  signature: DUMMY_SIGNATURE,
518
536
  };
519
- if (retryFactory) {
520
- retryOp.factory = retryFactory;
521
- retryOp.factoryData = retryFactoryData;
537
+ if (factory) {
538
+ unsignedOp.factory = factory;
539
+ unsignedOp.factoryData = factoryData;
522
540
  }
523
541
 
524
- // Re-sponsor and re-sign
525
- const retrySponsor = await rpc('pm_sponsorUserOperation', [retryOp, entryPoint]);
526
- Object.assign(retryOp, retrySponsor);
527
- const retryOpJson = JSON.stringify(retryOp);
528
- const retryHashHex = getWasm().hashUserOp(retryOpJson, entryPoint, BigInt(config.chainId));
529
- const retrySigHex = getWasm().signUserOp(retryHashHex, eoa.private_key);
530
- retryOp.signature = `0x${retrySigHex}`;
531
-
532
- userOpHash = await rpc('eth_sendUserOperation', [retryOp, entryPoint]);
533
- } else {
542
+ // 7. Get paymaster sponsorship (fills gas limits + paymaster fields)
543
+ // This is where AA10 "sender already constructed" can occur if initCode
544
+ // is present but the sender is already deployed.
545
+ const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
546
+ Object.assign(unsignedOp, sponsorResult);
547
+
548
+ // 8. Hash and sign the UserOp via WASM
549
+ const opJson = JSON.stringify(unsignedOp);
550
+ const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
551
+ const sigHex = signUserOp(hashHex, eoa.private_key);
552
+ unsignedOp.signature = `0x${sigHex}`;
553
+
554
+ // 9. Submit the signed UserOp
555
+ userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
556
+ // Success — break out of retry loop
557
+ break;
558
+ } catch (err: any) {
559
+ const msg = err?.message || '';
560
+ // AA10 "sender already constructed" or AA25 invalid nonce → retry
561
+ if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
562
+ lastErr = err;
563
+ console.error(`AA25/AA10 detected (attempt ${attempt}/${maxAttempts}), retrying...`);
564
+ // On AA10, force-mark sender as deployed so next retry omits initCode
565
+ if (/AA10/i.test(msg)) {
566
+ forceDeployed.add(sender.toLowerCase());
567
+ console.error('AA10: force-marking sender as deployed, retrying without initCode');
568
+ }
569
+ // Wait for previous UserOp to mine before retrying with fresh nonce.
570
+ // Public RPC won't reflect the new nonce until the tx is on-chain.
571
+ await new Promise(r => setTimeout(r, 15000));
572
+ // Continue to next iteration of retry loop
573
+ continue;
574
+ }
575
+ // Not a retryable error — re-throw
534
576
  throw err;
535
577
  }
536
578
  }
537
579
 
580
+ // Retry budget exhausted with no successful submission — throw the last
581
+ // retryable error instead of falling through to a receipt poll against an
582
+ // undefined userOpHash (which used to burn 120s and surface a misleading
583
+ // 'submission failed (tx=…)'). See #402.
584
+ if (userOpHash == null) {
585
+ throw lastErr ?? new Error('eth_sendUserOperation returned no result');
586
+ }
587
+
538
588
  // 10. Wait for receipt (poll up to 120s)
539
589
  let receipt = null;
540
590
  for (let i = 0; i < 60; i++) {
@@ -547,10 +597,10 @@ async function submitFactOnChainLocked(
547
597
 
548
598
  const success = receipt?.success ?? false;
549
599
 
550
- // Mark account as deployed after first successful submission
551
- if (success) {
552
- deployedAccounts.add(sender.toLowerCase());
553
- }
600
+ // No session-deployment cache to update getInitCode always re-checks
601
+ // eth_getCode on the next submission, so a successful receipt needs no
602
+ // bookkeeping here. (Previous cache removed in the AA10 fix — see note
603
+ // at the top of this section.)
554
604
 
555
605
  return {
556
606
  txHash: receipt?.receipt?.transactionHash || '',
@@ -614,14 +664,22 @@ async function submitFactBatchOnChainLocked(
614
664
 
615
665
  // Helper for JSON-RPC calls to relay bundler (with 429 retry)
616
666
  async function rpc(method: string, params: unknown[]): Promise<any> {
617
- return rpcWithRetry(bundlerUrl, headers, method, params);
667
+ return rpcWithRetry({ url: bundlerUrl, headers, method, params });
618
668
  }
619
669
  const entryPoint = config.entryPointAddress || getWasm().getEntryPointAddress();
620
670
 
621
- // Encode batch calldata (SimpleAccount.executeBatch)
622
- // encodeBatchCall expects a JSON array of hex-encoded payload strings
671
+ // Encode batch calldata (SimpleAccount.executeBatch).
672
+ // encodeBatchCall{,To} expect a JSON array of hex-encoded payload strings.
673
+ // Target the resolved DataEdge address (env → relay billing → WASM default,
674
+ // per getSubgraphConfig / #462) so writes land on the SAME contract the
675
+ // relay reads from — the legacy `encodeBatchCall` bakes the PROD DataEdge and
676
+ // stranded staging writes on prod (#460). Guard: empty address → legacy
677
+ // encoder (behavior-identical; avoids `encodeBatchCallTo` throwing).
623
678
  const payloadsHex = protobufPayloads.map(p => p.toString('hex'));
624
- const calldataBytes = getWasm().encodeBatchCall(JSON.stringify(payloadsHex));
679
+ const payloadsJson = JSON.stringify(payloadsHex);
680
+ const calldataBytes = config.dataEdgeAddress
681
+ ? getWasm().encodeBatchCallTo(payloadsJson, config.dataEdgeAddress)
682
+ : getWasm().encodeBatchCall(payloadsJson);
625
683
  const callData = `0x${Buffer.from(calldataBytes).toString('hex')}`;
626
684
 
627
685
  // Get gas prices
@@ -630,110 +688,58 @@ async function submitFactBatchOnChainLocked(
630
688
 
631
689
  const rpcUrl = config.rpcUrl || CONFIG.rpcUrl || getDefaultRpcUrl(config.chainId);
632
690
 
633
- // Check if Smart Account is deployed (needed for factory/factoryData)
634
- const { factory, factoryData } = await getInitCode(sender, eoa.address, rpcUrl);
635
-
636
691
  // Get nonce via bundler (accounts for pending mempool UserOps) with public RPC fallback
637
692
  const senderPadded = sender.slice(2).toLowerCase().padStart(64, '0');
638
693
  const keyPadded = '0'.repeat(64);
639
694
  const nonceCalldata = `0x35567e1a${senderPadded}${keyPadded}`;
640
695
 
641
- let nonce: string;
642
- try {
643
- const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
644
- nonce = nonceResult || '0x0';
645
- } catch {
646
- const nonceResp = await fetch(rpcUrl, {
647
- method: 'POST',
648
- headers: { 'Content-Type': 'application/json' },
649
- body: JSON.stringify({
650
- jsonrpc: '2.0', id: 1, method: 'eth_call',
651
- params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
652
- }),
653
- });
654
- const nonceJson = await nonceResp.json() as { result?: string };
655
- nonce = nonceJson.result || '0x0';
656
- }
657
-
658
- // Build unsigned UserOp
659
- const unsignedOp: Record<string, any> = {
660
- sender,
661
- nonce,
662
- callData,
663
- callGasLimit: '0x0',
664
- verificationGasLimit: '0x0',
665
- preVerificationGas: '0x0',
666
- maxFeePerGas: fast.maxFeePerGas,
667
- maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
668
- signature: DUMMY_SIGNATURE,
669
- };
670
- if (factory) {
671
- unsignedOp.factory = factory;
672
- unsignedOp.factoryData = factoryData;
673
- }
674
-
675
- // Gas estimation for batch operations — get accurate gas limits from Pimlico
676
- // before paymaster sponsorship (can't bump after sponsorship as it invalidates
677
- // the paymaster's signature, causing AA34).
678
- if (protobufPayloads.length > 1) {
696
+ // Helper to fetch nonce (with bundler fallback)
697
+ async function fetchNonce(): Promise<string> {
679
698
  try {
680
- const gasEstimate = await rpc('eth_estimateUserOperationGas', [unsignedOp, entryPoint]);
681
- if (gasEstimate.callGasLimit) unsignedOp.callGasLimit = gasEstimate.callGasLimit;
682
- if (gasEstimate.verificationGasLimit) unsignedOp.verificationGasLimit = gasEstimate.verificationGasLimit;
683
- if (gasEstimate.preVerificationGas) unsignedOp.preVerificationGas = gasEstimate.preVerificationGas;
699
+ const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
700
+ return nonceResult || '0x0';
684
701
  } catch {
685
- // If estimation fails, let the paymaster handle it (default behavior)
702
+ const nonceJson = await rpcRequest({
703
+ url: rpcUrl,
704
+ headers: { 'Content-Type': 'application/json' },
705
+ method: 'eth_call',
706
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
707
+ });
708
+ return (nonceJson.result as string) || '0x0';
686
709
  }
687
710
  }
688
711
 
689
- // Paymaster sponsorship (uses gas limits from estimation above for batches)
690
- const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
691
- Object.assign(unsignedOp, sponsorResult);
712
+ // Track force-deployed senders for AA10 retry (local to this submission attempt)
713
+ const forceDeployed = new Set<string>();
692
714
 
693
- // Hash and sign via WASM
694
- const opJson = JSON.stringify(unsignedOp);
695
- const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
696
- const sigHex = getWasm().signUserOp(hashHex, eoa.private_key);
697
- unsignedOp.signature = `0x${sigHex}`;
715
+ // Single retry loop: getInitCode → build UserOp → estimate → sponsor → sign send
716
+ // On AA10 "sender already constructed", mark sender as force-deployed and retry.
717
+ let userOpHash: string | undefined;
718
+ let lastErr: any;
719
+ let attempt = 0;
720
+ const maxAttempts = 2;
698
721
 
699
- // Submit (with AA25 nonce conflict retry)
700
- let userOpHash: string;
701
- try {
702
- userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
703
- } catch (err: any) {
704
- const msg = err?.message || '';
705
- if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
706
- console.error('AA25/AA10 nonce conflict detected (batch), rebuilding UserOp with fresh nonce...');
707
- // Bust deployment cache so getInitCode re-checks on-chain
708
- deployedAccounts.delete(sender.toLowerCase());
709
-
710
- // Wait for previous UserOp to mine before retrying with fresh nonce.
711
- // Public RPC won't reflect the new nonce until the tx is on-chain.
712
- await new Promise(r => setTimeout(r, 15000));
713
-
714
- // Re-fetch initCode and nonce
715
- const { factory: retryFactory, factoryData: retryFactoryData } = await getInitCode(sender, eoa.address, rpcUrl);
716
- let retryNonce: string;
717
- try {
718
- const retryNonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
719
- retryNonce = retryNonceResult || '0x0';
720
- } catch {
721
- const retryNonceResp = await fetch(rpcUrl, {
722
- method: 'POST',
723
- headers: { 'Content-Type': 'application/json' },
724
- body: JSON.stringify({
725
- jsonrpc: '2.0', id: 1, method: 'eth_call',
726
- params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
727
- }),
728
- });
729
- const retryNonceJson = await retryNonceResp.json() as { result?: string };
730
- retryNonce = retryNonceJson.result || '0x0';
722
+ while (attempt < maxAttempts) {
723
+ attempt++;
724
+
725
+ try {
726
+ // Check if Smart Account is deployed (needed for factory/factoryData)
727
+ // If force-deployed, skip eth_getCode and return null initCode.
728
+ let factory: string | null = null;
729
+ let factoryData: string | null = null;
730
+ if (!forceDeployed.has(sender.toLowerCase())) {
731
+ const initCode = await getInitCode(sender, eoa.address, rpcUrl);
732
+ factory = initCode.factory;
733
+ factoryData = initCode.factoryData;
731
734
  }
732
735
 
733
- // Rebuild unsigned UserOp with fresh nonce and initCode
734
- const retryOp: Record<string, any> = {
736
+ // Fetch fresh nonce for each attempt
737
+ const nonce = await fetchNonce();
738
+
739
+ // Build unsigned UserOp
740
+ const unsignedOp: Record<string, any> = {
735
741
  sender,
736
- nonce: retryNonce,
742
+ nonce,
737
743
  callData,
738
744
  callGasLimit: '0x0',
739
745
  verificationGasLimit: '0x0',
@@ -742,25 +748,71 @@ async function submitFactBatchOnChainLocked(
742
748
  maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
743
749
  signature: DUMMY_SIGNATURE,
744
750
  };
745
- if (retryFactory) {
746
- retryOp.factory = retryFactory;
747
- retryOp.factoryData = retryFactoryData;
751
+ if (factory) {
752
+ unsignedOp.factory = factory;
753
+ unsignedOp.factoryData = factoryData;
748
754
  }
749
755
 
750
- // Re-sponsor and re-sign
751
- const retrySponsor = await rpc('pm_sponsorUserOperation', [retryOp, entryPoint]);
752
- Object.assign(retryOp, retrySponsor);
753
- const retryOpJson = JSON.stringify(retryOp);
754
- const retryHashHex = getWasm().hashUserOp(retryOpJson, entryPoint, BigInt(config.chainId));
755
- const retrySigHex = getWasm().signUserOp(retryHashHex, eoa.private_key);
756
- retryOp.signature = `0x${retrySigHex}`;
756
+ // Gas estimation for batch operations — get accurate gas limits from Pimlico
757
+ // before paymaster sponsorship (can't bump after sponsorship as it invalidates
758
+ // the paymaster's signature, causing AA34).
759
+ if (protobufPayloads.length > 1) {
760
+ try {
761
+ const gasEstimate = await rpc('eth_estimateUserOperationGas', [unsignedOp, entryPoint]);
762
+ if (gasEstimate.callGasLimit) unsignedOp.callGasLimit = gasEstimate.callGasLimit;
763
+ if (gasEstimate.verificationGasLimit) unsignedOp.verificationGasLimit = gasEstimate.verificationGasLimit;
764
+ if (gasEstimate.preVerificationGas) unsignedOp.preVerificationGas = gasEstimate.preVerificationGas;
765
+ } catch {
766
+ // If estimation fails, let the paymaster handle it (default behavior)
767
+ }
768
+ }
757
769
 
758
- userOpHash = await rpc('eth_sendUserOperation', [retryOp, entryPoint]);
759
- } else {
770
+ // Paymaster sponsorship (uses gas limits from estimation above for batches)
771
+ // This is where AA10 "sender already constructed" can occur if initCode
772
+ // is present but the sender is already deployed.
773
+ const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
774
+ Object.assign(unsignedOp, sponsorResult);
775
+
776
+ // Hash and sign via WASM
777
+ const opJson = JSON.stringify(unsignedOp);
778
+ const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
779
+ const sigHex = signUserOp(hashHex, eoa.private_key);
780
+ unsignedOp.signature = `0x${sigHex}`;
781
+
782
+ // Submit the signed UserOp
783
+ userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
784
+ // Success — break out of retry loop
785
+ break;
786
+ } catch (err: any) {
787
+ const msg = err?.message || '';
788
+ // AA10 "sender already constructed" or AA25 invalid nonce → retry
789
+ if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
790
+ lastErr = err;
791
+ console.error(`AA25/AA10 detected (batch, attempt ${attempt}/${maxAttempts}), retrying...`);
792
+ // On AA10, force-mark sender as deployed so next retry omits initCode
793
+ if (/AA10/i.test(msg)) {
794
+ forceDeployed.add(sender.toLowerCase());
795
+ console.error('AA10: force-marking sender as deployed, retrying without initCode');
796
+ }
797
+ // Wait for previous UserOp to mine before retrying with fresh nonce.
798
+ // Public RPC won't reflect the new nonce until the tx is on-chain.
799
+ await new Promise(r => setTimeout(r, 15000));
800
+ // Continue to next iteration of retry loop
801
+ continue;
802
+ }
803
+ // Not a retryable error — re-throw
760
804
  throw err;
761
805
  }
762
806
  }
763
807
 
808
+ // Retry budget exhausted with no successful submission — throw the last
809
+ // retryable error instead of polling for a receipt against an undefined
810
+ // userOpHash (which used to burn 120s and surface a misleading
811
+ // 'submission failed (tx=…)'). See #402.
812
+ if (userOpHash == null) {
813
+ throw lastErr ?? new Error('eth_sendUserOperation returned no result');
814
+ }
815
+
764
816
  // Wait for receipt (poll up to 120s)
765
817
  let receipt = null;
766
818
  for (let i = 0; i < 60; i++) {
@@ -773,10 +825,10 @@ async function submitFactBatchOnChainLocked(
773
825
 
774
826
  const batchSuccess = receipt?.success ?? false;
775
827
 
776
- // Mark account as deployed after first successful submission
777
- if (batchSuccess) {
778
- deployedAccounts.add(sender.toLowerCase());
779
- }
828
+ // No session-deployment cache to update getInitCode always re-checks
829
+ // eth_getCode on the next submission, so a successful receipt needs no
830
+ // bookkeeping here. (Previous cache removed in the AA10 fix — see note
831
+ // at the top of the Smart Account deployment-check section.)
780
832
 
781
833
  return {
782
834
  txHash: receipt?.receipt?.transactionHash || '',
@@ -809,7 +861,10 @@ export function isSubgraphMode(): boolean {
809
861
  * - TOTALRECLAW_SELF_HOSTED -- set "true" to use self-hosted server (default: managed service)
810
862
  *
811
863
  * Chain ID is no longer configurable via env — it is auto-detected from the
812
- * relay billing response (free = Base Sepolia, Pro = Gnosis mainnet).
864
+ * relay billing response (post-ops-1 both tiers are on Gnosis mainnet, chain
865
+ * 100). The DataEdge contract address is likewise consumed from the relay's
866
+ * authoritative `data_edge_address` (env override still wins, WASM default is
867
+ * the final fallback — see the `dataEdgeAddress` resolution below, #460).
813
868
  */
814
869
  export function getSubgraphConfig(): SubgraphStoreConfig {
815
870
  return {
@@ -818,7 +873,12 @@ export function getSubgraphConfig(): SubgraphStoreConfig {
818
873
  mnemonic: CONFIG.recoveryPhrase,
819
874
  cachePath: CONFIG.cachePath,
820
875
  chainId: CONFIG.chainId,
821
- dataEdgeAddress: CONFIG.dataEdgeAddress || getWasm().getDataEdgeAddress(),
876
+ // Resolution order (#460): explicit operator env override → relay billing
877
+ // `data_edge_address` (verbatim) → WASM-baked default. The relay routes
878
+ // each environment to its own DataEdge (staging is on-chain isolated), so
879
+ // consuming its value keeps writes + reads on the same contract; without
880
+ // the middle term staging writes mined on the prod default → empty recall.
881
+ dataEdgeAddress: CONFIG.dataEdgeAddress || getDataEdgeAddressOverride() || getWasm().getDataEdgeAddress(),
822
882
  entryPointAddress: CONFIG.entryPointAddress || getWasm().getEntryPointAddress(),
823
883
  rpcUrl: CONFIG.rpcUrl || undefined,
824
884
  };