Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/cashu/src/nuts/nut07.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}")
Expand All @@ -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),
}
}
Expand Down
3 changes: 2 additions & 1 deletion crates/cdk-common/src/database/mint/test/proofs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 23 additions & 112 deletions crates/cdk-common/src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,19 @@ pub struct ReceiveOptions {
pub metadata: HashMap<String, String>,
}

/// 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<MintUrl>,
/// Optional minimum locktime required for the token
pub minimum_locktime: Option<u64>,
/// 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 {
Expand Down Expand Up @@ -816,6 +829,16 @@ pub trait Wallet: Send + Sync {
token: Option<String>,
) -> Result<Self::Amount, Self::Error>;

/// Receive an encoded token offline without contacting the mint
async fn receive_offline(
&self,
encoded_token: &str,
options: OfflineReceiveOptions,
) -> Result<Self::Amount, Self::Error>;

/// Finalize pending offline receives by attempting to swap them
async fn finalize_pending_receives(&self) -> Result<Self::Amount, Self::Error>;

/// Prepare a send transaction
async fn prepare_send(
&self,
Expand Down Expand Up @@ -1040,115 +1063,3 @@ pub struct P2PKSigningKey {
/// Created time
pub created_time: u64,
}

#[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?

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])));
}
}
4 changes: 4 additions & 0 deletions crates/cdk-ffi/src/types/proof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub enum ProofState {
Spent,
Reserved,
PendingSpent,
/// Proofs received offline, verified via DLEQ, awaiting final swap
PendingReceive,
}

impl From<CdkState> for ProofState {
Expand All @@ -27,6 +29,7 @@ impl From<CdkState> for ProofState {
CdkState::Spent => ProofState::Spent,
CdkState::Reserved => ProofState::Reserved,
CdkState::PendingSpent => ProofState::PendingSpent,
CdkState::PendingReceive => ProofState::PendingReceive,
}
}
}
Expand All @@ -39,6 +42,7 @@ impl From<ProofState> for CdkState {
ProofState::Spent => CdkState::Spent,
ProofState::Reserved => CdkState::Reserved,
ProofState::PendingSpent => CdkState::PendingSpent,
ProofState::PendingReceive => CdkState::PendingReceive,
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions crates/cdk-ffi/src/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions crates/cdk-ffi/src/wallet_trait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self::Amount, Self::Error> {
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<Self::Amount, Self::Error> {
let amount =
WalletTraitDef::finalize_pending_receives(self.inner().as_ref()).await?;
Ok(amount.into())
}
}
Original file line number Diff line number Diff line change
@@ -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'));
Original file line number Diff line number Diff line change
@@ -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;
Loading