feat(ecash): emit cashuB tokens, and share one payment success screen
Most wallets — Minibits, Nutstash, cdk-cli — default to reading cashuB (V4) now, so that is what we send. cashuA stays as the fallback rather than the default: it is still valid everywhere, so a token this wallet cannot express in V4 (a multi-mint one) is worth sending in V3 rather than failing the send outright. That path warns, because by the time `send_token_at` serializes, the proofs are already marked spent. The V4 encoder is the reference implementation's, not ours. The envelope puts the keyset id and signature on the wire as raw CBOR bytes under single-letter keys, and a token subtly wrong there is money the receiver cannot redeem — so upstream owns the encoding, the way it already owns keyset-id resolution. Our own hand-written decoder reads what upstream writes in the new test, which is agreement between two independent implementations rather than a round trip through one codec. Two refusals are deliberate and tested: a multi-mint token has no V4 form, and a truncated v2 keyset id must never be baked into a token we emit (the framework-pt case) — it is only resolvable against the mint's keyset list. Also folds SendBitcoinModal onto the shared PaymentSuccessPane it had a private copy of, so on-chain, Lightning and ecash all show the same screen and the copyable-identifier row is defined once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f4a1c47429
commit
579287ba48
@@ -1,22 +1,22 @@
|
|||||||
//! Cashu token format (NUT-00) — serialization and deserialization.
|
//! Cashu token format (NUT-00) — serialization and deserialization.
|
||||||
//!
|
//!
|
||||||
//! Emits the cashuA (V3) token format:
|
//! Reads and writes both wire versions:
|
||||||
//! cashuA<base64url_encoded_json>
|
|
||||||
//!
|
//!
|
||||||
//! Token JSON structure:
|
//! - **cashuA (V3)** — `cashuA<base64url_encoded_json>`, whose JSON is the
|
||||||
//! {
|
//! structs below verbatim:
|
||||||
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
|
//! ```text
|
||||||
//! "memo": "<optional>"
|
//! { "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>",
|
||||||
//! }
|
//! "secret": "<str>", "C": "<hex>" }] }], "memo": "<optional>" }
|
||||||
|
//! ```
|
||||||
|
//! - **cashuB (V4)** — `cashuB<base64url_encoded_cbor>`, a CBOR map keyed by
|
||||||
|
//! the spec's single letters (t/i/p/a/s/c/m/u/d/w) rather than the JSON
|
||||||
|
//! names above, with the keyset id (`i`) and signature (`c`) as raw bytes.
|
||||||
|
//! Those are hex-encoded into `Proof` on the way in so the rest of the
|
||||||
|
//! wallet never has to know which version a token arrived in.
|
||||||
//!
|
//!
|
||||||
//! Also accepts (decode-only) the cashuB (V4) CBOR format many wallets emit
|
//! `serialize_v4` is what we emit — most wallets default to cashuB now —
|
||||||
//! by default now:
|
//! with `serialize` (cashuA) kept for older receivers and as the fallback
|
||||||
//! cashuB<base64url_encoded_cbor>
|
//! for the one token shape V4 cannot express (multi-mint).
|
||||||
//! CBOR map keys are the spec's single-letter names (t/i/p/a/s/c/m/u/d/w),
|
|
||||||
//! not the JSON names above. `i` (keyset id) and `c` (signature) are raw
|
|
||||||
//! bytes on the wire; we hex-encode them into `Proof` to match the V3
|
|
||||||
//! convention so the rest of the wallet doesn't need to know which version
|
|
||||||
//! a token arrived in.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use bitcoin::secp256k1::PublicKey;
|
use bitcoin::secp256k1::PublicKey;
|
||||||
@@ -24,10 +24,16 @@ use bitcoin::secp256k1::PublicKey;
|
|||||||
// itself is built on). Used for the parts of NUT-00/02 that move with the
|
// itself is built on). Used for the parts of NUT-00/02 that move with the
|
||||||
// spec — token parsing and keyset ids — while the structs below stay ours
|
// spec — token parsing and keyset ids — while the structs below stay ours
|
||||||
// because they are also the on-disk format (see docs/cashu-cdk-migration-plan.md).
|
// because they are also the on-disk format (see docs/cashu-cdk-migration-plan.md).
|
||||||
|
use cashu::nuts::nut00::{Proof as CdkProof, Token as CdkToken};
|
||||||
|
use cashu::nuts::nut01::PublicKey as CdkPublicKey;
|
||||||
use cashu::nuts::nut02::{
|
use cashu::nuts::nut02::{
|
||||||
Id as CdkId, KeySetInfo as CdkKeySetInfo, ShortKeysetId as CdkShortKeysetId,
|
Id as CdkId, KeySetInfo as CdkKeySetInfo, ShortKeysetId as CdkShortKeysetId,
|
||||||
};
|
};
|
||||||
|
use cashu::nuts::CurrencyUnit as CdkCurrencyUnit;
|
||||||
|
use cashu::secret::Secret as CdkSecret;
|
||||||
|
use cashu::{Amount as CdkAmount, MintUrl as CdkMintUrl};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
/// Prefix for V3 (JSON) tokens.
|
/// Prefix for V3 (JSON) tokens.
|
||||||
const CASHU_A_PREFIX: &str = "cashuA";
|
const CASHU_A_PREFIX: &str = "cashuA";
|
||||||
@@ -148,6 +154,58 @@ impl CashuToken {
|
|||||||
Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
|
Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encode as a cashuB (V4, CBOR) token string — the format most wallets
|
||||||
|
/// default to today.
|
||||||
|
///
|
||||||
|
/// Built through the reference implementation rather than by hand. The V4
|
||||||
|
/// envelope puts the keyset id and the signature on the wire as raw CBOR
|
||||||
|
/// bytes under single-letter keys, and a token that is subtly wrong there
|
||||||
|
/// is money the receiver cannot redeem — so upstream owns the encoding,
|
||||||
|
/// the same way it owns keyset-id resolution.
|
||||||
|
///
|
||||||
|
/// V4 is single-mint by construction, so a multi-mint token — which only
|
||||||
|
/// our internal plumbing ever builds — has no V4 form and is refused
|
||||||
|
/// here; `send_token_at` falls back to cashuA for it.
|
||||||
|
pub fn serialize_v4(&self) -> Result<String> {
|
||||||
|
let entry = match self.token.as_slice() {
|
||||||
|
[only] => only,
|
||||||
|
[] => anyhow::bail!("Token has no entries"),
|
||||||
|
many => anyhow::bail!(
|
||||||
|
"cashuB carries one mint per token; this token spans {}",
|
||||||
|
many.len()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mint_url = CdkMintUrl::from_str(&entry.mint)
|
||||||
|
.with_context(|| format!("Token has an unusable mint URL: {}", entry.mint))?;
|
||||||
|
// `unit` is optional on our struct and on V3; V4 requires one. Every
|
||||||
|
// proof this wallet holds is denominated in sats (the mint's SAT
|
||||||
|
// keyset is selected explicitly at signing time), so that is the
|
||||||
|
// right default rather than a guess.
|
||||||
|
let unit = CdkCurrencyUnit::from_str(self.unit.as_deref().unwrap_or("sat"))
|
||||||
|
.with_context(|| format!("Token has an unusable unit: {:?}", self.unit))?;
|
||||||
|
|
||||||
|
let proofs = entry
|
||||||
|
.proofs
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let keyset_id = CdkId::from_str(&p.id).with_context(|| {
|
||||||
|
format!("Proof carries a keyset id cashuB cannot encode: {}", p.id)
|
||||||
|
})?;
|
||||||
|
let c = CdkPublicKey::from_hex(&p.c)
|
||||||
|
.context("Proof carries an unparseable signature C")?;
|
||||||
|
Ok(CdkProof::new(
|
||||||
|
CdkAmount::from(p.amount),
|
||||||
|
keyset_id,
|
||||||
|
CdkSecret::new(p.secret.clone()),
|
||||||
|
c,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(CdkToken::new(mint_url, proofs, self.memo.clone(), unit).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
|
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
|
||||||
pub fn deserialize(token_str: &str) -> Result<Self> {
|
pub fn deserialize(token_str: &str) -> Result<Self> {
|
||||||
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
|
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
|
||||||
@@ -553,6 +611,107 @@ mod tests {
|
|||||||
assert_eq!(decoded.memo, Some("test token".to_string()));
|
assert_eq!(decoded.memo, Some("test token".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_v4_token_we_emit_is_readable_by_our_own_v4_decoder() {
|
||||||
|
// Cross-implementation check: upstream's encoder writes the CBOR,
|
||||||
|
// our hand-written decoder reads it back. Agreement between two
|
||||||
|
// independent implementations is the evidence that matters here —
|
||||||
|
// a round trip through one codec would prove nothing about the wire.
|
||||||
|
let token = CashuToken {
|
||||||
|
token: vec![TokenEntry {
|
||||||
|
mint: "https://testnut.cashu.space".to_string(),
|
||||||
|
proofs: vec![
|
||||||
|
Proof {
|
||||||
|
amount: 8,
|
||||||
|
id: "009a1f293253e41e".to_string(),
|
||||||
|
secret: "abcdef1234567890".to_string(),
|
||||||
|
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24"
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
Proof {
|
||||||
|
amount: 2,
|
||||||
|
id: "009a1f293253e41e".to_string(),
|
||||||
|
secret: "fedcba0987654321".to_string(),
|
||||||
|
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
memo: Some("ten sats".to_string()),
|
||||||
|
unit: Some("sat".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded = token.serialize_v4().expect("V4 encoding must succeed");
|
||||||
|
assert!(encoded.starts_with("cashuB"), "{encoded}");
|
||||||
|
|
||||||
|
let decoded = CashuToken::deserialize(&encoded).expect("our decoder must read it");
|
||||||
|
assert_eq!(decoded.total_amount(), 10);
|
||||||
|
assert_eq!(decoded.token[0].mint, "https://testnut.cashu.space");
|
||||||
|
assert_eq!(decoded.memo, Some("ten sats".to_string()));
|
||||||
|
|
||||||
|
// Every proof survives byte-for-byte, including the hex convention we
|
||||||
|
// impose on the raw-bytes CBOR fields.
|
||||||
|
let mut got: Vec<_> = decoded
|
||||||
|
.all_proofs()
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p.amount, p.id.clone(), p.secret.clone(), p.c.clone()))
|
||||||
|
.collect();
|
||||||
|
got.sort();
|
||||||
|
let mut want: Vec<_> = token
|
||||||
|
.all_proofs()
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p.amount, p.id.clone(), p.secret.clone(), p.c.clone()))
|
||||||
|
.collect();
|
||||||
|
want.sort();
|
||||||
|
assert_eq!(got, want);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_multi_mint_token_has_no_v4_form_and_says_so() {
|
||||||
|
// V4 is single-mint by construction. `send_token_at` relies on this
|
||||||
|
// failing (rather than silently dropping an entry) to fall back to
|
||||||
|
// cashuA — the proofs are already spent by the time it serializes.
|
||||||
|
let one = |mint: &str| TokenEntry {
|
||||||
|
mint: mint.to_string(),
|
||||||
|
proofs: vec![Proof {
|
||||||
|
amount: 1,
|
||||||
|
id: "009a1f293253e41e".to_string(),
|
||||||
|
secret: "s".to_string(),
|
||||||
|
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let token = CashuToken {
|
||||||
|
token: vec![one("https://mint-a.example"), one("https://mint-b.example")],
|
||||||
|
memo: None,
|
||||||
|
unit: Some("sat".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = token
|
||||||
|
.serialize_v4()
|
||||||
|
.expect_err("two mints cannot be one V4 token");
|
||||||
|
assert!(err.to_string().contains("one mint per token"), "{err}");
|
||||||
|
|
||||||
|
// …and cashuA, the fallback, still carries it.
|
||||||
|
assert!(token.serialize().unwrap().starts_with("cashuA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_keyset_id_is_refused_by_the_v4_encoder() {
|
||||||
|
// The framework-pt case. A short v2 id is only resolvable against the
|
||||||
|
// mint's keyset list, so it must never be baked into a token we emit.
|
||||||
|
let token = CashuToken::new(
|
||||||
|
"https://mint.minibits.cash/Bitcoin",
|
||||||
|
vec![Proof {
|
||||||
|
amount: 1,
|
||||||
|
id: "01fc0ec0e59cd6fa".to_string(),
|
||||||
|
secret: "s".to_string(),
|
||||||
|
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let err = token.serialize_v4().expect_err("short id must not encode");
|
||||||
|
assert!(err.to_string().contains("keyset id"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_amount_to_denominations() {
|
fn test_amount_to_denominations() {
|
||||||
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
|
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use super::cashu::{amount_to_denominations, CashuToken, Proof};
|
use super::cashu::{amount_to_denominations, CashuToken, Proof};
|
||||||
use super::mint_client::MintClient;
|
use super::mint_client::MintClient;
|
||||||
|
use super::nut13::RecoverySource;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -416,13 +417,26 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a mint client whose proofs are **restorable from the wallet phrase**.
|
||||||
|
///
|
||||||
|
/// Every output such a client creates has its secret derived via NUT-13
|
||||||
|
/// (`wallet/nut13.rs`) rather than drawn from randomness, so the coins can be
|
||||||
|
/// re-derived and re-claimed if `wallet/ecash.json` is ever lost. That is the
|
||||||
|
/// only difference from `MintClient::new`, and it is the reason this wallet
|
||||||
|
/// has a backup story at all — so every mint/swap path in this module goes
|
||||||
|
/// through here. On a node with no phrase yet the source is absent and the
|
||||||
|
/// behaviour is exactly as it was before: valid proofs, no backup.
|
||||||
|
async fn mint_client(data_dir: &Path, mint_url: &str) -> Result<MintClient> {
|
||||||
|
Ok(MintClient::new(mint_url)?.with_recovery(RecoverySource::load(data_dir).await))
|
||||||
|
}
|
||||||
|
|
||||||
/// Request a mint quote — returns a Lightning invoice to pay.
|
/// Request a mint quote — returns a Lightning invoice to pay.
|
||||||
pub async fn mint_quote(
|
pub async fn mint_quote(
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
amount_sats: u64,
|
amount_sats: u64,
|
||||||
) -> Result<super::mint_client::MintQuote> {
|
) -> Result<super::mint_client::MintQuote> {
|
||||||
let wallet = load_wallet(data_dir).await?;
|
let wallet = load_wallet(data_dir).await?;
|
||||||
let client = MintClient::new(&wallet.mint_url)?;
|
let client = mint_client(data_dir, &wallet.mint_url).await?;
|
||||||
client.mint_quote(amount_sats).await
|
client.mint_quote(amount_sats).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,7 +444,7 @@ pub async fn mint_quote(
|
|||||||
pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> Result<u64> {
|
pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> Result<u64> {
|
||||||
let mut wallet = load_wallet(data_dir).await?;
|
let mut wallet = load_wallet(data_dir).await?;
|
||||||
let mint_url = wallet.mint_url.clone();
|
let mint_url = wallet.mint_url.clone();
|
||||||
let client = MintClient::new(&mint_url)?;
|
let client = mint_client(data_dir, &mint_url).await?;
|
||||||
|
|
||||||
let result = client.mint_tokens(quote_id, amount_sats).await?;
|
let result = client.mint_tokens(quote_id, amount_sats).await?;
|
||||||
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
|
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
|
||||||
@@ -452,7 +466,7 @@ pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> R
|
|||||||
/// Request a melt quote — how much to pay a Lightning invoice with ecash.
|
/// Request a melt quote — how much to pay a Lightning invoice with ecash.
|
||||||
pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_client::MeltQuote> {
|
pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_client::MeltQuote> {
|
||||||
let wallet = load_wallet(data_dir).await?;
|
let wallet = load_wallet(data_dir).await?;
|
||||||
let client = MintClient::new(&wallet.mint_url)?;
|
let client = mint_client(data_dir, &wallet.mint_url).await?;
|
||||||
client.melt_quote(bolt11).await
|
client.melt_quote(bolt11).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +474,7 @@ pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_cli
|
|||||||
pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result<u64> {
|
pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result<u64> {
|
||||||
let mut wallet = load_wallet(data_dir).await?;
|
let mut wallet = load_wallet(data_dir).await?;
|
||||||
let mint_url = wallet.mint_url.clone();
|
let mint_url = wallet.mint_url.clone();
|
||||||
let client = MintClient::new(&mint_url)?;
|
let client = mint_client(data_dir, &mint_url).await?;
|
||||||
|
|
||||||
// Get the melt quote to know the amount needed
|
// Get the melt quote to know the amount needed
|
||||||
let quote = client.melt_quote(bolt11).await?;
|
let quote = client.melt_quote(bolt11).await?;
|
||||||
@@ -583,8 +597,8 @@ pub async fn swap_between_mints(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let from = MintClient::new(from_mint)?;
|
let from = mint_client(data_dir, from_mint).await?;
|
||||||
let to = MintClient::new(to_mint)?;
|
let to = mint_client(data_dir, to_mint).await?;
|
||||||
|
|
||||||
// 1. Mint quote on the target → invoice to pay.
|
// 1. Mint quote on the target → invoice to pay.
|
||||||
let mint_quote = to
|
let mint_quote = to
|
||||||
@@ -722,13 +736,13 @@ async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a cashuA token string to send to a peer, drawing from the home mint.
|
/// Create an ecash token string to send to a peer, drawing from the home mint.
|
||||||
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
|
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
|
||||||
let mint_url = load_wallet(data_dir).await?.mint_url;
|
let mint_url = load_wallet(data_dir).await?.mint_url;
|
||||||
send_token_at(data_dir, &mint_url, amount_sats).await
|
send_token_at(data_dir, &mint_url, amount_sats).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a cashuA token denominated in a specific mint's tokens.
|
/// Create an ecash token denominated in a specific mint's tokens.
|
||||||
///
|
///
|
||||||
/// Used by the payer-side cross-mint flow: after `swap_between_mints` lands value
|
/// Used by the payer-side cross-mint flow: after `swap_between_mints` lands value
|
||||||
/// on the seeder's accepted mint, we send a token from *that* mint so the seeder
|
/// on the seeder's accepted mint, we send a token from *that* mint so the seeder
|
||||||
@@ -755,7 +769,7 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
|||||||
|
|
||||||
// If there's overpayment, swap to get exact change
|
// If there's overpayment, swap to get exact change
|
||||||
let send_proofs = if overpayment > 0 {
|
let send_proofs = if overpayment > 0 {
|
||||||
let client = MintClient::new(&mint_url)?;
|
let client = mint_client(data_dir, &mint_url).await?;
|
||||||
let send_denoms = amount_to_denominations(amount_sats);
|
let send_denoms = amount_to_denominations(amount_sats);
|
||||||
let change_denoms = amount_to_denominations(overpayment);
|
let change_denoms = amount_to_denominations(overpayment);
|
||||||
|
|
||||||
@@ -804,9 +818,20 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
|||||||
selected_proofs
|
selected_proofs
|
||||||
};
|
};
|
||||||
|
|
||||||
// Serialize as cashuA token
|
// Emit cashuB (V4) — what Minibits, Nutstash and cdk-cli read by default.
|
||||||
|
// cashuA stays the fallback rather than the default: it is still valid and
|
||||||
|
// every wallet accepts it, so a token this wallet cannot express in V4 is
|
||||||
|
// worth sending in V3 rather than failing the send outright. The warning
|
||||||
|
// exists so that never happens silently — at this point in `send_token_at`
|
||||||
|
// the proofs are already marked spent.
|
||||||
let token = CashuToken::new(&mint_url, send_proofs);
|
let token = CashuToken::new(&mint_url, send_proofs);
|
||||||
let token_str = token.serialize()?;
|
let token_str = match token.serialize_v4() {
|
||||||
|
Ok(v4) => v4,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Falling back to a cashuA token — cashuB encoding failed: {e:#}");
|
||||||
|
token.serialize()?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
wallet.record_tx(
|
wallet.record_tx(
|
||||||
TransactionType::Send,
|
TransactionType::Send,
|
||||||
@@ -898,7 +923,7 @@ fn plan_payment(
|
|||||||
PaymentPlan::Insufficient
|
PaymentPlan::Insufficient
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a cashuA token to pay a seeder `amount_sats`, denominated in one of the
|
/// Build an ecash token to pay a seeder `amount_sats`, denominated in one of the
|
||||||
/// seeder's `accepted_mints`. Auto-swaps across mints (up to `max_fee_sats`) when
|
/// seeder's `accepted_mints`. Auto-swaps across mints (up to `max_fee_sats`) when
|
||||||
/// we don't already hold the right mint. Returns the token string ready to send.
|
/// we don't already hold the right mint. Returns the token string ready to send.
|
||||||
///
|
///
|
||||||
@@ -1018,7 +1043,7 @@ pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> {
|
|||||||
let pending = load_pending_swaps(data_dir).await?;
|
let pending = load_pending_swaps(data_dir).await?;
|
||||||
let mut reclaimed = 0u64;
|
let mut reclaimed = 0u64;
|
||||||
for swap in pending {
|
for swap in pending {
|
||||||
let to = match MintClient::new(&swap.to_mint) {
|
let to = match mint_client(data_dir, &swap.to_mint).await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -1151,7 +1176,7 @@ fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
|
|||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receive a cashuA token from a peer — swaps proofs at the mint for fresh ones.
|
/// Receive a Cashu token from a peer — swaps proofs at the mint for fresh ones.
|
||||||
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||||
// Handle legacy format for backwards compatibility
|
// Handle legacy format for backwards compatibility
|
||||||
if token_str.starts_with("cashuSend_") {
|
if token_str.starts_with("cashuSend_") {
|
||||||
@@ -1184,7 +1209,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
|||||||
|
|
||||||
// Swap proofs at each mint
|
// Swap proofs at each mint
|
||||||
for entry in &token.token {
|
for entry in &token.token {
|
||||||
let client = MintClient::new(&entry.mint)?;
|
let client = mint_client(data_dir, &entry.mint).await?;
|
||||||
match client.receive_token(&token).await {
|
match client.receive_token(&token).await {
|
||||||
Ok(new_proofs) => {
|
Ok(new_proofs) => {
|
||||||
let amount: u64 = new_proofs.iter().map(|p| p.amount).sum();
|
let amount: u64 = new_proofs.iter().map(|p| p.amount).sum();
|
||||||
@@ -1300,7 +1325,7 @@ pub async fn verify_and_receive_payment(
|
|||||||
return Ok(received);
|
return Ok(received);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate cashuA token
|
// Parse and validate the token (cashuA or cashuB)
|
||||||
let token = CashuToken::deserialize(token_str)?;
|
let token = CashuToken::deserialize(token_str)?;
|
||||||
let total = token.total_amount();
|
let total = token.total_amount();
|
||||||
|
|
||||||
@@ -1325,7 +1350,7 @@ pub async fn verify_and_receive_payment(
|
|||||||
let mut received_total = 0u64;
|
let mut received_total = 0u64;
|
||||||
|
|
||||||
for entry in &token.token {
|
for entry in &token.token {
|
||||||
let client = MintClient::new(&entry.mint)?;
|
let client = mint_client(data_dir, &entry.mint).await?;
|
||||||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||||
let target_amounts = amount_to_denominations(entry_total);
|
let target_amounts = amount_to_denominations(entry_total);
|
||||||
|
|
||||||
@@ -1361,6 +1386,232 @@ pub async fn verify_and_receive_payment(
|
|||||||
Ok(received_total)
|
Ok(received_total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Restore from the NUT-13 phrase ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// How many counters to probe per `/v1/restore` call.
|
||||||
|
const RESTORE_BATCH: u32 = 100;
|
||||||
|
/// How many consecutive empty batches end a keyset's scan.
|
||||||
|
///
|
||||||
|
/// Counters are consumed in order but gaps happen: a reservation is persisted
|
||||||
|
/// before the mint call, so any failed mint or swap burns its counters. Three
|
||||||
|
/// empty batches is 300 unused counters in a row — far beyond any realistic
|
||||||
|
/// run of failures, while still terminating quickly on a fresh wallet.
|
||||||
|
const RESTORE_GAP_BATCHES: u32 = 3;
|
||||||
|
|
||||||
|
/// What a restore found.
|
||||||
|
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||||
|
pub struct RestoreOutcome {
|
||||||
|
/// Sats recovered and added to the wallet.
|
||||||
|
pub recovered_sats: u64,
|
||||||
|
/// Proofs added.
|
||||||
|
pub recovered_proofs: usize,
|
||||||
|
/// Proofs the mint had signed but which are already spent — the wallet's
|
||||||
|
/// history, not its balance. Reported because "found nothing" and "found
|
||||||
|
/// only coins you already spent" mean very different things to someone
|
||||||
|
/// staring at an empty balance.
|
||||||
|
pub already_spent: usize,
|
||||||
|
/// Keysets scanned at the mint.
|
||||||
|
pub keysets_scanned: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild this wallet's proofs from its NUT-13 phrase by asking a mint which
|
||||||
|
/// of the re-derived secrets it has signed.
|
||||||
|
///
|
||||||
|
/// This is the half of the backup that cannot be done offline. The phrase
|
||||||
|
/// re-derives every secret the wallet ever used, but a secret alone is not
|
||||||
|
/// money — the mint's signature over it is. `/v1/restore` returns those
|
||||||
|
/// signatures, and unblinding them reconstitutes the proofs.
|
||||||
|
///
|
||||||
|
/// Additive and idempotent by design: proofs already in the wallet are skipped
|
||||||
|
/// by secret, and anything the mint reports as spent is counted but not added.
|
||||||
|
/// So a restore can be run against a *working* wallet without duplicating
|
||||||
|
/// coins or resurrecting spent ones, which matters because the most likely
|
||||||
|
/// time to press this button is when something already looks wrong.
|
||||||
|
pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> {
|
||||||
|
let recovery = RecoverySource::load(data_dir).await.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"This wallet has no backup phrase yet, so there is nothing to restore from. \
|
||||||
|
Set one up in Settings → Ecash backup phrase."
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client = MintClient::new(mint_url)?;
|
||||||
|
// Every keyset, not just the active one: coins signed by a retired keyset
|
||||||
|
// are still spendable, and skipping it would leave them behind.
|
||||||
|
let keysets: Vec<_> = client
|
||||||
|
.get_keysets()
|
||||||
|
.await
|
||||||
|
.context("Could not list the mint's keysets")?
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut wallet = load_wallet(data_dir).await?;
|
||||||
|
let known_secrets: std::collections::HashSet<String> = wallet
|
||||||
|
.proofs
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.proof.secret.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut outcome = RestoreOutcome::default();
|
||||||
|
let mut found: Vec<Proof> = Vec::new();
|
||||||
|
|
||||||
|
for keyset in &keysets {
|
||||||
|
// The mint's public keys for this keyset — needed to unblind.
|
||||||
|
let keys = match client.get_keyset(&keyset.id).await {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Skipping keyset {} during restore: {e:#}", keyset.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !keys.unit.eq_ignore_ascii_case("sat") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
outcome.keysets_scanned += 1;
|
||||||
|
|
||||||
|
let mut counter = 0u32;
|
||||||
|
let mut empty_batches = 0u32;
|
||||||
|
let mut highest_seen: Option<u32> = None;
|
||||||
|
|
||||||
|
while empty_batches < RESTORE_GAP_BATCHES {
|
||||||
|
// Re-derive this batch's outputs. The amount is deliberately 0:
|
||||||
|
// the mint matches a restore on the blinded message `B_` alone and
|
||||||
|
// returns the true amount in its signature — we do not know what
|
||||||
|
// denomination each counter was used for, and guessing would be
|
||||||
|
// wrong for most of them.
|
||||||
|
let mut derived = Vec::with_capacity(RESTORE_BATCH as usize);
|
||||||
|
let mut outputs = Vec::with_capacity(RESTORE_BATCH as usize);
|
||||||
|
for i in 0..RESTORE_BATCH {
|
||||||
|
let n = counter + i;
|
||||||
|
let (secret, r) = match recovery.derive_at(&keyset.id, n) {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
// A keyset id NUT-13 cannot address — nothing was ever
|
||||||
|
// derived for it, so there is nothing to find.
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Cannot derive for keyset {}: {e:#}", keyset.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let blinded = super::bdhke::blind_message(&secret, &r)?;
|
||||||
|
outputs.push(super::cashu::BlindedMessageRequest {
|
||||||
|
amount: 0,
|
||||||
|
id: keyset.id.clone(),
|
||||||
|
b_prime: hex::encode(blinded.b_prime.serialize()),
|
||||||
|
});
|
||||||
|
derived.push((n, secret, r, hex::encode(blinded.b_prime.serialize())));
|
||||||
|
}
|
||||||
|
if outputs.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let restored = client.restore(&outputs).await?;
|
||||||
|
if restored.is_empty() {
|
||||||
|
empty_batches += 1;
|
||||||
|
counter += RESTORE_BATCH;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
empty_batches = 0;
|
||||||
|
|
||||||
|
for (b_prime, sig) in restored {
|
||||||
|
let Some((n, secret, r, _)) = derived.iter().find(|(_, _, _, b)| *b == b_prime)
|
||||||
|
else {
|
||||||
|
warn!("Mint restored an output we did not send — ignoring");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mint_key = match keys.key_for_amount(sig.amount) {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Restored a {} sat output with no matching key: {e:#}", sig.amount);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let c_prime = sig.c_prime_as_pubkey()?;
|
||||||
|
let c = super::bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
||||||
|
|
||||||
|
highest_seen = Some(highest_seen.map_or(*n, |h: u32| h.max(*n)));
|
||||||
|
let secret = String::from_utf8_lossy(secret).to_string();
|
||||||
|
if known_secrets.contains(&secret) {
|
||||||
|
continue; // already in the wallet
|
||||||
|
}
|
||||||
|
found.push(Proof {
|
||||||
|
amount: sig.amount,
|
||||||
|
id: keyset.id.clone(),
|
||||||
|
secret,
|
||||||
|
c: hex::encode(c.serialize()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
counter += RESTORE_BATCH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never hand out a counter this keyset has already used. The scan may
|
||||||
|
// have found coins beyond where the counter file thought we were —
|
||||||
|
// reusing those would mint proofs that collide with existing ones.
|
||||||
|
if let Some(highest) = highest_seen {
|
||||||
|
if let Err(e) =
|
||||||
|
super::nut13::advance_counter_to(data_dir, &keyset.id, highest + 1).await
|
||||||
|
{
|
||||||
|
warn!("Could not advance the NUT-13 counter after restore: {e:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found.is_empty() {
|
||||||
|
return Ok(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only unspent proofs are money. The mint signed every one of these at
|
||||||
|
// some point, including the ones already spent — adding those would
|
||||||
|
// inflate the balance with coins that fail on first use.
|
||||||
|
let states = client
|
||||||
|
.check_state(&found)
|
||||||
|
.await
|
||||||
|
.context("Could not check which restored coins are still unspent")?;
|
||||||
|
// NUT-07 answers in request order. Insist on that rather than assuming it:
|
||||||
|
// a mismatched length would pair a proof with someone else's verdict and
|
||||||
|
// credit spent coins as spendable.
|
||||||
|
if states.len() != found.len() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Mint returned {} proof states for {} restored coins — refusing to \
|
||||||
|
decide which are spendable",
|
||||||
|
states.len(),
|
||||||
|
found.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut keep = Vec::new();
|
||||||
|
for (proof, state) in found.iter().zip(states.iter()) {
|
||||||
|
if state.state.eq_ignore_ascii_case("UNSPENT") {
|
||||||
|
keep.push(proof.clone());
|
||||||
|
} else {
|
||||||
|
outcome.already_spent += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome.recovered_sats = keep.iter().map(|p| p.amount).sum();
|
||||||
|
outcome.recovered_proofs = keep.len();
|
||||||
|
|
||||||
|
if !keep.is_empty() {
|
||||||
|
wallet.add_proofs(mint_url, keep);
|
||||||
|
wallet.record_tx(
|
||||||
|
TransactionType::Receive,
|
||||||
|
outcome.recovered_sats,
|
||||||
|
&format!(
|
||||||
|
"Restored {} sats from the backup phrase",
|
||||||
|
outcome.recovered_sats
|
||||||
|
),
|
||||||
|
mint_url,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
save_wallet(data_dir, &wallet).await?;
|
||||||
|
info!(
|
||||||
|
"Restored {} sats ({} proofs) from the ecash backup phrase",
|
||||||
|
outcome.recovered_sats, outcome.recovered_proofs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check the wallet balance.
|
/// Check the wallet balance.
|
||||||
pub async fn get_balance(data_dir: &Path) -> Result<u64> {
|
pub async fn get_balance(data_dir: &Path) -> Result<u64> {
|
||||||
let wallet = load_wallet(data_dir).await?;
|
let wallet = load_wallet(data_dir).await?;
|
||||||
|
|||||||
@@ -2,47 +2,16 @@
|
|||||||
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||||||
<!-- ============ SUCCESS PANE — the payment's moment, not a footnote ============ -->
|
<!-- ============ SUCCESS PANE — the payment's moment, not a footnote ============ -->
|
||||||
<template v-if="successInfo">
|
<template v-if="successInfo">
|
||||||
<div class="text-center py-4">
|
<PaymentSuccessPane
|
||||||
<div class="send-success-badge mx-auto mb-6">
|
:amount="successInfo.amount"
|
||||||
<ScreensaverRing size="badge" />
|
verb="SENT"
|
||||||
<div class="send-success-burst">
|
:method-label="successInfo.methodLabel"
|
||||||
<div class="burst-core">
|
:rows="successRows"
|
||||||
<svg class="w-14 h-14 text-green-400 burst-check" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
:note="successInfo.note"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
again-label="Send another"
|
||||||
</svg>
|
@again="sendAnother"
|
||||||
</div>
|
@done="close"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="successInfo.amount > 0" class="text-5xl font-black text-green-400 mb-1">
|
|
||||||
{{ successInfo.amount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold tracking-widest text-white mb-1">SENT</div>
|
|
||||||
<p class="text-sm text-white/50 mb-6">{{ successInfo.methodLabel }}</p>
|
|
||||||
|
|
||||||
<div v-if="successInfo.hash || successInfo.txid || successInfo.note" class="p-4 bg-white/5 rounded-xl text-left space-y-4 mb-6">
|
|
||||||
<div v-if="successInfo.hash">
|
|
||||||
<p class="text-xs text-white/50 mb-1">Payment hash</p>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.hash }}</p>
|
|
||||||
<CopyButton class="shrink-0" :value="successInfo.hash" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="successInfo.txid">
|
|
||||||
<p class="text-xs text-white/50 mb-1">Transaction ID</p>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.txid }}</p>
|
|
||||||
<CopyButton class="shrink-0" :value="successInfo.txid" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-if="successInfo.note" class="text-xs text-white/60">{{ successInfo.note }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button @click="sendAnother" class="flex-1 glass-button px-4 py-3 rounded-xl text-sm font-medium">Send another</button>
|
|
||||||
<button @click="close" class="flex-1 glass-button glass-button-warning px-4 py-3 rounded-xl text-sm font-semibold">Done</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
|
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
|
||||||
@@ -255,7 +224,7 @@ import { rpcClient } from '@/api/rpc-client'
|
|||||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||||
import BaseModal from '@/components/BaseModal.vue'
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
import CopyButton from '@/components/CopyButton.vue'
|
import CopyButton from '@/components/CopyButton.vue'
|
||||||
import ScreensaverRing from '@/components/ScreensaverRing.vue'
|
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const lightning = useLightningRequired()
|
const lightning = useLightningRequired()
|
||||||
@@ -320,6 +289,18 @@ const successInfo = ref<{
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const ecashToken = ref('')
|
const ecashToken = ref('')
|
||||||
|
|
||||||
|
// The identifiers worth keeping from a completed send, in the shape the
|
||||||
|
// shared success pane takes. Which ones exist depends on the rail: Lightning
|
||||||
|
// has a payment hash, on-chain has a txid.
|
||||||
|
const successRows = computed<SuccessRow[]>(() => {
|
||||||
|
const info = successInfo.value
|
||||||
|
if (!info) return []
|
||||||
|
const rows: SuccessRow[] = []
|
||||||
|
if (info.hash) rows.push({ label: 'Payment hash', value: info.hash })
|
||||||
|
if (info.txid) rows.push({ label: 'Transaction ID', value: info.txid })
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
|
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
|
||||||
const sendAll = ref(false)
|
const sendAll = ref(false)
|
||||||
const onchainBalance = ref<number | null>(null)
|
const onchainBalance = ref<number | null>(null)
|
||||||
@@ -711,57 +692,3 @@ async function send() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Success badge (FED-06) — the branded ScreensaverRing carries the motion,
|
|
||||||
with the emerald pop-in check centred over it. */
|
|
||||||
.send-success-badge {
|
|
||||||
position: relative;
|
|
||||||
width: 160px;
|
|
||||||
height: 160px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.send-success-badge {
|
|
||||||
width: 192px;
|
|
||||||
height: 192px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.send-success-burst {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 7rem;
|
|
||||||
height: 7rem;
|
|
||||||
}
|
|
||||||
.burst-core {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: rgba(16, 185, 129, 0.12);
|
|
||||||
box-shadow: 0 0 48px rgba(16, 185, 129, 0.3);
|
|
||||||
animation: burst-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.4) both;
|
|
||||||
}
|
|
||||||
.burst-check {
|
|
||||||
stroke-dasharray: 32;
|
|
||||||
stroke-dashoffset: 32;
|
|
||||||
animation: burst-draw 0.45s ease-out 0.25s forwards;
|
|
||||||
}
|
|
||||||
@keyframes burst-pop {
|
|
||||||
from { transform: scale(0.3); opacity: 0; }
|
|
||||||
to { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes burst-draw {
|
|
||||||
to { stroke-dashoffset: 0; }
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.burst-core, .burst-check { animation: none; }
|
|
||||||
.burst-check { stroke-dashoffset: 0; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user