@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.
- package/SKILL.md +33 -27
- package/api-client.ts +17 -9
- package/batch-gate.ts +42 -0
- package/billing-cache.ts +72 -23
- package/claims-helper.ts +2 -1
- package/config.ts +95 -13
- package/consolidation.ts +6 -13
- package/contradiction-sync.ts +19 -14
- package/credential-provider.ts +184 -0
- package/crypto.ts +27 -160
- package/dist/api-client.js +17 -9
- package/dist/batch-gate.js +40 -0
- package/dist/billing-cache.js +54 -24
- package/dist/claims-helper.js +2 -1
- package/dist/config.js +91 -13
- package/dist/consolidation.js +6 -15
- package/dist/contradiction-sync.js +15 -15
- package/dist/credential-provider.js +145 -0
- package/dist/crypto.js +17 -137
- package/dist/download-ux.js +11 -7
- package/dist/embedder-loader.js +266 -0
- package/dist/embedding.js +36 -3
- package/dist/entry.js +123 -0
- package/dist/fs-helpers.js +75 -379
- package/dist/import-adapters/gemini-adapter.js +29 -159
- package/dist/index.js +864 -2631
- package/dist/memory-runtime.js +459 -0
- package/dist/native-memory.js +123 -0
- package/dist/onboarding-cli.js +4 -8
- package/dist/pair-cli-relay.js +1 -8
- package/dist/pair-cli.js +1 -1
- package/dist/pair-crypto.js +16 -358
- package/dist/pair-http.js +147 -4
- package/dist/relay.js +140 -0
- package/dist/reranker.js +13 -8
- package/dist/semantic-dedup.js +5 -7
- package/dist/skill-register.js +97 -0
- package/dist/subgraph-search.js +3 -1
- package/dist/subgraph-store.js +348 -290
- package/dist/tool-gating.js +39 -26
- package/dist/tools.js +367 -0
- package/dist/tr-cli-export-helper.js +3 -3
- package/dist/tr-cli.js +65 -156
- package/dist/trajectory-poller.js +155 -9
- package/dist/vault-crypto.js +551 -0
- package/download-ux.ts +12 -6
- package/embedder-loader.ts +293 -1
- package/embedding.ts +43 -3
- package/entry.ts +132 -0
- package/fs-helpers.ts +93 -458
- package/import-adapters/gemini-adapter.ts +38 -183
- package/index.ts +912 -2917
- package/memory-runtime.ts +723 -0
- package/native-memory.ts +196 -0
- package/onboarding-cli.ts +3 -9
- package/openclaw.plugin.json +5 -17
- package/package.json +6 -3
- package/pair-cli-relay.ts +0 -9
- package/pair-cli.ts +1 -1
- package/pair-crypto.ts +41 -483
- package/pair-http.ts +194 -5
- package/postinstall.mjs +138 -0
- package/relay.ts +172 -0
- package/reranker.ts +13 -8
- package/semantic-dedup.ts +5 -6
- package/skill-register.ts +146 -0
- package/skill.json +1 -1
- package/subgraph-search.ts +3 -1
- package/subgraph-store.ts +367 -307
- package/tool-gating.ts +39 -26
- package/tools.ts +499 -0
- package/tr-cli-export-helper.ts +3 -3
- package/tr-cli.ts +69 -182
- package/trajectory-poller.ts +162 -10
- package/vault-crypto.ts +705 -0
- package/auto-pair-on-load.ts +0 -308
- package/dist/auto-pair-on-load.js +0 -197
- package/dist/pair-pending-injection.js +0 -125
- 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
|
|
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
|
|
9
|
-
* ECDSA
|
|
10
|
-
* and chain RPCs
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
|
|
158
|
+
const json = await rpcRequest({
|
|
159
|
+
url: rpcUrl,
|
|
207
160
|
headers: { 'Content-Type': 'application/json' },
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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
|
-
|
|
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`
|
|
305
|
-
* in the UserOp so the EntryPoint
|
|
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
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
params: [sender, 'latest'],
|
|
324
|
-
}),
|
|
358
|
+
method: 'eth_getCode',
|
|
359
|
+
params: [sender, 'latest'],
|
|
325
360
|
});
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
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
|
|
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
|
|
406
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
443
|
-
nonce = nonceJson.result || '0x0';
|
|
491
|
+
});
|
|
492
|
+
return (nonceJson.result as string) || '0x0';
|
|
493
|
+
}
|
|
444
494
|
}
|
|
445
495
|
|
|
446
|
-
//
|
|
447
|
-
const
|
|
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
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
//
|
|
508
|
-
const
|
|
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
|
|
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 (
|
|
520
|
-
|
|
521
|
-
|
|
537
|
+
if (factory) {
|
|
538
|
+
unsignedOp.factory = factory;
|
|
539
|
+
unsignedOp.factoryData = factoryData;
|
|
522
540
|
}
|
|
523
541
|
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
//
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
642
|
-
|
|
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
|
|
681
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
690
|
-
const
|
|
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
|
-
//
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
//
|
|
734
|
-
const
|
|
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
|
|
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 (
|
|
746
|
-
|
|
747
|
-
|
|
751
|
+
if (factory) {
|
|
752
|
+
unsignedOp.factory = factory;
|
|
753
|
+
unsignedOp.factoryData = factoryData;
|
|
748
754
|
}
|
|
749
755
|
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
759
|
-
|
|
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
|
-
//
|
|
777
|
-
|
|
778
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
};
|