From 89e386024dae1276ba2b3b1ba31f642357cf7412 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 13 Oct 2026 17:16:58 +0300 Subject: [PATCH 01/10] fix(x402): avoid spurious budget refusals on stale hold snapshots The ledger now re-reads the hold document before refusing a reservation, so a snapshot that straddles another writer's commit no longer double counts and rejects a valid request. The lease renewal test ages the stored hold deterministically instead of sleeping on the clock. Auto-committed-on: dragonfly --- .../src/service/x402_storage_bus_tests.rs | 20 ++++++++++++++++++- crates/tinywallet-x402/src/ledger/store.rs | 10 +++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs b/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs index c30ed109..4062b9eb 100644 --- a/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs +++ b/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs @@ -294,7 +294,25 @@ async fn the_lease_task_extends_the_hold_expiry_in_host_storage() { .doc, ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; + // Age the stored lease deterministically instead of sleeping on the clock. + let stored = documents + .get("x402_budget", "holds") + .await + .unwrap() + .unwrap(); + let mut aged = stored.doc.clone(); + for hold in aged["holds"].as_object_mut().unwrap().values_mut() { + hold["expires_at_ms"] = serde_json::json!(before - 1_000); + } + documents + .put( + "x402_budget", + "holds", + aged, + tinystoragedrivers_core::Precondition::Version(stored.version), + ) + .await + .unwrap(); renew_holds_once().await; let after = expiry( diff --git a/crates/tinywallet-x402/src/ledger/store.rs b/crates/tinywallet-x402/src/ledger/store.rs index a7db99a4..46457656 100644 --- a/crates/tinywallet-x402/src/ledger/store.rs +++ b/crates/tinywallet-x402/src/ledger/store.rs @@ -244,7 +244,15 @@ impl PaymentLedger { doc.holds.retain(|_, hold| hold.expires_at_ms > now_ms); self.holds.clone_from(&doc.holds); if let Some(refusal) = self.check_budget_at(now, amount).refusal() { - return Ok(Err(refusal)); + // The snapshot may straddle another writer's commit (its record + // appended, its hold still in our copy) and double count. Only + // refuse on a snapshot the hold document still matches; + // otherwise retry against the current one. + let (current, _) = self.persist.load_holds()?; + if current == version { + return Ok(Err(refusal)); + } + continue; } let id = ReservationId(NEXT_RESERVATION.fetch_add(1, Ordering::Relaxed)); let key = format!("{}:{}", self.owner, id.0); From e7f25f1a351e1ae4467ef462ca9b792245a39d5a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 13 Oct 2026 17:17:08 +0300 Subject: [PATCH 02/10] test(x402): relax lease renewal expiry assertion The renewal test now asserts the expiry moved forward from an aged baseline rather than strictly increasing, and imports Precondition directly to shorten the call site. This keeps the check meaningful while tolerating the clock granularity seen in host storage. Auto-committed-on: dragonfly --- .../src/service/x402_storage_bus_tests.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs b/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs index 4062b9eb..591cf3a4 100644 --- a/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs +++ b/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs @@ -12,7 +12,7 @@ use tinybus::Connection; use tinystoragedrivers_busproxy::{PerCallerEndpoint, serve}; use tinystoragedrivers_client::StorageServer; use tinystoragedrivers_core::{ - DocumentStore, MemoryStorage, Result as StorageResult, Scope, ScopedStorage, StorageBackend, + DocumentStore, MemoryStorage, Result as StorageResult, Scope, Precondition, ScopedStorage, StorageBackend, StreamEntry, StreamStore, async_trait, }; use tinywallet_bus::wire::{X402CloseRequest, X402ConfigureRequest, X402SummaryRequest}; @@ -309,7 +309,7 @@ async fn the_lease_task_extends_the_hold_expiry_in_host_storage() { "x402_budget", "holds", aged, - tinystoragedrivers_core::Precondition::Version(stored.version), + Precondition::Version(stored.version), ) .await .unwrap(); @@ -324,8 +324,9 @@ async fn the_lease_task_extends_the_hold_expiry_in_host_storage() { .doc, ); assert!( - after > before, - "renewal moved the expiry: {before} -> {after}" + after > before - 1_000, + "renewal moved the aged expiry forward: {} -> {after}", + before - 1_000 ); fixture From 98c70cfd2c2db5acad2f5b252ab69a7bbbda395a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 13 Oct 2026 17:17:48 +0300 Subject: [PATCH 03/10] chore: files changed crates/tinywallet-module/src/service/x402_storage_bus_tests.rs,crates/tinywalle Auto-committed-on: dragonfly --- .../src/service/x402_storage_bus_tests.rs | 4 +- .../src/ledger/mod_holds_tests.rs | 47 ++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs b/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs index 591cf3a4..3a62bc73 100644 --- a/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs +++ b/crates/tinywallet-module/src/service/x402_storage_bus_tests.rs @@ -12,8 +12,8 @@ use tinybus::Connection; use tinystoragedrivers_busproxy::{PerCallerEndpoint, serve}; use tinystoragedrivers_client::StorageServer; use tinystoragedrivers_core::{ - DocumentStore, MemoryStorage, Result as StorageResult, Scope, Precondition, ScopedStorage, StorageBackend, - StreamEntry, StreamStore, async_trait, + DocumentStore, MemoryStorage, Precondition, Result as StorageResult, Scope, ScopedStorage, + StorageBackend, StreamEntry, StreamStore, async_trait, }; use tinywallet_bus::wire::{X402CloseRequest, X402ConfigureRequest, X402SummaryRequest}; use tinywallet_x402::ledger::{LedgerStorage, PaymentLedger, SpendingBudget}; diff --git a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs index b32d7936..dcbc52bb 100644 --- a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs +++ b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs @@ -66,6 +66,9 @@ impl std::fmt::Debug for Interleave { struct InterleavingDocuments { inner: Arc, before_first_hold_read: Mutex>, + /// Runs once just AFTER the first read of the hold document is answered + /// (the reader keeps the snapshot it was given). + after_first_hold_read: Mutex>, } #[async_trait] @@ -83,7 +86,12 @@ impl DocumentStore for InterleavingDocuments { // future runs on must not be blocked by it. std::thread::spawn(action.0).join().unwrap(); } - self.inner.get(collection, id).await + let found = self.inner.get(collection, id).await; + let action = self.after_first_hold_read.lock().unwrap().take(); + if let Some(action) = action { + std::thread::spawn(action.0).join().unwrap(); + } + found } async fn put(&self, c: &str, id: &str, doc: Value, p: Precondition) -> Result { self.inner.put(c, id, doc, p).await @@ -151,6 +159,7 @@ fn a_commit_between_the_hold_read_and_the_record_read_cannot_be_missed() { Arc::new(InterleavingDocuments { inner: Arc::clone(&base.documents), before_first_hold_read: Mutex::new(Some(Interleave(Box::new(commit)))), + after_first_hold_read: Mutex::new(None), }), Arc::clone(&base.streams), ); @@ -243,3 +252,39 @@ fn renewal_fails_loudly_when_the_hold_document_is_unreadable() { .unwrap(); assert!(owner.renew_holds_at(now()).is_err()); } + +#[test] +fn a_reservation_that_fits_is_retried_when_a_commit_makes_the_snapshot_stale() { + // B holds 30. A (50) reads that hold; then B commits (record 30, hold + // dropped) before A reads the records, so the snapshot counts B twice + // (110 > 100). The true total is 80, so A must retry and be admitted. + let base = LedgerStorage::memory(); + let mut doc = HoldsDoc::default(); + doc.holds.insert( + "other:1".into(), + hold(30, now().timestamp_millis() + HOLD_TTL_MS), + ); + put_holds(&base, &doc); + + let b_storage = base.clone(); + let commit = move || { + let mut b = PaymentLedger::open(b_storage.clone(), "other", budget()).unwrap(); + b.record_payment(settled(30)); + put_holds(&b_storage, &HoldsDoc::default()); + }; + let storage = LedgerStorage::new( + Arc::new(InterleavingDocuments { + inner: Arc::clone(&base.documents), + before_first_hold_read: Mutex::new(None), + after_first_hold_read: Mutex::new(Some(Interleave(Box::new(commit)))), + }), + Arc::clone(&base.streams), + ); + let mut a = PaymentLedger::open(storage, SESSION, budget()).unwrap(); + + let verdict = a.reserve_at(now(), 50).unwrap(); + assert!( + verdict.is_ok(), + "a stale double-counted snapshot must be retried, not refused: {verdict:?}" + ); +} From 01703fd80b0c22f761021656bc2ce39d2483733d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 13 Oct 2026 23:06:24 +0300 Subject: [PATCH 04/10] test(ledger): cover reservations during an in-flight commit Add tests that pause a committing writer after its payment append but before the hold document is released, then reserve from a second ledger. They pin down that an appended payment counts exactly once against the cap while its hold is still outstanding, for both the durable and non-durable commit paths. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/ledger/mod_holds_tests.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs index dcbc52bb..dd9740dc 100644 --- a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs +++ b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs @@ -288,3 +288,44 @@ fn a_reservation_that_fits_is_retried_when_a_commit_makes_the_snapshot_stale() { "a stale double-counted snapshot must be retried, not refused: {verdict:?}" ); } + +/// Pause the committing writer on its first release read, after its append +/// has completed but before the hold document changes. +fn reserve_during_commit(durable: bool, committed_amount: u64) { + let base = LedgerStorage::memory(); + let documents = Arc::new(InterleavingDocuments { + inner: Arc::clone(&base.documents), + before_first_hold_read: Mutex::new(None), + after_first_hold_read: Mutex::new(None), + }); + let storage = LedgerStorage::new(documents.clone(), Arc::clone(&base.streams)); + let mut owner = PaymentLedger::open(storage, SESSION, budget()).unwrap(); + let id = owner.reserve_at(now(), committed_amount).unwrap().unwrap(); + let mut observer = PaymentLedger::open(base, "observer", budget()).unwrap(); + *documents.before_first_hold_read.lock().unwrap() = + Some(Interleave(Box::new(move || { + let verdict = observer.reserve_at(now(), 50).unwrap(); + assert_eq!( + verdict.is_ok(), + committed_amount == 30, + "the committed record must count once while its hold is unchanged: {verdict:?}" + ); + }))); + if durable { + owner.try_commit_reservation(id, settled(committed_amount)).unwrap(); + } else { + owner.commit_reservation(id, settled(committed_amount)); + } +} + +#[test] +fn a_reservation_counts_an_appended_payment_once_before_its_hold_is_released() { + reserve_during_commit(false, 30); + reserve_during_commit(true, 30); +} + +#[test] +fn a_payment_still_counts_against_the_cap_before_its_hold_is_released() { + reserve_during_commit(false, 80); + reserve_during_commit(true, 80); +} From 41989eb645c5ef2d9ec6919e3ce9534985176b9c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 13 Oct 2026 23:07:07 +0300 Subject: [PATCH 05/10] test(ledger): assert hold interleave verdict after commit Move the assertion on the observer's reserve verdict out of the before_first_hold_read callback and into the test body, capturing the result through a shared slot instead. Asserting inside the callback panicked while the ledger lock was still held, which obscured the real failure and could deadlock the test. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinywallet-x402/src/ledger/mod_holds_tests.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs index dd9740dc..d77094b9 100644 --- a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs +++ b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs @@ -302,20 +302,24 @@ fn reserve_during_commit(durable: bool, committed_amount: u64) { let mut owner = PaymentLedger::open(storage, SESSION, budget()).unwrap(); let id = owner.reserve_at(now(), committed_amount).unwrap().unwrap(); let mut observer = PaymentLedger::open(base, "observer", budget()).unwrap(); + let result = Arc::new(Mutex::new(None)); + let observed = Arc::clone(&result); *documents.before_first_hold_read.lock().unwrap() = Some(Interleave(Box::new(move || { let verdict = observer.reserve_at(now(), 50).unwrap(); - assert_eq!( - verdict.is_ok(), - committed_amount == 30, - "the committed record must count once while its hold is unchanged: {verdict:?}" - ); + *observed.lock().unwrap() = Some(verdict); }))); if durable { owner.try_commit_reservation(id, settled(committed_amount)).unwrap(); } else { owner.commit_reservation(id, settled(committed_amount)); } + let verdict = result.lock().unwrap().take().expect("release read ran"); + assert_eq!( + verdict.is_ok(), + committed_amount == 30, + "the committed record must count once while its hold is unchanged: {verdict:?}" + ); } #[test] From 8144127b86ef30abc4586e635b23944fe74116af Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 13 Oct 2026 23:07:49 +0300 Subject: [PATCH 06/10] feat(ledger): tag commit records with their hold key Commit entries now carry the owner-qualified hold key as internal stream metadata, so readers can exclude a hold as soon as its durable record is visible even if the writer is paused before releasing it. Older entries without the metadata stay readable and keep conservative accounting until their holds release. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-x402/src/ledger/mod.rs | 6 ++- .../src/ledger/mod_holds_tests.rs | 13 ++--- crates/tinywallet-x402/src/ledger/persist.rs | 26 +++++++-- crates/tinywallet-x402/src/ledger/store.rs | 53 ++++++++++++++----- 4 files changed, 75 insertions(+), 23 deletions(-) diff --git a/crates/tinywallet-x402/src/ledger/mod.rs b/crates/tinywallet-x402/src/ledger/mod.rs index 382ceafd..d0fcc85c 100644 --- a/crates/tinywallet-x402/src/ledger/mod.rs +++ b/crates/tinywallet-x402/src/ledger/mod.rs @@ -40,7 +40,11 @@ //! processes, not only across threads. A hold expires after 15 minutes so a //! crashed process cannot lock budget forever, and a live owner extends the lease //! with [`PaymentLedger::renew_holds`] every [`HOLD_RENEW_EVERY`]; once the signed authorization is -//! durably recorded, that record protects the budget across restart. +//! durably recorded, that record protects the budget across restart. Commit +//! entries include the owner-qualified hold key as internal stream metadata. +//! Readers exclude that hold once they see the durable entry, even if the +//! writer is paused before releasing it. Older entries without this metadata +//! remain readable and keep conservative accounting until their holds release. //! //! # The process-wide handle //! diff --git a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs index d77094b9..2b98dfda 100644 --- a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs +++ b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs @@ -304,13 +304,14 @@ fn reserve_during_commit(durable: bool, committed_amount: u64) { let mut observer = PaymentLedger::open(base, "observer", budget()).unwrap(); let result = Arc::new(Mutex::new(None)); let observed = Arc::clone(&result); - *documents.before_first_hold_read.lock().unwrap() = - Some(Interleave(Box::new(move || { - let verdict = observer.reserve_at(now(), 50).unwrap(); - *observed.lock().unwrap() = Some(verdict); - }))); + *documents.before_first_hold_read.lock().unwrap() = Some(Interleave(Box::new(move || { + let verdict = observer.reserve_at(now(), 50).unwrap(); + *observed.lock().unwrap() = Some(verdict); + }))); if durable { - owner.try_commit_reservation(id, settled(committed_amount)).unwrap(); + owner + .try_commit_reservation(id, settled(committed_amount)) + .unwrap(); } else { owner.commit_reservation(id, settled(committed_amount)); } diff --git a/crates/tinywallet-x402/src/ledger/persist.rs b/crates/tinywallet-x402/src/ledger/persist.rs index 0d67a4bf..c70375d0 100644 --- a/crates/tinywallet-x402/src/ledger/persist.rs +++ b/crates/tinywallet-x402/src/ledger/persist.rs @@ -94,6 +94,16 @@ pub(super) struct HoldsDoc { pub holds: BTreeMap, } +/// Internal stream envelope. Flattening keeps old payment entries readable and +/// leaves the public payment wire type unchanged. +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct StoredPayment { + #[serde(flatten)] + pub record: PaymentRecord, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub committed_hold: Option, +} + /// Storage plus the sync bridge. #[derive(Debug)] pub(super) struct Persist { @@ -129,7 +139,7 @@ impl Persist { /// Records from stream offset `from` to the end, with the next offset. /// Entries that no longer parse are skipped with a warning. - pub(super) fn read_from(&self, from: u64) -> Result<(Vec, u64), String> { + pub(super) fn read_from(&self, from: u64) -> Result<(Vec, u64), String> { let mut records = Vec::new(); let mut next = from; loop { @@ -140,7 +150,7 @@ impl Persist { let Some(last) = window.last() else { break }; next = last.offset.saturating_add(1); for entry in window { - match serde_json::from_value::(entry.value) { + match serde_json::from_value::(entry.value) { Ok(record) => records.push(record), Err(error) => log::warn!( "{LOG_PREFIX} corrupt record at offset {}: {error}", @@ -153,8 +163,16 @@ impl Persist { } /// Append one record and return its stream offset. - pub(super) fn append(&self, record: &PaymentRecord) -> Result { - let value = serde_json::to_value(record).map_err(|error| error.to_string())?; + pub(super) fn append( + &self, + record: &PaymentRecord, + committed_hold: Option<&str>, + ) -> Result { + let value = serde_json::to_value(StoredPayment { + record: record.clone(), + committed_hold: committed_hold.map(str::to_owned), + }) + .map_err(|error| error.to_string())?; let streams = Arc::clone(&self.storage.streams); self.run("append ledger record", async move { streams.append(PAYMENTS_STREAM, value).await diff --git a/crates/tinywallet-x402/src/ledger/store.rs b/crates/tinywallet-x402/src/ledger/store.rs index 46457656..9a204e6b 100644 --- a/crates/tinywallet-x402/src/ledger/store.rs +++ b/crates/tinywallet-x402/src/ledger/store.rs @@ -1,7 +1,7 @@ //! The ledger itself: an in-memory record list over a payments stream, with //! budget holds kept in one compare-and-swap document. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use chrono::{DateTime, Datelike, Months, NaiveTime, TimeZone, Utc}; @@ -41,6 +41,8 @@ struct OwnHold { pub struct PaymentLedger { generation: u64, records: Vec, + /// Holds already represented by a durably appended payment. + committed_holds: HashSet, /// The next payments-stream offset to read; everything below it is in /// `records` (or was unparseable). next_offset: u64, @@ -74,11 +76,17 @@ impl PaymentLedger { "{LOG_PREFIX} loaded {} existing payment records", records.len() ); + let committed_holds = records + .iter() + .filter_map(|entry| entry.committed_hold.clone()) + .collect(); + let records = records.into_iter().map(|entry| entry.record).collect(); let generation = NEXT_LEDGER_GENERATION.fetch_add(1, Ordering::Relaxed); let started = Utc::now().timestamp_nanos_opt().unwrap_or_default(); Ok(Self { generation, records, + committed_holds, next_offset, persist, budget, @@ -92,14 +100,26 @@ impl PaymentLedger { /// Pull records other writers appended since this ledger last looked. fn refresh_records(&mut self) -> Result<(), String> { let (fresh, next) = self.persist.read_from(self.next_offset)?; - self.records.extend(fresh); + for entry in fresh { + if let Some(key) = entry.committed_hold { + self.committed_holds.insert(key); + } + self.records.push(entry.record); + } self.next_offset = next; Ok(()) } /// Append `record` to the stream and mirror it into memory. - fn append_record(&mut self, record: PaymentRecord) -> Result<(), String> { - let offset = self.persist.append(&record)?; + fn append_record( + &mut self, + record: PaymentRecord, + committed_hold: Option, + ) -> Result<(), String> { + let offset = self.persist.append(&record, committed_hold.as_deref())?; + if let Some(key) = committed_hold { + self.committed_holds.insert(key); + } if offset == self.next_offset { self.records.push(record); self.next_offset = offset.saturating_add(1); @@ -237,16 +257,20 @@ impl PaymentLedger { // writer commits by appending its record and only then dropping its // hold, so a reader that sees the hold gone is guaranteed to see // the record on this read; a reader that still sees the hold - // counts it, and the drop changes the version so the CAS below - // fails and retries. Reading records first would leave a window in + // counts it unless its record carries the same committed hold key. + // That key also covers a writer paused between append and release. + // A concurrent hold change makes the CAS below fail and retry. Reading records first would leave a window in // which neither is counted. self.refresh_records()?; - doc.holds.retain(|_, hold| hold.expires_at_ms > now_ms); + doc.holds.retain(|key, hold| { + hold.expires_at_ms > now_ms && !self.committed_holds.contains(key) + }); self.holds.clone_from(&doc.holds); if let Some(refusal) = self.check_budget_at(now, amount).refusal() { // The snapshot may straddle another writer's commit (its record // appended, its hold still in our copy) and double count. Only - // refuse on a snapshot the hold document still matches; + // refuse on a snapshot the hold document still matches after + // excluding holds identified by durable commit records; // otherwise retry against the current one. let (current, _) = self.persist.load_holds()?; if current == version { @@ -355,7 +379,11 @@ impl PaymentLedger { /// never missing from the totals: it is held, then held and recorded for /// an instant, then recorded. pub fn commit_reservation(&mut self, id: ReservationId, record: PaymentRecord) { - self.record_payment(record); + let key = self.reservations.get(&id).map(|hold| hold.key.clone()); + if let Err(error) = self.append_record(record.clone(), key) { + warn!("{LOG_PREFIX} record append failed: {error}"); + self.records.push(record); + } self.release(id); } @@ -373,7 +401,7 @@ impl PaymentLedger { /// payment already happened, and losing the record from this session's /// budget would be worse than losing it from the file. pub fn record_payment(&mut self, record: PaymentRecord) { - if let Err(error) = self.append_record(record.clone()) { + if let Err(error) = self.append_record(record.clone(), None) { warn!("{LOG_PREFIX} record append failed: {error}"); self.records.push(record); } @@ -388,7 +416,7 @@ impl PaymentLedger { /// Returns an error if serialization, file creation, writing, or syncing /// fails. The in-memory history is left unchanged. pub fn try_record_payment(&mut self, record: PaymentRecord) -> Result<(), String> { - self.append_record(record) + self.append_record(record, None) } /// Persist finalized Solana block-height evidence that a pending @@ -445,7 +473,8 @@ impl PaymentLedger { id: ReservationId, record: PaymentRecord, ) -> Result<(), String> { - self.append_record(record)?; + let key = self.reservations.get(&id).map(|hold| hold.key.clone()); + self.append_record(record, key)?; self.release(id); Ok(()) } From 73ece5a2d8b9d00937b5b40a08c3250bcc7c3101 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 13 Oct 2026 23:08:24 +0300 Subject: [PATCH 07/10] test(ledger): cover reservation counting across reopen The reservation-during-commit tests now run with the observer ledger reopened as well as kept open, so the appended-payment and cap checks exercise both paths. The store comment was reworded to describe the legacy-writer double-count window more accurately. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/ledger/mod_holds_tests.rs | 18 ++++++++++++------ crates/tinywallet-x402/src/ledger/store.rs | 9 +++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs index 2b98dfda..3e0323e5 100644 --- a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs +++ b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs @@ -291,7 +291,7 @@ fn a_reservation_that_fits_is_retried_when_a_commit_makes_the_snapshot_stale() { /// Pause the committing writer on its first release read, after its append /// has completed but before the hold document changes. -fn reserve_during_commit(durable: bool, committed_amount: u64) { +fn reserve_during_commit(durable: bool, committed_amount: u64, reopen: bool) { let base = LedgerStorage::memory(); let documents = Arc::new(InterleavingDocuments { inner: Arc::clone(&base.documents), @@ -301,7 +301,7 @@ fn reserve_during_commit(durable: bool, committed_amount: u64) { let storage = LedgerStorage::new(documents.clone(), Arc::clone(&base.streams)); let mut owner = PaymentLedger::open(storage, SESSION, budget()).unwrap(); let id = owner.reserve_at(now(), committed_amount).unwrap().unwrap(); - let mut observer = PaymentLedger::open(base, "observer", budget()).unwrap(); + let mut observer = PaymentLedger::open(base.clone(), "observer", budget()).unwrap(); let result = Arc::new(Mutex::new(None)); let observed = Arc::clone(&result); *documents.before_first_hold_read.lock().unwrap() = Some(Interleave(Box::new(move || { @@ -325,12 +325,18 @@ fn reserve_during_commit(durable: bool, committed_amount: u64) { #[test] fn a_reservation_counts_an_appended_payment_once_before_its_hold_is_released() { - reserve_during_commit(false, 30); - reserve_during_commit(true, 30); + for durable in [false, true] { + for reopen in [false, true] { + reserve_during_commit(durable, 30, reopen); + } + } } #[test] fn a_payment_still_counts_against_the_cap_before_its_hold_is_released() { - reserve_during_commit(false, 80); - reserve_during_commit(true, 80); + for durable in [false, true] { + for reopen in [false, true] { + reserve_during_commit(durable, 80, reopen); + } + } } diff --git a/crates/tinywallet-x402/src/ledger/store.rs b/crates/tinywallet-x402/src/ledger/store.rs index 9a204e6b..264f2cba 100644 --- a/crates/tinywallet-x402/src/ledger/store.rs +++ b/crates/tinywallet-x402/src/ledger/store.rs @@ -259,16 +259,17 @@ impl PaymentLedger { // the record on this read; a reader that still sees the hold // counts it unless its record carries the same committed hold key. // That key also covers a writer paused between append and release. - // A concurrent hold change makes the CAS below fail and retry. Reading records first would leave a window in - // which neither is counted. + // A concurrent hold change makes the CAS below fail and retry. + // Reading records first would leave a window in which neither is + // counted. self.refresh_records()?; doc.holds.retain(|key, hold| { hold.expires_at_ms > now_ms && !self.committed_holds.contains(key) }); self.holds.clone_from(&doc.holds); if let Some(refusal) = self.check_budget_at(now, amount).refusal() { - // The snapshot may straddle another writer's commit (its record - // appended, its hold still in our copy) and double count. Only + // A legacy writer without commit metadata can still straddle + // this snapshot and double count its record and hold. Only // refuse on a snapshot the hold document still matches after // excluding holds identified by durable commit records; // otherwise retry against the current one. From ec8f666ebc6538f99ae86b0117e6dafe96291b55 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 13 Oct 2026 23:10:06 +0300 Subject: [PATCH 08/10] test(ledger): reopen observer ledger in reserve-during-commit test The interleave hook now reopens the observer ledger when the reopen flag is set, so the test exercises reservation against a freshly opened ledger rather than a stale handle. The captured result binding was renamed for clarity. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-x402/src/ledger/mod_holds_tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs index 3e0323e5..6ceeb27f 100644 --- a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs +++ b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs @@ -303,10 +303,13 @@ fn reserve_during_commit(durable: bool, committed_amount: u64, reopen: bool) { let id = owner.reserve_at(now(), committed_amount).unwrap().unwrap(); let mut observer = PaymentLedger::open(base.clone(), "observer", budget()).unwrap(); let result = Arc::new(Mutex::new(None)); - let observed = Arc::clone(&result); + let captured = Arc::clone(&result); *documents.before_first_hold_read.lock().unwrap() = Some(Interleave(Box::new(move || { + if reopen { + observer = PaymentLedger::open(base, "observer", budget()).unwrap(); + } let verdict = observer.reserve_at(now(), 50).unwrap(); - *observed.lock().unwrap() = Some(verdict); + *captured.lock().unwrap() = Some(verdict); }))); if durable { owner From 539a58778ab693d275d786e6a731e3e5b7f26546 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 13 Oct 2026 23:16:31 +0300 Subject: [PATCH 09/10] test(ledger): cover per-request refusal without storage reads Add a test asserting that a per-request budget refusal is returned even when storage reads are faulted, and that no funds are reserved in that case. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-x402/src/ledger/mod_holds_tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs index 6ceeb27f..60b8bc4e 100644 --- a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs +++ b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs @@ -343,3 +343,15 @@ fn a_payment_still_counts_against_the_cap_before_its_hold_is_released() { } } } + +#[test] +fn a_per_request_refusal_does_not_depend_on_storage_reads() { + let (storage, faults) = super::faulty::faulty_storage(); + let mut ledger = PaymentLedger::open(storage, SESSION, budget()).unwrap(); + faults.read.store(true, std::sync::atomic::Ordering::SeqCst); + assert_eq!( + ledger.reserve_at(now(), 101).unwrap().unwrap_err(), + BudgetRefusal::PerRequest { requested: 101, cap: 100 } + ); + assert_eq!(ledger.reserved_atomic(), 0); +} From 3e38b2ba681a1624b4c79cf3bbfd0e99524118c4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 13 Oct 2026 23:16:57 +0300 Subject: [PATCH 10/10] perf(ledger): refuse over-cap requests before reading storage The per-request cap check now runs before any storage access, so a request above the cap is refused without loading holds. Since the verdict depends only on configuration, storage failures or concurrent hold changes could never have altered it. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-x402/src/ledger/mod.rs | 4 +++- crates/tinywallet-x402/src/ledger/mod_holds_tests.rs | 5 ++++- crates/tinywallet-x402/src/ledger/store.rs | 8 ++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/tinywallet-x402/src/ledger/mod.rs b/crates/tinywallet-x402/src/ledger/mod.rs index d0fcc85c..12f0c606 100644 --- a/crates/tinywallet-x402/src/ledger/mod.rs +++ b/crates/tinywallet-x402/src/ledger/mod.rs @@ -19,7 +19,9 @@ //! //! [`PaymentLedger::check_budget`] enforces a per-request cap and daily and //! monthly totals over settled payments, unresolved signed authorizations, and -//! outstanding reservations. A bounded authorization is charged to each +//! outstanding reservations. Requests above the per-request cap are refused +//! before reading storage, since shared state cannot change that verdict. +//! A bounded authorization is charged to each //! calendar window it could have settled in. Once the proof is no longer //! usable, future non-overlapping windows can admit new spending, while the //! unresolved amount remains in durable outstanding totals until a trusted diff --git a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs index 60b8bc4e..ede594bb 100644 --- a/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs +++ b/crates/tinywallet-x402/src/ledger/mod_holds_tests.rs @@ -351,7 +351,10 @@ fn a_per_request_refusal_does_not_depend_on_storage_reads() { faults.read.store(true, std::sync::atomic::Ordering::SeqCst); assert_eq!( ledger.reserve_at(now(), 101).unwrap().unwrap_err(), - BudgetRefusal::PerRequest { requested: 101, cap: 100 } + BudgetRefusal::PerRequest { + requested: 101, + cap: 100 + } ); assert_eq!(ledger.reserved_atomic(), 0); } diff --git a/crates/tinywallet-x402/src/ledger/store.rs b/crates/tinywallet-x402/src/ledger/store.rs index 264f2cba..00852809 100644 --- a/crates/tinywallet-x402/src/ledger/store.rs +++ b/crates/tinywallet-x402/src/ledger/store.rs @@ -250,6 +250,14 @@ impl PaymentLedger { now: DateTime, amount: u64, ) -> Result, String> { + // This cap depends only on configuration, so storage failures or + // concurrent hold changes cannot alter the definitive refusal. + if amount > self.budget.per_request_max_atomic { + return Ok(Err(BudgetRefusal::PerRequest { + requested: amount, + cap: self.budget.per_request_max_atomic, + })); + } let now_ms = now.timestamp_millis(); for _ in 0..HOLD_CAS_ATTEMPTS { let (version, mut doc) = self.persist.load_holds()?;