diff --git a/crates/cashu/src/nuts/nut07.rs b/crates/cashu/src/nuts/nut07.rs index 88cc228746..57277f1a82 100644 --- a/crates/cashu/src/nuts/nut07.rs +++ b/crates/cashu/src/nuts/nut07.rs @@ -38,6 +38,8 @@ pub enum State { Reserved, /// Pending spent (i.e., spent but not yet swapped by receiver) PendingSpent, + /// Pending receive (i.e. offline received but not yet swapped) + PendingReceive, } impl fmt::Display for State { @@ -48,6 +50,7 @@ impl fmt::Display for State { Self::Pending => "PENDING", Self::Reserved => "RESERVED", Self::PendingSpent => "PENDING_SPENT", + Self::PendingReceive => "PENDING_RECEIVE", }; write!(f, "{s}") @@ -64,6 +67,7 @@ impl FromStr for State { "PENDING" => Ok(Self::Pending), "RESERVED" => Ok(Self::Reserved), "PENDING_SPENT" => Ok(Self::PendingSpent), + "PENDING_RECEIVE" => Ok(Self::PendingReceive), _ => Err(Error::UnknownState), } } diff --git a/crates/cdk-common/src/database/mint/test/proofs.rs b/crates/cdk-common/src/database/mint/test/proofs.rs index 6b706373c2..b89b7c36c0 100644 --- a/crates/cdk-common/src/database/mint/test/proofs.rs +++ b/crates/cdk-common/src/database/mint/test/proofs.rs @@ -582,7 +582,8 @@ where | State::Reserved | State::Pending | State::Spent - | State::PendingSpent => {} + | State::PendingSpent + | State::PendingReceive => {} } } // It's OK if state is None for some implementations diff --git a/crates/cdk-common/src/wallet/mod.rs b/crates/cdk-common/src/wallet/mod.rs index 8d39a87370..4b969ddec9 100644 --- a/crates/cdk-common/src/wallet/mod.rs +++ b/crates/cdk-common/src/wallet/mod.rs @@ -358,6 +358,19 @@ pub struct ReceiveOptions { pub metadata: HashMap, } +/// Offline Receive options +#[derive(Debug, Clone, Default)] +pub struct OfflineReceiveOptions { + /// Require the token to contain DLEQ proofs + pub require_dleq: bool, + /// List of trusted mint URLs (if empty, all mints are accepted) + pub trusted_mints: Vec, + /// Optional minimum locktime required for the token + pub minimum_locktime: Option, + /// Require the token to be P2PK locked + pub require_locked: bool, +} + /// Send Kind #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum SendKind { @@ -816,6 +829,16 @@ pub trait Wallet: Send + Sync { token: Option, ) -> Result; + /// Receive an encoded token offline without contacting the mint + async fn receive_offline( + &self, + encoded_token: &str, + options: OfflineReceiveOptions, + ) -> Result; + + /// Finalize pending offline receives by attempting to swap them + async fn finalize_pending_receives(&self) -> Result; + /// Prepare a send transaction async fn prepare_send( &self, @@ -1040,115 +1063,3 @@ pub struct P2PKSigningKey { /// Created time pub created_time: u64, } - -#[cfg(test)] -mod tests { - use super::*; - use crate::nuts::Id; - use crate::secret::Secret; - - #[test] - fn test_transaction_id_from_hex() { - let hex_str = "a1b2c3d4e5f60718293a0b1c2d3e4f506172839a0b1c2d3e4f506172839a0b1c"; - let transaction_id = TransactionId::from_hex(hex_str).unwrap(); - assert_eq!(transaction_id.to_string(), hex_str); - } - - #[test] - fn test_transaction_id_from_hex_empty_string() { - let hex_str = ""; - let res = TransactionId::from_hex(hex_str); - assert!(matches!(res, Err(Error::InvalidTransactionId))); - } - - #[test] - fn test_transaction_id_from_hex_longer_string() { - let hex_str = "a1b2c3d4e5f60718293a0b1c2d3e4f506172839a0b1c2d3e4f506172839a0b1ca1b2"; - let res = TransactionId::from_hex(hex_str); - assert!(matches!(res, Err(Error::InvalidTransactionId))); - } - - #[test] - fn test_matches_conditions() { - let keyset_id = Id::from_str("00deadbeef123456").unwrap(); - let proof = Proof::new( - Amount::from(64), - keyset_id, - Secret::new("test_secret"), - PublicKey::from_hex( - "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - ) - .unwrap(), - ); - - let mint_url = MintUrl::from_str("https://example.com").unwrap(); - let proof_info = - ProofInfo::new(proof, mint_url.clone(), State::Unspent, CurrencyUnit::Sat).unwrap(); - - // Test matching mint_url - assert!(proof_info.matches_conditions(&Some(mint_url.clone()), &None, &None, &None)); - assert!(!proof_info.matches_conditions( - &Some(MintUrl::from_str("https://different.com").unwrap()), - &None, - &None, - &None - )); - - // Test matching unit - assert!(proof_info.matches_conditions(&None, &Some(CurrencyUnit::Sat), &None, &None)); - assert!(!proof_info.matches_conditions(&None, &Some(CurrencyUnit::Msat), &None, &None)); - - // Test matching state - assert!(proof_info.matches_conditions(&None, &None, &Some(vec![State::Unspent]), &None)); - assert!(proof_info.matches_conditions( - &None, - &None, - &Some(vec![State::Unspent, State::Spent]), - &None - )); - assert!(!proof_info.matches_conditions(&None, &None, &Some(vec![State::Spent]), &None)); - - // Test with no conditions (should match) - assert!(proof_info.matches_conditions(&None, &None, &None, &None)); - - // Test with multiple conditions - assert!(proof_info.matches_conditions( - &Some(mint_url), - &Some(CurrencyUnit::Sat), - &Some(vec![State::Unspent]), - &None - )); - } - - #[test] - fn test_matches_conditions_with_spending_conditions() { - // This test would need to be expanded with actual SpendingConditions - // implementation, but we can test the basic case where no spending - // conditions are present - - let keyset_id = Id::from_str("00deadbeef123456").unwrap(); - let proof = Proof::new( - Amount::from(64), - keyset_id, - Secret::new("test_secret"), - PublicKey::from_hex( - "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - ) - .unwrap(), - ); - - let mint_url = MintUrl::from_str("https://example.com").unwrap(); - let proof_info = - ProofInfo::new(proof, mint_url, State::Unspent, CurrencyUnit::Sat).unwrap(); - - // Test with empty spending conditions (should match when proof has none) - assert!(proof_info.matches_conditions(&None, &None, &None, &Some(vec![]))); - - // Test with non-empty spending conditions (should not match when proof has none) - let dummy_condition = SpendingConditions::P2PKConditions { - data: SecretKey::generate().public_key(), - conditions: None, - }; - assert!(!proof_info.matches_conditions(&None, &None, &None, &Some(vec![dummy_condition]))); - } -} diff --git a/crates/cdk-ffi/src/types/proof.rs b/crates/cdk-ffi/src/types/proof.rs index a70963df3d..e61b74d233 100644 --- a/crates/cdk-ffi/src/types/proof.rs +++ b/crates/cdk-ffi/src/types/proof.rs @@ -17,6 +17,8 @@ pub enum ProofState { Spent, Reserved, PendingSpent, + /// Proofs received offline, verified via DLEQ, awaiting final swap + PendingReceive, } impl From for ProofState { @@ -27,6 +29,7 @@ impl From for ProofState { CdkState::Spent => ProofState::Spent, CdkState::Reserved => ProofState::Reserved, CdkState::PendingSpent => ProofState::PendingSpent, + CdkState::PendingReceive => ProofState::PendingReceive, } } } @@ -39,6 +42,7 @@ impl From for CdkState { ProofState::Spent => CdkState::Spent, ProofState::Reserved => CdkState::Reserved, ProofState::PendingSpent => CdkState::PendingSpent, + ProofState::PendingReceive => CdkState::PendingReceive, } } } diff --git a/crates/cdk-ffi/src/wallet.rs b/crates/cdk-ffi/src/wallet.rs index eaf71feb9e..cd061f87a3 100644 --- a/crates/cdk-ffi/src/wallet.rs +++ b/crates/cdk-ffi/src/wallet.rs @@ -450,6 +450,11 @@ impl Wallet { ProofState::Pending => self.inner.get_pending_proofs().await?, ProofState::Reserved => self.inner.get_reserved_proofs().await?, ProofState::PendingSpent => self.inner.get_pending_spent_proofs().await?, + ProofState::PendingReceive => { + self.inner + .get_proofs_by_states(vec![cdk::nuts::State::PendingReceive]) + .await? + } ProofState::Spent => { // CDK doesn't have a method to get spent proofs directly // They are removed from the database when spent diff --git a/crates/cdk-ffi/src/wallet_trait.rs b/crates/cdk-ffi/src/wallet_trait.rs index 67ca430708..96be7cdc47 100644 --- a/crates/cdk-ffi/src/wallet_trait.rs +++ b/crates/cdk-ffi/src/wallet_trait.rs @@ -546,4 +546,22 @@ impl WalletTraitDef for Wallet { let signing_key = WalletTraitDef::get_signing_key(self.inner().as_ref(), pubkey).await?; Ok(signing_key) } + + /// Receive a token offline: verify DLEQ proofs and store in PendingReceive state. + async fn receive_offline( + &self, + encoded_token: &str, + options: cdk_common::wallet::OfflineReceiveOptions, + ) -> Result { + let amount = + WalletTraitDef::receive_offline(self.inner().as_ref(), encoded_token, options).await?; + Ok(amount.into()) + } + + /// Finalize all pending receives by swapping them with the mint. + async fn finalize_pending_receives(&self) -> Result { + let amount = + WalletTraitDef::finalize_pending_receives(self.inner().as_ref()).await?; + Ok(amount.into()) + } } diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20260423000000_allow_pending_receive.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20260423000000_allow_pending_receive.sql new file mode 100644 index 0000000000..f62dda602c --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/20260423000000_allow_pending_receive.sql @@ -0,0 +1,12 @@ +-- Migration to add PENDING_RECEIVE to the proof state check constraint +-- Since the constraint is anonymous in initial migration, we drop and recreate it with a name + +-- Drop potential anonymous constraints (Postgres generates names like proof_state_check) +DO $$ +BEGIN + ALTER TABLE proof DROP CONSTRAINT IF EXISTS proof_state_check; +EXCEPTION + WHEN undefined_object THEN null; +END $$; + +ALTER TABLE proof ADD CONSTRAINT proof_state_check CHECK (state IN ('SPENT', 'UNSPENT', 'PENDING', 'RESERVED', 'PENDING_SPENT', 'PENDING_RECEIVE')); diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20260423000000_allow_pending_receive.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20260423000000_allow_pending_receive.sql new file mode 100644 index 0000000000..b774a47c75 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20260423000000_allow_pending_receive.sql @@ -0,0 +1,39 @@ +-- Create a new table with the updated CHECK constraint +CREATE TABLE IF NOT EXISTS proof_new ( + y BLOB PRIMARY KEY, + mint_url TEXT NOT NULL, + state TEXT CHECK ( state IN ('SPENT', 'UNSPENT', 'PENDING', 'RESERVED', 'PENDING_SPENT', 'PENDING_RECEIVE' ) ) NOT NULL, + spending_condition TEXT, + unit TEXT NOT NULL, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, + secret TEXT NOT NULL, + c BLOB NOT NULL, + witness TEXT, + dleq_e BLOB, + dleq_s BLOB, + dleq_r BLOB, + used_by_operation TEXT, + created_by_operation TEXT, + p2pk_e BLOB +); + +CREATE INDEX IF NOT EXISTS secret_index ON proof_new(secret); +CREATE INDEX IF NOT EXISTS state_index ON proof_new(state); +CREATE INDEX IF NOT EXISTS spending_condition_index ON proof_new(spending_condition); +CREATE INDEX IF NOT EXISTS unit_index ON proof_new(unit); +CREATE INDEX IF NOT EXISTS amount_index ON proof_new(amount); +CREATE INDEX IF NOT EXISTS mint_url_index ON proof_new(mint_url); +CREATE INDEX IF NOT EXISTS proof_used_by_operation_index ON proof_new(used_by_operation); +CREATE INDEX IF NOT EXISTS proof_created_by_operation_index ON proof_new(created_by_operation); + +-- Copy data from old proof table to new proof table +INSERT INTO proof_new (y, mint_url, state, spending_condition, unit, amount, keyset_id, secret, c, witness, dleq_e, dleq_s, dleq_r, used_by_operation, created_by_operation, p2pk_e) +SELECT y, mint_url, state, spending_condition, unit, amount, keyset_id, secret, c, witness, dleq_e, dleq_s, dleq_r, used_by_operation, created_by_operation, p2pk_e +FROM proof; + +-- Drop the old proof table +DROP TABLE proof; + +-- Rename the new proof table to proof +ALTER TABLE proof_new RENAME TO proof; diff --git a/crates/cdk/src/wallet/receive/mod.rs b/crates/cdk/src/wallet/receive/mod.rs index 0f4f858ef0..c3bf792f11 100644 --- a/crates/cdk/src/wallet/receive/mod.rs +++ b/crates/cdk/src/wallet/receive/mod.rs @@ -124,4 +124,165 @@ impl Wallet { let token_str = Token::try_from(binary_token)?.to_string(); self.receive(token_str.as_str(), opts).await } + + /// Receive an encoded token offline without contacting the mint + #[instrument(skip_all)] + pub async fn receive_offline( + &self, + encoded_token: &str, + opts: cdk_common::wallet::OfflineReceiveOptions, + ) -> Result { + let token = Token::from_str(encoded_token)?; + + let unit = token.unit().unwrap_or_default(); + ensure_cdk!(unit == self.unit, Error::UnsupportedUnit); + + let mint_url = token.mint_url()?; + ensure_cdk!(self.mint_url == mint_url, Error::IncorrectMint); + + if !opts.trusted_mints.is_empty() { + ensure_cdk!(opts.trusted_mints.contains(&mint_url), Error::IncorrectMint); + } + + if let Token::TokenV3(token) = &token { + ensure_cdk!(!token.is_multi_mint(), Error::MultiMintTokenNotSupported); + } + + let keysets_info = self.load_mint_keysets().await?; + use cdk_common::ProofsMethods; + let mut proofs = token.proofs(&keysets_info)?; + let proofs_ys = proofs.ys()?; + + let mut total_amount = Amount::ZERO; + + for proof in &mut proofs { + 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)?; + } + + if opts.require_locked { + use crate::nuts::nut10::Kind; + let secret_res: Result = proof.secret.clone().try_into(); + if let Ok(secret) = secret_res { + let is_p2pk = match secret.kind() { + Kind::P2PK => true, + _ => false, + }; + ensure_cdk!( + is_p2pk, + Error::InvalidSpendConditions("Token must be P2PK locked".to_string()) + ); + } else { + return Err(Error::InvalidSpendConditions( + "Token must be P2PK locked".to_string(), + )); + } + } + + if let Some(min_locktime) = opts.minimum_locktime { + let secret_res: Result = proof.secret.clone().try_into(); + if let Ok(secret) = secret_res { + let conditions: Result = secret + .secret_data() + .tags() + .cloned() + .unwrap_or_default() + .try_into(); + if let Ok(conditions) = conditions { + if let Some(locktime) = conditions.locktime { + ensure_cdk!( + locktime >= min_locktime, + Error::InvalidSpendConditions(format!( + "Locktime {} is less than required {}", + locktime, min_locktime + )) + ); + } else { + return Err(Error::LocktimeNotProvided); + } + } else { + return Err(Error::LocktimeNotProvided); + } + } else { + return Err(Error::LocktimeNotProvided); + } + } + + total_amount += proof.amount; + } + + use crate::nuts::State; + use crate::wallet::ProofInfo; + use cdk_common::util::unix_time; + use cdk_common::wallet::{Transaction, TransactionDirection}; + + let proofs_info = proofs + .clone() + .into_iter() + .map(|p| { + ProofInfo::new( + p, + self.mint_url.clone(), + State::PendingReceive, + self.unit.clone(), + ) + }) + .collect::, _>>()?; + + self.localstore.update_proofs(proofs_info, vec![]).await?; + + let memo = token.memo().clone(); + + self.localstore + .add_transaction(Transaction { + mint_url: self.mint_url.clone(), + direction: TransactionDirection::Incoming, + amount: total_amount, + fee: Amount::ZERO, + unit: self.unit.clone(), + ys: proofs_ys, + timestamp: unix_time(), + memo, + metadata: std::collections::HashMap::new(), + quote_id: None, + payment_request: None, + payment_proof: None, + payment_method: None, + saga_id: None, + }) + .await?; + + Ok(total_amount) + } + + /// Finalize pending offline receives by attempting to swap them + #[instrument(skip_all)] + pub async fn finalize_pending_receives(&self) -> Result { + use crate::nuts::State; + + let proofs_info = self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(self.unit.clone()), + Some(vec![State::PendingReceive]), + None, + ) + .await?; + + if proofs_info.is_empty() { + return Ok(Amount::ZERO); + } + + let proofs: Proofs = proofs_info.into_iter().map(|p| p.proof).collect(); + + self.receive_proofs(proofs, ReceiveOptions::default(), None, None) + .await + } } diff --git a/crates/cdk/src/wallet/wallet_trait.rs b/crates/cdk/src/wallet/wallet_trait.rs index 7fe880b2b1..bb0692c6e8 100644 --- a/crates/cdk/src/wallet/wallet_trait.rs +++ b/crates/cdk/src/wallet/wallet_trait.rs @@ -208,6 +208,20 @@ impl WalletTrait for super::Wallet { self.receive_proofs(proofs, options, memo, token).await } + #[instrument(skip(self, encoded_token, options))] + async fn receive_offline( + &self, + encoded_token: &str, + options: cdk_common::wallet::OfflineReceiveOptions, + ) -> Result { + self.receive_offline(encoded_token, options).await + } + + #[instrument(skip(self))] + async fn finalize_pending_receives(&self) -> Result { + self.finalize_pending_receives().await + } + #[instrument(skip(self, options))] async fn prepare_send( &self,