refactor
This commit is contained in:
+80
-80
@@ -7,6 +7,8 @@ use sha2::Sha256;
|
||||
const SEED_SALT: &[u8] = b"coop nip60 seed salt";
|
||||
const SEED_INFO: &[u8] = b"coop cashu seed";
|
||||
|
||||
/// Parsed 17375 wallet event: the wallet key and its mint list.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct WalletData {
|
||||
pub(crate) event_id: EventId,
|
||||
pub(crate) created_at: Timestamp,
|
||||
@@ -14,32 +16,68 @@ pub(crate) struct WalletData {
|
||||
pub(crate) mints: Vec<Url>,
|
||||
}
|
||||
|
||||
impl Clone for WalletData {
|
||||
fn clone(&self) -> Self {
|
||||
impl WalletData {
|
||||
/// A new wallet with a fresh key.
|
||||
pub(crate) fn generate(first_mint: Url) -> Self {
|
||||
Self {
|
||||
event_id: self.event_id,
|
||||
created_at: self.created_at,
|
||||
privkey: self.privkey.clone(),
|
||||
mints: self.mints.clone(),
|
||||
event_id: EventId::from_byte_array([0; 32]),
|
||||
created_at: Timestamp::from_secs(0),
|
||||
privkey: SecretKey::generate(),
|
||||
mints: vec![first_mint],
|
||||
}
|
||||
}
|
||||
|
||||
/// The cdk seed, derived deterministically from the wallet key.
|
||||
pub(crate) fn derive_seed(privkey: &SecretKey) -> Result<[u8; 64], Error> {
|
||||
let ikm = privkey.to_secret_bytes();
|
||||
let hk = Hkdf::<Sha256>::new(Some(SEED_SALT), &ikm);
|
||||
let mut seed = [0u8; 64];
|
||||
hk.expand(SEED_INFO, &mut seed)
|
||||
.map_err(|error| anyhow!("seed derivation failed: {error}"))?;
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
/// Decrypt and parse a 17375 event published by this account.
|
||||
pub(crate) async fn decrypt<S>(signer: &S, me: &PublicKey, event: &Event) -> Result<Self, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.pubkey != *me {
|
||||
return Err(anyhow!("wallet event was not published by this account"));
|
||||
}
|
||||
|
||||
let content = signer.nip44_decrypt_async(me, &event.content).await?;
|
||||
let (privkey, mints) = parse_wallet_content(&content)?;
|
||||
|
||||
Ok(Self {
|
||||
event_id: event.id,
|
||||
created_at: event.created_at,
|
||||
privkey,
|
||||
mints,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the encrypted 17375 event for this wallet data.
|
||||
pub(crate) async fn to_event<S>(&self, signer: &S, me: &PublicKey) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut rows = vec![vec!["privkey".to_string(), self.privkey.to_secret_hex()]];
|
||||
for mint in &self.mints {
|
||||
rows.push(vec!["mint".to_string(), mint.to_string()]);
|
||||
}
|
||||
|
||||
let content = signer
|
||||
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
|
||||
.await?;
|
||||
|
||||
Ok(EventBuilder::new(Kind::CashuWallet, content)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate_privkey() -> SecretKey {
|
||||
SecretKey::generate()
|
||||
}
|
||||
|
||||
/// Derive the cdk seed from the wallet key.
|
||||
pub(crate) fn derive_seed(privkey: &SecretKey) -> Result<[u8; 64], Error> {
|
||||
let ikm = privkey.to_secret_bytes();
|
||||
let hk = Hkdf::<Sha256>::new(Some(SEED_SALT), &ikm);
|
||||
let mut seed = [0u8; 64];
|
||||
hk.expand(SEED_INFO, &mut seed)
|
||||
.map_err(|error| anyhow!("seed derivation failed: {error}"))?;
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec<Url>), Error> {
|
||||
fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec<Url>), Error> {
|
||||
let rows: Vec<Vec<String>> =
|
||||
serde_json::from_str(content).map_err(|error| anyhow!("invalid wallet event: {error}"))?;
|
||||
|
||||
@@ -70,29 +108,6 @@ pub(crate) fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec<Url>
|
||||
Ok((privkey, mints))
|
||||
}
|
||||
|
||||
pub(crate) async fn decrypt_wallet_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
event: &Event,
|
||||
) -> Result<WalletData, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.pubkey != *me {
|
||||
return Err(anyhow!("wallet event was not published by this account"));
|
||||
}
|
||||
|
||||
let content = signer.nip44_decrypt_async(me, &event.content).await?;
|
||||
let (privkey, mints) = parse_wallet_content(&content)?;
|
||||
|
||||
Ok(WalletData {
|
||||
event_id: event.id,
|
||||
created_at: event.created_at,
|
||||
privkey,
|
||||
mints,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn decrypt_token_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
@@ -113,29 +128,6 @@ where
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_wallet_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
privkey: &SecretKey,
|
||||
mints: &[Url],
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut rows = vec![vec!["privkey".to_string(), privkey.to_secret_hex()]];
|
||||
for mint in mints {
|
||||
rows.push(vec!["mint".to_string(), mint.to_string()]);
|
||||
}
|
||||
|
||||
let content = signer
|
||||
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
|
||||
.await?;
|
||||
|
||||
Ok(EventBuilder::new(Kind::CashuWallet, content)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_token_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
@@ -167,23 +159,29 @@ where
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Encrypted 7376 history payload for one 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) async fn build_history_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
direction: TransactionDirection,
|
||||
amount: u64,
|
||||
history: &History,
|
||||
created: EventId,
|
||||
destroyed: &[EventId],
|
||||
redeemed: &[EventId],
|
||||
sender: Option<PublicKey>,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut rows = vec![
|
||||
vec!["direction".to_string(), direction.to_string()],
|
||||
vec!["amount".to_string(), amount.to_string()],
|
||||
vec!["direction".to_string(), history.direction.to_string()],
|
||||
vec!["amount".to_string(), history.amount.to_string()],
|
||||
];
|
||||
|
||||
for id in destroyed {
|
||||
@@ -202,12 +200,13 @@ where
|
||||
"created".to_string(),
|
||||
]);
|
||||
|
||||
let mut tags: Vec<Tag> = redeemed
|
||||
let mut tags: Vec<Tag> = history
|
||||
.redeemed
|
||||
.iter()
|
||||
.map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()]))
|
||||
.collect();
|
||||
|
||||
if let Some(sender) = sender {
|
||||
if let Some(sender) = history.sender {
|
||||
tags.push(Tag::custom("p", [sender.to_hex()]));
|
||||
}
|
||||
|
||||
@@ -224,7 +223,7 @@ where
|
||||
pub(crate) async fn build_nutzap_info_event<S>(
|
||||
signer: &S,
|
||||
mints: &[Url],
|
||||
relays: &[&str],
|
||||
relays: &[RelayUrl],
|
||||
wallet_pubkey: PublicKey,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
@@ -282,11 +281,12 @@ mod tests {
|
||||
#[test]
|
||||
fn seed_is_deterministic_per_key() {
|
||||
let key = SecretKey::generate();
|
||||
let first = derive_seed(&key).expect("seed derivation cannot fail");
|
||||
let second = derive_seed(&key).expect("seed derivation cannot fail");
|
||||
let first = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
|
||||
let second = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
|
||||
assert_eq!(first, second);
|
||||
|
||||
let other = derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail");
|
||||
let other =
|
||||
WalletData::derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail");
|
||||
assert_ne!(first, other);
|
||||
}
|
||||
}
|
||||
|
||||
+314
-344
@@ -43,7 +43,7 @@ pub struct MintQuoteEntry {
|
||||
pub quote_id: String,
|
||||
pub request: String,
|
||||
pub amount: u64,
|
||||
pub mint: Url,
|
||||
pub mint: MintUrl,
|
||||
}
|
||||
|
||||
pub struct WalletRegistry {
|
||||
@@ -137,61 +137,17 @@ impl WalletRegistry {
|
||||
|
||||
/// Create a lightning invoice that funds the wallet once paid.
|
||||
pub fn create_mint_quote(&mut self, amount: u64, cx: &mut Context<Self>) {
|
||||
let Some(mint) = self
|
||||
.wallet
|
||||
.as_ref()
|
||||
.and_then(|wallet| wallet.mints.first().cloned())
|
||||
else {
|
||||
self.emit_error("No wallet is set up".to_string(), cx);
|
||||
let Some(mint_url) = self.primary_mint(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else {
|
||||
self.emit_error("Invalid mint URL".to_string(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(wallet) = self.wallets.get(&mint_url).cloned() else {
|
||||
self.emit_error("Wallet is not ready".to_string(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let created = cx.background_spawn(async move {
|
||||
let quote = wallet
|
||||
.mint_quote(
|
||||
cdk::nuts::PaymentMethod::Known(cdk::nuts::nut00::KnownMethod::Bolt11),
|
||||
Some(cdk::Amount::from(amount)),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<_, Error>((quote.id, quote.request))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let (quote_id, request) = match created.await {
|
||||
Ok(quote) => quote,
|
||||
Err(error) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.emit_error(format!("{error:#}"), cx);
|
||||
})
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.quotes.push(MintQuoteEntry {
|
||||
quote_id,
|
||||
request,
|
||||
amount,
|
||||
mint: mint.clone(),
|
||||
});
|
||||
this.emit_updated(cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
self.schedule_job(
|
||||
ops::Job::CreateQuote {
|
||||
mint: mint_url,
|
||||
amount,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check a mint quote with the mint and claim it once it is paid.
|
||||
@@ -201,14 +157,9 @@ impl WalletRegistry {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(mint_url) = MintUrl::from_str(entry.mint.as_str()) else {
|
||||
self.emit_error("Invalid mint URL".to_string(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
self.schedule_job(
|
||||
ops::Job::ClaimQuote {
|
||||
mint: mint_url,
|
||||
mint: entry.mint.clone(),
|
||||
quote_id,
|
||||
},
|
||||
cx,
|
||||
@@ -217,17 +168,7 @@ impl WalletRegistry {
|
||||
|
||||
/// Pay a lightning invoice from the wallet.
|
||||
pub fn melt(&mut self, invoice: String, cx: &mut Context<Self>) {
|
||||
let Some(mint) = self
|
||||
.wallet
|
||||
.as_ref()
|
||||
.and_then(|wallet| wallet.mints.first().cloned())
|
||||
else {
|
||||
self.emit_error("No wallet is set up".to_string(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else {
|
||||
self.emit_error("Invalid mint URL".to_string(), cx);
|
||||
let Some(mint_url) = self.primary_mint(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -242,17 +183,7 @@ impl WalletRegistry {
|
||||
|
||||
/// Send ecash as a token string the recipient can paste anywhere.
|
||||
pub fn send_token(&mut self, amount: u64, cx: &mut Context<Self>) {
|
||||
let Some(mint) = self
|
||||
.wallet
|
||||
.as_ref()
|
||||
.and_then(|wallet| wallet.mints.first().cloned())
|
||||
else {
|
||||
self.emit_error("No wallet is set up".to_string(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else {
|
||||
self.emit_error("Invalid mint URL".to_string(), cx);
|
||||
let Some(mint_url) = self.primary_mint(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -265,6 +196,22 @@ impl WalletRegistry {
|
||||
);
|
||||
}
|
||||
|
||||
/// The first wallet mint; the UI is effectively single-mint in v1.
|
||||
fn primary_mint(&self, cx: &mut Context<Self>) -> Option<MintUrl> {
|
||||
let Some(mint) = self.wallet.as_ref().and_then(|wallet| wallet.mints.first()) else {
|
||||
self.emit_error("No wallet is set up".to_string(), cx);
|
||||
return None;
|
||||
};
|
||||
|
||||
match MintUrl::from_str(mint.as_str()) {
|
||||
Ok(mint_url) => Some(mint_url),
|
||||
Err(error) => {
|
||||
self.emit_error(format!("invalid mint url: {error}"), cx);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive a cashu token created elsewhere.
|
||||
pub fn receive_token(&mut self, token: String, cx: &mut Context<Self>) {
|
||||
let mint = match cdk::nuts::Token::from_str(&token) {
|
||||
@@ -306,19 +253,15 @@ impl WalletRegistry {
|
||||
}
|
||||
};
|
||||
|
||||
let (privkey, mints) = match self
|
||||
.wallet
|
||||
.as_ref()
|
||||
.map(|wallet| (wallet.privkey.clone(), wallet.mints.clone()))
|
||||
{
|
||||
Some((privkey, mut mints)) => {
|
||||
if mints.contains(&mint) {
|
||||
let mut data = match self.wallet.clone() {
|
||||
Some(mut data) => {
|
||||
if data.mints.contains(&mint) {
|
||||
return;
|
||||
}
|
||||
mints.push(mint.clone());
|
||||
(privkey, mints)
|
||||
data.mints.push(mint.clone());
|
||||
data
|
||||
}
|
||||
None => (keys::generate_privkey(), vec![mint.clone()]),
|
||||
None => keys::WalletData::generate(mint.clone()),
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -328,24 +271,30 @@ impl WalletRegistry {
|
||||
let publish = cx.background_spawn(async move {
|
||||
let me = signer.get_public_key_async().await?;
|
||||
|
||||
let event = keys::build_wallet_event(&signer, &me, &privkey, &mints).await?;
|
||||
let event = data.to_event(&signer, &me).await?;
|
||||
client.send_event(&event).await?;
|
||||
|
||||
let filter = Filter::new().kind(Kind::RelayList).author(me).limit(1);
|
||||
let relays = match client.database().query(filter).await?.into_iter().next() {
|
||||
Some(list) => nip65::extract_relay_list(&list)
|
||||
.map(|(relay, _)| relay)
|
||||
.collect::<Vec<_>>(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let info = keys::build_nutzap_info_event(
|
||||
&signer,
|
||||
&mints,
|
||||
&state::BOOTSTRAP_RELAYS,
|
||||
Keys::new(privkey.clone()).public_key(),
|
||||
&data.mints,
|
||||
&relays,
|
||||
Keys::new(data.privkey.clone()).public_key(),
|
||||
)
|
||||
.await?;
|
||||
client.send_event(&info).await?;
|
||||
|
||||
Ok::<_, Error>(keys::WalletData {
|
||||
event_id: event.id,
|
||||
created_at: event.created_at,
|
||||
privkey,
|
||||
mints,
|
||||
})
|
||||
data.event_id = event.id;
|
||||
data.created_at = event.created_at;
|
||||
|
||||
Ok::<_, Error>(data)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
@@ -406,10 +355,10 @@ impl WalletRegistry {
|
||||
let (tx, rx) = flume::bounded::<Event>(100);
|
||||
|
||||
self.notification_listener = Some(cx.background_spawn(async move {
|
||||
const MAX_PROCESSED: usize = 1000;
|
||||
|
||||
let me = signer.get_public_key_async().await.ok();
|
||||
let mut notifications = client.notifications();
|
||||
|
||||
const MAX_PROCESSED: usize = 1000;
|
||||
let mut processed_events = HashSet::new();
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
@@ -417,13 +366,15 @@ impl WalletRegistry {
|
||||
&& let RelayMessage::Event { event, .. } = *message
|
||||
{
|
||||
let ours = me == Some(event.pubkey);
|
||||
let wallet_event = ours
|
||||
&& (matches!(
|
||||
event.kind,
|
||||
Kind::CashuWallet
|
||||
| Kind::CashuWalletUnspentProof
|
||||
| Kind::CashuWalletSpendingHistory
|
||||
) || is_token_deletion(&event));
|
||||
let is_cashu = matches!(
|
||||
event.kind,
|
||||
Kind::CashuWallet
|
||||
| Kind::CashuWalletUnspentProof
|
||||
| Kind::CashuWalletSpendingHistory
|
||||
);
|
||||
|
||||
let wallet_event = ours && is_cashu || is_token_deletion(&event);
|
||||
|
||||
let nutzap = event.kind == Kind::Custom(9321)
|
||||
&& me.is_some_and(|me| event.tags.public_keys().any(|key| key == me));
|
||||
|
||||
@@ -483,7 +434,7 @@ impl WalletRegistry {
|
||||
|
||||
let decrypted = cx.background_spawn(async move {
|
||||
let me = signer.get_public_key_async().await?;
|
||||
keys::decrypt_wallet_event(&signer, &me, &event).await
|
||||
keys::WalletData::decrypt(&signer, &me, &event).await
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
@@ -499,9 +450,17 @@ impl WalletRegistry {
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.wallet = Some(data);
|
||||
this.ensure_wallets(cx);
|
||||
this.emit_updated(cx);
|
||||
// A stale decrypt may land after a newer wallet event
|
||||
let take_over = match &this.wallet {
|
||||
Some(current) => data.created_at >= current.created_at,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if take_over {
|
||||
this.wallet = Some(data);
|
||||
this.ensure_wallets(cx);
|
||||
this.emit_updated(cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
@@ -525,10 +484,7 @@ impl WalletRegistry {
|
||||
let token = match decrypted.await {
|
||||
Ok(token) => token,
|
||||
Err(error) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.emit_error(format!("{error:#}"), cx);
|
||||
})
|
||||
.ok();
|
||||
log::warn!("skipping unreadable token event {event_id}: {error:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -577,46 +533,33 @@ impl WalletRegistry {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(wallet) = self.wallet.as_ref() else {
|
||||
log::warn!("dropping nutzap {}: no wallet is set up", 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)) {
|
||||
let nutzap = match Nutzap::from_event(&event, &target) {
|
||||
Ok(nutzap) => nutzap,
|
||||
Err(error) => {
|
||||
log::warn!("skipping nutzap {}: {error:#}", event.id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !self.wallets.contains_key(&nutzap.mint) {
|
||||
log::warn!(
|
||||
"nutzap {} proofs are not locked to the wallet key",
|
||||
event.id
|
||||
"nutzap {} is on an unlisted mint: {}",
|
||||
event.id,
|
||||
nutzap.mint
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -624,8 +567,8 @@ impl WalletRegistry {
|
||||
self.redeeming.insert(event.id);
|
||||
self.schedule_job(
|
||||
ops::Job::RedeemNutzap {
|
||||
mint: mint_url,
|
||||
proofs,
|
||||
mint: nutzap.mint,
|
||||
proofs: nutzap.proofs,
|
||||
nutzap_id: event.id,
|
||||
sender: event.pubkey,
|
||||
},
|
||||
@@ -661,7 +604,7 @@ impl WalletRegistry {
|
||||
}
|
||||
|
||||
if let Some(store) = self.store.clone() {
|
||||
let seed = match keys::derive_seed(&data.privkey) {
|
||||
let seed = match keys::WalletData::derive_seed(&data.privkey) {
|
||||
Ok(seed) => seed,
|
||||
Err(error) => {
|
||||
self.emit_error(format!("{error:#}"), cx);
|
||||
@@ -703,7 +646,7 @@ impl WalletRegistry {
|
||||
let mints = data.mints.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match Tokio::spawn_result(cx, open_store(privkey, mints)).await {
|
||||
match Tokio::spawn_result(cx, Self::open_store(privkey, mints)).await {
|
||||
Ok((store, wallets)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
if this.store.is_none() {
|
||||
@@ -751,15 +694,16 @@ impl WalletRegistry {
|
||||
|
||||
fn start_job(&mut self, job: ops::Job, cx: &mut Context<Self>) {
|
||||
if self.store.is_none() || self.wallets.is_empty() {
|
||||
self.emit_error("Wallet is not ready".to_string(), cx);
|
||||
self.job_queued.push(job);
|
||||
return;
|
||||
}
|
||||
|
||||
let wallets = self.wallets.clone();
|
||||
let tokens = self.tokens.clone();
|
||||
|
||||
let signer = NostrRegistry::global(cx).read(cx).signer().clone();
|
||||
let client = NostrRegistry::global(cx).read(cx).client().clone();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer().clone();
|
||||
let client = nostr.read(cx).client().clone();
|
||||
|
||||
let wallet_key = match self
|
||||
.wallet
|
||||
@@ -789,7 +733,7 @@ impl WalletRegistry {
|
||||
tokens,
|
||||
};
|
||||
|
||||
ops::run_job(job, ctx).await
|
||||
job.run(ctx).await
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -827,6 +771,20 @@ impl WalletRegistry {
|
||||
self.emit_updated(cx);
|
||||
}
|
||||
}
|
||||
ops::Outcome::QuoteCreated {
|
||||
mint,
|
||||
quote_id,
|
||||
request,
|
||||
amount,
|
||||
} => {
|
||||
self.quotes.push(MintQuoteEntry {
|
||||
quote_id,
|
||||
request,
|
||||
amount,
|
||||
mint,
|
||||
});
|
||||
self.emit_updated(cx);
|
||||
}
|
||||
ops::Outcome::QuoteMinted {
|
||||
quote_id,
|
||||
update,
|
||||
@@ -881,7 +839,6 @@ impl WalletRegistry {
|
||||
self.redeemed = state.redeemed;
|
||||
cx.notify();
|
||||
self.ensure_wallets(cx);
|
||||
self.schedule_job(ops::Job::Reconcile, cx);
|
||||
|
||||
// Claimed once the wallets are open; live redemptions continue
|
||||
// through the notification listener
|
||||
@@ -903,7 +860,7 @@ impl WalletRegistry {
|
||||
let subscribe_client = client.clone();
|
||||
let cached = cx.background_spawn(async move {
|
||||
let me = signer.get_public_key_async().await?;
|
||||
let state = load_inner(&me, &client, &signer).await?;
|
||||
let state = LoadedState::load(me, &client, &signer).await?;
|
||||
Ok::<_, Error>((me, state))
|
||||
});
|
||||
|
||||
@@ -933,12 +890,11 @@ impl WalletRegistry {
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Subscribe only after the cache snapshot; from then on the state
|
||||
// advances exclusively through the notification listener.
|
||||
let subscribed = cx.background_spawn(async move {
|
||||
subscribe_client.subscribe(wallet_filter(me)).await?;
|
||||
subscribe_client.subscribe(nutzap_filter(me)).await
|
||||
subscribe_client.subscribe(Self::wallet_filter(me)).await?;
|
||||
subscribe_client.subscribe(Self::nutzap_filter(me)).await
|
||||
});
|
||||
|
||||
if let Err(error) = subscribed.await {
|
||||
log::warn!("wallet subscription failed: {error:#}");
|
||||
}
|
||||
@@ -954,154 +910,163 @@ impl WalletRegistry {
|
||||
log::error!("wallet: {message}");
|
||||
cx.emit(WalletEvent::Error(message));
|
||||
}
|
||||
|
||||
/// Open the cdk store and build a wallet per mint.
|
||||
async fn open_store(
|
||||
privkey: SecretKey,
|
||||
mints: Vec<Url>,
|
||||
) -> Result<(Arc<WalletRedbDatabase>, HashMap<MintUrl, Wallet>), Error> {
|
||||
let dir = config_dir()
|
||||
.join("cashu")
|
||||
.join(Keys::new(privkey.clone()).public_key().to_hex());
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let store = Arc::new(WalletRedbDatabase::new(&dir.join("wallet.redb"))?);
|
||||
let seed = keys::WalletData::derive_seed(&privkey)?;
|
||||
|
||||
let mut wallets = HashMap::new();
|
||||
for mint in mints {
|
||||
let mint_url = MintUrl::from_str(mint.as_str())?;
|
||||
let wallet = WalletBuilder::new()
|
||||
.mint_url(mint_url.clone())
|
||||
.unit(CurrencyUnit::Sat)
|
||||
.localstore(store.clone())
|
||||
.seed(seed)
|
||||
.build()?;
|
||||
wallets.insert(mint_url, wallet);
|
||||
}
|
||||
|
||||
Ok((store, wallets))
|
||||
}
|
||||
|
||||
fn wallet_filter(author: PublicKey) -> Filter {
|
||||
Filter::new().author(author).kinds([
|
||||
Kind::CashuWallet,
|
||||
Kind::CashuWalletUnspentProof,
|
||||
Kind::CashuWalletSpendingHistory,
|
||||
Kind::EventDeletion,
|
||||
])
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_inner<S>(me: &PublicKey, client: &Client, signer: &S) -> Result<LoadedState, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
let events = client.database().query(wallet_filter(*me)).await?;
|
||||
let nutzap_events = client.database().query(nutzap_filter(*me)).await?;
|
||||
impl LoadedState {
|
||||
async fn load<S>(me: PublicKey, client: &Client, signer: &S) -> Result<Self, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
let events = client
|
||||
.database()
|
||||
.query(WalletRegistry::wallet_filter(me))
|
||||
.await?;
|
||||
let nutzap_events = client
|
||||
.database()
|
||||
.query(WalletRegistry::nutzap_filter(me))
|
||||
.await?;
|
||||
|
||||
let mut wallet_event: Option<&Event> = None;
|
||||
let mut token_events = Vec::new();
|
||||
let mut history_events = Vec::new();
|
||||
let mut deletions = Vec::new();
|
||||
let mut wallet_event: Option<&Event> = None;
|
||||
let mut token_events = Vec::new();
|
||||
let mut history_events = Vec::new();
|
||||
let mut deletions = Vec::new();
|
||||
|
||||
for event in &events {
|
||||
match event.kind {
|
||||
Kind::CashuWallet => {
|
||||
let replace = match &wallet_event {
|
||||
Some(current) => event.created_at > current.created_at,
|
||||
None => true,
|
||||
for event in &events {
|
||||
match event.kind {
|
||||
Kind::CashuWallet => {
|
||||
let replace = match &wallet_event {
|
||||
Some(current) => event.created_at > current.created_at,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if replace {
|
||||
wallet_event = Some(event);
|
||||
}
|
||||
}
|
||||
Kind::CashuWalletUnspentProof => token_events.push(event),
|
||||
Kind::CashuWalletSpendingHistory => history_events.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();
|
||||
for event in deletions {
|
||||
for tag in event.tags.iter() {
|
||||
if tag.kind() != "e" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(hex) = tag.content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if replace {
|
||||
wallet_event = Some(event);
|
||||
if let Ok(id) = EventId::from_hex(hex) {
|
||||
stale.insert(id);
|
||||
}
|
||||
}
|
||||
Kind::CashuWalletUnspentProof => token_events.push(event),
|
||||
Kind::CashuWalletSpendingHistory => history_events.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();
|
||||
for event in deletions {
|
||||
for tag in event.tags.iter() {
|
||||
if tag.kind() != "e" {
|
||||
let mut tokens = HashMap::new();
|
||||
for event in token_events {
|
||||
if stale.contains(&event.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(hex) = tag.content() else {
|
||||
continue;
|
||||
let token = match keys::decrypt_token_event(signer, &me, event).await {
|
||||
Ok(token) => token,
|
||||
Err(error) => {
|
||||
log::warn!("skipping unreadable token event {}: {error:#}", event.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(id) = EventId::from_hex(hex) {
|
||||
stale.insert(id);
|
||||
}
|
||||
tokens.insert(event.id, token);
|
||||
}
|
||||
}
|
||||
|
||||
let mut tokens = HashMap::new();
|
||||
for event in token_events {
|
||||
if stale.contains(&event.id) {
|
||||
continue;
|
||||
}
|
||||
let token = match keys::decrypt_token_event(signer, me, event).await {
|
||||
Ok(token) => token,
|
||||
Err(error) => {
|
||||
log::warn!("skipping unreadable token event {}: {error:#}", event.id);
|
||||
continue;
|
||||
}
|
||||
let superseded: HashSet<EventId> = tokens
|
||||
.values()
|
||||
.flat_map(|token| token.del.iter())
|
||||
.filter_map(|hex| EventId::from_hex(hex).ok())
|
||||
.collect();
|
||||
|
||||
tokens.retain(|id, _| !stale.contains(id) && !superseded.contains(id));
|
||||
stale.extend(superseded);
|
||||
|
||||
let wallet = match wallet_event {
|
||||
Some(event) => match keys::WalletData::decrypt(signer, &me, event).await {
|
||||
Ok(wallet) => Some(wallet),
|
||||
Err(error) => {
|
||||
log::warn!("skipping wallet event {}: {error:#}", event.id);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
tokens.insert(event.id, token);
|
||||
|
||||
Ok(Self {
|
||||
wallet,
|
||||
tokens,
|
||||
stale,
|
||||
redeemed,
|
||||
nutzaps,
|
||||
})
|
||||
}
|
||||
|
||||
let superseded: HashSet<EventId> = tokens
|
||||
.values()
|
||||
.flat_map(|token| token.del.iter())
|
||||
.filter_map(|hex| EventId::from_hex(hex).ok())
|
||||
.collect();
|
||||
|
||||
tokens.retain(|id, _| !stale.contains(id) && !superseded.contains(id));
|
||||
stale.extend(superseded);
|
||||
|
||||
let wallet = match wallet_event {
|
||||
Some(event) => match keys::decrypt_wallet_event(signer, me, event).await {
|
||||
Ok(wallet) => Some(wallet),
|
||||
Err(error) => {
|
||||
log::warn!("skipping wallet event {}: {error:#}", event.id);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(LoadedState {
|
||||
wallet,
|
||||
tokens,
|
||||
stale,
|
||||
redeemed,
|
||||
nutzaps,
|
||||
})
|
||||
}
|
||||
|
||||
async fn open_store(
|
||||
privkey: SecretKey,
|
||||
mints: Vec<Url>,
|
||||
) -> Result<(Arc<WalletRedbDatabase>, HashMap<MintUrl, Wallet>), Error> {
|
||||
let dir = config_dir()
|
||||
.join("cashu")
|
||||
.join(Keys::new(privkey.clone()).public_key().to_hex());
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let store = Arc::new(WalletRedbDatabase::new(&dir.join("wallet.redb"))?);
|
||||
let seed = keys::derive_seed(&privkey)?;
|
||||
|
||||
let mut wallets = HashMap::new();
|
||||
for mint in &mints {
|
||||
let mint_url = MintUrl::from_str(mint.as_str())?;
|
||||
let wallet = WalletBuilder::new()
|
||||
.mint_url(mint_url.clone())
|
||||
.unit(CurrencyUnit::Sat)
|
||||
.localstore(store.clone())
|
||||
.seed(seed)
|
||||
.build()?;
|
||||
wallets.insert(mint_url, wallet);
|
||||
}
|
||||
|
||||
Ok((store, wallets))
|
||||
}
|
||||
|
||||
fn wallet_filter(author: PublicKey) -> Filter {
|
||||
Filter::new().author(author).kinds([
|
||||
Kind::CashuWallet,
|
||||
Kind::CashuWalletUnspentProof,
|
||||
Kind::CashuWalletSpendingHistory,
|
||||
Kind::EventDeletion,
|
||||
])
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -1112,57 +1077,6 @@ fn is_token_deletion(event: &Event) -> bool {
|
||||
.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
|
||||
@@ -1176,3 +1090,59 @@ fn redeemed_ids(event: &Event) -> Vec<EventId> {
|
||||
.filter_map(|hex| EventId::from_hex(hex).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A NIP-61 nutzap's proofs, validated against the wallet pubkey.
|
||||
struct Nutzap {
|
||||
mint: MintUrl,
|
||||
proofs: Proofs,
|
||||
}
|
||||
|
||||
impl Nutzap {
|
||||
fn from_event(event: &Event, wallet_target: &str) -> Result<Self, Error> {
|
||||
let mint = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "u" && tag.content().is_some())
|
||||
.and_then(|tag| tag.content())
|
||||
.ok_or_else(|| anyhow!("nutzap is missing the mint tag"))?;
|
||||
let mint = MintUrl::from_str(mint).map_err(|error| anyhow!("invalid mint url: {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!("nutzap has no proofs"));
|
||||
}
|
||||
|
||||
let proofs = Proofs::from(proofs);
|
||||
let locked = proofs.iter().all(|proof| locked_to(proof, wallet_target));
|
||||
if !locked {
|
||||
return Err(anyhow!("proofs are not locked to the wallet key"));
|
||||
}
|
||||
|
||||
Ok(Self { mint, 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()
|
||||
.is_some_and(|keys| keys.iter().any(|key| key.to_string() == target))
|
||||
}
|
||||
|
||||
+298
-292
@@ -22,6 +22,10 @@ use crate::keys;
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Job {
|
||||
Reconcile,
|
||||
CreateQuote {
|
||||
mint: MintUrl,
|
||||
amount: u64,
|
||||
},
|
||||
ClaimQuote {
|
||||
mint: MintUrl,
|
||||
quote_id: String,
|
||||
@@ -46,14 +50,181 @@ pub(crate) enum Job {
|
||||
},
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
impl Job {
|
||||
pub(crate) async fn run<S>(self, ctx: JobCtx<S>) -> Result<Outcome, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
|
||||
{
|
||||
match self {
|
||||
Job::Reconcile => ctx.reconcile().await,
|
||||
Job::CreateQuote { mint, amount } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let quote = wallet
|
||||
.mint_quote(
|
||||
PaymentMethod::Known(KnownMethod::Bolt11),
|
||||
Some(Amount::from(amount)),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::QuoteCreated {
|
||||
mint,
|
||||
quote_id: quote.id,
|
||||
request: quote.request,
|
||||
amount,
|
||||
})
|
||||
}
|
||||
Job::ClaimQuote { mint, quote_id } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let quote = wallet.check_mint_quote("e_id).await?;
|
||||
|
||||
if quote.state != MintQuoteState::Paid {
|
||||
return Ok(Outcome::Idle);
|
||||
}
|
||||
|
||||
let proofs = wallet.mint("e_id, SplitTarget::default(), None).await?;
|
||||
let amount: u64 = proofs.iter().map(|proof| u64::from(proof.amount)).sum();
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(quote.amount.unwrap_or_default()),
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::QuoteMinted {
|
||||
quote_id,
|
||||
update,
|
||||
amount,
|
||||
})
|
||||
}
|
||||
Job::Melt { mint, invoice } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let quote = wallet
|
||||
.melt_quote(
|
||||
PaymentMethod::Known(KnownMethod::Bolt11),
|
||||
invoice,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let finalized = wallet
|
||||
.prepare_melt("e.id, HashMap::new())
|
||||
.await?
|
||||
.confirm()
|
||||
.await?;
|
||||
|
||||
if finalized.state() != MeltQuoteState::Paid {
|
||||
return Err(anyhow!("melt did not settle: {:?}", finalized.state()));
|
||||
}
|
||||
|
||||
let amount = u64::from(finalized.total_amount());
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Melted { update, amount })
|
||||
}
|
||||
Job::SendToken { mint, amount } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let prepared = wallet
|
||||
.prepare_send(Amount::from(amount), SendOptions::default())
|
||||
.await?;
|
||||
let token = prepared.confirm(None).await?.to_string();
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Sent { update, token })
|
||||
}
|
||||
Job::Receive { mint, token } => {
|
||||
let wallet = ctx.wallet(&mint)?;
|
||||
let received = wallet.receive(&token, ReceiveOptions::default()).await?;
|
||||
|
||||
let update = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(received),
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Received {
|
||||
update,
|
||||
amount: u64::from(received),
|
||||
})
|
||||
}
|
||||
Job::RedeemNutzap {
|
||||
mint,
|
||||
proofs,
|
||||
nutzap_id,
|
||||
sender,
|
||||
} => {
|
||||
let wallet = ctx.wallet(&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 = ctx
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&ctx.previous_ids(&mint),
|
||||
Some(keys::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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct TokenUpdate {
|
||||
@@ -69,6 +240,12 @@ pub(crate) enum Outcome {
|
||||
Reconciled {
|
||||
updates: Vec<TokenUpdate>,
|
||||
},
|
||||
QuoteCreated {
|
||||
mint: MintUrl,
|
||||
quote_id: String,
|
||||
request: String,
|
||||
amount: u64,
|
||||
},
|
||||
QuoteMinted {
|
||||
quote_id: String,
|
||||
update: TokenUpdate,
|
||||
@@ -103,295 +280,130 @@ pub(crate) struct JobCtx<S> {
|
||||
pub(crate) tokens: HashMap<EventId, TokenEvent>,
|
||||
}
|
||||
|
||||
pub(crate) async fn run_job<S>(job: Job, ctx: JobCtx<S>) -> Result<Outcome, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
|
||||
{
|
||||
match job {
|
||||
Job::Reconcile => reconcile(ctx).await,
|
||||
Job::ClaimQuote { mint, quote_id } => {
|
||||
let wallet = wallet_for(&ctx, &mint)?;
|
||||
let quote = wallet.check_mint_quote("e_id).await?;
|
||||
|
||||
if quote.state != MintQuoteState::Paid {
|
||||
return Ok(Outcome::Idle);
|
||||
}
|
||||
|
||||
let proofs = wallet.mint("e_id, SplitTarget::default(), None).await?;
|
||||
let amount: u64 = proofs.iter().map(|proof| u64::from(proof.amount)).sum();
|
||||
|
||||
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(quote.amount.unwrap_or_default()),
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::QuoteMinted {
|
||||
quote_id,
|
||||
update,
|
||||
amount,
|
||||
})
|
||||
}
|
||||
Job::Melt { mint, invoice } => {
|
||||
let wallet = wallet_for(&ctx, &mint)?;
|
||||
let quote = wallet
|
||||
.melt_quote(
|
||||
PaymentMethod::Known(KnownMethod::Bolt11),
|
||||
invoice,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let finalized = wallet
|
||||
.prepare_melt("e.id, HashMap::new())
|
||||
.await?
|
||||
.confirm()
|
||||
.await?;
|
||||
|
||||
if finalized.state() != MeltQuoteState::Paid {
|
||||
return Err(anyhow!("melt did not settle: {:?}", finalized.state()));
|
||||
}
|
||||
|
||||
let amount = u64::from(finalized.total_amount());
|
||||
let update = sync_token_events(
|
||||
&ctx.signer,
|
||||
&ctx.client,
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&previous_ids(&ctx.tokens, &mint),
|
||||
Some(History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Melted { update, amount })
|
||||
}
|
||||
Job::SendToken { mint, amount } => {
|
||||
let wallet = wallet_for(&ctx, &mint)?;
|
||||
let prepared = wallet
|
||||
.prepare_send(Amount::from(amount), SendOptions::default())
|
||||
.await?;
|
||||
let token = prepared.confirm(None).await?.to_string();
|
||||
|
||||
let update = sync_token_events(
|
||||
&ctx.signer,
|
||||
&ctx.client,
|
||||
wallet,
|
||||
nostr_mint(&mint)?,
|
||||
&previous_ids(&ctx.tokens, &mint),
|
||||
Some(History {
|
||||
direction: TransactionDirection::Out,
|
||||
amount,
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Sent { update, token })
|
||||
}
|
||||
Job::Receive { mint, token } => {
|
||||
let wallet = wallet_for(&ctx, &mint)?;
|
||||
let received = wallet.receive(&token, ReceiveOptions::default()).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::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Outcome::Received {
|
||||
update,
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim paid quotes, then converge each mint.
|
||||
async fn reconcile<S>(ctx: JobCtx<S>) -> Result<Outcome, Error>
|
||||
impl<S> JobCtx<S>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
|
||||
{
|
||||
let mut tokens = ctx.tokens.clone();
|
||||
let mut updates = Vec::new();
|
||||
|
||||
for (mint_url, wallet) in &ctx.wallets {
|
||||
let minted = wallet.mint_unissued_quotes().await?;
|
||||
if minted == Amount::ZERO {
|
||||
continue;
|
||||
}
|
||||
|
||||
let update = sync_token_events(
|
||||
&ctx.signer,
|
||||
&ctx.client,
|
||||
wallet,
|
||||
nostr_mint(mint_url)?,
|
||||
&previous_ids(&tokens, mint_url),
|
||||
Some(History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(minted),
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
apply_update(&mut tokens, &update);
|
||||
updates.push(update);
|
||||
fn wallet(&self, mint: &MintUrl) -> Result<&Wallet, Error> {
|
||||
self.wallets
|
||||
.get(mint)
|
||||
.ok_or_else(|| anyhow!("no open wallet for mint {mint}"))
|
||||
}
|
||||
|
||||
for (mint_url, wallet) in &ctx.wallets {
|
||||
let mut relay_ys = HashSet::new();
|
||||
let mut added = Vec::new();
|
||||
|
||||
for token in tokens
|
||||
.values()
|
||||
.filter(|token| token.mint.as_str() == mint_url.to_string())
|
||||
{
|
||||
for proof in &token.proofs {
|
||||
let info = match proof_info(proof, mint_url) {
|
||||
Ok(info) => info,
|
||||
Err(error) => {
|
||||
log::warn!("skipping proof: {error:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if relay_ys.insert(info.y) {
|
||||
added.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn previous_ids(&self, mint: &MintUrl) -> Vec<EventId> {
|
||||
previous_ids(&self.tokens, mint)
|
||||
}
|
||||
|
||||
/// Publish the store's current unspent set as a fresh token event.
|
||||
async fn sync_token_events(
|
||||
&self,
|
||||
wallet: &Wallet,
|
||||
mint: Url,
|
||||
previous: &[EventId],
|
||||
history: Option<keys::History>,
|
||||
) -> Result<TokenUpdate, Error> {
|
||||
let me = self.signer.get_public_key_async().await?;
|
||||
let unspent = wallet.get_unspent_proofs().await?;
|
||||
let removed = unspent
|
||||
|
||||
let proofs = unspent
|
||||
.iter()
|
||||
.filter(|proof| match proof.y() {
|
||||
Ok(y) => !relay_ys.contains(&y),
|
||||
Err(_) => false,
|
||||
})
|
||||
.map(|proof| proof.y())
|
||||
.map(cashu_proof)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
wallet.localstore.update_proofs(added, removed).await?;
|
||||
let mut token = TokenEvent::new(mint, proofs);
|
||||
token.del = previous.iter().map(|id| id.to_hex()).collect();
|
||||
let event = keys::build_token_event(&self.signer, &me, &token).await?;
|
||||
self.client.send_event(&event).await?;
|
||||
|
||||
if !previous.is_empty() {
|
||||
let deletion = keys::build_token_deletion(&self.signer, previous).await?;
|
||||
self.client.send_event(&deletion).await?;
|
||||
}
|
||||
|
||||
if let Some(history) = history {
|
||||
let history_event =
|
||||
keys::build_history_event(&self.signer, &me, &history, event.id, previous).await?;
|
||||
self.client.send_event(&history_event).await?;
|
||||
}
|
||||
|
||||
Ok(TokenUpdate {
|
||||
event_id: event.id,
|
||||
token,
|
||||
destroyed: previous.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
Ok(Outcome::Reconciled { updates })
|
||||
}
|
||||
/// Claim paid quotes, then converge each mint.
|
||||
async fn reconcile(self) -> Result<Outcome, Error> {
|
||||
let mut tokens = self.tokens.clone();
|
||||
let mut updates = Vec::new();
|
||||
|
||||
/// Publish the store's current unspent set as a fresh token event.
|
||||
async fn sync_token_events<S>(
|
||||
signer: &S,
|
||||
client: &Client,
|
||||
wallet: &Wallet,
|
||||
mint: Url,
|
||||
previous: &[EventId],
|
||||
history: Option<History>,
|
||||
) -> Result<TokenUpdate, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync + ?Sized,
|
||||
{
|
||||
let me = signer.get_public_key_async().await?;
|
||||
let unspent = wallet.get_unspent_proofs().await?;
|
||||
for (mint_url, wallet) in &self.wallets {
|
||||
wallet.recover_incomplete_sagas().await?;
|
||||
|
||||
let mut proofs = Vec::new();
|
||||
for proof in &unspent {
|
||||
proofs.push(cashu_proof(proof)?);
|
||||
let minted = wallet.mint_unissued_quotes().await?;
|
||||
if minted == Amount::ZERO {
|
||||
continue;
|
||||
}
|
||||
|
||||
let update = self
|
||||
.sync_token_events(
|
||||
wallet,
|
||||
nostr_mint(mint_url)?,
|
||||
&previous_ids(&tokens, mint_url),
|
||||
Some(keys::History {
|
||||
direction: TransactionDirection::In,
|
||||
amount: u64::from(minted),
|
||||
redeemed: Vec::new(),
|
||||
sender: None,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for id in &update.destroyed {
|
||||
tokens.remove(id);
|
||||
}
|
||||
tokens.insert(update.event_id, update.token.clone());
|
||||
updates.push(update);
|
||||
}
|
||||
|
||||
for (mint_url, wallet) in &self.wallets {
|
||||
let mut relay_ys = HashSet::new();
|
||||
let mut added = Vec::new();
|
||||
|
||||
for token in tokens
|
||||
.values()
|
||||
.filter(|token| token.mint.as_str() == mint_url.to_string())
|
||||
{
|
||||
for proof in &token.proofs {
|
||||
let info = match proof_info(proof, mint_url) {
|
||||
Ok(info) => info,
|
||||
Err(error) => {
|
||||
log::warn!("skipping proof: {error:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if relay_ys.insert(info.y) {
|
||||
added.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unspent = wallet.get_unspent_proofs().await?;
|
||||
let removed = unspent
|
||||
.iter()
|
||||
.filter(|proof| match proof.y() {
|
||||
Ok(y) => !relay_ys.contains(&y),
|
||||
Err(_) => false,
|
||||
})
|
||||
.map(|proof| proof.y())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
wallet.localstore.update_proofs(added, removed).await?;
|
||||
}
|
||||
|
||||
Ok(Outcome::Reconciled { updates })
|
||||
}
|
||||
|
||||
let token = TokenEvent::new(mint, proofs);
|
||||
let event = keys::build_token_event(signer, &me, &token).await?;
|
||||
client.send_event(&event).await?;
|
||||
|
||||
if !previous.is_empty() {
|
||||
let deletion = keys::build_token_deletion(signer, previous).await?;
|
||||
client.send_event(&deletion).await?;
|
||||
}
|
||||
|
||||
if let Some(history) = history {
|
||||
let history_event = keys::build_history_event(
|
||||
signer,
|
||||
&me,
|
||||
history.direction,
|
||||
history.amount,
|
||||
event.id,
|
||||
previous,
|
||||
&history.redeemed,
|
||||
history.sender,
|
||||
)
|
||||
.await?;
|
||||
client.send_event(&history_event).await?;
|
||||
}
|
||||
|
||||
Ok(TokenUpdate {
|
||||
event_id: event.id,
|
||||
token,
|
||||
destroyed: previous.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_update(tokens: &mut HashMap<EventId, TokenEvent>, update: &TokenUpdate) {
|
||||
for id in &update.destroyed {
|
||||
tokens.remove(id);
|
||||
}
|
||||
tokens.insert(update.event_id, update.token.clone());
|
||||
}
|
||||
|
||||
fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<EventId> {
|
||||
@@ -402,16 +414,6 @@ fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<Ev
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn wallet_for<'a, S>(ctx: &'a JobCtx<S>, mint: &MintUrl) -> Result<&'a Wallet, Error> {
|
||||
ctx.wallets
|
||||
.get(mint)
|
||||
.ok_or_else(|| anyhow!("no open wallet for mint {mint}"))
|
||||
}
|
||||
|
||||
fn nostr_mint(mint: &MintUrl) -> Result<Url, Error> {
|
||||
Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}"))
|
||||
}
|
||||
|
||||
fn cashu_proof(proof: &Proof) -> Result<CashuProof, Error> {
|
||||
Ok(CashuProof {
|
||||
id: proof.keyset_id.to_string(),
|
||||
@@ -421,6 +423,10 @@ fn cashu_proof(proof: &Proof) -> Result<CashuProof, Error> {
|
||||
})
|
||||
}
|
||||
|
||||
fn nostr_mint(mint: &MintUrl) -> Result<Url, Error> {
|
||||
Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}"))
|
||||
}
|
||||
|
||||
fn proof_info(proof: &CashuProof, mint: &MintUrl) -> Result<ProofInfo, Error> {
|
||||
let keyset_id = Id::from_str(&proof.id)?;
|
||||
let c = cdk::nuts::PublicKey::from_str(&proof.c)?;
|
||||
|
||||
+54
-11
@@ -143,43 +143,46 @@ Spike findings (all resolved and verified with `cargo check`):
|
||||
- One subscription: `authors: [me], kinds: [17375, 7375, 7376]` plus
|
||||
kind `5` with `k: 7375`, on the existing client (bootstrap relays).
|
||||
Notifications flow through the nip29-style flume channel.
|
||||
- Published relays: the same client/relays coop already uses. Publishing a
|
||||
`10019` wallet relay list with those relays is a one-liner for interop;
|
||||
reading `10019` from other clients is skipped in v1 (coop always uses
|
||||
- Published relays: the `10019` nutzap info event advertises the user's
|
||||
NIP-65 relay list (queried from the local database), never the bootstrap
|
||||
relays; subscriptions and publishes ride the SDK's NIP-65 handling.
|
||||
Reading `10019` from other clients is skipped in v1 (coop always uses
|
||||
its own relay set).
|
||||
|
||||
### Operations
|
||||
|
||||
Each op runs on tokio and ends with the same `sync` tail:
|
||||
read unspent proofs from cdk -> publish one `7375` per changed mint
|
||||
(rolling up previous event ids into `del`) -> NIP-09 delete old events with
|
||||
(with `del` set to the previous event ids) -> NIP-09 delete old events with
|
||||
a `k:7375` tag -> publish `7376` history -> update registry state and emit.
|
||||
|
||||
- `setup(mint_url)`: create wallet key + seed, publish `17375`, build cdk
|
||||
`Wallet`, open subscription.
|
||||
- `create_mint_quote(amount)`: `wallet.mint_quote` -> return invoice to UI;
|
||||
poll / subscribe quote state, then `wallet.mint(quote_id)` -> sync.
|
||||
- `create_mint_quote(amount)`: a job in the queue — `wallet.mint_quote` ->
|
||||
invoice recorded in `mint_quotes()` for the UI; poll /
|
||||
`claim_mint_quote(id)`, then `wallet.mint(quote_id)` -> sync.
|
||||
On load, `mint_unissued_quotes` claims quotes paid elsewhere.
|
||||
- `melt(invoice)`: `wallet.melt` -> sync (change proofs roll over).
|
||||
- `send_token(amount)`: `wallet.send` -> return token string for the user
|
||||
to share -> sync. (No in-chat token delivery in v1.)
|
||||
- `receive_token(token)`: `wallet.receive` -> sync.
|
||||
- Redeem nutzap (NIP-61): verify the gift-wrapped proofs are P2PK-locked to
|
||||
- Redeem nutzap (NIP-61): verify the proofs are P2PK-locked to
|
||||
the wallet pubkey, then `wallet.receive_proofs(proofs, ReceiveOptions {
|
||||
p2pk_signing_keys: [wallet_key] })` -> `7376` with unencrypted
|
||||
`e <nutzap-id> "" redeemed` tag -> sync.
|
||||
- Remote `7375` from another device: import its unspent proofs into the
|
||||
cdk store as the new local truth (cdk proof insertion + state check via
|
||||
`check_proofs_spent`); superseded `7375`s reconcile through `del`/NIP-09.
|
||||
- On every load: `recover_incomplete_sagas` and `mint_unissued_quotes`
|
||||
(required by cdk to clear interrupted operations), then re-derive the
|
||||
unspent set from `7375` minus deletes.
|
||||
- On every reconcile: `recover_incomplete_sagas` and
|
||||
`mint_unissued_quotes` (required by cdk to clear interrupted operations),
|
||||
then converge the unspent set from `7375` minus deletes.
|
||||
|
||||
### Crate layout (3 files, kept small on purpose)
|
||||
|
||||
- `lib.rs` — `init`, `WalletRegistry`, `WalletEvent` (`Updated`, `Error`),
|
||||
subscription/notification handling, `reset` on signer change.
|
||||
- `keys.rs` — wallet key/seed derivation, `17375` build/parse via signer.
|
||||
- `keys.rs` — wallet key/seed derivation, `17375` build/parse via signer,
|
||||
`7375`/deletion/`7376` event builders, the `History` payload.
|
||||
- `ops.rs` — cdk operations + the shared `sync` tail (rollover, deletes,
|
||||
history), NIP-61 redemption.
|
||||
|
||||
@@ -277,6 +280,46 @@ Implemented:
|
||||
- Already-redeemed nutzaps (from cached `7376` tags) and ones with a job
|
||||
in flight are skipped; failed redemptions are retried on relay echo.
|
||||
|
||||
### Audit (post phase 3) — done
|
||||
|
||||
Backend review before Phase 4: keep it simple, no premature edge-case
|
||||
handling, struct/impl blocks over standalone functions. Applied:
|
||||
|
||||
- Code shape: `keys::WalletData` carries generate/derive_seed/decrypt/
|
||||
to_event as an impl block; `ops::Job::run` and the `JobCtx` helpers
|
||||
(`wallet`, `previous_ids`, `sync_token_events`, `reconcile`) are impl
|
||||
methods; `LoadedState::load`, `WalletRegistry::open_store` and the
|
||||
filters are associated functions; nutzap tag parsing moved into a
|
||||
`Nutzap` struct (dropped the speculative `mint` tag fallback for the
|
||||
current `u` tag). `History` moved to `keys.rs` so `build_history_event`
|
||||
takes one struct instead of eight arguments.
|
||||
- Spec gap: the sync tail never set `TokenEvent.del` — new `7375`s now
|
||||
carry the previous event ids, so superseded tokens stay pruned even if
|
||||
a NIP-09 delete is dropped by a relay.
|
||||
- Bug: `start_job` errored "Wallet is not ready" for the auto `Reconcile`
|
||||
scheduled before the redb store finished opening. Jobs scheduled while
|
||||
the store is opening now queue and run once `ensure_wallets` completes;
|
||||
`apply_loaded` no longer schedules a redundant `Reconcile`.
|
||||
- Bug: a late-landing stale wallet decrypt could overwrite a newer
|
||||
`17375`; `absorb_wallet` now re-checks `created_at` when applying.
|
||||
- Consistency: an unreadable live token event logs a warning instead of
|
||||
surfacing an error event (same as the load path); nutzaps arriving with
|
||||
no wallet set up are dropped (a fresh setup would generate a different
|
||||
key, so they were unredeemable anyway).
|
||||
- Invariant: quote creation moved into the serialized job queue
|
||||
(`Job::CreateQuote` / `Outcome::QuoteCreated`) — `mint_quote` writes to
|
||||
the cdk store, so it must not interleave with running jobs. This also
|
||||
collapsed the mint-resolution boilerplate shared by quote/melt/send
|
||||
into `primary_mint`, and `MintQuoteEntry.mint` is now a `cdk::MintUrl`.
|
||||
- Reconcile now calls `recover_incomplete_sagas` (the plan promised it;
|
||||
it was missing), before `mint_unissued_quotes`.
|
||||
|
||||
Not changed, deliberately: per-outcome `Outcome` variants, the bounded
|
||||
notification dedup set, the single queued Reconcile collapse, and the
|
||||
remaining small conversion helpers in `ops.rs` (`cashu_proof`,
|
||||
`proof_info`, `nostr_mint`) — they are simple, and further abstraction
|
||||
would be over-engineering.
|
||||
|
||||
### Phase 4 — UI
|
||||
|
||||
- Settings entry for the mint URL (`settings` crate pattern).
|
||||
|
||||
Reference in New Issue
Block a user