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;