@@ -134,6 +134,9 @@ pub(crate) enum Rejected {
134134 WrongRequest ,
135135 /// Sealed under a catalogue generation that is no longer live — an approval moved underneath it.
136136 WrongGeneration ,
137+ /// ALREADY REDEEMED. Perfectly valid, for this caller, for this request, inside its window — and
138+ /// already spent on the call it was minted to approve. See [`SpentAskStates`].
139+ AlreadySpent ,
137140}
138141
139142impl Rejected {
@@ -146,6 +149,7 @@ impl Rejected {
146149 Rejected :: WrongPrincipal => "state_wrong_principal" ,
147150 Rejected :: WrongRequest => "state_wrong_request" ,
148151 Rejected :: WrongGeneration => "state_wrong_generation" ,
152+ Rejected :: AlreadySpent => "state_already_spent" ,
149153 }
150154 }
151155}
@@ -291,6 +295,77 @@ pub(crate) fn digest_arguments(arguments: &serde_json::Value) -> String {
291295 hex:: encode ( h. finalize ( ) )
292296}
293297
298+ /// THE SPENT-APPROVAL LEDGER — what makes an approval SINGLE-USE.
299+ ///
300+ /// ## What the seal could not do on its own
301+ ///
302+ /// Everything else about this module is a statement the seal itself can carry: who it was minted
303+ /// for, what request, which round, until when. Single use is the one property that cannot ride
304+ /// inside the blob, because a caller presenting the identical blob a second time presents an
305+ /// identical, perfectly valid blob. The only thing that can tell the second presentation from the
306+ /// first is a RECORD THAT THE FIRST HAPPENED — and until this existed there was none, so an operator
307+ /// who gated a money-moving tool behind a confirmation got confirm-once-execute-many.
308+ ///
309+ /// ## Keyed on the nonce, and only the terminal redemption is recorded
310+ ///
311+ /// The nonce already exists and is already unique per mint (`mrtr`'s multi-round scenario requires
312+ /// it), so it is the natural handle and nothing new has to be sealed. What is recorded is the ONE
313+ /// redemption that dispatches: an intermediate round's state is answered with a fresh ask and a
314+ /// fresh state, so burning it would refuse the ordinary case of a client retrying a request whose
315+ /// answer it never saw. The spend therefore happens exactly where the exchange COMPLETES.
316+ ///
317+ /// ## What a restart does to it, and why that is the right trade
318+ ///
319+ /// This is PROCESS-LOCAL, and deliberately so rather than for want of a durable store.
320+ ///
321+ /// - The window a restart reopens is bounded by the state's own life: a state that has lapsed is
322+ /// already refused by [`Sealer::open`], so the most a restart can restore is the unredeemed
323+ /// remainder of one [`DEFAULT_TTL_SECS`] window. It is not a standing hole; it closes by itself.
324+ /// - It is not attacker-triggerable. A caller cannot restart the process, and a caller who could
325+ /// has a larger primitive than double-spending one confirmation.
326+ /// - The alternative is a durable spent-nonce table, which means a new `busbar_api::Store` method,
327+ /// which means the plugin ABI — a substantial change to buy the residual, and one this tree is
328+ /// the wrong place to spend: it is scheduled for deletion and rebuild on the `rmcp` SDK, and the
329+ /// rebuilt tree gets to decide where its state lives. The BEHAVIOUR is pinned by test either way,
330+ /// so the decision can be revisited without the property being lost.
331+ ///
332+ /// A fleet is the same trade one hop out: two nodes sharing a signing key share the seal but not
333+ /// this ledger, so a redemption on node A does not stop one on node B. That is a real limit and it
334+ /// is written down here rather than discovered; closing it needs shared state, which is the same
335+ /// durable-store decision.
336+ ///
337+ /// ## The size of it
338+ ///
339+ /// An entry lives at most as long as the state it records, and every call evicts what has lapsed,
340+ /// so the table holds at most the approvals minted in one TTL window. Minting one costs the caller
341+ /// a metered, budget-charged round, so the rate is bounded by governance rather than by this map.
342+ #[ derive( Debug , Default ) ]
343+ pub ( crate ) struct SpentAskStates {
344+ /// nonce ⇒ the instant after which the entry is meaningless, because the state it records can
345+ /// no longer be opened anyway.
346+ seen : std:: sync:: Mutex < std:: collections:: HashMap < String , u64 > > ,
347+ }
348+
349+ impl SpentAskStates {
350+ pub ( crate ) fn new ( ) -> Self {
351+ Self :: default ( )
352+ }
353+
354+ /// SPEND this approval. `true` if it had not been spent before; `false` if it had.
355+ ///
356+ /// Test-and-set under one lock, and that is not an optimisation: a caller that fires two
357+ /// redemptions of one approval concurrently is the obvious way to attack a check that reads and
358+ /// then writes, and it is the shape the whole gate exists to refuse.
359+ pub ( crate ) fn spend ( & self , nonce : & str , expires_at : u64 , now : u64 ) -> bool {
360+ // Poison-recovering, like every other request-path lock in this process: the data behind it
361+ // is still valid after a panic, and cascading the poison would turn one stray panic into a
362+ // gate that refuses every confirmation for the life of the process.
363+ let mut seen = self . seen . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
364+ seen. retain ( |_, expiry| * expiry >= now) ;
365+ seen. insert ( nonce. to_string ( ) , expires_at) . is_none ( )
366+ }
367+ }
368+
294369/// A fresh nonce. `getrandom` is the same fail-closed entropy source key secrets use; a failure is
295370/// not survivable here, because a predictable nonce is a `multi-round` scenario that passes by
296371/// accident and a replay window that is wider than it looks.
0 commit comments