diff --git a/Cargo.lock b/Cargo.lock index c409f8f..489b188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.21.0" +version = "0.22.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index e20c119..4423f73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ # is designed, matching the sibling dig--protocol crates' bootstrap order. [package] name = "dig-node-control-interface" -version = "0.21.0" +version = "0.22.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" diff --git a/README.md b/README.md index 1d5e2a1..ec78bcf 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ and pushes bytes somebody else signed. | Method | Auth | Route | Params | Result | |---|---|---|---|---| +| `control.spends.list` | T | own | `{since_ms?, until_ms?, store_id?, kind?, status?, after_id?, limit?:u32}` | `{spends:[AutomatedSpend], complete:bool, cursor:string\|null, unreadable_lines:u32}`; READ-ONLY — the audit record of spends this node made WITHOUT per-transaction approval. It never initiates, signs, cancels or alters a spend. A `failed` row carries the STAGE it died at, because only `signing` means the money definitely did not move; `unresolved` is its own state and MUST NOT be folded into `failed`. `complete` states truncation explicitly — never infer it from the page length. A non-zero `unreadable_lines` means the trail is INCOMPLETE and must be surfaced; a record that cannot be read at all is `-32048`, never an empty page | | `control.profile.putBody` | T | del | `{store_id:string, root:string, body_b64:string}` | `{stored:true, store_id, root, body_bytes}`; the node INDEPENDENTLY resolves `root` on chain and REFUSES any body whose recomputed root is not the confirmed one — `root` is a claim to be checked, never a fact to be trusted, and dig-app is a caller like any other. Decoded bodies above `MAX_BODY_BYTES` (4 MiB) are refused as `INVALID_PARAMS` | | `control.profile.getBody` | T | del | `{store_id:string, root:string}` | `{store_id, root, body_b64:string\|null, body_bytes}`; `body_b64:null` means this node holds no body at that root and NEVER that the body could not be read, which is an error. The answer is at the root that was ASKED for, never a newer one | diff --git a/SPEC.md b/SPEC.md index 3f6f8c5..d231d09 100644 --- a/SPEC.md +++ b/SPEC.md @@ -127,6 +127,7 @@ master token specifically; `Routing` = how the node resolves it (`owned` by the | `control.wallet.reservations.held` | yes | delegated | — | `{reserved:[ReservedCoin], as_of_unix:u64}` | | `control.wallet.reservations.reserve` | yes | delegated | `{coin_ids:[string], ttl_secs?:u64}` | `{reservation_id, coin_ids, expires_at_unix, ttl_secs}` | | `control.wallet.reservations.release` | yes | delegated | `{reservation_id:string}` | `{released:bool, coin_ids:[string]}` | +| `control.spends.list` | yes | owned | `{since_ms?:u64, until_ms?:u64, store_id?:string, kind?:string, status?:string, after_id?:string, limit?:u32}` | `{spends:[AutomatedSpend], complete:bool, cursor:string\|null, unreadable_lines:u32}` | | `control.profile.putBody` | yes | delegated | `{store_id:string, root:string, body_b64:string}` | `{stored, store_id, root, body_bytes}` | | `control.profile.getBody` | yes | delegated | `{store_id:string, root:string}` | `{store_id, root, body_b64:string\|null, body_bytes}` | | `pairing.request` | no | open | `{client_name:string}` | `{pairing_id, pairing_code, expires_ms}` | @@ -664,6 +665,33 @@ a struct over them. Rendering is specified here, beside the parsing, because they are two halves of one format. A node MUST NOT hand-roll its own `product/version` string. +- **`AutomatedSpend`** — one spend the node made WITHOUT per-transaction approval: + `{id:string, revision:u32, kind:string, purpose:string, authority:{principal:string, + grant:string}, asset:Asset, amount_mojos:string, fee_mojos:string, store_id:string|null, + initiated_ms:u64, updated_ms:u64, status:SpendOutcome, funding_coin_ids:[string], + chain_reference:{coin_id:string, confirmed:bool}|null}`. + + `amount_mojos` and `fee_mojos` are decimal STRINGS — they carry the full `u64` range, which a JSON + number does not survive through an f64 parser. `chain_reference` MUST be PRESENT as `null` when + the node knows no coin id yet (never omitted), and its `confirmed` flag says whether the node + OBSERVED that coin: a client MUST render an unobserved id as an intention, never as a fact. + `funding_coin_ids` names the coins CONSUMED and is never confirmation evidence — a competing spend + of the same funding coin consumes it identically while the intended coin never exists. + +- **`SpendOutcome`** — internally tagged on `state`. Exactly five forms are valid: + + | JSON | Meaning | + |---|---| + | `{"state":"pending"}` | recorded, not yet handed to the network | + | `{"state":"submitted"}` | accepted by the mempool; NOT a claim that it will confirm | + | `{"state":"confirmed","height":u32,"coin_id":string}` | the chain shows the coin this spend created | + | `{"state":"failed","stage":"signing"\|"broadcast"\|"confirmation","reason":string}` | the attempt ended in an observed failure | + | `{"state":"unresolved","reason":string}` | the node signed and does not know how it ended | + + The height and coin id live INSIDE the `confirmed` form, so a record cannot hold a confirmation + height without a confirmation. `failed` MUST carry its `stage`, and `unresolved` MUST NOT be + reported as `failed` — see §4.2d. + - **`StatusResult.version`** already reports THIS node's own build; there is no separate method for it, and `control.peerStatus` covers both the point lookup ("what is that peer running") and the census (a group-by over the returned array). @@ -809,6 +837,66 @@ additionally follows the profile ROOT and syncs its body from peers. from a node build that predates the field, treating the absent `kind` as `"capsule"`. - An UNRECOGNISED `kind` token is `-32602 INVALID_PARAMS`; absence is not. +### 4.2d The automated-spend audit record (`control.spends.list`) + +`control.spends.list` is the ONE sanctioned way to read the spends a node made without +per-transaction approval. The record itself is node-private (dig-node SPEC §23): it is a file the +node owns, and every other view — dig-app's Activity tab included — reads it through this method. A +second process parsing that file would be a second implementation of a growing append-only format, +which is how two views of "what did the node spend" begin to disagree. + +**The method is read-only, and the catalog offers no companion that is not.** A conforming node MUST +NOT let this call initiate, sign, retry, cancel or amend a spend, and MUST NOT expose a control +method that edits or deletes an entry. The record replaces authorization with accountability, and a +record that can be edited accounts for nothing. + +**A failure MUST carry the stage it died at.** Only `stage: "signing"` means the money definitely did +not move: no signed bundle ever existed, so nothing could reach a mempool. `"broadcast"` and +`"confirmation"` both happen after a valid signed bundle exists, and neither observation proves +absence — a rejection this node saw does not bind a network it does not fully observe. A node MUST +NOT emit a bare `failed` without a stage, and a client MUST NOT render a `broadcast` or +`confirmation` failure as settled. Collapsing the distinction makes every surface structurally +unable to tell a person the truth about their own money. + +**`unresolved` is a state, not a kind of failure.** It means the node signed and does not know how it +ended — a timeout, a restart mid-flight, a producer that dropped the spend. A node MUST NOT report it +as `failed`, and a client MUST NOT fold it into a failure bucket: saying "it did not happen" about a +spend that landed is the same class of lie as claiming an unconfirmed success. + +**Money amounts are decimal STRINGS.** `amount_mojos` and `fee_mojos` carry the full `u64` range, +which a JSON number does not survive through an f64 parser. + +**A page states its own completeness.** `spends` is bounded by 500 rows (default 50). `complete` MUST +be `false` whenever a matching row was withheld, and MUST NOT be inferred by a client from +`spends.len() < limit` — a node may return a short page for its own reasons, and a matching set that +is an exact multiple of the page size makes the last full page indistinguishable from a truncated +one. Without the flag a caller cannot tell "there are no more spends" from "we stopped telling you", +and on an audit record those read the same and mean opposite things. A `limit` of `0`, or above 500, +MUST be refused as `INVALID_PARAMS` rather than clamped: a silently shrunk page hands back a cursor +for a position the caller did not ask about. + +**The order is part of the contract.** Rows are returned by DESCENDING `initiated_ms`, ties broken by +ASCENDING `id`, and the order MUST stay stable across the pages of one walk. `after_id` means +strictly after that row in that order. The tiebreak is required rather than incidental: automated +spends are issued by a cycle and several can share a millisecond, so a time-only order names no +position and a walk would repeat some rows and skip others. + +**Unreadable entries are part of the answer.** `unreadable_lines` counts entries the node could not +parse, across the whole record rather than the page — a corrupt entry has no parsed timestamp and no +parsed id, so it can be attributed to neither. A client MUST surface a non-zero value: an audit trail +that lost rows and reads as a tidy shorter one is the same lie as a missing entry, told more +convincingly. + +**An empty page is an answer; an unreadable record is an error.** `spends: []` with `complete: true` +means this node moved no money unattended matching the filters, and a record that was never written +answers exactly that way — a node that has never spent automatically is the ordinary case. A record +that could not be read AT ALL is `-32048 SPEND_AUDIT_UNREADABLE`, never an empty page: "nothing to +report" and "I could not look" are different answers, and the first is the one a person stops +investigating on. + +**The read is token-gated although it is a read.** The caller supplies no identifier, so the answer +is this node's OWN spending history — the same rule that keeps `control.wallet.arrivals` gated. + ### 4.3 The custody boundary (§908) The node holds no user key and produces no signature. `control.wallet.broadcast` carries signed bytes @@ -841,15 +929,19 @@ where the error was minted. | `-32044` | `WALLET_NODE_SPEND_DISABLED` | node | `control.wallet.broadcast` refused: the bundle requires a signature from one of the NODE's OWN custodied keys while `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off. The node relays bundles somebody else signed on every install; sending its own money is a separate, default-OFF custody decision, and a caller could otherwise sign through the node and hand the bundle straight back. **Retrying cannot help**: the remedy is a bundle that does not spend the node's coins, or the flag. | | `-32046` | `WALLET_COINS_RESERVED` | node | coins named by the call are committed to a live in-flight spend; nothing was reserved. A WAIT, never a shortfall | | `-32047` | `WALLET_RESERVATIONS_UNAVAILABLE` | node | the node's coin-reservation set could not be read, so what is in flight is UNKNOWN | +| `-32048` | `SPEND_AUDIT_UNREADABLE` | shell | the automated-spend audit record could not be read at all, so what this node spent unattended is UNKNOWN. Never an empty page. A record that was never written is NOT this: it is an empty page | -The `-3204x` band is the wallet's, and **this document owns it**. A node or client MUST NOT mint a +The `-3204x` band began as the wallet's and now also carries the audit record's `-32048`; **this +document owns the whole band**. A node or client MUST NOT mint a `-3204x` code that is not declared in the table above: a privately-minted code cannot be seen at allocation time, and two implementations then disagree about what one number means. The codes in the band do NOT share one disposition, so a client MUST branch on the symbol rather than on the band. `-32040`..`-32043` and `-32047` say the answer is UNKNOWN — the node could not look, not that the chain said no — so a client MUST NOT degrade them into an empty or zero result, -and MUST NOT report a mint, a spend or a balance as failed on their strength alone. `-32046` is a +and MUST NOT report a mint, a spend or a balance as failed on their strength alone. `-32048` says +the same about the audit record: it could not be read, which a client MUST NOT render as "this node +has spent nothing". `-32046` is a transient WAIT and a client SHOULD retry. `-32044` is TERMINAL: retrying cannot help, and a client that treats it as a wait retries forever against a decision that will not change. diff --git a/src/error.rs b/src/error.rs index 77363d4..833d8aa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -25,6 +25,7 @@ //! | `-32044` | [`WalletNodeSpendDisabled`](ControlErrorCode::WalletNodeSpendDisabled) | node | the bundle spends the NODE's own coins and live broadcast is off | //! | `-32046` | [`WalletCoinsReserved`](ControlErrorCode::WalletCoinsReserved) | node | coins are held by an in-flight spend | //! | `-32047` | [`WalletReservationsUnavailable`](ControlErrorCode::WalletReservationsUnavailable) | node | the reservation set could not be read | +//! | `-32048` | [`SpendAuditUnreadable`](ControlErrorCode::SpendAuditUnreadable) | shell | the automated-spend audit record could not be read | //! //! The `-3204x` block is OWNED BY THIS CRATE. A node or client MUST NOT mint a code in it that is //! not declared here: a privately-minted code is invisible to allocation, and two implementations @@ -96,6 +97,19 @@ pub enum ControlErrorCode { /// reserved" demand opposite actions from a caller: the first permits a spend, the second must /// refuse one. A guard that fails open is not a guard. WalletReservationsUnavailable, + /// `-32048` — the automated-spend audit record could not be read at all, so what this node + /// spent unattended is UNKNOWN. + /// + /// Never collapsed into an empty page. "This node has moved no money unattended" and "I cannot + /// tell you what this node moved" are opposite answers, and the first is the one a person stops + /// investigating on. A partial read is a different thing again and is NOT an error: entries the + /// node could parse are returned, and the ones it could not are counted in + /// [`SpendsListResult::unreadable_lines`](crate::results::SpendsListResult::unreadable_lines). + /// This code is for the case where nothing at all could be read. + /// + /// An audit record that has never been written is likewise NOT an error — a node that has never + /// spent automatically is the ordinary case, and it answers with an empty page. + SpendAuditUnreadable, } impl ControlErrorCode { @@ -117,6 +131,7 @@ impl ControlErrorCode { ControlErrorCode::WalletNodeSpendDisabled => -32044, ControlErrorCode::WalletCoinsReserved => -32046, ControlErrorCode::WalletReservationsUnavailable => -32047, + ControlErrorCode::SpendAuditUnreadable => -32048, } } @@ -138,6 +153,7 @@ impl ControlErrorCode { ControlErrorCode::WalletNodeSpendDisabled => "WALLET_NODE_SPEND_DISABLED", ControlErrorCode::WalletCoinsReserved => "WALLET_COINS_RESERVED", ControlErrorCode::WalletReservationsUnavailable => "WALLET_RESERVATIONS_UNAVAILABLE", + ControlErrorCode::SpendAuditUnreadable => "SPEND_AUDIT_UNREADABLE", } } @@ -196,6 +212,9 @@ impl ControlErrorCode { ControlErrorCode::WalletReservationsUnavailable => { "The node's coin-reservation set could not be read, so coin selection cannot be trusted." } + ControlErrorCode::SpendAuditUnreadable => { + "The automated-spend audit record could not be read, so what this node spent unattended is unknown. This is NOT an empty record." + } } } @@ -224,6 +243,7 @@ impl ControlErrorCode { ControlErrorCode::WalletNodeSpendDisabled, ControlErrorCode::WalletCoinsReserved, ControlErrorCode::WalletReservationsUnavailable, + ControlErrorCode::SpendAuditUnreadable, ]; } diff --git a/src/kats.rs b/src/kats.rs index d6a945e..64fd9d1 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -1597,6 +1597,12 @@ impl ControlHandler for MockNode { } })) } + async fn spends_list( + &self, + params: SpendsListParams, + ) -> Result { + Ok(audit_page(¶ms)) + } async fn profile_put_body( &self, params: ProfilePutBodyParams, @@ -3911,3 +3917,494 @@ fn adding_a_banned_peer_reports_that_no_bypass_was_granted() { "the person still needs to be told what actually happened" ); } + +// --------------------------------------------------------------------------------------------- +// control.spends.list — the automated-spend audit record +// --------------------------------------------------------------------------------------------- + +/// The audit fixture, newest-initiated FIRST — the order the contract fixes. +/// +/// Four spends, chosen so the three wrong implementations this method invites are each visible: +/// +/// * a **confirmed** spend is the honest control. Without one, a fixture of nothing-but-failures +/// cannot tell "failures are listed" from "everything is listed", and cannot tell a client that +/// renders every row as unsettled from one that reads the status at all. +/// * the two **failures differ in exactly one field** — the stage — so an implementation that +/// flattens the stage into a bare "failed" produces two identical rows here and fails. A fixture +/// carrying only one failure could not see that collapse at all. +/// * **unresolved** sits beside them so a `status: "failed"` filter that swept unknown outcomes into +/// the failure bucket returns three rows instead of two. +/// +/// Four rows paged two at a time is also what kills the completeness-by-length reading: BOTH pages +/// carry exactly two rows, and only `complete` tells them apart. +/// +/// `initiated_ms` values are a pinned constant, never a wall clock — a fixture whose timestamps +/// drift with the run cannot pin an order. +const AUDIT_BASE_MS: u64 = 1_700_000_000_000; + +/// Entries in the fixture's record that could not be parsed. NON-ZERO on purpose: a zero here would +/// pass identically for an implementation that never reports corruption at all. +const AUDIT_UNREADABLE: u32 = 2; + +/// The SAME reason on both failing spends. +/// +/// Deliberate: the two failure rows must differ in EXACTLY ONE field, the stage. Giving them +/// different reasons was the first version of this fixture, and it made the test pass against an +/// implementation that dropped the stage from the wire entirely — the rows still compared unequal, +/// on the reason. A shared reason removes that second signal, so only the stage can tell them apart. +const AUDIT_FAILURE_REASON: &str = "no chain source could be reached"; + +fn audit_fixture() -> Vec { + fn spend( + id: &str, + initiated_ms: u64, + status: results::SpendOutcome, + chain: Option, + ) -> results::AutomatedSpend { + results::AutomatedSpend { + id: id.into(), + revision: 2, + kind: "mirror-coin".into(), + purpose: "renew the mirror advertising this store".into(), + authority: results::SpendAuthority { + principal: "node".into(), + grant: "auto_mirror_renewal".into(), + }, + asset: results::SpendAsset::Xch, + // Above 2^53, so a client parsing amounts as JSON numbers loses the value. That is the + // whole reason the field is a string, and a fixture under the boundary could not see it. + amount_mojos: "9007199254740993".into(), + fee_mojos: "1000".into(), + store_id: Some(STORE.into()), + initiated_ms, + updated_ms: initiated_ms + 10, + status, + funding_coin_ids: vec![SPENT_COIN.into()], + chain_reference: chain, + } + } + + vec![ + spend( + "sp_confirmed", + AUDIT_BASE_MS + 400, + results::SpendOutcome::Confirmed { + height: 9_172_077, + coin_id: CHILD_COINS[0].into(), + }, + Some(results::SpendChainReference { + coin_id: CHILD_COINS[0].into(), + confirmed: true, + }), + ), + spend( + "sp_broadcast", + AUDIT_BASE_MS + 300, + results::SpendOutcome::Failed { + stage: results::SpendFailureStage::Broadcast, + reason: AUDIT_FAILURE_REASON.into(), + }, + // An INTENDED coin, not an observed one: the node signed, so a coin id exists to check + // even though nothing confirms it. + Some(results::SpendChainReference { + coin_id: CHILD_COINS[1].into(), + confirmed: false, + }), + ), + spend( + "sp_signing", + AUDIT_BASE_MS + 200, + results::SpendOutcome::Failed { + stage: results::SpendFailureStage::Signing, + reason: AUDIT_FAILURE_REASON.into(), + }, + None, + ), + spend( + "sp_unresolved", + AUDIT_BASE_MS + 100, + results::SpendOutcome::Unresolved { + reason: "the node restarted while the spend was in flight".into(), + }, + Some(results::SpendChainReference { + coin_id: CHILD_COINS[2].into(), + confirmed: false, + }), + ), + ] +} + +/// Read the fixture the way a conforming node must: filter, then cursor, then page, then state +/// completeness from what was WITHHELD rather than from the page's length. +fn audit_page(params: &SpendsListParams) -> results::SpendsListResult { + let mut rows = audit_fixture(); + if let Some(status) = params.status.as_deref() { + rows.retain(|r| r.status.token() == status); + } + if let Some(kind) = params.kind.as_deref() { + rows.retain(|r| r.kind == kind); + } + if let Some(since) = params.since_ms { + rows.retain(|r| r.initiated_ms >= since); + } + if let Some(until) = params.until_ms { + rows.retain(|r| r.initiated_ms < until); + } + if let Some(after) = params.after_id.as_deref() { + match rows.iter().position(|r| r.id == after) { + Some(i) => rows.drain(..=i).for_each(drop), + None => rows.clear(), + } + } + let limit = params.effective_limit() as usize; + let complete = rows.len() <= limit; + rows.truncate(limit); + results::SpendsListResult { + cursor: rows.last().map(|r| r.id.clone()), + spends: rows, + complete, + unreadable_lines: AUDIT_UNREADABLE, + } +} + +/// **A broadcast failure and a signing failure MUST stay distinguishable on the wire.** +/// +/// Both carry the token `failed`, so a client keying only on the token treats them identically — +/// and they mean opposite things about a person's money. The fixture varies exactly ONE field +/// between the two rows and keeps a confirmed spend beside them as an honest control, so the +/// nearest wrong implementation (flattening `Failed` to a bare token, which is the collapse dig-node +/// has had to undo twice) produces two rows that compare EQUAL here. +#[test] +fn a_broadcast_failure_is_not_the_same_answer_as_a_signing_failure() { + let node = MockNode; + let page = block_on(node.spends_list(SpendsListParams::default())).unwrap(); + + let broadcast = page.spends.iter().find(|s| s.id == "sp_broadcast").unwrap(); + let signing = page.spends.iter().find(|s| s.id == "sp_signing").unwrap(); + let confirmed = page.spends.iter().find(|s| s.id == "sp_confirmed").unwrap(); + + // The control: the read does distinguish outcomes at all. + assert_eq!(confirmed.status.token(), "confirmed"); + + // Same token — which is exactly why the token alone must never be the whole answer. + assert_eq!(broadcast.status.token(), "failed"); + assert_eq!(signing.status.token(), "failed"); + + // …and yet the two rows must not be equal, on the wire or in the type. + assert_ne!( + serde_json::to_value(&broadcast.status).unwrap(), + serde_json::to_value(&signing.status).unwrap(), + "a flattened `failed` makes these two identical, which is the money-lie this shape exists \ + to prevent" + ); + + // The load-bearing consequence: only the signing failure claims the money stayed put. + assert!(broadcast.status.outcome_is_unknown()); + assert!(!signing.status.outcome_is_unknown()); + assert!(!confirmed.status.outcome_is_unknown()); +} + +/// **`unresolved` is a first-class state, never a flavour of `failed`.** +/// +/// The `status: "failed"` filter is the placement-sensitive probe: an implementation that folds +/// unknown outcomes into the failure bucket — anywhere between the record and the wire — returns +/// three rows here instead of two, whichever layer it does it at. +#[test] +fn an_unresolved_spend_is_not_returned_as_a_failure() { + let node = MockNode; + let failed = block_on(node.spends_list(SpendsListParams { + status: Some("failed".into()), + ..SpendsListParams::default() + })) + .unwrap(); + let ids: Vec<&str> = failed.spends.iter().map(|s| s.id.as_str()).collect(); + assert_eq!(ids, vec!["sp_broadcast", "sp_signing"]); + + let unresolved = block_on(node.spends_list(SpendsListParams { + status: Some("unresolved".into()), + ..SpendsListParams::default() + })) + .unwrap(); + assert_eq!(unresolved.spends.len(), 1); + assert!(unresolved.spends[0].status.outcome_is_unknown()); + assert!(matches!( + unresolved.spends[0].status, + results::SpendOutcome::Unresolved { .. } + )); +} + +/// **Truncation is STATED, never inferred from the page's length.** +/// +/// Four rows paged two at a time: both pages carry exactly two rows, so `complete` is the only thing +/// that tells the middle of the walk from its end. A client inferring completeness from +/// `spends.len() < limit` stops after the first page and presents half the audit record as the whole +/// of it — "no more spends" and "we stopped telling you" rendered identically. +#[test] +fn a_truncated_spend_page_and_a_final_one_are_told_apart_only_by_complete() { + let node = MockNode; + + let first = block_on(node.spends_list(SpendsListParams { + limit: Some(2), + ..SpendsListParams::default() + })) + .unwrap(); + assert_eq!(first.spends.len(), 2); + assert!( + !first.complete, + "more rows were withheld, and it must say so" + ); + assert_eq!(first.cursor.as_deref(), Some("sp_broadcast")); + + let second = block_on(node.spends_list(SpendsListParams { + limit: Some(2), + after_id: first.cursor.clone(), + ..SpendsListParams::default() + })) + .unwrap(); + assert_eq!(second.spends.len(), first.spends.len()); + assert!(second.complete, "the walk is finished and must say so"); + assert_eq!(second.cursor.as_deref(), Some("sp_unresolved")); + + // The pages tile the record exactly: no row repeated, none skipped. + let walked: Vec<&str> = first + .spends + .iter() + .chain(second.spends.iter()) + .map(|s| s.id.as_str()) + .collect(); + assert_eq!( + walked, + vec![ + "sp_confirmed", + "sp_broadcast", + "sp_signing", + "sp_unresolved" + ] + ); +} + +/// **Entries the node could not parse are part of the ANSWER.** +/// +/// A trail that lost rows to corruption and reads as a tidy shorter one is the same lie as a missing +/// entry. The count rides on every page, including a page that was itself truncated, so a client can +/// never present a partial record as complete. +#[test] +fn unreadable_entries_are_reported_on_every_page() { + let node = MockNode; + let whole = block_on(node.spends_list(SpendsListParams::default())).unwrap(); + assert_eq!(whole.unreadable_lines, AUDIT_UNREADABLE); + + let page = block_on(node.spends_list(SpendsListParams { + limit: Some(1), + ..SpendsListParams::default() + })) + .unwrap(); + assert_eq!(page.unreadable_lines, AUDIT_UNREADABLE); +} + +/// **An out-of-range page size is REFUSED, not clamped**, and the bound is pinned from BOTH sides. +/// +/// At-bound must pass and one over must fail; a bound tested only from below confirms only itself. +/// Clamping would hand back a cursor for a position the caller never asked about, which is how a +/// paged walk loses rows. +#[test] +fn the_page_bound_is_refused_from_above_and_accepted_at_the_bound() { + let at_bound = SpendsListParams { + limit: Some(SPENDS_LIST_MAX_LIMIT), + ..SpendsListParams::default() + }; + assert!(at_bound.validated().is_ok(), "the cap itself must be legal"); + + for bad in [0, SPENDS_LIST_MAX_LIMIT + 1] { + let err = SpendsListParams { + limit: Some(bad), + ..SpendsListParams::default() + } + .validated() + .expect_err("an out-of-range page size must be refused"); + assert_eq!(err.code_enum(), Some(ControlErrorCode::InvalidParams)); + } + + // And the refusal is enforced on the way IN, so a node cannot forget to validate. + let over = serde_json::to_value(SPENDS_LIST_MAX_LIMIT + 1).unwrap(); + assert!(serde_json::from_value::(json!({"limit": over})).is_err()); + assert!(serde_json::from_value::(json!({"limit": 0})).is_err()); + // An omitted limit is not a refusal — it is the contract's own default. + let defaulted: SpendsListParams = serde_json::from_value(json!({})).unwrap(); + assert_eq!(defaulted.effective_limit(), SPENDS_LIST_DEFAULT_LIMIT); +} + +/// **`control.spends.list` is TOKEN-GATED and is not on the open surface.** +/// +/// The caller names no identifier, so the answer is this node's OWN spending history — the same rule +/// that keeps `control.wallet.arrivals` gated. Asserted against the open-surface predicate rather +/// than by reading the summary, so widening the open set by analogy trips here. +#[test] +fn the_audit_read_is_gated_because_the_caller_names_nothing() { + assert!(ControlMethod::SpendsList.requires_auth()); + assert!(!ControlMethod::SpendsList.is_open_read()); + assert!(!ControlMethod::SpendsList.requires_master_token()); + assert_eq!(ControlMethod::SpendsList.name(), "control.spends.list"); +} + +/// The golden wire vectors: the request envelope, and a response that decodes and re-encodes +/// byte-for-byte. +/// +/// The result vector carries `cursor: null` on purpose — the key is REQUIRED even when empty, so a +/// truncated payload cannot decode into a confident "there was nothing to resume from". +#[test] +fn spends_list_wire_vectors_are_pinned() { + assert_request( + &SpendsListParams { + status: Some("failed".into()), + limit: Some(2), + ..SpendsListParams::default() + }, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "control.spends.list", + "params": {"status": "failed", "limit": 2}, + }), + ); + + assert_result_round_trips::(json!({ + "spends": [], + "complete": true, + "cursor": null, + "unreadable_lines": 0, + })); + + assert_result_round_trips::(json!({ + "spends": [{ + "id": "sp_broadcast", + "revision": 2, + "kind": "mirror-coin", + "purpose": "renew the mirror advertising this store", + "authority": {"principal": "node", "grant": "auto_mirror_renewal"}, + "asset": {"asset": "xch"}, + "amount_mojos": "9007199254740993", + "fee_mojos": "1000", + "store_id": STORE, + "initiated_ms": AUDIT_BASE_MS + 300, + "updated_ms": AUDIT_BASE_MS + 310, + "status": {"state": "failed", "stage": "broadcast", "reason": "mempool rejected the bundle"}, + "funding_coin_ids": [SPENT_COIN], + "chain_reference": {"coin_id": CHILD_COINS[1], "confirmed": false}, + }], + "complete": false, + "cursor": "sp_broadcast", + "unreadable_lines": 2, + })); +} + +/// **A missing `cursor` key must NOT decode as "nothing to resume from".** +/// +/// `null` is meaningful here, so serde's default treatment of `Option` would let a truncated or +/// mis-routed payload decode into a confident end-of-walk. Same rule on a row's `chain_reference`, +/// where the absent key would read as "this spend has no coin to look up". +#[test] +fn an_absent_cursor_or_chain_reference_key_is_a_decode_error() { + assert!(serde_json::from_value::(json!({ + "spends": [], + "complete": true, + "unreadable_lines": 0, + })) + .is_err()); + + assert!(serde_json::from_value::(json!({ + "id": "sp_signing", + "revision": 1, + "kind": "mirror-coin", + "purpose": "p", + "authority": {"principal": "node", "grant": "g"}, + "asset": {"asset": "dig"}, + "amount_mojos": "1", + "fee_mojos": "0", + "store_id": null, + "initiated_ms": AUDIT_BASE_MS, + "updated_ms": AUDIT_BASE_MS, + "status": {"state": "failed", "stage": "signing", "reason": "insufficient funds"}, + "funding_coin_ids": [], + })) + .is_err()); +} + +/// **Every catalogued error code is declared as a ROW of an error-code TABLE, with its own symbol.** +/// +/// `SPEC.md`'s error section is normative and states that a node or client MUST NOT mint a `-3204x` +/// code "that is not declared in the table above". That sentence makes TABLE MEMBERSHIP — not mere +/// presence in the file — the thing a reimplementer is entitled to rely on, and Markdown terminates +/// a table at the first blank line. A code whose row is separated from the table by one stray +/// newline therefore renders as an ordinary paragraph of pipe-delimited text, and the document then +/// forbids the very code this crate mints. That is exactly how `-32048` landed: the sibling guard +/// above covers method names and phase tokens, error codes had no equivalent, and 160 tests passed +/// over a split contract. +/// +/// So this is a STRUCTURAL check, deliberately, on two axes a weaker one would miss: +/// +/// * A `text.contains("-32048")` assertion passes against that exact defect — the number is in the +/// file, just not in a table. The row is therefore located inside a parsed table BLOCK (a run of +/// pipe lines carrying a `|---|` separator), never anywhere in the prose. +/// * A check on the number alone would accept a code mapped to the WRONG symbol. The row's first +/// TWO cells are pinned together, so a transposed or renamed symbol fails here. +/// +/// `README.md` is held to a weaker bar ON PURPOSE: its table is an abbreviated agent-facing subset +/// that has never carried the `-3204x` band. Requiring exhaustiveness there would be a different +/// change. What IS required is that any code it does list is listed CORRECTLY — a wrong symbol in +/// the short table misleads exactly the reader it is written for. +#[test] +fn every_catalogued_error_code_is_a_row_of_an_error_code_table() { + /// The maximal runs of consecutive pipe-prefixed lines that carry a `|---|` separator, i.e. the + /// blocks Markdown actually renders as tables. A blank line ends a block, which is the whole + /// point of parsing rather than scanning. + fn table_rows(text: &str) -> Vec<&str> { + fn flush<'a>(block: &mut Vec<&'a str>, rows: &mut Vec<&'a str>) { + let is_table = block + .iter() + .any(|l| l.trim_start_matches('|').trim_start().starts_with("---")); + if is_table { + rows.extend(block.iter().copied()); + } + block.clear(); + } + + let mut rows: Vec<&str> = Vec::new(); + let mut block: Vec<&str> = Vec::new(); + for line in text.lines() { + if line.trim_start().starts_with('|') { + block.push(line.trim()); + } else { + flush(&mut block, &mut rows); + } + } + flush(&mut block, &mut rows); + rows + } + + let spec_rows = table_rows(include_str!("../SPEC.md")); + let readme_rows = table_rows(include_str!("../README.md")); + + for &code in ControlErrorCode::ALL { + // The row as it must appear, both cells at once: the number cannot be declared beside some + // other symbol, and the symbol cannot be declared beside some other number. + let row = format!("| `{}` | `{}` |", code.code(), code.name()); + + assert!( + spec_rows.iter().any(|l| l.starts_with(&row)), + "SPEC.md has no error-code TABLE ROW starting `{row}` -- either the code is undeclared, \ + it carries the wrong symbol, or a blank line has split its row out of the table (which \ + renders it as a paragraph, and SPEC.md's own MUST then forbids the code)" + ); + + // README lists a subset; whatever it lists must agree with the catalog. + let number_cell = format!("| `{}` |", code.code()); + if let Some(listed) = readme_rows.iter().find(|l| l.starts_with(&number_cell)) { + assert!( + listed.starts_with(&row), + "README.md declares `{}` with the wrong symbol: {listed}", + code.code() + ); + } + } +} diff --git a/src/method.rs b/src/method.rs index 7780e28..6f92103 100644 --- a/src/method.rs +++ b/src/method.rs @@ -54,6 +54,9 @@ pub enum Category { /// Wallet chain transport: the read-only chain views (balance, coins, one coin by id, peak, /// sync status) plus the push of an already-signed spend bundle. Wallet, + /// The automated-spend AUDIT record: what this node signed WITHOUT per-transaction approval. + /// Read-only; nothing in this category initiates, signs or alters a spend. + Spends, /// dig-profile BODIES: handing the node the bytes a confirmed on-chain root commits to, and /// reading one back. The chain root itself is never written here -- dig-app signs and pushes /// that (§908); this category moves only the bytes an already-confirmed root commits to. @@ -196,6 +199,10 @@ pub enum ControlMethod { /// `control.wallet.reservations.release` — free a hold now, ahead of its TTL. WalletReservationsRelease, + // ---- Automated-spend audit record (shell-owned) ---- + /// `control.spends.list` — read the record of spends this node made WITHOUT asking. + SpendsList, + // ---- dig-profile bodies (delegated to the engine) ---- /// `control.profile.putBody` — hand the node the profile body a CONFIRMED chain root commits to. ProfilePutBody, @@ -260,6 +267,7 @@ impl ControlMethod { ControlMethod::WalletReservationsHeld => "control.wallet.reservations.held", ControlMethod::WalletReservationsReserve => "control.wallet.reservations.reserve", ControlMethod::WalletReservationsRelease => "control.wallet.reservations.release", + ControlMethod::SpendsList => "control.spends.list", ControlMethod::ProfilePutBody => "control.profile.putBody", ControlMethod::ProfileGetBody => "control.profile.getBody", ControlMethod::PairingRequest => "pairing.request", @@ -489,6 +497,7 @@ impl ControlMethod { | ControlMethod::WalletReservationsHeld | ControlMethod::WalletReservationsReserve | ControlMethod::WalletReservationsRelease => Category::Wallet, + ControlMethod::SpendsList => Category::Spends, ControlMethod::ProfilePutBody | ControlMethod::ProfileGetBody => Category::Profile, } } @@ -539,6 +548,7 @@ impl ControlMethod { ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).", ControlMethod::WalletWatch => "Enrol PUBLIC keys (48-byte G1, lowercase 96-hex) for the node's chain replica to follow, so their addresses are synced and readable. IDEMPOTENT: re-enrolling a key already enrolled succeeds and changes nothing. Keys, never puzzle hashes -- the node derives the addresses itself, so one derivation serves every client. TOKEN-GATED.", ControlMethod::WalletUnwatch => "Deregister enrolled public keys, so the node stops following their addresses. IDEMPOTENT: a key that was never enrolled is not an error. TOKEN-GATED.", + ControlMethod::SpendsList => "READ-only: the record of spends this node made WITHOUT per-transaction approval -- what moved, when, on whose standing authority, and whether the chain confirmed it. It NEVER initiates, signs, cancels or alters a spend, and there is no verb here that edits an entry. A failed spend is reported WITH the stage it died at, because only a signing failure means the money definitely did not move; a broadcast or confirmation failure is an UNKNOWN outcome, as is `unresolved`. A page is bounded and says so via `complete`; `unreadable_lines` reports entries the node could not parse, so an audit trail that lost rows can never read as a tidy shorter one. TOKEN-GATED although it is a read: the caller supplies no identifier, so the answer is this node's OWN state.", ControlMethod::ProfilePutBody => "Hand the node the dig-profile BODY that a chain root commits to. The node INDEPENDENTLY resolves that root on chain and REFUSES any body whose recomputed root is not the confirmed one -- the caller's `root` is a claim to be checked, never a fact to be trusted, and dig-app is a caller like any other. Bodies are capped at MAX_BODY_BYTES (4 MiB). TOKEN-GATED.", ControlMethod::ProfileGetBody => "READ-only: the dig-profile body this node holds at a given store id + root, or `body: null` when it holds none. `null` NEVER means the body could not be read, which is an error. TOKEN-GATED.", ControlMethod::WalletWatched => "READ-only: the public keys currently enrolled, so a client can reconcile what it asked for against what the node holds. TOKEN-GATED although it is a read -- the caller supplies nothing, so the answer is this node's OWN key set.", @@ -600,6 +610,7 @@ impl ControlMethod { ControlMethod::WalletReservationsHeld, ControlMethod::WalletReservationsReserve, ControlMethod::WalletReservationsRelease, + ControlMethod::SpendsList, ControlMethod::ProfilePutBody, ControlMethod::ProfileGetBody, ControlMethod::PairingRequest, diff --git a/src/params.rs b/src/params.rs index 8e25e60..d9dfa81 100644 --- a/src/params.rs +++ b/src/params.rs @@ -1347,6 +1347,168 @@ pub struct ProfileGetBodyParams { } control_call!(ProfileGetBodyParams => ControlMethod::ProfileGetBody, results::ProfileGetBodyResult); +/// The page size `control.spends.list` uses when the caller names none. +/// +/// Stated on the contract rather than chosen by each side, so a node and a client cannot resolve the +/// same omitted field to two different numbers — a disagreement that shows up as a page boundary in +/// the wrong place, which is where a paged walk loses rows. +pub const SPENDS_LIST_DEFAULT_LIMIT: u32 = 50; + +/// The largest page `control.spends.list` will serve. +/// +/// The audit record grows without limit — it is append-only and every automated cycle adds to it — +/// so an unbounded read is unbounded work and an unbounded response. The bound is declared here from +/// the start rather than added later, because a method that ships unbounded teaches every client to +/// expect the whole record in one call, and bounding it afterwards is then a breaking change. +/// +/// **This bound is the only thing bounding the call.** dig-node's control plane has no request rate +/// limiting of any kind (dig_ecosystem#2577), so a future reader weighing a larger cap should assume +/// no limiter exists behind it, because none does. +pub const SPENDS_LIST_MAX_LIMIT: u32 = 500; + +/// The one refusal message for an out-of-range `control.spends.list` page size. +const SPENDS_LIST_LIMIT_ERROR: &str = "limit must be between 1 and 500 spends per page"; + +/// `control.spends.list` params: WHICH automated spends to read, and how much of the record at once. +/// +/// # A read, and only a read +/// +/// Nothing here initiates, signs, cancels or amends a spend, and the catalog offers no method that +/// does. The record exists to make automatic signing accountable, and a surface able to edit it +/// would be a surface able to edit the evidence. +/// +/// # Every filter is an AND; an unset filter constrains nothing +/// +/// Omitting a field means "do not narrow on this", never "match nothing". A client that sends no +/// field at all asks for the newest page of the whole record. +/// +/// # Paged, and the page boundary is the caller's +/// +/// Rows come newest-initiated first (the full ordering rule is on +/// [`SpendsListResult`](results::SpendsListResult)) and a caller resumes from +/// [`after_id`](Self::after_id) — the id of the last row it was actually HANDED. An out-of-range +/// [`limit`](Self::limit) is REFUSED rather than clamped, matching +/// [`WalletCoinsByParentParams`] and for the same reason: a silently shrunk page hands back a cursor +/// for a position the caller did not ask about. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct SpendsListParams { + /// Only spends INITIATED at or after this unix-ms instant. Inclusive. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub since_ms: Option, + /// Only spends INITIATED strictly before this unix-ms instant. Exclusive. + /// + /// Half-open with [`since_ms`](Self::since_ms) so consecutive windows tile the record exactly: + /// one window's `until_ms` is the next window's `since_ms`, and no spend is counted twice or + /// dropped between them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub until_ms: Option, + /// Only spends serving this store id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub store_id: Option, + /// Only this kind of spend — the stable token the producer stamped (`"mirror-coin"`, …). + /// + /// An open string rather than an enum, because a new producer must be able to appear in the + /// record without a release of this crate. An unrecognised kind matches nothing rather than + /// erroring, which is the same answer a caller gets for a real kind that has no rows yet. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Only this outcome, by its [`SpendOutcome::token`](results::SpendOutcome::token) — + /// `pending` / `submitted` / `confirmed` / `failed` / `unresolved`. + /// + /// **`failed` does NOT mean "the money stayed put"**, so a client filtering on it must still + /// read each row's stage — see [`SpendOutcome::Failed`](results::SpendOutcome::Failed). A UI + /// that offers a "failed" filter and renders its rows as untouched money asserts something the + /// node does not know. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Resume STRICTLY AFTER this audit id, in the read's documented order. `None` starts at the + /// newest matching row. + /// + /// This is the value the previous page handed back as + /// [`cursor`](results::SpendsListResult::cursor) — never an id the caller kept for another + /// reason, and never a timestamp. Resuming by time would drop every spend sharing the boundary + /// millisecond. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after_id: Option, + /// The page size. `None` asks for [`SPENDS_LIST_DEFAULT_LIMIT`]. + /// + /// A zero, or a value above [`SPENDS_LIST_MAX_LIMIT`], is REFUSED as `INVALID_PARAMS` rather + /// than clamped — see [`Self::validated`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +impl SpendsListParams { + /// The page size this request asks for, resolving `None` to [`SPENDS_LIST_DEFAULT_LIMIT`]. + /// + /// Stated once here so the node and the client cannot resolve the same omitted field to two + /// different numbers. + pub fn effective_limit(&self) -> u32 { + self.limit.unwrap_or(SPENDS_LIST_DEFAULT_LIMIT) + } + + /// Check the page bound, or reject as `-32602 INVALID_PARAMS`. + /// + /// `limit: 0` is refused because a page that can hold nothing makes no progress: a caller + /// looping until [`complete`](results::SpendsListResult::complete) would loop forever. A limit + /// above the cap is refused rather than clamped so the caller's model of the page and the node's + /// stay identical. + /// + /// The time window is deliberately NOT validated. `since_ms > until_ms` is a well-formed request + /// for an empty window, and an empty window has an honest answer — no rows — which is a + /// different thing from a malformed request. + pub fn validated(self) -> Result { + if let Some(limit) = self.limit { + if limit == 0 || limit > SPENDS_LIST_MAX_LIMIT { + return Err(ControlError::of( + ControlErrorCode::InvalidParams, + SPENDS_LIST_LIMIT_ERROR, + )); + } + } + Ok(self) + } +} + +impl<'de> Deserialize<'de> for SpendsListParams { + /// Validates on the way in, so a node cannot forget to call [`Self::validated`]. + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Raw { + #[serde(default)] + since_ms: Option, + #[serde(default)] + until_ms: Option, + #[serde(default)] + store_id: Option, + #[serde(default)] + kind: Option, + #[serde(default)] + status: Option, + #[serde(default)] + after_id: Option, + #[serde(default)] + limit: Option, + } + let raw = Raw::deserialize(deserializer)?; + SpendsListParams { + since_ms: raw.since_ms, + until_ms: raw.until_ms, + store_id: raw.store_id, + kind: raw.kind, + status: raw.status, + after_id: raw.after_id, + limit: raw.limit, + } + .validated() + .map_err(serde::de::Error::custom) + } +} +control_call!(SpendsListParams => ControlMethod::SpendsList, results::SpendsListResult); + #[cfg(test)] mod tests { use super::*; diff --git a/src/results.rs b/src/results.rs index 3a76d40..57f2001 100644 --- a/src/results.rs +++ b/src/results.rs @@ -1852,6 +1852,312 @@ pub struct ProfileGetBodyResult { pub body_bytes: u64, } +/// Which asset an automated spend moved. +/// +/// Externally tagged on `asset` so a CAT carries its asset id in the same object rather than in a +/// sibling field that could go missing: `{"asset":"xch"}`, `{"asset":"dig"}`, +/// `{"asset":"cat","asset_id":"…"}`. An amount is never readable without its asset, so the two +/// travel together. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "asset", rename_all = "snake_case")] +pub enum SpendAsset { + /// Chia itself. + Xch, + /// The $DIG CAT. + Dig, + /// Any other CAT, identified by its asset id. + Cat { + /// The CAT's asset id, lowercase 64-hex. + asset_id: String, + }, +} + +/// ON WHOSE AUTHORITY the node signed without asking. +/// +/// Two fields rather than one sentence, because a person auditing an unapproved spend asks two +/// separate questions: WHO holds the standing permission, and WHICH standing permission was used. A +/// prose sentence answers neither in a form a filter — or a revocation — can act on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpendAuthority { + /// The principal whose funds moved and whose consent was relied on: an account id, a profile id, + /// or `"node"` for the node's own operating wallet. + pub principal: String, + /// The standing grant relied on, in a form the operator can go and revoke — a setting name, a + /// policy id, a pairing token id. + pub grant: String, +} + +/// Where an attempt died. +/// +/// Coarse and stable on purpose: the point is which STEP failed, because that is what tells a person +/// whether their money is at risk. **This distinction is load-bearing and MUST NOT be flattened into +/// a bare "failed".** A client that collapses it is structurally unable to tell someone the truth +/// about their own money. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpendFailureStage { + /// The spend could not be built or signed. No signed bundle ever existed, so nothing could reach + /// a mempool and nothing moved. + Signing, + /// A signed bundle was rejected by the mempool, **as far as this node saw**. The bundle may + /// still have reached the network by another route, or been accepted after the rejection this + /// node observed. + Broadcast, + /// The bundle went out and the chain then reported it could not succeed. + Confirmation, +} + +impl SpendFailureStage { + /// Could the money have moved anyway, despite the attempt failing at this stage? + /// + /// [`Signing`](Self::Signing) is the only stage that answers NO, and it answers structurally: no + /// signed bundle existed, so there was nothing that could reach a mempool. + /// [`Broadcast`](Self::Broadcast) and [`Confirmation`](Self::Confirmation) both happen AFTER a + /// valid signed bundle exists, and neither observation proves absence — a rejection this node + /// saw does not bind a network it does not fully observe. + /// + /// This is the ONE place the distinction is decided. Every consumer asks the stage rather than + /// re-listing the variants, so the "it did not happen" claim cannot be re-attached to a stage + /// that never earned it. Written as an exhaustive `match` so adding a stage is a compile error + /// here, forcing whoever adds it to choose a side. + pub fn money_may_have_moved(self) -> bool { + match self { + SpendFailureStage::Signing => false, + SpendFailureStage::Broadcast | SpendFailureStage::Confirmation => true, + } + } + + /// The stable lowercase wire token. + pub const fn token(self) -> &'static str { + match self { + SpendFailureStage::Signing => "signing", + SpendFailureStage::Broadcast => "broadcast", + SpendFailureStage::Confirmation => "confirmation", + } + } +} + +/// Where one automated spend got to. +/// +/// Internally tagged on `state`, so a row is `{"state":"confirmed","height":…,"coin_id":"…"}`. +/// +/// # Two shape rules, each from a measured money-lie +/// +/// 1. **[`Confirmed`](Self::Confirmed) carries its evidence inside the variant.** There is no +/// optional height field to fill in optimistically, so a row cannot hold a confirmation height +/// without a confirmation. +/// 2. **[`Unresolved`](Self::Unresolved) is NOT a kind of failure.** "The node signed and does not +/// know how it ended" is not "it did not happen": money may well have moved, and saying `failed` +/// about a spend that landed is the same class of lie as claiming an unconfirmed success. A +/// client that maps it onto `failed` to keep a two-state UI has chosen the wrong UI. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum SpendOutcome { + /// Recorded, not yet handed to the network. Written before the producer may sign. + Pending, + /// A signed bundle was accepted by the mempool. NOT a claim that it will confirm. + Submitted, + /// The chain shows the coin this spend created. + Confirmed { + /// The height the created coin was confirmed at. + height: u32, + /// The coin the spend CREATED — the reference a person can paste into an explorer. + coin_id: String, + }, + /// The attempt ended in a failure this node observed. + /// + /// **This is not uniformly a claim that the money stayed put.** Only + /// [`SpendFailureStage::Signing`] carries that claim; at `Broadcast` and `Confirmation` a signed + /// bundle already existed and the outcome is genuinely UNKNOWN. Ask + /// [`SpendFailureStage::money_may_have_moved`] before rendering any `failed` row as settled. + Failed { + /// Which step failed — and, through [`SpendFailureStage::money_may_have_moved`], whether + /// this row claims the money is untouched or merely records where the attempt died. + stage: SpendFailureStage, + /// One line a person can act on. "Insufficient funds" is the difference between a broken + /// node and a wallet that needs topping up. + reason: String, + }, + /// The node signed and does not know how it ended — a timeout, a restart mid-flight, or a + /// producer that dropped the spend. + Unresolved { + /// Why the outcome is unknown. + reason: String, + }, +} + +impl SpendOutcome { + /// The stable lowercase token, matching the `state` tag and the + /// [`status`](crate::params::SpendsListParams::status) filter. + pub const fn token(&self) -> &'static str { + match self { + SpendOutcome::Pending => "pending", + SpendOutcome::Submitted => "submitted", + SpendOutcome::Confirmed { .. } => "confirmed", + SpendOutcome::Failed { .. } => "failed", + SpendOutcome::Unresolved { .. } => "unresolved", + } + } + + /// Is what happened to the money still UNKNOWN? + /// + /// True for [`Unresolved`](Self::Unresolved), and true for a [`Failed`](Self::Failed) row whose + /// stage [may have moved money](SpendFailureStage::money_may_have_moved). Those two are the rows + /// a person still has to chase, and a UI grouping them with settled failures hides exactly the + /// spends worth looking at. + /// + /// `Pending` and `Submitted` are NOT unknown outcomes — they are outcomes that have not happened + /// yet, and the node expects to learn them. Conflating "in flight" with "lost track of" would + /// raise an alarm about every spend in progress. + pub fn outcome_is_unknown(&self) -> bool { + match self { + SpendOutcome::Unresolved { .. } => true, + SpendOutcome::Failed { stage, .. } => stage.money_may_have_moved(), + SpendOutcome::Pending | SpendOutcome::Submitted | SpendOutcome::Confirmed { .. } => { + false + } + } + } +} + +/// A chain reference, paired with whether this node actually OBSERVED it. +/// +/// The [`confirmed`](Self::confirmed) flag is not decoration. Before confirmation the node knows the +/// coin id it INTENDS to create, and rendering that bare id beside a confirmed one presents an +/// intention as a fact. The two travel together so a client can render "expected" differently from +/// "on chain" without re-deriving the distinction — which is the derivation it would get wrong. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpendChainReference { + /// The coin id to look up. + pub coin_id: String, + /// `true` when this node observed the coin on chain; `false` when it is only the intended result. + pub confirmed: bool, +} + +/// One spend this node made WITHOUT per-transaction approval. +/// +/// # Amounts are decimal STRINGS +/// +/// `amount_mojos` and `fee_mojos` carry the full `u64` range, which a JSON number does not survive +/// through an f64 parser — and a silently rounded figure about somebody's money is exactly the lie +/// this record exists to prevent. Every money field in this crate is a string for that reason. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AutomatedSpend { + /// The audit id — stable for the life of the spend, and the value + /// [`after_id`](crate::params::SpendsListParams::after_id) resumes from. + pub id: String, + /// The revision of the record this row reflects. The audit trail is append-only and each entry + /// is a snapshot; this row is the highest revision the node holds for this spend. + pub revision: u32, + /// What the spend was for, as the producer's stable token (`"mirror-coin"`, …). + pub kind: String, + /// One human sentence: why this happened without asking. + pub purpose: String, + /// Whose standing consent was relied on, and which grant. + pub authority: SpendAuthority, + /// Which asset moved. + pub asset: SpendAsset, + /// How much, in the asset's base units, as a decimal string. + pub amount_mojos: String, + /// The network fee in mojos of XCH, as a decimal string. + pub fee_mojos: String, + /// The store this spend serves, when it serves one. + pub store_id: Option, + /// When the node decided to spend, unix ms. The field the ordering and the time filters use. + pub initiated_ms: u64, + /// When this revision was written, unix ms. + pub updated_ms: u64, + /// Where the spend got to. + pub status: SpendOutcome, + /// The coins this spend CONSUMED, once known. + /// + /// Never the confirmation evidence. The legacy implementation waited for a funding coin to be + /// spent and called that confirmation, which a competing spend of the same coin satisfies + /// identically while the intended coin never exists — so a client MUST NOT infer success from + /// anything here. [`chain_reference`](Self::chain_reference) is the only reference that carries + /// an observed/expected flag. + pub funding_coin_ids: Vec, + /// The chain reference to show, or `null` when the node knows no coin id yet — which is honest: + /// there is nothing to look up. + /// + /// The key MUST be present. `null` is meaningful, so an ABSENT key must not decode into it: a + /// truncated or mis-routed payload would otherwise decode as a confident "there is nothing to + /// look up". + #[serde(deserialize_with = "required_option")] + pub chain_reference: Option, +} + +/// `control.spends.list` — one page of the automated-spend audit record. +/// +/// # Why this method is the only sanctioned reader +/// +/// The record is a node-private file (dig-node SPEC §23). Every other view — dig-app's Activity tab +/// included — reads it THROUGH the node, and this is that route. A second process parsing the file +/// would be a second implementation of a growing append-only format, which is how two views of "what +/// did the node spend" start disagreeing, on the one subject where disagreeing is least affordable. +/// +/// # A page, and it says so +/// +/// [`spends`](Self::spends) is bounded by +/// [`SPENDS_LIST_MAX_LIMIT`](crate::params::SPENDS_LIST_MAX_LIMIT). Whether it is the whole matching +/// set is stated by [`complete`](Self::complete) and never left to be inferred from the page's +/// length: a node may return a short page for its own reasons, and a matching set that is an exact +/// multiple of the page size makes the last full page indistinguishable from a truncated one. +/// Without an explicit flag a caller cannot tell "there are no more spends" from "we stopped telling +/// you" — and on an audit record those read the same and mean opposite things. +/// +/// # The order is part of the contract +/// +/// A node MUST return rows by DESCENDING [`initiated_ms`](AutomatedSpend::initiated_ms), breaking +/// ties by ASCENDING [`id`](AutomatedSpend::id), and MUST keep that order stable across the pages of +/// one walk. [`after_id`](crate::params::SpendsListParams::after_id) means *strictly after this row +/// in that order*. The tiebreak is required rather than incidental: automated spends are issued by a +/// cycle and several can share a millisecond, so a time-only order names no position and a walk +/// would repeat some rows and skip others. +/// +/// # An empty page is an ANSWER, never a fallback +/// +/// `spends: []` with `complete: true` means this node has moved no money unattended that matches the +/// filters. It is NEVER what a caller gets when the record could not be read: that is +/// [`SpendAuditUnreadable`](crate::error::ControlErrorCode::SpendAuditUnreadable). "Nothing to +/// report" and "I could not look" are different answers, and the first is the one a person stops +/// investigating on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpendsListResult { + /// One page of matching spends, newest-initiated first, possibly empty. + pub spends: Vec, + /// Is this page the WHOLE matching set? + /// + /// `true` means every matching spend the node holds is in [`spends`](Self::spends). `false` + /// means the answer was TRUNCATED and more exist — resume from [`cursor`](Self::cursor). + /// + /// Required on the wire, and stated positively so the reading a caller falls into when the field + /// is absent or defaulted is the SAFE one. A boolean spelled `truncated` would default to + /// `false`, i.e. to "this is everything", which is the claim that ends a walk early; `complete` + /// defaults to "there may be more", which costs at worst one redundant request. + pub complete: bool, + /// The id of the last row in this page — **the value to resume from** — or `null` for an empty + /// page. + /// + /// It is the id the caller was HANDED, never a marker for where the record "got to". Pass it as + /// [`after_id`](crate::params::SpendsListParams::after_id). + /// + /// The key MUST be present; `null` is meaningful and an absent key must not decode into it. + #[serde(deserialize_with = "required_option")] + pub cursor: Option, + /// How many entries in the record the node could NOT parse. + /// + /// Part of the answer rather than a log line, and a client MUST surface a non-zero value. An + /// audit trail that lost entries to corruption and reads as a shorter, tidy list is + /// indistinguishable from one where those spends never happened — which is the same lie as a + /// missing entry, told more convincingly. + /// + /// It counts unreadable entries across the WHOLE record, not just this page: a corrupt entry has + /// no parsed timestamp and no parsed id, so it cannot be attributed to a page or excluded by a + /// filter. A caller therefore MUST NOT read it as "this many rows are missing from this page". + pub unreadable_lines: u32, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/traits.rs b/src/traits.rs index 16d5582..fe7249a 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -436,6 +436,37 @@ pub trait ControlHandler: Sync { &self, params: params::WalletReservationsReleaseParams, ) -> Result; + /// `control.spends.list` (READ-only, TOKEN-GATED) + /// + /// One page of the automated-spend audit record — the spends this node made WITHOUT + /// per-transaction approval. Gated although it is a read: the caller names no identifier, so the + /// answer is this node's OWN spending history. + /// + /// An implementation MUST NOT let this call initiate, sign, retry, cancel or amend a spend, and + /// MUST NOT expose any control method that edits or deletes an entry. The record replaces + /// authorization with accountability, and an editable record accounts for nothing. + /// + /// Four obligations, each of which a plausible implementation gets wrong: + /// + /// - **Report the failure STAGE, never a bare "failed."** Only + /// [`SpendFailureStage::Signing`](results::SpendFailureStage::Signing) means the money + /// definitely did not move; a broadcast or confirmation failure is an unknown outcome. An + /// implementation that flattens the stage makes every client structurally unable to tell a + /// person the truth about their money. + /// - **Keep [`Unresolved`](results::SpendOutcome::Unresolved) distinct from `Failed`.** It means + /// the node signed and does not know how it ended. + /// - **State completeness explicitly.** `complete` MUST be `false` whenever a matching row was + /// withheld, and `cursor` MUST be the id of the last row actually returned. + /// - **Report unreadable entries.** `unreadable_lines` MUST count entries the node could not + /// parse; a trail that lost rows must never read as a tidy shorter one. A record that could not + /// be read AT ALL is + /// [`SpendAuditUnreadable`](crate::error::ControlErrorCode::SpendAuditUnreadable), never an + /// empty page — while a record that was never written IS an empty page, because a node that has + /// never spent automatically is the ordinary case. + async fn spends_list( + &self, + params: params::SpendsListParams, + ) -> Result; /// `control.profile.putBody` (TOKEN-GATED) /// /// An implementation MUST independently resolve the profile's root ON CHAIN, recompute the root @@ -600,6 +631,11 @@ pub trait ControlHandler: Sync { ControlMethod::WalletReservationsRelease => { encode(self.wallet_reservations_release(decode(params)?).await?) } + // Re-validated here idempotently; deserialization already enforced the same rule. + ControlMethod::SpendsList => { + let params: params::SpendsListParams = decode(params)?; + encode(self.spends_list(params.validated()?).await?) + } ControlMethod::ProfilePutBody => encode(self.profile_put_body(decode(params)?).await?), ControlMethod::ProfileGetBody => encode(self.profile_get_body(decode(params)?).await?), ControlMethod::PairingRequest => encode(self.pairing_request(decode(params)?).await?),