From d002bb3b5cf23d31488a5eb6a80159ff9d981cf0 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 11:15:03 -0700 Subject: [PATCH] feat(wallet): declare control.wallet.operatorAddress, the node's own MACHINE wallet A node has an operator wallet of its own -- machine-custody, autoseed-derived -- and it is the wallet that pays mirror-coin collateral. Nothing named it, and the cost of that was measured: a node reported three mirror bonds `unfunded, short 1010` while the operator's own wallet held 1,015,000 base units of $DIG. Both statements were true and each was about a different wallet, and no surface let a person tell which. Somebody wanting to FUND the machine wallet had no way to obtain its address. Four properties, each of them the reason a wrong version would be worse than no method at all: - It returns a DESTINATION and nothing that can spend from it. An address and its puzzle hash, both public in the sense a coin id is. No seed, mnemonic, private or extended key, or derivation material, in any encoding (908). The node signs its own mirror spends and no key leaves it; a method exporting one would move the node from machine custody to none. - TOKEN-GATED, by the same rule as `control.wallet.arrivals`: the caller does not name the address, so the node volunteers its own node-to-address association, which no open read discloses. - OWNED, never delegated. Forwarding it upstream answers with another machine's address under field names that still say "operator" -- a confident wrong answer, and exactly the confusion the method exists to end. - `unavailable { reason }` rather than a blank or placeholder address, and no mutation on read. SPEC.md and README.md carry it in the same change; the existing catalogue guards proved load-bearing, failing on both until they did. Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 1 + SPEC.md | 30 +++++++++++++ src/kats.rs | 114 +++++++++++++++++++++++++++++++++++++++++++++++++ src/method.rs | 14 +++++- src/params.rs | 10 +++++ src/results.rs | 74 ++++++++++++++++++++++++++++++++ src/traits.rs | 21 +++++++++ 9 files changed, 264 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 368221b..5425105 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.28.0" +version = "0.29.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index 54c4b28..cb956dc 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.28.0" +version = "0.29.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" diff --git a/README.md b/README.md index 5c7ed8b..5062e53 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ and pushes bytes somebody else signed. | `control.wallet.coinSpend` | — | del | `{coin_id:string}` (64-hex, `0x` accepted) | `{spend:{coin,puzzle_reveal,solution}\|null, source, synced, peak_height}`; `spend:null` = the chain holds no spend of that coin (unspent, or unknown) — NEVER "could not answer", which is an error. `puzzle_reveal` MUST tree-hash to the coin's `puzzle_hash` | | `control.wallet.coinsByParent` | — | del | `{parent_coin_id:string, after_coin_id?:string, limit?:u32}` (ids 64-hex, `0x` accepted; `limit` 1..=1000, default 100, REFUSED out of range — never clamped) | `{coins:[WalletCoinRecord], complete:bool, cursor:string\|null, source, synced, peak_height}`; ONE PAGE of the parent's DIRECT children, ascending by `coin_id`, ONE hop, never a recursive walk. `complete:false` = TRUNCATED, resume from `cursor` — never infer completeness from the page length. `coins:[]` = no known children, never an unreachable chain | | `control.wallet.arrivals` | T | del | `{after_seq:u64=0, limit?:u32}` | `{arrivals:[WalletArrivalRecord], cursor, latest}`; TOKEN-GATED: the caller supplies only a cursor, so the answer names this node's own watched puzzle hashes. CONFIRMED incoming funds only, never the wallet's own change. Resume from `cursor`, never `latest` | +| `control.wallet.operatorAddress` | T | own | — | `{state:"known", address, puzzle_hash}` \| `{state:"unavailable", reason:"not_initialized"\|"unreadable"}`; the node's OWN MACHINE-custody wallet — the one that pays mirror collateral — never the user's. A DESTINATION only: no seed, key or derivation material, ever (§908). TOKEN-GATED because the caller does not name the address, so the node volunteers its own; OWNED because forwarding it upstream would answer with another machine's wallet under field names that still say "operator" | | `control.wallet.peak` | — | del | — | `{peak_height:u32\|null, synced:bool}`; `synced` = catch-up COMPLETED, weaker than `syncStatus`'s | | `control.wallet.syncStatus` | — | del | — | `{phase:"not_started"\|"syncing"\|"synced"\|"no_wallet_enrolled"\|"wallet_not_unlocked", peak_height:u32\|null, chia_peer_count:u32\|null, watched_addresses:u32\|null, subscription_peer_count:u32\|null, chia_peer_peak_height:u32\|null}`; `synced` also requires a LIVE Chia peer. `no_wallet_enrolled` = nothing to watch and that is correct; `wallet_not_unlocked` = a wallet EXISTS and nothing is watching it — NEVER render it as settled. `subscription_peer_count` is the replica's 0-or-1 supervisor-fed peer, NOT `chia_peer_count` and MUST NOT be summed with it; `chia_peer_peak_height` is the height this node's own Chia peers announced, distinct from `peak_height`. Both are optional, absent on older nodes. An UNKNOWN token parses to `Unrecognized(token)`, never an error | | `control.wallet.broadcast` | T | del | `{signed_bundle_hex:string}` | `{accepted, transaction_id, rejection}`; `accepted` = mempool admission, NOT confirmation | diff --git a/SPEC.md b/SPEC.md index 2332cb7..cbd184d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -118,6 +118,7 @@ master token specifically; `Routing` = how the node resolves it (`owned` by the | `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` | | `control.wallet.arrivals` | yes | delegated | `{after_seq:u64=0, limit?:u32}` | `WalletArrivalsResult` | +| `control.wallet.operatorAddress` | yes | owned | — | `{state:"known", address, puzzle_hash}` \| `{state:"unavailable", reason:"not_initialized"\|"unreadable"}` | | `control.wallet.peak` | no | delegated | — | `{peak_height:u32\|null, synced:bool}` | | `control.wallet.syncStatus` | no | delegated | — | `{phase:"not_started"\|"syncing"\|"synced"\|"no_wallet_enrolled"\|"wallet_not_unlocked", peak_height:u32\|null, chia_peer_count:u32\|null, watched_addresses:u32\|null, subscription_peer_count:u32\|null, chia_peer_peak_height:u32\|null}` | | `control.wallet.broadcast` | yes | delegated | `{signed_bundle_hex:string}` | `WalletBroadcastResult` | @@ -1091,6 +1092,35 @@ when it is not — WHY, together with the $DIG those bonds have locked. It is th `SPEC.md` §25.8 requires, and it is served over the control plane before the node adopts it (release-first). +### `control.wallet.operatorAddress` — which wallet is the MACHINE's + +The node has an operator wallet of its own, machine-custody and autoseed-derived, and it is the +wallet that pays mirror-coin collateral. It is NOT the user's wallet. This method names it. + +It exists because nothing named it, and the cost of that was measured: a node reported three mirror +bonds `unfunded, short 1010` while the operator's own wallet held 1,015,000 base units of $DIG. Both +statements were true and each was about a different wallet, and no surface anywhere let a person tell +which. An operator who wants to FUND the machine wallet needs its address, and had no way to obtain +one. + +The method MUST hold four properties: + +* **It returns a DESTINATION and nothing that can spend from it.** An address and its puzzle hash, + both public in the sense a coin id is. A seed, mnemonic, private or extended key, or derivation + material MUST NOT appear in the answer in any encoding. The node signs its own mirror spends and no + key leaves it (§908); a method exporting one would move the node from machine custody to none. +* **It is TOKEN-GATED**, by the same rule as `control.wallet.arrivals`: the CALLER does not name the + address, so the node volunteers its own node-to-address association. That mapping is not public, + and a stranger able to ask any node for it learns something no open read discloses. +* **It is OWNED, never delegated.** The question is which wallet THIS node spends from. Forwarding it + upstream returns another machine's address under field names that still say "operator" — a + confident wrong answer, and precisely the confusion the method exists to end. +* **It answers `unavailable` with a reason rather than a blank or placeholder address**, and it + performs no mutation: reading it MUST NOT create, initialise, unseal-and-cache or rotate a wallet. + A node that has none answers `not_initialized`, which is not a fault; a node whose wallet is + present and unreadable answers `unreadable`, which is one, and such a node cannot pay collateral + either. + **Its whole purpose is that "no coin yet" is never one answer.** Seven of the eight states mean there is no current-epoch coin, and each calls for a different response from a person. A client MUST NOT collapse any two of them; conflating "out of funds" with "withheld on purpose" produces hourly funding diff --git a/src/kats.rs b/src/kats.rs index 6c9850e..25cad1f 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -1471,6 +1471,16 @@ impl ControlHandler for MockNode { synced: true, }) } + /// Answers a DISTINCT address from every other fixture value in this file, so a dispatch arm + /// wired to the wrong wallet read is distinguishable here rather than agreeing by accident. + async fn wallet_operator_address( + &self, + ) -> Result { + Ok(results::WalletOperatorAddressResult::Known { + address: OPERATOR_ADDRESS.into(), + puzzle_hash: "7c".repeat(32), + }) + } /// Reports three DIFFERENT counts, so a handler that answered one number twice — or a dispatch /// arm wired to the wallet's sync status, whose chia count this deliberately matches — is /// distinguishable from a correct one by the DIG counts alone. The known count is unequal to @@ -4891,6 +4901,106 @@ fn every_collateral_unknown_reason_is_listed_in_all() { /// `balance_unreadable` token beside it. The nearest wrong implementation reuses one of the four /// census/record/chain reasons, and it is distinguishable here only because the token itself is /// asserted — a test that merely round-tripped the value would pass against every one of them. +/// **The operator-address method is TOKEN-GATED and OWNED, and both halves are the contract.** +/// +/// Neither property is cosmetic and each has a distinct wrong version. +/// +/// If it were an OPEN read, any stranger who can reach the control plane could map this node to a +/// chain identity -- a linkage no other open read discloses. +/// +/// If it were DELEGATED it would be forwarded to the upstream node, and the caller would be handed +/// **a different machine's address** while every field name still said "operator". That is not a +/// degraded answer; it is a confident wrong one, and somebody funding "the machine wallet" from it +/// would send money to a machine they do not run. The method exists precisely because two wallets +/// were indistinguishable, so an answer about the wrong wallet is the defect reintroduced. +/// +/// Asserted against the two neighbouring wallet reads rather than in isolation: `WalletPeak` is +/// open and delegated, so the controls prove these assertions can fail, and a blanket change to the +/// wallet category cannot make this test vacuous. +#[test] +fn the_operator_address_is_token_gated_and_answered_by_this_node() { + assert!( + ControlMethod::WalletOperatorAddress.requires_auth(), + "a stranger must not be able to map this node to a chain identity" + ); + assert!(!ControlMethod::WalletOperatorAddress.is_open_read()); + assert_eq!( + ControlMethod::WalletOperatorAddress.routing(), + crate::method::Routing::Owned, + "forwarding this upstream would answer with ANOTHER machine's wallet address" + ); + + // The controls: a neighbouring wallet read that is genuinely open and genuinely delegated, so + // neither assertion above can be satisfied by every method having those properties. + assert!(ControlMethod::WalletPeak.is_open_read()); + assert_eq!( + ControlMethod::WalletPeak.routing(), + crate::method::Routing::Delegated + ); + + assert_eq!( + ControlMethod::WalletOperatorAddress.name(), + "control.wallet.operatorAddress" + ); + assert_eq!( + ControlMethod::from_name("control.wallet.operatorAddress"), + Some(ControlMethod::WalletOperatorAddress), + "a variant missing from ControlMethod::ALL is unreachable by name" + ); +} + +/// **The operator-address answer carries a destination and NOTHING that could spend it (\u00a7908).** +/// +/// The wire form is asserted as EXACT BYTES rather than round-tripped, because round-tripping is +/// satisfied by any struct that agrees with itself -- including one that grew a `seed`, a +/// `mnemonic` or a `derivation_path` field. `assert_eq!` on the whole JSON object fails the moment +/// a field is added, which is the only mutation this test needs to catch and the one that matters. +/// +/// The unavailable arm is pinned beside it because the wrong implementation there is not a leak but +/// a fabrication: answering `{"address": ""}` for a node with no wallet renders a blank destination +/// on a funding screen, and a placeholder address renders a real-looking one. Both are money +/// statements. The tagged `unavailable` form cannot be mistaken for either. +#[test] +fn the_operator_address_result_carries_a_destination_and_no_spending_material() { + let known = results::WalletOperatorAddressResult::Known { + address: "xch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsjqfwvy".into(), + puzzle_hash: "00".repeat(32), + }; + assert_eq!( + serde_json::to_value(&known).unwrap(), + serde_json::json!({ + "state": "known", + "address": "xch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsjqfwvy", + "puzzle_hash": "00".repeat(32), + }), + "exactly two fields: where money goes, and the same place as a puzzle hash" + ); + + for (reason, token) in [ + ( + results::WalletOperatorAddressUnavailableReason::NotInitialized, + "not_initialized", + ), + ( + results::WalletOperatorAddressUnavailableReason::Unreadable, + "unreadable", + ), + ] { + let unavailable = results::WalletOperatorAddressResult::Unavailable { reason }; + let wire = serde_json::to_value(&unavailable).unwrap(); + assert_eq!( + wire, + serde_json::json!({ "state": "unavailable", "reason": token }) + ); + assert!( + wire.get("address").is_none(), + "an unavailable answer must not carry an address field at all -- a blank or \ + placeholder destination is a money statement" + ); + assert_result_round_trips::(wire); + } +} + /// **A node with nothing publishable to advertise is `unadvertised`, and that is NOT `disabled`.** /// /// A node whose advertise-URL list is empty (or whose every entry was rejected) creates no mirror @@ -5318,6 +5428,10 @@ const BOND_STORE_B: &str = "a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2 /// A second root under [`STORE`], so a fixture can show the SAME store at two roots in two /// different states — the case a store-keyed surface would silently merge. const BOND_ROOT_B: &str = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; +/// The handler fixture's operator-wallet address -- distinct from every other address in this file +/// so a dispatch arm wired to the wrong wallet read cannot agree with the right one by accident. +const OPERATOR_ADDRESS: &str = + "xch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsjqfwvy"; const BOND_COIN_A: &str = "d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1"; const BOND_COIN_B: &str = "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"; diff --git a/src/method.rs b/src/method.rs index dacd645..e3c6d72 100644 --- a/src/method.rs +++ b/src/method.rs @@ -189,6 +189,8 @@ pub enum ControlMethod { WalletCoinsByParent, /// `control.wallet.arrivals` — read confirmed INCOMING funds since a cursor position. WalletArrivals, + /// `control.wallet.operatorAddress` — read the address of the node's OWN machine wallet. + WalletOperatorAddress, /// `control.wallet.peak` — read the node's current chain peak height. WalletPeak, /// `control.wallet.syncStatus` — read whether the wallet's chain replica is being kept current. @@ -280,6 +282,7 @@ impl ControlMethod { ControlMethod::WalletCoinSpend => "control.wallet.coinSpend", ControlMethod::WalletCoinsByParent => "control.wallet.coinsByParent", ControlMethod::WalletArrivals => "control.wallet.arrivals", + ControlMethod::WalletOperatorAddress => "control.wallet.operatorAddress", ControlMethod::WalletPeak => "control.wallet.peak", ControlMethod::WalletSyncStatus => "control.wallet.syncStatus", ControlMethod::WalletBroadcast => "control.wallet.broadcast", @@ -517,6 +520,7 @@ impl ControlMethod { | ControlMethod::WalletArrivals | ControlMethod::WalletPeak | ControlMethod::WalletSyncStatus + | ControlMethod::WalletOperatorAddress | ControlMethod::WalletBroadcast | ControlMethod::WalletWatch | ControlMethod::WalletUnwatch @@ -574,6 +578,7 @@ impl ControlMethod { ControlMethod::WalletCoinSpend => "READ-only: the SPEND that spent a coin -- its puzzle reveal, its solution and the coin itself -- named by the coin's own id. `spend: null` means the consulted chain shows that coin as unspent or unknown; it NEVER means the chain could not be reached, which is an error.", ControlMethod::WalletCoinsByParent => "READ-only: the DIRECT children created by spending one coin, named by that parent's coin id. ONE hop, never a recursive walk: an empty list means the parent created no known children, and a caller wanting a lineage composes hops itself.", ControlMethod::WalletArrivals => "READ-only: confirmed INCOMING funds recorded since a cursor position, oldest first -- the answer to `was I just paid?`, which no balance or coin list can give. Each row is a coin the node determined ARRIVED: confirmed on chain, above the wallet's arrival baseline, not previously reported, and not the wallet's own change. Resume from `cursor` (the last row you were handed), never from `latest`.", + ControlMethod::WalletOperatorAddress => "READ-only: the address of the node's OWN operator wallet -- the MACHINE-custody wallet that pays mirror-coin collateral, never the user's. Returns a public address and puzzle hash and NEVER any key, seed or derivation material. TOKEN-GATED, and answered by THIS node rather than forwarded upstream.", ControlMethod::WalletPeak => "READ-only: the node's current chain peak height, independent of any address.", ControlMethod::WalletSyncStatus => "READ-only: whether the wallet's CHAIN replica is being kept current (not_started/syncing/synced/no_wallet_enrolled/wallet_not_unlocked), the replica's own height, and its CHIA full-node peer count -- unrelated to control.sync.status (DIG stores) and to control.peerStatus (DIG peers).", ControlMethod::WalletBroadcast => "Push an ALREADY-SIGNED spend bundle to the network; the node never signs. TOKEN-GATED.", @@ -640,6 +645,7 @@ impl ControlMethod { ControlMethod::WalletArrivals, ControlMethod::WalletPeak, ControlMethod::WalletSyncStatus, + ControlMethod::WalletOperatorAddress, ControlMethod::WalletBroadcast, ControlMethod::WalletWatch, ControlMethod::WalletUnwatch, @@ -716,8 +722,8 @@ mod tests { assert_eq!(actual_open, expected_open); } - /// **The gated wallet methods are the push, the arrival cursor, the three enrolment methods, - /// and the three reservation methods.** The fixture varies one thing -- which wallet method is asked -- against a category + /// **The gated wallet methods are the push, the arrival cursor, the operator address, the three + /// enrolment methods, and the three reservation methods.** The fixture varies one thing -- which wallet method is asked -- against a category /// whose other members ARE open, so both nearest wrong implementations fail here: one that opens /// the whole category (the state this crate shipped in at `1190a18`) and one that gates it /// wholesale. @@ -735,6 +741,10 @@ mod tests { gated, vec![ "control.wallet.arrivals", + // Gated for the SAME reason as `arrivals` above: the caller does not name the + // address, so the node volunteers its own node-to-address association. Here it is + // the machine wallet's, which no open read discloses. + "control.wallet.operatorAddress", "control.wallet.broadcast", "control.wallet.watch", "control.wallet.unwatch", diff --git a/src/params.rs b/src/params.rs index 534395f..774ff2d 100644 --- a/src/params.rs +++ b/src/params.rs @@ -1103,6 +1103,16 @@ impl WalletCoinsByParentParams { pub struct WalletPeakParams {} control_call!(WalletPeakParams => ControlMethod::WalletPeak, results::WalletPeakResult); +/// `control.wallet.operatorAddress` params — none. +/// +/// There is exactly one operator wallet per node, so there is nothing to scope the question by. +/// Deliberately NOT an address-taking method: a caller cannot ask *is THIS address the operator +/// wallet* by supplying a candidate, because a node answering yes/no about supplied addresses is an +/// oracle for probing which addresses a machine controls. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletOperatorAddressParams {} +control_call!(WalletOperatorAddressParams => ControlMethod::WalletOperatorAddress, results::WalletOperatorAddressResult); + /// `control.peerCounts` params — none. /// /// The counts describe this node's own connectivity on each network, so there is nothing to scope diff --git a/src/results.rs b/src/results.rs index 58e063a..7b75be9 100644 --- a/src/results.rs +++ b/src/results.rs @@ -1107,6 +1107,80 @@ pub struct WalletPeakResult { pub synced: bool, } +/// Where the node's OWN operator wallet lives on chain — the MACHINE wallet, not the user's. +/// +/// # Two wallets, and confusing them costs real money +/// +/// A node has an operator wallet of its own, derived from a machine-custody autoseed. It is the +/// wallet that pays mirror-coin collateral, and it is NOT the wallet whose keys the user holds and +/// whose addresses they watch. Nothing on any surface named which wallet a mirror figure was about, +/// and the cost of that was measured: a node reported three mirror bonds `unfunded, short 1010` +/// while the operator's OWN wallet held 1,015,000 base units of $DIG. Both statements were true and +/// they were about different wallets. This method exists so a client can name the second one, and +/// so somebody wanting to fund the machine wallet can find out where to send the money. +/// +/// # The custody boundary (§908) is the whole design of this result +/// +/// It carries an ADDRESS and a PUZZLE HASH and nothing else, and it never may carry more. Both are +/// public values in exactly the sense a coin id or an amount is: they say WHERE money can be sent, +/// never HOW it can be spent. A seed, a mnemonic, a private key, an extended key, a derivation path +/// with an index, or any other material from which a spend could be authorised MUST NOT appear +/// here, in any form, however encoded. The node signs its own mirror spends and no key ever leaves +/// it; a method that exported one would move the node from machine custody to no custody at all. +/// +/// # It is TOKEN-GATED and it is answered by THIS node +/// +/// Gated rather than open because the address links this specific node to a chain identity, and a +/// stranger able to ask any node for that mapping learns something about its operator that no other +/// open read discloses. Owned rather than delegated because the question is *which wallet does THIS +/// node spend from* — forwarding it upstream would return a different machine's address, which is +/// precisely the confusion the method exists to end. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum WalletOperatorAddressResult { + /// The node knows its operator wallet and reports where it is. + Known { + /// The bech32m address, `xch1…` on mainnet — the value a person pastes into a wallet to + /// send this node money. + address: String, + /// The same destination as a puzzle hash: LOWERCASE 64-hex, unprefixed. + /// + /// Beside the address rather than instead of it, because a client that must match this + /// wallet against a coin record is comparing puzzle hashes, and re-deriving one from an + /// address is a bech32m decode a consumer should not have to reimplement to answer *is + /// this coin the machine wallet's?* + puzzle_hash: String, + }, + /// The node cannot say where its operator wallet is. + /// + /// A DEFINITE statement that the answer is unavailable, with the reason — never an empty string + /// or a placeholder address. An address a client renders is an address somebody may send money + /// to, so a fabricated or blank one is a money statement of the worst kind. + Unavailable { + /// Why. + reason: WalletOperatorAddressUnavailableReason, + }, +} + +/// Why a node cannot name its own operator wallet. +/// +/// Two reasons, and they call for different responses: one is a node that has not finished setting +/// itself up, the other is a node whose machine custody is broken. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WalletOperatorAddressUnavailableReason { + /// The operator wallet has not been created yet. + /// + /// Nothing is wrong. A node that has never run its autoseed setup has no operator wallet, and + /// therefore no address; it will have one. A client MUST NOT present this as a fault. + NotInitialized, + /// The operator wallet exists but this node could not read it. + /// + /// A fault: the seed material is present and unreadable, or its unseal failed. The node cannot + /// pay mirror collateral in this state either, so a client SHOULD surface it. + Unreadable, +} + /// How far the node's wallet chain replica has got — the states a background sync can be in. /// /// Named states rather than a boolean, because "has never started" and "is caught up" are different diff --git a/src/traits.rs b/src/traits.rs index 3c2a5ae..7474b56 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -324,6 +324,26 @@ pub trait ControlHandler: Sync { ) -> Result; /// `control.wallet.peak` (READ-only, OPEN) async fn wallet_peak(&self) -> Result; + /// `control.wallet.operatorAddress` (READ-only, TOKEN-GATED) + /// + /// An implementation MUST: + /// + /// - **Answer for its OWN operator wallet** -- the machine-custody autoseed wallet that pays + /// mirror-coin collateral -- and never for the user's wallet, a watched address, or an + /// upstream node's wallet. The method exists because those were indistinguishable, and an + /// implementation that forwarded it would recreate the confusion exactly. + /// - **Return ONLY the address and its puzzle hash.** No seed, no mnemonic, no private or + /// extended key, no derivation material, in any encoding (§908). This method says where money + /// can be SENT; nothing here may help anyone spend it. + /// - **Perform no mutation.** It MUST NOT create, unseal-and-cache, rotate or initialise a + /// wallet as a side effect of being read. A node without one answers + /// [`NotInitialized`](results::WalletOperatorAddressUnavailableReason::NotInitialized). + /// - **Answer [`Unavailable`](results::WalletOperatorAddressResult::Unavailable) with a reason, + /// never a blank or placeholder address.** An address a client renders is an address somebody + /// may send money to. + async fn wallet_operator_address( + &self, + ) -> Result; /// `control.peerCounts` (READ-only, OPEN) /// /// `dig_peer_count` MUST be dig-node-core's `connected_peers` — the same figure @@ -746,6 +766,7 @@ pub trait ControlHandler: Sync { } ControlMethod::WalletArrivals => encode(self.wallet_arrivals(decode(params)?).await?), ControlMethod::WalletPeak => encode(self.wallet_peak().await?), + ControlMethod::WalletOperatorAddress => encode(self.wallet_operator_address().await?), ControlMethod::WalletSyncStatus => encode(self.wallet_sync_status().await?), ControlMethod::WalletBroadcast => encode(self.wallet_broadcast(decode(params)?).await?), // Re-validated here idempotently; deserialization already enforced the same rule.