Skip to content

feat(wallet): explicit wallet.restore() recovery mechanism#492

Open
Kukks wants to merge 17 commits into
masterfrom
feat/wallet-restore
Open

feat(wallet): explicit wallet.restore() recovery mechanism#492
Kukks wants to merge 17 commits into
masterfrom
feat/wallet-restore

Conversation

@Kukks
Copy link
Copy Markdown
Contributor

@Kukks Kukks commented May 16, 2026

Summary

Adds an explicit, mode-aware, modular wallet.restore() that recovers a wallet's contracts and balance on a fresh device / wiped repo — closing the gap with go-sdk (gap-limit HD scan) and dotnet-sdk (modular handler discovery).

  • Explicit API: wallet.restore(opts?: { gapLimit?: number }): Promise<void> — never automatic at boot. gapLimit defaults to 20; <= 0 throws.
  • Mode-aware: HD wallets run a gap-limit scan across the index range; static / non-HD wallets restore from the single default pubkey (single pass at index 0). Never throws because of identity/mode. The HD capability check requires both materializeDescriptorAt and advanceLastIndexUsed so a partial custom DescriptorProvider safely takes the static path.
  • Modular discovery seam: a single Discoverable capability interface on ContractHandler. default and delegate handlers implement it; an external boltz/swap package can register a handler that implements it too (it closes over its own Boltz client) — core never imports boltz. Swap hits participate in the same gap-limit loop, so a swap found at HD index i keeps the gap window open and advances the HD watermark.
  • Inline VTXO pull: after the scan, one cursor-safe refreshVtxos({ includeInactive: true }) so getBalance() is correct the instant restore() resolves.
  • Safety-critical error contract: per-handler discoverAt errors are collected, not thrown — the scan finishes, the inline pull recovers safely-discovered funds, then an AggregateError is thrown listing the failed handlers. Fatal/structural errors (indexer unreachable, descriptor materialization failure) propagate immediately so a truncated restore is never silent.
  • Idempotent (createContract dedupes on script), concurrent calls coalesce, monotonic + input-validated HD watermark, dispose() drains an in-flight restore.

Design

Full design + rationale (Sections 1–5, brainstormed and approved before implementation) lives in docs/superpowers/specs/2026-05-16-restore-mechanism-design.md. Note: docs/ is gitignored in this repo, so the spec/plan are local working artifacts and not part of this diff — the behavior they describe is what's implemented and tested here.

Key changes

  • src/contracts/types.tsDiscoverable, DiscoveryDeps, DiscoveredContract, isDiscoverable.
  • src/contracts/contractManager.tsscanContracts() gap-limit loop + ScanResult/ScanContractsOptions/HandlerError; IContractManager extended.
  • src/contracts/handlers/{default,delegate}.ts — implement discoverAt.
  • src/contracts/metadata.ts (new) — WALLET_RECEIVE_SOURCE extracted here to break a contracts → wallet import cycle; re-exported from walletReceiveRotator for back-compat.
  • src/wallet/hdDescriptorProvider.ts — public pure materializeDescriptorAt; monotonic, input-validated advanceLastIndexUsed.
  • src/wallet/walletReceiveRotator.tspickActiveReceive deterministic tiebreak on HD index (so a tight-loop restore doesn't re-advertise an old address); signingDescriptorIndex helper; reuses the extracted deriveDescriptorLeafPubKey.
  • src/identity/descriptor.ts — exported deriveDescriptorLeafPubKey (extracted from the rotator; layering fix).
  • src/wallet/wallet.tsWallet.restore() + _runRestore + in-flight guard; dispose() drains it.
  • src/index.ts / src/contracts/index.ts — public exports for the new types.

Test plan

  • Unit (test/restore.test.ts): helper extraction; pure/monotonic/input-validated HD provider methods; isDiscoverable; default & delegate discoverAt (untagged index-0 vs tagged index>0, multi-timelock, partial-timelock-hit, no-delegatePubKey); scanContracts (gapLimit validation, swap-keeps-gap-open asserting the full post-hit probe window, per-handler error collected vs materialize-throw fatal, static single-pass); signingDescriptorIndex tiebreak; Wallet.restore() (invalid gapLimit, static recovery, HD watermark advance, concurrent coalesce, handler-error rejects after the inline pull, dispose-races-restore). Full unit suite green (1223 passed, 1 pre-existing skip).
  • E2E (test/e2e/restore.test.ts): HD-mode, load-bearing — fund Wallet A at index-0 (rotator advances to index-1), fund again at index-1, then a fresh-repo Wallet B on the same seed whose index-0 baseline cannot cover the index-1 funds → B.restore() recovers the full balance via the gap scan. Wallets disposed in finally. Runs (and passes) in CI via pnpm test:integration-docker against the regtest stack.

Known limitations / follow-ups

  • ServiceWorkerWallet: does not extend Wallet, so it does not inherit restore(). The SW contract-manager proxy explicitly rejects scanContracts (its materialize callback is not structured-cloneable across the worker message boundary). Service-worker restore is out of scope for this change — follow-up.
  • Per-contract hydration during scan: createContract() called per discovered contract triggers a per-contract indexer fetch, redundant with the final batched refreshVtxos({ includeInactive: true }). Correctness is unaffected (the final pull guarantees balance); this is a bounded perf cost on a rare explicit op. A watch-only persistence path is deferred to a follow-up rather than adding a new API to protocol-critical fund-recovery code in this PR.

Review notes

CodeRabbit feedback addressed (HD capability check now requires both HD methods; advanceLastIndexUsed input-validated; test robustness: full post-hit probe-window assertion, per-script mock indexer + partial-hit coverage, guaranteed e2e disposal, unique mock outpoints, guarded test cast). The two items above are deliberate, reasoned deferrals. The arkana/policy CHANGES_REQUESTED is a protocol-critical human-sign-off gate, not outstanding defects.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added wallet restoration functionality enabling recovery of wallet state and funds from seed
    • Implemented automatic contract discovery with configurable gap limits during wallet restore
  • Tests

    • Added comprehensive test suite covering wallet restoration and contract discovery workflows

Review Change Stack

Kukks added 15 commits May 16, 2026 22:41
Add the Discoverable capability to DefaultContractHandler so it
participates in wallet.restore()'s gap-limit scan. discoverAt probes
every csvTimelock in the baseline matrix at the given descriptor's leaf
pubkey and returns any contracts the indexer shows on-chain history for.
Index 0 produces an untagged DiscoveredContract; index > 0 tags with
wallet-receive source + signingDescriptor metadata.

Type annotation widened to ContractHandler & Discoverable.
…→wallet cycle

Move the source-of-truth declaration of WALLET_RECEIVE_SOURCE to the
new dependency-free leaf src/contracts/metadata.ts. Update
contracts/handlers/default.ts to import from ../metadata (dropping the
../../wallet/walletReceiveRotator import that caused the cycle). Add a
re-export from wallet/walletReceiveRotator for backward compatibility.
…asts

Add a test that calls discoverAt with two distinct csvTimelocks, asserts
both are returned as separate entries, and checks each entry's
params.csvTimelock serializes to its own timelockToSequence value.

Remove as any from isDiscoverable(DefaultContractHandler) and
DefaultContractHandler.discoverAt(...) — the exported const is already
typed ContractHandler<...> & Discoverable so both call sites type-check
without casts. Keep as any only on the intentionally partial mock
objects (mockIndexer, onchainProvider stubs).
…st import

- Rename local `lastUsedIdx` → `lastIndexUsed` in `scanContracts` so the
  return statement reads `return { lastIndexUsed, handlerErrors }` with no
  aliasing double-take.
- Tighten `let found;` to `let found: DiscoveredContract[];` (catch does
  `continue`, so it is always assigned before use; removes the inferred
  `| undefined`). Add `DiscoveredContract` to the `./types` import.
- Update `ScanContractsOptions.hd` JSDoc: false branch now reads
  "probe only index 0 (single static pass)" for precision.
- Remove the unused `import { ContractManager }` from `test/restore.test.ts`;
  only `makeManagerForTest()` / `makeDeps` from the helper are used in code.
- dispose() now awaits _restoreInFlight?.catch(() => undefined) before
  touching the contract/vtxo managers, preventing _runRestore from
  calling manager.refreshVtxos() or manager.scanContracts() on a torn-
  down manager. _runRestore never calls dispose(), so this is deadlock-free.

- staticDescriptor in _runRestore is now computed lazily (only in the
  non-HD branch), avoiding an unnecessary xOnlyPublicKey() derivation
  for HD wallets where the static descriptor is never used.

- JSDoc on restore() notes that concurrent calls coalesce and that a
  second caller's gapLimit is ignored while a restore is in flight.

- Regression test: wallet.restore() + immediate wallet.dispose() (without
  awaiting restore first) must resolve dispose without throwing, and the
  restore promise must settle without an unhandled rejection.
Surface Discoverable, DiscoveryDeps, DiscoveredContract, isDiscoverable
(from src/contracts/types via src/contracts/index.ts) and ScanResult,
ScanContractsOptions, HandlerError (from contractManager) through the
package root src/index.ts, following the existing curated import/export
pattern for contract types.

Cleanups from task review:
- Remove spurious await on synchronous materializeDescriptorAt calls in
  test/restore.test.ts (method returns string, not Promise<string>).
- Add inline comment above the /\/(\d+)\)\s*$/ regex in
  walletReceiveRotator.ts explaining what it captures.
- Consolidate all mid-file import blocks in test/restore.test.ts into a
  single top-of-file block.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 16, 2026

Walkthrough

This PR adds HD wallet contract recovery scanning: discoverable handlers, ContractManager.scanContracts gap-limit scanning, Wallet.restore orchestration (scan → advance watermark → refreshVtxos), descriptor utilities, public exports, and comprehensive tests.

Changes

HD Wallet Restore and Contract Discovery

Layer / File(s) Summary
Discovery API types and infrastructure
src/contracts/types.ts
DiscoveredContract, DiscoveryDeps, and Discoverable interfaces and isDiscoverable type guard added.
Discovery utility functions and constants
src/contracts/metadata.ts, src/identity/descriptor.ts, src/wallet/walletReceiveRotator.ts
Adds WALLET_RECEIVE_SOURCE, deriveDescriptorLeafPubKey, and signingDescriptorIndex.
Handler discoverable implementations
src/contracts/handlers/default.ts, src/contracts/handlers/delegate.ts
DefaultContractHandler.discoverAt and DelegateContractHandler.discoverAt added; they query indexer history and return discovered contract entries with conditional metadata tagging.
ContractManager.scanContracts core scanning
src/contracts/contractManager.ts
Adds ScanContractsOptions, ScanResult, HandlerError and implements gap-limit HD scanning that materializes descriptors, invokes discoverAt, collects per-handler errors, upserts discovered contracts, and returns lastIndexUsed + handlerErrors.
HDDescriptorProvider gap-scan support
src/wallet/hdDescriptorProvider.ts
Renames materialize helper to materializeDescriptorAt (pure read), adjusts getNextSigningDescriptor/getCurrentSigningDescriptor, and documents advanceLastIndexUsed as monotonic.
Wallet.restore() entry point and orchestration
src/wallet/wallet.ts
Adds Wallet.restore(opts?) with gapLimit validation, in-flight coalescing, scanning orchestration, watermark advancement, refreshVtxos({ includeInactive: true }), and AggregateError handling; dispose() awaits in-flight restore.
Wallet receive-rotation updates
src/wallet/walletReceiveRotator.ts
Re-exports WALLET_RECEIVE_SOURCE from contracts, refactors leaf pubkey derivation to use the new helper, and tie-breaks receive selection using signingDescriptorIndex.
Public API surface and package exports
src/contracts/index.ts, src/index.ts, src/wallet/serviceWorker/wallet.ts
Re-exports discovery types and isDiscoverable at package root; adds a service-worker scanContracts proxy method that rejects due to message-boundary limitations.
Test helpers and mock providers
test/helpers/hdProvider.ts, test/helpers/scanManager.ts, test/helpers/restoreWallet.ts, test/e2e/utils.ts
Adds test harness, MockIndexer/Onchain mocks, manager/HD provider helpers, and wallet factory helpers for deterministic restore tests.
Comprehensive restore and scanning test suite
test/restore.test.ts, test/e2e/restore.test.ts
Extensive unit and e2e tests for descriptor utilities, handler discovery, scanContracts gap-window/error handling, signingDescriptorIndex, and Wallet.restore concurrency/behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Wallet
  participant ContractManager
  participant Handler as Discoverable Handler
  participant Indexer as IndexerProvider
  Handler->>Handler: materialize(index)
  Handler->>IndexerProvider: getVtxos({scripts})
  IndexerProvider-->>Handler: matching vtxos
  Handler-->>ContractManager: DiscoveredContract[]
  ContractManager->>ContractManager: createContract(discovered)
  ContractManager-->>Wallet: {lastIndexUsed, handlerErrors}
Loading
sequenceDiagram
  participant Caller
  participant Wallet
  participant Manager as ContractManager
  participant Refresh as RefreshVtxos
  Caller->>Wallet: restore(opts)
  Wallet->>Manager: scanContracts(deps)
  Manager-->>Wallet: {lastIndexUsed, handlerErrors}
  Wallet->>Wallet: advanceLastIndexUsed
  Wallet->>Refresh: refreshVtxos(includeInactive:true)
  Refresh-->>Wallet: recovered vtxos
  Wallet-->>Caller: void (or AggregateError)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • pietro909
  • arkanaai
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(wallet): explicit wallet.restore() recovery mechanism' clearly and concisely describes the primary feature addition—an explicit wallet recovery mechanism—and is well-scoped to the main change without being vague or overly broad.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wallet-restore

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(wallet): explicit wallet.restore() recovery mechanism

Verdict: Request changes (protocol-critical — requires human sign-off)

This is well-architected fund-recovery code with solid error semantics and thorough test coverage. The gap-limit scan, error-collection model, and concurrency guard are all sound. However, this is protocol-critical (VTXO discovery = fund recovery) — bugs here make users believe they have no funds when they do. Requesting human review per repo policy.


🟢 Strengths

  1. Error contract is correct: handler discoverAt errors collected → scan finishes → inline VTXO pull recovers safely-discovered funds → then AggregateError surfaces. A truncated restore is loud, never silent.
  2. Concurrency model is solid: _restoreInFlight coalesces concurrent calls; dispose() drains it deadlock-free (restore never calls dispose).
  3. HD watermark is monotonic: advanceLastIndexUsed uses mutate() (mutex-protected) and only advances.
  4. Cross-repo impact is nil: all changes are additive. No external implementations of IContractManager exist. The materializeAtmaterializeDescriptorAt rename is internal.
  5. Test coverage: 28 unit cases + e2e. Key invariants tested: gap-window-kept-open-by-swap, concurrent coalesce, dispose-races-restore, handler-error-after-pull.

🟡 Minor Issues (non-blocking, but worth addressing)

1. Unbounded loop if a buggy handler always returns results
src/contracts/contractManager.ts:131while (i <= maxIdx && unused < gapLimit) with maxIdx = POSITIVE_INFINITY for HD mode. If a malicious/buggy third-party Discoverable handler always returns non-empty from discoverAt, the loop never terminates.

Suggestion: Add a hard ceiling (e.g. const MAX_INDEX = 10_000) as a safety valve. Gap-limit 20 with continuous hits reaching 10k indices would already be extraordinary; an infinite loop would OOM/hang the wallet.

const maxIdx = opts.hd ? MAX_INDEX : 0;

2. Sequential handler probing — potential performance cliff
src/contracts/contractManager.ts:135-145 — handlers are iterated sequentially within each index. With N handlers × G gap-limit × T timelocks per handler, each making an indexer round-trip, a restore with 3 handlers and gap 20 would make ~60+ serial network calls minimum.

Suggestion (future): Consider Promise.all across handlers within an index (they're independent). Not blocking this PR, but worth a TODO comment.

3. Duck-typing for HD detection is fragile
src/wallet/wallet.ts:1192-1194:

const hd = !!provider && typeof (provider as Partial<HDDescriptorProvider>).materializeDescriptorAt === "function";

Any non-HD DescriptorProvider that happens to have a materializeDescriptorAt method would be incorrectly treated as HD. Consider using instanceof HDDescriptorProvider (you already import it) for type safety:

const hd = provider instanceof HDDescriptorProvider;

🟢 No Issues Found In

  • Gap-limit termination logic (correct BIP44-style scan)
  • createContract idempotency reliance (script-keyed dedup is existing behavior)
  • Metadata tagging (index 0 untagged, index > 0 tagged with wallet-receive + signingDescriptor)
  • deriveDescriptorLeafPubKey extraction (logic identical to original)
  • WALLET_RECEIVE_SOURCE layering fix (re-export maintains back-compat)
  • pickActiveReceive tiebreak (deterministic on HD index)
  • ServiceWorker proxy rejection (clear error, correct — callbacks not structured-cloneable)
  • signingDescriptorIndex regex (correctly parses trailing /N) pattern)

⚠️ Protocol-Critical Flag

This PR adds fund-recovery logic. If scanContracts terminates early (gap closes before all funds discovered), or if refreshVtxos fails silently after the scan, users lose visibility of their own money. The error contract handles both cases correctly in the current implementation, but this requires human sign-off before merge per protocol-critical review policy.


TL;DR: Clean, well-tested, correct error semantics. The unbounded-loop ceiling (#1) should be addressed before merge; #2-#3 are nice-to-haves. Human approval required for protocol-critical code.

Copy link
Copy Markdown
Contributor

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(wallet): explicit wallet.restore() recovery mechanism

Verdict: Request changes (protocol-critical — requires human sign-off)

This is well-architected fund-recovery code with solid error semantics and thorough test coverage. The gap-limit scan, error-collection model, and concurrency guard are all sound. However, this is protocol-critical (VTXO discovery = fund recovery) — bugs here make users believe they have no funds when they do. Requesting human review per repo policy.


🟢 Strengths

  1. Error contract is correct: handler discoverAt errors collected → scan finishes → inline VTXO pull recovers safely-discovered funds → then AggregateError surfaces. A truncated restore is loud, never silent.
  2. Concurrency model is solid: _restoreInFlight coalesces concurrent calls; dispose() drains it deadlock-free (restore never calls dispose).
  3. HD watermark is monotonic: advanceLastIndexUsed uses mutate() (mutex-protected) and only advances.
  4. Cross-repo impact is nil: all changes are additive. No external implementations of IContractManager exist. The materializeAtmaterializeDescriptorAt rename is internal.
  5. Test coverage: 28 unit cases + e2e. Key invariants tested: gap-window-kept-open-by-swap, concurrent coalesce, dispose-races-restore, handler-error-after-pull.

🟡 Minor Issues (non-blocking, but worth addressing)

1. Unbounded loop if a buggy handler always returns results
src/contracts/contractManager.ts:131while (i <= maxIdx && unused < gapLimit) with maxIdx = POSITIVE_INFINITY for HD mode. If a malicious/buggy third-party Discoverable handler always returns non-empty from discoverAt, the loop never terminates.

Suggestion: Add a hard ceiling (e.g. const MAX_INDEX = 10_000) as a safety valve. Gap-limit 20 with continuous hits reaching 10k indices would already be extraordinary; an infinite loop would OOM/hang the wallet.

const maxIdx = opts.hd ? MAX_INDEX : 0;

2. Sequential handler probing — potential performance cliff
src/contracts/contractManager.ts:135-145 — handlers are iterated sequentially within each index. With N handlers × G gap-limit × T timelocks per handler, each making an indexer round-trip, a restore with 3 handlers and gap 20 would make ~60+ serial network calls minimum.

Suggestion (future): Consider Promise.all across handlers within an index (they're independent). Not blocking this PR, but worth a TODO comment.

3. Duck-typing for HD detection is fragile
src/wallet/wallet.ts:1192-1194:

const hd = !!provider && typeof (provider as Partial<HDDescriptorProvider>).materializeDescriptorAt === "function";

Any non-HD DescriptorProvider that happens to have a materializeDescriptorAt method would be incorrectly treated as HD. Consider using instanceof HDDescriptorProvider (you already import it) for type safety:

const hd = provider instanceof HDDescriptorProvider;

🟢 No Issues Found In

  • Gap-limit termination logic (correct BIP44-style scan)
  • createContract idempotency reliance (script-keyed dedup is existing behavior)
  • Metadata tagging (index 0 untagged, index > 0 tagged with wallet-receive + signingDescriptor)
  • deriveDescriptorLeafPubKey extraction (logic identical to original)
  • WALLET_RECEIVE_SOURCE layering fix (re-export maintains back-compat)
  • pickActiveReceive tiebreak (deterministic on HD index)
  • ServiceWorker proxy rejection (clear error, correct — callbacks not structured-cloneable)
  • signingDescriptorIndex regex (correctly parses trailing /N) pattern)

⚠️ Protocol-Critical Flag

This PR adds fund-recovery logic. If scanContracts terminates early (gap closes before all funds discovered), or if refreshVtxos fails silently after the scan, users lose visibility of their own money. The error contract handles both cases correctly in the current implementation, but this requires human sign-off before merge per protocol-critical review policy.


TL;DR: Clean, well-tested, correct error semantics. The unbounded-loop ceiling (#1) should be addressed before merge; #2-#3 are nice-to-haves. Human approval required for protocol-critical code.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
test/restore.test.ts (2)

426-468: ⚡ Quick win

Assert that the scan really continues past the hit.

This regression test says the hit at index 4 keeps the window open, but the current assertions only prove the hit was found and lastIndexUsed became 4. A buggy loop that stops immediately after the first hit would still pass. Capture calls from makeFakeHandler() and assert the handler was probed through the full post-hit window.

♻️ Suggested assertion
-        const { handler } = makeFakeHandler("swapfake", (i) =>
+        const { handler, calls } = makeFakeHandler("swapfake", (i) =>
             i === 4
                 ? [
                       {
                           type: "swapfake",
                           params: { script: "aabb" },
@@
             // so the loop kept probing 5..9 instead of stopping at 4, and
             // lastIndexUsed is driven solely by the swap handler.
             expect(res.lastIndexUsed).toBe(4);
             expect(res.handlerErrors).toEqual([]);
+            expect(calls).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
             // The contract was actually registered (idempotent createContract).
             const [c] = await mgr.getContracts({ script: "aabb" });
             expect(c?.type).toBe("swapfake");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/restore.test.ts` around lines 426 - 468, The test currently only asserts
that a swap hit at index 4 was found and lastIndexUsed is 4, but doesn't verify
the scanner actually probed indices after the hit; capture the handler
invocation details returned by makeFakeHandler (the `handler`/`calls` structure)
and add an assertion that the handler was invoked for indices beyond 4 up to the
expected post-hit window (given gapLimit:5), e.g., verify `calls` includes
probes for indices 5..(4 + gapLimit - 1) or the equivalent sequence, so the test
fails if the loop stopped immediately after the hit; update the test to extract
`calls` from makeFakeHandler and assert the expected indices were probed.

102-111: ⚡ Quick win

Cover the partial-hit timelock case.

mockIndexer() reports success when any queried script matches, and both multi-timelock tests only cover the “all scripts hit” path. A handler regression that batches timelocks together and treats one hit as evidence for every candidate would still pass here. Please add a case where only one timelock script exists, or make the mock return per-script hits so the suite can catch false positives.

Also applies to: 174-220, 302-350

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/restore.test.ts` around lines 102 - 111, The mockIndexer’s getVtxos
currently returns a single boolean hit if any queried script matches, which
masks partial-hit bugs; update mockIndexer (and tests at the other locations) so
getVtxos returns per-script results (or at least an explicit case where only one
timelock script is present) — e.g., have mockIndexer examine each script in
opts.scripts and return vtxos only for the scripts that are in usedScripts (or
add a new test that supplies two timelock scripts but only marks one as used and
asserts the handler does not treat both as found); target the mockIndexer
function and getVtxos behavior used by the multi-timelock tests to ensure
partial-hit cases are covered.
test/e2e/restore.test.ts (1)

22-59: ⚡ Quick win

Always dispose the e2e wallets in finally.

If any assertion fails before the happy-path disposals, these wallet instances stay alive and can leak repo/indexer state into later e2e runs. Please wrap both wallet lifecycles in try/finally so cleanup is guaranteed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/e2e/restore.test.ts` around lines 22 - 59, The test creates two wallets
via createTestArkWalletFromMnemonic (variables a and b) but only disposes them
on the happy path; move both lifecycles into try/finally blocks so each wallet
is always disposed even if assertions fail: surround the logic after creating a
with try { ... } finally { await a.wallet.dispose(); } and likewise surround the
logic after creating b (using createSharedRepos and calling
faucetOffchain/waitFor/getBalance/restore) with a try/finally that awaits
b.wallet.dispose(); ensure awaits remain and no test logic is left outside the
try so cleanup is guaranteed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/contracts/contractManager.ts`:
- Around line 528-537: The discovery loop currently calls createContract() which
immediately triggers full hydration via fetchContractVxosFromIndexer() and
watcher registration; to avoid N full indexer pulls during restore, add a new
lightweight API (e.g., persistAndWatchContract or createContractWatchOnly) on
the same class that only persists the DiscoveredContract and registers watchers
but does not call fetchContractVxosFromIndexer(), then replace the
createContract(c) call inside the discoverables loop with this new method; keep
createContract(c) intact for later restore flow where refreshVtxos({
includeInactive: true }) will perform the full hydration. Ensure the new method
signature accepts the DiscoveredContract and any opts needed and that any error
handling (handlerErrors) remains unchanged.

In `@src/wallet/hdDescriptorProvider.ts`:
- Around line 134-142: The advanceLastIndexUsed method currently accepts invalid
watermarks; before calling mutate() add a guard that validates the incoming
index using Number.isInteger(index) && index >= 0 and bail out (no-op) if it
fails so you never persist NaN, fractions, or negatives to
settings.lastIndexUsed; ensure you reference advanceLastIndexUsed, mutate, and
settings.lastIndexUsed in the change and note that invalid values currently
break parseSettings on subsequent reads.

In `@src/wallet/wallet.ts`:
- Around line 1229-1243: The current HD branch uses hd inferred only from
materializeDescriptorAt but later calls advanceLastIndexUsed, causing a
TypeError; update the capability check to require both functions
(materializeDescriptorAt and advanceLastIndexUsed) on this._descriptorProvider
(e.g., replace hd with hdCapable or similar), narrow the type to an
HDDescriptorProvider-like shape for the rest of _runRestore, and adjust the
materialize function and subsequent calls (including where advanceLastIndexUsed
is invoked) to rely on that guarded, correctly typed provider so custom
providers that only implement one method won't take the HD path.
- Around line 1213-1224: Move the early-return that checks this._restoreInFlight
to before any validation of opts so concurrent calls coalesce; specifically, in
restore() check if (this._restoreInFlight) return this._restoreInFlight; first,
then compute/validate gapLimit and set this._restoreInFlight =
this._runRestore(gapLimit).finally(...). Ensure you still validate gapLimit
(Number.isInteger and >0) only for the caller that actually starts the run, and
keep the same error message when throwing.

In `@test/helpers/restoreWallet.ts`:
- Around line 84-95: makeVtxo currently returns a constant outpoint (txid/vout)
which causes accidental deduping; update makeVtxo to generate a unique outpoint
per call (e.g., derive txid or vout from the script, a counter, timestamp, or a
short hash of script+Date.now()) so each VirtualCoin has a distinct txid/vout
pair while preserving other fields (value, status, createdAt, script,
isUnrolled, isSpent, virtualStatus). Apply the same uniqueness fix to the other
similar helper(s) referenced in the file (the alternate makeVtxo usage around
the later lines 125-127) so all mocked VTXOs use unique outpoints.
- Around line 259-261: The current unchecked cast retrieving hdProvider from
wallet (const hdProvider = (wallet as unknown as { _descriptorProvider:
HDDescriptorProvider })._descriptorProvider) should be protected by a runtime
guard: verify wallet._descriptorProvider exists and matches the expected shape
(e.g., instanceof HDDescriptorProvider or has required methods/properties) and
if not throw a clear, fast-failing error mentioning wallet and
HDDescriptorProvider; replace the direct cast with this guard and use the
validated hdProvider thereafter so failures are explicit if wallet internals
change.

---

Nitpick comments:
In `@test/e2e/restore.test.ts`:
- Around line 22-59: The test creates two wallets via
createTestArkWalletFromMnemonic (variables a and b) but only disposes them on
the happy path; move both lifecycles into try/finally blocks so each wallet is
always disposed even if assertions fail: surround the logic after creating a
with try { ... } finally { await a.wallet.dispose(); } and likewise surround the
logic after creating b (using createSharedRepos and calling
faucetOffchain/waitFor/getBalance/restore) with a try/finally that awaits
b.wallet.dispose(); ensure awaits remain and no test logic is left outside the
try so cleanup is guaranteed.

In `@test/restore.test.ts`:
- Around line 426-468: The test currently only asserts that a swap hit at index
4 was found and lastIndexUsed is 4, but doesn't verify the scanner actually
probed indices after the hit; capture the handler invocation details returned by
makeFakeHandler (the `handler`/`calls` structure) and add an assertion that the
handler was invoked for indices beyond 4 up to the expected post-hit window
(given gapLimit:5), e.g., verify `calls` includes probes for indices 5..(4 +
gapLimit - 1) or the equivalent sequence, so the test fails if the loop stopped
immediately after the hit; update the test to extract `calls` from
makeFakeHandler and assert the expected indices were probed.
- Around line 102-111: The mockIndexer’s getVtxos currently returns a single
boolean hit if any queried script matches, which masks partial-hit bugs; update
mockIndexer (and tests at the other locations) so getVtxos returns per-script
results (or at least an explicit case where only one timelock script is present)
— e.g., have mockIndexer examine each script in opts.scripts and return vtxos
only for the scripts that are in usedScripts (or add a new test that supplies
two timelock scripts but only marks one as used and asserts the handler does not
treat both as found); target the mockIndexer function and getVtxos behavior used
by the multi-timelock tests to ensure partial-hit cases are covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 310ef6b3-7dac-4e9d-a7b5-480fbccaa359

📥 Commits

Reviewing files that changed from the base of the PR and between d663d15 and 38f5537.

📒 Files selected for processing (18)
  • src/contracts/contractManager.ts
  • src/contracts/handlers/default.ts
  • src/contracts/handlers/delegate.ts
  • src/contracts/index.ts
  • src/contracts/metadata.ts
  • src/contracts/types.ts
  • src/identity/descriptor.ts
  • src/index.ts
  • src/wallet/hdDescriptorProvider.ts
  • src/wallet/serviceWorker/wallet.ts
  • src/wallet/wallet.ts
  • src/wallet/walletReceiveRotator.ts
  • test/e2e/restore.test.ts
  • test/e2e/utils.ts
  • test/helpers/hdProvider.ts
  • test/helpers/restoreWallet.ts
  • test/helpers/scanManager.ts
  • test/restore.test.ts

Comment on lines +528 to +537
for (const h of discoverables) {
let found: DiscoveredContract[];
try {
found = await h.discoverAt(i, descriptor, opts.deps);
} catch (error) {
handlerErrors.push({ handler: h.type, index: i, error });
continue;
}
for (const c of found) {
await this.createContract(c); // idempotent (script-keyed)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid a full contract hydration on every discovery hit.

Line 537 routes each discovered contract through createContract(), and that path immediately does a full fetchContractVxosFromIndexer() plus watcher registration before restore later runs its single refreshVtxos({ includeInactive: true }). On wallets recovering many addresses/swaps, this turns restore into N extra full-history indexer pulls before the final balance refresh and can make recovery disproportionately slow or brittle. Split registration from hydration here, or add an internal “persist/watch only” path for restore.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/contracts/contractManager.ts` around lines 528 - 537, The discovery loop
currently calls createContract() which immediately triggers full hydration via
fetchContractVxosFromIndexer() and watcher registration; to avoid N full indexer
pulls during restore, add a new lightweight API (e.g., persistAndWatchContract
or createContractWatchOnly) on the same class that only persists the
DiscoveredContract and registers watchers but does not call
fetchContractVxosFromIndexer(), then replace the createContract(c) call inside
the discoverables loop with this new method; keep createContract(c) intact for
later restore flow where refreshVtxos({ includeInactive: true }) will perform
the full hydration. Ensure the new method signature accepts the
DiscoveredContract and any opts needed and that any error handling
(handlerErrors) remains unchanged.

Comment thread src/wallet/hdDescriptorProvider.ts
Comment thread src/wallet/wallet.ts
Comment on lines +1213 to +1224
async restore(opts?: { gapLimit?: number }): Promise<void> {
const gapLimit = opts?.gapLimit ?? 20;
if (!Number.isInteger(gapLimit) || gapLimit <= 0) {
throw new Error(
`restore: gapLimit must be a positive integer (got ${String(opts?.gapLimit)})`
);
}
if (this._restoreInFlight) return this._restoreInFlight;
this._restoreInFlight = this._runRestore(gapLimit).finally(() => {
this._restoreInFlight = undefined;
});
return this._restoreInFlight;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Check _restoreInFlight before validating the new call's options.

The note on Lines 1209-1211 says later callers should always share the running restore and ignore their own gapLimit, but this validates first. A concurrent restore({ gapLimit: 0 }) will currently throw instead of coalescing onto the existing promise.

Proposed fix
 async restore(opts?: { gapLimit?: number }): Promise<void> {
+    if (this._restoreInFlight) return this._restoreInFlight;
+
     const gapLimit = opts?.gapLimit ?? 20;
     if (!Number.isInteger(gapLimit) || gapLimit <= 0) {
         throw new Error(
             `restore: gapLimit must be a positive integer (got ${String(opts?.gapLimit)})`
         );
     }
-    if (this._restoreInFlight) return this._restoreInFlight;
     this._restoreInFlight = this._runRestore(gapLimit).finally(() => {
         this._restoreInFlight = undefined;
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async restore(opts?: { gapLimit?: number }): Promise<void> {
const gapLimit = opts?.gapLimit ?? 20;
if (!Number.isInteger(gapLimit) || gapLimit <= 0) {
throw new Error(
`restore: gapLimit must be a positive integer (got ${String(opts?.gapLimit)})`
);
}
if (this._restoreInFlight) return this._restoreInFlight;
this._restoreInFlight = this._runRestore(gapLimit).finally(() => {
this._restoreInFlight = undefined;
});
return this._restoreInFlight;
async restore(opts?: { gapLimit?: number }): Promise<void> {
if (this._restoreInFlight) return this._restoreInFlight;
const gapLimit = opts?.gapLimit ?? 20;
if (!Number.isInteger(gapLimit) || gapLimit <= 0) {
throw new Error(
`restore: gapLimit must be a positive integer (got ${String(opts?.gapLimit)})`
);
}
this._restoreInFlight = this._runRestore(gapLimit).finally(() => {
this._restoreInFlight = undefined;
});
return this._restoreInFlight;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/wallet/wallet.ts` around lines 1213 - 1224, Move the early-return that
checks this._restoreInFlight to before any validation of opts so concurrent
calls coalesce; specifically, in restore() check if (this._restoreInFlight)
return this._restoreInFlight; first, then compute/validate gapLimit and set
this._restoreInFlight = this._runRestore(gapLimit).finally(...). Ensure you
still validate gapLimit (Number.isInteger and >0) only for the caller that
actually starts the run, and keep the same error message when throwing.

Comment thread src/wallet/wallet.ts
Comment thread test/helpers/restoreWallet.ts
Comment thread test/helpers/restoreWallet.ts Outdated
Copy link
Copy Markdown
Contributor

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review (commit 0aaac20)

New commit: test(e2e): make restore test HD-mode and load-bearing — test-only, no production code changed.

✅ New test is correct and well-designed

The old e2e test was vacuous — index-0 funds are auto-registered at baseline, so a same-seed wallet B would see them without restore(). The new test is genuinely load-bearing:

  1. Funds wallet A at index-0 → receive rotator advances to index-1
  2. Funds wallet A at index-1 (a script baseline doesn't cover)
  3. Fresh wallet B on same seed → baseline only sees index-0 → before < totalA
  4. After restore() → gap scan discovers index-1 → after >= totalA

The waitFor polling for rotation and the toBeLessThan assertion (instead of === 0) correctly handle the timing window where B's watcher may or may not have synced index-0 VTXOs. Good.

The utils.ts change (adding optional walletMode param) is clean — no default changed, existing callers unaffected.

⚠️ Previous review concerns still open

My prior review's findings remain unaddressed:

  1. Unbounded loop (contractManager.ts:518maxIdx = POSITIVE_INFINITY) — still needs a hard ceiling
  2. Duck-typing for HD detection (wallet.ts:1233) — still uses method-existence check instead of instanceof

These are non-blocking for this test commit but still need resolution before merge. Protocol-critical flag and human sign-off requirement remain.

@pietro909 pietro909 self-assigned this May 18, 2026
@pietro909 pietro909 self-requested a review May 18, 2026 07:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants