@totalreclaw/totalreclaw 3.3.12-rc.18 → 3.3.12-rc.19

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: totalreclaw
3
3
  description: "End-to-end encrypted, decentralized memory for OpenClaw. A native kind:memory provider — recall is automatic via memory_search/memory_get, and facts are captured in the background. Trigger on 'install TotalReclaw', 'set up TotalReclaw', 'restore my recovery phrase', any recall request ('what do you remember about me', 'what's my X'), AND any explicit remember request ('remember X', 'save X')."
4
- version: 3.3.12-rc.18
4
+ version: 3.3.12-rc.19
5
5
  author: TotalReclaw Team
6
6
  license: MIT
7
7
  homepage: https://totalreclaw.xyz
package/dist/index.js CHANGED
@@ -4452,6 +4452,25 @@ const plugin = {
4452
4452
  // ---------------------------------------------------------------
4453
4453
  startTrajectoryPoller({
4454
4454
  logger: api.logger,
4455
+ // 3.3.12-rc.19: re-assert slots.memory each poll tick. OpenClaw's
4456
+ // config-rewrite-after-restart can strip it after register()'s
4457
+ // self-heal (pop-os reinstall 2026-06-30 → slot reverted to disabled
4458
+ // memory-core → memory_search/memory_get never bound). Cheap fs
4459
+ // read; heals + restarts only if the slot is wrong.
4460
+ recheckSlot: () => {
4461
+ try {
4462
+ if (patchOpenClawConfig(undefined, pluginVersion ?? undefined) === 'patched') {
4463
+ api.logger.warn('TotalReclaw: slots.memory was wrong at poll — re-healed openclaw.json, restarting to apply');
4464
+ try {
4465
+ process.kill(process.pid, 'SIGUSR1');
4466
+ }
4467
+ catch { /* gateway shutting down */ }
4468
+ }
4469
+ }
4470
+ catch (err) {
4471
+ api.logger.warn(`TotalReclaw: slot recheck failed: ${err instanceof Error ? err.message : String(err)}`);
4472
+ }
4473
+ },
4455
4474
  ensureInitialized: () => ensureInitialized(api.logger),
4456
4475
  isPairingPending: () => needsSetup,
4457
4476
  isImportActive: () => _importInProgress,
@@ -233,6 +233,31 @@ async function withSenderLock(sender, fn) {
233
233
  export function __resetSenderLocksForTests() {
234
234
  _senderSubmissionLocks.clear();
235
235
  }
236
+ // ---------------------------------------------------------------------------
237
+ // Test-only WASM mock seams (AA10 submit-path retry test)
238
+ // ---------------------------------------------------------------------------
239
+ /**
240
+ * Test-only seam: inject a mock WASM module.
241
+ *
242
+ * The AA10 submit-path retry test needs to drive the real submitFactBatchOnChain
243
+ * while controlling WASM behavior (deriveEoa, encodeBatchCall, hashUserOp,
244
+ * signUserOp, getEntryPointAddress). This seam swaps the module-level _wasm
245
+ * reference so the test can provide stubs.
246
+ *
247
+ * MUST be followed by __clearWasmForTests() in teardown.
248
+ */
249
+ export function __setWasmForTests(mock) {
250
+ _wasm = mock;
251
+ }
252
+ /**
253
+ * Test-only seam: restore the real WASM module.
254
+ *
255
+ * Clears a test-injected mock WASM and resets the module to null so the next
256
+ * getWasm() call reloads the real @totalreclaw/core.
257
+ */
258
+ export function __clearWasmForTests() {
259
+ _wasm = null;
260
+ }
236
261
  /**
237
262
  * Check if a Smart Account is deployed and return factory/factoryData if not.
238
263
  *
@@ -325,8 +350,6 @@ async function submitFactOnChainLocked(protobufPayload, config, eoa, sender) {
325
350
  const gasPrices = await rpc('pimlico_getUserOperationGasPrice', []);
326
351
  const fast = gasPrices.fast;
327
352
  const rpcUrl = config.rpcUrl || CONFIG.rpcUrl || getDefaultRpcUrl(config.chainId);
328
- // 4. Check if Smart Account is deployed (needed for factory/factoryData)
329
- const { factory, factoryData } = await getInitCode(sender, eoa.address, rpcUrl);
330
353
  // 5. Get nonce from EntryPoint via bundler RPC.
331
354
  // Routing through the bundler lets Pimlico account for pending mempool
332
355
  // UserOps, preventing AA25 nonce conflicts on rapid submissions.
@@ -336,79 +359,50 @@ async function submitFactOnChainLocked(protobufPayload, config, eoa, sender) {
336
359
  const senderPadded = sender.slice(2).toLowerCase().padStart(64, '0');
337
360
  const keyPadded = '0'.repeat(64);
338
361
  const nonceCalldata = `0x35567e1a${senderPadded}${keyPadded}`;
339
- let nonce;
340
- try {
341
- const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
342
- nonce = nonceResult || '0x0';
343
- }
344
- catch {
345
- // Fallback to public RPC if bundler doesn't support eth_call
346
- const nonceJson = await rpcRequest({
347
- url: rpcUrl,
348
- headers: { 'Content-Type': 'application/json' },
349
- method: 'eth_call',
350
- params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
351
- });
352
- nonce = nonceJson.result || '0x0';
353
- }
354
- // 6. Build unsigned UserOp (v0.7 fields, camelCase for Rust JSON serde)
355
- const unsignedOp = {
356
- sender,
357
- nonce,
358
- callData,
359
- callGasLimit: '0x0',
360
- verificationGasLimit: '0x0',
361
- preVerificationGas: '0x0',
362
- maxFeePerGas: fast.maxFeePerGas,
363
- maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
364
- signature: DUMMY_SIGNATURE,
365
- };
366
- if (factory) {
367
- unsignedOp.factory = factory;
368
- unsignedOp.factoryData = factoryData;
362
+ // Helper to fetch nonce (with bundler fallback)
363
+ async function fetchNonce() {
364
+ try {
365
+ const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
366
+ return nonceResult || '0x0';
367
+ }
368
+ catch {
369
+ // Fallback to public RPC if bundler doesn't support eth_call
370
+ const nonceJson = await rpcRequest({
371
+ url: rpcUrl,
372
+ headers: { 'Content-Type': 'application/json' },
373
+ method: 'eth_call',
374
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
375
+ });
376
+ return nonceJson.result || '0x0';
377
+ }
369
378
  }
370
- // 7. Get paymaster sponsorship (fills gas limits + paymaster fields)
371
- const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
372
- Object.assign(unsignedOp, sponsorResult);
373
- // 8. Hash and sign the UserOp via WASM
374
- const opJson = JSON.stringify(unsignedOp);
375
- const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
376
- const sigHex = signUserOp(hashHex, eoa.private_key);
377
- unsignedOp.signature = `0x${sigHex}`;
378
- // 9. Submit the signed UserOp (with AA25 nonce conflict retry)
379
+ // Track force-deployed senders for AA10 retry (local to this submission attempt)
380
+ const forceDeployed = new Set();
381
+ // Single retry loop: getInitCode → build UserOp → sponsor → sign → send
382
+ // On AA10 "sender already constructed", mark sender as force-deployed and retry.
383
+ // AA10 can occur at pm_sponsorUserOperation (initCode present on deployed sender)
384
+ // or eth_sendUserOperation (same root cause). This loop handles both.
379
385
  let userOpHash;
380
- try {
381
- userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
382
- }
383
- catch (err) {
384
- const msg = err?.message || '';
385
- if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
386
- console.error('AA25/AA10 nonce conflict detected, rebuilding UserOp with fresh nonce...');
387
- // getInitCode always re-checks eth_getCode (no session cache to bust),
388
- // so the retry below naturally picks up the post-deployment state.
389
- // Wait for previous UserOp to mine before retrying with fresh nonce.
390
- // Public RPC won't reflect the new nonce until the tx is on-chain.
391
- await new Promise(r => setTimeout(r, 15000));
392
- // Re-fetch initCode and nonce
393
- const { factory: retryFactory, factoryData: retryFactoryData } = await getInitCode(sender, eoa.address, rpcUrl);
394
- let retryNonce;
395
- try {
396
- const retryNonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
397
- retryNonce = retryNonceResult || '0x0';
398
- }
399
- catch {
400
- const retryNonceJson = await rpcRequest({
401
- url: rpcUrl,
402
- headers: { 'Content-Type': 'application/json' },
403
- method: 'eth_call',
404
- params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
405
- });
406
- retryNonce = retryNonceJson.result || '0x0';
386
+ let attempt = 0;
387
+ const maxAttempts = 2;
388
+ while (attempt < maxAttempts) {
389
+ attempt++;
390
+ try {
391
+ // 4. Check if Smart Account is deployed (needed for factory/factoryData)
392
+ // If force-deployed, skip eth_getCode and return null initCode.
393
+ let factory = null;
394
+ let factoryData = null;
395
+ if (!forceDeployed.has(sender.toLowerCase())) {
396
+ const initCode = await getInitCode(sender, eoa.address, rpcUrl);
397
+ factory = initCode.factory;
398
+ factoryData = initCode.factoryData;
407
399
  }
408
- // Rebuild unsigned UserOp with fresh nonce and initCode
409
- const retryOp = {
400
+ // Fetch fresh nonce for each attempt
401
+ const nonce = await fetchNonce();
402
+ // 6. Build unsigned UserOp (v0.7 fields, camelCase for Rust JSON serde)
403
+ const unsignedOp = {
410
404
  sender,
411
- nonce: retryNonce,
405
+ nonce,
412
406
  callData,
413
407
  callGasLimit: '0x0',
414
408
  verificationGasLimit: '0x0',
@@ -417,20 +411,42 @@ async function submitFactOnChainLocked(protobufPayload, config, eoa, sender) {
417
411
  maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
418
412
  signature: DUMMY_SIGNATURE,
419
413
  };
420
- if (retryFactory) {
421
- retryOp.factory = retryFactory;
422
- retryOp.factoryData = retryFactoryData;
414
+ if (factory) {
415
+ unsignedOp.factory = factory;
416
+ unsignedOp.factoryData = factoryData;
423
417
  }
424
- // Re-sponsor and re-sign
425
- const retrySponsor = await rpc('pm_sponsorUserOperation', [retryOp, entryPoint]);
426
- Object.assign(retryOp, retrySponsor);
427
- const retryOpJson = JSON.stringify(retryOp);
428
- const retryHashHex = getWasm().hashUserOp(retryOpJson, entryPoint, BigInt(config.chainId));
429
- const retrySigHex = signUserOp(retryHashHex, eoa.private_key);
430
- retryOp.signature = `0x${retrySigHex}`;
431
- userOpHash = await rpc('eth_sendUserOperation', [retryOp, entryPoint]);
418
+ // 7. Get paymaster sponsorship (fills gas limits + paymaster fields)
419
+ // This is where AA10 "sender already constructed" can occur if initCode
420
+ // is present but the sender is already deployed.
421
+ const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
422
+ Object.assign(unsignedOp, sponsorResult);
423
+ // 8. Hash and sign the UserOp via WASM
424
+ const opJson = JSON.stringify(unsignedOp);
425
+ const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
426
+ const sigHex = signUserOp(hashHex, eoa.private_key);
427
+ unsignedOp.signature = `0x${sigHex}`;
428
+ // 9. Submit the signed UserOp
429
+ userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
430
+ // Success — break out of retry loop
431
+ break;
432
432
  }
433
- else {
433
+ catch (err) {
434
+ const msg = err?.message || '';
435
+ // AA10 "sender already constructed" or AA25 invalid nonce → retry
436
+ if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
437
+ console.error(`AA25/AA10 detected (attempt ${attempt}/${maxAttempts}), retrying...`);
438
+ // On AA10, force-mark sender as deployed so next retry omits initCode
439
+ if (/AA10/i.test(msg)) {
440
+ forceDeployed.add(sender.toLowerCase());
441
+ console.error('AA10: force-marking sender as deployed, retrying without initCode');
442
+ }
443
+ // Wait for previous UserOp to mine before retrying with fresh nonce.
444
+ // Public RPC won't reflect the new nonce until the tx is on-chain.
445
+ await new Promise(r => setTimeout(r, 15000));
446
+ // Continue to next iteration of retry loop
447
+ continue;
448
+ }
449
+ // Not a retryable error — re-throw
434
450
  throw err;
435
451
  }
436
452
  }
@@ -509,101 +525,51 @@ async function submitFactBatchOnChainLocked(protobufPayloads, config, eoa, sende
509
525
  const gasPrices = await rpc('pimlico_getUserOperationGasPrice', []);
510
526
  const fast = gasPrices.fast;
511
527
  const rpcUrl = config.rpcUrl || CONFIG.rpcUrl || getDefaultRpcUrl(config.chainId);
512
- // Check if Smart Account is deployed (needed for factory/factoryData)
513
- const { factory, factoryData } = await getInitCode(sender, eoa.address, rpcUrl);
514
528
  // Get nonce via bundler (accounts for pending mempool UserOps) with public RPC fallback
515
529
  const senderPadded = sender.slice(2).toLowerCase().padStart(64, '0');
516
530
  const keyPadded = '0'.repeat(64);
517
531
  const nonceCalldata = `0x35567e1a${senderPadded}${keyPadded}`;
518
- let nonce;
519
- try {
520
- const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
521
- nonce = nonceResult || '0x0';
522
- }
523
- catch {
524
- const nonceJson = await rpcRequest({
525
- url: rpcUrl,
526
- headers: { 'Content-Type': 'application/json' },
527
- method: 'eth_call',
528
- params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
529
- });
530
- nonce = nonceJson.result || '0x0';
531
- }
532
- // Build unsigned UserOp
533
- const unsignedOp = {
534
- sender,
535
- nonce,
536
- callData,
537
- callGasLimit: '0x0',
538
- verificationGasLimit: '0x0',
539
- preVerificationGas: '0x0',
540
- maxFeePerGas: fast.maxFeePerGas,
541
- maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
542
- signature: DUMMY_SIGNATURE,
543
- };
544
- if (factory) {
545
- unsignedOp.factory = factory;
546
- unsignedOp.factoryData = factoryData;
547
- }
548
- // Gas estimation for batch operations — get accurate gas limits from Pimlico
549
- // before paymaster sponsorship (can't bump after sponsorship as it invalidates
550
- // the paymaster's signature, causing AA34).
551
- if (protobufPayloads.length > 1) {
532
+ // Helper to fetch nonce (with bundler fallback)
533
+ async function fetchNonce() {
552
534
  try {
553
- const gasEstimate = await rpc('eth_estimateUserOperationGas', [unsignedOp, entryPoint]);
554
- if (gasEstimate.callGasLimit)
555
- unsignedOp.callGasLimit = gasEstimate.callGasLimit;
556
- if (gasEstimate.verificationGasLimit)
557
- unsignedOp.verificationGasLimit = gasEstimate.verificationGasLimit;
558
- if (gasEstimate.preVerificationGas)
559
- unsignedOp.preVerificationGas = gasEstimate.preVerificationGas;
535
+ const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
536
+ return nonceResult || '0x0';
560
537
  }
561
538
  catch {
562
- // If estimation fails, let the paymaster handle it (default behavior)
539
+ const nonceJson = await rpcRequest({
540
+ url: rpcUrl,
541
+ headers: { 'Content-Type': 'application/json' },
542
+ method: 'eth_call',
543
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
544
+ });
545
+ return nonceJson.result || '0x0';
563
546
  }
564
547
  }
565
- // Paymaster sponsorship (uses gas limits from estimation above for batches)
566
- const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
567
- Object.assign(unsignedOp, sponsorResult);
568
- // Hash and sign via WASM
569
- const opJson = JSON.stringify(unsignedOp);
570
- const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
571
- const sigHex = signUserOp(hashHex, eoa.private_key);
572
- unsignedOp.signature = `0x${sigHex}`;
573
- // Submit (with AA25 nonce conflict retry)
548
+ // Track force-deployed senders for AA10 retry (local to this submission attempt)
549
+ const forceDeployed = new Set();
550
+ // Single retry loop: getInitCode → build UserOp → estimate → sponsor → sign → send
551
+ // On AA10 "sender already constructed", mark sender as force-deployed and retry.
574
552
  let userOpHash;
575
- try {
576
- userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
577
- }
578
- catch (err) {
579
- const msg = err?.message || '';
580
- if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
581
- console.error('AA25/AA10 nonce conflict detected (batch), rebuilding UserOp with fresh nonce...');
582
- // getInitCode always re-checks eth_getCode (no session cache to bust),
583
- // so the retry below naturally picks up the post-deployment state.
584
- // Wait for previous UserOp to mine before retrying with fresh nonce.
585
- // Public RPC won't reflect the new nonce until the tx is on-chain.
586
- await new Promise(r => setTimeout(r, 15000));
587
- // Re-fetch initCode and nonce
588
- const { factory: retryFactory, factoryData: retryFactoryData } = await getInitCode(sender, eoa.address, rpcUrl);
589
- let retryNonce;
590
- try {
591
- const retryNonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
592
- retryNonce = retryNonceResult || '0x0';
593
- }
594
- catch {
595
- const retryNonceJson = await rpcRequest({
596
- url: rpcUrl,
597
- headers: { 'Content-Type': 'application/json' },
598
- method: 'eth_call',
599
- params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
600
- });
601
- retryNonce = retryNonceJson.result || '0x0';
553
+ let attempt = 0;
554
+ const maxAttempts = 2;
555
+ while (attempt < maxAttempts) {
556
+ attempt++;
557
+ try {
558
+ // Check if Smart Account is deployed (needed for factory/factoryData)
559
+ // If force-deployed, skip eth_getCode and return null initCode.
560
+ let factory = null;
561
+ let factoryData = null;
562
+ if (!forceDeployed.has(sender.toLowerCase())) {
563
+ const initCode = await getInitCode(sender, eoa.address, rpcUrl);
564
+ factory = initCode.factory;
565
+ factoryData = initCode.factoryData;
602
566
  }
603
- // Rebuild unsigned UserOp with fresh nonce and initCode
604
- const retryOp = {
567
+ // Fetch fresh nonce for each attempt
568
+ const nonce = await fetchNonce();
569
+ // Build unsigned UserOp
570
+ const unsignedOp = {
605
571
  sender,
606
- nonce: retryNonce,
572
+ nonce,
607
573
  callData,
608
574
  callGasLimit: '0x0',
609
575
  verificationGasLimit: '0x0',
@@ -612,20 +578,59 @@ async function submitFactBatchOnChainLocked(protobufPayloads, config, eoa, sende
612
578
  maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
613
579
  signature: DUMMY_SIGNATURE,
614
580
  };
615
- if (retryFactory) {
616
- retryOp.factory = retryFactory;
617
- retryOp.factoryData = retryFactoryData;
581
+ if (factory) {
582
+ unsignedOp.factory = factory;
583
+ unsignedOp.factoryData = factoryData;
618
584
  }
619
- // Re-sponsor and re-sign
620
- const retrySponsor = await rpc('pm_sponsorUserOperation', [retryOp, entryPoint]);
621
- Object.assign(retryOp, retrySponsor);
622
- const retryOpJson = JSON.stringify(retryOp);
623
- const retryHashHex = getWasm().hashUserOp(retryOpJson, entryPoint, BigInt(config.chainId));
624
- const retrySigHex = signUserOp(retryHashHex, eoa.private_key);
625
- retryOp.signature = `0x${retrySigHex}`;
626
- userOpHash = await rpc('eth_sendUserOperation', [retryOp, entryPoint]);
585
+ // Gas estimation for batch operations — get accurate gas limits from Pimlico
586
+ // before paymaster sponsorship (can't bump after sponsorship as it invalidates
587
+ // the paymaster's signature, causing AA34).
588
+ if (protobufPayloads.length > 1) {
589
+ try {
590
+ const gasEstimate = await rpc('eth_estimateUserOperationGas', [unsignedOp, entryPoint]);
591
+ if (gasEstimate.callGasLimit)
592
+ unsignedOp.callGasLimit = gasEstimate.callGasLimit;
593
+ if (gasEstimate.verificationGasLimit)
594
+ unsignedOp.verificationGasLimit = gasEstimate.verificationGasLimit;
595
+ if (gasEstimate.preVerificationGas)
596
+ unsignedOp.preVerificationGas = gasEstimate.preVerificationGas;
597
+ }
598
+ catch {
599
+ // If estimation fails, let the paymaster handle it (default behavior)
600
+ }
601
+ }
602
+ // Paymaster sponsorship (uses gas limits from estimation above for batches)
603
+ // This is where AA10 "sender already constructed" can occur if initCode
604
+ // is present but the sender is already deployed.
605
+ const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
606
+ Object.assign(unsignedOp, sponsorResult);
607
+ // Hash and sign via WASM
608
+ const opJson = JSON.stringify(unsignedOp);
609
+ const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
610
+ const sigHex = signUserOp(hashHex, eoa.private_key);
611
+ unsignedOp.signature = `0x${sigHex}`;
612
+ // Submit the signed UserOp
613
+ userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
614
+ // Success — break out of retry loop
615
+ break;
627
616
  }
628
- else {
617
+ catch (err) {
618
+ const msg = err?.message || '';
619
+ // AA10 "sender already constructed" or AA25 invalid nonce → retry
620
+ if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
621
+ console.error(`AA25/AA10 detected (batch, attempt ${attempt}/${maxAttempts}), retrying...`);
622
+ // On AA10, force-mark sender as deployed so next retry omits initCode
623
+ if (/AA10/i.test(msg)) {
624
+ forceDeployed.add(sender.toLowerCase());
625
+ console.error('AA10: force-marking sender as deployed, retrying without initCode');
626
+ }
627
+ // Wait for previous UserOp to mine before retrying with fresh nonce.
628
+ // Public RPC won't reflect the new nonce until the tx is on-chain.
629
+ await new Promise(r => setTimeout(r, 15000));
630
+ // Continue to next iteration of retry loop
631
+ continue;
632
+ }
633
+ // Not a retryable error — re-throw
629
634
  throw err;
630
635
  }
631
636
  }
package/dist/tr-cli.js CHANGED
@@ -51,7 +51,7 @@ const STATE_PATH = CONFIG.onboardingStatePath;
51
51
  // Auto-synced by skill/scripts/sync-version.mjs from skill/plugin/package.json::version.
52
52
  // Do not edit by hand — running tests will catch drift but the publish workflow
53
53
  // rewrites this constant at the start of every npm/ClawHub publish.
54
- const PLUGIN_VERSION = '3.3.12-rc.18';
54
+ const PLUGIN_VERSION = '3.3.12-rc.19';
55
55
  function die(msg, code = 1) {
56
56
  process.stderr.write(`tr: ${msg}\n`);
57
57
  process.exit(code);
@@ -127,6 +127,9 @@ export function startTrajectoryPoller(deps, opts = {}) {
127
127
  const stateFile = opts.stateFile ?? STATE_FILE;
128
128
  const pollAndExtract = async () => {
129
129
  try {
130
+ // 3.3.12-rc.19: re-assert slots.memory each tick in case OpenClaw's
131
+ // config-rewrite stripped it after register() (see deps.recheckSlot).
132
+ deps.recheckSlot?.();
130
133
  await deps.ensureInitialized();
131
134
  if (deps.isPairingPending())
132
135
  return;
package/index.ts CHANGED
@@ -5239,6 +5239,21 @@ const plugin = {
5239
5239
 
5240
5240
  startTrajectoryPoller({
5241
5241
  logger: api.logger,
5242
+ // 3.3.12-rc.19: re-assert slots.memory each poll tick. OpenClaw's
5243
+ // config-rewrite-after-restart can strip it after register()'s
5244
+ // self-heal (pop-os reinstall 2026-06-30 → slot reverted to disabled
5245
+ // memory-core → memory_search/memory_get never bound). Cheap fs
5246
+ // read; heals + restarts only if the slot is wrong.
5247
+ recheckSlot: () => {
5248
+ try {
5249
+ if (patchOpenClawConfig(undefined, pluginVersion ?? undefined) === 'patched') {
5250
+ api.logger.warn('TotalReclaw: slots.memory was wrong at poll — re-healed openclaw.json, restarting to apply');
5251
+ try { process.kill(process.pid, 'SIGUSR1'); } catch { /* gateway shutting down */ }
5252
+ }
5253
+ } catch (err) {
5254
+ api.logger.warn(`TotalReclaw: slot recheck failed: ${err instanceof Error ? err.message : String(err)}`);
5255
+ }
5256
+ },
5242
5257
  ensureInitialized: () => ensureInitialized(api.logger),
5243
5258
  isPairingPending: () => needsSetup,
5244
5259
  isImportActive: () => _importInProgress,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totalreclaw/totalreclaw",
3
- "version": "3.3.12-rc.18",
3
+ "version": "3.3.12-rc.19",
4
4
  "description": "End-to-end encrypted, agent-portable memory for OpenClaw and any LLM-agent runtime. XChaCha20-Poly1305 with protobuf v4 + on-chain Memory Taxonomy v1 (claim / preference / directive / commitment / episode / summary).",
5
5
  "type": "module",
6
6
  "keywords": [
package/skill.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totalreclaw",
3
- "version": "3.3.12-rc.18",
3
+ "version": "3.3.12-rc.19",
4
4
  "description": "End-to-end encrypted memory for AI agents — portable, yours forever. XChaCha20-Poly1305 E2EE: server never sees plaintext.",
5
5
  "author": "TotalReclaw Team",
6
6
  "license": "MIT",
package/subgraph-store.ts CHANGED
@@ -294,6 +294,34 @@ export function __resetSenderLocksForTests(): void {
294
294
  _senderSubmissionLocks.clear();
295
295
  }
296
296
 
297
+ // ---------------------------------------------------------------------------
298
+ // Test-only WASM mock seams (AA10 submit-path retry test)
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * Test-only seam: inject a mock WASM module.
303
+ *
304
+ * The AA10 submit-path retry test needs to drive the real submitFactBatchOnChain
305
+ * while controlling WASM behavior (deriveEoa, encodeBatchCall, hashUserOp,
306
+ * signUserOp, getEntryPointAddress). This seam swaps the module-level _wasm
307
+ * reference so the test can provide stubs.
308
+ *
309
+ * MUST be followed by __clearWasmForTests() in teardown.
310
+ */
311
+ export function __setWasmForTests(mock: any): void {
312
+ _wasm = mock;
313
+ }
314
+
315
+ /**
316
+ * Test-only seam: restore the real WASM module.
317
+ *
318
+ * Clears a test-injected mock WASM and resets the module to null so the next
319
+ * getWasm() call reloads the real @totalreclaw/core.
320
+ */
321
+ export function __clearWasmForTests(): void {
322
+ _wasm = null;
323
+ }
324
+
297
325
  /**
298
326
  * Check if a Smart Account is deployed and return factory/factoryData if not.
299
327
  *
@@ -413,9 +441,6 @@ async function submitFactOnChainLocked(
413
441
 
414
442
  const rpcUrl = config.rpcUrl || CONFIG.rpcUrl || getDefaultRpcUrl(config.chainId);
415
443
 
416
- // 4. Check if Smart Account is deployed (needed for factory/factoryData)
417
- const { factory, factoryData } = await getInitCode(sender, eoa.address, rpcUrl);
418
-
419
444
  // 5. Get nonce from EntryPoint via bundler RPC.
420
445
  // Routing through the bundler lets Pimlico account for pending mempool
421
446
  // UserOps, preventing AA25 nonce conflicts on rapid submissions.
@@ -426,83 +451,55 @@ async function submitFactOnChainLocked(
426
451
  const keyPadded = '0'.repeat(64);
427
452
  const nonceCalldata = `0x35567e1a${senderPadded}${keyPadded}`;
428
453
 
429
- let nonce: string;
430
- try {
431
- const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
432
- nonce = nonceResult || '0x0';
433
- } catch {
434
- // Fallback to public RPC if bundler doesn't support eth_call
435
- const nonceJson = await rpcRequest({
436
- url: rpcUrl,
437
- headers: { 'Content-Type': 'application/json' },
438
- method: 'eth_call',
439
- params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
440
- });
441
- nonce = (nonceJson.result as string) || '0x0';
454
+ // Helper to fetch nonce (with bundler fallback)
455
+ async function fetchNonce(): Promise<string> {
456
+ try {
457
+ const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
458
+ return nonceResult || '0x0';
459
+ } catch {
460
+ // Fallback to public RPC if bundler doesn't support eth_call
461
+ const nonceJson = await rpcRequest({
462
+ url: rpcUrl,
463
+ headers: { 'Content-Type': 'application/json' },
464
+ method: 'eth_call',
465
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
466
+ });
467
+ return (nonceJson.result as string) || '0x0';
468
+ }
442
469
  }
443
470
 
444
- // 6. Build unsigned UserOp (v0.7 fields, camelCase for Rust JSON serde)
445
- const unsignedOp: Record<string, any> = {
446
- sender,
447
- nonce,
448
- callData,
449
- callGasLimit: '0x0',
450
- verificationGasLimit: '0x0',
451
- preVerificationGas: '0x0',
452
- maxFeePerGas: fast.maxFeePerGas,
453
- maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
454
- signature: DUMMY_SIGNATURE,
455
- };
456
- if (factory) {
457
- unsignedOp.factory = factory;
458
- unsignedOp.factoryData = factoryData;
459
- }
471
+ // Track force-deployed senders for AA10 retry (local to this submission attempt)
472
+ const forceDeployed = new Set<string>();
460
473
 
461
- // 7. Get paymaster sponsorship (fills gas limits + paymaster fields)
462
- const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
463
- Object.assign(unsignedOp, sponsorResult);
474
+ // Single retry loop: getInitCode build UserOp sponsor → sign → send
475
+ // On AA10 "sender already constructed", mark sender as force-deployed and retry.
476
+ // AA10 can occur at pm_sponsorUserOperation (initCode present on deployed sender)
477
+ // or eth_sendUserOperation (same root cause). This loop handles both.
478
+ let userOpHash: string;
479
+ let attempt = 0;
480
+ const maxAttempts = 2;
464
481
 
465
- // 8. Hash and sign the UserOp via WASM
466
- const opJson = JSON.stringify(unsignedOp);
467
- const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
468
- const sigHex = signUserOp(hashHex, eoa.private_key);
469
- unsignedOp.signature = `0x${sigHex}`;
482
+ while (attempt < maxAttempts) {
483
+ attempt++;
470
484
 
471
- // 9. Submit the signed UserOp (with AA25 nonce conflict retry)
472
- let userOpHash: string;
473
- try {
474
- userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
475
- } catch (err: any) {
476
- const msg = err?.message || '';
477
- if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
478
- console.error('AA25/AA10 nonce conflict detected, rebuilding UserOp with fresh nonce...');
479
- // getInitCode always re-checks eth_getCode (no session cache to bust),
480
- // so the retry below naturally picks up the post-deployment state.
481
-
482
- // Wait for previous UserOp to mine before retrying with fresh nonce.
483
- // Public RPC won't reflect the new nonce until the tx is on-chain.
484
- await new Promise(r => setTimeout(r, 15000));
485
-
486
- // Re-fetch initCode and nonce
487
- const { factory: retryFactory, factoryData: retryFactoryData } = await getInitCode(sender, eoa.address, rpcUrl);
488
- let retryNonce: string;
489
- try {
490
- const retryNonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
491
- retryNonce = retryNonceResult || '0x0';
492
- } catch {
493
- const retryNonceJson = await rpcRequest({
494
- url: rpcUrl,
495
- headers: { 'Content-Type': 'application/json' },
496
- method: 'eth_call',
497
- params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
498
- });
499
- retryNonce = (retryNonceJson.result as string) || '0x0';
485
+ try {
486
+ // 4. Check if Smart Account is deployed (needed for factory/factoryData)
487
+ // If force-deployed, skip eth_getCode and return null initCode.
488
+ let factory: string | null = null;
489
+ let factoryData: string | null = null;
490
+ if (!forceDeployed.has(sender.toLowerCase())) {
491
+ const initCode = await getInitCode(sender, eoa.address, rpcUrl);
492
+ factory = initCode.factory;
493
+ factoryData = initCode.factoryData;
500
494
  }
501
495
 
502
- // Rebuild unsigned UserOp with fresh nonce and initCode
503
- const retryOp: Record<string, any> = {
496
+ // Fetch fresh nonce for each attempt
497
+ const nonce = await fetchNonce();
498
+
499
+ // 6. Build unsigned UserOp (v0.7 fields, camelCase for Rust JSON serde)
500
+ const unsignedOp: Record<string, any> = {
504
501
  sender,
505
- nonce: retryNonce,
502
+ nonce,
506
503
  callData,
507
504
  callGasLimit: '0x0',
508
505
  verificationGasLimit: '0x0',
@@ -511,21 +508,44 @@ async function submitFactOnChainLocked(
511
508
  maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
512
509
  signature: DUMMY_SIGNATURE,
513
510
  };
514
- if (retryFactory) {
515
- retryOp.factory = retryFactory;
516
- retryOp.factoryData = retryFactoryData;
511
+ if (factory) {
512
+ unsignedOp.factory = factory;
513
+ unsignedOp.factoryData = factoryData;
517
514
  }
518
515
 
519
- // Re-sponsor and re-sign
520
- const retrySponsor = await rpc('pm_sponsorUserOperation', [retryOp, entryPoint]);
521
- Object.assign(retryOp, retrySponsor);
522
- const retryOpJson = JSON.stringify(retryOp);
523
- const retryHashHex = getWasm().hashUserOp(retryOpJson, entryPoint, BigInt(config.chainId));
524
- const retrySigHex = signUserOp(retryHashHex, eoa.private_key);
525
- retryOp.signature = `0x${retrySigHex}`;
526
-
527
- userOpHash = await rpc('eth_sendUserOperation', [retryOp, entryPoint]);
528
- } else {
516
+ // 7. Get paymaster sponsorship (fills gas limits + paymaster fields)
517
+ // This is where AA10 "sender already constructed" can occur if initCode
518
+ // is present but the sender is already deployed.
519
+ const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
520
+ Object.assign(unsignedOp, sponsorResult);
521
+
522
+ // 8. Hash and sign the UserOp via WASM
523
+ const opJson = JSON.stringify(unsignedOp);
524
+ const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
525
+ const sigHex = signUserOp(hashHex, eoa.private_key);
526
+ unsignedOp.signature = `0x${sigHex}`;
527
+
528
+ // 9. Submit the signed UserOp
529
+ userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
530
+ // Success — break out of retry loop
531
+ break;
532
+ } catch (err: any) {
533
+ const msg = err?.message || '';
534
+ // AA10 "sender already constructed" or AA25 invalid nonce → retry
535
+ if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
536
+ console.error(`AA25/AA10 detected (attempt ${attempt}/${maxAttempts}), retrying...`);
537
+ // On AA10, force-mark sender as deployed so next retry omits initCode
538
+ if (/AA10/i.test(msg)) {
539
+ forceDeployed.add(sender.toLowerCase());
540
+ console.error('AA10: force-marking sender as deployed, retrying without initCode');
541
+ }
542
+ // Wait for previous UserOp to mine before retrying with fresh nonce.
543
+ // Public RPC won't reflect the new nonce until the tx is on-chain.
544
+ await new Promise(r => setTimeout(r, 15000));
545
+ // Continue to next iteration of retry loop
546
+ continue;
547
+ }
548
+ // Not a retryable error — re-throw
529
549
  throw err;
530
550
  }
531
551
  }
@@ -625,104 +645,57 @@ async function submitFactBatchOnChainLocked(
625
645
 
626
646
  const rpcUrl = config.rpcUrl || CONFIG.rpcUrl || getDefaultRpcUrl(config.chainId);
627
647
 
628
- // Check if Smart Account is deployed (needed for factory/factoryData)
629
- const { factory, factoryData } = await getInitCode(sender, eoa.address, rpcUrl);
630
-
631
648
  // Get nonce via bundler (accounts for pending mempool UserOps) with public RPC fallback
632
649
  const senderPadded = sender.slice(2).toLowerCase().padStart(64, '0');
633
650
  const keyPadded = '0'.repeat(64);
634
651
  const nonceCalldata = `0x35567e1a${senderPadded}${keyPadded}`;
635
652
 
636
- let nonce: string;
637
- try {
638
- const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
639
- nonce = nonceResult || '0x0';
640
- } catch {
641
- const nonceJson = await rpcRequest({
642
- url: rpcUrl,
643
- headers: { 'Content-Type': 'application/json' },
644
- method: 'eth_call',
645
- params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
646
- });
647
- nonce = (nonceJson.result as string) || '0x0';
648
- }
649
-
650
- // Build unsigned UserOp
651
- const unsignedOp: Record<string, any> = {
652
- sender,
653
- nonce,
654
- callData,
655
- callGasLimit: '0x0',
656
- verificationGasLimit: '0x0',
657
- preVerificationGas: '0x0',
658
- maxFeePerGas: fast.maxFeePerGas,
659
- maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
660
- signature: DUMMY_SIGNATURE,
661
- };
662
- if (factory) {
663
- unsignedOp.factory = factory;
664
- unsignedOp.factoryData = factoryData;
665
- }
666
-
667
- // Gas estimation for batch operations — get accurate gas limits from Pimlico
668
- // before paymaster sponsorship (can't bump after sponsorship as it invalidates
669
- // the paymaster's signature, causing AA34).
670
- if (protobufPayloads.length > 1) {
653
+ // Helper to fetch nonce (with bundler fallback)
654
+ async function fetchNonce(): Promise<string> {
671
655
  try {
672
- const gasEstimate = await rpc('eth_estimateUserOperationGas', [unsignedOp, entryPoint]);
673
- if (gasEstimate.callGasLimit) unsignedOp.callGasLimit = gasEstimate.callGasLimit;
674
- if (gasEstimate.verificationGasLimit) unsignedOp.verificationGasLimit = gasEstimate.verificationGasLimit;
675
- if (gasEstimate.preVerificationGas) unsignedOp.preVerificationGas = gasEstimate.preVerificationGas;
656
+ const nonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
657
+ return nonceResult || '0x0';
676
658
  } catch {
677
- // If estimation fails, let the paymaster handle it (default behavior)
659
+ const nonceJson = await rpcRequest({
660
+ url: rpcUrl,
661
+ headers: { 'Content-Type': 'application/json' },
662
+ method: 'eth_call',
663
+ params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
664
+ });
665
+ return (nonceJson.result as string) || '0x0';
678
666
  }
679
667
  }
680
668
 
681
- // Paymaster sponsorship (uses gas limits from estimation above for batches)
682
- const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
683
- Object.assign(unsignedOp, sponsorResult);
684
-
685
- // Hash and sign via WASM
686
- const opJson = JSON.stringify(unsignedOp);
687
- const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
688
- const sigHex = signUserOp(hashHex, eoa.private_key);
689
- unsignedOp.signature = `0x${sigHex}`;
669
+ // Track force-deployed senders for AA10 retry (local to this submission attempt)
670
+ const forceDeployed = new Set<string>();
690
671
 
691
- // Submit (with AA25 nonce conflict retry)
672
+ // Single retry loop: getInitCode build UserOp → estimate → sponsor → sign → send
673
+ // On AA10 "sender already constructed", mark sender as force-deployed and retry.
692
674
  let userOpHash: string;
693
- try {
694
- userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
695
- } catch (err: any) {
696
- const msg = err?.message || '';
697
- if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
698
- console.error('AA25/AA10 nonce conflict detected (batch), rebuilding UserOp with fresh nonce...');
699
- // getInitCode always re-checks eth_getCode (no session cache to bust),
700
- // so the retry below naturally picks up the post-deployment state.
701
-
702
- // Wait for previous UserOp to mine before retrying with fresh nonce.
703
- // Public RPC won't reflect the new nonce until the tx is on-chain.
704
- await new Promise(r => setTimeout(r, 15000));
705
-
706
- // Re-fetch initCode and nonce
707
- const { factory: retryFactory, factoryData: retryFactoryData } = await getInitCode(sender, eoa.address, rpcUrl);
708
- let retryNonce: string;
709
- try {
710
- const retryNonceResult = await rpc('eth_call', [{ to: entryPoint, data: nonceCalldata }, 'latest']);
711
- retryNonce = retryNonceResult || '0x0';
712
- } catch {
713
- const retryNonceJson = await rpcRequest({
714
- url: rpcUrl,
715
- headers: { 'Content-Type': 'application/json' },
716
- method: 'eth_call',
717
- params: [{ to: entryPoint, data: nonceCalldata }, 'latest'],
718
- });
719
- retryNonce = (retryNonceJson.result as string) || '0x0';
675
+ let attempt = 0;
676
+ const maxAttempts = 2;
677
+
678
+ while (attempt < maxAttempts) {
679
+ attempt++;
680
+
681
+ try {
682
+ // Check if Smart Account is deployed (needed for factory/factoryData)
683
+ // If force-deployed, skip eth_getCode and return null initCode.
684
+ let factory: string | null = null;
685
+ let factoryData: string | null = null;
686
+ if (!forceDeployed.has(sender.toLowerCase())) {
687
+ const initCode = await getInitCode(sender, eoa.address, rpcUrl);
688
+ factory = initCode.factory;
689
+ factoryData = initCode.factoryData;
720
690
  }
721
691
 
722
- // Rebuild unsigned UserOp with fresh nonce and initCode
723
- const retryOp: Record<string, any> = {
692
+ // Fetch fresh nonce for each attempt
693
+ const nonce = await fetchNonce();
694
+
695
+ // Build unsigned UserOp
696
+ const unsignedOp: Record<string, any> = {
724
697
  sender,
725
- nonce: retryNonce,
698
+ nonce,
726
699
  callData,
727
700
  callGasLimit: '0x0',
728
701
  verificationGasLimit: '0x0',
@@ -731,21 +704,58 @@ async function submitFactBatchOnChainLocked(
731
704
  maxPriorityFeePerGas: fast.maxPriorityFeePerGas,
732
705
  signature: DUMMY_SIGNATURE,
733
706
  };
734
- if (retryFactory) {
735
- retryOp.factory = retryFactory;
736
- retryOp.factoryData = retryFactoryData;
707
+ if (factory) {
708
+ unsignedOp.factory = factory;
709
+ unsignedOp.factoryData = factoryData;
737
710
  }
738
711
 
739
- // Re-sponsor and re-sign
740
- const retrySponsor = await rpc('pm_sponsorUserOperation', [retryOp, entryPoint]);
741
- Object.assign(retryOp, retrySponsor);
742
- const retryOpJson = JSON.stringify(retryOp);
743
- const retryHashHex = getWasm().hashUserOp(retryOpJson, entryPoint, BigInt(config.chainId));
744
- const retrySigHex = signUserOp(retryHashHex, eoa.private_key);
745
- retryOp.signature = `0x${retrySigHex}`;
712
+ // Gas estimation for batch operations — get accurate gas limits from Pimlico
713
+ // before paymaster sponsorship (can't bump after sponsorship as it invalidates
714
+ // the paymaster's signature, causing AA34).
715
+ if (protobufPayloads.length > 1) {
716
+ try {
717
+ const gasEstimate = await rpc('eth_estimateUserOperationGas', [unsignedOp, entryPoint]);
718
+ if (gasEstimate.callGasLimit) unsignedOp.callGasLimit = gasEstimate.callGasLimit;
719
+ if (gasEstimate.verificationGasLimit) unsignedOp.verificationGasLimit = gasEstimate.verificationGasLimit;
720
+ if (gasEstimate.preVerificationGas) unsignedOp.preVerificationGas = gasEstimate.preVerificationGas;
721
+ } catch {
722
+ // If estimation fails, let the paymaster handle it (default behavior)
723
+ }
724
+ }
746
725
 
747
- userOpHash = await rpc('eth_sendUserOperation', [retryOp, entryPoint]);
748
- } else {
726
+ // Paymaster sponsorship (uses gas limits from estimation above for batches)
727
+ // This is where AA10 "sender already constructed" can occur if initCode
728
+ // is present but the sender is already deployed.
729
+ const sponsorResult = await rpc('pm_sponsorUserOperation', [unsignedOp, entryPoint]);
730
+ Object.assign(unsignedOp, sponsorResult);
731
+
732
+ // Hash and sign via WASM
733
+ const opJson = JSON.stringify(unsignedOp);
734
+ const hashHex = getWasm().hashUserOp(opJson, entryPoint, BigInt(config.chainId));
735
+ const sigHex = signUserOp(hashHex, eoa.private_key);
736
+ unsignedOp.signature = `0x${sigHex}`;
737
+
738
+ // Submit the signed UserOp
739
+ userOpHash = await rpc('eth_sendUserOperation', [unsignedOp, entryPoint]);
740
+ // Success — break out of retry loop
741
+ break;
742
+ } catch (err: any) {
743
+ const msg = err?.message || '';
744
+ // AA10 "sender already constructed" or AA25 invalid nonce → retry
745
+ if (/AA25|AA10|invalid account nonce|already being processed/i.test(msg)) {
746
+ console.error(`AA25/AA10 detected (batch, attempt ${attempt}/${maxAttempts}), retrying...`);
747
+ // On AA10, force-mark sender as deployed so next retry omits initCode
748
+ if (/AA10/i.test(msg)) {
749
+ forceDeployed.add(sender.toLowerCase());
750
+ console.error('AA10: force-marking sender as deployed, retrying without initCode');
751
+ }
752
+ // Wait for previous UserOp to mine before retrying with fresh nonce.
753
+ // Public RPC won't reflect the new nonce until the tx is on-chain.
754
+ await new Promise(r => setTimeout(r, 15000));
755
+ // Continue to next iteration of retry loop
756
+ continue;
757
+ }
758
+ // Not a retryable error — re-throw
749
759
  throw err;
750
760
  }
751
761
  }
package/tr-cli.ts CHANGED
@@ -68,7 +68,7 @@ const STATE_PATH = CONFIG.onboardingStatePath;
68
68
  // Auto-synced by skill/scripts/sync-version.mjs from skill/plugin/package.json::version.
69
69
  // Do not edit by hand — running tests will catch drift but the publish workflow
70
70
  // rewrites this constant at the start of every npm/ClawHub publish.
71
- const PLUGIN_VERSION = '3.3.12-rc.18';
71
+ const PLUGIN_VERSION = '3.3.12-rc.19';
72
72
 
73
73
  function die(msg: string, code = 1): never {
74
74
  process.stderr.write(`tr: ${msg}\n`);
@@ -104,6 +104,18 @@ export interface TrajectoryPollerDeps {
104
104
  error: (msg: string) => void;
105
105
  };
106
106
 
107
+ /**
108
+ * 3.3.12-rc.19: optional slot-self-heal hook invoked once per poll
109
+ * tick. OpenClaw's config-rewrite-after-restart can strip
110
+ * plugins.slots.memory after register()'s self-heal ran (observed on
111
+ * pop-os reinstall 2026-06-30 → slot reverted to disabled memory-core
112
+ * → memory_search/memory_get never bound). The host wires this to
113
+ * patchOpenClawConfig + a SIGUSR1 restart so the slot is re-asserted
114
+ * on the next tick if it was clobbered. Runs before the pairing/
115
+ * import gates so it fires even when extraction is deferred.
116
+ */
117
+ recheckSlot?: () => void;
118
+
107
119
  /** Initialization gate — same one the agent_end hook uses. */
108
120
  ensureInitialized: () => Promise<void>;
109
121
 
@@ -218,6 +230,9 @@ export function startTrajectoryPoller(
218
230
 
219
231
  const pollAndExtract = async (): Promise<void> => {
220
232
  try {
233
+ // 3.3.12-rc.19: re-assert slots.memory each tick in case OpenClaw's
234
+ // config-rewrite stripped it after register() (see deps.recheckSlot).
235
+ deps.recheckSlot?.();
221
236
  await deps.ensureInitialized();
222
237
  if (deps.isPairingPending()) return;
223
238
  if (deps.isImportActive()) return;