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()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// default) and falling back to other alphabets some implementations use.
|
||||
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
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
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 —
|
||||
// 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:#}");
|
||||
}
|
||||
super::nut13::advance_counter_to(data_dir, &keyset.id, highest + 1)
|
||||
.await
|
||||
.context("Could not preserve the restored wallet derivation position")?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ impl PreparedSwap {
|
||||
|
||||
pub(super) fn validate_for_mint(&self, mint_url: &str) -> Result<()> {
|
||||
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"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
@@ -173,7 +173,7 @@ impl PreparedSwap {
|
||||
.collect::<Result<_>>()?;
|
||||
for proof in proofs {
|
||||
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),
|
||||
"Payment result does not match prepared outputs"
|
||||
);
|
||||
@@ -1009,11 +1009,40 @@ impl MintClient {
|
||||
);
|
||||
}
|
||||
|
||||
Ok(echoed
|
||||
.into_iter()
|
||||
.map(|o| o.b_prime)
|
||||
.zip(signatures)
|
||||
.collect())
|
||||
let mut requested = std::collections::HashMap::new();
|
||||
for output in outputs {
|
||||
let point = output
|
||||
.b_prime
|
||||
.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.
|
||||
|
||||
@@ -89,14 +89,15 @@ impl Mint {
|
||||
{"id": ACTIVE,"unit":"sat","active":true,"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 =
|
||||
PublicKey::from_secret_key(&Secp256k1::new(), &signing_key())
|
||||
.to_string();
|
||||
let keys: serde_json::Map<String, Value> = (0..16)
|
||||
.map(|i| ((1u64 << i).to_string(), json!(public)))
|
||||
.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" => {
|
||||
let body: Value = serde_json::from_slice(
|
||||
@@ -157,7 +158,10 @@ impl Mint {
|
||||
)
|
||||
.unwrap();
|
||||
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" => {
|
||||
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]
|
||||
async fn damaged_or_wrong_mint_preparation_fails_before_spending() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
|
||||
@@ -802,10 +802,24 @@ impl<'a> Journal<'a> {
|
||||
match &record.request {
|
||||
Request::Exact { proofs: expected } => {
|
||||
anyhow::ensure!(
|
||||
outcome.change.is_empty()
|
||||
&& serde_json::to_value(proofs)? == serde_json::to_value(expected)?,
|
||||
outcome.change.is_empty() && proofs.len() == expected.len(),
|
||||
"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) => {
|
||||
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,
|
||||
`/tmp/archy-journal-restore-boundary-tests.log`. These source changes remain
|
||||
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
|
||||
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.
|
||||
The private handover and acknowledgement live in the separate mining review
|
||||
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
|
||||
bottom-anchored Firewall & tunnels button. Both open one central settings
|
||||
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.
|
||||
- Review and integrate once through ngit, preserve the already integrated mining
|
||||
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