feat: audit codebase (#72)

Reviewed-on: #72
This commit was merged in pull request #72.
This commit is contained in:
2026-11-14 01:27:25 +00:00
parent 8fa79aef33
commit 620cbaf89c
60 changed files with 3493 additions and 888 deletions
-1
View File
@@ -20,7 +20,6 @@ serde_json.workspace = true
futures.workspace = true
hkdf.workspace = true
sha2.workspace = true
tokio = { version = "1", default-features = false, features = [ "rt" ] }
cdk.workspace = true
cdk-redb.workspace = true
+73 -45
View File
@@ -31,7 +31,6 @@ impl Global for GlobalWalletRegistry {}
#[derive(Debug, Clone)]
pub enum WalletEvent {
Updated,
TokenCreated(String),
Error(String),
}
@@ -55,6 +54,9 @@ pub struct HistoryEntry {
pub created_at: Timestamp,
}
/// How many times a failed nutzap redemption is retried before giving up
const MAX_REDEEM_ATTEMPTS: u32 = 3;
pub struct WalletRegistry {
wallet: Option<keys::WalletData>,
tokens: HashMap<EventId, TokenEvent>,
@@ -69,6 +71,10 @@ pub struct WalletRegistry {
pending_nutzaps: Vec<Event>,
/// Nutzaps with a redemption job in flight
redeeming: HashSet<EventId>,
/// Redemption attempts per nutzap, reset on success
redeem_attempts: HashMap<EventId, u32>,
/// A job errored after possibly mutating the store
publish_failed: bool,
store: Option<Arc<WalletRedbDatabase>>,
wallets: HashMap<MintUrl, Wallet>,
loading: bool,
@@ -119,6 +125,8 @@ impl WalletRegistry {
redeemed: HashSet::new(),
pending_nutzaps: Vec::new(),
redeeming: HashSet::new(),
redeem_attempts: HashMap::new(),
publish_failed: false,
store: None,
wallets: HashMap::new(),
loading: false,
@@ -184,34 +192,14 @@ impl WalletRegistry {
);
}
/// Pay a lightning invoice from the wallet.
pub fn melt(&mut self, invoice: String, cx: &mut Context<Self>) {
let Some(mint_url) = self.primary_mint(cx) else {
/// Drop a pending invoice at the user's request.
pub fn dismiss_mint_quote(&mut self, quote_id: String, cx: &mut Context<Self>) {
if !self.quotes.iter().any(|quote| quote.quote_id == quote_id) {
return;
};
}
self.schedule_job(
ops::Job::Melt {
mint: mint_url,
invoice,
},
cx,
);
}
/// 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_url) = self.primary_mint(cx) else {
return;
};
self.schedule_job(
ops::Job::SendToken {
mint: mint_url,
amount,
},
cx,
);
self.quotes.retain(|quote| quote.quote_id != quote_id);
self.emit_updated(cx);
}
/// Send a NIP-61 nutzap to a peer, optionally nutzapping one of their events.
@@ -378,6 +366,8 @@ impl WalletRegistry {
self.redeemed.clear();
self.pending_nutzaps.clear();
self.redeeming.clear();
self.redeem_attempts.clear();
self.publish_failed = false;
self.store = None;
self.wallets.clear();
self.loading = false;
@@ -397,6 +387,7 @@ impl WalletRegistry {
let signer = nostr.read(cx).signer().clone();
let (tx, rx) = flume::bounded::<Event>(100);
let consumer_signer = signer.clone();
self.notification_listener = Some(cx.background_spawn(async move {
const MAX_PROCESSED: usize = 1000;
@@ -417,7 +408,7 @@ impl WalletRegistry {
| Kind::CashuWalletSpendingHistory
);
let wallet_event = ours && is_cashu || is_token_deletion(&event);
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));
@@ -443,21 +434,22 @@ impl WalletRegistry {
}));
self.tasks.push(cx.spawn(async move |this, cx| {
let me = consumer_signer.get_public_key_async().await.ok();
while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| {
this.absorb(event, cx);
this.absorb(event, me, cx);
})
.ok();
}
}));
}
fn absorb(&mut self, event: Event, cx: &mut Context<Self>) {
fn absorb(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
match event.kind {
Kind::CashuWallet => self.absorb_wallet(event, cx),
Kind::CashuWalletUnspentProof => self.absorb_token(event, cx),
Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx),
Kind::EventDeletion => self.absorb_deletion(event, cx),
Kind::EventDeletion => self.absorb_deletion(event, me, cx),
Kind::Custom(9321) => self.absorb_nutzap(event, cx),
_ => {}
}
@@ -549,7 +541,15 @@ impl WalletRegistry {
}));
}
fn absorb_deletion(&mut self, event: Event, cx: &mut Context<Self>) {
fn absorb_deletion(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
if me != Some(event.pubkey) {
log::warn!(
"ignoring kind 5 deletion {} from a foreign author",
event.id
);
return;
}
let mut removed_any = false;
for tag in event.tags.iter() {
if tag.kind() != "e" {
@@ -807,6 +807,9 @@ impl WalletRegistry {
self.job_running = true;
let job_handle = job.clone();
let publish_failed = self.publish_failed;
self.tasks.push(cx.spawn(async move |this, cx| {
let result = Tokio::spawn_result(cx, async move {
let ctx = ops::JobCtx {
@@ -815,6 +818,7 @@ impl WalletRegistry {
signer,
client,
tokens,
publish_failed,
};
job.run(ctx).await
@@ -823,7 +827,7 @@ impl WalletRegistry {
this.update(cx, |this, cx| {
this.job_running = false;
this.apply_outcome(result, cx);
this.apply_outcome(job_handle, result, cx);
for job in std::mem::take(&mut this.job_queued) {
this.schedule_job(job, cx);
@@ -833,18 +837,51 @@ impl WalletRegistry {
}));
}
fn apply_outcome(&mut self, result: Result<ops::Outcome, Error>, cx: &mut Context<Self>) {
fn apply_outcome(
&mut self,
job: ops::Job,
result: Result<ops::Outcome, Error>,
cx: &mut Context<Self>,
) {
let outcome = match result {
Ok(outcome) => outcome,
Err(error) => {
if !matches!(job, ops::Job::CreateQuote { .. }) {
self.publish_failed = true;
}
if !matches!(job, ops::Job::CreateQuote { .. } | ops::Job::Reconcile) {
self.schedule_job(ops::Job::Reconcile, cx);
}
if let ops::Job::RedeemNutzap { nutzap_id, .. } = &job {
self.redeeming.remove(nutzap_id);
let attempts = self.redeem_attempts.entry(*nutzap_id).or_default();
*attempts += 1;
if *attempts <= MAX_REDEEM_ATTEMPTS {
self.job_queued.push(job.clone());
} else {
log::warn!(
"giving up redeeming nutzap {nutzap_id} after {MAX_REDEEM_ATTEMPTS} attempts"
);
}
}
self.emit_error(format!("{error:#}"), cx);
return;
}
};
match outcome {
ops::Outcome::Idle => {}
ops::Outcome::Idle => {
if matches!(job, ops::Job::ClaimQuote { .. }) {
self.emit_error("Invoice is not paid yet".to_string(), cx);
}
}
ops::Outcome::Reconciled { updates } => {
self.publish_failed = false;
let changed = !updates.is_empty();
for update in updates {
@@ -879,16 +916,6 @@ impl WalletRegistry {
self.emit_updated(cx);
log::info!("minted {amount} sats from quote {quote_id}");
}
ops::Outcome::Melted { update, amount } => {
self.apply_token_update(update);
self.emit_updated(cx);
log::info!("melted {amount} sats");
}
ops::Outcome::Sent { update, token } => {
self.apply_token_update(update);
cx.emit(WalletEvent::TokenCreated(token));
self.emit_updated(cx);
}
ops::Outcome::NutzapSent { update, amount } => {
self.apply_token_update(update);
self.emit_updated(cx);
@@ -905,6 +932,7 @@ impl WalletRegistry {
amount,
} => {
self.redeeming.remove(&nutzap_id);
self.redeem_attempts.remove(&nutzap_id);
self.redeemed.insert(nutzap_id);
self.apply_token_update(update);
self.emit_updated(cx);
+130 -117
View File
@@ -8,8 +8,8 @@ use cdk::amount::SplitTarget;
use cdk::mint_url::MintUrl;
use cdk::nuts::nut00::KnownMethod;
use cdk::nuts::{
CurrencyUnit, Id, KeySetInfo, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs,
SecretKey, SpendingConditions, State,
CurrencyUnit, Id, KeySetInfo, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey,
SpendingConditions, State,
};
use cdk::secret::Secret;
use cdk::wallet::types::ProofInfo;
@@ -35,14 +35,6 @@ pub(crate) enum Job {
mint: MintUrl,
quote_id: String,
},
Melt {
mint: MintUrl,
invoice: String,
},
SendToken {
mint: MintUrl,
amount: u64,
},
SendNutzap {
recipient: PublicKey,
amount: u64,
@@ -117,67 +109,6 @@ impl Job {
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(&quote.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::SendNutzap {
recipient,
amount,
@@ -330,14 +261,6 @@ pub(crate) enum Outcome {
update: TokenUpdate,
amount: u64,
},
Melted {
update: TokenUpdate,
amount: u64,
},
Sent {
update: TokenUpdate,
token: String,
},
NutzapSent {
update: TokenUpdate,
amount: u64,
@@ -361,6 +284,8 @@ pub(crate) struct JobCtx<S> {
pub(crate) client: Client,
/// Relay token events at schedule time.
pub(crate) tokens: HashMap<EventId, TokenEvent>,
/// A previous job errored after possibly mutating the store
pub(crate) publish_failed: bool,
}
impl<S> JobCtx<S>
@@ -426,35 +351,14 @@ where
log::warn!("saga recovery failed for {mint_url}: {error:#}");
}
let minted = wallet.mint_unissued_quotes().await?;
if minted == Amount::ZERO {
continue;
}
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
let states = vec![State::Pending, State::Reserved, State::PendingSpent];
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?;
let in_flight = wallet.get_proofs_by_states(states).await?;
let in_flight_ys = proof_ys(in_flight.iter())?;
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();
let mut unseen = Vec::new();
for token in tokens
.values()
@@ -468,23 +372,104 @@ where
continue;
}
};
if relay_ys.insert(info.y) {
added.push(info);
if !relay_ys.insert(info.y) {
continue;
}
if local_ys.contains(&info.y) || in_flight_ys.contains(&info.y) {
continue;
}
unseen.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<_>, _>>()?;
if unseen.is_empty() {
continue;
}
wallet.localstore.update_proofs(added, removed).await?;
let unseen_proofs: Proofs = unseen.iter().map(|info| info.proof.clone()).collect();
let spent = match wallet.check_proofs_spent(unseen_proofs).await {
Ok(states) => states
.into_iter()
.filter(|state| state.state == State::Spent)
.map(|state| state.y)
.collect::<HashSet<_>>(),
Err(error) => {
log::warn!("mint state check failed, accepting unseen proofs: {error:#}");
HashSet::new()
}
};
let added: Vec<ProofInfo> = unseen
.into_iter()
.filter(|info| !spent.contains(&info.y))
.collect();
if !added.is_empty() {
wallet.localstore.update_proofs(added, Vec::new()).await?;
}
}
for (mint_url, wallet) in &self.wallets {
let minted = wallet.mint_unissued_quotes().await?;
if minted != Amount::ZERO {
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);
}
let relay_ys = relay_proof_ys(&tokens, mint_url);
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
if local_ys != relay_ys {
if self.publish_failed {
let update = self
.sync_token_events(
wallet,
nostr_mint(mint_url)?,
&previous_ids(&tokens, mint_url),
None,
)
.await?;
for id in &update.destroyed {
tokens.remove(id);
}
tokens.insert(update.event_id, update.token.clone());
updates.push(update);
} else {
let removed = wallet
.get_unspent_proofs()
.await?
.into_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(Vec::new(), removed).await?;
}
}
}
Ok(Outcome::Reconciled { updates })
@@ -499,6 +484,34 @@ fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<Ev
.collect()
}
fn proof_ys<'a>(
proofs: impl Iterator<Item = &'a Proof>,
) -> Result<HashSet<cdk::nuts::PublicKey>, Error> {
proofs.map(|proof| proof.y().map_err(Error::from)).collect()
}
/// Proof ys referenced by the relay token events of one mint
fn relay_proof_ys(
tokens: &HashMap<EventId, TokenEvent>,
mint: &MintUrl,
) -> HashSet<cdk::nuts::PublicKey> {
let mut ys = HashSet::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint.to_string())
{
for proof in &token.proofs {
match proof_info(proof, mint) {
Ok(info) => {
ys.insert(info.y);
}
Err(error) => log::warn!("skipping proof: {error:#}"),
}
}
}
ys
}
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
async fn recipient_wallet_info<S>(
ctx: &JobCtx<S>,