diff --git a/Cargo.lock b/Cargo.lock index 30d6d9dc..788ff692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.259.0" +version = "0.260.0" dependencies = [ "async-trait", "axum", @@ -3217,9 +3217,9 @@ dependencies = [ [[package]] name = "dig-rewards-coin" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bb94b1f02239b4ad8072c5b1d37a00cca366cfeca73b34fcecd94a2470fdd0" +checksum = "7afdbc8cf70e84ad13779824948d165577df91e0409cd65a40130f5421e99f5b" dependencies = [ "chia-bls 0.36.1", "chia-consensus 0.36.1", diff --git a/Cargo.toml b/Cargo.toml index 262f7bbf..31e48aa5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ edition = "2021" # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.259.0" +version = "0.260.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over # it, so silent wrapping in release would turn a length bug into a memory/logic hazard. diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 9578bba1..c30c1d58 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -10353,6 +10353,125 @@ mod tests { } } + /// **Proves (dig_ecosystem#3342, gate H1):** the wire actually carries the not-a-distributor / + /// chain-unavailable split, through the REAL dispatch path — not just the port-level enum. An + /// installed port answering `Err(NotADistributor)` must reach `data.code == + /// "REWARD_NOT_A_DISTRIBUTOR"`; an installed port answering `Err(Unavailable)` must reach + /// `data.code == "REWARD_CHAIN_UNAVAILABLE"`; the two must differ; and neither response body + /// may contain the substring `"adapter is wired"` — that sentence is reserved for the ONE case + /// where no port is installed at all. + /// **Mutation-probe:** in `seams::dig_rpc::dispatch::reward_chain_port_error_response`, point + /// the `NotADistributor` arm's `data.code` at `REWARD_CHAIN_UNAVAILABLE_MACHINE` (re-collapsing + /// the split) and this test's `assert_ne!` on the two codes fails. + /// **Catches:** a future edit that re-merges the two wire codes while the port-level enum + /// variant, and everything else, stays green. Tests BOTH `dig.getRewardDistributor` and + /// `dig.listRewardDistributorCommitments` -- separate handlers that could drift independently. + #[test] + fn reward_distributor_methods_pin_the_not_a_distributor_wire_code_distinct_from_unavailable() { + let absent_launcher_id = [0x90u8; 32]; + let missing_launcher_id = [0x91u8; 32]; + let outage_launcher_id = [0x92u8; 32]; + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + // A fresh node per method: `install_reward_chain_port` is once-only (backed by a + // `OnceLock`), and the "no port installed" case below must be true independently for + // each method, not just the first one through the loop. + let (node, _td) = test_node(None); + + // Case 1: no port installed at all -- the ONE case allowed to say "adapter is wired". + let absent_resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":method, + "params":{"launcher_id": hex::encode(absent_launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!( + absent_resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE"), + "{method}" + ); + assert!( + absent_resp["error"]["message"] + .as_str() + .unwrap() + .contains("adapter is wired"), + "{method}: no-port-installed case must say so: {absent_resp}" + ); + + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([ + ( + missing_launcher_id, + Err(crate::rewards::port::ChainPortError::NotADistributor), + ), + ( + outage_launcher_id, + Err(crate::rewards::port::ChainPortError::Unavailable), + ), + ]), + })) + ); + + // Case 2: the chain answered -- no distributor there. + let missing_resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":2,"method":method, + "params":{"launcher_id": hex::encode(missing_launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + missing_resp.get("result").is_none(), + "{method}: {missing_resp}" + ); + assert_eq!( + missing_resp["error"]["data"]["code"], + json!("REWARD_NOT_A_DISTRIBUTOR"), + "{method}" + ); + assert!( + !missing_resp.to_string().contains("adapter is wired"), + "{method}: an installed adapter's own answer must never claim none is wired: \ + {missing_resp}" + ); + + // Case 3: the chain source itself could not be reached. + let outage_resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":3,"method":method, + "params":{"launcher_id": hex::encode(outage_launcher_id)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + outage_resp.get("result").is_none(), + "{method}: {outage_resp}" + ); + assert_eq!( + outage_resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE"), + "{method}" + ); + assert!( + !outage_resp.to_string().contains("adapter is wired"), + "{method}: an installed adapter's own outage must never claim none is wired: \ + {outage_resp}" + ); + + // The wire distinction actually exists: these two must differ. + assert_ne!( + missing_resp["error"]["data"]["code"], outage_resp["error"]["data"]["code"], + "{method}: not-a-distributor and chain-unavailable must be distinguishable on \ + the wire" + ); + } + } + /// **Proves:** when the port refuses because `withdrawal_share_bps` is out of range (either /// side: doesn't fit `u16`, the caller narrows before calling this port, or the adapter's own /// `0..=10_000` domain check), BOTH methods refuse the WHOLE call with a distinct machine code diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 0fae5124..1a98205e 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -148,6 +148,12 @@ pub enum ChainPortError { /// No chain source is wired yet — the [`unavailable`] adapter's only answer, and what any real /// adapter should answer for an unreachable chain too (SPEC §12.2 clause 4). Unavailable, + /// dig_ecosystem#3342: the chain source ANSWERED, and no reward distributor exists at the + /// requested `launcher_id`. This is deliberately NOT [`ChainPortError::Unavailable`]: a funder + /// deciding whether to claw back must be able to tell "you have nothing there" (this variant) + /// apart from "we cannot see the chain" (`Unavailable`) — collapsing both onto one shape turns + /// that decision into a guess on a money surface. + NotADistributor, /// dig_ecosystem#3269/#3284/#3303: the distributor's `withdrawal_share_bps` (a `u64` on the /// puzzle) either does not fit the wire's `u16` domain or exceeds the legitimate `0..=10_000` /// bps range. The adapter MUST refuse the WHOLE [`RewardsChainPort::distributor_report`] call diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 46352a0d..6d6e1ded 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -35,15 +35,26 @@ use crate::*; /// own surface. const ENGINE_WARMING: i64 = -32002; -/// `REWARD_CHAIN_UNAVAILABLE` (dig_ecosystem#3269): no reward-distributor chain-read adapter is -/// wired yet (`rewards::port::ChainPortError::Unavailable`, or no adapter installed at all). +/// `REWARD_CHAIN_UNAVAILABLE` (dig_ecosystem#3269, corrected by dig_ecosystem#3342): the +/// reward-distributor chain read could not complete — either no adapter is installed at all (the +/// `let Some(port) = … else` arms below, via [`reward_chain_port_absent_response`]), or an +/// installed adapter's `ChainPortError::Unavailable` means the chain source itself could not +/// answer. It no longer means "the chain answered and there is nothing there" — that is +/// [`ChainPortError::NotADistributor`], reported under [`REWARD_NOT_A_DISTRIBUTOR_MACHINE`]. /// Distinct from [`REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE`] below — a caller must be able to tell -/// "ask me again once the adapter lands" apart from "this distributor's own constant is out of -/// range". Reuses [`CONTROL_ERROR`]'s numeric code (both are control-plane runtime errors, -/// `-32032`), but carries its own `data.code` machine string so the two are still distinguishable -/// in the body. +/// "the chain could not be reached" apart from "this distributor's own constant is out of range". +/// Reuses [`CONTROL_ERROR`]'s numeric code (both are control-plane runtime errors, `-32032`), but +/// carries its own `data.code` machine string so the two are still distinguishable in the body. const REWARD_CHAIN_UNAVAILABLE_MACHINE: &str = "REWARD_CHAIN_UNAVAILABLE"; +/// `REWARD_NOT_A_DISTRIBUTOR` (dig_ecosystem#3342): the chain source answered, and no reward +/// distributor exists at the requested launcher id. Kept distinct from +/// [`REWARD_CHAIN_UNAVAILABLE_MACHINE`] on purpose — see [`ChainPortError::NotADistributor`]'s own +/// doc for why collapsing the two is a money-surface defect, not a cosmetic one. Reuses +/// [`CONTROL_ERROR`]'s numeric code, matching every other reward-distributor machine code here; no +/// wire-protocol change is needed since `data.code` alone carries the distinction. +const REWARD_NOT_A_DISTRIBUTOR_MACHINE: &str = "REWARD_NOT_A_DISTRIBUTOR"; + /// `REWARD_INVALID_WITHDRAWAL_SHARE` (dig_ecosystem#3269/#3284/#3303): the distributor's /// `withdrawal_share_bps` does not fit the wire's `u16` domain or exceeds the legitimate /// `0..=10_000` range. Refuses the WHOLE call — see `rewards::port::ChainPortError::InvalidWithdrawalShare`'s @@ -71,9 +82,14 @@ fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value match error { ChainPortError::Unavailable => json!({"jsonrpc":"2.0","id":id,"error":{ "code": CONTROL_ERROR, - "message": "reward-distributor chain read is unavailable: no chain-read adapter is wired yet", + "message": "reward-distributor chain read is unavailable: the chain source could not answer", "data": { "code": REWARD_CHAIN_UNAVAILABLE_MACHINE, "origin": "control" } }}), + ChainPortError::NotADistributor => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "no reward distributor exists at this launcher id on chain", + "data": { "code": REWARD_NOT_A_DISTRIBUTOR_MACHINE, "origin": "control" } + }}), ChainPortError::InvalidWithdrawalShare => json!({"jsonrpc":"2.0","id":id,"error":{ "code": CONTROL_ERROR, "message": "distributor's withdrawal_share_bps is out of range (must fit u16 and be <= 10000)", @@ -92,6 +108,19 @@ fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value } } +/// The response for the ONE case where "no chain-read adapter is wired yet" is actually true: no +/// `rewards::port::RewardsChainPort` has been installed on this `Node` at all +/// (dig_ecosystem#3342). Kept separate from [`reward_chain_port_error_response`] so that +/// function's `Unavailable` arm never has to carry a sentence that is false whenever an installed +/// adapter reports its own `Unavailable` for a chain-source outage. +fn reward_chain_port_absent_response(id: &Value) -> Value { + json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "reward-distributor chain read is unavailable: no chain-read adapter is wired yet", + "data": { "code": REWARD_CHAIN_UNAVAILABLE_MACHINE, "origin": "control" } + }}) +} + /// The largest legitimate `withdrawal_share_bps`: 10,000 basis points IS 100%, so this is an /// inclusive bound and `10_000` itself is a valid distributor constant, not an error. const MAX_WITHDRAWAL_SHARE_BPS: u16 = 10_000; @@ -878,7 +907,8 @@ impl RpcDispatch for Node { "count": set.len()}}); } // dig.getRewardProverStatus (dig_ecosystem#3269, dig-rewards-coin SPEC.md - // §2.3/§2.4) — CONTROL plane: loopback admin / in-process FFI ONLY, NEVER over the + // §2.3/§2.4) — CONTROL plane: loopback admin / in-process FFI ONLY (the token tier of + // this NODE-LOCAL read is dig_ecosystem#3352's decision, not #3351's), NEVER over the // mTLS peer surface (absent from `is_peer_reachable_method`; // `reward_methods_tier_guard.rs` fails closed on that). Reads the node's live // `reward_prover_statuses` registry (empty until dig_ecosystem#3265 spawns a prover @@ -968,12 +998,22 @@ impl RpcDispatch for Node { }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } - // dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — CONTROL - // plane: loopback admin / in-process FFI ONLY, absent from `is_peer_reachable_method` - // (`reward_methods_tier_guard.rs` fails closed on that). Chain-derived state ONLY — - // never the local prover loop's self-reported state (see `GetRewardProverStatus` - // above for that). Goes entirely through `rewards::port::RewardsChainPort`: this - // crate never calls `dig-rewards-coin` itself (dig_ecosystem#3269 unit 0). + // dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — `Tier::Control` + // in dig-rpc-protocol's sense: served ONLY by the local `handle_rpc` dispatch (the + // service's `POST /` and the in-process FFI), NEVER over the mTLS peer surface (absent + // from `is_peer_reachable_method`; `reward_methods_tier_guard.rs` fails closed on that). + // NOT token-gated (dig_ecosystem#3351): an OPEN read of public on-chain state keyed by + // the caller's `launcher_id`, answered to any caller that reaches `POST /` with no token: + // only the `control.` prefix is token-gated (SPEC §7.2 `is_control_method`; §5.5 + // `requires_auth: false` for every non-`control.*` method), and the read passes §7.2's + // WHO-NAMES-THE-SUBJECT test the same way `control.wallet.balance` does (#1851, + // `control::is_open_control_read`): the subject arrives in the request, so the answer + // discloses no node-local association. + // Pinned by `reward_distributor_reads_answer_on_post_slash_without_a_token` in + // dig-node-service `tests/server.rs`. Chain-derived state ONLY — never the local prover + // loop's self-reported state (see `GetRewardProverStatus` above for that). Goes entirely + // through `rewards::port::RewardsChainPort`: this crate never calls `dig-rewards-coin` + // itself (dig_ecosystem#3269 unit 0). Some(Method::GetRewardDistributor) => { let params = req.get("params").cloned().unwrap_or(json!({})); let launcher_id = match parse_launcher_id_arg(¶ms) { @@ -981,7 +1021,7 @@ impl RpcDispatch for Node { Err(msg) => return rpc_err(&id, -32602, &msg), }; let Some(port) = node.reward_chain_port() else { - return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + return reward_chain_port_absent_response(&id); }; // Range-check at THIS seam, not only in the adapter: see // `range_checked_report` for why an out-of-range share must refuse here. @@ -1012,7 +1052,8 @@ impl RpcDispatch for Node { return json!({"jsonrpc":"2.0","id":id,"result": result}); } // dig.listRewardDistributorCommitments (dig_ecosystem#3269 unit 2, SPEC §7.4 clause 5) - // — CONTROL plane, same guard shape as `GetRewardDistributor` above. `commitments` + // — same guard shape as `GetRewardDistributor` above (`Tier::Control`, not token-gated, + // OPEN on `POST /`; dig_ecosystem#3351). `commitments` // empty is legitimate (a donation-only distributor); `recoverable_base_units` per slot // is ALWAYS the port's pre-computed figure -- this handler never recomputes it (see // `rewards::port::CommitmentSlot`'s doc for why that arithmetic never lives here). @@ -1023,7 +1064,7 @@ impl RpcDispatch for Node { Err(msg) => return rpc_err(&id, -32602, &msg), }; let Some(port) = node.reward_chain_port() else { - return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + return reward_chain_port_absent_response(&id); }; // Range-check at THIS seam, not only in the adapter: see // `range_checked_report` for why an out-of-range share must refuse here. @@ -1109,7 +1150,7 @@ impl RpcDispatch for Node { let mut funded_refs = Vec::with_capacity(identities.len()); for identity in identities { let Some(port) = node.reward_chain_port() else { - return reward_chain_port_error_response(&id, &ChainPortError::Unavailable); + return reward_chain_port_absent_response(&id); }; let report = match port .distributor_report(identity.launcher_id) diff --git a/crates/dig-node-core/tests/inbound_pool_membership.rs b/crates/dig-node-core/tests/inbound_pool_membership.rs index 996e0e0d..523b6959 100644 --- a/crates/dig-node-core/tests/inbound_pool_membership.rs +++ b/crates/dig-node-core/tests/inbound_pool_membership.rs @@ -493,3 +493,222 @@ async fn the_accepted_direct_cap_still_binds_after_a_supersede_and_stale_release server.abort(); service.stop().await.expect("stop"); } + +/// Build a `NatPeerConnection` over a loopback duplex with a chosen `peer_id`, remote address and +/// traversal tier -- the same pattern `peer.rs`'s own unit tests use to exercise `adopt_nat_connection` +/// (the single outbound-adoption entry point, called in production from `bootstrap.rs`/`pex.rs`) +/// without a real socket. The `peer_id` is passed in explicitly rather than derived from a TLS +/// handshake here, so the caller can make it byte-identical to a real mTLS identity used elsewhere -- +/// which is exactly how the test below gets the SAME identity into both an outbound and an inbound +/// slot. Returns the server `PeerSession` half; drop it to end the session, hold it to keep the +/// outbound slot's session alive. +fn loopback_nat_conn( + peer_id_bytes: [u8; 32], + remote: std::net::SocketAddr, + method: dig_nat::TraversalKind, +) -> (dig_gossip::NatPeerConnection, dig_nat::PeerSession) { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let inner = dig_nat::PeerConnection { + peer_id: dig_nat::PeerId::from_bytes(peer_id_bytes), + method, + remote_addr: remote, + peer_bls_pub: None, + session: dig_nat::PeerSession::client(client_io), + }; + ( + dig_gossip::NatPeerConnection::new(inner), + dig_nat::PeerSession::server(server_io), + ) +} + +/// **dig_ecosystem#3124 -- the one unmeasured property: a peer that is BOTH dialled (outbound) and +/// accepted (inbound) is counted exactly ONCE, and both directions keep being served.** +/// +/// `adopt_inbound_peer_in_pool`'s own doc (`peer.rs:3658-3662`) lists "a peer already holding a +/// dialable slot" among dig-gossip's refusals -- meaning the de-duplication this test proves lives in +/// dig-gossip, not dig-node, and has never before been exercised FROM dig-node. Nothing here builds a +/// pool entry directly: the outbound slot is created through the real `adopt_nat_connection` adoption +/// path (the single outbound entry point), and the inbound slot is created by a real mTLS dial against +/// `serve_peer_rpc_listener_with`'s listener, exactly like every other test in this file. +#[tokio::test] +async fn a_peer_that_is_both_dialled_and_accepted_is_counted_once() { + dig_node_core::peer::install_crypto_provider(); + + let (service, gossip_a, _gdir) = running_gossip().await; + assert_eq!(gossip_a.peer_count().await, 0, "the pool starts empty"); + + let server_identity = test_identity("3124-dualslot-server"); + let server_peer_id = server_identity.peer_id(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let listen_addr = listener.local_addr().expect("local addr"); + + let responder: Arc = Arc::new(TestResponder); + let server = tokio::spawn(serve_peer_rpc_listener_with( + listener, + server_identity, + responder, + None, + Some(gossip_a.clone()), + )); + + // Identity B -- used for BOTH the outbound and the inbound slot below. + let b_identity = test_identity("3124-dualslot-peer-b"); + let b_peer_id = b_identity.peer_id(); + let b_bytes = *b_peer_id.as_bytes(); + + // -- Step 1: B occupies an OUTBOUND (dialled) slot, via the real adoption path ----------------- + let fake_dial_addr: std::net::SocketAddr = "198.51.100.9:9444".parse().expect("addr"); + let (outbound_conn, _outbound_server_session) = + loopback_nat_conn(b_bytes, fake_dial_addr, dig_nat::TraversalKind::Direct); + let adopted = gossip_a + .adopt_nat_connection(outbound_conn) + .await + .expect("B's outbound slot is uncontested"); + assert_eq!(adopted, dig_gossip::PeerId::from(b_bytes)); + + // Precondition: exactly one DIALABLE slot for B, before the inbound leg touches anything. + assert_eq!( + gossip_a.peer_count().await, + 1, + "the outbound adoption must land before the inbound leg is driven" + ); + let pool_id_b = dig_gossip::PeerId::from(b_bytes); + let outbound_detail = gossip_a + .connected_pool_peers_detailed() + .into_iter() + .find(|p| p.peer_id == pool_id_b) + .expect("B's outbound slot exists"); + assert!( + outbound_detail.is_outbound, + "step 1 must produce a DIALLED slot, or this test measures the wrong thing" + ); + assert!( + !gossip_a.dialable_pool_peers().is_empty(), + "the outbound slot must be dialable before the inbound leg is driven" + ); + + // -- Step 2: the SAME identity B now dials A INBOUND over real mTLS ----------------------------- + let target = dig_nat::PeerTarget::with_addr(server_peer_id, listen_addr, "DIG_MAINNET"); + let config = dig_nat::NatConfig::builder() + .enabled_methods(vec![dig_nat::TraversalKind::Direct]) + .per_method_timeout(Duration::from_secs(5)) + .build(); + let mut inbound_conn = dig_nat::connect(&target, &b_identity, &config) + .await + .expect("B's transport-level connect succeeds even if the pool refuses to adopt it"); + // (No same-identity assertion here: `b_bytes` was derived FROM `b_identity` two lines above, so + // comparing them back is true by construction and proves nothing about the server side. The + // property that matters -- that the server admitted B as the SAME identity, not a distinct one -- + // is carried by `peer_count() == 1` below: a real identity mismatch would create a SECOND slot and + // the count would read 2. That is the assertion doing the real work.) + + // -- Step 3 (moved ahead of Step 4's count/row assertions): the RPC round-trip IS the ordering + // barrier, not a courtesy check. `adopt_inbound_peer_in_pool` is called at `peer.rs:3602`, + // strictly BEFORE `serve_peer_session_from_with` starts answering RPC on the accepted session + // (peer.rs:3614) -- so a successful `dig.getNetworkInfo` response over `inbound_conn` proves the + // server has already reached and returned from the adoption attempt. A bare `sleep` before the + // count assertion below cannot make that promise: on a slow CI box the accept task may simply not + // have run yet, and `peer_count() == 1` would be trivially true for the wrong reason (the inbound + // leg never having been driven at all), not because the pool correctly refused it. This is also + // why the RPC assertion moved ahead of the "still served" comment it used to sit under -- it now + // does double duty as both the serve-path proof AND the happens-before proof for step 2/3. + { + let mut stream = inbound_conn + .session + .open_stream() + .await + .expect("open stream"); + let req = json!({"jsonrpc":"2.0","id":21,"method":"dig.getNetworkInfo"}); + write_framed(&mut stream, &req).await.expect("write"); + let resp = read_one_frame(&mut stream).await; + assert_eq!( + resp["result"]["served_method"], "dig.getNetworkInfo", + "the un-adopted inbound peer must still be served -- refusing adoption must not refuse service" + ); + } + + // The RPC round-trip above already proves the adoption attempt ran and returned, so this count + // read needs no sleep to be meaningful: it must NOT go to 2. + assert_eq!( + gossip_a.peer_count().await, + 1, + "a peer that is both dialled and accepted must be counted ONCE, not twice" + ); + + // -- Step 4: exactly ONE row for B's peer_id among connected_pool_peers() ----------------------- + // (`connected_peers_json` is `pub(crate)` inside dig-node-core and unreachable from this + // integration-test crate; `connected_pool_peers()` is its public dig-gossip source, so counting + // matching rows here proves the same property `connected_peers_json` would report.) + let matching_rows = gossip_a + .connected_pool_peers() + .into_iter() + .filter(|(peer_id, _addr, _outbound)| *peer_id == pool_id_b) + .count(); + assert_eq!( + matching_rows, 1, + "exactly one row must carry B's peer_id -- a de-duplication failure would emit two" + ); + // The surviving row is the DIALLED slot: `adopt_direct_inbound_handle` + // (dig-gossip `service/gossip_handle.rs`, admission section) refuses outright -- it does not + // supersede -- whenever the held slot's `dial_addr()` is `Some`: "an accepted connection NEVER + // supersedes a slot this node can dial" (the #870 rule). `adopt_inbound_peer_in_pool`'s own doc + // (peer.rs:3658-3662) names the same refusal. So the pre-existing outbound slot is kept and the + // inbound accept is the one turned away -- this is a REFUSAL, not a supersede-by-newer-connection. + let surviving = gossip_a + .connected_pool_peers_detailed() + .into_iter() + .find(|p| p.peer_id == pool_id_b) + .expect("B still has exactly one slot"); + assert!( + surviving.is_outbound, + "the surviving slot must be the pre-existing DIALLED one, per the documented refusal" + ); + assert_eq!( + gossip_a.peer_count().await, + 1, + "serving the refused inbound peer must not perturb the count" + ); + + // -- Step 5: released cleanly, SESSION-scoped ------------------------------------------------------ + // Drop the inbound session first: the outbound slot must survive, because releasing it was never + // the inbound session's to release (it never held the slot). + // + // This checks a NON-event -- that no release happens -- so a single instant read right after + // `drop` cannot prove it: dropping the CLIENT side does not synchronously run the SERVER's + // teardown. The server must first observe the closed transport in its own accept/serve task and + // only then run `release_inbound_pool_slot`; an instant read fires before the server has had a + // chance to act, which passes just as well under the defect this step exists to catch (an + // erroneous release of the outbound slot) as it does under correct behaviour -- it cannot tell + // the two apart. Poll across a bounded window instead: if the inbound leg's teardown incorrectly + // released the OUTBOUND slot it never owned, the count drops to 0 at some point inside the + // window and this loop catches it; if the release is correctly a no-op, the count simply stays + // at 1 for the whole window. + // + // There is no cheap positive signal available here that the server has specifically finished + // processing THIS disconnect (the RPC-round-trip trick Step 3 uses needs a live stream, which + // `drop` just closed) -- so the window is the whole proof, not a supplement to one. + drop(inbound_conn); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + assert_eq!( + gossip_a.peer_count().await, + 1, + "the inbound session ending released the outbound slot it never owned -- a \ + session-scoped release defect: the refused inbound leg tore down B's dialled slot when \ + its own transport closed" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Now release the outbound slot itself and confirm the count reaches zero. + gossip_a + .disconnect(&pool_id_b) + .await + .expect("release the outbound slot"); + await_peer_count(&gossip_a, 0, "after the outbound slot is released").await; + + server.abort(); + service.stop().await.expect("stop"); +} diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 4cc7b516..5674b4ea 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -153,6 +153,12 @@ chia-sdk-types = { version = "=0.36.0", features = ["chip-0035", "action-layer"] # Same chia 0.36 set as the crates around it -- a mirror coin's collateral is $DIG, so the returned # coin sits at the CAT puzzle hash and never at the bare owner puzzle hash. chia-puzzle-types = "=0.36.1" +# `SINGLETON_LAUNCHER_HASH` -- `rewards_claim::chain_port::HintedLauncherIndex` filters a hinted +# coin candidate down to an actual singleton-launcher coin before ever handing its id to +# `dig_rewards_coin::discover_distributor` (dig_ecosystem#3347). Promoted from a dev-only edge to +# a normal one at the SAME `=0.20.3` pin the dev-dependency section below already resolves -- no +# second version enters the tree. +chia-puzzles = "=0.20.3" # `ToTreeHash`, to re-derive an owner puzzle hash from the key a mirror create is built for, so the # signer can refuse spends that are not its own wallet's rather than trusting the call site to have # passed the right key. Part of the same chia set above -- `chia-sdk-driver` already resolves it. @@ -202,11 +208,15 @@ dig-wallet = { path = "../dig-wallet" } # `ChainSource`. This is the ONE crate in this seam that depends on it — `dig-node-core` # deliberately does not (see `dig_node_core::rewards::port`'s module doc) because the # reader takes a `ChainSource` it has no seam to hold; this crate does (`dig-wallet`'s -# `CorroboratedChainSource`), so the adapter lives here. `0.5`, not the `0.4` the ticket -# names (stale): 0.5.0 is the release that already refuses `epoch_seconds == 0` inside -# `read_distributor` itself (see `rewards/chain_source.rs`'s module doc for the exact -# line, and why this crate ALSO refuses at its own edge as defence in depth). -dig-rewards-coin = "0.5" +# `CorroboratedChainSource`), so the adapter lives here. 0.5.0 was the release that first +# refused `epoch_seconds == 0` inside `read_distributor` itself (see `rewards/chain_source.rs`'s +# module doc for the exact line, and why this crate ALSO refuses at its own edge as defence in +# depth). Bumped to 0.8 for dig_ecosystem#3347: 0.8.0 adds `payout::accrued_base_units` (a public, +# pure accrual read) and `payout::ChainEntrySlotSource` (a chain-backed `EntrySlotSource`), which +# is what makes `RealClaimChainPort::own_entry` and `submit_initiate_payout` real instead of +# refusals. Still the same chia 0.36 line (chia-sdk-driver `=0.36.0`, chia-protocol 0.36.1) every +# other dependency in this crate is already pinned to. +dig-rewards-coin = "0.8" # HTTP stack: the same axum/tokio the node itself uses, so there is one async runtime # and one server framework across the node and the service shell. `ws` enables diff --git a/crates/dig-node-service/src/rewards/chain_port.rs b/crates/dig-node-service/src/rewards/chain_port.rs index 3afca571..e1f6b49d 100644 --- a/crates/dig-node-service/src/rewards/chain_port.rs +++ b/crates/dig-node-service/src/rewards/chain_port.rs @@ -61,6 +61,17 @@ impl RealRewardsChainPort { } } +#[cfg(test)] +impl RealRewardsChainPort { + /// Test-only read of the degradation latch (dig_ecosystem#3342, gate H2) -- a direct load of + /// the real field, not a re-derivation, so a test can prove the latch's actual state rather + /// than scraping it back out of `tracing`'s output. + fn is_degraded(&self) -> bool { + self.report_degraded + .load(std::sync::atomic::Ordering::Relaxed) + } +} + #[async_trait] impl RewardsChainPort for RealRewardsChainPort { async fn funded_distributors(&self) -> Result, ChainPortError> { @@ -105,11 +116,20 @@ impl RewardsChainPort for RealRewardsCha // R4 (dig_ecosystem#3310 gate leg 3, §4): a failing chain source must be observable, not // only correctly typed. `swap` both reads and sets `report_degraded` atomically, so the // warn fires exactly once per failure->success transition even under concurrent callers. + // + // `NotADistributor` (dig_ecosystem#3342, gate H2) is excluded from BOTH the latch and the + // warn: the chain answered fine and simply holds nothing at this launcher id, which is not + // a degradation of the chain source at all. Arming the latch on it would (a) blind a + // GENUINE outage that follows -- the warn only fires on a false->true transition, so the + // real failure would log nothing until some later `Ok` reset it -- and (b) misreport an + // ordinary "not mine" probe as chain trouble. `Ok` still clears the latch as before, + // matching a real recovery. match &result { Ok(_) => { self.report_degraded .store(false, std::sync::atomic::Ordering::Relaxed); } + Err(ChainPortError::NotADistributor) => {} Err(port_error) => { let was_already_degraded = self .report_degraded @@ -141,7 +161,7 @@ where let snapshot = read_distributor_guarded(source, launcher_id) .map_err(guarded_read_error_to_port_error)? - .ok_or(ChainPortError::Unavailable)?; + .ok_or(ChainPortError::NotADistributor)?; let comment = read_launch_comment(source, launcher_id).map_err(launch_comment_error_to_port_error)?; @@ -264,11 +284,16 @@ fn guarded_read_error_to_port_error(error: GuardedReadError) -> ChainPortError { } /// Maps [`LaunchCommentError`] onto [`ChainPortError`] (dig_ecosystem#3310 gate leg 3, R5). -/// `ParentSpendUnavailable` is a chain-source GAP (the source does not yet hold the launcher's -/// parent spend), not a classification of the distributor's identity -- it maps onto the same -/// `Unavailable` `read_distributor_guarded`'s own `Ok(None)` already answers with, not `Other`, -/// which would render it to a caller as a definitive "not a DIG distributor". Every other variant -/// genuinely is a refused/malformed read, or a real classification, so it stays `Other`. +/// `ParentSpendUnavailable` is a chain-source GAP: the source does not yet hold the launcher's +/// parent spend, so this call cannot say anything about the distributor's identity at all -- that +/// is an outage of the read, not an answer from it, so it maps onto [`ChainPortError::Unavailable`] +/// on its own merit (dig_ecosystem#3342: it no longer piggybacks on +/// `read_distributor_guarded`'s `Ok(None)` path, which now reports +/// [`ChainPortError::NotADistributor`] instead -- a chain source that never reached the parent +/// spend is a different failure from one that reached the chain and found no distributor there). +/// It is not `Other`, which would render it to a caller as a definitive "not a DIG distributor". +/// Every other variant genuinely is a refused/malformed read, or a real classification, so it +/// stays `Other`. fn launch_comment_error_to_port_error(error: LaunchCommentError) -> ChainPortError { match error { LaunchCommentError::ParentSpendUnavailable => ChainPortError::Unavailable, @@ -351,6 +376,144 @@ mod tests { ); } + /// dig_ecosystem#3342, the money-surface defect: a chain source that ANSWERS and holds no + /// reward distributor at `launcher_id` is an ABSENCE, never an OUTAGE. An empty + /// `MockChainSource` answers every read successfully with `None`, which + /// `dig_rewards_coin::state::read_distributor` reports as `Ok(None)` -- the chain saying + /// "nothing here", not "I could not look". A funder deciding whether to claw back must be + /// able to tell that apart from an unreachable chain, so it must NOT be `Unavailable`. + #[tokio::test] + async fn an_answering_chain_with_no_distributor_is_an_absence_not_an_outage() { + let source = MockChainSource::new(); + let port = RealRewardsChainPort::::new(Arc::new(source)); + + let result = port.distributor_report([0x22; 32]).await; + + assert_eq!( + result, + Err(ChainPortError::NotADistributor), + "an answering chain that holds no distributor is an absence, not an unreachable \ + chain, got {result:?}" + ); + } + + /// A `ChainSource` that starts answering like an empty chain (every read `Ok` with nothing + /// found -- the `NotADistributor` shape) and can be flipped, mid-test, to fail every read (the + /// `Unavailable` shape). Lets [`a_not_a_distributor_result_never_arms_the_latch_and_never_blinds_a_later_outage`] + /// drive a SINGLE `RealRewardsChainPort` instance through the exact absence-then-outage + /// sequence dig_ecosystem#3342 gate H2 is about, instead of two instances that could never + /// prove the exclusion is scoped to `NotADistributor` alone. + #[derive(Default)] + struct SwitchableChainSource { + failing: std::sync::atomic::AtomicBool, + } + + impl SwitchableChainSource { + fn switch_to_failing(&self) { + self.failing + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + fn guard(&self) -> Result<(), ChainSourceError> { + if self.failing.load(std::sync::atomic::Ordering::SeqCst) { + Err(ChainSourceError::Timeout) + } else { + Ok(()) + } + } + } + + impl dig_chainsource_interface::ChainSource for SwitchableChainSource { + type Error = ChainSourceError; + + fn coin_record( + &self, + _coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn coin_records_by_puzzle_hash( + &self, + _puzzle_hash: chia_protocol::Bytes32, + _include_spent: bool, + ) -> Result, Self::Error> { + self.guard()?; + Ok(Vec::new()) + } + + fn coin_records_by_parent( + &self, + _parent_coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(Vec::new()) + } + + fn coin_spend( + &self, + _coin_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn resolve_singleton_lineage( + &self, + _launcher_id: chia_protocol::Bytes32, + ) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn peak_height(&self) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + + fn block_timestamp(&self, _height: u32) -> Result, Self::Error> { + self.guard()?; + Ok(None) + } + } + + /// **Proves (dig_ecosystem#3342, gate H2):** a `NotADistributor` result must not arm the + /// degradation latch -- so a genuine outage that follows still transitions the latch + /// false->true and still would warn, exactly as if the `NotADistributor` call had never + /// happened. Before the fix, EVERY `Err(_)` armed the latch, so the outage below would find it + /// already `true` and treat itself as a no-op continuation of an existing degradation. + /// **Mutation-probe:** in `RealRewardsChainPort::distributor_report`, delete the + /// `Err(ChainPortError::NotADistributor) => {}` arm (folding it back into the general `Err` + /// arm) and this test's first `assert!(!port.is_degraded())` goes red. + #[tokio::test] + async fn a_not_a_distributor_result_never_arms_the_latch_and_never_blinds_a_later_outage() { + let source = Arc::new(SwitchableChainSource::default()); + let port = RealRewardsChainPort::::new(Arc::clone(&source)); + + // Phase 1: the chain answers, no distributor here -- an absence, not a degradation. + let absence_result = port.distributor_report([0x33; 32]).await; + assert_eq!(absence_result, Err(ChainPortError::NotADistributor)); + assert!( + !port.is_degraded(), + "a NotADistributor result must never arm the degradation latch" + ); + + // Phase 2: the chain source itself now fails -- a genuine outage. + source.switch_to_failing(); + let outage_result = port.distributor_report([0x33; 32]).await; + assert_eq!( + outage_result, + Err(ChainPortError::Unavailable), + "a failing chain source must still report Unavailable after a prior absence" + ); + assert!( + port.is_degraded(), + "a genuine outage must still arm the latch even after a preceding NotADistributor \ + result -- that is exactly the blinding H2 guards against" + ); + } + /// The adjacent guard this crate's own `epoch_seconds == 0` refusal must keep: that refusal is /// a NAMED distributor-level refusal (`ChainPortError::Other`), never conflated with /// `ChainPortError::Unavailable` -- which must mean the CHAIN SOURCE could not answer, not @@ -371,10 +534,11 @@ mod tests { ); } - /// R5's regression (dig_ecosystem#3310 gate leg 3): a chain-source GAP on the launcher's - /// parent spend must never be reported as the definitive "not a DIG distributor" verdict -- - /// it must agree with the OTHER absence path (`read_distributor_guarded`'s own `Ok(None)`), - /// which answers `Unavailable`. + /// R5's regression (dig_ecosystem#3310 gate leg 3, corrected by dig_ecosystem#3342): a + /// chain-source GAP on the launcher's parent spend must never be reported as the definitive + /// "not a DIG distributor" verdict -- it stands on its own merit as an unreachable read + /// (`Unavailable`), independent of `read_distributor_guarded`'s `Ok(None)` path, which since + /// #3342 answers `ChainPortError::NotADistributor` instead: a genuine absence, not an outage. #[test] fn parent_spend_gap_is_reported_as_unavailable_not_as_a_distributor_identity_verdict() { let mapped = super::launch_comment_error_to_port_error( diff --git a/crates/dig-node-service/src/rewards_claim/chain_port.rs b/crates/dig-node-service/src/rewards_claim/chain_port.rs new file mode 100644 index 00000000..e5bb7aff --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/chain_port.rs @@ -0,0 +1,597 @@ +//! `RealClaimChainPort` -- the production [`super::port::ClaimChainPort`] adapter over +//! `dig-rewards-coin` 0.8.0 and this node's own [`dig_wallet::sage::corroborated_source::CorroboratedChainSource`] +//! (DIG-Network/dig_ecosystem#3347). Until this file existed, [`super::port::UnavailableClaimChainPort`] +//! was the ONLY adapter this trait had, so every real cycle reported `ChainSourceUnavailable` -- +//! see [`super`]'s module doc, "the chain seam", for the history. +//! +//! # Every method is a real chain read or a real broadcast +//! +//! Discovery, comment resolution, the reserve asset id and the chain-curried payout threshold are +//! all real reads. [`RealClaimChainPort::own_entry`] reads the real accrued amount via +//! `dig_rewards_coin::accrued_base_units` -- a public, pure function 0.8.0 added -- applied to a +//! freshly chain-read entry slot; it never fabricates `0` and never caches across calls. +//! [`RealClaimChainPort::submit_initiate_payout`] builds and broadcasts a real `InitiatePayout` +//! spend: the entry slot comes ONLY from `dig_rewards_coin::ChainEntrySlotSource` (a fresh, +//! authenticated chain walk, `SPEC.md` §12.5 clause 3a on `dig-rewards-coin`'s side) -- **never** +//! `RewardDistributor::created_slot_value_to_slot` on a chain-rebuilt distributor, which derives a +//! well-formed but PHANTOM `LineageProof` for a slot an earlier generation created +//! (DIG-Network/dig_ecosystem#3357). `initiate_payout`'s returned `conditions` are a CALLER-SIDE +//! assertion for a coin the caller would add to the same bundle; this adapter adds no coin of its +//! own (no fee coin, no key, nothing to sign -- `required_fee_mojos` is `0`), so it drops them -- +//! the simulator acceptance test in `tests/rewards_claim_chain_port_3347.rs` is the proof the +//! resulting bundle is accepted without them. +//! +//! A silent no-op would be the exact defect this ticket exists to prevent -- a refused method +//! reports a NAMED [`ClaimPortError`], never a fabricated success. + +use std::sync::Arc; + +use async_trait::async_trait; +use chia_protocol::{Bytes32, SpendBundle}; +use chia_sdk_driver::{RewardDistributorConstants, RewardDistributorState, SpendContext}; +use chia_sdk_types::puzzles::RewardDistributorEntrySlotValue; +use dig_chainsource_interface::ChainSource; +use dig_rewards_coin::payout::{initiate_payout, PayoutOutcome}; +use dig_rewards_coin::ChainEntrySlotSource; +use dig_wallet::sage::spend::Broadcaster; + +use crate::rewards::chain_source::{read_distributor_guarded, GuardedReadError}; + +use super::port::{ClaimChainPort, ClaimPortError}; +use super::types::{DiscoveredDistributor, OwnEntry}; + +/// The longest a chain port's own error text is allowed to carry before it is truncated -- the +/// same 200-char discipline [`super::types::ClaimOutcome::Faulted`]'s `reason` field documents, +/// applied here at the source so every producer of a bounded string agrees on the bound. +const MAX_ERROR_CHARS: usize = 200; + +fn bounded(message: impl Into) -> String { + let message = message.into(); + if message.chars().count() <= MAX_ERROR_CHARS { + message + } else { + let truncated: String = message.chars().take(MAX_ERROR_CHARS).collect(); + format!("{truncated}... (truncated)") + } +} + +/// Proposes launcher ids for [`RealClaimChainPort::discover_distributors`] to try -- SPEC 13.1 +/// clause 2 needs a chain-wide enumerator and [`ChainSource`] has none of its own. An index only +/// PROPOSES: every id it returns is still re-verified through `dig_rewards_coin::discover_distributor` +/// inside `discover_distributors`, so an index that lies (or is merely stale) yields nothing, +/// never a forged discovery. +#[async_trait] +pub trait LauncherIndex: Send + Sync { + /// The launcher ids this index currently believes are worth trying. May include ids that turn + /// out not to be DIG rewards distributors at all -- that is `discover_distributors`'s filter to + /// apply, not this trait's. + async fn launcher_ids(&self) -> Result, ClaimPortError>; +} + +/// The real, chain-backed [`ClaimChainPort`] -- generic over the [`ChainSource`] (production: +/// [`dig_wallet::sage::corroborated_source::CorroboratedChainSource`], tests: +/// `dig_chainsource_interface::MockChainSource`) and the [`LauncherIndex`] (production: +/// [`HintedLauncherIndex`], tests: a small fixture in this crate's own test binaries). +pub struct RealClaimChainPort +where + S: ChainSource + Send + Sync + 'static, + I: LauncherIndex, +{ + source: Arc, + index: I, + broadcaster: Arc, +} + +impl RealClaimChainPort +where + S: ChainSource + Send + Sync + 'static, + I: LauncherIndex, +{ + /// Wraps an already-constructed chain source, launcher index and broadcaster. Takes the source + /// by `Arc` (mirroring `rewards::chain_port::RealRewardsChainPort::new`) since a blocking read + /// clones it into a `spawn_blocking` closure on every call. + #[must_use] + pub fn new(source: Arc, index: I, broadcaster: Arc) -> Self { + Self { + source, + index, + broadcaster, + } + } +} + +/// The pure decision [`RealClaimChainPort::own_entry`] delegates to once it has (or has not) +/// found a matching entry slot -- kept separate from the chain read itself so it is unit-testable +/// without a real launch: no entry means `Ok(None)`, honestly; a found entry's accrual is computed +/// via `dig_rewards_coin::accrued_base_units`, the puzzle's own arithmetic (never re-derived here, +/// per DIG-Network/dig_ecosystem#3286) -- `None` from THAT means the arithmetic overflowed or +/// underflowed, refused by name rather than reported as a fabricated `0`. +fn own_entry_from_slot( + payout_puzzle_hash: Bytes32, + constants: &RewardDistributorConstants, + state: &RewardDistributorState, + entry: Option<&RewardDistributorEntrySlotValue>, +) -> Result, ClaimPortError> { + let Some(entry) = entry else { + return Ok(None); + }; + + match dig_rewards_coin::accrued_base_units(constants, state, entry) { + Some(accrued_base_units) => Ok(Some(OwnEntry { + payout_puzzle_hash, + counter: entry.counter, + accrued_base_units, + })), + None => Err(ClaimPortError::Other(bounded( + "accrued amount overflowed the puzzle's arithmetic; refusing rather than reporting 0", + ))), + } +} + +/// Maps a [`GuardedReadError`] (this crate's own `epoch_seconds == 0` refusal, or +/// `dig_rewards_coin::state::read_distributor`'s own error) onto [`ClaimPortError`]. +fn guarded_read_error_to_claim_port_error(error: GuardedReadError) -> ClaimPortError { + match error { + GuardedReadError::NonTerminatingEpochSeconds => ClaimPortError::Other(bounded( + "refused: this distributor's epoch_seconds is 0, which would hang \ + commit_incentives's generation walk (see chain_source.rs's module doc)", + )), + GuardedReadError::Reader(dig_rewards_coin::RewardsError::ChainUnavailable(_)) => { + ClaimPortError::Unavailable + } + GuardedReadError::Reader(other) => ClaimPortError::Other(bounded(other.to_string())), + } +} + +/// Maps a `dig_rewards_coin::RewardsError` from `discover_distributor` onto [`ClaimPortError`] -- +/// the same `ChainUnavailable` split as [`guarded_read_error_to_claim_port_error`], applied to the +/// discovery module's own error type instead of the guarded-read one. +fn rewards_error_to_claim_port_error(error: dig_rewards_coin::RewardsError) -> ClaimPortError { + match error { + dig_rewards_coin::RewardsError::ChainUnavailable(_) => ClaimPortError::Unavailable, + other => ClaimPortError::Other(bounded(other.to_string())), + } +} + +/// Re-derives one launcher id's generation over `source`, verifying it through the real +/// parent-spend memo decode rather than trusting the id alone (SPEC 13.1 clause 6: a discovered +/// distributor's own fields are never caller input). `Ok(None)` covers BOTH "unknown to `source`" +/// and "not a DIG rewards distributor" -- neither is an error (SPEC §1.3). +fn resolve_via_chain( + source: &S, + launcher_id: Bytes32, +) -> Result, ClaimPortError> +where + S: ChainSource, +{ + let discovered = dig_rewards_coin::discover_distributor(source, launcher_id) + .map_err(rewards_error_to_claim_port_error)?; + + Ok(discovered.map(|d| { + let generation = d.generation(); + DiscoveredDistributor { + launcher_id: d.launcher_id(), + store_id: generation.store_id, + root: generation.root, + } + })) +} + +#[async_trait] +impl ClaimChainPort for RealClaimChainPort +where + S: ChainSource + Send + Sync + 'static, + I: LauncherIndex, +{ + /// `&'static str` naming this adapter -- see the trait's own doc. Never a default impl, so a + /// new adapter must choose its own name rather than silently inheriting one that describes a + /// different adapter. + fn kind(&self) -> &'static str { + "real-corroborated" + } + + async fn discover_distributors(&self) -> Result, ClaimPortError> { + let candidate_ids = self.index.launcher_ids().await?; + let source = Arc::clone(&self.source); + + tokio::task::spawn_blocking(move || { + let mut discovered = Vec::new(); + for launcher_id in candidate_ids { + // SPEC 13.1 clause 6: the index only PROPOSES; every id is re-verified through the + // real memo decode. An id the decode rejects (unknown to `source`, or a spend that + // is not a DIG rewards launch) is DROPPED, never echoed back. + match resolve_via_chain(source.as_ref(), launcher_id) { + Ok(Some(distributor)) => discovered.push(distributor), + Ok(None) => {} + Err(ClaimPortError::Unavailable) => return Err(ClaimPortError::Unavailable), + Err(other) => return Err(other), + } + } + Ok(discovered) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "discover_distributors task panicked: {join_error}" + ))) + })? + } + + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || resolve_via_chain(source.as_ref(), launcher_id)) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "resolve_launch_comment task panicked: {join_error}" + ))) + })? + } + + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || { + let snapshot = read_distributor_guarded(source.as_ref(), launcher_id) + .map_err(guarded_read_error_to_claim_port_error)? + .ok_or_else(|| { + ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) + })?; + Ok(snapshot.distributor().info.constants.reserve_asset_id) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "reserve_asset_id task panicked: {join_error}" + ))) + })? + } + + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || { + let snapshot = read_distributor_guarded(source.as_ref(), launcher_id) + .map_err(guarded_read_error_to_claim_port_error)? + .ok_or_else(|| { + ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) + })?; + // Chain-curried, per SPEC §8.3 -- never `dig_rewards_coin::PAYOUT_THRESHOLD_BASE_UNITS` + // (that constant is this distributor's DEFAULT launch value, not what any given + // on-chain distributor was actually launched with; a distributor with a + // non-default threshold would silently mis-evaluate against the literal). + Ok(dig_rewards_coin::payout::payout_threshold_base_units( + snapshot.distributor(), + )) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "payout_threshold task panicked: {join_error}" + ))) + })? + } + + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + let source = Arc::clone(&self.source); + tokio::task::spawn_blocking(move || { + // SPEC §10.2/§12.5: fresh on EVERY call -- no cache field of any kind on this adapter. + let snapshot = read_distributor_guarded(source.as_ref(), launcher_id) + .map_err(guarded_read_error_to_claim_port_error)? + .ok_or_else(|| { + ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) + })?; + + let entry = snapshot + .entry_slot(payout_puzzle_hash) + .map_err(rewards_error_to_claim_port_error)?; + own_entry_from_slot( + payout_puzzle_hash, + &snapshot.distributor().info.constants, + &snapshot.distributor().info.state, + entry.map(|slot| &slot.info.value), + ) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!("own_entry task panicked: {join_error}"))) + })? + } + + async fn required_fee_mojos(&self, _launcher_id: Bytes32) -> Result { + // This adapter attaches no fee coin and signs nothing: `InitiatePayout` is permissionless + // (SPEC §7.1, `require_payout_approval = false`) and the reserve pays out via a + // singleton-delegated announcement, not a fee this node fronts. Node policy, not a chain + // read -- so `0` here is not a fabricated chain answer, it is what this adapter charges. + Ok(0) + } + + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + // This adapter attaches no fee coin (see `required_fee_mojos`'s doc): a non-zero fee has + // nowhere to be paid from here, so refuse by name rather than silently dropping it. + if fee_mojos != 0 { + return Err(ClaimPortError::Other(bounded( + "this adapter attaches no fee coin; required_fee_mojos is 0 and a non-zero fee \ + cannot be paid here", + ))); + } + + let source = Arc::clone(&self.source); + let built = tokio::task::spawn_blocking(move || { + // SPEC §10.2/§12.5: fresh on EVERY call -- the same guarded, authenticated read every + // other method here uses. + let snapshot = read_distributor_guarded(source.as_ref(), launcher_id) + .map_err(guarded_read_error_to_claim_port_error)? + .ok_or_else(|| { + ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) + })?; + + // NEVER `snapshot.distributor().created_slot_value_to_slot(..)` -- that derives a + // well-formed but PHANTOM `LineageProof` for a slot an earlier generation created + // (DIG-Network/dig_ecosystem#3357, this module's doc). The entry slot for THIS spend + // comes only from a fresh `ChainEntrySlotSource` walk. + let mut distributor = snapshot.distributor().clone(); + let mut ctx = SpendContext::new(); + let slots = ChainEntrySlotSource::new(source.as_ref(), launcher_id); + + let outcome = initiate_payout(&mut ctx, &mut distributor, &slots, payout_puzzle_hash) + .map_err(rewards_error_to_claim_port_error)?; + + let (_conditions, amount_base_units, counter) = + match outcome { + PayoutOutcome::Paid { + conditions, + amount_base_units, + counter, + } => (conditions, amount_base_units, counter), + PayoutOutcome::EntrySlotAbsent => return Err(ClaimPortError::Other(bounded( + "entry slot absent at submission; the entry set moved between own_entry \ + and submit", + ))), + }; + // `conditions` is a CALLER-SIDE assertion for a coin the caller would add to the same + // bundle (this module's doc) -- this adapter adds none, so it is dropped here rather + // than threaded into a bundle with nothing to satisfy it. + + let (_distributor, signature) = distributor + .finish_spend(&mut ctx, vec![]) + .map_err(|error| ClaimPortError::Other(bounded(error.to_string())))?; + + let bundle = SpendBundle::new(ctx.take(), signature); + Ok::<_, ClaimPortError>((bundle, amount_base_units, counter)) + }) + .await + .map_err(|join_error| { + ClaimPortError::Other(bounded(format!( + "submit_initiate_payout task panicked: {join_error}" + ))) + })??; + + let (bundle, amount_base_units, counter) = built; + let coin_spends = bundle.coin_spends.len(); + + self.broadcaster.broadcast(&bundle).await.map_err(|error| { + ClaimPortError::Other(bounded(format!("broadcast refused: {error}"))) + })?; + + tracing::info!( + target: "rewards_claim", + %launcher_id, + amount_base_units, + counter, + coin_spends, + "InitiatePayout submitted" + ); + + Ok(()) + } +} + +/// The production [`LauncherIndex`]: proposes every launcher coin this node's own peers have seen +/// hinted with the DIG rewards distributor hint (SPEC 13.1 clause 5's literal, +/// `"Reward Distributor v1"`, tree-hashed here rather than written as a hash literal), filtered to +/// an actual singleton-launcher coin. +/// +/// Every id this proposes is still re-verified through `discover_distributor` by +/// [`RealClaimChainPort::discover_distributors`] -- a hinted coin that is not really a +/// DIG-rewards-launching singleton launcher yields nothing, it is never trusted directly. +pub struct HintedLauncherIndex { + wallet_chain: Arc, +} + +impl HintedLauncherIndex { + /// Wraps the node's own wallet chain transport -- the SAME `Arc` `server.rs` holds as + /// `state.wallet_chain`. + #[must_use] + pub fn new(wallet_chain: Arc) -> Self { + Self { wallet_chain } + } +} + +#[async_trait] +impl LauncherIndex for HintedLauncherIndex { + async fn launcher_ids(&self) -> Result, ClaimPortError> { + use dig_wallet::sage::fallback::ChainFallback; + + // SPEC 13.1 clause 5: the hint is COMPUTED as the tree hash of the literal string, never a + // hash literal -- the identical discipline `dig_rewards_coin::discovery`'s own decode + // applies to the same constant. + let mut allocator = clvmr::Allocator::new(); + let hint_ptr = clvm_traits::ToClvm::to_clvm(&"Reward Distributor v1", &mut allocator) + .map_err(|error| { + ClaimPortError::Other(bounded(format!( + "could not allocate the launcher hint literal: {error}" + ))) + })?; + let hint: chia_protocol::Bytes32 = clvm_utils::tree_hash(&allocator, hint_ptr).into(); + let hint_hex = hex::encode(hint.to_bytes()); + + let coins = self + .wallet_chain + .coin_records_by_hints(&[hint_hex]) + .await + .map_err(|error| ClaimPortError::Other(bounded(error.to_string())))?; + + let launcher_hash_hex = hex::encode(chia_puzzles::SINGLETON_LAUNCHER_HASH); + + Ok(coins + .into_iter() + .filter(|coin| coin.puzzle_hash == launcher_hash_hex) + .filter_map(|coin| { + hex::decode(&coin.coin_id) + .ok() + .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok()) + .map(Bytes32::from) + }) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chia_sdk_driver::{RewardDistributorType, RoundRewardInfo, RoundTimeInfo}; + use chia_sdk_types::puzzles::RewardDistributorEntrySlotValue; + + fn some_constants(precision: u64) -> RewardDistributorConstants { + RewardDistributorConstants { + launcher_id: Bytes32::new([1; 32]), + reward_distributor_type: RewardDistributorType::Managed { + manager_singleton_launcher_id: Bytes32::new([7; 32]), + }, + fee_payout_puzzle_hash: Bytes32::new([2; 32]), + epoch_seconds: 1, + precision, + max_seconds_offset: 0, + payout_threshold: 0, + require_payout_approval: false, + fee_bps: 0, + withdrawal_share_bps: 0, + reserve_asset_id: Bytes32::new([3; 32]), + reserve_inner_puzzle_hash: Bytes32::new([4; 32]), + reserve_full_puzzle_hash: Bytes32::new([5; 32]), + } + } + + fn some_state(cumulative_payout: u128) -> RewardDistributorState { + RewardDistributorState { + total_reserves: 0, + active_shares: 0, + round_reward_info: RoundRewardInfo { + cumulative_payout, + remaining_rewards: 0, + }, + round_time_info: RoundTimeInfo { + last_update: 0, + epoch_end: 0, + }, + } + } + + /// No matching entry slot reads `Ok(None)` -- "no entry", never fabricated. + #[test] + fn no_entry_reads_ok_none() { + let constants = some_constants(100); + let state = some_state(1_000); + assert_eq!( + own_entry_from_slot(Bytes32::from([0x42; 32]), &constants, &state, None), + Ok(None) + ); + } + + /// SHAPE guard: a found entry's accrued amount comes from `dig_rewards_coin::accrued_base_units` + /// -- the puzzle's own arithmetic -- never a fabricated `0`. Mutation-proved: replacing this + /// function's `Some(accrued_base_units)` arm with `Some(0)` turns this test red. + #[test] + fn a_found_entry_reports_the_real_accrued_amount_never_zero() { + let constants = some_constants(100); + let state = some_state(1_000); + let payout_puzzle_hash = Bytes32::from([0x42; 32]); + let entry = RewardDistributorEntrySlotValue { + counter: 1, + payout_puzzle_hash, + initial_cumulative_payout: 200, + shares: 10, + }; + + let result = own_entry_from_slot(payout_puzzle_hash, &constants, &state, Some(&entry)); + + // (1_000 - 200) * 10 / 100 = 80 -- the puzzle's own figure, mirroring + // `dig_rewards_coin::payout`'s own equality-tested arithmetic. + assert_eq!( + result, + Ok(Some(OwnEntry { + payout_puzzle_hash, + counter: 1, + accrued_base_units: 80, + })) + ); + } + + /// A diverged read (`state`'s cumulative payout behind the entry's own) refuses rather than + /// reporting a wrapped or fabricated figure. + #[test] + fn a_diverged_read_refuses_rather_than_wraps() { + let constants = some_constants(100); + let state = some_state(50); + let payout_puzzle_hash = Bytes32::from([0x42; 32]); + let entry = RewardDistributorEntrySlotValue { + counter: 1, + payout_puzzle_hash, + initial_cumulative_payout: 200, + shares: 10, + }; + + let result = own_entry_from_slot(payout_puzzle_hash, &constants, &state, Some(&entry)); + assert!( + matches!(result, Err(ClaimPortError::Other(_))), + "an overflowed/underflowed accrual must refuse by name, never answer Ok at all: \ + got {result:?}" + ); + } + + /// #3357's phantom-slot trap: `RewardDistributor::created_slot_value_to_slot` on a + /// chain-rebuilt distributor derives a well-formed but PHANTOM `LineageProof` for a slot an + /// earlier generation created. This adapter must read every entry slot through + /// `ChainEntrySlotSource`/`snapshot.entry_slot(..)`, never that method. A literal-string check + /// rather than a compile-time one so it still catches the call even via a re-export or a fully + /// qualified path. + /// + /// Scoped to CODE lines only (comment lines, `//`/`///`/`//!`, are dropped first) -- the module + /// doc and this file's own inline warning both name the trap in prose, which must not trip the + /// guard meant to catch an actual call. Also scoped to the file's own non-test region: this + /// test's name/assertion text contains the literal string, so an unscoped scan over the whole + /// file would be self-defeating. + #[test] + fn adapter_source_never_calls_created_slot_value_to_slot() { + let production_src = production_region(include_str!("chain_port.rs")); + let code_only: String = production_src + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + assert!( + !code_only.contains("created_slot_value_to_slot"), + "chain_port.rs must never call created_slot_value_to_slot -- #3357 phantom-slot trap" + ); + } + + /// The slice of this source file before its own `#[cfg(test)]` module -- i.e. what actually + /// ships. Falls back to the whole file if there is no such marker. + fn production_region(source: &str) -> &str { + match source.find("#[cfg(test)]") { + Some(test_module_start) => &source[..test_module_start], + None => source, + } + } +} diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index 4d4bdd52..a62eb998 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -21,13 +21,15 @@ //! runs a cycle, then repeats — the counter is genuinely `0` until the first interval elapses. //! //! # No RPC surface here (SCOPE) -//! [`handle`] is an IN-PROCESS accessor only — a future RPC (blocked on DIG-Network/dig_ecosystem#3249 -//! re-deriving the `ClaimStatus` wire semantics) can read it; this module puts nothing on the wire -//! and adds no RPC method, dispatch-table row or handler. +//! [`handle`] is an IN-PROCESS accessor only — no RPC reads it yet; this module puts nothing on +//! the wire and adds no RPC method, dispatch-table row or handler. //! -//! # The only production adapter is [`super::UnavailableClaimChainPort`] -//! #3249 has not landed, so every real cycle this driver runs reports [`super::ClaimLoopState::ChainSourceUnavailable`] -//! and submits nothing — the honest state, not an invented adapter. +//! # The production adapter is [`super::RealClaimChainPort`] +//! Built from `wallet_chain.corroborated_chain_source(..)` -- the SAME call `server.rs`'s +//! funder-side install already makes -- paired with [`super::HintedLauncherIndex`]. If that source +//! cannot be built (offline, no peers), [`run_claim_driver`] records +//! [`ClaimDriverRefusal::ChainSourceUnbuildable`] and never builds an engine at all, rather than +//! falling back to [`super::UnavailableClaimChainPort`] silently. use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; @@ -37,10 +39,13 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use chia_protocol::Bytes32; use super::cadence::{next_interval_seconds, JitterSource}; +use super::chain_port::{HintedLauncherIndex, RealClaimChainPort}; use super::config::{RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT}; -use super::engine::ClaimEngine; +use super::engine::{ClaimCadences, ClaimEngine, RawConfiguredCadence}; use super::hints::{DistributorHintSource, NoHintSource}; -use super::port::{ClaimChainPort, UnavailableClaimChainPort}; +use super::port::ClaimChainPort; +#[cfg(test)] +use super::port::UnavailableClaimChainPort; use super::types::{ClaimLoopState, ClaimStatus}; /// The in-process accessor onto the running claim loop (SCOPE: never exposed over the wire here). @@ -80,6 +85,15 @@ pub enum ClaimDriverRefusal { /// `enabled = true`, chain sync is on, but this node has no operator wallet to derive /// [`own_payout_puzzle_hash`] from -- there is no puzzle hash to build an engine with at all. NoOperatorWallet, + /// `enabled = true`, chain sync is on, this node HAS an operator wallet, but + /// `wallet_chain.corroborated_chain_source(..)` itself errored (offline, no peers) -- there is + /// no chain source to build [`super::RealClaimChainPort`] with. Never silently substitutes + /// [`super::UnavailableClaimChainPort`] instead (SHAPE: a named refusal, not a fallback). + ChainSourceUnbuildable, + /// `enabled = true`, chain sync is on, this node has a chain source, but + /// `wallet_chain.broadcaster(..)` itself errored -- there is no broadcaster to submit a real + /// `InitiatePayout` spend with. Never silently proceeds without one. + BroadcasterUnbuildable, } impl ClaimLoopHandle { @@ -139,7 +153,7 @@ impl ClaimLoopHandle { /// "not spawned yet" as a THIRD state distinct from `Idle` -- it is the same state, honestly. static HANDLE: OnceLock = OnceLock::new(); -/// The in-process accessor a future RPC (blocked on #3249) reads. Never wired onto the wire here. +/// The in-process accessor a future RPC could read. Never wired onto the wire here. #[must_use] pub fn handle() -> ClaimLoopHandle { HANDLE.get_or_init(ClaimLoopHandle::default).clone() @@ -162,6 +176,7 @@ async fn drive( P: ClaimChainPort, H: DistributorHintSource, { + let adapter = engine.port_kind(); loop { let interval = next_interval_seconds(cadence_seconds, jitter_seconds, jitter); tokio::time::sleep(Duration::from_secs(interval)).await; @@ -169,15 +184,15 @@ async fn drive( engine.run_cycle(t).await; let status = engine.status(); handle.record(status); - log_cycle(&status, handle.cycles_driven(), &adjustment); + log_cycle(&status, handle.cycles_driven(), &adjustment, adapter); } } /// Emit the ONE record that makes a driven cycle observable in a running node. /// /// Without this, the whole status surface has no reader in a shipped binary: [`handle`] is -/// in-process only and deliberately carries no RPC (deferred to DIG-Network/dig_ecosystem#3249), -/// so a node whose claim loop can never claim a single reward would produce output IDENTICAL to a +/// in-process only and deliberately carries no RPC yet, so a node whose claim loop can never +/// claim a single reward would produce output IDENTICAL to a /// healthy one -- silence. A status nobody can read is a doc claim, not a measurement. /// /// [`ClaimLoopState::Nominal`] is the routine case (`info`). Every other state means this peer is @@ -188,7 +203,12 @@ async fn drive( /// named on every single cycle line, not just in the once-per-spawn WARN `sanitized_schedule` /// itself emits. Without this, an operator reading any one cycle log line has no way to learn the /// schedule in force differs from the one they configured. -fn log_cycle(status: &ClaimStatus, cycles_driven: u64, adjustment: &ScheduleAdjustment) { +fn log_cycle( + status: &ClaimStatus, + cycles_driven: u64, + adjustment: &ScheduleAdjustment, + adapter: &'static str, +) { let (configured_cadence_seconds, effective_cadence_seconds) = adjustment .cadence .map_or((None, None), |(c, e)| (Some(c), Some(e))); @@ -199,6 +219,7 @@ fn log_cycle(status: &ClaimStatus, cycles_driven: u64, adjustment: &ScheduleAdju if status.state == ClaimLoopState::Nominal { tracing::info!( target: "rewards_claim", + adapter, state = ?status.state, cycles_driven, distributors_known = status.distributors_known, @@ -213,6 +234,7 @@ fn log_cycle(status: &ClaimStatus, cycles_driven: u64, adjustment: &ScheduleAdju } else { tracing::warn!( target: "rewards_claim", + adapter, state = ?status.state, cycles_driven, distributors_known = status.distributors_known, @@ -281,15 +303,36 @@ pub fn own_payout_puzzle_hash(owner_inner_puzzle_hash: Bytes32) -> Bytes32 { /// The real, detached claim-loop task: derive this node's own payout puzzle hash from its operator /// wallet (the same public, no-unseal-required derivation [`crate::server::spawn_mirror_passes`] -/// falls back to), load [`RewardsClaimConfig`], build a [`ClaimEngine`] against the only -/// production adapter that exists ([`UnavailableClaimChainPort`] -- see this module's doc), and -/// drive it forever. +/// falls back to), load [`RewardsClaimConfig`], build a [`ClaimEngine`] against the production +/// [`super::RealClaimChainPort`] adapter (see this module's doc), and drive it forever. /// /// Never called directly by `server.rs` -- see [`spawn_claim_driver_if`], the tested gate that /// decides WHETHER to call this. `handle` is INJECTED (never the [`handle`] singleton read /// directly) so a test can drive this against a private, non-shared handle instead of the /// process-wide one. -async fn run_claim_driver(handle: ClaimLoopHandle) { +/// The production chain-port factory, split out of [`run_claim_driver`] so its return TYPE can be +/// pinned by `const _: fn(...) = production_claim_port;` in the test module -- a retyped factory +/// (e.g. one that starts returning [`super::UnavailableClaimChainPort`]) is then a COMPILE error, +/// not just a string-guard failure. See `run_claim_drivers_happy_path_actually_constructs_the_real_adapter` +/// for the companion source-reading guard, which catches a bypass of this factory entirely. +#[allow(clippy::let_and_return)] // the local `port` binding is the literal string the guard test + // in `production_region` searches this source for. +fn production_claim_port( + source: std::sync::Arc, + index: HintedLauncherIndex, + broadcaster: std::sync::Arc, +) -> RealClaimChainPort< + dig_wallet::sage::corroborated_source::CorroboratedChainSource, + HintedLauncherIndex, +> { + let port = RealClaimChainPort::new(source, index, broadcaster); + port +} + +async fn run_claim_driver( + handle: ClaimLoopHandle, + wallet_chain: std::sync::Arc, +) { let paths = dig_wallet::autoseed::default_paths(); let Some(owner_inner_puzzle_hash) = dig_wallet::operator_wallet::operator_puzzle_hash(&paths) else { @@ -304,10 +347,50 @@ async fn run_claim_driver(handle: ClaimLoopHandle) { }; let own_payout_puzzle_hash = own_payout_puzzle_hash(owner_inner_puzzle_hash); + // The SAME call server.rs's funder-side install already makes -- see this module's doc. + // A named refusal, never a silent fallback to `UnavailableClaimChainPort`. + let source = match wallet_chain.corroborated_chain_source(tokio::runtime::Handle::current()) { + Ok(source) => source, + Err(error) => { + tracing::warn!( + target: "rewards_claim", + %error, + "could not build a corroborated chain source, so this node has no chain to claim \ + against; the claim loop is NOT started -- rewards_claim.enabled stays true but no \ + cycle will ever run until this node has peer reads to corroborate against" + ); + handle.set_refusal(ClaimDriverRefusal::ChainSourceUnbuildable); + return; + } + }; + + // A named refusal, never a silent proceed-without-broadcast -- see `ClaimDriverRefusal`'s doc. + let broadcaster = match wallet_chain.broadcaster().await { + Ok(broadcaster) => broadcaster, + Err(error) => { + tracing::warn!( + target: "rewards_claim", + %error, + "could not build a broadcaster, so this node has no way to submit a real \ + InitiatePayout spend; the claim loop is NOT started -- rewards_claim.enabled \ + stays true but no cycle will ever run until this node can broadcast" + ); + handle.set_refusal(ClaimDriverRefusal::BroadcasterUnbuildable); + return; + } + }; + + let port = production_claim_port( + std::sync::Arc::new(source), + HintedLauncherIndex::new(wallet_chain), + broadcaster, + ); + run_claim_driver_in( &crate::state::state_dir(), own_payout_puzzle_hash, - UnavailableClaimChainPort, + dig_mirror_coin::DIG_ASSET_ID, + port, handle, ) .await; @@ -320,9 +403,8 @@ async fn run_claim_driver(handle: ClaimLoopHandle) { /// tested gate and the tested [`drive`] loop was previously the only UNTESTED link in the chain, /// and an untested joint is exactly how #594's claim engine shipped complete and inert. /// -/// Generic over `P` so a test can drive this real body against a fake port; production always -/// passes [`UnavailableClaimChainPort`] (see the module doc -- there is deliberately no second -/// production adapter until #3249 lands). +/// Generic over `P` so a test can drive this real body against a fake port; production passes +/// [`super::RealClaimChainPort`] (see the module doc). /// The largest schedule value this driver will honour, in seconds: 31 days. Chosen to sit /// comfortably above every documented default -- [`CLAIM_CADENCE_SECONDS_DEFAULT`] is 86,400s /// (1 day) and [`CLAIM_JITTER_SECONDS_DEFAULT`] is 3,600s (1 hour) -- and above any plausible @@ -335,7 +417,7 @@ async fn run_claim_driver(handle: ClaimLoopHandle) { /// this ticket: the claim loop never fires again, so no cycle, no `log_cycle` line, and the /// cycle counter reads a permanent, reassuring `0`. #594 shipped an engine that was inert and /// green; a config value must not be able to put this driver back in that state silently. -const CLAIM_SCHEDULE_SECONDS_MAX: u64 = 31 * 24 * 60 * 60; +pub(crate) const CLAIM_SCHEDULE_SECONDS_MAX: u64 = 31 * 24 * 60 * 60; /// [`sanitized_schedule`]'s call-scoped report of what it changed, threaded into [`drive`] and /// on into [`log_cycle`] -- NEVER stored on [`ClaimEngine`] as a field (see this module's SHAPE @@ -431,13 +513,57 @@ fn sanitized_schedule(cadence_seconds: u64, jitter_seconds: u64) -> (u64, u64, S (cadence, jitter, adjustment) } -async fn run_claim_driver_in

( +/// Public ONLY for DIG-Network/dig_ecosystem#3347's acceptance integration test +/// (`tests/rewards_claim_chain_port_3347.rs`), which needs to drive the real production body end +/// to end against a real `RealClaimChainPort`. Not part of this crate's public API otherwise -- +/// every other caller reaches this exclusively through [`spawn_claim_driver_from_config`]. +#[doc(hidden)] +pub async fn run_claim_driver_in

( state_dir: &Path, own_payout_puzzle_hash: Bytes32, + reserve_asset_id: Bytes32, port: P, handle: ClaimLoopHandle, ) where P: ClaimChainPort, +{ + run_claim_driver_in_with_clock( + state_dir, + own_payout_puzzle_hash, + reserve_asset_id, + port, + handle, + unix_now_seconds, + ) + .await; +} + +/// The same production body as [`run_claim_driver_in`], with the clock [`drive`] ticks on taken +/// as a parameter instead of hardcoded to [`unix_now_seconds`] (real wall-clock). +/// +/// # DIG-Network/dig_ecosystem#3336: why this seam exists +/// `tokio::time::advance` (the mechanism every other test in this module uses to fast-forward +/// [`drive`]'s `sleep`, under `#[tokio::test(start_paused = true)]`) moves ONLY the tokio virtual +/// clock -- it cannot move [`SystemTime::now()`], which is what [`unix_now_seconds`] reads. Before +/// this seam, [`run_claim_driver_in`] was therefore untestable for anything that depends on the +/// VALUE `now()` returns each cycle (the restart-safety gate, the persisted fee-budget window's +/// roll condition) -- a test could advance the scheduler's ticks but every cycle would still see +/// the same real `now()`, so a defect in either cadence value threaded through +/// [`ClaimEngine::with_persisted_fee_window`] could not be observed through THIS function, only by +/// hand-assembling `drive` directly (see `the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`, +/// which had to do exactly that before this seam existed). +/// +/// [`run_claim_driver_in`] stays a thin wrapper that passes the real clock, so no caller of it -- +/// including `run_claim_driver`, the only production caller -- changes at all. +async fn run_claim_driver_in_with_clock

( + state_dir: &Path, + own_payout_puzzle_hash: Bytes32, + reserve_asset_id: Bytes32, + port: P, + handle: ClaimLoopHandle, + now: impl FnMut() -> u64, +) where + P: ClaimChainPort, { let cfg = RewardsClaimConfig::load_from(state_dir); // A4/F8: a corrupt config is not a reason to refuse to SPAWN -- `ClaimEngine::run_cycle` @@ -460,19 +586,22 @@ async fn run_claim_driver_in

( own_payout_puzzle_hash, cfg.max_fee_mojos, cfg.max_cycle_fee_budget_mojos, - dig_mirror_coin::DIG_ASSET_ID, + reserve_asset_id, ) .with_rotation_cursor(cfg.rotation_cursor) - // F2: two DIFFERENT cadence values, deliberately -- `cadence_seconds` (CLAMPED, already - // bounded to `CLAIM_SCHEDULE_SECONDS_MAX`) gates WHEN a cycle may run, tracking the same - // schedule the driver below actually sleeps on. `cfg.cadence_seconds` (RAW, unclamped) sizes - // the persisted fee-budget window -- reusing the clamped value there would double the number - // of budget windows a long-cadence operator sized (a 60-day config would get ~12 windows/year - // instead of the ~6 its cadence implies -- 2x the fee ceiling they configured). Conflating the - // two into one value in either direction is wrong: clamped-for-both doubles the fee ceiling, - // raw-for-both can silently starve the gate (an unbounded-above raw cadence would stop cycles - // from ever running while the scheduler keeps ticking on the clamped interval). - .with_persisted_fee_window(state_dir, cadence_seconds, cfg.cadence_seconds); + // F2 (money): ONE argument, and it must be the RAW `cfg.cadence_seconds`. `ClaimCadences` + // derives both halves from it -- the CLAMPED gate (bounded to `CLAIM_SCHEDULE_SECONDS_MAX`, + // so WHEN a cycle may run tracks the same schedule the driver below actually sleeps on) and + // the RAW fee window (how long the persisted fee-budget window stays open). The one argument + // makes them impossible to transpose, but NOT impossible to get wrong: passing the clamped + // local `cadence_seconds` here instead of `cfg.cadence_seconds` has the same type, compiles, + // and halves the fee window -- ~12 budget windows a year for a 60-day operator instead of the + // ~6 their cadence implies, i.e. 2x the fee ceiling they configured. Exactly one test catches + // that: `tests::the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`. + .with_persisted_fee_window( + state_dir, + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), + ); drive( engine, @@ -480,7 +609,7 @@ async fn run_claim_driver_in

( jitter_seconds, adjustment, &OsJitter, - unix_now_seconds, + now, handle, ) .await; @@ -489,8 +618,11 @@ async fn run_claim_driver_in

( /// Spawn the real claim-loop task, detached, against `handle` -- injected, never the [`handle`] /// singleton read from inside, so the only place the process-wide singleton is named is /// [`spawn_claim_driver_from_config`]. -fn spawn_claim_driver(handle: ClaimLoopHandle) { - tokio::spawn(run_claim_driver(handle)); +fn spawn_claim_driver( + handle: ClaimLoopHandle, + wallet_chain: std::sync::Arc, +) { + tokio::spawn(run_claim_driver(handle, wallet_chain)); } /// Why [`spawn_claim_driver_if`] declined to spawn -- named so the caller can log a reason instead @@ -557,14 +689,20 @@ fn spawn_claim_driver_if( /// Reads [`RewardsClaimConfig::load`] (the node's own state-dir config) and `enable_chain_sync`, /// and calls [`spawn_claim_driver_if`] -- the exact one call `serve_with_shutdown` makes. -pub fn spawn_claim_driver_from_config(enable_chain_sync: bool) { +/// `wallet_chain` is this node's own wallet chain transport, the SAME `Arc` `server.rs` holds as +/// `state.wallet_chain` -- threaded through to [`run_claim_driver`] to build the production +/// [`super::RealClaimChainPort`]. +pub fn spawn_claim_driver_from_config( + enable_chain_sync: bool, + wallet_chain: std::sync::Arc, +) { let cfg = RewardsClaimConfig::load(); // The ONE place the process-wide singleton is read: everything below it takes an injected // handle so it stays testable in-process. let process_handle = handle(); let driver_handle = process_handle.clone(); spawn_claim_driver_if(cfg.enabled, enable_chain_sync, &process_handle, move || { - spawn_claim_driver(driver_handle); + spawn_claim_driver(driver_handle, wallet_chain); }); } @@ -699,7 +837,11 @@ mod tests { return; // this machine HAS an operator wallet; the refusal branch is unreachable here } let handle = ClaimLoopHandle::default(); - run_claim_driver(handle.clone()).await; + run_claim_driver( + handle.clone(), + Arc::new(dig_wallet::sage::chain::ChainTransport::new()), + ) + .await; assert_eq!( handle.refusal(), Some(ClaimDriverRefusal::NoOperatorWallet), @@ -708,6 +850,135 @@ mod tests { assert_eq!(handle.cycles_driven(), 0, "and it must drive no cycle"); } + /// SHAPE guard: when this node HAS an operator wallet but its wallet chain transport cannot + /// build a corroborated source (offline, no peers -- `ChainTransport::new()`'s bare default), + /// `run_claim_driver` must record the named `ChainSourceUnbuildable` refusal, never fall back + /// to `UnavailableClaimChainPort` silently. Skipped on a machine with no operator wallet at + /// all -- that is the OTHER, earlier refusal, proven above. + #[tokio::test] + async fn an_unbuildable_chain_source_is_a_distinct_named_refusal() { + let paths = dig_wallet::autoseed::default_paths(); + if dig_wallet::operator_wallet::operator_puzzle_hash(&paths).is_none() { + return; // no operator wallet on this machine: the earlier refusal fires first + } + let handle = ClaimLoopHandle::default(); + run_claim_driver( + handle.clone(), + Arc::new(dig_wallet::sage::chain::ChainTransport::new()), + ) + .await; + assert_eq!( + handle.refusal(), + Some(ClaimDriverRefusal::ChainSourceUnbuildable), + "a chain transport with no peer reads must be a named refusal, not a silent \ + UnavailableClaimChainPort substitution" + ); + assert_eq!(handle.cycles_driven(), 0, "and it must drive no cycle"); + } + + /// SHAPE (6d, the #3310-class trap "compiles, nothing constructs it"): [`RealClaimChainPort`] + /// itself, built against a mock source with the fixture-provided [`LauncherIndex`], names + /// itself `"real-corroborated"`. This does NOT exercise `run_claim_driver`'s own construction + /// line -- see `run_claim_drivers_happy_path_actually_constructs_the_real_adapter` below for + /// the guard on THAT (this machine has no operator wallet, so `run_claim_driver`'s happy path + /// cannot be driven end to end here; that guard reads its own shipped source instead). + #[tokio::test] + async fn the_production_factory_builds_a_real_corroborated_port() { + struct NoLauncherIds; + #[async_trait] + impl super::super::chain_port::LauncherIndex for NoLauncherIds { + async fn launcher_ids(&self) -> Result, ClaimPortError> { + Ok(Vec::new()) + } + } + + let source = dig_chainsource_interface::MockChainSource::new(); + let port = RealClaimChainPort::new( + Arc::new(source), + NoLauncherIds, + Arc::new(dig_wallet::sage::spend::MockBroadcaster::default()), + ); + assert_eq!( + ClaimChainPort::kind(&port), + "real-corroborated", + "the production adapter must name itself, not inherit UnavailableClaimChainPort's name" + ); + } + + /// SHAPE guard: `run_claim_driver`'s happy-path construction line must actually build + /// [`RealClaimChainPort`], never [`UnavailableClaimChainPort`] -- checked by reading this + /// module's own SHIPPED source (the production region, before this `#[cfg(test)]` module), + /// the same shape `rewards_chain_port_a3.rs`'s `install_reward_chain_port_refuses_a_second_install_with_a_warn` + /// and `chain_port.rs`'s `adapter_source_never_imports_withdraw_committed_incentives` already + /// use for a call site no test on this machine can drive behaviourally (this machine has no + /// operator wallet, so `run_claim_driver`'s happy path -- past both named refusals -- is + /// unreachable here). Mutation-proved: replacing the production `let port = + /// RealClaimChainPort::new(` line with `UnavailableClaimChainPort` turns this assertion red. + #[test] + fn run_claim_drivers_happy_path_actually_constructs_the_real_adapter() { + let source = production_region(include_str!("driver.rs")); + assert!( + source.contains("let port = RealClaimChainPort::new("), + "run_claim_driver's happy path must construct RealClaimChainPort, not silently fall \ + back to UnavailableClaimChainPort or anything else" + ); + } + + /// #3347/U3 SHAPE guard: `run_claim_driver`, the only production caller of + /// [`run_claim_driver_in`], must pass the REAL reserve asset, `dig_mirror_coin::DIG_ASSET_ID` + /// -- never a placeholder like `Bytes32::default()`, which would silently make the engine + /// treat every real distributor as `NotOurs`. Same shape as + /// `run_claim_drivers_happy_path_actually_constructs_the_real_adapter` above: a call site no + /// test on this machine can drive behaviourally (no operator wallet here), so read the + /// SHIPPED source instead. Mutation-proved: replacing the production + /// `dig_mirror_coin::DIG_ASSET_ID` argument with `Bytes32::default()` turns this assertion red. + #[test] + fn run_claim_driver_passes_the_real_reserve_asset_id() { + // CRLF-normalized: this file is checked out with `\r\n` line endings, which would break a + // literal `\n`-joined needle otherwise. + let source = production_region(include_str!("driver.rs")).replace("\r\n", "\n"); + assert!( + source.contains( + "run_claim_driver_in(\n &crate::state::state_dir(),\n \ + own_payout_puzzle_hash,\n dig_mirror_coin::DIG_ASSET_ID," + ), + "run_claim_driver must pass dig_mirror_coin::DIG_ASSET_ID as run_claim_driver_in's \ + reserve_asset_id argument, not a placeholder" + ); + } + + /// The slice of this file before its own `#[cfg(test)] mod tests` block -- i.e. what actually + /// ships. Searches for `"#[cfg(test)]\nmod tests"` specifically, never the bare + /// `"#[cfg(test)]"` marker: this file also has an EARLIER `#[cfg(test)] use` gating a single + /// test-only import, which the bare marker would match first and cut the slice off far too + /// early, before the very production code this helper exists to check. + fn production_region(source: &str) -> &str { + match source.find("#[cfg(test)]\nmod tests") { + Some(test_module_start) => &source[..test_module_start], + None => source, + } + } + + /// Compile-time companion to `run_claim_drivers_happy_path_actually_constructs_the_real_adapter`: + /// pins [`production_claim_port`]'s TYPE, not just its source text. The string guard above + /// catches a bypass of the factory (some other construction spliced into `run_claim_driver`); + /// this catches the factory itself being RETYPED to return + /// [`super::UnavailableClaimChainPort`] (or anything else) -- a change the string guard cannot + /// see because `UnavailableClaimChainPort`'s own construction line would satisfy no textual + /// assertion this file makes, but a retyped factory would still compile and run. Mutation-proved: + /// changing `production_claim_port`'s return type is a compile error here. + type ProductionClaimPortFactory = fn( + std::sync::Arc, + HintedLauncherIndex, + std::sync::Arc, + ) -> RealClaimChainPort< + dig_wallet::sage::corroborated_source::CorroboratedChainSource, + HintedLauncherIndex, + >; + + #[allow(dead_code)] // referenced only for its type, never called + const _: ProductionClaimPortFactory = production_claim_port; + // ---- A1 + A2: the anti-silence cycle counter through the real drive() loop ------------- /// A fake port whose every call succeeds with an empty/zero answer -- enough to let @@ -752,6 +1023,10 @@ mod tests { ) -> Result<(), ClaimPortError> { Ok(()) } + + fn kind(&self) -> &'static str { + "test-empty" + } } fn empty_engine() -> ClaimEngine { @@ -913,6 +1188,10 @@ mod tests { ) -> Result<(), ClaimPortError> { Ok(()) } + + fn kind(&self) -> &'static str { + "test-one-distributor" + } } #[tokio::test] @@ -992,7 +1271,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), + ); let outcomes = engine.run_cycle(900).await; // 900 - 500 = 400 < 1_000 assert!(outcomes.is_empty()); @@ -1023,6 +1305,12 @@ mod tests { /// - the WINDOW must NOT roll at tick 2 (elapsed since it opened is one clamped interval, /// 2_678_400s, well under the raw 5_184_000s the operator configured) but MUST have rolled /// by tick 3 (elapsed is 2 clamped intervals, 5_356_800s, past the raw boundary). + /// + /// This test builds its `ClaimCadences` itself, so it stays GREEN if the PRODUCTION call site + /// in [`run_claim_driver_in_with_clock`] is mutated to pass the clamped local instead of the raw + /// `cfg.cadence_seconds`. It is therefore not a duplicate of + /// [`the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`], which is the only + /// test that catches that mutation -- do not delete that one as redundant with this one. #[tokio::test(start_paused = true)] async fn the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one() { let configured_cadence = 60 * 24 * 60 * 60u64; // 5_184_000, RAW -- sizes the fee window. @@ -1039,7 +1327,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), effective_cadence, configured_cadence); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(configured_cadence)), + ); let handle = ClaimLoopHandle::default(); let h = handle.clone(); @@ -1070,10 +1361,11 @@ mod tests { tokio::time::advance(Duration::from_secs(effective_cadence)).await; settle().await; assert_eq!(handle.cycles_driven(), 1); - assert_ne!( + assert_eq!( handle.status().state, - super::super::types::ClaimLoopState::CadenceNotElapsed, - "the very first cycle has no prior completion to gate against" + super::super::types::ClaimLoopState::Nominal, + "the very first cycle has no prior completion to gate against, so it must run to \ + completion and report Nominal" ); let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1093,11 +1385,14 @@ mod tests { "F2: the gate must track the CLAMPED cadence -- a cycle sized from the raw 5_184_000 \ cadence would still be refused here, reproducing the silent-non-claiming defect" ); - assert_ne!( + assert_eq!( handle.status().state, - super::super::types::ClaimLoopState::CadenceNotElapsed, + super::super::types::ClaimLoopState::Nominal, "F2: the gate opened one clamped interval after the last completion -- it must not \ - still be waiting on the raw 5_184_000s cadence" + still be waiting on the raw 5_184_000s cadence. Asserting the exact state, not \ + merely `!= CadenceNotElapsed`: that exclusion is equally satisfied by \ + PersistedStateCorrupt and ChainSourceUnavailable, whose early returns sit above the \ + window-roll block too, so it would go vacuous the moment one of those fired instead" ); let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; assert_eq!( @@ -1138,7 +1433,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), + ); let outcomes = engine.run_cycle(2_000).await; // 2_000 - 500 = 1_500 >= 1_000 assert!(outcomes.is_empty(), "nothing to claim, but the cycle RAN"); @@ -1166,7 +1464,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), cfg.cadence_seconds, cfg.cadence_seconds); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(cfg.cadence_seconds)), + ); let outcomes = engine.run_cycle(100).await; // now < last_cycle_completed_at assert!(outcomes.is_empty()); @@ -1198,7 +1499,10 @@ mod tests { 10, Bytes32::from([2u8; 32]), ) - .with_persisted_fee_window(dir.path(), 1_000, 1_000); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(1_000)), + ); let outcomes = engine.run_cycle(1).await; assert!(outcomes.is_empty()); @@ -1243,7 +1547,14 @@ mod tests { let h = handle.clone(); let state_dir = dir.path().to_path_buf(); let driver = tokio::spawn(async move { - run_claim_driver_in(&state_dir, Bytes32::from([1u8; 32]), EmptyPort, h).await; + run_claim_driver_in( + &state_dir, + Bytes32::from([1u8; 32]), + dig_mirror_coin::DIG_ASSET_ID, + EmptyPort, + h, + ) + .await; }); settle().await; @@ -1275,12 +1586,11 @@ mod tests { driver.abort(); } - /// The same production body against the port production ACTUALLY passes it - /// ([`UnavailableClaimChainPort`], the only adapter until #3249) reports - /// [`ClaimLoopState::ChainSourceUnavailable`] by name once a cycle has been driven -- the - /// honest state of a real node today. Proves the real adapter path is reached, not only a fake - /// one: a counted cycle whose outcome names the missing chain source, never a reassuring - /// `Nominal` and never silence. + /// The same production body [`run_claim_driver_in`] runs, against [`UnavailableClaimChainPort`] + /// (now only the engine's test double -- production passes [`super::RealClaimChainPort`]), + /// reports [`ClaimLoopState::ChainSourceUnavailable`] by name once a cycle has been driven: a + /// counted cycle whose outcome names the missing chain source, never a reassuring `Nominal` + /// and never silence. #[tokio::test(start_paused = true)] async fn the_production_adapter_reports_chain_source_unavailable_by_name() { let cadence = 100u64; @@ -1294,6 +1604,7 @@ mod tests { run_claim_driver_in( &state_dir, Bytes32::from([1u8; 32]), + dig_mirror_coin::DIG_ASSET_ID, UnavailableClaimChainPort, h, ) @@ -1320,6 +1631,133 @@ mod tests { driver.abort(); } + /// F2/#3336, MONEY, THE JOINT VERSION: [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] + /// proves the gate/window split by hand-assembling `drive` directly. This test proves the SAME + /// property through [`run_claim_driver_in_with_clock`] -- the actual production body, the one + /// that threads `sanitized_schedule`'s CLAMPED cadence and the RAW `cfg.cadence_seconds` into + /// [`ClaimEngine::with_persisted_fee_window`] at driver.rs's one call site. + /// + /// THIS IS THE ONLY TEST THAT CATCHES THE ONE MUTATION STILL LEFT AT THAT CALL SITE. The call + /// takes a single argument now, so the historic two-argument transposition cannot be written + /// at all. What still compiles is passing the already-clamped local as the raw value -- + /// `ClaimCadences::from_raw(RawConfiguredCadence(cadence_seconds))`. Measured: the fee window + /// then rolls at tick 2 instead of tick 3, failing the tick-2 assertion below with + /// `left: Some(5356800)`, `right: Some(2678400)` -- half the window length, so 2x the + /// fee-budget windows the operator sized. The gate is NOT affected (clamping an + /// already-clamped value is the identity), and + /// [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] stays GREEN + /// under that mutation, because it hand-assembles `drive` and never traverses the production + /// call site. + /// + /// Uses a written config with a 60-day RAW cadence (`5_184_000`s, clamped to the 31-day + /// `CLAIM_SCHEDULE_SECONDS_MAX`, `2_678_400`s) and a clock that advances one clamped interval + /// per invocation, matching the scheduler's own tick -- the same pattern + /// [`the_gate_tracks_the_clamped_cadence_while_the_fee_window_tracks_the_raw_one`] uses, but + /// driven through the production body instead of a hand-built engine. + #[tokio::test(start_paused = true)] + async fn the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window() { + let configured_cadence = 60 * 24 * 60 * 60u64; // 5_184_000, RAW -- sizes the fee window. + let effective_cadence = 31 * 24 * 60 * 60u64; // 2_678_400, CLAMPED -- sizes the gate. + assert_eq!(configured_cadence, 5_184_000); + assert_eq!(effective_cadence, 2_678_400); + + let dir = tempfile::tempdir().unwrap(); + write_config(dir.path(), configured_cadence); + + let handle = ClaimLoopHandle::default(); + let h = handle.clone(); + let state_dir = dir.path().to_path_buf(); + let driver = tokio::spawn(async move { + run_claim_driver_in_with_clock( + &state_dir, + Bytes32::from([1u8; 32]), + dig_mirror_coin::DIG_ASSET_ID, + EmptyPort, + h, + { + let mut t = 0u64; + move || { + t += effective_cadence; + t + } + }, + ) + .await; + }); + + settle().await; + assert_eq!(handle.cycles_driven(), 0, "no interval has elapsed yet"); + + // Tick 1: the window opens for the first time. + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + // `cycles_driven()` alone is NOT gate evidence: `drive` increments it unconditionally + // after every `run_cycle` call returns, whatever that cycle's outcome was -- a cycle the + // internal gate REFUSED (`ClaimLoopState::CadenceNotElapsed`) still increments it. Assert + // on the reported STATE, which the gate's early `return` in `run_cycle` actually controls. + assert_eq!(handle.cycles_driven(), 1); + assert_eq!( + handle.status().state, + super::super::types::ClaimLoopState::Nominal, + "the very first cycle has no prior completion to gate against, so it must run to \ + completion and report Nominal" + ); + let after_tick_1 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_eq!( + after_tick_1, + Some(effective_cadence), + "the window opens on tick 1" + ); + + // Tick 2: one clamped interval since tick 1 -- the GATE must open (it tracks the clamped + // schedule the loop actually ticks on) but the WINDOW must NOT roll yet (only one clamped + // interval, 2_678_400s, of its raw 5_184_000s length has elapsed). + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!( + handle.cycles_driven(), + 2, + "the driver's loop iterated a second time" + ); + assert_eq!( + handle.status().state, + super::super::types::ClaimLoopState::Nominal, + "#3336: the production body's gate must track the CLAMPED cadence -- a body that gated \ + on the raw 5_184_000s cadence would report CadenceNotElapsed here, restoring the \ + no-op gate #3306 fixed. Asserting the exact \ + state, not merely `!= CadenceNotElapsed`: that exclusion is equally satisfied by \ + PersistedStateCorrupt and ChainSourceUnavailable, whose early returns also sit above \ + the window-roll block, so it would go vacuous the moment one of those fired instead. \ + `cycles_driven()` cannot see any of this: it counts every drive loop iteration, \ + including ones the internal gate refused" + ); + let after_tick_2 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_eq!( + after_tick_2, after_tick_1, + "#3336: the fee window must NOT have rolled yet -- only one clamped interval has \ + elapsed against its raw 5_184_000s length. Sizing the window off the CLAMPED value \ + instead rolls it here (left Some(5356800), right Some(2678400)), halving the window \ + and doubling the operator's configured fee-window count. This assertion alone cannot \ + distinguish 'gate opened, window correctly held' from 'gate refused, window-roll \ + code never reached' (the gate's early return in `run_cycle` sits before the \ + window-roll block) -- it is only meaningful paired with the state assertion above, \ + which proves the gate did NOT refuse this cycle." + ); + + // Tick 3: two clamped intervals (5_356_800s) since the window opened -- past its raw + // 5_184_000s length. The window must finally roll. + tokio::time::advance(Duration::from_secs(effective_cadence)).await; + settle().await; + assert_eq!(handle.cycles_driven(), 3); + let after_tick_3 = RewardsClaimConfig::load_from(dir.path()).fee_window_start_unix; + assert_ne!( + after_tick_3, after_tick_1, + "#3336: the window must have rolled once the RAW 5_184_000s cadence elapsed" + ); + + driver.abort(); + } + // ---- the cycle log: the only reader of the status surface in a shipped binary ---------- /// An in-memory sink a `tracing_subscriber::fmt` layer renders records into, so a test can @@ -1655,7 +2093,14 @@ mod tests { let h = handle.clone(); let state_dir = dir.path().to_path_buf(); let driver = tokio::spawn(async move { - run_claim_driver_in(&state_dir, Bytes32::from([1u8; 32]), EmptyPort, h).await; + run_claim_driver_in( + &state_dir, + Bytes32::from([1u8; 32]), + dig_mirror_coin::DIG_ASSET_ID, + EmptyPort, + h, + ) + .await; }); settle().await; diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index b570920f..9aea1184 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -11,6 +11,99 @@ use super::hints::DistributorHintSource; use super::port::{ClaimChainPort, ClaimPortError}; use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; +/// The two cadences [`ClaimEngine::with_persisted_fee_window`] needs, DERIVED together from the +/// single raw configured value they both come from. +/// +/// # DIG-Network/dig_ecosystem#3336 (money) -- what this shape closes, and what it does not +/// Earlier shapes handed the engine two numbers the CALLER had already chosen: first two bare +/// `u64` arguments, then two distinct newtypes, then this struct with two typed fields. +/// +/// TYPE-ENFORCED: the gate and the fee window cannot disagree with EACH OTHER. +/// [`Self::from_raw`] is the only constructor, both fields are private (so a struct literal is +/// not an alternative path from outside this module), and it derives `gate_clamped` by clamping +/// the very [`RawConfiguredCadence`] it stores as `fee_window_raw`. There is no pairing in which +/// the window sizes off one number and the gate off another. +/// +/// NOT type-enforced, and no type here can be: WHICH `u64` the call site labels raw. +/// `from_raw(RawConfiguredCadence(cadence_seconds))` -- the already-clamped local instead of +/// `cfg.cadence_seconds` -- has the same type and compiles. It halves the fee window: measured, +/// the window rolls one tick early, `Some(5356800)` becoming `Some(2678400)`, exactly 2x the +/// number of fee-budget windows the operator sized. Exactly ONE test catches that, and it is +/// `driver::tests::the_production_body_tracks_the_clamped_gate_and_the_raw_fee_window`, which +/// drives the production call site. Do not delete it on the belief that a type stands behind it +/// -- nothing does. +/// +/// - `gate_clamped`: the schedule-CLAMPED cadence, in seconds, that [`ClaimEngine::run_cycle`]'s +/// restart-safety gate is measured against -- the same interval [`super::driver::drive`] +/// actually sleeps on. The RAW value here would restore the no-op gate +/// DIG-Network/dig_ecosystem#3306 fixed: an operator's 60-day config would gate on 60 days +/// again even though the loop keeps ticking every 31. +/// - `fee_window_raw`: the RAW configured cadence, in seconds, that sizes how long the persisted +/// aggregate fee-budget window stays open before rolling -- deliberately never the clamped +/// value. The CLAMPED value here doubles the number of fee-budget windows a long-cadence +/// operator sized (a 60-day config would get ~12 windows/year instead of the ~6 its cadence +/// implies), doubling the fee ceiling they configured. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ClaimCadences { + gate_clamped: ClampedGateCadence, + fee_window_raw: RawConfiguredCadence, +} + +impl ClaimCadences { + /// The ONLY constructor. The gate cadence is derived from the window's own raw source, so the + /// two can never be paired with each other's value -- that much the types enforce. What + /// nothing here enforces is that `raw` really is the raw configured value; see the + /// [`ClaimCadences`] #3336 section for the single test that does. + #[must_use] + pub(crate) fn from_raw(raw: RawConfiguredCadence) -> Self { + Self { + gate_clamped: ClampedGateCadence::clamp(raw), + fee_window_raw: raw, + } + } +} + +/// The RAW, operator-writable cadence in seconds (`RewardsClaimConfig::cadence_seconds` as +/// persisted) -- unbounded above. The single input [`ClaimCadences::from_raw`] takes: it sizes +/// the fee window directly and, through [`ClampedGateCadence::clamp`], the gate as well. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct RawConfiguredCadence(pub(crate) u64); + +/// The schedule-CLAMPED cadence (bounded to [`super::driver::CLAIM_SCHEDULE_SECONDS_MAX`]) that +/// [`ClaimEngine::run_cycle`]'s restart-safety gate is measured against -- the same interval +/// [`super::driver::drive`] actually sleeps on. +/// +/// # DIG-Network/dig_ecosystem#3336 -- why the field is private +/// [`Self::clamp`] is the only way to produce this type, it is private to this module, it always +/// applies the bound, and the only caller of `clamp` is [`ClaimCadences::from_raw`]. So every +/// number that reaches the gate has been through the clamp -- which bounds its MAGNITUDE and +/// nothing else. It is not evidence about where the number came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ClampedGateCadence(u64); + +impl ClampedGateCadence { + /// Bounds `raw` to `CLAIM_SCHEDULE_SECONDS_MAX` (31 days) -- e.g. a 60-day raw cadence in + /// yields the 31-day ceiling out, so the gate tracks the schedule the driver really sleeps + /// on. + /// + /// That is the same CEILING [`super::driver::sanitized_schedule`] applies at the config read, + /// but only its clamp arm: `sanitized_schedule` ALSO substitutes + /// `super::config::CLAIM_CADENCE_SECONDS_DEFAULT` for a zero cadence, and `clamp` has no such + /// arm. The two agree only under an unnamed-until-now precondition -- a zero never reaches + /// here, because [`super::config::RewardsClaimConfig::load_from`] floors `cadence_seconds` to + /// `super::config::CLAIM_CADENCE_FLOOR_SECONDS` (60) on its parse-success path and yields + /// `CLAIM_CADENCE_SECONDS_DEFAULT` (86_400) on its other four exits. A caller that builds a + /// cadence from anything but a loaded config breaks that precondition, and the equivalence + /// with it. + fn clamp(raw: RawConfiguredCadence) -> Self { + ClampedGateCadence(raw.0.min(super::driver::CLAIM_SCHEDULE_SECONDS_MAX)) + } + + fn seconds(self) -> u64 { + self.0 + } +} + /// Drives one claim cycle for this node against a [`ClaimChainPort`] + [`DistributorHintSource`] /// and the anti-silence status surface across calls to [`Self::run_cycle`]. /// @@ -138,19 +231,17 @@ impl ClaimEngine { self.rotation_cursor } + /// Which [`ClaimChainPort`] adapter this engine is driving against -- surfaced so a running + /// node's own log line can name it (DIG-Network/dig_ecosystem#3347), never left implicit. + #[must_use] + pub fn port_kind(&self) -> &'static str { + self.port.kind() + } + /// F7: restores the persisted aggregate-fee-budget window and cadence clock from `dir` and /// arms this engine to keep persisting them there after every submission and every completed /// cycle (never batched to cycle end — see [`Self::run_cycle`]'s "F7" doc section for why). /// - /// F2 (money): takes TWO cadence values, deliberately not one -- `gate_cadence_seconds` (the - /// CLAMPED value the driver's schedule actually runs on) gates WHEN a cycle is allowed to - /// start; `fee_window_seconds` (the RAW configured value) sizes how long the persisted - /// fee-budget window stays open. Conflating them into a single cadence (the pre-F2 shape) - /// either doubled the operator's fee ceiling (reusing the clamped value for the window) or - /// silently starved the gate to the raw value -- for an unbounded-above raw cadence, the gate - /// could stop opening at all while the scheduler kept ticking on the clamped interval. See - /// [`Self::gate_cadence_seconds`] and [`Self::fee_window_seconds`]'s field docs. - /// /// Without this call, the engine is exactly as it was before F7: a fresh /// [`Self::cycle_fee_budget_mojos`] and no cadence gate on every construction. That is /// deliberately still true for a caller that has not opted in (every pre-F7 test), but it is @@ -174,18 +265,27 @@ impl ClaimEngine { /// of every cycle unconditionally (its `CycleConditions`), so a construction-time copy was /// pure overhead: it was never trusted past the first cycle anyway once F16 landed, and now it /// is never even taken. + /// + /// # DIG-Network/dig_ecosystem#3336 + /// Takes ONE [`ClaimCadences`], which the caller can only build with + /// [`ClaimCadences::from_raw`] -- so the two cadences are derived together, from one value, + /// and cannot contradict each other. WHICH value that is is still the call site's choice, and + /// is test-guarded only; see the [`ClaimCadences`] #3336 section. + /// + /// `pub(crate)`, not `pub`: [`ClaimCadences`] is crate-private (it is the argument type, so a + /// `pub` method taking it would be uncallable from outside anyway), and no out-of-crate + /// caller exists. #[must_use] - pub fn with_persisted_fee_window( - mut self, - dir: &Path, - gate_cadence_seconds: u64, - fee_window_seconds: u64, - ) -> Self { + pub(crate) fn with_persisted_fee_window(mut self, dir: &Path, cadences: ClaimCadences) -> Self { self.fee_window_state_dir = Some(dir.to_path_buf()); - self.gate_cadence_seconds = - gate_cadence_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); - self.fee_window_seconds = - fee_window_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self.gate_cadence_seconds = cadences + .gate_clamped + .seconds() + .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self.fee_window_seconds = cadences + .fee_window_raw + .0 + .max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); self } @@ -886,7 +986,8 @@ mod tests { } /// A full in-memory fake standing in for the real chain adapter (see the module doc's "chain - /// seam" section) — the ONLY thing #3249 landing changes is which struct implements this trait. + /// seam" section) — #3249 landed `RealClaimChainPort` as the production implementer of this + /// trait; this fake stays as the engine's own unit-test double. struct FakeChainPort { distributors: Mutex>, submitted: Mutex>, @@ -1047,6 +1148,10 @@ mod tests { .push((launcher_id, payout_puzzle_hash, fee_mojos)); Ok(()) } + + fn kind(&self) -> &'static str { + "test-fake" + } } fn one_distributor( @@ -1359,6 +1464,10 @@ mod tests { ) -> Result<(), ClaimPortError> { self.0.submit_initiate_payout(l, p, f).await } + + fn kind(&self) -> &'static str { + self.0.kind() + } } let port = HintOnlyPort(FakeChainPort::new(vec![d])); @@ -1444,6 +1553,10 @@ mod tests { ) -> Result<(), ClaimPortError> { Err(ClaimPortError::Other("unreachable".into())) } + + fn kind(&self) -> &'static str { + "test-always-faulting-discovery" + } } /// Defect A1/A2 regression -- THE anti-green test for this defect: a port that errors on @@ -1774,7 +1887,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1799,7 +1915,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(1_010).await; let second_submitted = second_outcomes @@ -1838,7 +1957,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000 + u64::from(i)).await; if outcomes .iter() @@ -1878,7 +2000,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1901,7 +2026,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(later).await; assert_eq!( @@ -1934,7 +2062,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -1951,7 +2082,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(1_050).await; assert_eq!( @@ -1987,7 +2121,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await; let submitted: u64 = outcomes @@ -2036,7 +2173,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2094,7 +2234,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); // Still well inside the seeded window (`1_000 + 5 - 1_000 = 5 < CADENCE_SECONDS`), so the // gate cannot be what refuses this -- only the window accumulator can. @@ -2134,7 +2277,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let first_outcomes = first.run_cycle(1_000).await; assert_eq!( first_outcomes, @@ -2156,7 +2302,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let second_outcomes = second.run_cycle(1_010).await; assert_eq!( @@ -2208,7 +2357,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); // Cycle 1: `now` (1_000) is nowhere near `far_future` -- the clock reads as future-dated, // and must refuse. @@ -2281,7 +2433,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); // Cycle 1: the file is corrupt -- must refuse, submit nothing. let cycle1 = e.run_cycle(1_000).await; @@ -2353,7 +2508,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await; assert_eq!( @@ -2401,7 +2559,10 @@ mod tests { CYCLE_BUDGET, DIG_ASSET_ID, ) - .with_persisted_fee_window(dir.path(), CADENCE_SECONDS, CADENCE_SECONDS); + .with_persisted_fee_window( + dir.path(), + ClaimCadences::from_raw(RawConfiguredCadence(CADENCE_SECONDS)), + ); let outcomes = e.run_cycle(1_000).await; @@ -2709,6 +2870,10 @@ mod tests { .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) .await } + + fn kind(&self) -> &'static str { + self.inner.kind() + } } /// **F1 regression -- the anti-latch test.** `ChainSourceUnavailable` must be a PER-CYCLE @@ -2814,6 +2979,10 @@ mod tests { .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) .await } + + fn kind(&self) -> &'static str { + self.inner.kind() + } } /// **F3 regression -- staleness under a fresh timestamp.** Cycle 1 is healthy and submits a @@ -2933,6 +3102,10 @@ mod tests { .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) .await } + + fn kind(&self) -> &'static str { + self.0.kind() + } } /// **F4 (non-blocking, cheap) -- a duplicated launcher id must submit EXACTLY ONCE.** Without diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index 7804be4c..d4714c58 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -19,33 +19,35 @@ //! //! # The chain seam //! -//! `dig-rewards-coin` is v0.1.3, published on crates.io, and still SPEC-only (`src/` is -//! `error.rs` + `lib.rs`); its driver is -//! DIG-Network/dig_ecosystem#3249, still open. So the whole engine here is built against the narrow -//! [`ClaimChainPort`] trait derived from the SPEC's described surface, tested with a full in-memory -//! fake, and the production adapter — until #3249 ships — is [`UnavailableClaimChainPort`], which -//! reports the named state `ChainSourceUnavailable` and runs zero cycles. This mirrors #3250's own -//! `UnavailableChainPort` exactly. When #3249 lands, one adapter is written against -//! `ClaimChainPort` and nothing above this seam changes. +//! `dig-rewards-coin` 0.8.0 ships a real driver (`discovery`, `payout`, `state`), landed by +//! DIG-Network/dig_ecosystem#3249 and extended for DIG-Network/dig_ecosystem#3347. The production +//! adapter is [`RealClaimChainPort`] (`chain_port.rs`), built over this node's own corroborated +//! chain source and a real [`dig_wallet::sage::spend::Broadcaster`]; [`UnavailableClaimChainPort`] +//! remains only as the engine's test double. `own_entry` reads the real accrued amount via +//! `dig_rewards_coin::accrued_base_units`, and `submit_initiate_payout` builds, signs and +//! broadcasts a real `InitiatePayout` spend via `dig_rewards_coin::payout::initiate_payout` and +//! `RewardDistributor::finish_spend` — see `chain_port.rs`'s own module doc for the #3357 +//! phantom-slot trap both must avoid. //! //! A silent no-op that reported progress instead would be the exact defect this ticket exists to -//! prevent (SPEC §2.4): with the unavailable adapter wired, zero claims IS the true state, so the -//! status surface must say so by name, not by omission. +//! prevent (SPEC §2.4): a refused method reports a NAMED [`ClaimPortError`] or +//! [`super::types::ClaimOutcome::Faulted`], never a fabricated success. //! //! # Wired into node startup (DIG-Network/dig_ecosystem#3268) //! [`driver::spawn_claim_driver_from_config`] is the one call `dig-node-service::server`'s //! `serve_with_shutdown` makes: it is gated on `RewardsClaimConfig::enabled` AND //! `Config::enable_chain_sync` (the same flag `spawn_collateral_census` and //! `mirror::bond_verify::spawn_bond_verifier_install` already gate on), and when both are true it -//! spawns a detached task that drives [`ClaimEngine::run_cycle`] on a jittered cadence forever. -//! [`driver::handle`] is the IN-PROCESS accessor a future RPC can read once DIG-Network/dig_ecosystem#3249 -//! lands a real [`ClaimChainPort`] adapter and the `ClaimStatus` wire semantics are re-derived -//! against it — this module puts nothing on the wire itself (see `driver`'s own module doc for -//! why). Until #3249 lands, the only production adapter is still [`UnavailableClaimChainPort`], so -//! every real cycle reports [`ClaimLoopState::ChainSourceUnavailable`] and submits nothing — the -//! honest state, not a silent no-op. +//! spawns a detached task that drives [`ClaimEngine::run_cycle`] on a jittered cadence forever, +//! over a [`RealClaimChainPort`] built from `state.wallet_chain`'s corroborated source. If that +//! source cannot be built (offline, no peers), the loop reports the named refusal +//! `ClaimDriverRefusal::ChainSourceUnbuildable` and runs zero cycles rather than installing +//! [`UnavailableClaimChainPort`] silently. [`driver::handle`] is the IN-PROCESS accessor a future +//! RPC can read against the `ClaimStatus` wire semantics — this module puts nothing on the wire +//! itself (see `driver`'s own module doc for why). mod cadence; +mod chain_port; mod config; mod driver; mod engine; @@ -55,11 +57,15 @@ mod port; mod types; pub use cadence::{next_interval_seconds, FixedJitter, JitterSource, CLAIM_JITTER_SECONDS_DEFAULT}; +pub use chain_port::{HintedLauncherIndex, LauncherIndex, RealClaimChainPort}; pub use config::{ RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT, CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT, CLAIM_FEE_CEILING_MOJOS_DEFAULT, }; -pub use driver::{handle, spawn_claim_driver_from_config, ClaimDriverRefusal, ClaimLoopHandle}; +pub use driver::{ + handle, run_claim_driver_in, spawn_claim_driver_from_config, ClaimDriverRefusal, + ClaimLoopHandle, +}; pub use engine::ClaimEngine; pub use hints::{DistributorHint, DistributorHintSource, NoHintSource}; pub use parser::parse_launch_comment; diff --git a/crates/dig-node-service/src/rewards_claim/port.rs b/crates/dig-node-service/src/rewards_claim/port.rs index f5988f52..9a4c57a3 100644 --- a/crates/dig-node-service/src/rewards_claim/port.rs +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -67,11 +67,17 @@ pub trait ClaimChainPort: Send + Sync { payout_puzzle_hash: Bytes32, fee_mojos: u64, ) -> Result<(), ClaimPortError>; + + /// Names which adapter is installed, so a running node's own log line can say which one -- + /// no default impl, so a new adapter must choose its own name rather than silently inheriting + /// one that describes a different adapter. + fn kind(&self) -> &'static str; } -/// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports -/// [`ClaimPortError::Unavailable`] on every call and runs zero cycles — the named state -/// `ChainSourceUnavailable` (see the module doc), never a silent no-op. +/// The engine's test double: reports [`ClaimPortError::Unavailable`] on every call and runs zero +/// cycles — the named state `ChainSourceUnavailable` (see the module doc), never a silent no-op. +/// No production path constructs this any more; the real adapter is +/// [`super::chain_port::RealClaimChainPort`]. pub struct UnavailableClaimChainPort; #[async_trait] @@ -115,6 +121,10 @@ impl ClaimChainPort for UnavailableClaimChainPort { ) -> Result<(), ClaimPortError> { Err(ClaimPortError::Unavailable) } + + fn kind(&self) -> &'static str { + "unavailable" + } } #[cfg(test)] diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index ecb5b3bf..8a291270 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2231,16 +2231,25 @@ where // (a tested unit, #1864) so it cannot be silently flipped to always- or never-spawn. crate::self_heal::spawn_driver_if_service(); - // The peer reward-claim loop (DIG-Network/dig_ecosystem#3268, #3251): drives + // The peer reward-claim loop (DIG-Network/dig_ecosystem#3268, #3251, #3347): drives // `rewards_claim::ClaimEngine::run_cycle` on a jittered cadence so // `RewardsClaimConfig::enabled = true` stops being a false statement. Gated the same way the // census and bond-verifier spawns above are -- `enable_chain_sync` already means "this node // talks to the Chia network", and a harness sets it false precisely so nothing dials. The // service-gate lives inside the seam (a tested unit, mirroring `self_heal::spawn_driver_if`) - // so it cannot be silently flipped to always- or never-spawn. The only production chain - // adapter until DIG-Network/dig_ecosystem#3249 lands is `UnavailableClaimChainPort`, so every - // real cycle reports `ChainSourceUnavailable` and submits nothing -- the honest state. - crate::rewards_claim::spawn_claim_driver_from_config(config.enable_chain_sync); + // so it cannot be silently flipped to always- or never-spawn. The production chain adapter is + // `rewards_claim::RealClaimChainPort`, built from this node's own `wallet_chain`'s + // corroborated source (the same call the funder-side reward-chain-port install above makes) -- + // a source that fails to build is a named refusal (`ClaimDriverRefusal::ChainSourceUnbuildable`), + // paired with a real `Broadcaster` (`ClaimDriverRefusal::BroadcasterUnbuildable` if that fails + // to build), never a silent `UnavailableClaimChainPort` substitution. `own_entry`'s accrued + // amount and `submit_initiate_payout` are both real over `dig-rewards-coin` 0.8.0 + // (DIG-Network/dig_ecosystem#3347): a chain-backed spendable entry slot via + // `dig_rewards_coin::ChainEntrySlotSource` and a real `InitiatePayout` broadcast. + crate::rewards_claim::spawn_claim_driver_from_config( + config.enable_chain_sync, + state.wallet_chain.clone(), + ); // Best-effort wallet mTLS listener (#368, Sage byte-parity, node-class clients, §5.3). Binds // loopback only on [`DEFAULT_MTLS_PORT`], which is deliberately NOT Sage's own RPC port diff --git a/crates/dig-node-service/tests/common/mod.rs b/crates/dig-node-service/tests/common/mod.rs new file mode 100644 index 00000000..4f1d11ff --- /dev/null +++ b/crates/dig-node-service/tests/common/mod.rs @@ -0,0 +1,4 @@ +//! Shared integration-test fixtures — deliberately thin: each submodule owns one fixture, no +//! cross-fixture coupling. + +pub mod rewards_fixture; diff --git a/crates/dig-node-service/tests/common/rewards_fixture.rs b/crates/dig-node-service/tests/common/rewards_fixture.rs new file mode 100644 index 00000000..964bbb66 --- /dev/null +++ b/crates/dig-node-service/tests/common/rewards_fixture.rs @@ -0,0 +1,740 @@ +//! A real DIG rewards distributor, launched once against `chia-sdk-test`'s peer simulator, and a +//! `MockChainSource` loaded from that real state — shared between +//! `tests/rewards_chain_port_a3.rs` (DIG-Network/dig_ecosystem#3310) and +//! `tests/rewards_claim_chain_port_3347.rs` (DIG-Network/dig_ecosystem#3347), which both need the +//! SAME real, decodable launch rather than two independently hand-rolled ones. See +//! `rewards_chain_port_a3.rs`'s original module doc (still the fixture's own doc below) for why +//! this substitution (the network transport, nothing else) is sound. + +use chia_protocol::{Bytes32, Coin, CoinSpend, SpendBundle}; +use chia_puzzle_types::singleton::{SingletonArgs, SingletonSolution}; +use chia_puzzle_types::CoinProof; +use chia_puzzle_types::Memos; +use chia_puzzle_types::{EveProof, LineageProof, Proof}; +use chia_puzzles::{SETTLEMENT_PAYMENT_HASH, SINGLETON_LAUNCHER_HASH}; +use chia_sdk_driver::{ + sign_standard_transaction, Cat, CatSpend, Launcher, Offer, RewardDistributorConstants, + RewardDistributorType, SingleCatSpend, Slot, Spend, SpendContext, SpendWithConditions, + StandardLayer, +}; +use chia_sdk_test::Simulator; +use chia_sdk_types::puzzles::{RewardDistributorRewardSlotValue, RewardDistributorSlotNonce}; +use chia_sdk_types::{Conditions, TESTNET11_CONSTANTS}; +use clvm_traits::{clvm_quote, ToClvm}; +use clvmr::NodePtr; +use dig_chainsource_interface::{CoinRecord, MockChainSource, SingletonLineage}; +use dig_rewards_coin::comment::LaunchComment; +use dig_rewards_coin::constants::{ + MAX_SECONDS_OFFSET, PAYOUT_THRESHOLD_BASE_UNITS, WITHDRAWAL_SHARE_BPS, +}; +use dig_rewards_coin::eligibility::{judge_candidate, EligibilityQuestion, MirrorCoinFacts}; +use dig_rewards_coin::entries::{add_entry, ManagerAuthority}; +use dig_rewards_coin::epoch::{start_next_distributor_epoch, sync_distributor}; +use dig_rewards_coin::fund::commit_incentives_for_distributor_epoch; +use dig_rewards_coin::launch::launch_dig_distributor; + +/// Small on purpose: the simulator's clock starts at zero. +pub const FIRST_EPOCH_START: u64 = 1_234; +/// A short epoch; these tests are about a report's/adapter's fields, not the epoch length. +pub const TEST_EPOCH_SECONDS: u64 = 1_000; +/// $DIG the funder mints for itself. +const MINTED_BASE_UNITS: u64 = 10_000_000_000; + +/// A fixed, never-launched manager singleton launcher id: curried into the constants table for +/// shape only, never read back off chain by `read_distributor`. +const DUMMY_MANAGER_LAUNCHER_ID: Bytes32 = Bytes32::new([0x42; 32]); + +/// The `store_id`/`root` this fixture's launch comment carries — asserted against by both +/// consumers of this fixture. +pub const LAUNCH_STORE_ID: Bytes32 = Bytes32::new([0xaa; 32]); +pub const LAUNCH_ROOT: Bytes32 = Bytes32::new([0xbb; 32]); + +/// Everything a real launch produced, named rather than positional. +pub struct LaunchedFixture { + pub sim: Simulator, + pub launcher_id: Bytes32, + pub security_coin_id: Bytes32, + pub distributor_coin_id: Bytes32, + pub reserve_coin_id: Bytes32, + pub reserve_launch_id: Bytes32, + pub reserve_parent_id: Bytes32, + pub launch_comment: LaunchComment, + pub constants: RewardDistributorConstants, +} + +/// Mints a reward CAT, builds a launch offer, and launches a real DIG distributor via +/// `launch_dig_distributor` against a fresh `Simulator` — trimmed from +/// `dig-rewards-coin::tests::simulator::launch_harness_with_constants_builder`. +pub fn launch_fixture() -> Result> { + let ctx = &mut SpendContext::new(); + let mut sim = Simulator::new(); + + let funder = sim.bls(MINTED_BASE_UNITS); + let funder_p2 = StandardLayer::new(funder.pk); + let (issue_cat, source_cats) = Cat::single_issuance( + ctx, + funder.coin.coin_id(), + None, + MINTED_BASE_UNITS, + Conditions::new().create_coin(funder.puzzle_hash, MINTED_BASE_UNITS, Memos::None), + )?; + funder_p2.spend(ctx, funder.coin, issue_cat)?; + let source_cat = source_cats[0]; + sim.spend_coins(ctx.take(), std::slice::from_ref(&funder.sk))?; + + let offer_amount = 1; + let launcher_bls = sim.bls(offer_amount); + let offer_spend = StandardLayer::new(launcher_bls.pk).spend_with_conditions( + ctx, + Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), offer_amount, Memos::None), + )?; + let puzzle_reveal = ctx.serialize(&offer_spend.puzzle)?; + let solution = ctx.serialize(&offer_spend.solution)?; + + let cat_inner_puzzle = clvm_quote!(Conditions::new().create_coin( + SETTLEMENT_PAYMENT_HASH.into(), + source_cat.coin.amount, + Memos::None + )) + .to_clvm(ctx)?; + let cat_inner_spend = funder_p2.delegated_inner_spend( + ctx, + Spend { + puzzle: cat_inner_puzzle, + solution: NodePtr::NIL, + }, + )?; + source_cat.spend( + ctx, + SingleCatSpend { + prev_coin_id: source_cat.coin.coin_id(), + next_coin_proof: CoinProof { + parent_coin_info: source_cat.coin.parent_coin_info, + inner_puzzle_hash: funder.puzzle_hash, + amount: source_cat.coin.amount, + }, + prev_subtotal: 0, + extra_delta: 0, + p2_spend: cat_inner_spend, + revoke: false, + }, + )?; + + let spends = ctx.take(); + let cat_offer_spend = spends + .iter() + .find(|spend| spend.coin.coin_id() == source_cat.coin.coin_id()) + .expect("the CAT offer spend") + .clone(); + for spend in spends { + if spend.coin.coin_id() != source_cat.coin.coin_id() { + ctx.insert(spend); + } + } + + let signature = sign_standard_transaction( + ctx, + launcher_bls.coin, + offer_spend, + &launcher_bls.sk, + &TESTNET11_CONSTANTS, + )?; + let offer = Offer::from_spend_bundle( + ctx, + &SpendBundle { + coin_spends: vec![ + CoinSpend::new(launcher_bls.coin, puzzle_reveal, solution), + cat_offer_spend, + ], + aggregated_signature: signature, + }, + )?; + + let constants = RewardDistributorConstants::without_launcher_id( + RewardDistributorType::Managed { + manager_singleton_launcher_id: DUMMY_MANAGER_LAUNCHER_ID, + }, + funder.puzzle_hash, + TEST_EPOCH_SECONDS, + u64::MAX, + MAX_SECONDS_OFFSET, + // Deliberately NOT `PAYOUT_THRESHOLD_BASE_UNITS` (the distributor's default launch + // value) -- #3347 mutation proof (iv) needs a fixture whose on-chain threshold DIFFERS + // from the default, or a port that ignores the chain and returns the default constant + // reads as correct by coincidence. See `reserve_asset_id_and_payout_threshold_are_read_from_chain`. + PAYOUT_THRESHOLD_BASE_UNITS.saturating_add(1_000_000), + false, + 0, + WITHDRAWAL_SHARE_BPS, + source_cat.info.asset_id, + ); + + let launch_comment = LaunchComment::new(LAUNCH_STORE_ID, LAUNCH_ROOT); + + let launched = launch_dig_distributor( + ctx, + &offer, + FIRST_EPOCH_START, + constants, + &TESTNET11_CONSTANTS, + launch_comment, + // The simulator's clock starts at zero, so FIRST_EPOCH_START is in the future. + 0, + )?; + + sim.spend_coins( + ctx.take(), + &[ + launcher_bls.sk.clone(), + launched.security_coin_secret_key.clone(), + funder.sk.clone(), + ], + )?; + + let launcher_id = launched.distributor.info.constants.launcher_id; + let distributor_coin_id = launched.distributor.coin.coin_id(); + let reserve_launch_id = launched.distributor.reserve.coin.coin_id(); + let reserve_parent_id = launched.distributor.reserve.coin.parent_coin_info; + let reserve_coin_id = launched.distributor.reserve.coin.coin_id(); + + // The launcher's own parent (its "security coin") is what CREATES the launcher coin, i.e. + // the spend `read_launch_comment` needs. Derived by looking the launcher's confirmed record + // up after the fact, rather than tracking the security coin id through the launch machinery + // by hand. + let security_coin_id = sim + .coin_state(launcher_id) + .expect("the launcher coin was confirmed by the launch spend") + .coin + .parent_coin_info; + + Ok(LaunchedFixture { + sim, + launcher_id, + security_coin_id, + distributor_coin_id, + reserve_coin_id, + reserve_launch_id, + reserve_parent_id, + launch_comment, + constants: launched.distributor.info.constants, + }) +} + +/// Builds a `MockChainSource` over `fixture`'s real simulator state, loading exactly what +/// `read_distributor_guarded`/`read_launch_comment` read — mirrors +/// `dig-rewards-coin::tests::simulator::chain_source_with_gaps`. +pub fn mock_chain_source(fixture: &LaunchedFixture) -> MockChainSource { + let singleton_members = [fixture.launcher_id, fixture.distributor_coin_id]; + + // The eve coin: `read_distributor` needs the SPEND that consumed it, not any record it + // named directly. + let eve_coin_id = fixture + .sim + .children(fixture.launcher_id) + .first() + .map(|state| state.coin.coin_id()); + + let extra_ids = [ + fixture.security_coin_id, + fixture.reserve_launch_id, + fixture.reserve_parent_id, + fixture.reserve_coin_id, + ]; + + let mut source = MockChainSource::new(); + for id in singleton_members + .iter() + .copied() + .chain(extra_ids.iter().copied()) + .chain(eve_coin_id) + { + if let Some(state) = fixture.sim.coin_state(id) { + source = source.with_coin(id, CoinRecord::from_coin_state(state)); + } + if let Some(spend) = fixture.sim.coin_spend(id) { + source = source.with_spend(id, spend); + } + } + + source = source.with_lineage( + fixture.launcher_id, + SingletonLineage::new( + fixture.distributor_coin_id, + singleton_members.iter().copied(), + ), + ); + + let peak = fixture.sim.height(); + for height in 0..=peak { + source = source.with_timestamp(height, u64::from(height) * 1_000 + 1); + } + source.with_peak(peak) +} + +// --------------------------------------------------------------------------------------------- +// A FUNDED, ADMITTED fixture -- DIG-Network/dig_ecosystem#3347's U2. `launch_fixture` above never +// spawns a real manager singleton (its `DUMMY_MANAGER_LAUNCHER_ID` is curried for shape only), so +// it cannot authorize an `AddEntry`. This second fixture launches a REAL manager singleton, commits +// incentives, admits one entry, rolls the epoch and syncs mid-epoch -- ported, line for line in +// spirit, from `dig-rewards-coin` 0.8.0's own +// `tests/simulator.rs::a_claim_built_entirely_from_a_chain_read_is_accepted` (the crate's own proof +// that a claim built entirely from a chain read is accepted by the simulator). +// --------------------------------------------------------------------------------------------- + +/// $DIG committed to the first epoch -- the SAME figure `dig-rewards-coin`'s own golden test uses. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +const COMMITTED_BASE_UNITS: u64 = 1_000_000; + +/// The mirror-collateral epoch [`verdict_for`] judges against. Any ordinal will do; what matters is +/// that the same one is asked and advertised. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +const TEST_MIRROR_COLLATERAL_EPOCH: u32 = 7; + +/// A mirror coin that passes every eligibility check and pays out to one hash -- mirrors +/// `dig-rewards-coin`'s own `EligibleMirrorCoin` test double. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +struct EligibleMirrorCoin { + payout_puzzle_hash: Bytes32, +} + +impl MirrorCoinFacts for EligibleMirrorCoin { + fn advertises(&self, _store: Bytes32, _root: Bytes32, mirror_collateral_epoch: u32) -> bool { + mirror_collateral_epoch == TEST_MIRROR_COLLATERAL_EPOCH + } + + fn declares_peer(&self, _peer_id: Bytes32) -> bool { + true + } + + fn owner_puzzle_hash(&self) -> Bytes32 { + self.payout_puzzle_hash + } +} + +/// Judge a candidate whose mirror coin pays out to `payout_puzzle_hash`, and take the verdict -- +/// the only way `add_entry` can be handed a payout hash at all. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +fn verdict_for(payout_puzzle_hash: Bytes32) -> dig_rewards_coin::eligibility::EligiblePayoutHash { + let question = EligibilityQuestion { + store_launcher_id: LAUNCH_STORE_ID, + root_hash: LAUNCH_ROOT, + mirror_collateral_epoch: TEST_MIRROR_COLLATERAL_EPOCH, + }; + let coin = EligibleMirrorCoin { payout_puzzle_hash }; + + judge_candidate(question, Bytes32::new([0xcc; 32]), Some(&coin)) + .expect("the epoch is established") + .expect("every eligibility check passes") +} + +/// A test manager singleton with an inner puzzle of `1` -- mirrors `dig-rewards-coin`'s own +/// `TestSingleton`. The cheapest singleton that can deliver conditions; nothing here depends on +/// which inner puzzle it is. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +struct TestSingleton { + launcher_id: Bytes32, + coin: Coin, + proof: Proof, + inner_puzzle_hash: Bytes32, + puzzle: NodePtr, +} + +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +fn launch_test_singleton( + ctx: &mut SpendContext, + sim: &mut Simulator, +) -> Result> { + let launcher_coin = sim.new_coin(SINGLETON_LAUNCHER_HASH.into(), 1); + let launcher = Launcher::new(launcher_coin.parent_coin_info, 1); + let launcher_id = launcher.coin().coin_id(); + + let inner_puzzle = ctx.alloc(&1)?; + let inner_puzzle_hash = ctx.tree_hash(inner_puzzle); + let (_, coin) = launcher.spend(ctx, inner_puzzle_hash.into(), ())?; + + let puzzle = ctx.curry(SingletonArgs::new(launcher_id, inner_puzzle))?; + let proof = Proof::Eve(EveProof { + parent_parent_coin_info: launcher_coin.parent_coin_info, + parent_amount: launcher_coin.amount, + }); + + Ok(TestSingleton { + launcher_id, + coin, + proof, + inner_puzzle_hash: inner_puzzle_hash.into(), + puzzle, + }) +} + +/// Deliver `output_conditions` from the manager singleton, recreating it for the next spend -- +/// mirrors `dig-rewards-coin`'s own `spend_manager_singleton`. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +fn spend_manager_singleton( + ctx: &mut SpendContext, + singleton: &TestSingleton, + output_conditions: Conditions, +) -> Result<(Coin, Proof), Box> { + let inner_puzzle = ctx.alloc(&1)?; + let inner_puzzle_hash: Bytes32 = ctx.tree_hash(inner_puzzle).into(); + + let inner_solution = output_conditions + .create_coin(inner_puzzle_hash, 1, Memos::None) + .to_clvm(ctx)?; + let solution = ctx.alloc(&SingletonSolution { + lineage_proof: singleton.proof, + amount: 1, + inner_solution, + })?; + + ctx.spend(singleton.coin, Spend::new(singleton.puzzle, solution))?; + + let next_proof = Proof::Lineage(LineageProof { + parent_parent_coin_info: singleton.coin.parent_coin_info, + parent_inner_puzzle_hash: inner_puzzle_hash, + parent_amount: singleton.coin.amount, + }); + let next_coin = Coin::new(singleton.coin.coin_id(), singleton.coin.puzzle_hash, 1); + + Ok((next_coin, next_proof)) +} + +/// Assert a permissionless action's conditions via a zero-value checker coin -- mirrors +/// `dig-rewards-coin`'s own `ensure_conditions_met`. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +fn ensure_conditions_met( + ctx: &mut SpendContext, + sim: &mut Simulator, + conditions: Conditions, +) -> Result<(), Box> { + let checker_puzzle = clvm_quote!(conditions).to_clvm(ctx)?; + let checker_coin = sim.new_coin(ctx.tree_hash(checker_puzzle).into(), 0); + ctx.spend(checker_coin, Spend::new(checker_puzzle, NodePtr::NIL))?; + Ok(()) +} + +/// As [`ensure_conditions_met`], but for the OPTIONAL `Sync` conditions an entry-set write may or +/// may not carry. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +fn ensure_optional_conditions_met( + ctx: &mut SpendContext, + sim: &mut Simulator, + conditions: Option>, +) -> Result<(), Box> { + match conditions { + Some(conditions) => ensure_conditions_met(ctx, sim, conditions), + None => Ok(()), + } +} + +/// A real launch, funded, with one admitted entry -- everything +/// `RealClaimChainPort::submit_initiate_payout` needs to build a claim the simulator will accept. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +pub struct FundedFixture { + pub sim: Simulator, + pub launcher_id: Bytes32, + /// The launcher's own parent (its "security coin") -- the spend that CREATES the launcher + /// coin, which `dig_rewards_coin::discover_distributor`'s `source.parent_spend(launcher_id)` + /// needs. Same derivation as `LaunchedFixture::security_coin_id`. + pub security_coin_id: Bytes32, + /// Every singleton generation's coin id, launcher first, tip last -- what `mock_chain_source` + /// needs to build a `SingletonLineage`. + pub singleton_members: Vec, + pub reserve_launch_id: Bytes32, + pub reserve_parent_id: Bytes32, + pub reserve_tip_id: Bytes32, + pub constants: RewardDistributorConstants, + /// The payout puzzle hash the one admitted entry was added with -- the same hash the caller + /// passed to [`launch_funded_admitted_fixture`]. + pub payout_puzzle_hash: Bytes32, +} + +/// Launches a real manager singleton and distributor, mints `COMMITTED_BASE_UNITS` into the first +/// epoch, admits ONE entry at `payout_puzzle_hash`, rolls to the next epoch, then syncs at the +/// epoch's midpoint -- so the entry has accrued something, comfortably above +/// `PAYOUT_THRESHOLD_BASE_UNITS`, entirely from real puzzle arithmetic. Mirrors +/// `dig-rewards-coin` 0.8.0's own `a_claim_built_entirely_from_a_chain_read_is_accepted` harness. +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +pub fn launch_funded_admitted_fixture( + payout_puzzle_hash: Bytes32, +) -> Result> { + let ctx = &mut SpendContext::new(); + let mut sim = Simulator::new(); + + let funder = sim.bls(MINTED_BASE_UNITS); + let funder_p2 = StandardLayer::new(funder.pk); + let (issue_cat, source_cats) = Cat::single_issuance( + ctx, + funder.coin.coin_id(), + None, + MINTED_BASE_UNITS, + Conditions::new().create_coin(funder.puzzle_hash, MINTED_BASE_UNITS, Memos::None), + )?; + funder_p2.spend(ctx, funder.coin, issue_cat)?; + let mut source_cat = source_cats[0]; + sim.spend_coins(ctx.take(), std::slice::from_ref(&funder.sk))?; + + let manager = launch_test_singleton(ctx, &mut sim)?; + + let offer_amount = 1; + let launcher_bls = sim.bls(offer_amount); + let offer_spend = StandardLayer::new(launcher_bls.pk).spend_with_conditions( + ctx, + Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), offer_amount, Memos::None), + )?; + let puzzle_reveal = ctx.serialize(&offer_spend.puzzle)?; + let solution = ctx.serialize(&offer_spend.solution)?; + + let cat_inner_puzzle = clvm_quote!(Conditions::new().create_coin( + SETTLEMENT_PAYMENT_HASH.into(), + source_cat.coin.amount, + Memos::None + )) + .to_clvm(ctx)?; + let cat_inner_spend = funder_p2.delegated_inner_spend( + ctx, + Spend { + puzzle: cat_inner_puzzle, + solution: NodePtr::NIL, + }, + )?; + source_cat.spend( + ctx, + SingleCatSpend { + prev_coin_id: source_cat.coin.coin_id(), + next_coin_proof: CoinProof { + parent_coin_info: source_cat.coin.parent_coin_info, + inner_puzzle_hash: funder.puzzle_hash, + amount: source_cat.coin.amount, + }, + prev_subtotal: 0, + extra_delta: 0, + p2_spend: cat_inner_spend, + revoke: false, + }, + )?; + + let spends = ctx.take(); + let cat_offer_spend = spends + .iter() + .find(|spend| spend.coin.coin_id() == source_cat.coin.coin_id()) + .expect("the CAT offer spend") + .clone(); + for spend in spends { + if spend.coin.coin_id() != source_cat.coin.coin_id() { + ctx.insert(spend); + } + } + + let signature = sign_standard_transaction( + ctx, + launcher_bls.coin, + offer_spend, + &launcher_bls.sk, + &TESTNET11_CONSTANTS, + )?; + let offer = Offer::from_spend_bundle( + ctx, + &SpendBundle { + coin_spends: vec![ + CoinSpend::new(launcher_bls.coin, puzzle_reveal, solution), + cat_offer_spend, + ], + aggregated_signature: signature, + }, + )?; + + let constants = RewardDistributorConstants::without_launcher_id( + RewardDistributorType::Managed { + manager_singleton_launcher_id: manager.launcher_id, + }, + funder.puzzle_hash, + TEST_EPOCH_SECONDS, + u64::MAX, + MAX_SECONDS_OFFSET, + PAYOUT_THRESHOLD_BASE_UNITS, + false, + 0, + WITHDRAWAL_SHARE_BPS, + source_cat.info.asset_id, + ); + + let launch_comment = LaunchComment::new(LAUNCH_STORE_ID, LAUNCH_ROOT); + + let launched = launch_dig_distributor( + ctx, + &offer, + FIRST_EPOCH_START, + constants, + &TESTNET11_CONSTANTS, + launch_comment, + 0, + )?; + + sim.spend_coins( + ctx.take(), + &[ + launcher_bls.sk.clone(), + launched.security_coin_secret_key.clone(), + funder.sk.clone(), + ], + )?; + + let launcher_id = launched.distributor.info.constants.launcher_id; + let mut distributor = launched.distributor; + let first_epoch_slot = launched.first_distributor_epoch_slot; + source_cat = launched.refund_cat; + + // Same derivation as `LaunchedFixture::security_coin_id` above: the launcher's own parent is + // the spend that CREATES the launcher coin, which `discover_distributor` reads. + let security_coin_id = sim + .coin_state(launcher_id) + .expect("the launcher coin was confirmed by the launch spend") + .coin + .parent_coin_info; + + let reserve_launch_id = distributor.reserve.coin.coin_id(); + let reserve_parent_id = distributor.reserve.coin.parent_coin_info; + + let mut singleton_members = vec![launcher_id, distributor.coin.coin_id()]; + + // Commit COMMITTED_BASE_UNITS to the first epoch. + let secure_conditions = commit_incentives_for_distributor_epoch( + ctx, + &mut distributor, + first_epoch_slot, + FIRST_EPOCH_START, + funder.puzzle_hash, + COMMITTED_BASE_UNITS, + )?; + + let hint = ctx.hint(funder.puzzle_hash)?; + let change = source_cat.coin.amount - COMMITTED_BASE_UNITS; + let source_cat_spend = CatSpend::new( + source_cat, + StandardLayer::new(funder.pk).spend_with_conditions( + ctx, + secure_conditions.create_coin(funder.puzzle_hash, change, hint), + )?, + ); + + let reward_slots: Vec<_> = distributor + .pending_spend + .created_reward_slots + .iter() + .map(|value| { + distributor.created_slot_value_to_slot(*value, RewardDistributorSlotNonce::REWARD) + }) + .collect(); + + distributor = distributor + .clone() + .finish_spend(ctx, vec![source_cat_spend])? + .0; + sim.spend_coins(ctx.take(), std::slice::from_ref(&funder.sk))?; + singleton_members.push(distributor.coin.coin_id()); + + // Admit one entry at `payout_puzzle_hash`. + let authority = ManagerAuthority::new(manager.inner_puzzle_hash)?; + let write = add_entry( + ctx, + &mut distributor, + authority, + verdict_for(payout_puzzle_hash), + 0, + )?; + distributor = distributor.clone().finish_spend(ctx, vec![])?.0; + ensure_optional_conditions_met(ctx, &mut sim, write.sync_conditions)?; + // The manager singleton's post-AddEntry generation is never spent again by this fixture, so + // its returned coin/proof are discarded rather than threaded into an unused `mut` binding. + let (_next_manager_coin, _next_manager_proof) = + spend_manager_singleton(ctx, &manager, write.manager_conditions)?; + sim.spend_coins(ctx.take(), &[])?; + singleton_members.push(distributor.coin.coin_id()); + + // Two more generations past AddEntry: roll to the next epoch, then sync at its midpoint -- + // the entry slot the claim later spends was created several generations before the tip. + sim.set_next_timestamp(FIRST_EPOCH_START)?; + let first_reward_slot: Slot = reward_slots + .into_iter() + .find(|slot| slot.info.value.epoch_start == FIRST_EPOCH_START) + .expect("a reward slot for the first epoch"); + let roll = start_next_distributor_epoch(ctx, &mut distributor, first_reward_slot)?; + ensure_conditions_met(ctx, &mut sim, roll.conditions)?; + distributor = distributor.clone().finish_spend(ctx, vec![])?.0; + sim.spend_coins(ctx.take(), &[])?; + singleton_members.push(distributor.coin.coin_id()); + + let sync_time = FIRST_EPOCH_START + TEST_EPOCH_SECONDS / 2; + sim.set_next_timestamp(sync_time)?; + let sync_conditions = sync_distributor(ctx, &mut distributor, sync_time)?; + ensure_conditions_met(ctx, &mut sim, sync_conditions)?; + distributor = distributor.clone().finish_spend(ctx, vec![])?.0; + sim.spend_coins(ctx.take(), &[])?; + singleton_members.push(distributor.coin.coin_id()); + + let reserve_tip_id = distributor.reserve.coin.coin_id(); + + Ok(FundedFixture { + sim, + launcher_id, + security_coin_id, + singleton_members, + reserve_launch_id, + reserve_parent_id, + reserve_tip_id, + constants: distributor.info.constants, + payout_puzzle_hash, + }) +} + +/// Builds a `MockChainSource` over `fixture`'s real, multi-generation simulator state -- the +/// general form `mock_chain_source` above cannot serve, since a funded/admitted fixture has more +/// than one post-launch generation. Mirrors `dig-rewards-coin`'s own `mock_chain_source` (the +/// general `sim`/`singleton_members`/`extra_coin_ids` form, `tests/simulator.rs`). +#[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs +pub fn mock_chain_source_for_funded_fixture(fixture: &FundedFixture) -> MockChainSource { + let eve_coin_id = fixture + .sim + .children(fixture.launcher_id) + .first() + .map(|state| state.coin.coin_id()); + + let extra_ids = [ + fixture.security_coin_id, + fixture.reserve_launch_id, + fixture.reserve_parent_id, + fixture.reserve_tip_id, + ]; + + let mut source = MockChainSource::new(); + for id in fixture + .singleton_members + .iter() + .copied() + .chain(extra_ids.iter().copied()) + .chain(eve_coin_id) + { + if let Some(state) = fixture.sim.coin_state(id) { + source = source.with_coin(id, CoinRecord::from_coin_state(state)); + } + if let Some(spend) = fixture.sim.coin_spend(id) { + source = source.with_spend(id, spend); + } + } + + let tip = *fixture + .singleton_members + .last() + .expect("a singleton chain always has at least the launcher"); + source = source.with_lineage( + fixture.launcher_id, + SingletonLineage::new(tip, fixture.singleton_members.iter().copied()), + ); + + let peak = fixture.sim.height(); + for height in 0..=peak { + source = source.with_timestamp(height, u64::from(height) * 1_000 + 1); + } + source.with_peak(peak) +} diff --git a/crates/dig-node-service/tests/rewards_chain_port_a3.rs b/crates/dig-node-service/tests/rewards_chain_port_a3.rs index 01ad05b7..d5ad5d32 100644 --- a/crates/dig-node-service/tests/rewards_chain_port_a3.rs +++ b/crates/dig-node-service/tests/rewards_chain_port_a3.rs @@ -35,258 +35,19 @@ //! itself. That is true of this test as written; it is not a structural guarantee, and would stop //! being true the moment a second source of those fields is added here. +mod common; + use std::sync::Arc; -use chia_protocol::{Bytes32, CoinSpend, SpendBundle}; -use chia_puzzle_types::CoinProof; -use chia_puzzle_types::Memos; -use chia_puzzles::SETTLEMENT_PAYMENT_HASH; -use chia_sdk_driver::{ - sign_standard_transaction, Cat, Offer, RewardDistributorConstants, RewardDistributorType, - SingleCatSpend, Spend, SpendContext, SpendWithConditions, StandardLayer, -}; -use chia_sdk_test::Simulator; -use chia_sdk_types::{Conditions, TESTNET11_CONSTANTS}; -use clvm_traits::{clvm_quote, ToClvm}; -use clvmr::NodePtr; -use dig_chainsource_interface::{CoinRecord, MockChainSource, SingletonLineage}; +use dig_chainsource_interface::MockChainSource; use dig_node_core::rewards::port::RewardsChainPort; use dig_node_core::Node; use dig_node_service::rewards::RealRewardsChainPort; -use dig_rewards_coin::comment::LaunchComment; -use dig_rewards_coin::constants::{ - MAX_SECONDS_OFFSET, PAYOUT_THRESHOLD_BASE_UNITS, WITHDRAWAL_SHARE_BPS, -}; -use dig_rewards_coin::launch::launch_dig_distributor; - -/// Small on purpose: the simulator's clock starts at zero. -const FIRST_EPOCH_START: u64 = 1_234; -/// A short epoch; this test is about the report's fields, not the epoch length. -const TEST_EPOCH_SECONDS: u64 = 1_000; -/// $DIG the funder mints for itself. -const MINTED_BASE_UNITS: u64 = 10_000_000_000; - -/// A fixed, never-launched manager singleton launcher id: curried into the constants table for -/// shape only, never read back off chain by `read_distributor`. -const DUMMY_MANAGER_LAUNCHER_ID: Bytes32 = Bytes32::new([0x42; 32]); - -/// Everything a real launch produced, named rather than positional. -struct LaunchedFixture { - sim: Simulator, - launcher_id: Bytes32, - security_coin_id: Bytes32, - distributor_coin_id: Bytes32, - reserve_coin_id: Bytes32, - reserve_launch_id: Bytes32, - reserve_parent_id: Bytes32, - launch_comment: LaunchComment, - constants: RewardDistributorConstants, -} - -/// Mints a reward CAT, builds a launch offer, and launches a real DIG distributor via -/// `launch_dig_distributor` against a fresh `Simulator` — trimmed from -/// `dig-rewards-coin::tests::simulator::launch_harness_with_constants_builder`. -fn launch_fixture() -> Result> { - let ctx = &mut SpendContext::new(); - let mut sim = Simulator::new(); - - let funder = sim.bls(MINTED_BASE_UNITS); - let funder_p2 = StandardLayer::new(funder.pk); - let (issue_cat, source_cats) = Cat::single_issuance( - ctx, - funder.coin.coin_id(), - None, - MINTED_BASE_UNITS, - Conditions::new().create_coin(funder.puzzle_hash, MINTED_BASE_UNITS, Memos::None), - )?; - funder_p2.spend(ctx, funder.coin, issue_cat)?; - let source_cat = source_cats[0]; - sim.spend_coins(ctx.take(), std::slice::from_ref(&funder.sk))?; - - let offer_amount = 1; - let launcher_bls = sim.bls(offer_amount); - let offer_spend = StandardLayer::new(launcher_bls.pk).spend_with_conditions( - ctx, - Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), offer_amount, Memos::None), - )?; - let puzzle_reveal = ctx.serialize(&offer_spend.puzzle)?; - let solution = ctx.serialize(&offer_spend.solution)?; - - let cat_inner_puzzle = clvm_quote!(Conditions::new().create_coin( - SETTLEMENT_PAYMENT_HASH.into(), - source_cat.coin.amount, - Memos::None - )) - .to_clvm(ctx)?; - let cat_inner_spend = funder_p2.delegated_inner_spend( - ctx, - Spend { - puzzle: cat_inner_puzzle, - solution: NodePtr::NIL, - }, - )?; - source_cat.spend( - ctx, - SingleCatSpend { - prev_coin_id: source_cat.coin.coin_id(), - next_coin_proof: CoinProof { - parent_coin_info: source_cat.coin.parent_coin_info, - inner_puzzle_hash: funder.puzzle_hash, - amount: source_cat.coin.amount, - }, - prev_subtotal: 0, - extra_delta: 0, - p2_spend: cat_inner_spend, - revoke: false, - }, - )?; - - let spends = ctx.take(); - let cat_offer_spend = spends - .iter() - .find(|spend| spend.coin.coin_id() == source_cat.coin.coin_id()) - .expect("the CAT offer spend") - .clone(); - for spend in spends { - if spend.coin.coin_id() != source_cat.coin.coin_id() { - ctx.insert(spend); - } - } - - let signature = sign_standard_transaction( - ctx, - launcher_bls.coin, - offer_spend, - &launcher_bls.sk, - &TESTNET11_CONSTANTS, - )?; - let offer = Offer::from_spend_bundle( - ctx, - &SpendBundle { - coin_spends: vec![ - CoinSpend::new(launcher_bls.coin, puzzle_reveal, solution), - cat_offer_spend, - ], - aggregated_signature: signature, - }, - )?; - - let constants = RewardDistributorConstants::without_launcher_id( - RewardDistributorType::Managed { - manager_singleton_launcher_id: DUMMY_MANAGER_LAUNCHER_ID, - }, - funder.puzzle_hash, - TEST_EPOCH_SECONDS, - u64::MAX, - MAX_SECONDS_OFFSET, - PAYOUT_THRESHOLD_BASE_UNITS, - false, - 0, - WITHDRAWAL_SHARE_BPS, - source_cat.info.asset_id, - ); - - let launch_comment = LaunchComment::new(Bytes32::new([0xaa; 32]), Bytes32::new([0xbb; 32])); - - let launched = launch_dig_distributor( - ctx, - &offer, - FIRST_EPOCH_START, - constants, - &TESTNET11_CONSTANTS, - launch_comment, - // The simulator's clock starts at zero, so FIRST_EPOCH_START is in the future. - 0, - )?; +use dig_rewards_coin::constants::WITHDRAWAL_SHARE_BPS; - sim.spend_coins( - ctx.take(), - &[ - launcher_bls.sk.clone(), - launched.security_coin_secret_key.clone(), - funder.sk.clone(), - ], - )?; - - let launcher_id = launched.distributor.info.constants.launcher_id; - let distributor_coin_id = launched.distributor.coin.coin_id(); - let reserve_launch_id = launched.distributor.reserve.coin.coin_id(); - let reserve_parent_id = launched.distributor.reserve.coin.parent_coin_info; - let reserve_coin_id = launched.distributor.reserve.coin.coin_id(); - - // The launcher's own parent (its "security coin") is what CREATES the launcher coin, i.e. - // the spend `read_launch_comment` needs. Derived by looking the launcher's confirmed record - // up after the fact, rather than tracking the security coin id through the launch machinery - // by hand. - let security_coin_id = sim - .coin_state(launcher_id) - .expect("the launcher coin was confirmed by the launch spend") - .coin - .parent_coin_info; - - Ok(LaunchedFixture { - sim, - launcher_id, - security_coin_id, - distributor_coin_id, - reserve_coin_id, - reserve_launch_id, - reserve_parent_id, - launch_comment, - constants: launched.distributor.info.constants, - }) -} - -/// Builds a `MockChainSource` over `fixture`'s real simulator state, loading exactly what -/// `read_distributor_guarded`/`read_launch_comment` read — mirrors -/// `dig-rewards-coin::tests::simulator::chain_source_with_gaps`. -fn mock_chain_source(fixture: &LaunchedFixture) -> MockChainSource { - let singleton_members = [fixture.launcher_id, fixture.distributor_coin_id]; - - // The eve coin: `read_distributor` needs the SPEND that consumed it, not any record it - // named directly. - let eve_coin_id = fixture - .sim - .children(fixture.launcher_id) - .first() - .map(|state| state.coin.coin_id()); - - let extra_ids = [ - fixture.security_coin_id, - fixture.reserve_launch_id, - fixture.reserve_parent_id, - fixture.reserve_coin_id, - ]; - - let mut source = MockChainSource::new(); - for id in singleton_members - .iter() - .copied() - .chain(extra_ids.iter().copied()) - .chain(eve_coin_id) - { - if let Some(state) = fixture.sim.coin_state(id) { - source = source.with_coin(id, CoinRecord::from_coin_state(state)); - } - if let Some(spend) = fixture.sim.coin_spend(id) { - source = source.with_spend(id, spend); - } - } - - source = source.with_lineage( - fixture.launcher_id, - SingletonLineage::new( - fixture.distributor_coin_id, - singleton_members.iter().copied(), - ), - ); - - let peak = fixture.sim.height(); - for height in 0..=peak { - source = source.with_timestamp(height, u64::from(height) * 1_000 + 1); - } - source.with_peak(peak) -} +use common::rewards_fixture::{ + launch_fixture, mock_chain_source, FIRST_EPOCH_START, TEST_EPOCH_SECONDS, +}; /// A3: `RealRewardsChainPort::distributor_report` — the real production adapter, driven by a /// `MockChainSource` loaded from a real simulator launch — reports the values launched with, @@ -317,7 +78,11 @@ async fn distributor_report_reflects_a_real_simulator_launch() { assert_eq!(report.epoch_seconds, TEST_EPOCH_SECONDS); assert_eq!(report.first_epoch_start, FIRST_EPOCH_START); - assert_eq!(report.payout_threshold, PAYOUT_THRESHOLD_BASE_UNITS); + assert_eq!( + report.payout_threshold, fixture.constants.payout_threshold, + "the report must echo the threshold the distributor was actually launched with, not a \ + constant -- the fixture launches with a non-default threshold (#3347 mutation proof iv)" + ); assert_eq!(report.fee_bps, 0); assert_eq!( report.withdrawal_share_bps, diff --git a/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs new file mode 100644 index 00000000..89da668f --- /dev/null +++ b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs @@ -0,0 +1,515 @@ +//! DIG-Network/dig_ecosystem#3347 acceptance: `RealClaimChainPort` — the real claim-side adapter, +//! over the SAME real, decodable simulator launch `rewards_chain_port_a3.rs` uses (shared via +//! `tests/common/rewards_fixture.rs`). +//! +//! # What this proves, and what it does not +//! +//! Discovery (through an untrusted [`LauncherIndex`]), `reserve_asset_id`, and `payout_threshold` +//! are all real reads, proven end to end against a real launch. `own_entry` for an unknown payout +//! puzzle hash correctly reads `Ok(None)` (there is no entry keyed to a hash this fixture never +//! launched with). The accrued-amount and submit paths are proven separately, against a funded, +//! admitted distributor -- see this file's own DIG-Network/dig_ecosystem#3347 tests below. + +mod common; + +use std::sync::Arc; + +use async_trait::async_trait; +use chia_protocol::Bytes32; +use chia_puzzle_types::cat::CatArgs; +use dig_chainsource_interface::MockChainSource; +use dig_node_service::rewards_claim::{ + run_claim_driver_in, ClaimChainPort, ClaimLoopHandle, ClaimLoopState, ClaimPortError, + LauncherIndex, RealClaimChainPort, RewardsClaimConfig, +}; +use dig_rewards_coin::constants::PAYOUT_THRESHOLD_BASE_UNITS; +use dig_wallet::sage::spend::MockBroadcaster; + +use common::rewards_fixture::{ + launch_fixture, launch_funded_admitted_fixture, mock_chain_source, + mock_chain_source_for_funded_fixture, +}; + +/// An index that proposes exactly the ids it is built with -- no re-verification of its own; that +/// is `RealClaimChainPort::discover_distributors`'s job, which this file's tests exercise. +struct FixtureLauncherIndex(Vec); + +#[async_trait] +impl LauncherIndex for FixtureLauncherIndex { + async fn launcher_ids(&self) -> Result, ClaimPortError> { + Ok(self.0.clone()) + } +} + +/// The real launcher id discovers with the fixture's own `store_id`/`root` -- proving the SAME +/// memo-decode path `rewards_chain_port_a3.rs` proves for the funder-side adapter, now for the +/// claim-side one. +#[tokio::test(flavor = "multi_thread")] +async fn discover_distributors_returns_exactly_the_real_launch() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + + let discovered = port + .discover_distributors() + .await + .expect("a real launched distributor must discover"); + + assert_eq!(discovered.len(), 1); + assert_eq!(discovered[0].launcher_id, fixture.launcher_id); + assert_eq!(discovered[0].store_id, fixture.launch_comment.store_id); + assert_eq!(discovered[0].root, fixture.launch_comment.root); +} + +/// SPEC 13.1 clause 2: an index only PROPOSES. A bogus id mixed in with the real one must be +/// dropped, silently, by discovery's own re-verification -- never echoed back and never an error +/// for the whole batch. +#[tokio::test(flavor = "multi_thread")] +async fn a_bogus_index_entry_is_dropped_not_echoed() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let bogus_id = Bytes32::from([0xEE; 32]); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![bogus_id, fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + + let discovered = port + .discover_distributors() + .await + .expect("a bogus id must be dropped, not fail the whole discovery"); + + assert_eq!( + discovered.len(), + 1, + "an index lie must yield nothing for that id, and never overrule the real one" + ); + assert_eq!(discovered[0].launcher_id, fixture.launcher_id); +} + +/// `reserve_asset_id` and `payout_threshold` are real chain-curried reads, not the crate's own +/// default-launch literal -- both read from the fixture's OWN launched constants. +#[tokio::test(flavor = "multi_thread")] +async fn reserve_asset_id_and_payout_threshold_are_read_from_chain() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + + let reserve_asset_id = port + .reserve_asset_id(fixture.launcher_id) + .await + .expect("a real launched distributor's reserve asset id must read"); + assert_eq!( + reserve_asset_id, fixture.constants.reserve_asset_id, + "must be the fixture's OWN minted CAT asset id, not any literal" + ); + + let payout_threshold = port + .payout_threshold(fixture.launcher_id) + .await + .expect("a real launched distributor's payout threshold must read"); + assert_eq!( + payout_threshold, fixture.constants.payout_threshold, + "must be the chain-curried value the fixture launched with (deliberately NOT \ + PAYOUT_THRESHOLD_BASE_UNITS, the default -- see rewards_fixture.rs), read via \ + dig_rewards_coin::payout::payout_threshold_base_units, never a literal" + ); + assert_ne!( + payout_threshold, PAYOUT_THRESHOLD_BASE_UNITS, + "the fixture must diverge from the default, or a port that ignored the chain and \ + returned the default constant would read as correct by coincidence" + ); +} + +/// `own_entry` for a payout puzzle hash this fixture never launched with reads `Ok(None)` -- +/// "no entry", never a fabricated one and never an error. +#[tokio::test(flavor = "multi_thread")] +async fn own_entry_reads_none_for_an_unknown_payout_puzzle_hash() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + + let entry = port + .own_entry(fixture.launcher_id, Bytes32::from([0x42; 32])) + .await + .expect("a bare launch with no matching entry must read Ok(None), not an error"); + assert_eq!(entry, None); +} + +/// The production adapter names itself -- never inherits `UnavailableClaimChainPort`'s name. +#[tokio::test(flavor = "multi_thread")] +async fn kind_names_the_real_adapter() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + assert_eq!(port.kind(), "real-corroborated"); +} + +/// A source that fails outright must surface `ClaimPortError::Unavailable` from every method, +/// never a silent empty answer that would misreport "the chain has nothing" instead of "the chain +/// could not be read". +#[tokio::test(flavor = "multi_thread")] +async fn a_failing_source_reports_unavailable_everywhere() { + let source = + MockChainSource::new().fail_with(dig_chainsource_interface::ChainSourceError::Transport( + "simulated transport failure".into(), + )); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![]), + Arc::new(MockBroadcaster::default()), + ); + + let launcher_id = Bytes32::from([1u8; 32]); + assert_eq!( + port.reserve_asset_id(launcher_id).await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.payout_threshold(launcher_id).await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.own_entry(launcher_id, Bytes32::from([2u8; 32])).await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.resolve_launch_comment(launcher_id).await, + Err(ClaimPortError::Unavailable) + ); +} + +/// The 3347 acceptance proof itself: driving the REAL production body +/// (`run_claim_driver_in`, the same function [`dig_node_service::rewards_claim::spawn_claim_driver_from_config`] +/// spawns in production) with a real `RealClaimChainPort` over the fixture's real launch reaches +/// an actual chain read -- not `ClaimLoopState::ChainSourceUnavailable`, and it actually discovers +/// the one real distributor and its one (entry-less) cycle outcome. This is the one test in this +/// file that proves the WIRING, not just the adapter in isolation. +#[tokio::test(start_paused = true)] +async fn a_driven_cycle_over_the_real_adapter_reaches_a_real_chain_read() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + Arc::new(MockBroadcaster::default()), + ); + + let state_dir_guard = tempfile::tempdir().expect("a temp state dir"); + let state_dir = state_dir_guard.path().to_path_buf(); + let cfg = RewardsClaimConfig { + enabled: true, + cadence_seconds: 3_600, + jitter_seconds: 0, + ..RewardsClaimConfig::default() + }; + cfg.save_to(&state_dir) + .expect("the config must save before the driver reads it"); + + let handle = ClaimLoopHandle::default(); + let handle_for_task = handle.clone(); + let own_payout_puzzle_hash = Bytes32::from([0x42; 32]); + + tokio::spawn(async move { + run_claim_driver_in( + &state_dir, + own_payout_puzzle_hash, + dig_mirror_coin::DIG_ASSET_ID, + port, + handle_for_task, + ) + .await; + }); + + // Let the spawned task run far enough to register its first `sleep` BEFORE advancing the + // virtual clock -- `tokio::time::advance` only fires timers already registered. + for _ in 0..10 { + tokio::task::yield_now().await; + } + + // The driver's first pass sleeps `cadence_seconds + jitter` before running its first cycle + // (see `driver.rs`'s own `drive` doc) -- jitter is pinned to 0 above, so this is exact. + tokio::time::advance(std::time::Duration::from_secs(3_600)).await; + // Let the woken task actually run its cycle (real chain reads go through + // `tokio::task::spawn_blocking`, which runs on a real OS thread unaffected by the paused + // virtual clock) before reading the handle back. + for _ in 0..50 { + tokio::task::yield_now().await; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + for _ in 0..50 { + tokio::task::yield_now().await; + } + + let status = handle.status(); + // Per `rewards_chain_port_a3.rs`'s own module doc: this fixture's reserve asset is the + // SIMULATOR's own freshly minted CAT, never the real (unmintable-in-a-simulator) + // `dig_mirror_coin::DIG_ASSET_ID` -- this call passes that real asset id (the same value + // `run_claim_driver` passes in production, since #3347's U3), so + // the engine correctly reads this real distributor, sees its asset does not match, and drops + // it as `NotOurs` (SPEC 9.3) BEFORE the entry-slot read -- it is never faulted, never + // read as chain-unavailable, and never fabricated as claimable. That is still real proof the + // production body reached a real chain read through `RealClaimChainPort`: a fabricated, + // no-adapter or wrongly-wired path could not produce "discovered exactly one, asset mismatch, + // cycle completed cleanly" -- it would read either zero known or chain-unavailable instead. + assert_ne!( + status.state, + ClaimLoopState::ChainSourceUnavailable, + "a real, launched fixture must not be read as chain-unavailable" + ); + assert_eq!( + status.distributors_known, 1, + "discovery must find the one real distributor this fixture launched" + ); + assert!( + !status.fault_reported, + "a real asset-id mismatch is a clean NotOurs drop, never a fault" + ); + assert!( + status.last_cycle_at.is_some(), + "a cycle must have actually completed, not merely been scheduled" + ); +} + +/// The 3347 closure proof at the adapter level: `submit_initiate_payout` over a real, funded, +/// admitted distributor builds a bundle the SIMULATOR actually accepts (never merely well-formed), +/// and the entry's own accrued figure -- read via `own_entry` -- is what the simulator pays out. +/// Ported from `dig-rewards-coin` 0.8.0's own +/// `tests/simulator.rs::a_claim_built_entirely_from_a_chain_read_is_accepted`, with the production +/// `RealClaimChainPort` (built with a `MockBroadcaster`) standing in for that test's hand-rolled +/// `initiate_payout` + `finish_spend` + `spend_coins` call sequence. +#[tokio::test(flavor = "multi_thread")] +async fn submit_initiate_payout_builds_a_bundle_the_simulator_accepts_and_pays_the_entry() { + let payout_puzzle_hash = Bytes32::from([0x77; 32]); + let mut fixture = launch_funded_admitted_fixture(payout_puzzle_hash) + .expect("a funded, admitted distributor launches cleanly in the simulator"); + let source = mock_chain_source_for_funded_fixture(&fixture); + let broadcaster = Arc::new(MockBroadcaster::default()); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + broadcaster.clone(), + ); + + assert_eq!( + fixture.payout_puzzle_hash, payout_puzzle_hash, + "the fixture must have admitted the same payout puzzle hash this test drives against" + ); + + let entry = port + .own_entry(fixture.launcher_id, payout_puzzle_hash) + .await + .expect("the admitted entry must read") + .expect("the fixture admitted exactly this payout puzzle hash"); + assert!( + entry.accrued_base_units > 0, + "half an epoch with one entry must have accrued something" + ); + assert!( + entry.accrued_base_units >= fixture.constants.payout_threshold, + "the fixture must fund enough that the entry clears its own launched threshold" + ); + + port.submit_initiate_payout(fixture.launcher_id, payout_puzzle_hash, 0) + .await + .expect("a real accrued entry, submitted with zero fee, must build and broadcast"); + + // Fee refused -- `required_fee_mojos` is 0 for this adapter and it has nowhere to pay one from. + let fee_refusal = port + .submit_initiate_payout(fixture.launcher_id, payout_puzzle_hash, 1) + .await; + assert!( + matches!(fee_refusal, Err(ClaimPortError::Other(_))), + "a non-zero fee must be refused by name, never silently dropped or paid" + ); + + let sent = broadcaster.sent.lock().expect("the broadcaster's own lock"); + assert_eq!( + sent.len(), + 1, + "exactly one bundle must have been broadcast -- the fee-refused call must never reach \ + the broadcaster" + ); + let bundle = sent[0].clone(); + drop(sent); + + // The assertion the whole test exists for: a bundle built by the PRODUCTION adapter is + // actually ACCEPTED by the simulator, never merely well-formed. + fixture + .sim + .spend_coins(bundle.coin_spends.clone(), &[]) + .expect("the production adapter's bundle must be accepted by the simulator"); + + let reserve_asset_id = fixture.constants.reserve_asset_id; + let payee_puzzle_hash: Bytes32 = + CatArgs::curry_tree_hash(reserve_asset_id, payout_puzzle_hash.into()).into(); + let children = fixture.sim.children(fixture.reserve_tip_id); + let payee_coins: Vec<_> = children + .iter() + .filter(|state| state.coin.puzzle_hash == payee_puzzle_hash) + .collect(); + assert_eq!( + payee_coins.len(), + 1, + "exactly one payee CAT coin must exist on chain at the entry's payout puzzle hash" + ); + assert_eq!( + payee_coins[0].coin.amount, entry.accrued_base_units, + "the payee's on-chain CAT coin amount must equal the entry's own accrued figure" + ); +} + +/// DIG-Network/dig_ecosystem#3347's CLOSURE ARTIFACT: drives the whole PRODUCTION BODY +/// (`run_claim_driver_in`, the same function `run_claim_driver` calls in production, over a real +/// `RealClaimChainPort`) against a real, funded, admitted distributor -- and asserts the payout +/// coin the simulator actually accepted, not merely `cycles_driven()`. Where +/// `a_driven_cycle_over_the_real_adapter_reaches_a_real_chain_read` above stops at a clean +/// `NotOurs` (asset mismatch) and +/// `submit_initiate_payout_builds_a_bundle_the_simulator_accepts_and_pays_the_entry` drives the +/// adapter directly, this test is the two combined: the production loop itself finds the entry, +/// builds and broadcasts the spend, and the simulator pays this peer. +#[tokio::test(start_paused = true)] +async fn a_driven_cycle_over_a_funded_admitted_distributor_pays_this_peer() { + let payout_puzzle_hash = Bytes32::from([0x99; 32]); + let mut fixture = launch_funded_admitted_fixture(payout_puzzle_hash) + .expect("a funded, admitted distributor launches cleanly in the simulator"); + let source = mock_chain_source_for_funded_fixture(&fixture); + let broadcaster = Arc::new(MockBroadcaster::default()); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + broadcaster.clone(), + ); + + // Read the entry's own accrued figure directly through the adapter, BEFORE handing `port` to + // the driver -- this is the figure the driven cycle below must actually pay, independent of + // whatever the engine does with it. + let expected_accrued = port + .own_entry(fixture.launcher_id, payout_puzzle_hash) + .await + .expect("the admitted entry must read") + .expect("the fixture admitted exactly this payout puzzle hash") + .accrued_base_units; + assert!( + expected_accrued > 0, + "half an epoch with one entry must have accrued something" + ); + + let discovered = port + .discover_distributors() + .await + .expect("discovery must not error"); + assert_eq!( + discovered.len(), + 1, + "discovery must find the one real distributor this fixture launched" + ); + + let state_dir_guard = tempfile::tempdir().expect("a temp state dir"); + let state_dir = state_dir_guard.path().to_path_buf(); + let cfg = RewardsClaimConfig { + enabled: true, + cadence_seconds: 3_600, + jitter_seconds: 0, + ..RewardsClaimConfig::default() + }; + cfg.save_to(&state_dir) + .expect("the config must save before the driver reads it"); + + let handle = ClaimLoopHandle::default(); + let handle_for_task = handle.clone(); + let reserve_asset_id = fixture.constants.reserve_asset_id; + + tokio::spawn(async move { + run_claim_driver_in( + &state_dir, + payout_puzzle_hash, + reserve_asset_id, + port, + handle_for_task, + ) + .await; + }); + + // Same settle pattern as `a_driven_cycle_over_the_real_adapter_reaches_a_real_chain_read`: + // reach the driver's first `sleep` before advancing, then let the real chain read (a + // `spawn_blocking`, unaffected by the paused virtual clock) actually complete. + for _ in 0..10 { + tokio::task::yield_now().await; + } + tokio::time::advance(std::time::Duration::from_secs(3_600)).await; + for _ in 0..50 { + tokio::task::yield_now().await; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + for _ in 0..50 { + tokio::task::yield_now().await; + } + + let status = handle.status(); + assert_ne!( + status.state, + ClaimLoopState::ChainSourceUnavailable, + "a real, funded, admitted distributor must not be read as chain-unavailable" + ); + assert_eq!( + status.claims_submitted, 1, + "one cadence over one admitted, thresholded entry must submit exactly one claim" + ); + assert_eq!( + status.distributors_claimable, 1, + "the one real distributor, with its entry cleared for payout, must count as claimable" + ); + assert!( + !status.fault_reported, + "a real, correctly-built submission must never be reported as a fault" + ); + + let sent = broadcaster.sent.lock().expect("the broadcaster's own lock"); + assert_eq!( + sent.len(), + 1, + "the production driver must have broadcast exactly one bundle" + ); + let bundle = sent[0].clone(); + drop(sent); + + fixture + .sim + .spend_coins(bundle.coin_spends.clone(), &[]) + .expect("the bundle the production driver broadcast must be accepted by the simulator"); + + let payee_puzzle_hash: Bytes32 = + CatArgs::curry_tree_hash(reserve_asset_id, payout_puzzle_hash.into()).into(); + let children = fixture.sim.children(fixture.reserve_tip_id); + let payee_coins: Vec<_> = children + .iter() + .filter(|state| state.coin.puzzle_hash == payee_puzzle_hash) + .collect(); + assert_eq!( + payee_coins.len(), + 1, + "exactly one payee CAT coin must exist on chain at this peer's payout puzzle hash" + ); + assert_eq!( + payee_coins[0].coin.amount, expected_accrued, + "the payee's on-chain CAT coin amount must equal the entry's own accrued figure" + ); +} diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index d383f7af..92b63f44 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -1535,6 +1535,62 @@ async fn cache_list_cached_is_not_routable_over_ws() { ); } +/// **Proves (dig_ecosystem#3351, WS parity):** `dig.getRewardDistributor` and +/// `dig.listRewardDistributorCommitments` are OPEN reads on the HTTP transport (no token required), +/// but that openness must not accidentally widen into a SECOND, WS-reachable path. The `ws_dispatch` +/// fall-through routes an unrecognized method to `WalletBackend::dispatch`, whose match has no +/// `dig.*` arm, so both methods come back as an unknown-method error over `/ws` -- never as +/// `UNAUTHORIZED` (that would mean WS gates them where HTTP does not, which is its own bug) and +/// never as a real result (that would mean the reward-chain answer leaked over an unaudited +/// transport). +/// +/// **Catches:** a wallet-backend or `ws_dispatch` arm that starts routing `dig.*` reward reads over +/// `/ws` without the tier decision being revisited. +#[tokio::test] +async fn reward_distributor_reads_are_not_routable_over_ws() { + use tokio_tungstenite::tungstenite::Message; + let (upstream, _calls) = start_mock_upstream().await; + let (addr, _token, _backend, _hold) = start_node_wallet(&upstream).await; + + let (mut ws, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("connect to /ws"); + let _ = next_ws_json(&mut ws).await; // drain the initial sync_status snapshot + + for (idx, method) in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] + .into_iter() + .enumerate() + { + // No token: these reads are OPEN on HTTP, but that has no bearing on WS routability. + ws.send(Message::Text( + json!({ "id": format!("rd{idx}"), "type": "request", "method": method }).to_string(), + )) + .await + .unwrap(); + let resp = next_ws_json(&mut ws).await; + assert_eq!(resp["id"], json!(format!("rd{idx}"))); + assert_eq!( + resp["ok"], + json!(false), + "{method} is not a WS method, got {resp:?}" + ); + // `ws_err` emits `error.code` as the NUMERIC control-plane code (`ErrorCode::code()`, + // -32030 for Unauthorized) while `ws_from_jsonrpc` surfaces the string name; a gate + // added on either path must trip this, so reject BOTH spellings. + let is_unauthorized = resp + .pointer("/error/code") + .is_some_and(|c| c == &json!("UNAUTHORIZED") || c == &json!(-32030)); + assert!( + !is_unauthorized, + "{method} over WS must fail as unknown-method, not UNAUTHORIZED -- \ + a WS gate would contradict the HTTP-side open-read decision, got {resp:?}" + ); + } +} + /// **A person can add, list and remove a trusted Chia peer, end to end over the REAL control plane.** /// /// The whole round trip through the real server, the real token gate, the real wallet backend and @@ -3853,3 +3909,56 @@ async fn a_client_can_register_and_deregister_the_addresses_the_node_follows() { "deregistering one key must stop following exactly it, and leave the other followed" ); } + +/// **Proves:** `dig.getRewardDistributor` and `dig.listRewardDistributorCommitments` are answered +/// on `POST /` with NO control token presented — `Tier::Control` in dig-rpc-protocol's sense means +/// "loopback / in-process dispatch only, never over the mTLS peer surface", NOT token-gated +/// (dig_ecosystem#3351). Both requests must pass every ingress gate (no `-32030`/`UNAUTHORIZED`) and +/// reach the reward handler itself, which then reports `REWARD_CHAIN_UNAVAILABLE` because this +/// ephemeral test node has no chain-read adapter wired — proving dispatch, not a passthrough relay +/// or a method-not-found stub, answered the call. +/// **Catches:** a future gate added at the `server.rs` ingress (e.g. folded into the cache-trio +/// token check) that silently demotes these reads to token-gated — breaking the anonymous callers +/// nobody can enumerate — and a doc claiming they are gated when the enforced behaviour is open. +#[tokio::test] +async fn reward_distributor_reads_answer_on_post_slash_without_a_token() { + let (addr, _hold) = start_node("").await; + let launcher_id = "11".repeat(32); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp: Value = client() + .post(format!("http://{addr}/")) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": { "launcher_id": launcher_id } + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + assert_ne!( + resp["error"]["code"], + json!(-32030), + "{method} must not be Unauthorized when no token is presented: {resp}" + ); + assert_ne!( + resp["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} must not be gated by the control token: {resp}" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE"), + "{method} must reach the reward handler (no chain port wired on this ephemeral node), \ + not a passthrough or a method-not-found stub: {resp}" + ); + } +}