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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion crates/dig-node-service/src/meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,14 @@ pub enum ErrorCode {
/// leave the next person debugging a refusal unable to tell which bound they hit. Retriable:
/// the caller should back off. Shell error (minted before dispatch). (Control range `-3203x`.)
ControlIngressLimited,
/// `-32034` — an OPEN `pairing.request` was refused: either the pending-pairing slot budget is
/// already at its cap (a pending request the node already accepted is never displaced to make
/// room for a later one), or this node's pairing-request rate bound for the window is
/// exhausted. ONE code for both, because the remedy is identical either way — back off; a slot
/// frees itself as an existing pairing is approved or expires. Distinct from
/// `CONTROL_INGRESS_LIMITED` (bounds token-less READS) and `WALLET_RATE_LIMITED` (bounds egress
/// to a third-party chain oracle). Retriable. Node error. (Control range `-3203x`.)
PairingPendingLimited,
}

/// The numeric code the shared wire contract assigns, widened to the `i64` the JSON-RPC
Expand Down Expand Up @@ -838,6 +846,7 @@ impl ErrorCode {
ErrorCode::PeerPingRefused => -32060,
ErrorCode::PushPendingLimited => -32016,
ErrorCode::ControlIngressLimited => -32033,
ErrorCode::PairingPendingLimited => -32034,
}
}

Expand Down Expand Up @@ -872,6 +881,7 @@ impl ErrorCode {
ErrorCode::PeerPingRefused => "PEER_PING_REFUSED",
ErrorCode::PushPendingLimited => "PUSH_PENDING_LIMITED",
ErrorCode::ControlIngressLimited => "CONTROL_INGRESS_LIMITED",
ErrorCode::PairingPendingLimited => "PAIRING_PENDING_LIMITED",
}
}

Expand Down Expand Up @@ -906,7 +916,9 @@ impl ErrorCode {
// The peer ping is run BY the node's own peer network, so the refusal is the node's.
| ErrorCode::PeerPingRefused
// The push-reassembly bound is enforced by the node's own capsule seam.
| ErrorCode::PushPendingLimited => "node",
| ErrorCode::PushPendingLimited
// The pairing-slot bound is enforced by the node's own pairing plane.
| ErrorCode::PairingPendingLimited => "node",
// INVALID_PARAMS is returned by the embedded read path's locally-served
// read methods (bad store_id / retrieval_key) before any I/O.
ErrorCode::InvalidParams => "node",
Expand Down Expand Up @@ -991,6 +1003,11 @@ impl ErrorCode {
"An open, token-less control read was refused at ingress: this source's request \
bound is exhausted. Distinct from WALLET_RATE_LIMITED, which bounds chain egress."
}
ErrorCode::PairingPendingLimited => {
"A pairing.request was refused: the pending-pairing slot budget is at its cap, or \
this node's pairing-request rate bound is exhausted. Back off and retry; a slot \
frees itself as an existing pairing is approved or expires."
}
}
}

Expand Down Expand Up @@ -1019,6 +1036,7 @@ impl ErrorCode {
ErrorCode::PeerPingRefused,
ErrorCode::PushPendingLimited,
ErrorCode::ControlIngressLimited,
ErrorCode::PairingPendingLimited,
]
}
}
Expand Down
31 changes: 30 additions & 1 deletion crates/dig-node-service/src/pair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ fn now_ms() -> u64 {
.unwrap_or(0)
}

/// The params `pairing.poll` is called with — lifted out (like the strings below)
/// because the poll loop itself dials a real node and cannot be driven from a unit
/// test. `redemption_secret` is REQUIRED (#3191/W1): the node refuses a poll that
/// omits it, so a client that forgot to thread it through would fail every poll.
fn poll_params(pairing_id: &str, redemption_secret: &str) -> Value {
json!({ "pairing_id": pairing_id, "redemption_secret": redemption_secret })
}

/// `dig-node pair connect` — the CLIENT half of the handshake (#403).
///
/// Uses [`call_open`], never [`call_control`]: the requester by definition holds no token yet, and
Expand All @@ -117,13 +125,25 @@ fn connect(config: &Config, client_name: Option<String>) -> std::io::Result<Outc
.as_str()
.ok_or_else(|| std::io::Error::other("dig-node: pairing.request returned no pairing_id"))?
.to_string();
// #3191/W1: the redemption secret is the value that actually redeems the token —
// captured here from the request result and never derived from `pairing_id`.
let redemption_secret = requested["redemption_secret"]
.as_str()
.ok_or_else(|| {
std::io::Error::other("dig-node: pairing.request returned no redemption_secret")
})?
.to_string();
let code = requested["pairing_code"].as_str().unwrap_or("??????");
let expires_ms = requested["expires_ms"].as_u64().unwrap_or(0);

eprintln!("{}", waiting_banner(code, &pairing_id));

loop {
let polled = call_open(config, "pairing.poll", json!({ "pairing_id": pairing_id }))?;
let polled = call_open(
config,
"pairing.poll",
poll_params(&pairing_id, &redemption_secret),
)?;
match polled["status"].as_str().unwrap_or("unknown") {
"approved" => {
let token = polled["token"].as_str().ok_or_else(|| {
Expand Down Expand Up @@ -259,6 +279,15 @@ fn format_list(result: &Value) -> String {
mod tests {
use super::*;

/// **Proves (#3191/W1):** the CLI client polls with the redemption secret it received from
/// `pairing.request`, never with the `pairing_id` alone.
#[test]
fn the_pair_client_polls_with_the_redemption_secret_it_received() {
let params = poll_params("a-pairing-id", "a-redemption-secret");
assert_eq!(params["pairing_id"], json!("a-pairing-id"));
assert_eq!(params["redemption_secret"], json!("a-redemption-secret"));
}

/// **Proves (dig-node#403):** none of `pair connect`'s user-facing strings carries the
/// signature of a lost `\` line continuation.
///
Expand Down
Loading
Loading