Skip to content

Commit 32603f9

Browse files
committed
feat(wallet): add offline ecash receiving with DLEQ verification (NUT-12)
1 parent c6957ac commit 32603f9

22 files changed

Lines changed: 1106 additions & 11 deletions

File tree

crates/cashu/src/nuts/nut07.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ pub enum State {
3737
Reserved,
3838
/// Pending spent (i.e., spent but not yet swapped by receiver)
3939
PendingSpent,
40+
/// Pending receive (i.e. offline received but not yet swapped)
41+
PendingReceive,
4042
}
4143

4244
impl fmt::Display for State {
@@ -47,6 +49,7 @@ impl fmt::Display for State {
4749
Self::Pending => "PENDING",
4850
Self::Reserved => "RESERVED",
4951
Self::PendingSpent => "PENDING_SPENT",
52+
Self::PendingReceive => "PENDING_RECEIVE",
5053
};
5154

5255
write!(f, "{s}")
@@ -63,6 +66,7 @@ impl FromStr for State {
6366
"PENDING" => Ok(Self::Pending),
6467
"RESERVED" => Ok(Self::Reserved),
6568
"PENDING_SPENT" => Ok(Self::PendingSpent),
69+
"PENDING_RECEIVE" => Ok(Self::PendingReceive),
6670
_ => Err(Error::UnknownState),
6771
}
6872
}

crates/cdk-cli/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ enum Commands {
9696
Transfer(sub_commands::transfer::TransferSubCommand),
9797
/// Reclaim pending proofs that are no longer pending
9898
CheckPending,
99+
/// Finalize pending receives by contacting the mint and swapping
100+
FinalizeReceives(sub_commands::finalize_receives::FinalizeReceivesSubCommand),
99101
/// Check incoming payments for stored payment requests.
100102
CheckRequests,
101103
/// View mint info
@@ -298,6 +300,10 @@ async fn main() -> Result<()> {
298300
Commands::CheckPending => {
299301
sub_commands::check_pending::check_pending(&wallet_repository).await
300302
}
303+
Commands::FinalizeReceives(sub_command_args) => {
304+
sub_commands::finalize_receives::finalize_receives(&wallet_repository, sub_command_args)
305+
.await
306+
}
301307
Commands::CheckRequests => {
302308
sub_commands::check_requests::check_requests(&wallet_repository).await
303309
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
use anyhow::Result;
2+
use cdk::wallet::WalletRepository;
3+
4+
#[derive(clap::Args, Clone)]
5+
pub struct FinalizeReceivesSubCommand {
6+
/// Specify the mint url
7+
#[arg(short, long)]
8+
pub mint_url: Option<String>,
9+
}
10+
11+
pub async fn finalize_receives(
12+
wallet_repository: &WalletRepository,
13+
sub_command_args: &FinalizeReceivesSubCommand,
14+
) -> Result<()> {
15+
let wallets = wallet_repository.get_wallets().await;
16+
17+
let mut found_mint = false;
18+
for wallet in wallets.iter() {
19+
if let Some(mint_url) = &sub_command_args.mint_url {
20+
if wallet.mint_url.to_string() != *mint_url {
21+
continue;
22+
}
23+
}
24+
found_mint = true;
25+
26+
match wallet.finalize_pending_receives().await {
27+
Ok(amount) => {
28+
println!(
29+
"Finalized pending receives for {}: {} {}",
30+
wallet.mint_url, amount, wallet.unit
31+
);
32+
}
33+
Err(e) => {
34+
println!("Error finalizing receives for {}: {}", wallet.mint_url, e);
35+
}
36+
}
37+
}
38+
39+
if !found_mint {
40+
if let Some(mint_url) = &sub_command_args.mint_url {
41+
println!("No wallet found for mint: {}", mint_url);
42+
} else {
43+
println!("No wallets found.");
44+
}
45+
}
46+
47+
Ok(())
48+
}

crates/cdk-cli/src/sub_commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod check_requests;
77
pub mod create_request;
88
pub mod decode_request;
99
pub mod decode_token;
10+
pub mod finalize_receives;
1011
pub mod generate_public_key;
1112
pub mod get_public_keys;
1213
pub mod list_mint_proofs;

crates/cdk-cli/src/sub_commands/receive.rs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ pub struct ReceiveSubCommand {
3737
/// Allow receiving from untrusted mints (mints not already in the wallet)
3838
#[arg(long, default_value = "false")]
3939
allow_untrusted: bool,
40+
/// Only accept the token offline and place it into PendingReceive state
41+
#[arg(long, default_value = "false")]
42+
offline: bool,
4043
}
4144

4245
pub async fn receive(
@@ -72,6 +75,7 @@ pub async fn receive(
7275
&signing_keys,
7376
&sub_command_args.preimage,
7477
sub_command_args.allow_untrusted,
78+
sub_command_args.offline,
7579
unit,
7680
)
7781
.await?
@@ -114,6 +118,7 @@ pub async fn receive(
114118
&signing_keys,
115119
&sub_command_args.preimage,
116120
sub_command_args.allow_untrusted,
121+
sub_command_args.offline,
117122
unit,
118123
)
119124
.await
@@ -142,6 +147,7 @@ async fn receive_token(
142147
signing_keys: &[SecretKey],
143148
preimage: &[String],
144149
allow_untrusted: bool,
150+
offline: bool,
145151
unit: &CurrencyUnit,
146152
) -> Result<Amount> {
147153
let token: Token = Token::from_str(token_str)?;
@@ -162,15 +168,24 @@ async fn receive_token(
162168
// Get or create wallet for the token's mint
163169
let wallet = get_or_create_wallet(wallet_repository, &mint_url, unit).await?;
164170

165-
// Create receive options
166-
let receive_options = ReceiveOptions {
167-
p2pk_signing_keys: signing_keys.to_vec(),
168-
preimages: preimage.to_vec(),
169-
..Default::default()
170-
};
171-
172-
let received = wallet.receive(token_str, receive_options).await?;
173-
Ok(received)
171+
if offline {
172+
let options = cdk_common::wallet::OfflineReceiveOptions {
173+
minimum_locktime: None,
174+
require_locked: false,
175+
};
176+
let received = wallet.receive_offline(token_str, options).await?;
177+
Ok(received)
178+
} else {
179+
// Create receive options
180+
let receive_options = ReceiveOptions {
181+
p2pk_signing_keys: signing_keys.to_vec(),
182+
preimages: preimage.to_vec(),
183+
..Default::default()
184+
};
185+
186+
let received = wallet.receive(token_str, receive_options).await?;
187+
Ok(received)
188+
}
174189
}
175190

176191
/// Receive tokens sent to nostr pubkey via dm

crates/cdk-common/src/database/mint/test/proofs.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,8 @@ where
582582
| State::Reserved
583583
| State::Pending
584584
| State::Spent
585-
| State::PendingSpent => {}
585+
| State::PendingSpent
586+
| State::PendingReceive => {}
586587
}
587588
}
588589
// It's OK if state is None for some implementations

crates/cdk-common/src/wallet/mod.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,22 @@ impl fmt::Debug for ReceiveOptions {
486486
}
487487
}
488488

489+
/// Offline Receive options
490+
///
491+
/// DLEQ proof verification is always performed for offline receives
492+
/// since it is the only way to verify token authenticity without
493+
/// contacting the mint.
494+
///
495+
/// The wallet is already bound to a specific mint URL, so mint
496+
/// filtering is handled automatically.
497+
#[derive(Debug, Clone, Default)]
498+
pub struct OfflineReceiveOptions {
499+
/// Optional minimum locktime required for the token
500+
pub minimum_locktime: Option<u64>,
501+
/// Require the token to be P2PK locked
502+
pub require_locked: bool,
503+
}
504+
489505
/// Send Kind
490506
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
491507
pub enum SendKind {
@@ -855,6 +871,12 @@ pub trait Wallet: Send + Sync {
855871
/// Total pending balance of the wallet
856872
async fn total_pending_balance(&self) -> Result<Self::Amount, Self::Error>;
857873

874+
/// Total pending-receive balance of the wallet
875+
///
876+
/// Proofs in this state have been DLEQ-verified offline but not yet
877+
/// swapped with the mint. Call `finalize_pending_receives` to settle them.
878+
async fn total_pending_receive_balance(&self) -> Result<Self::Amount, Self::Error>;
879+
858880
/// Total reserved balance of the wallet
859881
async fn total_reserved_balance(&self) -> Result<Self::Amount, Self::Error>;
860882

@@ -967,6 +989,16 @@ pub trait Wallet: Send + Sync {
967989
token: Option<String>,
968990
) -> Result<Self::Amount, Self::Error>;
969991

992+
/// Receive an encoded token offline without contacting the mint
993+
async fn receive_offline(
994+
&self,
995+
encoded_token: &str,
996+
options: OfflineReceiveOptions,
997+
) -> Result<Self::Amount, Self::Error>;
998+
999+
/// Finalize pending offline receives by attempting to swap them
1000+
async fn finalize_pending_receives(&self) -> Result<Self::Amount, Self::Error>;
1001+
9701002
/// Prepare a send transaction
9711003
async fn prepare_send(
9721004
&self,

crates/cdk-common/src/wallet/saga/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ impl WalletSagaState {
8484
WalletSagaState::Receive(s) => match s {
8585
ReceiveSagaState::ProofsPending => "proofs_pending",
8686
ReceiveSagaState::SwapRequested => "swap_requested",
87+
ReceiveSagaState::OfflinePendingReceive => "offline_pending_receive",
8788
},
8889
WalletSagaState::Swap(s) => match s {
8990
SwapSagaState::ProofsReserved => "proofs_reserved",

crates/cdk-common/src/wallet/saga/receive.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,19 @@ pub enum ReceiveSagaState {
1313
ProofsPending,
1414
/// Swap request sent to mint, awaiting signatures for new proofs
1515
SwapRequested,
16+
/// Proofs accepted offline (DLEQ-verified), stored as PendingReceive awaiting
17+
/// `finalize_pending_receives`. This saga holds the original token string so
18+
/// the memo and proof grouping survive until the wallet comes back online.
19+
/// Recovery must NOT compensate this state — `finalize_pending_receives` owns it.
20+
OfflinePendingReceive,
1621
}
1722

1823
impl std::fmt::Display for ReceiveSagaState {
1924
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2025
match self {
2126
ReceiveSagaState::ProofsPending => write!(f, "proofs_pending"),
2227
ReceiveSagaState::SwapRequested => write!(f, "swap_requested"),
28+
ReceiveSagaState::OfflinePendingReceive => write!(f, "offline_pending_receive"),
2329
}
2430
}
2531
}
@@ -30,6 +36,7 @@ impl std::str::FromStr for ReceiveSagaState {
3036
match s {
3137
"proofs_pending" => Ok(ReceiveSagaState::ProofsPending),
3238
"swap_requested" => Ok(ReceiveSagaState::SwapRequested),
39+
"offline_pending_receive" => Ok(ReceiveSagaState::OfflinePendingReceive),
3340
_ => Err(Error::InvalidOperationState),
3441
}
3542
}

crates/cdk-ffi/src/types/proof.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ pub enum ProofState {
1717
Spent,
1818
Reserved,
1919
PendingSpent,
20+
/// Proofs received offline, verified via DLEQ, awaiting final swap
21+
PendingReceive,
2022
}
2123

2224
impl From<CdkState> for ProofState {
@@ -27,6 +29,7 @@ impl From<CdkState> for ProofState {
2729
CdkState::Spent => ProofState::Spent,
2830
CdkState::Reserved => ProofState::Reserved,
2931
CdkState::PendingSpent => ProofState::PendingSpent,
32+
CdkState::PendingReceive => ProofState::PendingReceive,
3033
}
3134
}
3235
}
@@ -39,6 +42,7 @@ impl From<ProofState> for CdkState {
3942
ProofState::Spent => CdkState::Spent,
4043
ProofState::Reserved => CdkState::Reserved,
4144
ProofState::PendingSpent => CdkState::PendingSpent,
45+
ProofState::PendingReceive => CdkState::PendingReceive,
4246
}
4347
}
4448
}

0 commit comments

Comments
 (0)