feat(ecash): Minibits @minibits.cash Lightning address on Cashu receive

The wallet used Minibits only as a Cashu mint, so the node could hold and
swap ecash there but had no addressable name at it. This derives a LUD-16
Lightning address (name@minibits.cash) from the node's own ecash wallet and
surfaces it in the ecash Receive tab above the existing paste-token box.

Identity reuses the NUT-13 ecash phrase, so there is no second secret:
  - seedHash = sha256(mnemonic.to_seed("")) — the exact hash the Minibits app
    stores, so restoring the same phrase recovers the same address both ways;
  - Nostr keys via NIP-06 at m/44'/1237'/0'/0/0 (nostr-sdk Keys::from_mnemonic,
    pinned by a unit test against the NIP-06 vector so a bump cannot silently
    move the derivation and orphan the profile).

Backend (wallet/minibits.rs) implements the verified live /v3 flow: NIP-42
challenge/verify -> JWT, idempotent /profile registration with collision
retry, and /claim polling that NIP-04-decrypts each token (service pubkey read
from the address's own LUD-16 metadata, constant fallback) and redeems it
through ecash::receive_token. Mainnet-only; state cached 0600 in
wallet/minibits.json.

New RPC: wallet.ecash-lnaddress (register-or-read, idempotent) and
wallet.ecash-lnaddress-claim (sweep Lightning payments into ecash). The modal
fetches the address on tab open, renders QR + copy, and sweeps claims while
open; a registration failure is non-fatal so paste-token still works.

Verified end-to-end against production: registered a disposable
@minibits.cash address, confirmed it resolves via /.well-known/lnurlp, and the
claim poll returns cleanly.
This commit is contained in:
2026-09-08 02:49:02 +00:00
parent db52c06a72
commit 6effc6b574
9 changed files with 790 additions and 2 deletions
+3 -2
View File
@@ -90,8 +90,9 @@ rustls-pemfile = "1.0"
webpki = { package = "rustls-webpki", version = "0.101" }
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
# Nostr (node discovery + NIP-44 encrypted peer handshake)
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
# Nostr (node discovery + NIP-44 encrypted peer handshake).
# nip06: NIP-06 key derivation for the Minibits @minibits.cash profile flow.
nostr-sdk = { version = "0.44", features = ["nip04", "nip06", "nip44"] }
# Backup encryption (DID identity export) + TOTP 2FA encryption
argon2 = "0.5.3"
@@ -269,6 +269,8 @@ impl RpcHandler {
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
"wallet.ecash-lnaddress" => self.handle_wallet_ecash_lnaddress().await,
"wallet.ecash-lnaddress-claim" => self.handle_wallet_ecash_lnaddress_claim().await,
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
+19
View File
@@ -421,6 +421,25 @@ impl RpcHandler {
}))
}
/// `wallet.ecash-lnaddress` — the node's Minibits Lightning address
/// (`<name>@minibits.cash`, LUD-16), derived from and authenticated by the
/// ecash wallet's own seed. Registers the profile on first use; safe to call
/// on every open of the Cashu receive screen (it is idempotent).
pub(super) async fn handle_wallet_ecash_lnaddress(&self) -> Result<serde_json::Value> {
crate::wallet::minibits::lnaddress(&self.config.data_dir).await
}
/// `wallet.ecash-lnaddress-claim` — redeem any Lightning payments that
/// arrived on the node's Minibits address as ecash. Returns the sats swept in
/// (0 when nothing was waiting), so the UI can refresh its balance.
pub(super) async fn handle_wallet_ecash_lnaddress_claim(&self) -> Result<serde_json::Value> {
let outcome = crate::wallet::minibits::claim_and_redeem(&self.config.data_dir).await?;
Ok(serde_json::json!({
"claimed_count": outcome.claimed_count,
"received_sats": outcome.received_sats,
}))
}
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
Ok(serde_json::json!({
+641
View File
@@ -0,0 +1,641 @@
//! The Minibits `@minibits.cash` Lightning address (LUD-16) for the node's
//! ecash wallet.
//!
//! ## Why this exists next to the mint client
//!
//! The ecash wallet already talks to `mint.minibits.cash` as a plain Cashu
//! mint (`wallet::mint_client`): mint/melt quotes, swap, receive. That gives the
//! node ecash *from* Minibits, but not a *name* at Minibits. A human-readable
//! Lightning address like `braveharbor42@minibits.cash` is a separate service —
//! the Minibits profile API at `api.minibits.cash/v3` — and it is what lets any
//! Lightning wallet pay this node by typing an address, with the payment landing
//! as ecash.
//!
//! ## Identity: the ecash wallet *is* the Minibits wallet
//!
//! Minibits ties an address to a wallet by `seedHash`, and authenticates the
//! wallet with a NIP-06 Nostr keypair. Both come from the *existing* NUT-13
//! ecash phrase (`wallet::nut13`), so there is no second secret to back up:
//!
//! - `seedHash = sha256(mnemonic.to_seed(""))` — the exact bytes the Minibits
//! app hashes, so restoring the same phrase in the Minibits app recovers the
//! same address (and vice-versa).
//! - Nostr keys via NIP-06 at `m/44'/1237'/0'/0/0`. `nostr_sdk::Keys::from_mnemonic`
//! uses that path with an empty BIP-39 passphrase — byte-for-byte the derivation
//! the Minibits app (nostr-tools `accountFromSeedWords`) does, verified against
//! the crate's own NIP-06 test vector.
//!
//! ## Flow (all verified against the live v3 API)
//!
//! 1. `POST /auth/challenge {pubkey}` → `{challenge, createdAt}`.
//! 2. Sign a NIP-42 kind-22242 event (`relay` + `challenge` tags, server's
//! `createdAt`) with the Nostr key.
//! 3. `POST /auth/verify {pubkey, challenge, signature}` → JWT access token.
//! 4. `POST /profile {walletId, seedHash}` → the assigned `lud16`/`nip05`.
//! Idempotent per pubkey: re-registering returns the existing address.
//! 5. `POST /claim {seedHash}` → NIP-04-encrypted Cashu tokens for Lightning
//! payments sent to the address; decrypt with the Nostr key + the server's
//! Nostr pubkey, then redeem through `ecash::receive_token`.
//!
//! Only runs on the mainnet ecash network — Minibits is a mainnet service, and a
//! testnet node must not register a profile or hit the production API.
use super::ecash::{self, EcashNetwork};
use super::nut13;
use anyhow::{anyhow, Context, Result};
use base64::Engine;
use nostr_sdk::nips::{nip04, nip06::FromMnemonic};
use nostr_sdk::{EventBuilder, Kind, RelayUrl, Tag, TagKind, Timestamp, ToBech32};
use rand::seq::SliceRandom;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::Path;
use tokio::fs;
use tracing::{debug, info, warn};
/// Minibits profile/LNURL API. Confirmed live: `/v3/auth/challenge`,
/// `/v3/profile`, `/v3/claim` (the older `/v2` host no longer serves profiles).
const API_BASE: &str = "https://api.minibits.cash/v3";
/// The relay named in the NIP-42 auth event. Matches the value the Minibits app
/// sends and the relay the service publishes in its NIP-05 record.
const RELAY_URL: &str = "wss://relay.minibits.cash";
/// NIP-42 client authentication event kind.
const AUTH_KIND: u16 = 22242;
/// The Minibits service Nostr pubkey that NIP-04-encrypts claimed tokens. Used
/// only as a fallback: the authoritative value is read from the address's own
/// LUD-16 metadata (`nostrPubkey`) at claim time, so a Minibits key rotation
/// does not strand claims.
const FALLBACK_SERVER_NOSTR_PUBKEY: &str =
"beeb48407a6f087ea8f76dc384a5d88c67ced9bd9fb0cdba90930210df3d92e7";
/// Re-authenticate this long before the JWT actually expires, so a claim poll
/// never races the expiry boundary.
const TOKEN_EXPIRY_SKEP_SECS: i64 = 120;
const STATE_FILE: &str = "wallet/minibits.json";
/// Small word lists for the generated address name. Uniqueness comes from the
/// numeric suffix plus the retry-on-collision below — the Minibits server rejects
/// a name already taken by another wallet and we simply draw another, so these do
/// not need to be exhaustive (the Minibits app ships lists hundreds long).
const ADJECTIVES: &[&str] = &[
"calm", "brave", "quiet", "solar", "rapid", "noble", "lunar", "vivid", "amber", "crisp",
"eager", "fancy", "gentle", "happy", "jolly", "keen", "lucky", "mellow", "nimble", "proud",
"quick", "rusty", "sunny", "tidy", "urban", "vital", "warm", "zesty", "bold", "clever",
"daring", "epic", "fiery", "grand", "humble", "iron", "merry", "polar", "sleek", "wild",
];
const NOUNS: &[&str] = &[
"harbor", "meadow", "canyon", "summit", "river", "forest", "island", "comet", "nebula",
"orbit", "quartz", "maple", "willow", "falcon", "otter", "badger", "salmon", "crane",
"ridge", "creek", "glade", "grove", "prairie", "delta", "cobalt", "onyx", "topaz", "ember",
"anchor", "lantern", "beacon", "cabin", "drift", "signal", "thunder", "zephyr", "marble",
"pebble", "sequoia", "tundra",
];
/// Persistent state for the node's Minibits address.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MinibitsState {
/// The chosen wallet name (the `name` in `name@minibits.cash`).
pub wallet_id: String,
/// The full LUD-16 Lightning address, e.g. `braveharbor42@minibits.cash`.
pub lud16: String,
/// NIP-05 address (Minibits sets this equal to `lud16`).
pub nip05: String,
/// This node's NIP-06 Nostr pubkey (hex) the profile is bound to.
pub nostr_pubkey: String,
/// `sha256(seed)` — the wallet identifier Minibits keys claims on.
pub seed_hash: String,
/// Cached JWT access token.
#[serde(default)]
pub access_token: String,
/// Access-token expiry (unix seconds); 0 when unknown/expired.
#[serde(default)]
pub access_expires: i64,
/// Server Nostr pubkey used to decrypt claims, discovered from LUD-16.
#[serde(default)]
pub server_nostur_pubkey: String,
#[serde(default)]
pub created_at: String,
}
/// A fresh Nostr keypair + seedHash derived from the node's ecash phrase.
struct MinibitsIdentity {
keys: nostr_sdk::Keys,
seed_hash: String,
}
/// Derive the Minibits identity (NIP-06 Nostr keys + seedHash) from the node's
/// ecash mnemonic. Both are deterministic, so the address and claims are
/// recoverable from the same 24 words the ecash already lives on.
fn derive_identity(phrase: &str, seed: &[u8; 64]) -> Result<MinibitsIdentity> {
let keys = nostr_sdk::Keys::from_mnemonic(phrase, None::<&str>)
.map_err(|e| anyhow!("NIP-06 derivation failed: {e}"))?;
let seed_hash = hex::encode(Sha256::digest(seed));
Ok(MinibitsIdentity { keys, seed_hash })
}
/// A Minibits profile record — the fields we read off every profile response.
#[derive(Debug, Deserialize)]
struct ProfileRecord {
#[serde(rename = "walletId")]
wallet_id: String,
#[serde(default)]
nip05: String,
#[serde(default)]
lud16: Option<String>,
#[serde(default)]
pubkey: String,
}
/// Turn a non-2xx Minibits response into a readable error, surfacing the
/// server's `error.name`/`error.message` when present.
fn minibits_error(status: reqwest::StatusCode, body: &str) -> anyhow::Error {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(err) = v.get("error") {
let name = err.get("name").and_then(|n| n.as_str()).unwrap_or("ERROR");
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("");
return anyhow!("Minibits API error {status}: {name} {msg}");
}
}
anyhow!(
"Minibits API error {status}: {}",
&body[..body.len().min(180)]
)
}
fn state_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(STATE_FILE)
}
async fn load_state(data_dir: &Path) -> Result<Option<MinibitsState>> {
let path = state_path(data_dir);
match fs::read_to_string(&path).await {
Ok(s) => {
let st: MinibitsState = serde_json::from_str(&s)
.with_context(|| format!("Failed to parse {}", path.display()))?;
if st.wallet_id.is_empty() {
Ok(None)
} else {
Ok(Some(st))
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
}
}
/// Write the state file 0600 — it holds a bearer JWT. Same sensitivity class as
/// the ecash files it sits beside, so it gets the same owner-only mode.
async fn save_state(data_dir: &Path, state: &MinibitsState) -> Result<()> {
let path = state_path(data_dir);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.context("Failed to create the wallet directory")?;
}
let content = serde_json::to_string_pretty(state)
.context("Failed to serialize the Minibits profile")?;
fs::write(&path, content)
.await
.with_context(|| format!("Failed to write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.await
.with_context(|| format!("Failed to chmod 0600 {}", path.display()))?;
}
Ok(())
}
/// Read the `exp` claim (unix seconds) from a JWT without verifying it — the
/// token comes straight from Minibits over TLS; we only use the expiry to decide
/// when to refresh.
fn jwt_expiry(token: &str) -> Option<i64> {
let payload = token.split('.').nth(1)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
v.get("exp")?.as_i64()
}
/// Run the NIP-42 challenge/verify dance and return the access token plus its
/// expiry. Idempotent and cheap enough to redo whenever the cached token lapses.
async fn authenticate(
client: &reqwest::Client,
keys: &nostr_sdk::Keys,
) -> Result<(String, i64)> {
let ch: serde_json::Value = client
.post(format!("{API_BASE}/auth/challenge"))
.json(&serde_json::json!({ "pubkey": keys.public_key().to_hex() }))
.send()
.await
.context("Minibits auth challenge request failed")?
.error_for_status()
.context("Minibits auth challenge rejected")?
.json()
.await
.context("Minibits auth challenge was not JSON")?;
let challenge = ch["challenge"]
.as_str()
.ok_or_else(|| anyhow!("Minibits challenge response missing 'challenge'"))?
.to_string();
let created_at = ch["createdAt"]
.as_u64()
.ok_or_else(|| anyhow!("Minibits challenge response missing 'createdAt'"))?;
// Sign a NIP-42 auth event, stamping the server's own createdAt so the
// signature lines up with the challenge it was issued for.
let unsigned = EventBuilder::new(Kind::from(AUTH_KIND), "")
.tag(Tag::relay(
RelayUrl::parse(RELAY_URL).context("Invalid Minibits relay URL")?,
))
.tag(Tag::custom(
TagKind::custom("challenge"),
vec![challenge.clone()],
))
.custom_created_at(Timestamp::from(created_at))
.build(keys.public_key());
let signed = unsigned
.sign_with_keys(keys)
.map_err(|e| anyhow!("Failed to sign the Minibits auth challenge: {e}"))?;
let tok: serde_json::Value = client
.post(format!("{API_BASE}/auth/verify"))
.json(&serde_json::json!({
"pubkey": keys.public_key().to_hex(),
"challenge": challenge,
"signature": hex::encode(signed.sig.serialize()),
}))
.send()
.await
.context("Minibits auth verify request failed")?
.error_for_status()
.context("Minibits auth verify rejected (bad challenge signature)")?
.json()
.await
.context("Minibits auth verify was not JSON")?;
let access = tok["accessToken"]
.as_str()
.ok_or_else(|| anyhow!("Minibits verify response missing 'accessToken'"))?
.to_string();
let expires = jwt_expiry(&access).unwrap_or_else(|| {
chrono::Utc::now().timestamp() + 3600 // conservative fallback
});
Ok((access, expires))
}
/// True when the cached access token is missing or about to lapse.
fn token_is_stale(state: &MinibitsState) -> bool {
let now = chrono::Utc::now().timestamp();
state.access_token.is_empty() || now + TOKEN_EXPIRY_SKEP_SECS >= state.access_expires
}
/// Ensure we hold a valid access token, re-authenticating as needed and folding
/// the fresh token back into `state` (which the caller persists).
async fn ensure_token(
client: &reqwest::Client,
state: &mut MinibitsState,
keys: &nostr_sdk::Keys,
) -> Result<()> {
if token_is_stale(state) {
let (access, expires) = authenticate(client, keys).await?;
state.access_token = access;
state.access_expires = expires;
debug!("Minibits: authenticated (token valid to {})", expires);
}
Ok(())
}
/// Draw a fresh readable wallet name, Minibits-style: adjective + noun + number.
fn generate_wallet_id() -> String {
let mut rng = rand::thread_rng();
let adj = ADJECTIVES.choose(&mut rng).copied().unwrap_or("quiet");
let noun = NOUNS.choose(&mut rng).copied().unwrap_or("harbor");
let num = rand::Rng::gen_range(&mut rng, 1..=999);
format!("{adj}{noun}{num}")
}
/// Register the profile, returning the assigned address. Retries with a new name
/// a handful of times if the generated name is already taken by another wallet.
async fn register_profile(
client: &reqwest::Client,
access: &str,
seed_hash: &str,
) -> Result<ProfileRecord> {
let mut last_err = None;
for attempt in 0..6 {
let wallet_id = generate_wallet_id();
let resp = client
.post(format!("{API_BASE}/profile"))
.bearer_auth(access)
.json(&serde_json::json!({ "walletId": wallet_id, "seedHash": seed_hash }))
.send()
.await
.context("Minibits profile registration request failed")?;
let status = resp.status();
let body = resp
.text()
.await
.context("Minibits profile response body read failed")?;
if status.is_success() {
let rec: ProfileRecord = serde_json::from_str(&body)
.context("Minibits profile response was not the expected shape")?;
return Ok(rec);
}
// Name collision → draw another. Anything else is fatal.
let is_taken = body.contains("ALREADY_EXISTS") || body.contains("already");
if is_taken {
warn!("Minibits name '{wallet_id}' taken, retrying (attempt {attempt})");
last_err = Some(minibits_error(status, &body));
continue;
}
return Err(minibits_error(status, &body));
}
Err(last_err.unwrap_or_else(|| anyhow!("Could not register a free Minibits name")))
}
/// Fetch the LUD-16 metadata for our own address and read the service's
/// `nostrPubkey` — the key that NIP-04-encrypts claimed tokens.
async fn discover_server_nostr_pubkey(
client: &reqwest::Client,
lud16: &str,
) -> Result<String> {
let (name, domain) = lud16
.split_once('@')
.ok_or_else(|| anyhow!("Malformed Minibits address '{lud16}'"))?;
let url = format!("https://{domain}/.well-known/lnurlp/{name}");
let md: serde_json::Value = client
.get(&url)
.send()
.await
.context("Minibits LUD-16 metadata request failed")?
.json()
.await
.context("Minibits LUD-16 metadata was not JSON")?;
md.get("nostrPubkey")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("Minibits LUD-16 metadata missing 'nostrPubkey'"))
}
/// Load the ecash phrase, or establish it from the node master seed when this
/// node has not materialised one yet — but never fail an address request just
/// because a phrase is not on disk; report that clearly instead.
async fn ecash_phrase(data_dir: &Path) -> Result<(String, [u8; 64])> {
let seed = nut13::load_seed(data_dir)
.await?
.ok_or_else(|| anyhow!("The ecash wallet has no seed yet — restore or reveal it first"))?;
Ok((seed.phrase(), seed.seed_bytes()))
}
/// Get (registering on first use) the node's Minibits Lightning address.
///
/// On mainnet this registers a profile with the Minibits server the first time
/// and caches it in `wallet/minibits.json`; later calls return the cached address
/// and refresh the access token as needed. Registration is idempotent per pubkey,
/// so a node that restores the same ecash phrase recovers the same address.
pub async fn lnaddress(data_dir: &Path) -> Result<serde_json::Value> {
let network = ecash::load_network(data_dir).await;
if network == EcashNetwork::Testnet {
return Err(anyhow!(
"Minibits Lightning addresses are mainnet-only — switch the ecash network to mainnet to set one up"
));
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.build()
.context("Failed to build the Minibits HTTP client")?;
let (phrase, seed) = ecash_phrase(data_dir).await?;
let identity = derive_identity(&phrase, &seed)?;
let mut state = match load_state(data_dir).await? {
Some(st) => st,
None => {
info!("Minibits: no profile yet, registering a new @minibits.cash address");
let (access, expires) = authenticate(&client, &identity.keys).await?;
let rec = register_profile(&client, &access, &identity.seed_hash).await?;
MinibitsState {
wallet_id: rec.wallet_id.clone(),
lud16: rec
.lud16
.clone()
.unwrap_or_else(|| format!("{}@minibits.cash", rec.wallet_id)),
nip05: rec.nip05.clone(),
nostr_pubkey: rec.pubkey.clone(),
seed_hash: identity.seed_hash.clone(),
access_token: access,
access_expires: expires,
server_nostur_pubkey: String::new(),
created_at: chrono::Utc::now().to_rfc3339(),
}
}
};
ensure_token(&client, &mut state, &identity.keys).await?;
save_state(data_dir, &state).await?;
Ok(serde_json::json!({
"address": state.lud16,
"nip05": state.nip05,
"wallet_id": state.wallet_id,
"nostr_pubkey": state.nostr_pubkey,
"npub": identity.keys.public_key().to_bech32().unwrap_or_default(),
}))
}
/// Outcome of a claim poll.
#[derive(Debug, Serialize)]
pub struct ClaimOutcome {
pub claimed_count: usize,
pub received_sats: u64,
}
/// Poll Minibits for Lightning payments sent to the node's address and redeem
/// each into the ecash wallet.
///
/// Each claim is a NUT-00 token NIP-04-encrypted by the Minibits service to this
/// wallet's Nostr key; decrypting it needs the service pubkey (discovered from
/// our LUD-16 metadata, falling back to the known constant). A token that fails
/// to decrypt or redeem is logged and skipped rather than aborting the batch —
/// but note a claim is consumed server-side the moment it is fetched, so any
/// failure here is surfaced loudly since those coins cannot be re-fetched.
pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
let network = ecash::load_network(data_dir).await;
if network == EcashNetwork::Testnet {
return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 });
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build the Minibits HTTP client")?;
let (phrase, seed) = ecash_phrase(data_dir).await?;
let identity = derive_identity(&phrase, &seed)?;
let mut state = match load_state(data_dir).await? {
Some(st) => st,
// Nothing is addressable until a profile exists; registering lazily here
// means a payment could not have arrived, so claiming is a no-op.
None => return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 }),
};
ensure_token(&client, &mut state, &identity.keys).await?;
// Discover (and cache) the service key that wraps claimed tokens.
if state.server_nostur_pubkey.is_empty() {
match discover_server_nostr_pubkey(&client, &state.lud16).await {
Ok(pk) => state.server_nostur_pubkey = pk,
Err(e) => {
warn!("Minibits: could not read service Nostr pubkey ({e}); using fallback");
state.server_nostur_pubkey = FALLBACK_SERVER_NOSTR_PUBKEY.to_string();
}
}
}
save_state(data_dir, &state).await?;
let server_pk = nostr_sdk::PublicKey::from_hex(&state.server_nostur_pubkey)
.context("Service Nostr pubkey was not valid hex")?;
let resp = client
.post(format!("{API_BASE}/claim"))
.bearer_auth(&state.access_token)
.json(&serde_json::json!({ "seedHash": state.seed_hash }))
.send()
.await
.context("Minibits claim request failed")?;
let status = resp.status();
let body = resp.text().await.context("Minibits claim body read failed")?;
if !status.is_success() {
return Err(minibits_error(status, &body));
}
let claims: Vec<serde_json::Value> =
serde_json::from_str(&body).context("Minibits claim response was not a JSON array")?;
if claims.is_empty() {
return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 });
}
let mut redeemed = 0usize;
let mut sats = 0u64;
for claim in &claims {
let enc = match claim.get("token").and_then(|t| t.as_str()) {
Some(t) => t,
None => {
warn!("Minibits claim had no 'token' field; skipping");
continue;
}
};
let decoded = match nip04::decrypt(identity.keys.secret_key(), &server_pk, enc) {
Ok(d) => d,
Err(e) => {
// Claim already consumed server-side — this is a real loss.
warn!("Minibits claim could not be decrypted ({e}); coins may be unrecoverable");
continue;
}
};
match ecash::receive_token(data_dir, &decoded).await {
Ok(got) => {
redeemed += 1;
sats += got;
info!("Minibits: redeemed a claimed payment ({got} sats)");
}
Err(e) => {
warn!("Minibits claim decrypted but failed to redeem ({e}); coins may be unrecoverable")
}
}
}
Ok(ClaimOutcome { claimed_count: redeemed, received_sats: sats })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derived_nostr_key_matches_the_nip06_vector() {
// The Minibits app derives its Nostr key at m/44'/1237'/0'/0/0 with an
// empty BIP-39 passphrase (nostr-tools accountFromSeedWords). Lock to the
// crate's own NIP-06 secret-key vector so a nostr-sdk bump cannot silently
// move our derivation and orphan the registered address.
let phrase = "leader monkey parrot ring guide accident before fence cannon height naive bean";
let keys = nostr_sdk::Keys::from_mnemonic(phrase, None::<&str>).unwrap();
assert_eq!(
hex::encode(keys.secret_key().as_secret_bytes()),
"7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a"
);
}
#[test]
fn seed_hash_is_sha256_of_the_bip39_seed() {
// Minibits hashes the *seed*, not the phrase — a regression here would
// make the node register a profile that the Minibits app cannot recover.
let phrase = "leader monkey parrot ring guide accident before fence cannon height naive bean";
let m: bip39::Mnemonic = phrase.parse().unwrap();
let seed = m.to_seed("");
let want = hex::encode(Sha256::digest(seed));
let id = derive_identity(phrase, &seed).unwrap();
assert_eq!(id.seed_hash, want);
}
#[test]
fn generated_names_are_readable_and_bounded() {
for _ in 0..200 {
let n = generate_wallet_id();
assert!(!n.is_empty());
assert!(n.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
// ends in at least one digit (the 1..=999 suffix)
assert!(n.chars().last().map(|c| c.is_ascii_digit()).unwrap_or(false));
}
}
#[test]
fn stale_token_when_missing_or_near_expiry() {
let now = chrono::Utc::now().timestamp();
assert!(token_is_stale(&MinibitsState::default()));
assert!(token_is_stale(&MinibitsState {
access_token: "x".into(),
access_expires: now + 10, // inside the skew window
..Default::default()
}));
assert!(!token_is_stale(&MinibitsState {
access_token: "x".into(),
access_expires: now + 3600,
..Default::default()
}));
}
/// Live end-to-end against the production Minibits API: register a throwaway
/// profile with a random ecash phrase and claim (nothing pending → 0). Run
/// with `cargo test -- --ignored --nocapture`. It creates one disposable
/// profile on the public service and holds no funds.
#[tokio::test]
#[ignore]
async fn registers_and_claims_against_live_minibits() {
let dir = std::env::temp_dir().join(format!("mbtest-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
// A node has an ecash phrase before it has a Minibits profile; stand up
// a fresh random one so registration derives a real identity.
nut13::establish_independent(&dir).await.unwrap();
let info = lnaddress(&dir).await.expect("live registration failed");
let addr = info["address"].as_str().unwrap().to_string();
assert!(addr.ends_with("@minibits.cash"), "bad address {addr}");
println!("registered live address: {addr}");
// A second call must return the same cached address, not register again.
let again = lnaddress(&dir).await.unwrap();
assert_eq!(again["address"].as_str().unwrap(), addr.as_str());
let out = claim_and_redeem(&dir).await.expect("live claim poll failed");
println!("claim poll: {out:?}");
assert_eq!(out.claimed_count, 0);
let _ = std::fs::remove_dir_all(&dir);
}
}
+1
View File
@@ -6,6 +6,7 @@ pub mod bdhke;
pub mod cashu;
pub mod ecash;
pub mod fedimint_client;
pub mod minibits;
pub mod mint_client;
pub mod nut13;
pub mod profits;
+12
View File
@@ -137,6 +137,18 @@ impl EcashSeed {
self.mnemonic.words().map(|w| w.to_string()).collect()
}
/// The phrase as a single string — the input to NUT-13 *and* to the NIP-06
/// Nostr derivation the Minibits profile flow needs (`crate::wallet::minibits`).
pub fn phrase(&self) -> String {
self.mnemonic.to_string()
}
/// The 64-byte BIP-39 seed. Same bytes Minibits hashes with SHA-256 to get
/// its `seedHash`, so the two wallets agree on wallet identity.
pub fn seed_bytes(&self) -> [u8; 64] {
self.seed
}
pub fn source(&self) -> SeedSource {
self.source
}
@@ -106,6 +106,27 @@
<!-- Ecash -->
<div v-if="receiveMethod === 'ecash'">
<!-- Shareable @minibits.cash Lightning address (LUD-16): any Lightning
wallet can pay this node by address, and the sats land as ecash.
Fetched on tab open; claimed payments are polled in while open. -->
<div v-if="lnAddress" class="mb-4 p-3 bg-white/5 rounded-lg text-center">
<p class="text-white/60 text-sm mb-2">{{ t('receiveBitcoin.lnAddressTitle') }}</p>
<canvas ref="lnAddressQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.lnAddressLabel') }}</p>
<p class="text-base font-mono text-white/95 break-all mb-2">{{ lnAddress }}</p>
<CopyButton :value="lnAddress" :label="t('common.copy')" />
<p class="text-white/40 text-xs mt-3 leading-relaxed">{{ t('receiveBitcoin.lnAddressHint') }}</p>
<p v-if="lnClaimedSats > 0" class="text-green-400 text-sm mt-2">
{{ t('receiveBitcoin.lnAddressReceived', { amount: lnClaimedSats.toLocaleString() }) }}
</p>
</div>
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
{{ t('receiveBitcoin.lnAddressLoading') }}
</div>
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
{{ t('receiveBitcoin.lnAddressUnavailable') }}
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
@@ -175,6 +196,11 @@ watch(() => props.show, (open) => {
arkAddress.value = ''
ecashToken.value = ''
ecashResult.value = ''
stopLnClaimPoll()
lnAddress.value = ''
lnAddressLoading.value = false
lnAddressError.value = false
lnClaimedSats.value = 0
error.value = ''
processing.value = false
if (props.autoGenerate && receiveMethod.value === 'onchain') {
@@ -193,9 +219,80 @@ const ecashResult = ref('')
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
const lnAddressQrCanvas = ref<HTMLCanvasElement | null>(null)
const processing = ref(false)
const error = ref('')
// ── Minibits Lightning address (ecash receive) ──────────────────────────────
// The ecash tab doubles as "receive onto my @minibits.cash address": the node
// derives/registers it from its own ecash seed (wallet.ecash-lnaddress) and
// sweeps any Lightning payments that land there back into ecash while the tab is
// open (wallet.ecash-lnaddress-claim). A registration failure is never fatal —
// the paste-token path below always works.
const lnAddress = ref('')
const lnAddressLoading = ref(false)
const lnAddressError = ref(false)
const lnClaimedSats = ref(0)
let lnClaimTimer: ReturnType<typeof setInterval> | null = null
async function loadLnAddress() {
if (lnAddress.value || lnAddressLoading.value) return
lnAddressLoading.value = true
lnAddressError.value = false
try {
const res = await rpcClient.call<{ address?: string }>({ method: 'wallet.ecash-lnaddress' })
lnAddress.value = res?.address || ''
if (lnAddress.value) {
await nextTick()
renderQr(lnAddress.value, lnAddressQrCanvas.value)
startLnClaimPoll()
} else {
lnAddressError.value = true
}
} catch {
lnAddressError.value = true
} finally {
lnAddressLoading.value = false
}
}
function stopLnClaimPoll() {
if (lnClaimTimer) {
clearInterval(lnClaimTimer)
lnClaimTimer = null
}
}
function startLnClaimPoll() {
stopLnClaimPoll()
lnClaimTimer = setInterval(() => void pollLnClaims(), 8000)
}
async function pollLnClaims() {
if (!props.show || !lnAddress.value) {
stopLnClaimPoll()
return
}
try {
const res = await rpcClient.call<{ received_sats?: number }>({
method: 'wallet.ecash-lnaddress-claim',
})
if (res?.received_sats && res.received_sats > 0) {
lnClaimedSats.value += res.received_sats
emit('received')
}
} catch {
// Transient poll failure (offline, mint busy) — keep polling.
}
}
onUnmounted(stopLnClaimPoll)
// Fetch the address the first time the operator opens the ecash tab.
watch(receiveMethod, (m) => {
if (m === 'ecash' && props.show) void loadLnAddress()
})
// ── On-chain payment detection ────────────────────────────────────────────
// The generated address is FRESH (lnd.newaddress), so any incoming wallet
// transaction paying it is this receive — no baseline bookkeeping needed.
@@ -309,12 +406,15 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
function close() {
stopWatchingPayment()
stopLnClaimPoll()
paymentSeen.value = null
invoiceResult.value = ''
onchainAddress.value = ''
arkAddress.value = ''
ecashToken.value = ''
ecashResult.value = ''
lnAddress.value = ''
lnClaimedSats.value = 0
error.value = ''
emit('close')
}
+6
View File
@@ -775,6 +775,12 @@
"paymentConfirmed": "Payment confirmed",
"transactionId": "Transaction ID",
"pasteEcashToken": "Paste ecash token",
"lnAddressTitle": "Or share your Minibits Lightning address",
"lnAddressHint": "Anyone can pay you sats with any Lightning wallet by sending to this address — the sats arrive as ecash. Keep this screen open to receive them.",
"lnAddressLabel": "Your @minibits.cash address:",
"lnAddressLoading": "Setting up your Lightning address…",
"lnAddressUnavailable": "Lightning address unavailable — you can still paste a token below.",
"lnAddressReceived": "Received {amount} sats to your Lightning address!",
"processing": "Processing...",
"generateAddress": "Generate Address",
"createInvoice": "Create Invoice",
+6
View File
@@ -756,6 +756,12 @@
"paymentConfirmed": "Pago confirmado",
"transactionId": "ID de transacci\u00f3n",
"pasteEcashToken": "Pegar token Ecash",
"lnAddressTitle": "O comparte tu direcci\u00f3n Lightning de Minibits",
"lnAddressHint": "Cualquier persona puede pagarte sats con cualquier billetera Lightning enviando a esta direcci\u00f3n \u2014 los sats llegan como ecash. Mant\u00e9n esta pantalla abierta para recibirlos.",
"lnAddressLabel": "Su direcci\u00f3n @minibits.cash:",
"lnAddressLoading": "Configurando su direcci\u00f3n Lightning\u2026",
"lnAddressUnavailable": "Direcci\u00f3n Lightning no disponible \u2014 a\u00fan puede pegar un token abajo.",
"lnAddressReceived": "\u00a1Recibi\u00f3 {amount} sats en su direcci\u00f3n Lightning!",
"processing": "Procesando...",
"generateAddress": "Generar direcci\u00f3n",
"createInvoice": "Crear factura",