@totalreclaw/totalreclaw 1.6.0 → 3.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/subgraph-store.ts CHANGED
@@ -5,34 +5,83 @@
5
5
  * "true"). Replaces the HTTP POST to /v1/store with an on-chain transaction
6
6
  * flow.
7
7
  *
8
- * Builds UserOps client-side using `permissionless` + `viem` and submits
9
- * them through the TotalReclaw relay server, which proxies bundler/paymaster
10
- * JSON-RPC to Pimlico with its own API key. Clients never need a Pimlico key.
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
11
  */
12
12
 
13
- import { createPublicClient, http, type Hex, type Address, type Chain } from 'viem';
14
- import { entryPoint07Address } from 'viem/account-abstraction';
15
- import { mnemonicToAccount } from 'viem/accounts';
16
- import { gnosis, gnosisChiado, baseSepolia } from 'viem/chains';
17
- import { createSmartAccountClient } from 'permissionless';
18
- import { toSimpleSmartAccount } from 'permissionless/accounts';
19
- import { createPimlicoClient } from 'permissionless/clients/pimlico';
13
+ // Lazy-load WASM to avoid crash when npm install hasn't finished yet.
14
+ let _wasm: typeof import('@totalreclaw/core') | null = null;
15
+ function getWasm() {
16
+ if (!_wasm) _wasm = require('@totalreclaw/core');
17
+ return _wasm;
18
+ }
19
+ import { CONFIG } from './config.js';
20
20
 
21
21
  // ---------------------------------------------------------------------------
22
- // Types
22
+ // Pimlico 429 retry helper
23
23
  // ---------------------------------------------------------------------------
24
24
 
25
- /** Default EventfulDataEdge contract address on Gnosis mainnet */
26
- const DEFAULT_DATA_EDGE_ADDRESS = '0xC445af1D4EB9fce4e1E61fE96ea7B8feBF03c5ca';
25
+ /**
26
+ * Wrap a fetch-based JSON-RPC call with exponential backoff for HTTP 429
27
+ * (rate limit) responses from Pimlico. Max 5 retries with 5s base delay,
28
+ * doubling each attempt, capped at 60s, plus random jitter (0-1000ms).
29
+ * Total retry window: ~135s (5+10+20+40+60 plus jitter).
30
+ * All other HTTP errors throw immediately.
31
+ */
32
+ async function rpcWithRetry(
33
+ url: string,
34
+ headers: Record<string, string>,
35
+ method: string,
36
+ params: unknown[],
37
+ ): Promise<any> {
38
+ const maxRetries = 5;
39
+ const baseDelay = 5000; // 5 seconds
40
+ const maxDelay = 60_000; // 60 seconds cap
41
+ const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
42
+
43
+ for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
44
+ const resp = await fetch(url, { method: 'POST', headers, body });
45
+
46
+ if (resp.ok) {
47
+ const json = await resp.json() as { result?: any; error?: { message: string } };
48
+ if (json.error) {
49
+ // Check if the RPC-level error message indicates a rate limit
50
+ if (attempt <= maxRetries && /429|rate limit/i.test(json.error.message)) {
51
+ const delay = Math.min(Math.pow(2, attempt - 1) * baseDelay, maxDelay) + Math.floor(Math.random() * 1000);
52
+ console.error(`Pimlico rate limited, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})...`);
53
+ await new Promise(r => setTimeout(r, delay));
54
+ continue;
55
+ }
56
+ throw new Error(`RPC ${method}: ${json.error.message}`);
57
+ }
58
+ return json.result;
59
+ }
60
+
61
+ // HTTP-level 429 — retry with backoff
62
+ if (resp.status === 429 && attempt <= maxRetries) {
63
+ const delay = Math.min(Math.pow(2, attempt - 1) * baseDelay, maxDelay) + Math.floor(Math.random() * 1000);
64
+ console.error(`Pimlico rate limited, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})...`);
65
+ await new Promise(r => setTimeout(r, delay));
66
+ continue;
67
+ }
68
+
69
+ throw new Error(`Relay returned HTTP ${resp.status} for ${method}`);
70
+ }
27
71
 
28
- /** Well-known ERC-4337 EntryPoint v0.7 address (same on all chains) */
29
- const DEFAULT_ENTRYPOINT_ADDRESS = '0x0000000071727De22E5E9d8BAf0edAc6f37da032';
72
+ // Should not be reached, but satisfies TypeScript
73
+ throw new Error(`RPC ${method}: max retries exceeded`);
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Types
78
+ // ---------------------------------------------------------------------------
30
79
 
31
80
  export interface SubgraphStoreConfig {
32
81
  relayUrl: string; // TotalReclaw relay server URL (proxies bundler + subgraph)
33
82
  mnemonic: string; // BIP-39 mnemonic for key derivation
34
83
  cachePath: string; // Hot cache file path
35
- chainId: number; // 100 for Gnosis mainnet, 10200 for Chiado testnet, 84532 for Base Sepolia
84
+ chainId: number; // 100 for Gnosis mainnet, 84532 for Base Sepolia
36
85
  dataEdgeAddress: string; // EventfulDataEdge contract address
37
86
  entryPointAddress: string; // ERC-4337 EntryPoint v0.7
38
87
  authKeyHex?: string; // HKDF auth key for relay server Authorization header
@@ -44,141 +93,203 @@ export interface FactPayload {
44
93
  id: string;
45
94
  timestamp: string;
46
95
  owner: string; // Smart Account address (hex)
47
- encryptedBlob: string; // Hex-encoded AES-256-GCM ciphertext
96
+ encryptedBlob: string; // Hex-encoded XChaCha20-Poly1305 ciphertext
48
97
  blindIndices: string[]; // SHA-256 hashes (word + LSH)
49
98
  decayScore: number;
50
99
  source: string;
51
100
  contentFp: string;
52
101
  agentId: string;
53
102
  encryptedEmbedding?: string;
103
+ /**
104
+ * Outer protobuf schema version. Plugin v3.0.0 writes Memory Taxonomy v1
105
+ * JSON inner blobs, so this defaults to `PROTOBUF_VERSION_V4` (4). Omitting
106
+ * the field (or passing 0) yields the legacy `DEFAULT_PROTOBUF_VERSION`
107
+ * (3), which is retained so tombstone rows stay wire-compatible with
108
+ * pre-v3 readers if ever needed.
109
+ */
110
+ version?: number;
54
111
  }
55
112
 
113
+ /** Legacy protobuf wrapper schema version (v0/v1-binary inner blob). */
114
+ export const PROTOBUF_VERSION_LEGACY = 3;
115
+
116
+ /** Memory Taxonomy v1 protobuf wrapper schema version. */
117
+ export const PROTOBUF_VERSION_V4 = 4;
118
+
119
+ // Stub 65-byte signature for gas estimation (pm_sponsorUserOperation).
120
+ // Must be a structurally valid ECDSA signature (r,s,v) so that ecrecover does
121
+ // NOT revert inside SimpleAccount._validateSignature. All-zeros causes
122
+ // OpenZeppelin ECDSA.recover() to revert with ECDSAInvalidSignature() (0xf645eedf),
123
+ // which the EntryPoint surfaces as AA23.
124
+ // This matches the stub used by permissionless/viem — ecrecover returns a
125
+ // non-owner address, so validateUserOp returns SIG_VALIDATION_FAILED (1)
126
+ // instead of reverting, which is what bundlers expect during simulation.
127
+ const DUMMY_SIGNATURE =
128
+ '0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c';
129
+
56
130
  // ---------------------------------------------------------------------------
57
- // Protobuf encoding (unchanged)
131
+ // Protobuf encoding (WASM)
58
132
  // ---------------------------------------------------------------------------
59
133
 
60
134
  /**
61
- * Encode a fact payload as a minimal Protobuf wire format.
135
+ * Encode a fact payload as a minimal Protobuf wire format via WASM core.
62
136
  *
63
- * Field numbers match server/proto/totalreclaw.proto:
64
- * 1: id (string), 2: timestamp (string), 3: owner (string),
65
- * 4: encrypted_blob (bytes), 5: blind_indices (repeated string),
66
- * 6: decay_score (double), 7: is_active (bool), 8: version (int32),
67
- * 9: source (string), 10: content_fp (string), 11: agent_id (string),
68
- * 12: sequence_id (int64), 13: encrypted_embedding (string)
137
+ * Field numbers match server/proto/totalreclaw.proto.
138
+ *
139
+ * As of plugin v3.0.0 the outer protobuf `version` field is written as 4
140
+ * when the caller passes `version: PROTOBUF_VERSION_V4`. Omitting the field
141
+ * preserves legacy v3 semantics (e.g. for tombstone tombstone rows that
142
+ * should round-trip through pre-v3 readers).
69
143
  */
70
144
  export function encodeFactProtobuf(fact: FactPayload): Buffer {
71
- const parts: Buffer[] = [];
72
-
73
- // Helper: encode a string field
74
- const writeString = (fieldNumber: number, value: string) => {
75
- if (!value) return;
76
- const data = Buffer.from(value, 'utf-8');
77
- const key = (fieldNumber << 3) | 2; // wire type 2 = length-delimited
78
- parts.push(encodeVarint(key));
79
- parts.push(encodeVarint(data.length));
80
- parts.push(data);
81
- };
82
-
83
- // Helper: encode a bytes field
84
- const writeBytes = (fieldNumber: number, value: Buffer) => {
85
- const key = (fieldNumber << 3) | 2;
86
- parts.push(encodeVarint(key));
87
- parts.push(encodeVarint(value.length));
88
- parts.push(value);
89
- };
90
-
91
- // Helper: encode a double field (wire type 1 = 64-bit)
92
- const writeDouble = (fieldNumber: number, value: number) => {
93
- const key = (fieldNumber << 3) | 1;
94
- parts.push(encodeVarint(key));
95
- const buf = Buffer.alloc(8);
96
- buf.writeDoubleLE(value);
97
- parts.push(buf);
98
- };
99
-
100
- // Helper: encode a varint field (wire type 0)
101
- const writeVarintField = (fieldNumber: number, value: number) => {
102
- const key = (fieldNumber << 3) | 0;
103
- parts.push(encodeVarint(key));
104
- parts.push(encodeVarint(value));
105
- };
106
-
107
- // Encode fields
108
- writeString(1, fact.id);
109
- writeString(2, fact.timestamp);
110
- writeString(3, fact.owner);
111
- writeBytes(4, Buffer.from(fact.encryptedBlob, 'hex'));
112
-
113
- for (const index of fact.blindIndices) {
114
- writeString(5, index);
115
- }
116
-
117
- writeDouble(6, fact.decayScore);
118
- writeVarintField(7, 1); // is_active = true
119
- writeVarintField(8, 2); // version = 2
120
- writeString(9, fact.source);
121
- writeString(10, fact.contentFp);
122
- writeString(11, fact.agentId);
123
- // Field 12 (sequence_id) is assigned by the subgraph mapping, not the client
124
- if (fact.encryptedEmbedding) {
125
- writeString(13, fact.encryptedEmbedding);
126
- }
127
-
128
- return Buffer.concat(parts);
129
- }
130
-
131
- /** Encode an integer as a Protobuf varint */
132
- export function encodeVarint(value: number): Buffer {
133
- const bytes: number[] = [];
134
- let v = value >>> 0; // unsigned
135
- while (v > 0x7f) {
136
- bytes.push((v & 0x7f) | 0x80);
137
- v >>>= 7;
138
- }
139
- bytes.push(v & 0x7f);
140
- return Buffer.from(bytes);
145
+ const json = JSON.stringify({
146
+ id: fact.id,
147
+ timestamp: fact.timestamp,
148
+ owner: fact.owner,
149
+ encrypted_blob_hex: fact.encryptedBlob,
150
+ blind_indices: fact.blindIndices,
151
+ decay_score: fact.decayScore,
152
+ source: fact.source,
153
+ content_fp: fact.contentFp,
154
+ agent_id: fact.agentId,
155
+ encrypted_embedding: fact.encryptedEmbedding || null,
156
+ version: fact.version ?? PROTOBUF_VERSION_LEGACY,
157
+ });
158
+ return Buffer.from(getWasm().encodeFactProtobuf(json));
141
159
  }
142
160
 
143
161
  // ---------------------------------------------------------------------------
144
162
  // Chain helpers
145
163
  // ---------------------------------------------------------------------------
146
164
 
147
- /** Resolve a viem Chain object from chain ID */
148
- function getChainFromId(chainId: number): Chain {
165
+ /** Get the default public RPC URL for a chain ID */
166
+ function getDefaultRpcUrl(chainId: number): string {
149
167
  switch (chainId) {
150
168
  case 100:
151
- return gnosis;
152
- case 10200:
153
- return gnosisChiado;
169
+ return 'https://rpc.gnosischain.com';
154
170
  case 84532:
155
- return baseSepolia;
171
+ return 'https://sepolia.base.org';
156
172
  default:
157
- return gnosis;
173
+ return 'https://sepolia.base.org';
158
174
  }
159
175
  }
160
176
 
161
- /** Build the relay bundler RPC URL from the relay server URL */
162
- function getRelayBundlerUrl(relayUrl: string): string {
163
- return `${relayUrl}/v1/bundler`;
177
+ // ---------------------------------------------------------------------------
178
+ // Smart Account address derivation
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Derive the Smart Account address from a BIP-39 mnemonic.
183
+ *
184
+ * Uses the SimpleAccountFactory's getAddress(owner, salt=0) view function
185
+ * via a raw eth_call to the chain RPC. The address is deterministic (CREATE2).
186
+ */
187
+ export async function deriveSmartAccountAddress(mnemonic: string, chainId?: number): Promise<string> {
188
+ const eoa = getWasm().deriveEoa(mnemonic) as { private_key: string; address: string };
189
+ const resolvedChainId = chainId ?? 84532;
190
+
191
+ // SimpleAccountFactory.getAddress(address owner, uint256 salt) — view function
192
+ // Selector: 0x8cb84e18 = keccak256("getAddress(address,uint256)")[0:4]
193
+ const factoryAddress = getWasm().getSimpleAccountFactory();
194
+ const ownerPadded = eoa.address.slice(2).toLowerCase().padStart(64, '0');
195
+ const saltPadded = '0'.repeat(64);
196
+ const selector = '8cb84e18';
197
+ const calldata = `0x${selector}${ownerPadded}${saltPadded}`;
198
+
199
+ const rpcUrl = CONFIG.rpcUrl || getDefaultRpcUrl(resolvedChainId);
200
+ const response = await fetch(rpcUrl, {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({
204
+ jsonrpc: '2.0',
205
+ id: 1,
206
+ method: 'eth_call',
207
+ params: [{ to: factoryAddress, data: calldata }, 'latest'],
208
+ }),
209
+ });
210
+ const json = await response.json() as { result?: string; error?: { message: string } };
211
+ if (json.error) {
212
+ throw new Error(`Failed to resolve Smart Account address: ${json.error.message}`);
213
+ }
214
+ if (!json.result || json.result === '0x') {
215
+ throw new Error('Failed to resolve Smart Account address: empty result');
216
+ }
217
+ // Result is a 32-byte ABI-encoded address — take last 20 bytes
218
+ return `0x${json.result.slice(-40)}`.toLowerCase();
164
219
  }
165
220
 
166
221
  // ---------------------------------------------------------------------------
167
- // On-chain submission (Pimlico UserOps)
222
+ // Smart Account deployment check (with session cache)
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /**
226
+ * Session-level cache for account deployment status.
227
+ * Once an account is deployed (first successful UserOp), we skip the
228
+ * eth_getCode check and omit factory/factoryData for all subsequent calls.
229
+ * This prevents AA10 "duplicate deployment" errors when multiple facts
230
+ * are stored in rapid succession for a first-time user.
231
+ */
232
+ const deployedAccounts = new Set<string>();
233
+
234
+ /**
235
+ * Check if a Smart Account is deployed and return factory/factoryData if not.
236
+ *
237
+ * For ERC-4337 v0.7, undeployed accounts need `factory` and `factoryData`
238
+ * in the UserOp so the EntryPoint can deploy them during the first transaction.
239
+ */
240
+ async function getInitCode(
241
+ sender: string,
242
+ eoaAddress: string,
243
+ rpcUrl: string,
244
+ ): Promise<{ factory: string | null; factoryData: string | null }> {
245
+ // Session cache: if we already deployed this account, skip the RPC check
246
+ if (deployedAccounts.has(sender.toLowerCase())) {
247
+ return { factory: null, factoryData: null };
248
+ }
249
+
250
+ // Check if the Smart Account contract is deployed
251
+ const codeResp = await fetch(rpcUrl, {
252
+ method: 'POST',
253
+ headers: { 'Content-Type': 'application/json' },
254
+ body: JSON.stringify({
255
+ jsonrpc: '2.0', id: 1, method: 'eth_getCode',
256
+ params: [sender, 'latest'],
257
+ }),
258
+ });
259
+ const codeJson = await codeResp.json() as { result?: string };
260
+ const isDeployed = codeJson.result && codeJson.result !== '0x' && codeJson.result !== '0x0';
261
+
262
+ if (isDeployed) {
263
+ deployedAccounts.add(sender.toLowerCase());
264
+ return { factory: null, factoryData: null };
265
+ }
266
+
267
+ // Account not deployed — build factory + factoryData for first-time deployment.
268
+ // createAccount(address owner, uint256 salt) — state-changing function
269
+ // Selector: 0x5fbfb9cf = keccak256("createAccount(address,uint256)")[0:4]
270
+ const factory = getWasm().getSimpleAccountFactory();
271
+ const ownerPadded = eoaAddress.slice(2).toLowerCase().padStart(64, '0');
272
+ const saltPadded = '0'.repeat(64);
273
+ const selector = '5fbfb9cf';
274
+ const factoryData = `0x${selector}${ownerPadded}${saltPadded}`;
275
+
276
+ return { factory, factoryData };
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // On-chain submission (ERC-4337 UserOps via raw fetch)
168
281
  // ---------------------------------------------------------------------------
169
282
 
170
283
  /**
171
284
  * Submit a fact on-chain via ERC-4337 UserOp through the relay server.
172
285
  *
173
- * Builds a UserOp client-side using `permissionless` + `viem`:
174
- * 1. Derives private key from mnemonic (BIP-39 + BIP-44 m/44'/60'/0'/0/0)
175
- * 2. Creates a SimpleSmartAccount
176
- * 3. Gets paymaster sponsorship (via relay proxy to Pimlico)
177
- * 4. Signs and submits the UserOp to relay bundler endpoint
178
- * 5. Waits for the transaction receipt
286
+ * Uses @totalreclaw/core WASM for:
287
+ * 1. EOA derivation from mnemonic (BIP-39 + BIP-44)
288
+ * 2. Calldata encoding (SimpleAccount.execute)
289
+ * 3. UserOp hashing (ERC-4337 v0.7)
290
+ * 4. ECDSA signing (EIP-191 prefixed)
179
291
  *
180
- * The relay server proxies all bundler/paymaster JSON-RPC to Pimlico
181
- * with its own API key. Clients never need a Pimlico API key.
292
+ * All JSON-RPC calls go through raw fetch() to the relay bundler endpoint.
182
293
  */
183
294
  export async function submitFactOnChain(
184
295
  protobufPayload: Buffer,
@@ -192,92 +303,178 @@ export async function submitFactOnChain(
192
303
  throw new Error('Mnemonic (TOTALRECLAW_RECOVERY_PHRASE) is required for on-chain submission');
193
304
  }
194
305
 
195
- const chain = getChainFromId(config.chainId);
196
- const bundlerRpcUrl = getRelayBundlerUrl(config.relayUrl);
197
- const dataEdgeAddress = config.dataEdgeAddress as Address;
198
- const entryPointAddr = (config.entryPointAddress || entryPoint07Address) as Address;
199
-
200
- // Build authenticated transport for relay server proxy
306
+ const bundlerUrl = `${config.relayUrl}/v1/bundler`;
201
307
  const headers: Record<string, string> = {
308
+ 'Content-Type': 'application/json',
202
309
  'X-TotalReclaw-Client': 'openclaw-plugin',
203
310
  };
204
311
  if (config.authKeyHex) headers['Authorization'] = `Bearer ${config.authKeyHex}`;
205
312
  if (config.walletAddress) headers['X-Wallet-Address'] = config.walletAddress;
206
313
 
207
- const authTransport = Object.keys(headers).length > 0
208
- ? http(bundlerRpcUrl, { fetchOptions: { headers } })
209
- : http(bundlerRpcUrl);
210
-
211
- // 1. Derive EOA signer from mnemonic (BIP-44 m/44'/60'/0'/0/0)
212
- const ownerAccount = mnemonicToAccount(config.mnemonic);
314
+ // Helper for JSON-RPC calls to relay bundler (with 429 retry)
315
+ async function rpc(method: string, params: unknown[]): Promise<any> {
316
+ return rpcWithRetry(bundlerUrl, headers, method, params);
317
+ }
213
318
 
214
- // 2. Create a public client for chain reads (using explicit RPC if configured,
215
- // NOT the bundler proxy which only supports ERC-4337 JSON-RPC methods)
216
- const publicClient = createPublicClient({
217
- chain,
218
- transport: config.rpcUrl ? http(config.rpcUrl) : http(),
219
- });
319
+ // 1. Derive EOA from mnemonic
320
+ const eoa = getWasm().deriveEoa(config.mnemonic) as { private_key: string; address: string };
321
+ const sender = config.walletAddress || await deriveSmartAccountAddress(config.mnemonic, config.chainId);
322
+ const entryPoint = config.entryPointAddress || getWasm().getEntryPointAddress();
323
+
324
+ // 2. Encode calldata (SimpleAccount.execute → DataEdge fallback)
325
+ const calldataBytes = getWasm().encodeSingleCall(protobufPayload);
326
+ const callData = `0x${Buffer.from(calldataBytes).toString('hex')}`;
327
+
328
+ // 3. Get gas prices from Pimlico
329
+ const gasPrices = await rpc('pimlico_getUserOperationGasPrice', []);
330
+ const fast = gasPrices.fast;
331
+
332
+ const rpcUrl = config.rpcUrl || CONFIG.rpcUrl || getDefaultRpcUrl(config.chainId);
333
+
334
+ // 4. Check if Smart Account is deployed (needed for factory/factoryData)
335
+ const { factory, factoryData } = await getInitCode(sender, eoa.address, rpcUrl);
336
+
337
+ // 5. Get nonce from EntryPoint via bundler RPC.
338
+ // Routing through the bundler lets Pimlico account for pending mempool
339
+ // UserOps, preventing AA25 nonce conflicts on rapid submissions.
340
+ // Requires relay allowlist to include eth_call (added in relay v1.x).
341
+ // Fallback: if bundler rejects eth_call (403/method_not_allowed), use public RPC.
342
+ // getNonce(address sender, uint192 key) — selector 0x35567e1a
343
+ const senderPadded = sender.slice(2).toLowerCase().padStart(64, '0');
344
+ const keyPadded = '0'.repeat(64);
345
+ const nonceCalldata = `0x35567e1a${senderPadded}${keyPadded}`;
346
+
347
+ let nonce: string;
348
+ try {
349
+ const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
350
+ nonce = nonceResult || '0x0';
351
+ } catch {
352
+ // Fallback to public RPC if bundler doesn't support eth_call
353
+ const nonceResp = await fetch(rpcUrl, {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({
357
+ jsonrpc: '2.0', id: 1, method: 'eth_call',
358
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
359
+ }),
360
+ });
361
+ const nonceJson = await nonceResp.json() as { result?: string };
362
+ nonce = nonceJson.result || '0x0';
363
+ }
220
364
 
221
- // 3. Create Pimlico client for bundler + paymaster operations (via relay)
222
- const pimlicoClient = createPimlicoClient({
223
- chain,
224
- transport: authTransport,
225
- entryPoint: {
226
- address: entryPointAddr,
227
- version: '0.7',
228
- },
229
- });
365
+ // 6. Build unsigned UserOp (v0.7 fields, camelCase for Rust JSON serde)
366
+ const unsignedOp: Record<string, any> = {
367
+ sender,
368
+ nonce,
369
+ callData,
370
+ callGasLimit: '0x0',
371
+ verificationGasLimit: '0x0',
372
+ preVerificationGas: '0x0',
373
+ maxFeePerGas: fast.maxFeePerGas,
374
+ maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
375
+ signature: DUMMY_SIGNATURE,
376
+ };
377
+ if (factory) {
378
+ unsignedOp.factory = factory;
379
+ unsignedOp.factoryData = factoryData;
380
+ }
230
381
 
231
- // 4. Create a SimpleSmartAccount (auto-generates initCode if undeployed)
232
- const smartAccount = await toSimpleSmartAccount({
233
- client: publicClient,
234
- owner: ownerAccount,
235
- entryPoint: {
236
- address: entryPointAddr,
237
- version: '0.7',
238
- },
239
- });
382
+ // 7. Get paymaster sponsorship (fills gas limits + paymaster fields)
383
+ const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
384
+ Object.assign(unsignedOp, sponsorResult);
385
+
386
+ // 8. Hash and sign the UserOp via WASM
387
+ const opJson = JSON.stringify(unsignedOp);
388
+ const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
389
+ const sigHex = getWasm().signUserOp(hashHex, eoa.private_key);
390
+ unsignedOp.signature = `0x${sigHex}`;
391
+
392
+ // 9. Submit the signed UserOp (with AA25 nonce conflict retry)
393
+ let userOpHash: string;
394
+ try {
395
+ userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
396
+ } catch (err: any) {
397
+ const msg = err?.message || '';
398
+ if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
399
+ console.error('AA25/AA10 nonce conflict detected, rebuilding UserOp with fresh nonce...');
400
+ // Bust deployment cache so getInitCode re-checks on-chain
401
+ deployedAccounts.delete(sender.toLowerCase());
402
+
403
+ // Wait for previous UserOp to mine before retrying with fresh nonce.
404
+ // Public RPC won't reflect the new nonce until the tx is on-chain.
405
+ await new Promise(r => setTimeout(r, 15000));
406
+
407
+ // Re-fetch initCode and nonce
408
+ const { factory: retryFactory, factoryData: retryFactoryData } = await getInitCode(sender, eoa.address, rpcUrl);
409
+ let retryNonce: string;
410
+ try {
411
+ const retryNonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
412
+ retryNonce = retryNonceResult || '0x0';
413
+ } catch {
414
+ const retryNonceResp = await fetch(rpcUrl, {
415
+ method: 'POST',
416
+ headers: { 'Content-Type': 'application/json' },
417
+ body: JSON.stringify({
418
+ jsonrpc: '2.0', id: 1, method: 'eth_call',
419
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
420
+ }),
421
+ });
422
+ const retryNonceJson = await retryNonceResp.json() as { result?: string };
423
+ retryNonce = retryNonceJson.result || '0x0';
424
+ }
425
+
426
+ // Rebuild unsigned UserOp with fresh nonce and initCode
427
+ const retryOp: Record<string, any> = {
428
+ sender,
429
+ nonce: retryNonce,
430
+ callData,
431
+ callGasLimit: '0x0',
432
+ verificationGasLimit: '0x0',
433
+ preVerificationGas: '0x0',
434
+ maxFeePerGas: fast.maxFeePerGas,
435
+ maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
436
+ signature: DUMMY_SIGNATURE,
437
+ };
438
+ if (retryFactory) {
439
+ retryOp.factory = retryFactory;
440
+ retryOp.factoryData = retryFactoryData;
441
+ }
442
+
443
+ // Re-sponsor and re-sign
444
+ const retrySponsor = await rpc('pm_sponsorUserOperation', [retryOp, entryPoint]);
445
+ Object.assign(retryOp, retrySponsor);
446
+ const retryOpJson = JSON.stringify(retryOp);
447
+ const retryHashHex = getWasm().hashUserOp(retryOpJson, entryPoint, BigInt(config.chainId));
448
+ const retrySigHex = getWasm().signUserOp(retryHashHex, eoa.private_key);
449
+ retryOp.signature = `0x${retrySigHex}`;
450
+
451
+ userOpHash = await rpc('eth_sendUserOperation', [retryOp, entryPoint]);
452
+ } else {
453
+ throw err;
454
+ }
455
+ }
240
456
 
241
- // 5. Create smart account client wired to relay bundler + paymaster
242
- const smartAccountClient = createSmartAccountClient({
243
- account: smartAccount,
244
- chain,
245
- bundlerTransport: authTransport,
246
- // Paymaster sponsorship proxied through relay to Pimlico
247
- paymaster: pimlicoClient,
248
- userOperation: {
249
- estimateFeesPerGas: async () => {
250
- return (await pimlicoClient.getUserOperationGasPrice()).fast;
251
- },
252
- },
253
- });
457
+ // 10. Wait for receipt (poll up to 120s)
458
+ let receipt = null;
459
+ for (let i = 0; i < 60; i++) {
460
+ await new Promise(r => setTimeout(r, 2000));
461
+ try {
462
+ receipt = await rpc('eth_getUserOperationReceipt', [userOpHash]);
463
+ if (receipt) break;
464
+ } catch { /* not mined yet */ }
465
+ }
254
466
 
255
- // 6. Send the transaction: Smart Account execute(dataEdgeAddress, 0, protobufPayload)
256
- // The DataEdge contract has a fallback() that emits Log(bytes), so the calldata
257
- // IS the protobuf payload directly (no function selector needed).
258
- // permissionless encodes the execute() call internally from to/value/data.
259
- const calldata = `0x${protobufPayload.toString('hex')}` as Hex;
260
-
261
- // Use sendUserOperation to get the userOpHash, then wait for receipt
262
- const userOpHash = await smartAccountClient.sendUserOperation({
263
- calls: [
264
- {
265
- to: dataEdgeAddress,
266
- value: 0n,
267
- data: calldata,
268
- },
269
- ],
270
- });
467
+ const success = receipt?.success ?? false;
271
468
 
272
- // 7. Wait for the UserOp to be included in a transaction
273
- const receipt = await pimlicoClient.waitForUserOperationReceipt({
274
- hash: userOpHash,
275
- });
469
+ // Mark account as deployed after first successful submission
470
+ if (success) {
471
+ deployedAccounts.add(sender.toLowerCase());
472
+ }
276
473
 
277
474
  return {
278
- txHash: receipt.receipt.transactionHash,
475
+ txHash: receipt?.receipt?.transactionHash || '',
279
476
  userOpHash,
280
- success: receipt.success,
477
+ success,
281
478
  };
282
479
  }
283
480
 
@@ -311,71 +508,187 @@ export async function submitFactBatchOnChain(
311
508
  throw new Error('Mnemonic (TOTALRECLAW_RECOVERY_PHRASE) is required for on-chain submission');
312
509
  }
313
510
 
314
- const chain = getChainFromId(config.chainId);
315
- const bundlerRpcUrl = getRelayBundlerUrl(config.relayUrl);
316
- const dataEdgeAddress = config.dataEdgeAddress as Address;
317
- const entryPointAddr = (config.entryPointAddress || entryPoint07Address) as Address;
318
-
511
+ const bundlerUrl = `${config.relayUrl}/v1/bundler`;
319
512
  const headers: Record<string, string> = {
513
+ 'Content-Type': 'application/json',
320
514
  'X-TotalReclaw-Client': 'openclaw-plugin',
321
515
  };
322
516
  if (config.authKeyHex) headers['Authorization'] = `Bearer ${config.authKeyHex}`;
323
517
  if (config.walletAddress) headers['X-Wallet-Address'] = config.walletAddress;
324
518
 
325
- const authTransport = Object.keys(headers).length > 0
326
- ? http(bundlerRpcUrl, { fetchOptions: { headers } })
327
- : http(bundlerRpcUrl);
519
+ // Helper for JSON-RPC calls to relay bundler (with 429 retry)
520
+ async function rpc(method: string, params: unknown[]): Promise<any> {
521
+ return rpcWithRetry(bundlerUrl, headers, method, params);
522
+ }
328
523
 
329
- const ownerAccount = mnemonicToAccount(config.mnemonic);
330
- const publicClient = createPublicClient({
331
- chain,
332
- transport: config.rpcUrl ? http(config.rpcUrl) : http(),
333
- });
524
+ const eoa = getWasm().deriveEoa(config.mnemonic) as { private_key: string; address: string };
525
+ const sender = config.walletAddress || await deriveSmartAccountAddress(config.mnemonic, config.chainId);
526
+ const entryPoint = config.entryPointAddress || getWasm().getEntryPointAddress();
527
+
528
+ // Encode batch calldata (SimpleAccount.executeBatch)
529
+ // encodeBatchCall expects a JSON array of hex-encoded payload strings
530
+ const payloadsHex = protobufPayloads.map(p => p.toString('hex'));
531
+ const calldataBytes = getWasm().encodeBatchCall(JSON.stringify(payloadsHex));
532
+ const callData = `0x${Buffer.from(calldataBytes).toString('hex')}`;
533
+
534
+ // Get gas prices
535
+ const gasPrices = await rpc('pimlico_getUserOperationGasPrice', []);
536
+ const fast = gasPrices.fast;
537
+
538
+ const rpcUrl = config.rpcUrl || CONFIG.rpcUrl || getDefaultRpcUrl(config.chainId);
539
+
540
+ // Check if Smart Account is deployed (needed for factory/factoryData)
541
+ const { factory, factoryData } = await getInitCode(sender, eoa.address, rpcUrl);
542
+
543
+ // Get nonce via bundler (accounts for pending mempool UserOps) with public RPC fallback
544
+ const senderPadded = sender.slice(2).toLowerCase().padStart(64, '0');
545
+ const keyPadded = '0'.repeat(64);
546
+ const nonceCalldata = `0x35567e1a${senderPadded}${keyPadded}`;
547
+
548
+ let nonce: string;
549
+ try {
550
+ const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
551
+ nonce = nonceResult || '0x0';
552
+ } catch {
553
+ const nonceResp = await fetch(rpcUrl, {
554
+ method: 'POST',
555
+ headers: { 'Content-Type': 'application/json' },
556
+ body: JSON.stringify({
557
+ jsonrpc: '2.0', id: 1, method: 'eth_call',
558
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
559
+ }),
560
+ });
561
+ const nonceJson = await nonceResp.json() as { result?: string };
562
+ nonce = nonceJson.result || '0x0';
563
+ }
334
564
 
335
- const pimlicoClient = createPimlicoClient({
336
- chain,
337
- transport: authTransport,
338
- entryPoint: {
339
- address: entryPointAddr,
340
- version: '0.7',
341
- },
342
- });
565
+ // Build unsigned UserOp
566
+ const unsignedOp: Record<string, any> = {
567
+ sender,
568
+ nonce,
569
+ callData,
570
+ callGasLimit: '0x0',
571
+ verificationGasLimit: '0x0',
572
+ preVerificationGas: '0x0',
573
+ maxFeePerGas: fast.maxFeePerGas,
574
+ maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
575
+ signature: DUMMY_SIGNATURE,
576
+ };
577
+ if (factory) {
578
+ unsignedOp.factory = factory;
579
+ unsignedOp.factoryData = factoryData;
580
+ }
343
581
 
344
- const smartAccount = await toSimpleSmartAccount({
345
- client: publicClient,
346
- owner: ownerAccount,
347
- entryPoint: {
348
- address: entryPointAddr,
349
- version: '0.7',
350
- },
351
- });
582
+ // Gas estimation for batch operations — get accurate gas limits from Pimlico
583
+ // before paymaster sponsorship (can't bump after sponsorship as it invalidates
584
+ // the paymaster's signature, causing AA34).
585
+ if (protobufPayloads.length > 1) {
586
+ try {
587
+ const gasEstimate = await rpc('eth_estimateUserOperationGas', [unsignedOp, entryPoint]);
588
+ if (gasEstimate.callGasLimit) unsignedOp.callGasLimit = gasEstimate.callGasLimit;
589
+ if (gasEstimate.verificationGasLimit) unsignedOp.verificationGasLimit = gasEstimate.verificationGasLimit;
590
+ if (gasEstimate.preVerificationGas) unsignedOp.preVerificationGas = gasEstimate.preVerificationGas;
591
+ } catch {
592
+ // If estimation fails, let the paymaster handle it (default behavior)
593
+ }
594
+ }
352
595
 
353
- const smartAccountClient = createSmartAccountClient({
354
- account: smartAccount,
355
- chain,
356
- bundlerTransport: authTransport,
357
- paymaster: pimlicoClient,
358
- userOperation: {
359
- estimateFeesPerGas: async () => {
360
- return (await pimlicoClient.getUserOperationGasPrice()).fast;
361
- },
362
- },
363
- });
596
+ // Paymaster sponsorship (uses gas limits from estimation above for batches)
597
+ const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
598
+ Object.assign(unsignedOp, sponsorResult);
599
+
600
+ // Hash and sign via WASM
601
+ const opJson = JSON.stringify(unsignedOp);
602
+ const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
603
+ const sigHex = getWasm().signUserOp(hashHex, eoa.private_key);
604
+ unsignedOp.signature = `0x${sigHex}`;
605
+
606
+ // Submit (with AA25 nonce conflict retry)
607
+ let userOpHash: string;
608
+ try {
609
+ userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
610
+ } catch (err: any) {
611
+ const msg = err?.message || '';
612
+ if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
613
+ console.error('AA25/AA10 nonce conflict detected (batch), rebuilding UserOp with fresh nonce...');
614
+ // Bust deployment cache so getInitCode re-checks on-chain
615
+ deployedAccounts.delete(sender.toLowerCase());
616
+
617
+ // Wait for previous UserOp to mine before retrying with fresh nonce.
618
+ // Public RPC won't reflect the new nonce until the tx is on-chain.
619
+ await new Promise(r => setTimeout(r, 15000));
620
+
621
+ // Re-fetch initCode and nonce
622
+ const { factory: retryFactory, factoryData: retryFactoryData } = await getInitCode(sender, eoa.address, rpcUrl);
623
+ let retryNonce: string;
624
+ try {
625
+ const retryNonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
626
+ retryNonce = retryNonceResult || '0x0';
627
+ } catch {
628
+ const retryNonceResp = await fetch(rpcUrl, {
629
+ method: 'POST',
630
+ headers: { 'Content-Type': 'application/json' },
631
+ body: JSON.stringify({
632
+ jsonrpc: '2.0', id: 1, method: 'eth_call',
633
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
634
+ }),
635
+ });
636
+ const retryNonceJson = await retryNonceResp.json() as { result?: string };
637
+ retryNonce = retryNonceJson.result || '0x0';
638
+ }
639
+
640
+ // Rebuild unsigned UserOp with fresh nonce and initCode
641
+ const retryOp: Record<string, any> = {
642
+ sender,
643
+ nonce: retryNonce,
644
+ callData,
645
+ callGasLimit: '0x0',
646
+ verificationGasLimit: '0x0',
647
+ preVerificationGas: '0x0',
648
+ maxFeePerGas: fast.maxFeePerGas,
649
+ maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
650
+ signature: DUMMY_SIGNATURE,
651
+ };
652
+ if (retryFactory) {
653
+ retryOp.factory = retryFactory;
654
+ retryOp.factoryData = retryFactoryData;
655
+ }
656
+
657
+ // Re-sponsor and re-sign
658
+ const retrySponsor = await rpc('pm_sponsorUserOperation', [retryOp, entryPoint]);
659
+ Object.assign(retryOp, retrySponsor);
660
+ const retryOpJson = JSON.stringify(retryOp);
661
+ const retryHashHex = getWasm().hashUserOp(retryOpJson, entryPoint, BigInt(config.chainId));
662
+ const retrySigHex = getWasm().signUserOp(retryHashHex, eoa.private_key);
663
+ retryOp.signature = `0x${retrySigHex}`;
664
+
665
+ userOpHash = await rpc('eth_sendUserOperation', [retryOp, entryPoint]);
666
+ } else {
667
+ throw err;
668
+ }
669
+ }
670
+
671
+ // Wait for receipt (poll up to 120s)
672
+ let receipt = null;
673
+ for (let i = 0; i < 60; i++) {
674
+ await new Promise(r => setTimeout(r, 2000));
675
+ try {
676
+ receipt = await rpc('eth_getUserOperationReceipt', [userOpHash]);
677
+ if (receipt) break;
678
+ } catch { /* not mined yet */ }
679
+ }
364
680
 
365
- // Build multi-call batch: each payload → one call to DataEdge fallback()
366
- const calls = protobufPayloads.map(payload => ({
367
- to: dataEdgeAddress,
368
- value: 0n,
369
- data: `0x${payload.toString('hex')}` as Hex,
370
- }));
681
+ const batchSuccess = receipt?.success ?? false;
371
682
 
372
- const userOpHash = await smartAccountClient.sendUserOperation({ calls });
373
- const receipt = await pimlicoClient.waitForUserOperationReceipt({ hash: userOpHash });
683
+ // Mark account as deployed after first successful submission
684
+ if (batchSuccess) {
685
+ deployedAccounts.add(sender.toLowerCase());
686
+ }
374
687
 
375
688
  return {
376
- txHash: receipt.receipt.transactionHash,
689
+ txHash: receipt?.receipt?.transactionHash || '',
377
690
  userOpHash,
378
- success: receipt.success,
691
+ success: batchSuccess,
379
692
  batchSize: protobufPayloads.length,
380
693
  };
381
694
  }
@@ -391,57 +704,28 @@ export async function submitFactBatchOnChain(
391
704
  * The managed service (subgraph mode) is the default.
392
705
  */
393
706
  export function isSubgraphMode(): boolean {
394
- return process.env.TOTALRECLAW_SELF_HOSTED !== 'true';
707
+ return !CONFIG.selfHosted;
395
708
  }
396
709
 
397
710
  /**
398
711
  * Get subgraph configuration from environment variables.
399
712
  *
400
- * After the relay refactor, clients only need:
713
+ * After the v1 env var cleanup, clients only need:
401
714
  * - TOTALRECLAW_RECOVERY_PHRASE -- BIP-39 mnemonic
402
715
  * - TOTALRECLAW_SERVER_URL -- relay server URL (default: https://api.totalreclaw.xyz)
403
716
  * - TOTALRECLAW_SELF_HOSTED -- set "true" to use self-hosted server (default: managed service)
404
- * - TOTALRECLAW_CHAIN_ID -- optional, defaults to 100 (Gnosis mainnet)
405
717
  *
406
- * Removed from client-side config (now server-side only):
407
- * - PIMLICO_API_KEY
408
- * - TOTALRECLAW_SUBGRAPH_ENDPOINT
409
- */
410
- /**
411
- * Derive the Smart Account address from a BIP-39 mnemonic.
412
- * This is the on-chain owner identity used in the subgraph.
718
+ * Chain ID is no longer configurable via env — it is auto-detected from the
719
+ * relay billing response (free = Base Sepolia, Pro = Gnosis mainnet).
413
720
  */
414
- export async function deriveSmartAccountAddress(mnemonic: string, chainId?: number): Promise<string> {
415
- const chain: Chain = getChainFromId(chainId ?? 100);
416
- const ownerAccount = mnemonicToAccount(mnemonic);
417
- const entryPointAddr = (process.env.TOTALRECLAW_ENTRYPOINT_ADDRESS || DEFAULT_ENTRYPOINT_ADDRESS) as Address;
418
- const rpcUrl = process.env.TOTALRECLAW_RPC_URL;
419
-
420
- const publicClient = createPublicClient({
421
- chain,
422
- transport: rpcUrl ? http(rpcUrl) : http(),
423
- });
424
-
425
- const smartAccount = await toSimpleSmartAccount({
426
- client: publicClient,
427
- owner: ownerAccount,
428
- entryPoint: {
429
- address: entryPointAddr,
430
- version: '0.7',
431
- },
432
- });
433
-
434
- return smartAccount.address.toLowerCase();
435
- }
436
-
437
721
  export function getSubgraphConfig(): SubgraphStoreConfig {
438
722
  return {
439
- relayUrl: process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz',
440
- mnemonic: process.env.TOTALRECLAW_RECOVERY_PHRASE || '',
441
- cachePath: process.env.TOTALRECLAW_CACHE_PATH || `${process.env.HOME}/.totalreclaw/cache.enc`,
442
- chainId: parseInt(process.env.TOTALRECLAW_CHAIN_ID || '100'),
443
- dataEdgeAddress: process.env.TOTALRECLAW_DATA_EDGE_ADDRESS || DEFAULT_DATA_EDGE_ADDRESS,
444
- entryPointAddress: process.env.TOTALRECLAW_ENTRYPOINT_ADDRESS || DEFAULT_ENTRYPOINT_ADDRESS,
445
- rpcUrl: process.env.TOTALRECLAW_RPC_URL || undefined,
723
+ relayUrl: CONFIG.serverUrl || 'https://api.totalreclaw.xyz',
724
+ mnemonic: CONFIG.recoveryPhrase,
725
+ cachePath: CONFIG.cachePath,
726
+ chainId: CONFIG.chainId,
727
+ dataEdgeAddress: CONFIG.dataEdgeAddress || getWasm().getDataEdgeAddress(),
728
+ entryPointAddress: CONFIG.entryPointAddress || getWasm().getEntryPointAddress(),
729
+ rpcUrl: CONFIG.rpcUrl || undefined,
446
730
  };
447
731
  }