WIP: feat(wallet): implement NUT-XX Efficient Wallet Recovery #1735

Draft
a1denvalu3 wants to merge 3 commits from a1denvalu3/feat/nut-xx-efficient-wallet-recovery into main
a1denvalu3 commented 2026-03-16 11:42:33 +00:00 (Migrated from github.com)

Summary

This pull request implements the new NUT-XX Efficient Wallet Recovery specification for the cdk wallet, reducing recovery complexity from O(T) to O(log N) and maintaining the required Depth Invariant.

Changes

  • Database Schema: Added keyset_counter to ProofInfo across SQLite and PostgreSQL, with corresponding migrations.
  • Depth Invariant: Added Wallet::ensure_depth_invariant that triggers a consolidation swap if unspent proofs exceed the T - d threshold or the compaction limit d.
  • Sagas Integration: Injected keyset counters on recv_proofs/change_proofs correctly across issue, melt, receive, and swap sagas to maintain accuracy.
  • Fast Recovery Algorithm:
    • Implemented find_t (binary search).
    • Implemented scan_gap (gap scan) to handle gaps securely.
    • Bound the new recovery behind Wallet::restore_fast.
  • Options & Compatibility: Replaced restore logic to accept a new RecoveryOptions with RecoveryStrategy::Fast (default) and RecoveryStrategy::LinearScan to fall back to the old NUT-13 scan.
  • FFI & CLI Support: Added bindings for the RecoveryOptions across UniFFI (cdk-ffi) and added a --legacy-scan flag to the cdk-cli for backwards compatibility.
  • One-Time Consolidation: Automatically run ensure_depth_invariant at startup when Wallet::recover_incomplete_sagas verifies keysets to migrate pre-existing keyset_counter = NULL proofs.
## Summary This pull request implements the new [NUT-XX Efficient Wallet Recovery](https://raw.githubusercontent.com/Forte11Cuba/nuts/9f6b390fd53c0a805b1237aa3b8f16d7cb8658f9/XX.md) specification for the `cdk` wallet, reducing recovery complexity from `O(T)` to `O(log N)` and maintaining the required Depth Invariant. ## Changes - **Database Schema**: Added `keyset_counter` to `ProofInfo` across SQLite and PostgreSQL, with corresponding migrations. - **Depth Invariant**: Added `Wallet::ensure_depth_invariant` that triggers a consolidation swap if unspent proofs exceed the `T - d` threshold or the compaction limit `d`. - **Sagas Integration**: Injected keyset counters on `recv_proofs`/`change_proofs` correctly across `issue`, `melt`, `receive`, and `swap` sagas to maintain accuracy. - **Fast Recovery Algorithm**: - Implemented `find_t` (binary search). - Implemented `scan_gap` (gap scan) to handle gaps securely. - Bound the new recovery behind `Wallet::restore_fast`. - **Options & Compatibility**: Replaced `restore` logic to accept a new `RecoveryOptions` with `RecoveryStrategy::Fast` (default) and `RecoveryStrategy::LinearScan` to fall back to the old NUT-13 scan. - **FFI & CLI Support**: Added bindings for the `RecoveryOptions` across UniFFI (`cdk-ffi`) and added a `--legacy-scan` flag to the `cdk-cli` for backwards compatibility. - **One-Time Consolidation**: Automatically run `ensure_depth_invariant` at startup when `Wallet::recover_incomplete_sagas` verifies keysets to migrate pre-existing `keyset_counter = NULL` proofs.
a1denvalu3 commented 2026-03-16 14:46:32 +00:00 (Migrated from github.com)

The compaction logic is handled primarily through an automatic, internal SwapSaga that is triggered whenever the wallet detects that the 'Depth Invariant' ( > T - d$) constraint is at risk.

Here is the specific breakdown of how it works:

  1. Triggering ensure_depth_invariant:
    Before running any wallet operation that generates new signatures (like a standard swap, receive, or issue), the wallet calls Wallet::ensure_depth_invariant(keyset_id, new_outputs). This predicts what the keyset counter ($) will be after the operation.

  2. Evaluating the Threshold:
    It fetches all Unspent proofs for the active keyset and checks three conditions against the depth limit ( = 100$):

    • Count Limit: If the total number of unspent proofs for the keyset plus the new_outputs exceeds 00$, all proofs for that keyset are marked for consolidation.
    • Depth Limit: Otherwise, it checks each proof's keyset_counter (). If \le T - 100, the specific proof is too old and is marked for consolidation.
    • Legacy Proofs: Any proof with a missing keyset_counter (e.g., loaded from an older DB schema) is automatically marked for consolidation.
  3. Executing the Compaction Swap:
    If any proofs are marked, the wallet instantiates an internal SwapSaga to exchange the offending proofs for fresh ones.

  4. Bypassing the Loop (skip_invariant: true):
    A standard SwapSaga naturally checks the depth invariant before running. Since this compaction is a swap, doing an invariant check inside the compaction swap would result in an infinite recursive loop. To solve this, the internal swap is initialized via saga.prepare(..., skip_invariant: true).

  5. Mint Interaction:
    saga.execute().await? is called. The selected, outdated proofs are sent to the Mint to be melted down, and new proofs are issued in their place.

Because the new proofs are freshly issued, their keyset_counter values represent the newest sequence indices at the top of the derivation path. This safely resolves the gap, compacts the outputs, and guarantees the entire wallet state successfully meets the strict (\log N)$ fast-recovery invariant before the user's primary operation proceeds.

The compaction logic is handled primarily through an automatic, internal `SwapSaga` that is triggered whenever the wallet detects that the 'Depth Invariant' ( > T - d$) constraint is at risk. Here is the specific breakdown of how it works: 1. **Triggering `ensure_depth_invariant`:** Before running any wallet operation that generates new signatures (like a standard swap, receive, or issue), the wallet calls `Wallet::ensure_depth_invariant(keyset_id, new_outputs)`. This predicts what the keyset counter ($) will be after the operation. 2. **Evaluating the Threshold:** It fetches all `Unspent` proofs for the active keyset and checks three conditions against the depth limit ( = 100$): * **Count Limit:** If the total number of unspent proofs for the keyset plus the `new_outputs` exceeds 00$, **all** proofs for that keyset are marked for consolidation. * **Depth Limit:** Otherwise, it checks each proof's `keyset_counter` ($). If \le T - 100$, the specific proof is too old and is marked for consolidation. * **Legacy Proofs:** Any proof with a missing `keyset_counter` (e.g., loaded from an older DB schema) is automatically marked for consolidation. 3. **Executing the Compaction Swap:** If any proofs are marked, the wallet instantiates an internal `SwapSaga` to exchange the offending proofs for fresh ones. 4. **Bypassing the Loop (`skip_invariant: true`):** A standard `SwapSaga` naturally checks the depth invariant before running. Since this compaction *is* a swap, doing an invariant check inside the compaction swap would result in an infinite recursive loop. To solve this, the internal swap is initialized via `saga.prepare(..., skip_invariant: true)`. 5. **Mint Interaction:** `saga.execute().await?` is called. The selected, outdated proofs are sent to the Mint to be melted down, and new proofs are issued in their place. Because the new proofs are freshly issued, their `keyset_counter` values represent the newest sequence indices at the top of the derivation path. This safely resolves the gap, compacts the outputs, and guarantees the entire wallet state successfully meets the strict (\log N)$ fast-recovery invariant before the user's primary operation proceeds.
This pull request is marked as a work in progress.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin a1denvalu3/feat/nut-xx-efficient-wallet-recovery:a1denvalu3/feat/nut-xx-efficient-wallet-recovery
git switch a1denvalu3/feat/nut-xx-efficient-wallet-recovery

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff a1denvalu3/feat/nut-xx-efficient-wallet-recovery
git switch a1denvalu3/feat/nut-xx-efficient-wallet-recovery
git rebase main
git switch main
git merge --ff-only a1denvalu3/feat/nut-xx-efficient-wallet-recovery
git switch a1denvalu3/feat/nut-xx-efficient-wallet-recovery
git rebase main
git switch main
git merge --no-ff a1denvalu3/feat/nut-xx-efficient-wallet-recovery
git switch main
git merge --squash a1denvalu3/feat/nut-xx-efficient-wallet-recovery
git switch main
git merge --ff-only a1denvalu3/feat/nut-xx-efficient-wallet-recovery
git switch main
git merge a1denvalu3/feat/nut-xx-efficient-wallet-recovery
git push origin main
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
cashubtc/cdk!1735
No description provided.