Skip to content

feat(wallet): add offline ecash receiving with DLEQ verification#1931

Open
GEET3001 wants to merge 1 commit into
cashubtc:mainfrom
GEET3001:feat/offline-receive
Open

feat(wallet): add offline ecash receiving with DLEQ verification#1931
GEET3001 wants to merge 1 commit into
cashubtc:mainfrom
GEET3001:feat/offline-receive

Conversation

@GEET3001

@GEET3001 GEET3001 commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Description

Closes #1927

Currently, receiving in the CDK wallet requires the wallet to be online to perform a swap. However, Cashu enables offline receiving pending DLEQ verification and locking conditions. This PR adds that capability end-to-end across the core library, mobile FFI bindings, and CLI.

receive_offline() accepts a token and an OfflineReceiveOptions struct containing:

  • min_locktime — the minimum locktime required on the token
  • require_locked — whether the token must be P2PK locked to protect against double-spend by the sender while offline

The wallet verifies the token's DLEQ proof and checks these conditions entirely offline. Upon successful verification, the proofs are added to the database in the new PendingReceive state. Wallets should display these with a warning that they must be swapped for final settlement.

finalize_pending_receives() checks for all proofs in PendingReceive state and executes the swaps with the mint. This should be called whenever the wallet comes back online.

What was added

Core Protocol

  • Adds PendingReceive state to the State enum in cashu/nut07
  • Adds OfflineReceiveOptions struct (min_locktime, require_locked)
  • Adds receive_offline() method to the Wallet trait and core implementation:
    • Verifies DLEQ proofs on all tokens without going online
    • Validates locking conditions (locktime, P2PK)
    • Stores verified proofs in PendingReceive state in the database
  • Adds finalize_pending_receives() to swap pending proofs with the mint
  • Adds total_pending_receive_balance() to query the total amount sitting in the pending offline queue
  • Fixes offline receive finalization losing the sender's memo and splitting one token into per-proof transactions. Proofs are now grouped by a UUID stamped at receive_offline time; finalize_pending_receives processes each group as a single receive operation and recovers the memo from the OfflinePendingReceive saga entry

Database

  • Adds SQLite and Postgres migrations to add PENDING_RECEIVE to the proof state CHECK constraint

FFI Layer (cdk-ffi)

  • Exposes receive_offline(), finalize_pending_receives(), and total_pending_receive_balance() over the UniFFI boundary so Swift/Kotlin consumers can use the full offline receive lifecycle
  • Exposes ProofState::PendingReceive as a UniFFI enum variant
  • Adds JSON encode/decode helpers for OfflineReceiveOptions for mobile callers
  • Handles PendingReceive in all exhaustive match sites (test utils, FFI)

CLI (cdk-cli)

  • Adds --offline flag to the receive subcommand — routes the token to receive_offline instead of the standard online swap
  • Adds new finalize-receives subcommand — sweeps all PendingReceive proofs and swaps them with the mint when the user is back online

Bug Fix

  • Fixes a non-exhaustive match compiler error in check_spendable.rs that was introduced when PendingReceive was added to the State enum

Known limitations

  • receive_offline depends on the keyset for the incoming token having been synced during a prior online session. This relies on Add load_from_db to MintMetadataCache #1957's load_from_db fallback in MintMetadataCache::load().

    is_populated is a coarse boolean — it returns true if any keyset is cached, not specifically the one the incoming token uses. If the wallet has never seen that keyset (e.g. the mint rotated keys between the sender minting and the receiver syncing), load_keyset_keys returns None. Scoped fix in this PR: improved error message at the load_keyset_keys call site directing the user to go online once to sync. Follow-up: is_populated should be keyset-id-aware rather than a global boolean.

  • Atomic offline receive: receive_offline does two DB writes with no transaction between them — a crash between them loses the memo but not funds. Needs a DB transaction API to fix properly.

  • No dismissal API for stuck pending receives — UI wallets have no way to let users clear a PendingReceive proof they know is gone.

Notes to reviewers

  • PendingReceive is kept intentionally separate from Pending (used for in-flight send/melt) to avoid ambiguity in state machine transitions and to make it easy to display pending offline receives distinctly in wallet UIs.
  • Database migrations update the CHECK (state IN (...)) constraint on the proof table to include PENDING_RECEIVE, preventing constraint violations at runtime.
  • The FFI layer is fully updated: ProofState::PendingReceive is exposed as a UniFFI enum variant, and all three new methods are callable from mobile/desktop consumers.
  • The CLI --offline flag requires users to explicitly opt-in to offline receives, as bearer tokens accepted offline carry a double-spend risk unless require_locked is set.

Changelog

Added

  • State::PendingReceive — new proof state for offline-received ecash awaiting final swap
  • Wallet::receive_offline(token, options) — verify a token's DLEQ proof offline and store proofs in PendingReceive state
  • Wallet::finalize_pending_receives() — sweep all PendingReceive proofs and swap them with the mint
  • Wallet::total_pending_receive_balance() — query the total pending offline balance
  • OfflineReceiveOptions struct with min_locktime and require_locked fields
  • SQLite and Postgres DB migrations to add PENDING_RECEIVE to the proof state constraint
  • FFI bindings for all of the above via cdk-ffi
  • cdk-cli receive --offline flag
  • cdk-cli finalize-receives subcommand

Checklist

  • followed the code style guidelines
  • ran just quick-check before committing
  • wallet API changes are reflected in FFI bindings

@TheMhv TheMhv left a comment

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.

You need to fix formatting and some errors that you can find running just final-check command.

Can you implement some integration test for this receive_offline function too.

And please, return the wallet tests

Comment thread crates/cdk-common/src/wallet/mod.rs
Comment thread crates/cdk/src/wallet/receive/mod.rs Outdated
Comment thread crates/cdk/src/wallet/receive/mod.rs Outdated
Comment thread crates/cdk/src/wallet/receive/mod.rs Outdated
Comment thread crates/cdk/src/wallet/receive/mod.rs Outdated
@github-project-automation github-project-automation Bot moved this from Backlog to In progress in CDK May 15, 2026
@GEET3001 GEET3001 force-pushed the feat/offline-receive branch 2 times, most recently from 6ebe70a to 8c736fd Compare May 26, 2026 13:22
@codecov

codecov Bot commented May 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 26.19048% with 62 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.43%. Comparing base (bfb5b8d) to head (98bbd6e).

Files with missing lines Patch % Lines
crates/cdk-ffi/src/wallet.rs 0.00% 22 Missing ⚠️
crates/cdk-ffi/src/types/wallet.rs 0.00% 15 Missing ⚠️
crates/cdk/src/wallet/receive/mod.rs 68.96% 9 Missing ⚠️
crates/cdk-ffi/src/wallet_trait.rs 0.00% 6 Missing ⚠️
crates/cdk-common/src/wallet/saga/receive.rs 0.00% 2 Missing ⚠️
crates/cdk-ffi/src/types/proof.rs 0.00% 2 Missing ⚠️
crates/cdk/src/wallet/balance.rs 0.00% 2 Missing ⚠️
crates/cdk/src/wallet/proofs.rs 0.00% 2 Missing ⚠️
crates/cdk-common/src/database/mint/test/proofs.rs 0.00% 1 Missing ⚠️
crates/cdk-common/src/wallet/saga/mod.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1931      +/-   ##
==========================================
- Coverage   71.48%   71.43%   -0.05%     
==========================================
  Files         356      356              
  Lines       73857    73940      +83     
==========================================
+ Hits        52798    52821      +23     
- Misses      21059    21119      +60     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread crates/cdk-common/src/wallet/mod.rs Outdated
Comment thread crates/cdk/src/wallet/receive/mod.rs Outdated
@crodas crodas self-requested a review May 27, 2026 15:34
@GEET3001 GEET3001 force-pushed the feat/offline-receive branch 5 times, most recently from 0b4239f to 3841334 Compare May 30, 2026 11:04
@GEET3001

GEET3001 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Integration tests added (as requested by @TheMhv)

Added two integration tests in crates/cdk-integration-tests/tests/integration_tests_pure.rs:

test_receive_offline_pending_then_finalize — full lifecycle test: token verified offline via DLEQ → stored as PendingReceive → finalized online via finalize_pending_receives() → spendable balance updated
test_receive_offline_rejects_token_without_dleq — security test: tokens without DLEQ proofs are rejected with DleqProofNotProvided
Both pass locally (CDK_TEST_DB_TYPE=memory).

Note on dependencies: This PR's offline path relies on keysets being available in the local cache. The correct behavior when the wallet is truly offline (no cached keysets) depends on the metadata cache improvements being worked on in the related issue. Once those land, receive_offline will work seamlessly without any prior online session

@GEET3001 GEET3001 force-pushed the feat/offline-receive branch 3 times, most recently from 401cc38 to e9d0499 Compare June 1, 2026 18:39
@ye0man ye0man moved this from In progress to Needs Review in CDK Jun 2, 2026
@ye0man ye0man added this to the 0.18.0 milestone Jun 2, 2026
@GEET3001 GEET3001 force-pushed the feat/offline-receive branch 5 times, most recently from 8801175 to 8b03dfb Compare June 9, 2026 11:58
@GEET3001

GEET3001 commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

just a heads up, it looks like the recent CI checks are failing due to a runner infrastructure issue. The logs for the Quick Check and Binding tests are all throwing a No space left on device (os error 28) error, which means the runner's disk is currently full.

Let me know when the runner has been cleared and I can re-trigger the checks, or feel free to re-trigger them on your end

@GEET3001 GEET3001 force-pushed the feat/offline-receive branch 2 times, most recently from eb799db to 27064ef Compare June 11, 2026 04:55
@GEET3001 GEET3001 force-pushed the feat/offline-receive branch 3 times, most recently from 30a8a0f to 90fbb30 Compare June 13, 2026 17:57

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified findings approved for disclosure:

  • Export receive_offline and finalize_pending_receives on the UniFFI Wallet (high) - FFI consumers cannot use the new offline receive lifecycle through the exported Wallet type, leaving the new public wallet functionality unavailable to Swift/Kotlin bindings.
    Unanchored locations included in summary:
    • crates/cdk-ffi/src/wallet.rs:189

@GEET3001 GEET3001 force-pushed the feat/offline-receive branch 2 times, most recently from 0a09d14 to 77932e6 Compare June 17, 2026 07:15

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified findings approved for disclosure:

  • Finalizing all offline receives in one swap can delete unrelated valid pending tokens (high) - A single bad/double-spent offline receive can cause unrelated valid pending offline tokens to be deleted from the wallet, resulting in loss of recoverability for those funds from local wallet state.
    Unanchored locations included in summary:
    • crates/cdk/src/wallet/receive/saga/mod.rs:312
    • crates/cdk/src/wallet/receive/saga/compensation.rs:40

Comment thread crates/cdk/src/wallet/receive/mod.rs
@GEET3001 GEET3001 force-pushed the feat/offline-receive branch 3 times, most recently from d851d2b to 705c7c6 Compare June 17, 2026 09:43

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified findings approved for disclosure:

  • Finalizing a rejected offline receive deletes the stored PendingReceive proof (medium) - An offline receive that was accepted and persisted can be silently erased during finalization if the mint definitively rejects the swap, leaving no local record of the token or failure.
  • Offline receive rejects valid tokens from inactive keysets (medium) - Offline receive rejects valid tokens from inactive/rotated keysets even though online receive can decode and redeem them.

Comment thread crates/cdk/src/wallet/receive/mod.rs
Comment thread crates/cdk/src/wallet/receive/mod.rs
Comment thread crates/cdk/src/wallet/receive/mod.rs
@GEET3001 GEET3001 force-pushed the feat/offline-receive branch 2 times, most recently from 84844c9 to dea1e36 Compare June 17, 2026 10:39

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified findings approved for disclosure:

  • Offline receive finalization loses token memo and splits one token into per-proof transactions (medium) - Offline-received tokens lose their memo/grouping when finalized; multi-proof tokens create multiple context-less incoming transaction history entries instead of one coherent receive.

Comment thread crates/cdk/src/wallet/receive/mod.rs
@GEET3001 GEET3001 force-pushed the feat/offline-receive branch from dea1e36 to 393e6fd Compare June 17, 2026 12:36
@GEET3001 GEET3001 force-pushed the feat/offline-receive branch from 393e6fd to 98bbd6e Compare June 18, 2026 13:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs Review

Development

Successfully merging this pull request may close these issues.

Wallet handle receive when offline

4 participants