implement nip61 nutzap redemption
This commit is contained in:
Generated
-14
@@ -1329,19 +1329,6 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cdk-nostr"
|
|
||||||
version = "0.18.0"
|
|
||||||
source = "git+https://github.com/cashubtc/cdk#578e719fbf683871a8480961627700f01f3ee7ee"
|
|
||||||
dependencies = [
|
|
||||||
"nostr",
|
|
||||||
"nostr-sdk",
|
|
||||||
"thiserror 2.0.21",
|
|
||||||
"tokio",
|
|
||||||
"tokio-util",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cdk-redb"
|
name = "cdk-redb"
|
||||||
version = "0.18.0"
|
version = "0.18.0"
|
||||||
@@ -6133,7 +6120,6 @@ version = "2.0.0-alpha3"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cdk",
|
"cdk",
|
||||||
"cdk-nostr",
|
|
||||||
"cdk-redb",
|
"cdk-redb",
|
||||||
"common",
|
"common",
|
||||||
"flume 0.11.1",
|
"flume 0.11.1",
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ rand = { version = "0.10", default-features = false, features = [ "std", "sys_rn
|
|||||||
# storage: pure Rust, avoids the libsqlite3-sys links clash with nostr-sqlite.
|
# storage: pure Rust, avoids the libsqlite3-sys links clash with nostr-sqlite.
|
||||||
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
|
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
|
||||||
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
|
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
|
||||||
cdk-nostr = { git = "https://github.com/cashubtc/cdk", default-features = false }
|
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.44"
|
||||||
|
|||||||
@@ -23,4 +23,3 @@ tokio = { version = "1", default-features = false, features = [ "rt" ] }
|
|||||||
|
|
||||||
cdk.workspace = true
|
cdk.workspace = true
|
||||||
cdk-redb.workspace = true
|
cdk-redb.workspace = true
|
||||||
cdk-nostr.workspace = true
|
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ pub(crate) async fn build_history_event<S>(
|
|||||||
amount: u64,
|
amount: u64,
|
||||||
created: EventId,
|
created: EventId,
|
||||||
destroyed: &[EventId],
|
destroyed: &[EventId],
|
||||||
|
redeemed: &[EventId],
|
||||||
|
sender: Option<PublicKey>,
|
||||||
) -> Result<Event, Error>
|
) -> Result<Event, Error>
|
||||||
where
|
where
|
||||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||||
@@ -200,11 +202,50 @@ where
|
|||||||
"created".to_string(),
|
"created".to_string(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
let mut tags: Vec<Tag> = redeemed
|
||||||
|
.iter()
|
||||||
|
.map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()]))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Some(sender) = sender {
|
||||||
|
tags.push(Tag::custom("p", [sender.to_hex()]));
|
||||||
|
}
|
||||||
|
|
||||||
let content = signer
|
let content = signer
|
||||||
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
|
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content)
|
Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content)
|
||||||
|
.tags(tags)
|
||||||
|
.finalize_async(signer)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn build_nutzap_info_event<S>(
|
||||||
|
signer: &S,
|
||||||
|
mints: &[Url],
|
||||||
|
relays: &[&str],
|
||||||
|
wallet_pubkey: PublicKey,
|
||||||
|
) -> Result<Event, Error>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
|
let mut tags: Vec<Tag> = relays
|
||||||
|
.iter()
|
||||||
|
.map(|relay| Tag::custom("relay", [relay.to_string()]))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for mint in mints {
|
||||||
|
tags.push(Tag::custom("mint", [mint.to_string(), "sat".to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(Tag::custom(
|
||||||
|
"pubkey",
|
||||||
|
[format!("02{}", wallet_pubkey.to_hex())],
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(EventBuilder::new(Kind::Custom(10019), "")
|
||||||
|
.tags(tags)
|
||||||
.finalize_async(signer)
|
.finalize_async(signer)
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|||||||
+239
-10
@@ -7,9 +7,9 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::{Error, anyhow};
|
||||||
use cdk::mint_url::MintUrl;
|
use cdk::mint_url::MintUrl;
|
||||||
use cdk::nuts::CurrencyUnit;
|
use cdk::nuts::{CurrencyUnit, Proof, Proofs, SpendingConditions};
|
||||||
use cdk::wallet::{Wallet, WalletBuilder};
|
use cdk::wallet::{Wallet, WalletBuilder};
|
||||||
use cdk_redb::WalletRedbDatabase;
|
use cdk_redb::WalletRedbDatabase;
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
@@ -52,6 +52,12 @@ pub struct WalletRegistry {
|
|||||||
/// Token events killed by a kind 5 delete or superseded by a `del` chain
|
/// Token events killed by a kind 5 delete or superseded by a `del` chain
|
||||||
stale: HashSet<EventId>,
|
stale: HashSet<EventId>,
|
||||||
quotes: Vec<MintQuoteEntry>,
|
quotes: Vec<MintQuoteEntry>,
|
||||||
|
/// NIP-61 nutzap events already claimed, recorded by our 7376 events
|
||||||
|
redeemed: HashSet<EventId>,
|
||||||
|
/// Nutzaps seen but not yet redeemed (wallet was not ready)
|
||||||
|
pending_nutzaps: Vec<Event>,
|
||||||
|
/// Nutzaps with a redemption job in flight
|
||||||
|
redeeming: HashSet<EventId>,
|
||||||
store: Option<Arc<WalletRedbDatabase>>,
|
store: Option<Arc<WalletRedbDatabase>>,
|
||||||
wallets: HashMap<MintUrl, Wallet>,
|
wallets: HashMap<MintUrl, Wallet>,
|
||||||
loading: bool,
|
loading: bool,
|
||||||
@@ -67,6 +73,8 @@ struct LoadedState {
|
|||||||
wallet: Option<keys::WalletData>,
|
wallet: Option<keys::WalletData>,
|
||||||
tokens: HashMap<EventId, TokenEvent>,
|
tokens: HashMap<EventId, TokenEvent>,
|
||||||
stale: HashSet<EventId>,
|
stale: HashSet<EventId>,
|
||||||
|
redeemed: HashSet<EventId>,
|
||||||
|
nutzaps: Vec<Event>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WalletRegistry {
|
impl WalletRegistry {
|
||||||
@@ -95,6 +103,9 @@ impl WalletRegistry {
|
|||||||
tokens: HashMap::new(),
|
tokens: HashMap::new(),
|
||||||
stale: HashSet::new(),
|
stale: HashSet::new(),
|
||||||
quotes: Vec::new(),
|
quotes: Vec::new(),
|
||||||
|
redeemed: HashSet::new(),
|
||||||
|
pending_nutzaps: Vec::new(),
|
||||||
|
redeeming: HashSet::new(),
|
||||||
store: None,
|
store: None,
|
||||||
wallets: HashMap::new(),
|
wallets: HashMap::new(),
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -316,9 +327,18 @@ impl WalletRegistry {
|
|||||||
|
|
||||||
let publish = cx.background_spawn(async move {
|
let publish = cx.background_spawn(async move {
|
||||||
let me = signer.get_public_key_async().await?;
|
let me = signer.get_public_key_async().await?;
|
||||||
|
|
||||||
let event = keys::build_wallet_event(&signer, &me, &privkey, &mints).await?;
|
let event = keys::build_wallet_event(&signer, &me, &privkey, &mints).await?;
|
||||||
client.send_event(&event).await?;
|
client.send_event(&event).await?;
|
||||||
client.database().save_event(&event).await?;
|
|
||||||
|
let info = keys::build_nutzap_info_event(
|
||||||
|
&signer,
|
||||||
|
&mints,
|
||||||
|
&state::BOOTSTRAP_RELAYS,
|
||||||
|
Keys::new(privkey.clone()).public_key(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
client.send_event(&info).await?;
|
||||||
|
|
||||||
Ok::<_, Error>(keys::WalletData {
|
Ok::<_, Error>(keys::WalletData {
|
||||||
event_id: event.id,
|
event_id: event.id,
|
||||||
@@ -362,6 +382,9 @@ impl WalletRegistry {
|
|||||||
self.tokens.clear();
|
self.tokens.clear();
|
||||||
self.stale.clear();
|
self.stale.clear();
|
||||||
self.quotes.clear();
|
self.quotes.clear();
|
||||||
|
self.redeemed.clear();
|
||||||
|
self.pending_nutzaps.clear();
|
||||||
|
self.redeeming.clear();
|
||||||
self.store = None;
|
self.store = None;
|
||||||
self.wallets.clear();
|
self.wallets.clear();
|
||||||
self.loading = false;
|
self.loading = false;
|
||||||
@@ -392,14 +415,22 @@ impl WalletRegistry {
|
|||||||
while let Some(notification) = notifications.next().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
if let ClientNotification::Message { message, .. } = notification
|
if let ClientNotification::Message { message, .. } = notification
|
||||||
&& let RelayMessage::Event { event, .. } = *message
|
&& let RelayMessage::Event { event, .. } = *message
|
||||||
&& me == Some(event.pubkey)
|
{
|
||||||
|
let ours = me == Some(event.pubkey);
|
||||||
|
let wallet_event = ours
|
||||||
&& (matches!(
|
&& (matches!(
|
||||||
event.kind,
|
event.kind,
|
||||||
Kind::CashuWallet
|
Kind::CashuWallet
|
||||||
| Kind::CashuWalletUnspentProof
|
| Kind::CashuWalletUnspentProof
|
||||||
| Kind::CashuWalletSpendingHistory
|
| Kind::CashuWalletSpendingHistory
|
||||||
) || is_token_deletion(&event))
|
) || is_token_deletion(&event));
|
||||||
{
|
let nutzap = event.kind == Kind::Custom(9321)
|
||||||
|
&& me.is_some_and(|me| event.tags.public_keys().any(|key| key == me));
|
||||||
|
|
||||||
|
if !wallet_event && !nutzap {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Prune the dedup set before it grows unbounded
|
// Prune the dedup set before it grows unbounded
|
||||||
if processed_events.len() >= MAX_PROCESSED {
|
if processed_events.len() >= MAX_PROCESSED {
|
||||||
processed_events.clear();
|
processed_events.clear();
|
||||||
@@ -430,7 +461,9 @@ impl WalletRegistry {
|
|||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::CashuWallet => self.absorb_wallet(event, cx),
|
Kind::CashuWallet => self.absorb_wallet(event, cx),
|
||||||
Kind::CashuWalletUnspentProof => self.absorb_token(event, cx),
|
Kind::CashuWalletUnspentProof => self.absorb_token(event, cx),
|
||||||
|
Kind::CashuWalletSpendingHistory => {}
|
||||||
Kind::EventDeletion => self.absorb_deletion(event, cx),
|
Kind::EventDeletion => self.absorb_deletion(event, cx),
|
||||||
|
Kind::Custom(9321) => self.absorb_nutzap(event, cx),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -538,6 +571,74 @@ impl WalletRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Claim a NIP-61 nutzap.
|
||||||
|
fn absorb_nutzap(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||||
|
if self.redeemed.contains(&event.id) || self.redeeming.contains(&event.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.store.is_none() || self.wallets.is_empty() {
|
||||||
|
self.pending_nutzaps.push(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(wallet) = self.wallet.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mint) = nutzap_mint(&event) else {
|
||||||
|
log::warn!("nutzap {} has no mint", event.id);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(mint_url) = MintUrl::from_str(&mint) else {
|
||||||
|
log::warn!("nutzap {} has an invalid mint: {mint}", event.id);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.wallets.contains_key(&mint_url) {
|
||||||
|
log::warn!("nutzap {} is on an unlisted mint: {mint_url}", event.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let proofs = match nutzap_proofs(&event) {
|
||||||
|
Ok(proofs) => proofs,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("nutzap {} has no usable proofs: {error:#}", event.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let target = format!(
|
||||||
|
"02{}",
|
||||||
|
Keys::new(wallet.privkey.clone()).public_key().to_hex()
|
||||||
|
);
|
||||||
|
if !proofs.iter().all(|proof| locked_to(proof, &target)) {
|
||||||
|
log::warn!(
|
||||||
|
"nutzap {} proofs are not locked to the wallet key",
|
||||||
|
event.id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.redeeming.insert(event.id);
|
||||||
|
self.schedule_job(
|
||||||
|
ops::Job::RedeemNutzap {
|
||||||
|
mint: mint_url,
|
||||||
|
proofs,
|
||||||
|
nutzap_id: event.id,
|
||||||
|
sender: event.pubkey,
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_pending_nutzaps(&mut self, cx: &mut Context<Self>) {
|
||||||
|
for event in std::mem::take(&mut self.pending_nutzaps) {
|
||||||
|
self.absorb_nutzap(event, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the cdk store and build a wallet per mint that is not open yet.
|
/// Open the cdk store and build a wallet per mint that is not open yet.
|
||||||
fn ensure_wallets(&mut self, cx: &mut Context<Self>) {
|
fn ensure_wallets(&mut self, cx: &mut Context<Self>) {
|
||||||
let Some(data) = self.wallet.clone() else {
|
let Some(data) = self.wallet.clone() else {
|
||||||
@@ -587,6 +688,7 @@ impl WalletRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
self.drain_pending_nutzaps(cx);
|
||||||
self.schedule_job(ops::Job::Reconcile, cx);
|
self.schedule_job(ops::Job::Reconcile, cx);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -613,6 +715,7 @@ impl WalletRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.store_opening = false;
|
this.store_opening = false;
|
||||||
|
this.drain_pending_nutzaps(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
this.schedule_job(ops::Job::Reconcile, cx);
|
this.schedule_job(ops::Job::Reconcile, cx);
|
||||||
})
|
})
|
||||||
@@ -657,12 +760,30 @@ impl WalletRegistry {
|
|||||||
|
|
||||||
let signer = NostrRegistry::global(cx).read(cx).signer().clone();
|
let signer = NostrRegistry::global(cx).read(cx).signer().clone();
|
||||||
let client = NostrRegistry::global(cx).read(cx).client().clone();
|
let client = NostrRegistry::global(cx).read(cx).client().clone();
|
||||||
|
|
||||||
|
let wallet_key = match self
|
||||||
|
.wallet
|
||||||
|
.as_ref()
|
||||||
|
.map(|wallet| cdk::nuts::SecretKey::from_str(&wallet.privkey.to_secret_hex()))
|
||||||
|
{
|
||||||
|
Some(Ok(key)) => key,
|
||||||
|
Some(Err(error)) => {
|
||||||
|
self.emit_error(format!("invalid wallet key: {error}"), cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.emit_error("Wallet is not ready".to_string(), cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
self.job_running = true;
|
self.job_running = true;
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let result = Tokio::spawn_result(cx, async move {
|
let result = Tokio::spawn_result(cx, async move {
|
||||||
let ctx = ops::JobCtx {
|
let ctx = ops::JobCtx {
|
||||||
wallets,
|
wallets,
|
||||||
|
wallet_key,
|
||||||
signer,
|
signer,
|
||||||
client,
|
client,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -731,6 +852,17 @@ impl WalletRegistry {
|
|||||||
self.emit_updated(cx);
|
self.emit_updated(cx);
|
||||||
log::info!("received {amount} sats");
|
log::info!("received {amount} sats");
|
||||||
}
|
}
|
||||||
|
ops::Outcome::NutzapRedeemed {
|
||||||
|
update,
|
||||||
|
nutzap_id,
|
||||||
|
amount,
|
||||||
|
} => {
|
||||||
|
self.redeeming.remove(&nutzap_id);
|
||||||
|
self.redeemed.insert(nutzap_id);
|
||||||
|
self.apply_token_update(update);
|
||||||
|
self.emit_updated(cx);
|
||||||
|
log::info!("redeemed nutzap {nutzap_id} for {amount} sats");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,9 +878,16 @@ impl WalletRegistry {
|
|||||||
self.wallet = state.wallet;
|
self.wallet = state.wallet;
|
||||||
self.tokens = state.tokens;
|
self.tokens = state.tokens;
|
||||||
self.stale = state.stale;
|
self.stale = state.stale;
|
||||||
|
self.redeemed = state.redeemed;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
self.ensure_wallets(cx);
|
self.ensure_wallets(cx);
|
||||||
self.schedule_job(ops::Job::Reconcile, cx);
|
self.schedule_job(ops::Job::Reconcile, cx);
|
||||||
|
|
||||||
|
// Claimed once the wallets are open; live redemptions continue
|
||||||
|
// through the notification listener
|
||||||
|
for event in state.nutzaps {
|
||||||
|
self.pending_nutzaps.push(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load(&mut self, cx: &mut Context<Self>) {
|
fn load(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -796,10 +935,10 @@ impl WalletRegistry {
|
|||||||
|
|
||||||
// Subscribe only after the cache snapshot; from then on the state
|
// Subscribe only after the cache snapshot; from then on the state
|
||||||
// advances exclusively through the notification listener.
|
// advances exclusively through the notification listener.
|
||||||
let subscribed =
|
let subscribed = cx.background_spawn(async move {
|
||||||
cx.background_spawn(
|
subscribe_client.subscribe(wallet_filter(me)).await?;
|
||||||
async move { subscribe_client.subscribe(wallet_filter(me)).await },
|
subscribe_client.subscribe(nutzap_filter(me)).await
|
||||||
);
|
});
|
||||||
if let Err(error) = subscribed.await {
|
if let Err(error) = subscribed.await {
|
||||||
log::warn!("wallet subscription failed: {error:#}");
|
log::warn!("wallet subscription failed: {error:#}");
|
||||||
}
|
}
|
||||||
@@ -822,9 +961,11 @@ where
|
|||||||
S: AsyncNip44 + ?Sized,
|
S: AsyncNip44 + ?Sized,
|
||||||
{
|
{
|
||||||
let events = client.database().query(wallet_filter(*me)).await?;
|
let events = client.database().query(wallet_filter(*me)).await?;
|
||||||
|
let nutzap_events = client.database().query(nutzap_filter(*me)).await?;
|
||||||
|
|
||||||
let mut wallet_event: Option<&Event> = None;
|
let mut wallet_event: Option<&Event> = None;
|
||||||
let mut token_events = Vec::new();
|
let mut token_events = Vec::new();
|
||||||
|
let mut history_events = Vec::new();
|
||||||
let mut deletions = Vec::new();
|
let mut deletions = Vec::new();
|
||||||
|
|
||||||
for event in &events {
|
for event in &events {
|
||||||
@@ -840,11 +981,25 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Kind::CashuWalletUnspentProof => token_events.push(event),
|
Kind::CashuWalletUnspentProof => token_events.push(event),
|
||||||
|
Kind::CashuWalletSpendingHistory => history_events.push(event),
|
||||||
Kind::EventDeletion if is_token_deletion(event) => deletions.push(event),
|
Kind::EventDeletion if is_token_deletion(event) => deletions.push(event),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nutzap events tagged redeemed in our history events are already claimed
|
||||||
|
let mut redeemed = HashSet::new();
|
||||||
|
for event in history_events {
|
||||||
|
for id in redeemed_ids(event) {
|
||||||
|
redeemed.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nutzaps = nutzap_events
|
||||||
|
.into_iter()
|
||||||
|
.filter(|event| !redeemed.contains(&event.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut stale = HashSet::new();
|
let mut stale = HashSet::new();
|
||||||
for event in deletions {
|
for event in deletions {
|
||||||
for tag in event.tags.iter() {
|
for tag in event.tags.iter() {
|
||||||
@@ -901,6 +1056,8 @@ where
|
|||||||
wallet,
|
wallet,
|
||||||
tokens,
|
tokens,
|
||||||
stale,
|
stale,
|
||||||
|
redeemed,
|
||||||
|
nutzaps,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,6 +1097,13 @@ fn wallet_filter(author: PublicKey) -> Filter {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NIP-61 nutzaps: public kind 9321 events p-tagging our identity pubkey
|
||||||
|
fn nutzap_filter(me: PublicKey) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kinds([Kind::Custom(9321)])
|
||||||
|
.custom_tag(SingleLetterTag::LOWERCASE_P, me.to_hex())
|
||||||
|
}
|
||||||
|
|
||||||
fn is_token_deletion(event: &Event) -> bool {
|
fn is_token_deletion(event: &Event) -> bool {
|
||||||
event.kind == Kind::EventDeletion
|
event.kind == Kind::EventDeletion
|
||||||
&& event
|
&& event
|
||||||
@@ -947,3 +1111,68 @@ fn is_token_deletion(event: &Event) -> bool {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|tag| tag.kind() == "k" && tag.content() == Some("7375"))
|
.any(|tag| tag.kind() == "k" && tag.content() == Some("7375"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `u` tag carries the mint URL; fall back to the older `mint` tag
|
||||||
|
fn nutzap_mint(event: &Event) -> Option<String> {
|
||||||
|
let tag = event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.kind() == "u" && tag.content().is_some())
|
||||||
|
.or_else(|| {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.kind() == "mint" && tag.content().is_some())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tag.content().map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nutzap_proofs(event: &Event) -> Result<Proofs, Error> {
|
||||||
|
let mut proofs = Vec::new();
|
||||||
|
for tag in event.tags.iter() {
|
||||||
|
if tag.kind() != "proof" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(value) = tag.content() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let proof: Proof =
|
||||||
|
serde_json::from_str(value).map_err(|error| anyhow!("invalid proof tag: {error}"))?;
|
||||||
|
proofs.push(proof);
|
||||||
|
}
|
||||||
|
|
||||||
|
if proofs.is_empty() {
|
||||||
|
return Err(anyhow!("no proofs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Proofs::from(proofs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NIP-61 requires nutzap proofs to be P2PK-locked to the wallet pubkey.
|
||||||
|
fn locked_to(proof: &Proof, target: &str) -> bool {
|
||||||
|
let Ok(conditions) = SpendingConditions::try_from(&proof.secret) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
conditions
|
||||||
|
.pubkeys()
|
||||||
|
.map(|keys| keys.iter().any(|key| key.to_string() == target))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nutzap event ids our history events marked as redeemed.
|
||||||
|
fn redeemed_ids(event: &Event) -> Vec<EventId> {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|tag| {
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
slice.len() >= 4 && slice[0] == "e" && slice[3] == "redeemed"
|
||||||
|
})
|
||||||
|
.filter_map(|tag| tag.content())
|
||||||
|
.filter_map(|hex| EventId::from_hex(hex).ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|||||||
+120
-14
@@ -6,7 +6,10 @@ use cdk::Amount;
|
|||||||
use cdk::amount::SplitTarget;
|
use cdk::amount::SplitTarget;
|
||||||
use cdk::mint_url::MintUrl;
|
use cdk::mint_url::MintUrl;
|
||||||
use cdk::nuts::nut00::KnownMethod;
|
use cdk::nuts::nut00::KnownMethod;
|
||||||
use cdk::nuts::{CurrencyUnit, Id, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, State};
|
use cdk::nuts::{
|
||||||
|
CurrencyUnit, Id, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey,
|
||||||
|
State,
|
||||||
|
};
|
||||||
use cdk::secret::Secret;
|
use cdk::secret::Secret;
|
||||||
use cdk::wallet::types::ProofInfo;
|
use cdk::wallet::types::ProofInfo;
|
||||||
use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
|
use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
|
||||||
@@ -19,10 +22,38 @@ use crate::keys;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) enum Job {
|
pub(crate) enum Job {
|
||||||
Reconcile,
|
Reconcile,
|
||||||
ClaimQuote { mint: MintUrl, quote_id: String },
|
ClaimQuote {
|
||||||
Melt { mint: MintUrl, invoice: String },
|
mint: MintUrl,
|
||||||
SendToken { mint: MintUrl, amount: u64 },
|
quote_id: String,
|
||||||
Receive { mint: MintUrl, token: String },
|
},
|
||||||
|
Melt {
|
||||||
|
mint: MintUrl,
|
||||||
|
invoice: String,
|
||||||
|
},
|
||||||
|
SendToken {
|
||||||
|
mint: MintUrl,
|
||||||
|
amount: u64,
|
||||||
|
},
|
||||||
|
Receive {
|
||||||
|
mint: MintUrl,
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
|
RedeemNutzap {
|
||||||
|
mint: MintUrl,
|
||||||
|
proofs: Proofs,
|
||||||
|
nutzap_id: EventId,
|
||||||
|
sender: PublicKey,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// History event payload for a token state change
|
||||||
|
pub(crate) struct History {
|
||||||
|
pub(crate) direction: TransactionDirection,
|
||||||
|
pub(crate) amount: u64,
|
||||||
|
/// NIP-61 nutzap events redeemed by this state change
|
||||||
|
pub(crate) redeemed: Vec<EventId>,
|
||||||
|
/// Author of the redeemed nutzap, for the unencrypted `p` tag
|
||||||
|
pub(crate) sender: Option<PublicKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct TokenUpdate {
|
pub(crate) struct TokenUpdate {
|
||||||
@@ -55,10 +86,17 @@ pub(crate) enum Outcome {
|
|||||||
update: TokenUpdate,
|
update: TokenUpdate,
|
||||||
amount: u64,
|
amount: u64,
|
||||||
},
|
},
|
||||||
|
NutzapRedeemed {
|
||||||
|
update: TokenUpdate,
|
||||||
|
nutzap_id: EventId,
|
||||||
|
amount: u64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct JobCtx<S> {
|
pub(crate) struct JobCtx<S> {
|
||||||
pub(crate) wallets: HashMap<MintUrl, Wallet>,
|
pub(crate) wallets: HashMap<MintUrl, Wallet>,
|
||||||
|
/// Wallet signing key, unlocking P2PK-locked nutzap proofs
|
||||||
|
pub(crate) wallet_key: SecretKey,
|
||||||
pub(crate) signer: S,
|
pub(crate) signer: S,
|
||||||
pub(crate) client: Client,
|
pub(crate) client: Client,
|
||||||
/// Relay token events at schedule time.
|
/// Relay token events at schedule time.
|
||||||
@@ -88,7 +126,12 @@ where
|
|||||||
wallet,
|
wallet,
|
||||||
nostr_mint(&mint)?,
|
nostr_mint(&mint)?,
|
||||||
&previous_ids(&ctx.tokens, &mint),
|
&previous_ids(&ctx.tokens, &mint),
|
||||||
Some((TransactionDirection::In, amount)),
|
Some(History {
|
||||||
|
direction: TransactionDirection::In,
|
||||||
|
amount: u64::from(quote.amount.unwrap_or_default()),
|
||||||
|
redeemed: Vec::new(),
|
||||||
|
sender: None,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -126,7 +169,12 @@ where
|
|||||||
wallet,
|
wallet,
|
||||||
nostr_mint(&mint)?,
|
nostr_mint(&mint)?,
|
||||||
&previous_ids(&ctx.tokens, &mint),
|
&previous_ids(&ctx.tokens, &mint),
|
||||||
Some((TransactionDirection::Out, amount)),
|
Some(History {
|
||||||
|
direction: TransactionDirection::Out,
|
||||||
|
amount,
|
||||||
|
redeemed: Vec::new(),
|
||||||
|
sender: None,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -145,7 +193,12 @@ where
|
|||||||
wallet,
|
wallet,
|
||||||
nostr_mint(&mint)?,
|
nostr_mint(&mint)?,
|
||||||
&previous_ids(&ctx.tokens, &mint),
|
&previous_ids(&ctx.tokens, &mint),
|
||||||
Some((TransactionDirection::Out, amount)),
|
Some(History {
|
||||||
|
direction: TransactionDirection::Out,
|
||||||
|
amount,
|
||||||
|
redeemed: Vec::new(),
|
||||||
|
sender: None,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -161,7 +214,12 @@ where
|
|||||||
wallet,
|
wallet,
|
||||||
nostr_mint(&mint)?,
|
nostr_mint(&mint)?,
|
||||||
&previous_ids(&ctx.tokens, &mint),
|
&previous_ids(&ctx.tokens, &mint),
|
||||||
Some((TransactionDirection::In, u64::from(received))),
|
Some(History {
|
||||||
|
direction: TransactionDirection::In,
|
||||||
|
amount: u64::from(received),
|
||||||
|
redeemed: Vec::new(),
|
||||||
|
sender: None,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -170,6 +228,40 @@ where
|
|||||||
amount: u64::from(received),
|
amount: u64::from(received),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
Job::RedeemNutzap {
|
||||||
|
mint,
|
||||||
|
proofs,
|
||||||
|
nutzap_id,
|
||||||
|
sender,
|
||||||
|
} => {
|
||||||
|
let wallet = wallet_for(&ctx, &mint)?;
|
||||||
|
let options = ReceiveOptions {
|
||||||
|
p2pk_signing_keys: vec![ctx.wallet_key.clone()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let received = wallet.receive_proofs(proofs, options, None, None).await?;
|
||||||
|
|
||||||
|
let update = sync_token_events(
|
||||||
|
&ctx.signer,
|
||||||
|
&ctx.client,
|
||||||
|
wallet,
|
||||||
|
nostr_mint(&mint)?,
|
||||||
|
&previous_ids(&ctx.tokens, &mint),
|
||||||
|
Some(History {
|
||||||
|
direction: TransactionDirection::In,
|
||||||
|
amount: u64::from(received),
|
||||||
|
redeemed: vec![nutzap_id],
|
||||||
|
sender: Some(sender),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Outcome::NutzapRedeemed {
|
||||||
|
update,
|
||||||
|
nutzap_id,
|
||||||
|
amount: u64::from(received),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +285,12 @@ where
|
|||||||
wallet,
|
wallet,
|
||||||
nostr_mint(mint_url)?,
|
nostr_mint(mint_url)?,
|
||||||
&previous_ids(&tokens, mint_url),
|
&previous_ids(&tokens, mint_url),
|
||||||
Some((TransactionDirection::In, u64::from(minted))),
|
Some(History {
|
||||||
|
direction: TransactionDirection::In,
|
||||||
|
amount: u64::from(minted),
|
||||||
|
redeemed: Vec::new(),
|
||||||
|
sender: None,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -246,7 +343,7 @@ async fn sync_token_events<S>(
|
|||||||
wallet: &Wallet,
|
wallet: &Wallet,
|
||||||
mint: Url,
|
mint: Url,
|
||||||
previous: &[EventId],
|
previous: &[EventId],
|
||||||
history: Option<(TransactionDirection, u64)>,
|
history: Option<History>,
|
||||||
) -> Result<TokenUpdate, Error>
|
) -> Result<TokenUpdate, Error>
|
||||||
where
|
where
|
||||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync + ?Sized,
|
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync + ?Sized,
|
||||||
@@ -268,9 +365,18 @@ where
|
|||||||
client.send_event(&deletion).await?;
|
client.send_event(&deletion).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((direction, amount)) = history {
|
if let Some(history) = history {
|
||||||
let history_event =
|
let history_event = keys::build_history_event(
|
||||||
keys::build_history_event(signer, &me, direction, amount, event.id, previous).await?;
|
signer,
|
||||||
|
&me,
|
||||||
|
history.direction,
|
||||||
|
history.amount,
|
||||||
|
event.id,
|
||||||
|
previous,
|
||||||
|
&history.redeemed,
|
||||||
|
history.sender,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
client.send_event(&history_event).await?;
|
client.send_event(&history_event).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-8
@@ -1,7 +1,8 @@
|
|||||||
# NIP-60: cashu wallet — implementation plan
|
# NIP-60: cashu wallet — implementation plan
|
||||||
|
|
||||||
Spec: https://github.com/nostr-protocol/nips/blob/master/60.md
|
Spec: https://github.com/nostr-protocol/nips/blob/master/60.md
|
||||||
CDK: https://github.com/cashubtc/cdk (crates `cdk`, `cdk-redb`, `cdk-nostr`, from git main)
|
CDK: https://github.com/cashubtc/cdk (crates `cdk`, `cdk-redb`, from git main)
|
||||||
|
NIP-61: https://github.com/nostr-protocol/nips/blob/master/61.md
|
||||||
|
|
||||||
NIP-60 stores a user's cashu wallet state on relays: the wallet config
|
NIP-60 stores a user's cashu wallet state on relays: the wallet config
|
||||||
(kind `17375`), its unspent proofs (kind `7375`), and an optional spending
|
(kind `17375`), its unspent proofs (kind `7375`), and an optional spending
|
||||||
@@ -28,9 +29,13 @@ Key mechanics:
|
|||||||
key). It exists to unlock P2PK-locked ecash, i.e. redeeming NIP-61
|
key). It exists to unlock P2PK-locked ecash, i.e. redeeming NIP-61
|
||||||
nutzaps.
|
nutzaps.
|
||||||
- NIP-60 itself does not move money to/from others. Receiving ecash from
|
- NIP-60 itself does not move money to/from others. Receiving ecash from
|
||||||
peers is NIP-61 (nutzaps): gift-wrapped token events P2PK-locked to the
|
peers is NIP-61 (nutzaps): public `kind:9321` events published by the
|
||||||
wallet pubkey. Redemption is `receive` + a `7376` with an `e` tag
|
sender, p-tagging the recipient's nostr identity pubkey, carrying
|
||||||
marked `redeemed` (this one tag stays unencrypted).
|
P2PK-locked proofs (`proof` tags) on a trusted mint (`u` tag). Redemption
|
||||||
|
is `receive_proofs` with the wallet signing key + a `7376` whose
|
||||||
|
unencrypted tags mark the nutzap `redeemed` and carry the sender pubkey.
|
||||||
|
- NIP-61 discovery runs through a `kind:10019` informational event
|
||||||
|
(relays, mints, wallet pubkey) published alongside the wallet event.
|
||||||
- History (7376) and quote events (7374) are optional; the spec recommends
|
- History (7376) and quote events (7374) are optional; the spec recommends
|
||||||
local state for quotes. We publish 7376, skip 7374 (cdk keeps quotes in
|
local state for quotes. We publish 7376, skip 7374 (cdk keeps quotes in
|
||||||
its local DB).
|
its local DB).
|
||||||
@@ -246,11 +251,31 @@ Implemented in `crates/nip60/src/ops.rs` plus registry wiring:
|
|||||||
Deferred: NUT-20/quote events (`7374`, per spec local state is fine),
|
Deferred: NUT-20/quote events (`7374`, per spec local state is fine),
|
||||||
sending tokens in-chat (v1 copies the token string).
|
sending tokens in-chat (v1 copies the token string).
|
||||||
|
|
||||||
### Phase 3 — NIP-61 redemption (cdk-nostr)
|
### Phase 3 — NIP-61 redemption (done)
|
||||||
|
|
||||||
1. `cdk_nostr::NostrInbox` on the wallet key + relays, started in
|
Spec note: NIP-61 changed since this plan was first written — nutzaps are
|
||||||
`Tokio::spawn`, stopped on reset; rumors parsed into proofs + mint.
|
now public `kind:9321` events p-tagging the recipient's nostr identity
|
||||||
2. Redemption path with `p2pk_signing_keys`, `redeemed` history tag.
|
pubkey (the old draft's `kind:938` + gift-wrap shape is gone). So
|
||||||
|
`cdk_nostr::NostrInbox` (a NIP-17 gift-wrap listener) is not the right
|
||||||
|
tool; the dependency was dropped and reception rides the app's existing
|
||||||
|
nostr client.
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
- `setup` also publishes a `kind:10019` nutzap info event (bootstrap
|
||||||
|
relays, wallet mints, wallet pubkey) so other clients can nutzap us.
|
||||||
|
- A second subscription (`kinds: [9321], #p: me`) feeds nutzaps through
|
||||||
|
the same notification listener; the cache load scans stored `9321`s
|
||||||
|
too, so unredeemed nutzaps survive restarts.
|
||||||
|
- `absorb_nutzap` parses `proof`/`u` tags, resolves the mint against the
|
||||||
|
open wallets, verifies every proof is P2PK-locked to the wallet pubkey
|
||||||
|
(NUT-10 spending conditions), and enqueues `Job::RedeemNutzap` through
|
||||||
|
the serialized job queue.
|
||||||
|
- The job redeems via `wallet.receive_proofs` with the wallet key in
|
||||||
|
`p2pk_signing_keys`, then syncs with a `7376` history event whose
|
||||||
|
unencrypted tags mark the nutzap `redeemed` and tag the sender.
|
||||||
|
- Already-redeemed nutzaps (from cached `7376` tags) and ones with a job
|
||||||
|
in flight are skipped; failed redemptions are retried on relay echo.
|
||||||
|
|
||||||
### Phase 4 — UI
|
### Phase 4 — UI
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user