Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# is designed, matching the sibling dig-<x>-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"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
30 changes: 30 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions src/kats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<results::WalletOperatorAddressResult, ControlError> {
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
Expand Down Expand Up @@ -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::<results::WalletOperatorAddressResult>(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
Expand Down Expand Up @@ -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";

Expand Down
14 changes: 12 additions & 2 deletions src/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -517,6 +520,7 @@ impl ControlMethod {
| ControlMethod::WalletArrivals
| ControlMethod::WalletPeak
| ControlMethod::WalletSyncStatus
| ControlMethod::WalletOperatorAddress
| ControlMethod::WalletBroadcast
| ControlMethod::WalletWatch
| ControlMethod::WalletUnwatch
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -640,6 +645,7 @@ impl ControlMethod {
ControlMethod::WalletArrivals,
ControlMethod::WalletPeak,
ControlMethod::WalletSyncStatus,
ControlMethod::WalletOperatorAddress,
ControlMethod::WalletBroadcast,
ControlMethod::WalletWatch,
ControlMethod::WalletUnwatch,
Expand Down Expand Up @@ -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.
Expand All @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading