Stop ecash recovery failures from silently reusing or abandoning backup state
This commit is contained in:
@@ -426,7 +426,7 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu
|
|||||||
/// through here. On a node with no phrase yet the source is absent and the
|
/// 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.
|
/// behaviour is exactly as it was before: valid proofs, no backup.
|
||||||
async fn mint_client(data_dir: &Path, mint_url: &str) -> Result<MintClient> {
|
async fn mint_client(data_dir: &Path, mint_url: &str) -> Result<MintClient> {
|
||||||
Ok(MintClient::new(mint_url)?.with_recovery(RecoverySource::load(data_dir).await))
|
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.
|
||||||
@@ -1424,7 +1424,7 @@ pub struct RestoreOutcome {
|
|||||||
/// coins or resurrecting spent ones, which matters because the most likely
|
/// coins or resurrecting spent ones, which matters because the most likely
|
||||||
/// time to press this button is when something already looks wrong.
|
/// 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> {
|
pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> {
|
||||||
let recovery = RecoverySource::load(data_dir).await.ok_or_else(|| {
|
let recovery = RecoverySource::load(data_dir).await?.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"This wallet has no backup phrase yet, so there is nothing to restore from. \
|
"This wallet has no backup phrase yet, so there is nothing to restore from. \
|
||||||
Set one up in Settings → Ecash backup phrase."
|
Set one up in Settings → Ecash backup phrase."
|
||||||
|
|||||||
@@ -221,11 +221,10 @@ impl MintClient {
|
|||||||
/// the `(secret, blinding factor, amount)` needed to unblind the mint's
|
/// the `(secret, blinding factor, amount)` needed to unblind the mint's
|
||||||
/// signatures afterwards.
|
/// signatures afterwards.
|
||||||
///
|
///
|
||||||
/// Prefers NUT-13 derivation so the resulting proofs are restorable. Falls
|
/// Uses NUT-13 when the wallet has a recovery source. Derivation or durable
|
||||||
/// back to random secrets when this wallet has no phrase yet, or when the
|
/// counter failures stop before sending a mint request; they must not
|
||||||
/// keyset id is one NUT-13 cannot address — a random secret still mints a
|
/// silently turn a backed-up wallet into one with unrecoverable outputs.
|
||||||
/// perfectly valid, spendable proof, so refusing here would break the
|
/// Legacy wallets with no seed retain their explicit random-output path.
|
||||||
/// wallet to protect a backup that does not exist.
|
|
||||||
async fn blinded_outputs(
|
async fn blinded_outputs(
|
||||||
&self,
|
&self,
|
||||||
keyset_id: &str,
|
keyset_id: &str,
|
||||||
@@ -235,13 +234,14 @@ impl MintClient {
|
|||||||
Vec<(Vec<u8>, secp256k1::SecretKey, u64)>,
|
Vec<(Vec<u8>, secp256k1::SecretKey, u64)>,
|
||||||
)> {
|
)> {
|
||||||
let derived = match &self.recovery {
|
let derived = match &self.recovery {
|
||||||
Some(source) => match source.next_outputs(keyset_id, amounts.len()).await {
|
Some(source) => Some(
|
||||||
Ok(pairs) => Some(pairs),
|
source
|
||||||
Err(e) => {
|
.next_outputs(keyset_id, amounts.len())
|
||||||
warn!("Minting unrecoverable proofs — NUT-13 derivation failed: {e:#}");
|
.await
|
||||||
None
|
.context(
|
||||||
}
|
"Could not prepare recoverable ecash outputs; no mint request was sent",
|
||||||
},
|
)?,
|
||||||
|
),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -908,4 +908,57 @@ mod tests {
|
|||||||
let client = MintClient::new("http://mint.example.com").unwrap();
|
let client = MintClient::new("http://mint.example.com").unwrap();
|
||||||
assert_eq!(client.url(), "http://mint.example.com");
|
assert_eq!(client.url(), "http://mint.example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn backed_outputs_fail_closed_when_counter_storage_is_damaged() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let (_, master) = crate::seed::MasterSeed::generate().unwrap();
|
||||||
|
super::super::nut13::establish_from_master(directory.path(), &master)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let recovery = RecoverySource::load(directory.path())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let client = MintClient::new("http://127.0.0.1:1")
|
||||||
|
.unwrap()
|
||||||
|
.with_recovery(Some(recovery.clone()));
|
||||||
|
let counter = directory.path().join("wallet/cashu_counters.json");
|
||||||
|
tokio::fs::write(&counter, "").await.unwrap();
|
||||||
|
assert!(client
|
||||||
|
.blinded_outputs("009a1f293253e41e", &[1, 2])
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(tokio::fs::read(&counter).await.unwrap().is_empty());
|
||||||
|
tokio::fs::remove_file(&counter).await.unwrap();
|
||||||
|
let (messages, blinding) = client
|
||||||
|
.blinded_outputs("009a1f293253e41e", &[1, 2])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(messages.len(), 2);
|
||||||
|
for (index, (secret, _, _)) in blinding.iter().enumerate() {
|
||||||
|
assert!(
|
||||||
|
*secret
|
||||||
|
== recovery
|
||||||
|
.derive_at("009a1f293253e41e", index as u32)
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(client
|
||||||
|
.blinded_outputs("01fc0ec0e59cd6fa", &[1])
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_explicit_legacy_wallet_without_a_seed_still_prepares_outputs() {
|
||||||
|
let client = MintClient::new("http://127.0.0.1:1").unwrap();
|
||||||
|
let (messages, blinding) = client
|
||||||
|
.blinded_outputs("009a1f293253e41e", &[1, 2])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(messages.len(), 2);
|
||||||
|
assert_eq!(blinding.len(), 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,8 +197,10 @@ pub fn seed_exists(data_dir: &Path) -> bool {
|
|||||||
/// telling the operator their backup was fine.
|
/// telling the operator their backup was fine.
|
||||||
pub async fn load_seed(data_dir: &Path) -> Result<Option<EcashSeed>> {
|
pub async fn load_seed(data_dir: &Path) -> Result<Option<EcashSeed>> {
|
||||||
let path = seed_path(data_dir);
|
let path = seed_path(data_dir);
|
||||||
let Ok(content) = fs::read_to_string(&path).await else {
|
let content = match fs::read_to_string(&path).await {
|
||||||
return Ok(None);
|
Ok(content) => content,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(error) => return Err(error).context("Could not read the existing ecash backup seed"),
|
||||||
};
|
};
|
||||||
let stored: StoredSeed = serde_json::from_str(&content)
|
let stored: StoredSeed = serde_json::from_str(&content)
|
||||||
.with_context(|| format!("The ecash seed file is damaged: {}", path.display()))?;
|
.with_context(|| format!("The ecash seed file is damaged: {}", path.display()))?;
|
||||||
@@ -400,9 +402,10 @@ pub async fn reserve_counters(data_dir: &Path, keyset_id: &str, count: usize) ->
|
|||||||
let path = data_dir.join(COUNTER_FILE);
|
let path = data_dir.join(COUNTER_FILE);
|
||||||
|
|
||||||
let mut state: StoredCounters = match fs::read_to_string(&path).await {
|
let mut state: StoredCounters = match fs::read_to_string(&path).await {
|
||||||
Ok(content) if !content.trim().is_empty() => serde_json::from_str(&content)
|
Ok(content) => serde_json::from_str(&content)
|
||||||
.with_context(|| format!("The ecash counter file is damaged: {}", path.display()))?,
|
.with_context(|| format!("The ecash counter file is damaged: {}", path.display()))?,
|
||||||
_ => StoredCounters::default(),
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => StoredCounters::default(),
|
||||||
|
Err(error) => return Err(error).context("Could not read the ecash counter file"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let start = *state.counters.get(keyset_id).unwrap_or(&0);
|
let start = *state.counters.get(keyset_id).unwrap_or(&0);
|
||||||
@@ -418,13 +421,49 @@ pub async fn reserve_counters(data_dir: &Path, keyset_id: &str, count: usize) ->
|
|||||||
}
|
}
|
||||||
let content =
|
let content =
|
||||||
serde_json::to_string_pretty(&state).context("Failed to serialize ecash counters")?;
|
serde_json::to_string_pretty(&state).context("Failed to serialize ecash counters")?;
|
||||||
fs::write(&path, content)
|
persist_counters(&path, content.as_bytes()).await?;
|
||||||
.await
|
|
||||||
.context("Failed to persist ecash counters")?;
|
|
||||||
|
|
||||||
Ok(start)
|
Ok(start)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Never truncate the active reservation file. A reservation is not usable
|
||||||
|
/// until both its replacement file and directory entry have reached storage.
|
||||||
|
async fn persist_counters(path: &Path, content: &[u8]) -> Result<()> {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
struct PendingCounterFile(PathBuf);
|
||||||
|
impl Drop for PendingCounterFile {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let parent = path.parent().context("Counter file has no directory")?;
|
||||||
|
let temporary =
|
||||||
|
PendingCounterFile(parent.join(format!(".cashu-counters-{}.tmp", uuid::Uuid::new_v4())));
|
||||||
|
let mut file = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&temporary.0)
|
||||||
|
.await
|
||||||
|
.context("Could not create the ecash counter reservation")?;
|
||||||
|
file.write_all(content)
|
||||||
|
.await
|
||||||
|
.context("Could not write the ecash counter reservation")?;
|
||||||
|
file.sync_all()
|
||||||
|
.await
|
||||||
|
.context("Could not flush the ecash counter reservation")?;
|
||||||
|
drop(file);
|
||||||
|
fs::rename(&temporary.0, path)
|
||||||
|
.await
|
||||||
|
.context("Could not replace the ecash counter reservation")?;
|
||||||
|
fs::File::open(parent)
|
||||||
|
.await?
|
||||||
|
.sync_all()
|
||||||
|
.await
|
||||||
|
.context("Could not flush the ecash counter directory")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Read the next-unused counter for a keyset without reserving anything.
|
/// Read the next-unused counter for a keyset without reserving anything.
|
||||||
pub async fn counter_for(data_dir: &Path, keyset_id: &str) -> u32 {
|
pub async fn counter_for(data_dir: &Path, keyset_id: &str) -> u32 {
|
||||||
let path = data_dir.join(COUNTER_FILE);
|
let path = data_dir.join(COUNTER_FILE);
|
||||||
@@ -463,17 +502,13 @@ impl RecoverySource {
|
|||||||
/// Build a recovery source for this node, or `None` when the wallet has no
|
/// Build a recovery source for this node, or `None` when the wallet has no
|
||||||
/// seed yet. Callers fall back to random secrets in that case, which is
|
/// seed yet. Callers fall back to random secrets in that case, which is
|
||||||
/// exactly the pre-NUT-13 behaviour — correct, just not restorable.
|
/// exactly the pre-NUT-13 behaviour — correct, just not restorable.
|
||||||
pub async fn load(data_dir: &Path) -> Option<Self> {
|
pub async fn load(data_dir: &Path) -> Result<Option<Self>> {
|
||||||
match load_seed(data_dir).await {
|
match load_seed(data_dir).await? {
|
||||||
Ok(Some(seed)) => Some(Self {
|
Some(seed) => Ok(Some(Self {
|
||||||
seed,
|
seed,
|
||||||
data_dir: data_dir.to_path_buf(),
|
data_dir: data_dir.to_path_buf(),
|
||||||
}),
|
})),
|
||||||
Ok(None) => None,
|
None => Ok(None),
|
||||||
Err(e) => {
|
|
||||||
warn!("Ecash wallet seed unusable, minting unrecoverable proofs: {e:#}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +520,7 @@ impl RecoverySource {
|
|||||||
) -> Result<Vec<(Vec<u8>, SecretKey)>> {
|
) -> Result<Vec<(Vec<u8>, SecretKey)>> {
|
||||||
// Fail the derivation *before* burning counters if this keyset id is
|
// Fail the derivation *before* burning counters if this keyset id is
|
||||||
// one NUT-13 cannot address.
|
// one NUT-13 cannot address.
|
||||||
|
self.seed.derive_output(keyset_id, 0)?;
|
||||||
let start = reserve_counters(&self.data_dir, keyset_id, count).await?;
|
let start = reserve_counters(&self.data_dir, keyset_id, count).await?;
|
||||||
(0..count)
|
(0..count)
|
||||||
.map(|i| self.seed.derive_output(keyset_id, start + i as u32))
|
.map(|i| self.seed.derive_output(keyset_id, start + i as u32))
|
||||||
@@ -763,8 +799,8 @@ mod tests {
|
|||||||
|
|
||||||
assert!(load_seed(d).await.is_err());
|
assert!(load_seed(d).await.is_err());
|
||||||
assert!(
|
assert!(
|
||||||
RecoverySource::load(d).await.is_none(),
|
RecoverySource::load(d).await.is_err(),
|
||||||
"an unusable seed must not be presented as a working one"
|
"an unusable seed must not downgrade to an unbacked wallet"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,7 +811,10 @@ mod tests {
|
|||||||
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||||
establish_from_master(d, &master).await.unwrap();
|
establish_from_master(d, &master).await.unwrap();
|
||||||
|
|
||||||
let source = RecoverySource::load(d).await.expect("seed was established");
|
let source = RecoverySource::load(d)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("seed was established");
|
||||||
let first = source.next_outputs(V1_KEYSET, 2).await.unwrap();
|
let first = source.next_outputs(V1_KEYSET, 2).await.unwrap();
|
||||||
let second = source.next_outputs(V1_KEYSET, 2).await.unwrap();
|
let second = source.next_outputs(V1_KEYSET, 2).await.unwrap();
|
||||||
|
|
||||||
@@ -794,4 +833,81 @@ mod tests {
|
|||||||
assert_eq!(secret, &expected);
|
assert_eq!(secret, &expected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_seed_is_distinct_from_a_seed_read_failure() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert!(RecoverySource::load(dir.path()).await.unwrap().is_none());
|
||||||
|
fs::create_dir_all(seed_path(dir.path())).await.unwrap();
|
||||||
|
assert!(load_seed(dir.path()).await.is_err());
|
||||||
|
assert!(RecoverySource::load(dir.path()).await.is_err());
|
||||||
|
assert!(seed_path(dir.path()).is_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_or_corrupt_counters_never_reset_a_reservation() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::create_dir_all(dir.path().join("wallet")).await.unwrap();
|
||||||
|
let path = dir.path().join(COUNTER_FILE);
|
||||||
|
for damaged in ["", " ", "{ truncated"] {
|
||||||
|
fs::write(&path, damaged).await.unwrap();
|
||||||
|
assert!(reserve_counters(dir.path(), V1_KEYSET, 1).await.is_err());
|
||||||
|
assert_eq!(fs::read_to_string(&path).await.unwrap(), damaged);
|
||||||
|
}
|
||||||
|
fs::remove_file(&path).await.unwrap();
|
||||||
|
fs::create_dir(&path).await.unwrap();
|
||||||
|
assert!(reserve_counters(dir.path(), V1_KEYSET, 1).await.is_err());
|
||||||
|
assert!(path.is_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_counter_reservations_survive_reload_and_leave_no_temporary_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for _ in 0..24 {
|
||||||
|
let path = dir.path().to_path_buf();
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
reserve_counters(&path, V1_KEYSET, 2).await.unwrap()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let mut starts = std::collections::HashSet::new();
|
||||||
|
for task in tasks {
|
||||||
|
assert!(starts.insert(task.await.unwrap()));
|
||||||
|
}
|
||||||
|
assert_eq!(counter_for(dir.path(), V1_KEYSET).await, 48);
|
||||||
|
assert_eq!(
|
||||||
|
reserve_counters(dir.path(), V1_KEYSET, 1).await.unwrap(),
|
||||||
|
48
|
||||||
|
);
|
||||||
|
let entries = std::fs::read_dir(dir.path().join("wallet"))
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| entry.unwrap().file_name())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
entries,
|
||||||
|
vec![std::ffi::OsString::from("cashu_counters.json")]
|
||||||
|
);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(dir.path().join(COUNTER_FILE))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_derivation_does_not_reserve_counters() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||||
|
establish_from_master(dir.path(), &master).await.unwrap();
|
||||||
|
let source = RecoverySource::load(dir.path()).await.unwrap().unwrap();
|
||||||
|
assert!(source.next_outputs("01fc0ec0e59cd6fa", 1).await.is_err());
|
||||||
|
assert!(!dir.path().join(COUNTER_FILE).exists());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,3 +76,31 @@ ignored tests**. Logs: `/tmp/archy-peer-payment-selection-tests.log` and
|
|||||||
`/tmp/archy-peer-payment-selection-full-tests.log`. Only the required isolated
|
`/tmp/archy-peer-payment-selection-full-tests.log`. Only the required isolated
|
||||||
runner was used. No live wallet data or real payments were involved. This is
|
runner was used. No live wallet data or real payments were involved. This is
|
||||||
source/test qualification; production build and deployment are separate.
|
source/test qualification; production build and deployment are separate.
|
||||||
|
|
||||||
|
## Recovery-metadata prerequisite qualified (2026-10-06)
|
||||||
|
|
||||||
|
Seed reads now distinguish genuine absence from I/O failure. Loading a damaged
|
||||||
|
recovery source returns an error instead of silently enabling random outputs.
|
||||||
|
A configured recovery source must reserve/derive outputs successfully before a
|
||||||
|
mint request; derivation/counter failures no longer fall back to random secrets.
|
||||||
|
Legacy wallets which genuinely have no seed retain their existing output path.
|
||||||
|
|
||||||
|
Counter reservations reject empty/corrupt/unreadable existing files rather than
|
||||||
|
resetting to zero. Updates use an owner-only sibling temporary file, file flush,
|
||||||
|
atomic replacement and directory flush before returning a usable reservation.
|
||||||
|
Invalid derivation is rejected before reserving counters. Existing counter
|
||||||
|
serialization within the management process is preserved; this is not a claim
|
||||||
|
that a new cross-process wallet lock or the purchase journal has been implemented.
|
||||||
|
|
||||||
|
Six new regressions cover damaged seed/counter reads, preserved damaged files,
|
||||||
|
concurrent reservations/reload/private permissions/temp cleanup, invalid keysets,
|
||||||
|
no silent random-output fallback and explicit legacy behavior. Wallet-focused
|
||||||
|
isolated result:150passed,0failed,1existing ignored. Full isolated result:
|
||||||
|
**1,755passed,0failed,5existing ignored**. Evidence:
|
||||||
|
`/tmp/archy-wallet-recovery-prerequisites-tests.log` and
|
||||||
|
`/tmp/archy-wallet-recovery-prerequisites-full-tests.log`.
|
||||||
|
|
||||||
|
Read-only checks found well-shaped seed/counter JSON on dev and Yaya; no secret
|
||||||
|
values were printed and no wallet files were changed by those checks. Production
|
||||||
|
build/deployment of this prerequisite remains pending. Durable initial purchase
|
||||||
|
intent, mint-operation recovery, seller receipt and refund recovery remain open.
|
||||||
|
|||||||
@@ -831,3 +831,18 @@ payment recovery, timed FIPS playback and complete node-to-node acceptance remai
|
|||||||
open. V4V managed installation still requires the pending node-only catalog
|
open. V4V managed installation still requires the pending node-only catalog
|
||||||
signature. The running dev/Yaya dashboard/backend builds are unchanged, and no
|
signature. The running dev/Yaya dashboard/backend builds are unchanged, and no
|
||||||
additional real payment was made.
|
additional real payment was made.
|
||||||
|
|
||||||
|
Full-suite follow-up: IndeeHub `f2668c0` adds active-rental continuation and
|
||||||
|
`920f3b0` repairs the subscription regressions and records final qualification.
|
||||||
|
All143backend tests across14suites now pass, with no skipped/failed tests;
|
||||||
|
all94frontend tests pass. Both production builds and mobile/desktop library/rental
|
||||||
|
browser checks pass. Failed original logs are retained. The disposable database,
|
||||||
|
volume and fixture credentials were removed. These remain source/artifact checks;
|
||||||
|
no IndeeHub app update was deployed and the full paid-video flow is still open.
|
||||||
|
|
||||||
|
Payment-recovery prerequisite under qualification next: preserve errors reading
|
||||||
|
an existing Cashu seed/counter, stop random-output fallback when an established
|
||||||
|
recovery source fails, and atomically flush counter reservations before use.
|
||||||
|
Rust wallet tests are running only through the required isolated test runner.
|
||||||
|
No real wallet mutation or additional payment is involved. This prerequisite is
|
||||||
|
not a completed purchase journal, seller receipt or initial-payment recovery gate.
|
||||||
|
|||||||
Reference in New Issue
Block a user