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