Make saved Cashu sends recoverable and record deferred connection UX scope
This commit is contained in:
@@ -358,6 +358,16 @@ pub fn is_truncated_v2_keyset_id(id: &str) -> bool {
|
|||||||
id.len() == 16 && id.starts_with("01") && hex::decode(id).is_ok()
|
id.len() == 16 && id.starts_with("01") && hex::decode(id).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Match a compact token's ID against the full ID already bound to a specific
|
||||||
|
/// proof/operation. This is not discovery of an unknown mint keyset.
|
||||||
|
pub(super) fn matches_stored_keyset_id(wire: &str, stored: &str) -> bool {
|
||||||
|
wire.eq_ignore_ascii_case(stored)
|
||||||
|
|| (is_truncated_v2_keyset_id(wire)
|
||||||
|
&& stored.len() == 66
|
||||||
|
&& hex::decode(stored).is_ok()
|
||||||
|
&& stored[..16].eq_ignore_ascii_case(wire))
|
||||||
|
}
|
||||||
|
|
||||||
/// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec
|
/// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec
|
||||||
/// default) and falling back to other alphabets some implementations use.
|
/// default) and falling back to other alphabets some implementations use.
|
||||||
fn decode_token_base64(payload: &str) -> Result<Vec<u8>, base64::DecodeError> {
|
fn decode_token_base64(payload: &str) -> Result<Vec<u8>, base64::DecodeError> {
|
||||||
|
|||||||
@@ -801,6 +801,128 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
|||||||
send_token_at_locked(data_dir, mint_url, amount_sats).await
|
send_token_at_locked(data_dir, mint_url, amount_sats).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prepare one immutable caller-owned payment. Callers must persist and reuse
|
||||||
|
/// the operation ID and context hash; a fresh ID is a different payment.
|
||||||
|
/// This does not authorize delivery or replace the seller's settlement receipt.
|
||||||
|
pub async fn send_token_recoverable(
|
||||||
|
data_dir: &Path,
|
||||||
|
operation_id: &str,
|
||||||
|
network: EcashNetwork,
|
||||||
|
mint_url: &str,
|
||||||
|
amount_sats: u64,
|
||||||
|
context_hash: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
use super::send_journal::{Binding, Journal, Outcome, Phase, Request};
|
||||||
|
let held = super::mutation::guard(data_dir).await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
load_network(data_dir).await? == network,
|
||||||
|
"Switch back to the payment's original network before recovering it"
|
||||||
|
);
|
||||||
|
let binding = Binding {
|
||||||
|
id: operation_id.into(),
|
||||||
|
network,
|
||||||
|
mint_url: mint_url.into(),
|
||||||
|
amount_sats,
|
||||||
|
context_hash: context_hash.into(),
|
||||||
|
};
|
||||||
|
let journal = Journal::new(&held);
|
||||||
|
let previous = journal.load(operation_id).await?;
|
||||||
|
let recovering = previous.is_some();
|
||||||
|
let record = if let Some(record) = previous {
|
||||||
|
anyhow::ensure!(
|
||||||
|
record.binding == binding,
|
||||||
|
"Payment operation terms changed; no new spend allowed"
|
||||||
|
);
|
||||||
|
record
|
||||||
|
} else {
|
||||||
|
anyhow::ensure!(amount_sats > 0, "Payment amount must be positive");
|
||||||
|
let wallet = load_wallet(data_dir).await?;
|
||||||
|
let (indices, excess) = wallet
|
||||||
|
.select_proofs(&binding.mint_url, amount_sats)
|
||||||
|
.context("Insufficient spendable balance for this payment")?;
|
||||||
|
let proofs: Vec<_> = indices
|
||||||
|
.iter()
|
||||||
|
.map(|&index| wallet.proofs[index].proof.clone())
|
||||||
|
.collect();
|
||||||
|
let request = if excess == 0 {
|
||||||
|
Request::Exact { proofs }
|
||||||
|
} else {
|
||||||
|
let mut denominations = amount_to_denominations(amount_sats);
|
||||||
|
denominations.extend(amount_to_denominations(excess));
|
||||||
|
let prepared = mint_client(data_dir, &binding.mint_url)
|
||||||
|
.await?
|
||||||
|
.prepare_swap_at_least(&proofs, &denominations, amount_sats)
|
||||||
|
.await?;
|
||||||
|
Request::Swap(prepared)
|
||||||
|
};
|
||||||
|
journal.prepare(binding.clone(), request).await?
|
||||||
|
};
|
||||||
|
if matches!(record.phase, Phase::Result(_) | Phase::Committed(_)) {
|
||||||
|
return journal.commit_wallet(&binding).await;
|
||||||
|
}
|
||||||
|
journal.reserve_wallet(&binding).await?;
|
||||||
|
let (send, change) = match &record.request {
|
||||||
|
Request::Exact { proofs } => (proofs.clone(), vec![]),
|
||||||
|
Request::Swap(prepared) => {
|
||||||
|
// All recovery material is already durable. Do not derive new
|
||||||
|
// outputs or release reservations after an ambiguous response.
|
||||||
|
let client = MintClient::new(&binding.mint_url)?;
|
||||||
|
let restored = if recovering {
|
||||||
|
client.restore_prepared_swap(prepared).await.map_err(|_| {
|
||||||
|
anyhow::anyhow!("Could not recover this payment yet; its funds remain reserved")
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let result = if let Some(restored) = restored {
|
||||||
|
restored
|
||||||
|
} else {
|
||||||
|
if recovering {
|
||||||
|
let states = client.check_state(prepared.inputs()).await.map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Could not verify this payment's original inputs; do not pay again"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
states.iter().all(|state| state.state == "UNSPENT"),
|
||||||
|
"This payment is still pending at the mint; do not pay again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
client.execute_prepared_swap(prepared).await.map_err(|_| anyhow::anyhow!(
|
||||||
|
"The mint did not confirm this payment; retry this same operation to recover it"))?
|
||||||
|
};
|
||||||
|
let mut needed = amount_to_denominations(amount_sats);
|
||||||
|
let mut send = Vec::new();
|
||||||
|
let mut change = Vec::new();
|
||||||
|
for proof in result.new_proofs {
|
||||||
|
if let Some(index) = needed.iter().position(|amount| *amount == proof.amount) {
|
||||||
|
needed.remove(index);
|
||||||
|
send.push(proof);
|
||||||
|
} else {
|
||||||
|
change.push(proof);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
needed.is_empty(),
|
||||||
|
"Recovered payment is incomplete; do not pay again"
|
||||||
|
);
|
||||||
|
(send, change)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let token = CashuToken::new(&binding.mint_url, send);
|
||||||
|
let encoded = token.serialize_v4().or_else(|_| token.serialize())?;
|
||||||
|
journal
|
||||||
|
.record_result(
|
||||||
|
&binding,
|
||||||
|
Outcome {
|
||||||
|
token: encoded,
|
||||||
|
change,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
journal.commit_wallet(&binding).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_token_at_locked(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> {
|
async fn send_token_at_locked(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> {
|
||||||
let mut wallet = load_wallet(data_dir).await?;
|
let mut wallet = load_wallet(data_dir).await?;
|
||||||
let mint_url = mint_url.to_string();
|
let mint_url = mint_url.to_string();
|
||||||
@@ -1611,11 +1733,9 @@ pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<Restor
|
|||||||
// have found coins beyond where the counter file thought we were —
|
// have found coins beyond where the counter file thought we were —
|
||||||
// reusing those would mint proofs that collide with existing ones.
|
// reusing those would mint proofs that collide with existing ones.
|
||||||
if let Some(highest) = highest_seen {
|
if let Some(highest) = highest_seen {
|
||||||
if let Err(e) =
|
super::nut13::advance_counter_to(data_dir, &keyset.id, highest + 1)
|
||||||
super::nut13::advance_counter_to(data_dir, &keyset.id, highest + 1).await
|
.await
|
||||||
{
|
.context("Could not preserve the restored wallet derivation position")?;
|
||||||
warn!("Could not advance the NUT-13 counter after restore: {e:#}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ impl PreparedSwap {
|
|||||||
|
|
||||||
pub(super) fn validate_for_mint(&self, mint_url: &str) -> Result<()> {
|
pub(super) fn validate_for_mint(&self, mint_url: &str) -> Result<()> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
self.mint_url == mint_url,
|
self.mint_url.trim_end_matches('/') == mint_url.trim_end_matches('/'),
|
||||||
"Prepared swap belongs to a different mint"
|
"Prepared swap belongs to a different mint"
|
||||||
);
|
);
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
@@ -173,7 +173,7 @@ impl PreparedSwap {
|
|||||||
.collect::<Result<_>>()?;
|
.collect::<Result<_>>()?;
|
||||||
for proof in proofs {
|
for proof in proofs {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
proof.id == self.keyset.id
|
super::cashu::matches_stored_keyset_id(&proof.id, &self.keyset.id)
|
||||||
&& expected.remove(proof.secret.as_str()) == Some(proof.amount),
|
&& expected.remove(proof.secret.as_str()) == Some(proof.amount),
|
||||||
"Payment result does not match prepared outputs"
|
"Payment result does not match prepared outputs"
|
||||||
);
|
);
|
||||||
@@ -1009,11 +1009,40 @@ impl MintClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(echoed
|
let mut requested = std::collections::HashMap::new();
|
||||||
.into_iter()
|
for output in outputs {
|
||||||
.map(|o| o.b_prime)
|
let point = output
|
||||||
.zip(signatures)
|
.b_prime
|
||||||
.collect())
|
.parse::<secp256k1::PublicKey>()
|
||||||
|
.context("Invalid restoration output point")?
|
||||||
|
.to_string();
|
||||||
|
anyhow::ensure!(
|
||||||
|
requested.insert(point, output).is_none(),
|
||||||
|
"Duplicate restoration request output"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut restored = Vec::new();
|
||||||
|
for (output, signature) in echoed.into_iter().zip(signatures) {
|
||||||
|
let point = output
|
||||||
|
.b_prime
|
||||||
|
.parse::<secp256k1::PublicKey>()
|
||||||
|
.context("Invalid restored output point")?
|
||||||
|
.to_string();
|
||||||
|
let original = requested
|
||||||
|
.remove(&point)
|
||||||
|
.context("Unknown or duplicate restored output")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
super::cashu::matches_stored_keyset_id(&output.id, &original.id)
|
||||||
|
&& super::cashu::matches_stored_keyset_id(&signature.id, &original.id)
|
||||||
|
&& signature.amount.is_power_of_two()
|
||||||
|
&& (original.amount == 0 || original.amount == signature.amount)
|
||||||
|
&& (output.amount == 0 || output.amount == signature.amount),
|
||||||
|
"Restored output metadata changed"
|
||||||
|
);
|
||||||
|
signature.c_prime_as_pubkey()?;
|
||||||
|
restored.push((point, signature));
|
||||||
|
}
|
||||||
|
Ok(restored)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receive a CashuToken by swapping its proofs for fresh ones.
|
/// Receive a CashuToken by swapping its proofs for fresh ones.
|
||||||
|
|||||||
@@ -89,14 +89,15 @@ impl Mint {
|
|||||||
{"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee},
|
{"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee},
|
||||||
{"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee}
|
{"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee}
|
||||||
]}),
|
]}),
|
||||||
"/v1/keys" => {
|
path if path == "/v1/keys" || path.starts_with("/v1/keys/") => {
|
||||||
let public =
|
let public =
|
||||||
PublicKey::from_secret_key(&Secp256k1::new(), &signing_key())
|
PublicKey::from_secret_key(&Secp256k1::new(), &signing_key())
|
||||||
.to_string();
|
.to_string();
|
||||||
let keys: serde_json::Map<String, Value> = (0..16)
|
let keys: serde_json::Map<String, Value> = (0..16)
|
||||||
.map(|i| ((1u64 << i).to_string(), json!(public)))
|
.map(|i| ((1u64 << i).to_string(), json!(public)))
|
||||||
.collect();
|
.collect();
|
||||||
json!({"keysets":[{"id": ACTIVE,"unit":"sat","keys":keys}]})
|
let id = path.strip_prefix("/v1/keys/").unwrap_or(ACTIVE);
|
||||||
|
json!({"keysets":[{"id": id,"unit":"sat","keys":keys}]})
|
||||||
}
|
}
|
||||||
"/v1/swap" => {
|
"/v1/swap" => {
|
||||||
let body: Value = serde_json::from_slice(
|
let body: Value = serde_json::from_slice(
|
||||||
@@ -157,7 +158,10 @@ impl Mint {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let overridden = state_override.lock().unwrap().clone();
|
let overridden = state_override.lock().unwrap().clone();
|
||||||
overridden.unwrap_or_else(|| json!({"states": body["Ys"].as_array().unwrap().iter().map(|y| json!({"Y":y,"state":"UNSPENT"})).collect::<Vec<_>>()}))
|
overridden.unwrap_or_else(|| {
|
||||||
|
let spent_points: std::collections::HashSet<_> = spent.lock().unwrap().iter().map(|secret| hex::encode(bdhke::hash_to_curve(secret.as_bytes()).unwrap().serialize())).collect();
|
||||||
|
json!({"states": body["Ys"].as_array().unwrap().iter().map(|y| json!({"Y":y,"state":if spent_points.contains(y.as_str().unwrap()) {"SPENT"} else {"UNSPENT"}})).collect::<Vec<_>>()})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
"/v1/restore" => {
|
"/v1/restore" => {
|
||||||
let body: Value = serde_json::from_slice(
|
let body: Value = serde_json::from_slice(
|
||||||
@@ -713,6 +717,215 @@ async fn journal_recovers_lost_swap_reply_and_commits_change_once() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recoverable_send_reuses_original_operation_after_ambiguous_mint_response() {
|
||||||
|
let mint = Mint::start(0, None).await;
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let mut wallet = WalletState::default();
|
||||||
|
wallet.mint_url = mint.url.clone();
|
||||||
|
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||||
|
save_wallet(root.path(), &wallet).await.unwrap();
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let context = "ab".repeat(32);
|
||||||
|
mint.lose_swap_reply
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
assert!(send_token_recoverable(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
&mint.url,
|
||||||
|
4,
|
||||||
|
&context
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
|
||||||
|
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||||
|
// A missing restore response is not permission to swap spent inputs again.
|
||||||
|
*mint.restore_reply.lock().unwrap() = Some(json!({"outputs":[],"signatures":[]}));
|
||||||
|
assert!(send_token_recoverable(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
&mint.url,
|
||||||
|
4,
|
||||||
|
&context
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||||
|
*mint.restore_reply.lock().unwrap() = None;
|
||||||
|
let token = send_token_recoverable(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
&mint.url,
|
||||||
|
4,
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(CashuToken::deserialize(&token).unwrap().total_amount(), 4);
|
||||||
|
let purse = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
send_token_recoverable(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
&mint.url,
|
||||||
|
4,
|
||||||
|
&context
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
token
|
||||||
|
);
|
||||||
|
assert!(send_token_recoverable(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
&mint.url,
|
||||||
|
8,
|
||||||
|
&context
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(send_token_recoverable(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
&mint.url,
|
||||||
|
4,
|
||||||
|
&"cd".repeat(32)
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
|
||||||
|
purse
|
||||||
|
);
|
||||||
|
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4);
|
||||||
|
assert_eq!(
|
||||||
|
load_wallet(root.path()).await.unwrap().transactions.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recoverable_exact_send_supports_compact_v2_and_keeps_full_wallet_id() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let mint = "https://unused-mint.invalid/";
|
||||||
|
let mut wallet = WalletState::default();
|
||||||
|
wallet.mint_url = mint.into();
|
||||||
|
wallet.add_proofs(mint, vec![proof(V2, 8)]);
|
||||||
|
save_wallet(root.path(), &wallet).await.unwrap();
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let context = "ab".repeat(32);
|
||||||
|
let token = send_token_recoverable(root.path(), &id, EcashNetwork::Mainnet, mint, 8, &context)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
CashuToken::deserialize(&token).unwrap().token[0].proofs[0].id,
|
||||||
|
&V2[..16]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
send_token_recoverable(root.path(), &id, EcashNetwork::Mainnet, mint, 8, &context)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
token
|
||||||
|
);
|
||||||
|
let wallet = load_wallet(root.path()).await.unwrap();
|
||||||
|
assert_eq!(wallet.balance(), 0);
|
||||||
|
assert_eq!(wallet.proofs[0].proof.id, V2);
|
||||||
|
assert_eq!(wallet.transactions.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seed_restore_refuses_to_credit_when_counter_persistence_fails() {
|
||||||
|
let mint = Mint::start(0, None).await;
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
crate::wallet::nut13::establish_independent(root.path())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let client = MintClient::new(&mint.url).unwrap().with_recovery(
|
||||||
|
crate::wallet::nut13::RecoverySource::load(root.path())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let prepared = client
|
||||||
|
.prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
client.execute_prepared_swap(&prepared).await.unwrap();
|
||||||
|
let counter = root.path().join("wallet/cashu_counters.json");
|
||||||
|
std::fs::rename(&counter, root.path().join("counter-fixture-backup")).unwrap();
|
||||||
|
std::fs::create_dir(&counter).unwrap();
|
||||||
|
let error = restore_from_seed(root.path(), &mint.url).await.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("derivation position"));
|
||||||
|
assert!(counter.is_dir());
|
||||||
|
assert!(!root.path().join("wallet/ecash.json").exists());
|
||||||
|
std::fs::remove_dir(&counter).unwrap();
|
||||||
|
std::fs::rename(root.path().join("counter-fixture-backup"), &counter).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
restore_from_seed(root.path(), &mint.url)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.recovered_sats,
|
||||||
|
8
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restore_from_seed(root.path(), &mint.url)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.recovered_sats,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
|
||||||
|
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seed_restore_matches_points_and_rejects_foreign_or_duplicated_metadata() {
|
||||||
|
let mint = Mint::start(0, None).await;
|
||||||
|
let client = MintClient::new(&mint.url).unwrap();
|
||||||
|
let prepared = client
|
||||||
|
.prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
client.execute_prepared_swap(&prepared).await.unwrap();
|
||||||
|
let encoded = serde_json::to_value(&prepared).unwrap();
|
||||||
|
let outputs: Vec<crate::wallet::cashu::BlindedMessageRequest> =
|
||||||
|
serde_json::from_value(encoded["outputs"].clone()).unwrap();
|
||||||
|
let restored = client.restore(&outputs).await.unwrap();
|
||||||
|
assert_eq!(restored.len(), 2);
|
||||||
|
assert!(restored
|
||||||
|
.iter()
|
||||||
|
.all(|(point, _)| outputs.iter().any(|output| &output.b_prime == point)));
|
||||||
|
let output = encoded["outputs"][0].clone();
|
||||||
|
let signature = json!({"id":ACTIVE,"amount":4,"C_":signed_point(output["B_"].as_str().unwrap().parse().unwrap())});
|
||||||
|
// A seed scan legitimately receives only the outputs the mint signed.
|
||||||
|
*mint.restore_reply.lock().unwrap() =
|
||||||
|
Some(json!({"outputs":[output.clone()],"signatures":[signature.clone()]}));
|
||||||
|
assert_eq!(client.restore(&outputs).await.unwrap().len(), 1);
|
||||||
|
let mut foreign = output.clone();
|
||||||
|
foreign["B_"] =
|
||||||
|
json!(PublicKey::from_secret_key(&Secp256k1::new(), &signing_key()).to_string());
|
||||||
|
let mut wrong_keyset = signature.clone();
|
||||||
|
wrong_keyset["id"] = json!(V2);
|
||||||
|
let mut wrong_amount = signature.clone();
|
||||||
|
wrong_amount["amount"] = json!(8);
|
||||||
|
for response in [
|
||||||
|
json!({"outputs":[output.clone(),output.clone()],"signatures":[signature.clone(),signature.clone()]}),
|
||||||
|
json!({"outputs":[foreign],"signatures":[signature.clone()]}),
|
||||||
|
json!({"outputs":[output.clone()],"signatures":[wrong_keyset]}),
|
||||||
|
json!({"outputs":[output],"signatures":[wrong_amount]}),
|
||||||
|
] {
|
||||||
|
*mint.restore_reply.lock().unwrap() = Some(response);
|
||||||
|
assert!(client.restore(&outputs).await.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn damaged_or_wrong_mint_preparation_fails_before_spending() {
|
async fn damaged_or_wrong_mint_preparation_fails_before_spending() {
|
||||||
let mint = Mint::start(0, None).await;
|
let mint = Mint::start(0, None).await;
|
||||||
|
|||||||
@@ -802,10 +802,24 @@ impl<'a> Journal<'a> {
|
|||||||
match &record.request {
|
match &record.request {
|
||||||
Request::Exact { proofs: expected } => {
|
Request::Exact { proofs: expected } => {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
outcome.change.is_empty()
|
outcome.change.is_empty() && proofs.len() == expected.len(),
|
||||||
&& serde_json::to_value(proofs)? == serde_json::to_value(expected)?,
|
|
||||||
"Exact payment result changed its proofs"
|
"Exact payment result changed its proofs"
|
||||||
);
|
);
|
||||||
|
let mut expected: std::collections::HashMap<_, _> = expected
|
||||||
|
.iter()
|
||||||
|
.map(|proof| (proof.secret.as_str(), proof))
|
||||||
|
.collect();
|
||||||
|
for proof in proofs {
|
||||||
|
let original = expected
|
||||||
|
.remove(proof.secret.as_str())
|
||||||
|
.context("Exact payment result has an unknown or duplicate proof")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
proof.amount == original.amount
|
||||||
|
&& super::cashu::matches_stored_keyset_id(&proof.id, &original.id)
|
||||||
|
&& proof.c_as_pubkey()? == original.c_as_pubkey()?,
|
||||||
|
"Exact payment result changed its proofs"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Request::Swap(prepared) => {
|
Request::Swap(prepared) => {
|
||||||
let mut all = proofs.clone();
|
let mut all = proofs.clone();
|
||||||
|
|||||||
@@ -272,3 +272,20 @@ requests also must cover the bound payment amount before being recorded.
|
|||||||
Full isolated qualification:1,779passed, zero failures, five existing skips,
|
Full isolated qualification:1,779passed, zero failures, five existing skips,
|
||||||
`/tmp/archy-journal-restore-boundary-tests.log`. These source changes remain
|
`/tmp/archy-journal-restore-boundary-tests.log`. These source changes remain
|
||||||
undeployed. The higher-level purchase/receipt executor remains open.
|
undeployed. The higher-level purchase/receipt executor remains open.
|
||||||
|
|
||||||
|
### Recoverable send executor qualified locally
|
||||||
|
|
||||||
|
The caller-owned operation now reserves inputs before remote spending, restores
|
||||||
|
the original saved swap after an ambiguous response, and commits its original
|
||||||
|
token/change/history once. Changed payment terms reject reuse. A spent input with
|
||||||
|
no recoverable output blocks another swap. Exact sends support compact v2 wire
|
||||||
|
keyset IDs while retaining the full wallet ID. Seed restoration canonicalizes
|
||||||
|
curve points, rejects foreign or duplicate metadata, and requires durable counter
|
||||||
|
advancement before crediting recovered funds.
|
||||||
|
|
||||||
|
The first test run exposed compact-ID comparison and uppercase restore-point
|
||||||
|
matching defects; both were fixed. The final isolated run passed1,783tests with
|
||||||
|
zero failures and five existing skips:
|
||||||
|
`/tmp/archy-recoverable-send-executor-final-tests.log`.
|
||||||
|
No real payment or deployment was performed. Purchase RPC integration, durable
|
||||||
|
seller settlement/receipt recovery and the end-to-end acceptance remain open.
|
||||||
|
|||||||
@@ -388,13 +388,14 @@ cover startup migration, hostname regeneration, first boot and explicit rotation
|
|||||||
tests pass. Exact operator hostname/app, trusted TLS and companion acceptance
|
tests pass. Exact operator hostname/app, trusted TLS and companion acceptance
|
||||||
remain open. See `docs/https-app-gate-followup-20261006.md`.
|
remain open. See `docs/https-app-gate-followup-20261006.md`.
|
||||||
|
|
||||||
## 18. Firewall and tunnel UI/settings — latest addition, last in sequence
|
## 18. Firewall and tunnel UI/settings
|
||||||
|
|
||||||
Status: handover read and acknowledged on6October; implementation pending.
|
Status: handover read and acknowledged on6October; implementation pending.
|
||||||
The private handover and acknowledgement live in the separate mining review
|
The private handover and acknowledgement live in the separate mining review
|
||||||
checkout. Do not commit its deployment addresses or operational details here.
|
checkout. Do not commit its deployment addresses or operational details here.
|
||||||
|
|
||||||
- Keep this task at the end, after the previously deferred MeshCore work.
|
- Keep this task after the previously deferred MeshCore work. The subsequent
|
||||||
|
connection UX additions below follow all other tasks and their acceptance.
|
||||||
- Network → Local network: make the Firewall Active row clickable and add a
|
- Network → Local network: make the Firewall Active row clickable and add a
|
||||||
bottom-anchored Firewall & tunnels button. Both open one central settings
|
bottom-anchored Firewall & tunnels button. Both open one central settings
|
||||||
screen; app pages may link to it, but are not the primary configuration UI.
|
screen; app pages may link to it, but are not the primary configuration UI.
|
||||||
@@ -419,3 +420,36 @@ checkout. Do not commit its deployment addresses or operational details here.
|
|||||||
as requested, while retaining repository regression and release requirements.
|
as requested, while retaining repository regression and release requirements.
|
||||||
- Review and integrate once through ngit, preserve the already integrated mining
|
- Review and integrate once through ngit, preserve the already integrated mining
|
||||||
work, and mirror accepted commits to Gitea. No release gate is waived.
|
work, and mirror accepted commits to Gitea. No release gate is waived.
|
||||||
|
|
||||||
|
## Deferred expansion of tasks 3/6: connection UX and final functional review
|
||||||
|
|
||||||
|
Operator sequencing on6October: finish all other tasks, related deployments,
|
||||||
|
tests, app deployments and UAT first. Then perform this expansion and the final
|
||||||
|
functional/UX review. These are additions to existing groups, not closed work.
|
||||||
|
|
||||||
|
- Distinguish peer relationships from federation membership in labels, actions
|
||||||
|
and removal confirmations, including nodes with both relationships. Remove only
|
||||||
|
the selected relationship; describe exactly what changed.
|
||||||
|
- Anchor removal buttons at card bottoms. Show an immediate per-action spinner,
|
||||||
|
prevent duplicate submissions, and show an accurate completion/error toast.
|
||||||
|
- Measure and reduce removal latency. Verify whether an authenticated existing
|
||||||
|
FIPS connection is preferred; implement that priority where supported, with
|
||||||
|
bounded fallback and no weakening of identity or authorization checks.
|
||||||
|
- Hide rejected, expired and otherwise resolved Nostr requests from actionable
|
||||||
|
lists. Prevent relay replay or stale refresh results from resurrecting them;
|
||||||
|
retain any history needed for diagnostics separately.
|
||||||
|
- Give incoming peer requests useful notifications linking directly to the
|
||||||
|
correct Federation/Peers request and its accept/reject actions.
|
||||||
|
- Update relationship, request, availability and operation states automatically
|
||||||
|
across screens without routine manual sync. Handle reconnect, missed events,
|
||||||
|
out-of-order replies and failed refreshes without invented success states.
|
||||||
|
- When a request changes the 3D map layout, rotate its node to the front, top
|
||||||
|
centre and clearly expose the request action. Cover multiple requests,
|
||||||
|
completion, user camera interaction, reduced motion and mobile viewports.
|
||||||
|
- Review complete desktop/mobile connection flows after the other work passes:
|
||||||
|
put useful node capabilities and actions first, reduce unnecessary navigation
|
||||||
|
and scrolling, and assess direct links to permitted peer Cloud files. Preserve
|
||||||
|
the design system, trust boundaries and meaningful loading/error feedback.
|
||||||
|
- Qualify real reciprocal removal/requests, offline and reconnect behavior,
|
||||||
|
duplicate/replayed events, automatic UI convergence, notifications/deep links,
|
||||||
|
map positioning and responsive card geometry before claiming acceptance.
|
||||||
|
|||||||
Reference in New Issue
Block a user