feat: add support for cashu wallet (nip60 & nip61) (#71)
Reviewed-on: #71
This commit was merged in pull request #71.
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
use anyhow::{Error, anyhow};
|
||||
use hkdf::Hkdf;
|
||||
use nostr::nips::nip60::TokenEvent;
|
||||
use nostr_sdk::prelude::*;
|
||||
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,
|
||||
pub(crate) privkey: SecretKey,
|
||||
pub(crate) mints: Vec<Url>,
|
||||
}
|
||||
|
||||
impl WalletData {
|
||||
/// A new wallet with a fresh key.
|
||||
pub(crate) fn generate(first_mint: Url) -> Self {
|
||||
Self {
|
||||
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?)
|
||||
}
|
||||
}
|
||||
|
||||
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}"))?;
|
||||
|
||||
let mut privkey = None;
|
||||
let mut mints = Vec::new();
|
||||
for row in rows {
|
||||
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
|
||||
continue;
|
||||
};
|
||||
match key.as_str() {
|
||||
"privkey" if privkey.is_none() => privkey = Some(SecretKey::from_hex(value)?),
|
||||
"mint" => {
|
||||
if let Ok(mint) = Url::parse(value) {
|
||||
mints.push(mint);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(privkey) = privkey else {
|
||||
return Err(anyhow!("wallet event is missing the private key"));
|
||||
};
|
||||
if mints.is_empty() {
|
||||
return Err(anyhow!("wallet event is missing mints"));
|
||||
}
|
||||
|
||||
Ok((privkey, mints))
|
||||
}
|
||||
|
||||
pub(crate) async fn decrypt_token_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
event: &Event,
|
||||
) -> Result<TokenEvent, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.pubkey != *me {
|
||||
return Err(anyhow!("token event was not published by this account"));
|
||||
}
|
||||
|
||||
let content = signer.nip44_decrypt_async(me, &event.content).await?;
|
||||
|
||||
let token =
|
||||
serde_json::from_str(&content).map_err(|error| anyhow!("invalid token event: {error}"))?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_token_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
token: &TokenEvent,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let content = signer
|
||||
.nip44_encrypt_async(me, &serde_json::to_string(token)?)
|
||||
.await?;
|
||||
|
||||
Ok(EventBuilder::new(Kind::CashuWalletUnspentProof, content)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// NIP-09 deletion carrying the `k: 7375` tag required by NIP-60.
|
||||
pub(crate) async fn build_token_deletion<S>(signer: &S, ids: &[EventId]) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut tags: Vec<Tag> = ids.iter().map(|id| Tag::event(*id)).collect();
|
||||
tags.push(Tag::custom("k", ["7375".to_string()]));
|
||||
|
||||
Ok(EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(tags)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Decrypted 7376 payload, enough for the wallet panel's history list.
|
||||
pub(crate) struct HistoryPayload {
|
||||
pub(crate) direction: TransactionDirection,
|
||||
pub(crate) amount: u64,
|
||||
}
|
||||
|
||||
pub(crate) async fn decrypt_history_event<S>(
|
||||
signer: &S,
|
||||
me: &PublicKey,
|
||||
event: &Event,
|
||||
) -> Result<HistoryPayload, Error>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.pubkey != *me {
|
||||
return Err(anyhow!("history event was not published by this account"));
|
||||
}
|
||||
|
||||
let content = signer.nip44_decrypt_async(me, &event.content).await?;
|
||||
let rows: Vec<Vec<String>> = serde_json::from_str(&content)
|
||||
.map_err(|error| anyhow!("invalid history event: {error}"))?;
|
||||
|
||||
let mut direction = None;
|
||||
let mut amount = None;
|
||||
for row in rows {
|
||||
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
|
||||
continue;
|
||||
};
|
||||
match key.as_str() {
|
||||
"direction" if direction.is_none() => direction = value.parse().ok(),
|
||||
"amount" if amount.is_none() => amount = value.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(direction) = direction else {
|
||||
return Err(anyhow!("history event is missing the direction"));
|
||||
};
|
||||
let Some(amount) = amount else {
|
||||
return Err(anyhow!("history event is missing the amount"));
|
||||
};
|
||||
|
||||
Ok(HistoryPayload { direction, amount })
|
||||
}
|
||||
|
||||
/// Build the public kind 9321 nutzap event carrying P2PK-locked proofs.
|
||||
pub(crate) async fn build_nutzap_event<S>(
|
||||
signer: &S,
|
||||
recipient: PublicKey,
|
||||
mint: &str,
|
||||
proofs: &[String],
|
||||
target: Option<EventId>,
|
||||
comment: &str,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let mut tags: Vec<Tag> = vec![
|
||||
Tag::custom("p", [recipient.to_hex()]),
|
||||
Tag::custom("u", [mint.to_string()]),
|
||||
Tag::custom("unit", ["sat".to_string()]),
|
||||
];
|
||||
|
||||
for proof in proofs {
|
||||
tags.push(Tag::custom("proof", [proof.clone()]));
|
||||
}
|
||||
|
||||
if let Some(target) = target {
|
||||
tags.push(Tag::custom("e", [target.to_hex()]));
|
||||
}
|
||||
|
||||
Ok(EventBuilder::new(Kind::Custom(9321), comment)
|
||||
.tags(tags)
|
||||
.finalize_async(signer)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
history: &History,
|
||||
created: EventId,
|
||||
destroyed: &[EventId],
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut rows = vec![
|
||||
vec!["direction".to_string(), history.direction.to_string()],
|
||||
vec!["amount".to_string(), history.amount.to_string()],
|
||||
];
|
||||
|
||||
for id in destroyed {
|
||||
rows.push(vec![
|
||||
"e".to_string(),
|
||||
id.to_hex(),
|
||||
String::new(),
|
||||
"destroyed".to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
rows.push(vec![
|
||||
"e".to_string(),
|
||||
created.to_hex(),
|
||||
String::new(),
|
||||
"created".to_string(),
|
||||
]);
|
||||
|
||||
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) = history.sender {
|
||||
tags.push(Tag::custom("p", [sender.to_hex()]));
|
||||
}
|
||||
|
||||
let content = signer
|
||||
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
|
||||
.await?;
|
||||
|
||||
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: &[RelayUrl],
|
||||
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)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_wallet_content() {
|
||||
let privkey = SecretKey::generate();
|
||||
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
|
||||
|
||||
let content = serde_json::to_string(&vec![
|
||||
vec!["privkey".to_string(), privkey.to_secret_hex()],
|
||||
vec!["mint".to_string(), mint.to_string()],
|
||||
])
|
||||
.expect("serialization cannot fail");
|
||||
|
||||
let (parsed_key, mints) = parse_wallet_content(&content).expect("valid content");
|
||||
assert_eq!(parsed_key.to_secret_hex(), privkey.to_secret_hex());
|
||||
assert_eq!(mints, vec![mint]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_content_rejects_missing_privkey() {
|
||||
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
|
||||
let content = serde_json::to_string(&vec![vec!["mint".to_string(), mint.to_string()]])
|
||||
.expect("serialization cannot fail");
|
||||
|
||||
assert!(parse_wallet_content(&content).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_is_deterministic_per_key() {
|
||||
let key = SecretKey::generate();
|
||||
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 =
|
||||
WalletData::derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail");
|
||||
assert_ne!(first, other);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user