From 555495cc38180a6ccd32e9c3b9aace7dcc0422ca Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 19:14:28 -0700 Subject: [PATCH 1/2] feat(wallet): declare paging on control.wallet.coins An address's unspent-coin count is unbounded, so the read was too: on a control plane with no request rate limiting, and on the fallback tier against a third-party oracle. Paged rather than capped, because a bare cap makes an address holding more coins than the cap permanently un-enumerable, and this read exists so a caller can BUILD A SPEND from the coins it names. The paging rules are coinsByParent's, pinned to it rather than restated: ascending coin_id, a cursor the caller was handed, an out-of-range limit refused rather than clamped. COINS_MAX_LIMIT/COINS_DEFAULT_LIMIT are defined AS the sibling constants so a frame-limit change moves both. complete is Option, not bool: this method shipped unpaged, so a pre-0.25 node emits neither key. None means undisclosed-and-therefore-whole, which is true of such a node; reading an absent key as Some(false) would send a caller resuming into a node that ignores after_coin_id, and it would walk forever. Refs DIG-Network/dig-node#381 Co-Authored-By: Claude --- Cargo.toml | 2 +- src/kats.rs | 249 +++++++++++++++++++++++++++++++++++++++++++------ src/params.rs | 168 ++++++++++++++++++++++++++++++--- src/results.rs | 45 ++++++++- 4 files changed, 422 insertions(+), 42 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fbed4c8..68d0fc3 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.24.0" +version = "0.25.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" diff --git a/src/kats.rs b/src/kats.rs index 221ac1f..b8662eb 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -49,6 +49,17 @@ const CHILD_COINS: [&str; 4] = [ "4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d", ]; +/// The UNSPENT coins the mock node holds at one address, ASCENDING by coin id (dig-node#381). +/// +/// FOUR of them for the reason [`CHILD_COINS`] is four, and every id DISTINCT from that set so a +/// dispatch arm wired to `control.wallet.coinsByParent` cannot land here and look like a hit. +const ADDRESS_COINS: [&str; 4] = [ + "5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e", + "6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f", + "7070707070707070707070707070707070707070707070707070707070707070", + "8181818181818181818181818181818181818181818181818181818181818181", +]; + /// The two halves of a spend, given DIFFERENT values so a serialization that transposed the fields /// cannot pass. Short stand-ins for serialized CLVM — the contract fixes the encoding (lowercase hex) /// and not the programs, which belong to whatever puzzle was actually revealed. @@ -164,10 +175,7 @@ fn golden_request_vectors() { json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.balance","params":{"address":"xch1exampleaddr","asset":"dig"}}), ); assert_request( - &WalletCoinsParams { - address: "xch1exampleaddr".into(), - asset: Asset::Xch, - }, + &WalletCoinsParams::first_page("xch1exampleaddr", Asset::Xch), json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.coins","params":{"address":"xch1exampleaddr","asset":"xch"}}), ); // The two vectors above are the ORIGINAL frozen bytes and stay untouched: $DIG and XCH keep @@ -181,10 +189,10 @@ fn golden_request_vectors() { json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.balance","params":{"address":"xch1exampleaddr","asset":{"cat":"3c".repeat(32)}}}), ); assert_request( - &WalletCoinsParams { - address: "xch1exampleaddr".into(), - asset: Asset::Cat(AssetId::from_hex(&"3c".repeat(32)).unwrap()), - }, + &WalletCoinsParams::first_page( + "xch1exampleaddr", + Asset::Cat(AssetId::from_hex(&"3c".repeat(32)).unwrap()), + ), json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.coins","params":{"address":"xch1exampleaddr","asset":{"cat":"3c".repeat(32)}}}), ); assert_request( @@ -296,12 +304,14 @@ fn golden_response_result_vectors_are_byte_stable() { "parent_coin_info": "bb".repeat(32), "puzzle_hash": "cc".repeat(32), "created_height": 5_000_000u32, "spent_height": null }], + "complete": true, "cursor": "aa".repeat(32), "source": "db", "synced": true, "peak_height": 5_000_000u32 })); // A chain that was consulted and holds nothing. It must be expressible as a SUCCESS, because // that is the only shape that leaves "unreachable" free to be an error. assert_result_round_trips::(json!({ - "coins": [], "source": "fallback", "synced": false, "peak_height": null + "coins": [], "complete": true, "cursor": null, + "source": "fallback", "synced": false, "peak_height": null })); // `control.wallet.coinById` — the SPENT coin. No `.coins` vector can carry one: that method // answers with unspent coins only, which is exactly why observing a mint needs this method. @@ -1292,26 +1302,45 @@ impl ControlHandler for MockNode { peak_height: Some(5_000_000), }) } - /// Echoes the REQUEST into the coin so a mis-routed dispatch cannot look like a hit: the coin - /// id carries the address and the amount carries the asset. + /// Serves [`ADDRESS_COINS`] one PAGE at a time, by the contract's own rules, and echoes the + /// REQUEST into every record so a mis-routed dispatch cannot look like a hit: the puzzle hash + /// carries the address and the amount carries the asset. + /// + /// `complete` is derived from a row fetched BEYOND the page, never from the page's length — + /// the mock is deliberately the shape a conforming node must have, because a mock that got this + /// wrong would let a KAT asserting the distinction pass against a fixture that cannot show it. async fn wallet_coins( &self, params: WalletCoinsParams, ) -> Result { - Ok(results::WalletCoinsResult { - coins: vec![results::WalletCoinRecord { - coin_id: params.address, + let amount = match params.asset { + Asset::Xch => 1, + a if a.is_dig() => 2, + Asset::Cat(_) => 3, + }; + let remaining = ADDRESS_COINS + .iter() + .skip_while(|id| params.after_coin_id.as_deref().is_some_and(|a| **id <= a)); + let limit = params.effective_limit() as usize; + let page: Vec<&str> = remaining.take(limit + 1).copied().collect(); + let complete = page.len() <= limit; + let coins: Vec = page + .into_iter() + .take(limit) + .map(|coin_id| results::WalletCoinRecord { + coin_id: coin_id.into(), asset: Some(params.asset), - amount: match params.asset { - Asset::Xch => 1, - a if a.is_dig() => 2, - Asset::Cat(_) => 3, - }, + amount, parent_coin_info: "11".repeat(32), - puzzle_hash: "22".repeat(32), + puzzle_hash: params.address.clone(), created_height: Some(5_000_000), spent_height: None, - }], + }) + .collect(); + Ok(results::WalletCoinsResult { + cursor: coins.last().map(|c| c.coin_id.clone()), + coins, + complete: Some(complete), source: Some(results::WalletReadSource::Db), synced: true, peak_height: Some(5_000_000), @@ -2364,12 +2393,12 @@ fn minimal_params(m: ControlMethod) -> Value { /// rather than passing on a shape both happen to share. #[test] fn the_dispatcher_routes_each_wallet_chain_method_to_its_own_handler() { - let coins = round_trip(&WalletCoinsParams { - address: "xch1mintfunder".into(), - asset: Asset::DIG, - }) - .expect("coins must route"); - assert_eq!(coins.coins[0].coin_id, "xch1mintfunder"); + let coins = round_trip(&WalletCoinsParams::first_page("xch1mintfunder", Asset::DIG)) + .expect("coins must route"); + assert_eq!( + coins.coins[0].puzzle_hash, "xch1mintfunder", + "the record echoes the ADDRESS it was asked for, so a mis-routed dispatch cannot pass" + ); assert_eq!( coins.coins[0].asset, Some(Asset::DIG), @@ -2712,6 +2741,168 @@ fn wallet_coins_by_parent_params_enforce_the_coin_id_rule_on_their_own_field() { ); } +/// **The coin read's truncated page and its final page carry the SAME number of rows.** +/// +/// Four coins read two at a time, so both pages hold exactly two records and only one of them is +/// the last. Every length-based inference — `coins.len() < limit`, `coins.is_empty()`, "a full page +/// means more" — gives the same answer for both, so this is the fixture that makes `complete` +/// load-bearing rather than decorative on THIS read. +/// +/// The dangerous direction is asserted first, and it is worse here than on `coinsByParent`: reading +/// page one as complete presents a partial coin set as the whole one, and a spend selected from it +/// refuses with a shortfall that is not true while the funds are sitting in the coins that were +/// withheld. +#[test] +fn the_coin_page_and_the_final_page_are_told_apart_by_complete_not_by_length() { + let first = round_trip(&WalletCoinsParams { + address: "xch1funded".into(), + asset: Asset::DIG, + after_coin_id: None, + limit: Some(2), + }) + .expect("a bounded first page must route"); + + assert_eq!(first.coins.len(), 2); + assert_eq!( + first.complete, + Some(false), + "two of four coins were withheld -- reporting this page as complete presents a partial \ + coin set as the whole one" + ); + assert_eq!( + first.cursor.as_deref(), + Some(ADDRESS_COINS[1]), + "the cursor is the last coin actually HANDED over, never a chain-head marker" + ); + + let second = round_trip(&WalletCoinsParams { + address: "xch1funded".into(), + asset: Asset::DIG, + after_coin_id: first.cursor.clone(), + limit: Some(2), + }) + .expect("resuming from the handed-back cursor must route"); + + assert_eq!( + second.coins.len(), + first.coins.len(), + "both pages carry the same row count -- which is exactly why length cannot decide \ + completeness" + ); + assert_eq!( + second.complete, + Some(true), + "the last two coins fit, so this page IS the end of the set" + ); + + // Nothing repeated, nothing skipped, ascending -- so a handler that ignored `after_coin_id` + // and re-served page one fails HERE rather than passing on a shape both pages share. + let walked: Vec<&str> = first + .coins + .iter() + .chain(second.coins.iter()) + .map(|c| c.coin_id.as_str()) + .collect(); + assert_eq!(walked, ADDRESS_COINS.to_vec()); +} + +/// **The coin read's page bound is refused out of range, and pinned from BOTH sides.** +/// +/// A bound tested only from below can only confirm itself, so the at-maximum case MUST be accepted +/// and the one-over case MUST be refused. Zero is refused for a different reason than "too large": +/// a page that can hold nothing makes no progress, so a caller looping until a short page arrives +/// would loop forever. +#[test] +fn the_coin_page_bound_is_refused_out_of_range_rather_than_clamped() { + let at_max = serde_json::from_value::(json!({ + "address": "xch1funded", "asset": "xch", "limit": COINS_MAX_LIMIT + })) + .expect("the documented maximum must be ACCEPTED, or the constant is not the real bound"); + assert_eq!(at_max.effective_limit(), COINS_MAX_LIMIT); + + for over in [COINS_MAX_LIMIT + 1, u32::MAX, 0] { + let wire = json!({"address": "xch1funded", "asset": "xch", "limit": over}); + assert!( + serde_json::from_value::(wire).is_err(), + "limit {over} must be REFUSED, never clamped: a silently shrunk page hands back a \ + cursor for a position the caller never asked about" + ); + assert!(WalletCoinsParams { + address: "xch1funded".into(), + asset: Asset::Xch, + after_coin_id: None, + limit: Some(over), + } + .validated() + .is_err()); + } + + // An omitted limit resolves in ONE place, so a node and a client cannot page to two different + // boundaries. + assert_eq!( + WalletCoinsParams::first_page("xch1funded", Asset::Xch).effective_limit(), + COINS_DEFAULT_LIMIT + ); + + // A cursor is held to the same id rule every by-coin read uses, at BOTH seams. + assert!(serde_json::from_value::( + json!({"address": "xch1funded", "asset": "xch", "after_coin_id": "AB".repeat(32)}) + ) + .is_err()); + let prefixed = serde_json::from_value::( + json!({"address": "xch1funded", "asset": "xch", "after_coin_id": format!("0x{}", "ab".repeat(32))}), + ) + .expect("an 0x-prefixed cursor is tolerated on input"); + assert_eq!(prefixed.after_coin_id.as_deref(), Some(&*"ab".repeat(32))); +} + +/// **A pre-0.25 node's UNPAGED answer decodes, and is distinguishable from a truncated page.** +/// +/// `control.wallet.coins` shipped before it was paged, so an older node emits neither key. This is +/// the one place the two absent-field readings differ in consequence, and the safe one is not the +/// one serde would pick by itself: +/// +/// * as `Some(false)` — "truncated, resume from the cursor" — a caller resumes from a `null` cursor, +/// is re-served page one by a node that ignores `after_coin_id`, and walks forever; +/// * as `None` — "this node does not page, so this IS everything" — which is the truth about such a +/// node's answer. +/// +/// A new node NEVER emits `None`, so the two cases can never be confused in the other direction. +#[test] +fn an_unpaged_answer_from_an_older_node_is_not_read_as_a_truncated_page() { + let legacy = serde_json::from_value::(json!({ + "coins": [{ + "coin_id": "aa".repeat(32), "asset": "xch", "amount": 1_750_000_000_000u64, + "parent_coin_info": "bb".repeat(32), "puzzle_hash": "cc".repeat(32), + "created_height": 5_000_000u32, "spent_height": null + }], + "source": "db", "synced": true, "peak_height": 5_000_000u32 + })) + .expect("a pre-0.25 node's answer must still decode"); + assert_eq!( + legacy.complete, None, + "an absent `complete` is UNDISCLOSED -- reading it as `Some(false)` sends a caller \ + resuming into a node that never paged" + ); + assert_eq!(legacy.cursor, None); + assert_eq!(legacy.coins.len(), 1, "the coins themselves still decode"); + + // ...and a node that DOES page says so explicitly, so the two are told apart by value and not + // by convention. + let truncated = round_trip(&WalletCoinsParams { + address: "xch1funded".into(), + asset: Asset::DIG, + after_coin_id: None, + limit: Some(2), + }) + .expect("a paged first page must route"); + assert_eq!(truncated.complete, Some(false)); + assert_ne!( + truncated.complete, legacy.complete, + "undisclosed and truncated MUST NOT be the same value" + ); +} + /// **A truncated page and a final page can carry the SAME number of rows.** /// /// The fixture is four children read two at a time, so both pages hold exactly two records and only @@ -2971,6 +3162,8 @@ fn dig_apps_frozen_engine_shapes_deserialize_our_wallet_results() { created_height: Some(5_000_000), spent_height: None, }], + complete: Some(true), + cursor: Some("aa".repeat(32)), source: Some(results::WalletReadSource::Db), synced: true, peak_height: Some(5_000_000), @@ -3026,6 +3219,8 @@ fn dig_apps_frozen_coin_shape_rejects_null_asset_in_wallet_coins() { created_height: Some(5_000_000), spent_height: None, }], + complete: Some(true), + cursor: Some("aa".repeat(32)), source: Some(results::WalletReadSource::Db), synced: true, peak_height: Some(5_000_000), diff --git a/src/params.rs b/src/params.rs index 585dbba..ddc779e 100644 --- a/src/params.rs +++ b/src/params.rs @@ -584,17 +584,51 @@ pub struct WalletBalanceParams { } control_call!(WalletBalanceParams => ControlMethod::WalletBalance, results::WalletBalanceResult); -/// `control.wallet.coins` params: which address + asset to read spendable coins for. +/// `control.wallet.coins` params: which address + asset to read spendable coins for, and which +/// PAGE of them. /// -/// Field-for-field identical to [`WalletBalanceParams`] — a balance is this read reduced to a sum — -/// and byte-identical to dig-app's frozen `CoinsRequest`, so adopting the method is a body swap -/// rather than a re-shape. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// The address + asset pair is byte-identical to dig-app's frozen `CoinsRequest` and to +/// [`WalletBalanceParams`] — a balance is this read reduced to a sum — so the paging fields are +/// purely additive and a caller that names neither asks exactly what it asked before. +/// +/// # Bounded, because an address's coin count is not (dig-node#381) +/// +/// A funded address accumulates coins without limit, and every change coin a spend produces adds +/// one. An unpaged read therefore has unbounded cardinality on the same loopback control plane that +/// has NO request rate limiting of any kind (dig_ecosystem#2577) — the identical exposure +/// [`WalletCoinsByParentParams`] documents at length, for the identical reason, and on the fallback +/// tier the work lands on a third-party coinset oracle rather than on this node. +/// +/// Paged rather than capped, for the reason its sibling records: a bare cap is a dead end, because +/// an address holding more coins than the cap could never be fully enumerated, and this read exists +/// so a caller can BUILD A SPEND from the coins it names. A spend built from a silently truncated +/// coin set refuses with a shortfall that is not true. +/// +/// # The paging rules are the sibling's rules, deliberately +/// +/// ASCENDING `coin_id`, a cursor the caller was HANDED rather than an offset, and an out-of-range +/// `limit` REFUSED rather than clamped. See [`WalletCoinsByParentParams`] for why each of those is +/// the money-safe choice; a second set of paging semantics on the same plane would be a place for +/// the two to disagree. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct WalletCoinsParams { /// The `xch1…` address to read coins for. pub address: String, /// The asset to read coins for. pub asset: Asset, + /// Resume STRICTLY AFTER this coin, in ascending `coin_id` order. `None` starts at the first. + /// + /// This is the value the previous page handed back as + /// [`cursor`](results::WalletCoinsResult::cursor) — never a value the caller invented, and never + /// a marker for where the chain got to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after_coin_id: Option, + /// The page size. `None` asks for [`COINS_DEFAULT_LIMIT`]. + /// + /// A value above [`COINS_MAX_LIMIT`], or a zero, is REFUSED as `INVALID_PARAMS` rather than + /// clamped — see [`Self::validated`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, } control_call!(WalletCoinsParams => ControlMethod::WalletCoins, results::WalletCoinsResult); @@ -840,7 +874,119 @@ pub const COINS_BY_PARENT_DEFAULT_LIMIT: u32 = 100; /// answer is undeliverable — the failure would surface as a truncated frame, not as a refusal. pub const COINS_BY_PARENT_MAX_LIMIT: u32 = 1_000; -const COINS_BY_PARENT_LIMIT_ERROR: &str = "limit must be between 1 and 1000"; +/// The page bound message BOTH paged coin reads refuse with. +/// +/// Shared rather than duplicated: the two reads page the same record type over the same frame, so +/// two copies of this sentence could only ever differ by drifting apart. +const PAGE_LIMIT_ERROR: &str = "limit must be between 1 and 1000"; + +/// The page size `control.wallet.coins` uses when the caller names none (dig-node#381). +/// +/// Pinned TO its sibling rather than restated. Both reads page the same +/// [`WalletCoinRecord`](results::WalletCoinRecord) over the same transport frame, so the derivation +/// that fixes one fixes the other, and a second literal here would be a second thing to forget when +/// the frame limit moves. +pub const COINS_DEFAULT_LIMIT: u32 = COINS_BY_PARENT_DEFAULT_LIMIT; + +/// The largest page `control.wallet.coins` will accept — the same frame-derived ceiling +/// [`COINS_BY_PARENT_MAX_LIMIT`] documents the arithmetic for, and pinned to it for the reason +/// [`COINS_DEFAULT_LIMIT`] gives. +pub const COINS_MAX_LIMIT: u32 = COINS_BY_PARENT_MAX_LIMIT; + +impl<'de> Deserialize<'de> for WalletCoinsParams { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct RawWalletCoinsParams { + address: String, + asset: Asset, + #[serde(default)] + after_coin_id: Option, + #[serde(default)] + limit: Option, + } + + let raw = RawWalletCoinsParams::deserialize(deserializer)?; + let after_coin_id = raw + .after_coin_id + .map(|id| { + normalize_coin_id(&id) + .map(str::to_owned) + .ok_or_else(|| serde::de::Error::custom(AFTER_COIN_ID_ERROR)) + }) + .transpose()?; + if !raw.limit.map_or(true, is_legal_page) { + return Err(serde::de::Error::custom(PAGE_LIMIT_ERROR)); + } + Ok(Self { + address: raw.address, + asset: raw.asset, + after_coin_id, + limit: raw.limit, + }) + } +} + +impl WalletCoinsParams { + /// A first page of coins at one address for one asset: the node's default size, from the start. + /// + /// The common case, and the one a caller should not have to spell out — naming a page size means + /// asserting a number this caller invented over the one the contract chose. + pub fn first_page(address: impl Into, asset: Asset) -> Self { + Self { + address: address.into(), + asset, + after_coin_id: None, + limit: None, + } + } + + /// The page size this request asks for, resolving `None` to [`COINS_DEFAULT_LIMIT`]. + /// + /// Stated once here 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 exactly where a paged walk loses rows. On a coin read a lost row is a coin the + /// caller cannot spend. + pub fn effective_limit(&self) -> u32 { + self.limit.unwrap_or(COINS_DEFAULT_LIMIT) + } + + /// Normalize the cursor and check the page bound, or reject as `-32602 INVALID_PARAMS`. + /// + /// The address is NOT validated here: it is decoded by the node's own bech32m reader, which is + /// the only thing that can tell a well-formed address from a well-formed string, and this crate + /// has never claimed otherwise for [`WalletBalanceParams`] either. + /// + /// An out-of-range `limit` is REFUSED, never clamped, for the reason + /// [`WalletCoinsByParentParams::validated`] states: a silently shrunk page hands back a cursor + /// for a position the caller did not ask about. + pub fn validated(self) -> Result { + fn invalid(message: &'static str) -> crate::error::ControlError { + crate::error::ControlError::of(crate::error::ControlErrorCode::InvalidParams, message) + } + + let after_coin_id = self + .after_coin_id + .as_deref() + .map(|id| { + normalize_coin_id(id) + .map(str::to_owned) + .ok_or_else(|| invalid(AFTER_COIN_ID_ERROR)) + }) + .transpose()?; + if !self.limit.map_or(true, is_legal_page) { + return Err(invalid(PAGE_LIMIT_ERROR)); + } + Ok(WalletCoinsParams { + address: self.address, + asset: self.asset, + after_coin_id, + limit: self.limit, + }) + } +} impl<'de> Deserialize<'de> for WalletCoinsByParentParams { fn deserialize(deserializer: D) -> Result @@ -869,7 +1015,7 @@ impl<'de> Deserialize<'de> for WalletCoinsByParentParams { }) .transpose()?; if !raw.limit.map_or(true, is_legal_page) { - return Err(serde::de::Error::custom(COINS_BY_PARENT_LIMIT_ERROR)); + return Err(serde::de::Error::custom(PAGE_LIMIT_ERROR)); } Ok(Self { parent_coin_id, @@ -938,7 +1084,7 @@ impl WalletCoinsByParentParams { }) .transpose()?; if !self.limit.map_or(true, is_legal_page) { - return Err(invalid(COINS_BY_PARENT_LIMIT_ERROR)); + return Err(invalid(PAGE_LIMIT_ERROR)); } Ok(WalletCoinsByParentParams { parent_coin_id, @@ -1645,11 +1791,7 @@ mod tests { json!({ "address": "xch1exampleaddr", "asset": { "cat": OTHER_CAT_HEX } }) ); assert_eq!( - serde_json::to_value(WalletCoinsParams { - address: "xch1exampleaddr".into(), - asset: cat, - }) - .unwrap(), + serde_json::to_value(WalletCoinsParams::first_page("xch1exampleaddr", cat)).unwrap(), json!({ "address": "xch1exampleaddr", "asset": { "cat": OTHER_CAT_HEX } }) ); } diff --git a/src/results.rs b/src/results.rs index ef5d28a..a2a2a3f 100644 --- a/src/results.rs +++ b/src/results.rs @@ -780,10 +780,53 @@ pub struct WalletCoinRecord { /// `WalletReadFailed` / `WalletRateLimited`). The distinction is the whole point of the method — /// a well-shaped empty result on an unreachable chain would tell somebody who holds funds that they /// hold nothing, and a spend built on that answer refuses with a shortfall that is not true. +/// +/// # The order is part of the contract, because paging is meaningless without one +/// +/// A node MUST return coins in ASCENDING `coin_id` order and MUST keep that order stable across the +/// pages of one walk; +/// [`after_coin_id`](crate::params::WalletCoinsParams::after_coin_id) means *strictly after this id +/// in that order*. Coin ids are fixed-length lowercase hex, so ascending lexicographic order and +/// ascending 32-byte numeric order are the SAME order and cannot disagree. +/// +/// The order is what makes the boundary survive a CHANGING coin set, which is the case that matters +/// here and does not arise for `coinsByParent`: a spent coin drops out of an address's unspent set +/// between two pages. Against a cursor, the rows before the boundary are simply gone and every row +/// after it still follows the cursor. Against an OFFSET, every remaining row shifts one position +/// earlier and the next page silently begins one row late — a coin the caller never sees, which on +/// this read means funds it cannot spend and a spend that refuses with an untrue shortfall. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WalletCoinsResult { - /// The spendable coins found at the address, possibly empty (see the type docs). + /// One page of the address's spendable coins, ascending by `coin_id`, possibly empty (see the + /// type docs). NOT necessarily the whole set — see [`complete`](Self::complete). pub coins: Vec, + /// Is this page the WHOLE unspent set at this address for this asset? + /// + /// `Some(true)` means every coin the node knows of is in [`coins`](Self::coins). `Some(false)` + /// means the answer was TRUNCATED and more coins exist — resume from [`cursor`](Self::cursor). + /// + /// A node MUST derive this from whether rows remain BEYOND the page, never from whether the page + /// filled. The two differ exactly when the coin count is a multiple of the page size, where the + /// length-based reading declares a truncated page whole — so a caller summing a balance or + /// selecting coins for a spend stops early on a set it believes it saw all of. + /// + /// `None` means a node too old to disclose it (pre-0.25), which served this read UNPAGED and + /// whose answer is therefore the whole set already. It is distinct from `Some(false)` on + /// purpose: such a node also ignores `after_coin_id`, so a caller that read `None` as + /// "truncated" and resumed would be re-served page one forever. + #[serde(default)] + pub complete: Option, + /// The last coin in this page — **the value to resume from** — or `null` for an empty page, and + /// from a pre-0.25 node that never paged at all. + /// + /// It is the id the caller was HANDED, never a marker for where the chain got to. Pass it as + /// [`after_coin_id`](crate::params::WalletCoinsParams::after_coin_id) to fetch the next page. + /// + /// Unlike its `coinsByParent` twin the key is OMITTABLE, because this method predates paging and + /// an older node emits no such key. [`complete`](Self::complete) is what carries the + /// old-node case, and reading this field without it is what the doc above warns against. + #[serde(default)] + pub cursor: Option, /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`]. pub source: Option, /// Whether THESE coins reflect a caught-up local view; always `false` for a fallback answer. From f59d453a0db25e35fa67caef4761f9c8e0718105 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 29 Aug 2026 19:23:43 -0700 Subject: [PATCH 2/2] docs(spec): state the coins read's paging rules normatively Co-Authored-By: Claude --- Cargo.lock | 2 +- SPEC.md | 43 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 87179ea..b0dfe34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.24.0" +version = "0.25.0" dependencies = [ "async-trait", "futures", diff --git a/SPEC.md b/SPEC.md index cc1fea2..4f7d929 100644 --- a/SPEC.md +++ b/SPEC.md @@ -113,7 +113,7 @@ master token specifically; `Routing` = how the node resolves it (`owned` by the | `control.unsubscribe` | yes | delegated | `{store_id:string}` | `{subscribed, removed, store_id}` | | `control.listSubscriptions` | yes | delegated | — | `{subscriptions:[string], count}` | | `control.wallet.balance` | no | delegated | `{address:string, asset:Asset}` | `{balance, pending, source, synced, peak_height}` | -| `control.wallet.coins` | no | delegated | `{address:string, asset:Asset}` | `WalletCoinsResult` | +| `control.wallet.coins` | no | delegated | `{address:string, asset:Asset, after_coin_id?:string, limit?:u32}` | `WalletCoinsResult` | | `control.wallet.coinById` | no | delegated | `{coin_id:string}` | `WalletCoinByIdResult` | | `control.wallet.coinSpend` | no | delegated | `{coin_id:string}` | `WalletCoinSpendResult` | | `control.wallet.coinsByParent` | no | delegated | `{parent_coin_id:string, after_coin_id?:string, limit?:u32}` | `WalletCoinsByParentResult` | @@ -238,10 +238,43 @@ opposite remedies — see §4.2. Consumers MUST model this as a two-case type — native XCH, or a CAT identified by asset id — with $DIG a named constant of the CAT case rather than a third case. -- **`WalletCoinsResult`**: `{coins:[WalletCoinRecord], source:"db"|"fallback"|null, synced:bool, - peak_height:u32|null}`. `source`/`synced`/`peak_height` carry exactly the meanings defined for - `WalletBalanceResult` below. `coins` MUST list the address's spendable coins for the requested - asset (XCH coins sit AT the puzzle hash; CAT coins are HINTED to it). +- **`WalletCoinsResult`**: `{coins:[WalletCoinRecord], complete:bool|null, cursor:string|null, + source:"db"|"fallback"|null, synced:bool, peak_height:u32|null}`. + `source`/`synced`/`peak_height` carry exactly the meanings defined for `WalletBalanceResult` + below. `coins` MUST list ONE PAGE of the address's spendable coins for the requested asset (XCH + coins sit AT the puzzle hash; CAT coins are HINTED to it). + + The read is PAGED. A node MUST return coins in ASCENDING `coin_id` order and MUST keep that order + stable across the pages of one walk; `after_coin_id` means strictly after that id in that order. + A node MUST NOT page by OFFSET. An address's unspent set SHRINKS as coins are spent, so against an + offset every row after a departed coin moves one position earlier and the next page begins one row + late — a coin the caller never sees, on the read whose whole purpose is selecting coins for a + spend. Against a cursor the rows before the boundary are simply gone and every row after it still + follows the cursor. + + `complete` MUST state whether the page carries the LAST coin, derived from whether rows remain + BEYOND the page. A node MUST NOT report `complete:true` on a page it truncated, and MUST NOT + derive it from the page LENGTH: a coin count that is an exact multiple of the page size makes the + final full page indistinguishable from a truncated one. A caller that stops early on that page + selects a spend from a coin set it believes it saw all of, and refuses with a shortfall that is + not true while the funds sit in the coins that were withheld. + + `complete:null` MUST mean the node is too old to page this read (pre-0.25) and therefore returned + the WHOLE set. A caller MUST NOT read it as `false`: such a node also ignores `after_coin_id`, so + a caller that resumed would be re-served the first page indefinitely. A node that pages MUST emit + a concrete boolean, so the two are told apart by value and never by convention. + + `cursor` MUST be the `coin_id` of the LAST record actually returned, `null` for an empty page, and + `null` from a node that does not page. A caller resumes by passing it as `after_coin_id`. + + `limit` MUST be refused as `-32602 INVALID_PARAMS` outside `1..=1000` rather than clamped, for the + reason `control.wallet.coinsByParent` states: a silently shrunk page hands back a cursor for a + position the caller did not ask about. The bound is the same frame-derived ceiling, because both + reads page the same `WalletCoinRecord` over the same transport frame. An omitted `limit` MUST + resolve to 100. + + Both paging parameters are OPTIONAL and a request naming neither MUST be byte-identical to the + pre-0.25 request, so adopting the paged form is additive on both sides of the wire. `coins:[]` MUST mean the node consulted a chain and the address holds nothing. A node that could NOT consult a chain MUST return the matching §5 wallet error instead — never an empty list. This