Skip to content

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

Open
GEET3001 wants to merge 2 commits into
cashubtc:mainfrom
GEET3001:feat/offline-receive
Open

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

Conversation

@GEET3001
Copy link
Copy Markdown

@GEET3001 GEET3001 commented Apr 23, 2026

Closes #1927

Implements offline receiving capability for the CDK wallet:

  • Adds PendingReceive state to the State enum in cashu/nut07
  • Adds OfflineReceiveOptions struct (min_locktime, allowed_mints, 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, mint restrictions, P2PK)
    • Stores verified proofs in PendingReceive state in the database
  • Adds finalize_pending_receives() to swap pending proofs with the mint
  • Adds DB migrations for SQLite and Postgres to recognise the new state
  • Updates FFI layer (cdk-ffi) to expose the new methods and ProofState variant
  • Handles PendingReceive in all exhaustive match sites (test utils, FFI)

Description

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 to CDK.

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

  • min_locktime — the minimum locktime required on the token
  • allowed_mints — a list of mints the token must originate from
  • require_locked — whether the token must be P2PK locked

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 and recorded in the wallet's transaction list. Wallets should display these transactions with a warning indicating that they must be swapped for final settlement.

A second function, finalize_pending_receives(), checks for all proofs in PendingReceive state and executes the swaps with the mint, converting them to fully spendable proofs. This should be called whenever the wallet comes back online.


Notes to the 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 for both SQLite and Postgres update the CHECK (state IN (...)) constraint on the proof table to include PENDING_RECEIVE, preventing constraint violations at runtime.
  • The FFI layer (cdk-ffi) is fully updated: ProofState::PendingReceive is exposed as a UniFFI enum variant, and both receive_offline and finalize_pending_receives are implemented in wallet_trait.rs and callable from mobile/desktop consumers.

Suggested CHANGELOG Updates

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
  • OfflineReceiveOptions struct with min_locktime, allowed_mints, 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

Checklist

  • I followed the code style guidelines
  • I ran just quick-check before committing
  • If the Wallet API was modified (added/removed/changed), I have reflected those changes in the FFI bindings (crates/cdk-ffi)

…-12)

Implements offline receiving capability for the CDK wallet:

- Adds `PendingReceive` state to the `State` enum in cashu/nut07
- Adds `OfflineReceiveOptions` struct (min_locktime, allowed_mints, 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, mint restrictions, P2PK)
  - Stores verified proofs in `PendingReceive` state in the database
- Adds `finalize_pending_receives()` to swap pending proofs with the mint
- Adds DB migrations for SQLite and Postgres to recognise the new state
- Updates FFI layer (cdk-ffi) to expose the new methods and ProofState variant
- Handles `PendingReceive` in all exhaustive match sites (test utils, FFI)
@github-project-automation github-project-automation Bot moved this to Backlog in CDK Apr 23, 2026
Copy link
Copy Markdown
Contributor

@TheMhv TheMhv left a comment

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

}

#[cfg(test)]
mod tests {
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.

Why you removed those tests?

}

let keysets_info = self.load_mint_keysets().await?;
use cdk_common::ProofsMethods;
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.

Where it is used? I think that can be removed

Comment on lines +206 to +214
} else {
return Err(Error::LocktimeNotProvided);
}
} else {
return Err(Error::LocktimeNotProvided);
}
} else {
return Err(Error::LocktimeNotProvided);
}
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.

I think that those else's statement can be unified


let proofs: Proofs = proofs_info.into_iter().map(|p| p.proof).collect();

self.receive_proofs(proofs, ReceiveOptions::default(), None, None)
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 was using a default and null values as a argument. This is correct?

Comment on lines +159 to +168
if opts.require_dleq {
ensure_cdk!(proof.dleq.is_some(), Error::DleqProofNotProvided);
}

if proof.dleq.is_some() {
let keys = self.load_keyset_keys(proof.keyset_id).await?;
let key = keys.amount_key(proof.amount).ok_or(Error::AmountKey)?;
proof.verify_dleq(key)?;
}

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.

Since the DLEQ proof is aways required for offline reception, this verification needs to aways run on this function, those if's statements can be removed.

@github-project-automation github-project-automation Bot moved this from Backlog to In progress in CDK May 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

Wallet handle receive when offline

2 participants