Skip to main content

decdn_incentive/
probe_sig.rs

1//! EIP-712 `slash_sig` signatures for `cdn/probe/v1` `ProbeResponse` messages.
2//!
3//! Every `ProbeResponse` carries a mandatory, non-empty secp256k1 EIP-712
4//! signature (`slash_sig`) over its frozen signed field set. The signature
5//! makes the response cryptographically attributable to a registered node
6//! and is the on-chain evidence for rate-manipulation and blacklist-violation
7//! slashing (ADR 005 §`cdn/probe/v1`, ADR 014).
8//!
9//! The signed payload follows ADR 014 §EIP-712 Type Definitions exactly so an
10//! off-chain Rust signature byte-matches what the on-chain `SlashJudge`
11//! contract recovers signatures against. `hash` is the queried blob hash:
12//! in this implementation's wire shape it *is* echoed back in
13//! `decdn_protocol::ProbeResponseBody.hash` (not omitted as request-only
14//! context), and it is part of the EIP-712 signed set (ADR 014 §1) — so a
15//! verifier reconstructs the typed data from the response body's own fields.
16//!
17//! # Domain
18//!
19//! ```text
20//! EIP712Domain {
21//!     name: "deCDN SlashJudge",
22//!     version: "1",
23//!     chainId: <L2 chain id>,
24//!     verifyingContract: <SlashJudge deployment address>,
25//! }
26//! ```
27//!
28//! The `SlashJudge` contract uses its own domain separator (distinct from
29//! `CapacityBond` / `PaymentPool`) to prevent cross-contract
30//! signature replay (ADR 014 §EIP-712 Type Definitions).
31//!
32//! # `ProbeResponse` type
33//!
34//! ```text
35//! ProbeResponse(bytes32 hash,bool hasBlob,uint64 ratePerMb,uint64 timestampUs)
36//! ```
37
38use alloy::dyn_abi::Eip712Domain;
39use alloy::primitives::{Address, B256, Signature};
40use alloy::signers::SignerSync;
41use alloy::sol_types::{SolStruct, eip712_domain};
42
43/// EIP-712 domain `name` field. Must match the `SlashJudge` contract's domain
44/// exactly — a mismatch produces a different digest and every signature fails
45/// on-chain.
46pub const SLASH_JUDGE_DOMAIN_NAME: &str = "deCDN SlashJudge";
47
48/// EIP-712 domain `version` field.
49pub const SLASH_JUDGE_DOMAIN_VERSION: &str = "1";
50
51// Solidity struct mirroring ADR 014 §EIP-712 Type Definitions. Field names and
52// order are part of the signed type — both must match the on-chain contract
53// verbatim, hence the camelCase. The `sol!` macro generates a `SolStruct`
54// impl whose `eip712_signing_hash` produces the same 32-byte digest the
55// contract recovers signatures against.
56//
57// Wrapped in a private module because the `sol!` macro uses the Rust struct
58// name as the on-chain Solidity type name in the EIP-712 type-string. The
59// contract's struct is `ProbeResponse`, so the Rust struct must be
60// `ProbeResponse` too — the wrapping module avoids a name clash with
61// `decdn_protocol::ProbeResponse`.
62mod sol_types {
63    alloy::sol! {
64        #[allow(non_snake_case, missing_debug_implementations)]
65        struct ProbeResponse {
66            bytes32 hash;
67            bool hasBlob;
68            uint64 ratePerMb;
69            uint64 timestampUs;
70        }
71    }
72}
73
74use sol_types::ProbeResponse as ProbeResponseSol;
75
76/// Pinned keccak256 digest of the canonical `ProbeResponse` EIP-712 type
77/// string (ADR 014 §EIP-712 Type Definitions):
78///
79/// ```text
80/// ProbeResponse(bytes32 hash,bool hasBlob,uint64 ratePerMb,uint64 timestampUs)
81/// ```
82///
83/// The Solidity suite pins `SlashJudge.PROBE_RESPONSE_TYPEHASH` to the same
84/// digest (`contracts/test/SlashJudge.t.sol`), so a one-sided edit of either
85/// side's type string fails default CI on both sides (#1843).
86pub const PROBE_RESPONSE_TYPEHASH: B256 =
87    alloy::primitives::b256!("0x4638cb019b77f8677ac7015556f19e56f0be5a6a0d9392006a7fb5b8acd587e5");
88
89/// Construct the EIP-712 domain used to sign probe responses for a given
90/// `SlashJudge` deployment.
91#[must_use]
92pub fn slash_judge_domain(chain_id: u64, verifying_contract: Address) -> Eip712Domain {
93    eip712_domain! {
94        name: SLASH_JUDGE_DOMAIN_NAME,
95        version: SLASH_JUDGE_DOMAIN_VERSION,
96        chain_id: chain_id,
97        verifying_contract: verifying_contract,
98    }
99}
100
101/// The signed field set of a `ProbeResponse` (ADR 014 §1).
102///
103/// `hash` is the queried blob hash echoed from the `ProbeRequest`;
104/// `timestamp_us` is the requester-generated timestamp echoed back. Together
105/// with `has_blob` and `rate_per_mb` these are exactly the fields the
106/// on-chain `SlashJudge` reconstructs to verify rate-manipulation and
107/// blacklist-violation evidence.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct ProbeSlashData {
110    /// BLAKE3 hash of the probed blob (iroh `Hash` bytes).
111    pub hash: B256,
112    /// Whether the node claimed to hold the blob.
113    pub has_blob: bool,
114    /// The node's quoted rate in token base units per MB.
115    pub rate_per_mb: u64,
116    /// Requester-generated microsecond timestamp echoed from the request.
117    pub timestamp_us: u64,
118}
119
120impl ProbeSlashData {
121    const fn to_sol(self) -> ProbeResponseSol {
122        ProbeResponseSol {
123            hash: self.hash,
124            hasBlob: self.has_blob,
125            ratePerMb: self.rate_per_mb,
126            timestampUs: self.timestamp_us,
127        }
128    }
129
130    /// EIP-712 signing hash bound to `domain`. This is the 32-byte digest
131    /// passed into `ecrecover` on-chain; it depends only on the data and
132    /// `domain` and is independent of the signing key (different signers
133    /// over the same data/domain produce different signatures of this same
134    /// digest).
135    #[must_use]
136    pub fn signing_hash(&self, domain: &Eip712Domain) -> B256 {
137        (*self).to_sol().eip712_signing_hash(domain)
138    }
139
140    /// EIP-712 struct hash (`keccak256(abi.encode(PROBE_RESPONSE_TYPEHASH,
141    /// fields...))`), independent of any domain. This is the per-message hash
142    /// `SlashJudge` folds into its `evidenceHash` commit
143    /// (`keccak256(abi.encode(offense, probeStructHash, streamStructHash))`), so
144    /// a challenger reconstructs the commitment from it (#1032, G-NODE-05).
145    #[must_use]
146    pub fn struct_hash(&self) -> B256 {
147        (*self).to_sol().eip712_hash_struct()
148    }
149
150    /// Sign the probe response with `signer` for the given EIP-712 `domain`,
151    /// returning the 65-byte (`r‖s‖v`) signature for the wire `slash_sig`.
152    ///
153    /// # Errors
154    ///
155    /// Propagates any error returned by the underlying signer (key locked,
156    /// remote signer offline, etc.).
157    pub fn sign<S: SignerSync>(
158        &self,
159        signer: &S,
160        domain: &Eip712Domain,
161    ) -> Result<Signature, alloy::signers::Error> {
162        let hash = self.signing_hash(domain);
163        signer.sign_hash_sync(&hash)
164    }
165
166    /// Recover the address that produced `signature` over this data under
167    /// `domain`.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`ProbeSlashError::InvalidSignature`] if the signature is
172    /// malformed (non-canonical `s`, invalid recovery id, etc.).
173    pub fn recover_signer(
174        &self,
175        signature: &Signature,
176        domain: &Eip712Domain,
177    ) -> Result<Address, ProbeSlashError> {
178        // Reject non-canonical high-`s` so the off-chain accept-set matches the
179        // on-chain `SlashJudge` verifiable-set (#836).
180        if crate::sig_canon::is_high_s(signature) {
181            return Err(ProbeSlashError::InvalidSignature);
182        }
183        let hash = self.signing_hash(domain);
184        signature
185            .recover_address_from_prehash(&hash)
186            .map_err(|_| ProbeSlashError::InvalidSignature)
187    }
188
189    /// Verify `signature` was produced by `expected` for `domain`.
190    ///
191    /// # Errors
192    ///
193    /// - [`ProbeSlashError::InvalidSignature`] — signature is malformed.
194    /// - [`ProbeSlashError::WrongSigner`] — recovered address differs from
195    ///   `expected`.
196    pub fn verify_signer(
197        &self,
198        signature: &Signature,
199        expected: Address,
200        domain: &Eip712Domain,
201    ) -> Result<(), ProbeSlashError> {
202        let recovered = self.recover_signer(signature, domain)?;
203        if recovered == expected {
204            Ok(())
205        } else {
206            Err(ProbeSlashError::WrongSigner {
207                expected,
208                recovered,
209            })
210        }
211    }
212}
213
214/// Failure modes for probe-response signature verification.
215#[derive(Debug, thiserror::Error, PartialEq, Eq)]
216pub enum ProbeSlashError {
217    /// The signature is malformed — non-canonical `s`, invalid recovery id,
218    /// or a corrupted byte. (Length is enforced earlier at the protocol
219    /// boundary via `decdn_protocol::SLASH_SIG_LEN` before an
220    /// `alloy::primitives::Signature` is ever constructed; this variant does
221    /// not itself length-check.)
222    #[error("probe slash_sig is malformed")]
223    InvalidSignature,
224    /// The signature is well-formed but recovers to an address that does not
225    /// match the expected signer.
226    #[error("probe slash_sig signed by {recovered}, expected {expected}")]
227    WrongSigner {
228        /// The address the probe attestation had to be signed by.
229        expected: Address,
230        /// The address the signature actually recovers to.
231        recovered: Address,
232    },
233}
234
235#[cfg(test)]
236#[allow(clippy::similar_names)] // signer/signed and address/addr pair up clearly here
237mod tests;