decdn_incentive/bind_sig.rs
1//! EIP-712 `BindNodeId` verification for ephemeral client bindings.
2//!
3//! Clients without on-chain registration MAY attach a signed
4//! `NodeId`↔Ethereum-address binding to their first `StreamRequest` on a
5//! connection (ADR 003 §Off-Chain Ephemeral Binding, ADR 005 §Client identity
6//! binding). The delivering node verifies the EIP-712 signature, recovers the
7//! Ethereum address, and uses it for voucher attribution for the connection's
8//! lifetime. The binding is not stored on-chain.
9//!
10//! The signed payload follows ADR 003 §Binding Message Format exactly so an
11//! off-chain Rust verification byte-matches what the on-chain `CapacityBond`
12//! contract accepts:
13//!
14//! ```text
15//! BindNodeId(bytes32 nodeId,uint64 nonce)
16//! ```
17//!
18//! Ephemeral client bindings always sign `nonce = 0` (the on-chain registration
19//! path uses the per-address `bindingNonce`, a distinct domain of values).
20//!
21//! # Domain
22//!
23//! The EIP-712 domain is the `CapacityBond` contract deployment
24//! (`EIP712("CapacityBond", "1")`, see `contracts/src/CapacityBond.sol`), so an
25//! ephemeral binding is bound to the same chain + contract as on-chain
26//! registration, preventing cross-chain / cross-contract replay (ADR 003
27//! §Binding Message Format).
28//!
29//! # Scope
30//!
31//! Only EOA (`ecrecover`) verification is implemented here, matching the
32//! EOA-only off-chain signing stance of [`crate::probe_sig`] and
33//! [`crate::voucher`]. Off-chain verification is EOA recovery only (ADR 024
34//! §Off-Chain Signature Verification — EOA Recovery Only); a smart-account
35//! client binds through a capability-delegated EOA `signer` instead, so no
36//! ERC-1271 off-chain branch exists.
37
38use alloy::dyn_abi::Eip712Domain;
39use alloy::primitives::{Address, B256, Signature};
40use alloy::sol_types::{SolStruct, eip712_domain};
41
42/// EIP-712 domain `name` field for the `CapacityBond` deployment. Must match
43/// `EIP712("CapacityBond", "1")` in `contracts/src/CapacityBond.sol` — a
44/// mismatch produces a different digest and every binding fails to recover the
45/// expected address.
46pub const CAPACITY_BOND_DOMAIN_NAME: &str = "CapacityBond";
47
48/// EIP-712 domain `version` field.
49pub const CAPACITY_BOND_DOMAIN_VERSION: &str = "1";
50
51/// `nonce` value for ephemeral (off-chain) client bindings (ADR 003 §Off-Chain
52/// Ephemeral Binding). On-chain registration uses the per-address
53/// `bindingNonce` instead.
54pub const EPHEMERAL_BINDING_NONCE: u64 = 0;
55
56// Solidity struct mirroring ADR 003 §Binding Message Format. The `sol!` macro
57// uses the Rust struct name as the on-chain Solidity type name; the contract's
58// struct is `BindNodeId`, wrapped in a private module to keep the name local.
59mod sol_types {
60 alloy::sol! {
61 #[allow(non_snake_case, missing_debug_implementations)]
62 struct BindNodeId {
63 bytes32 nodeId;
64 uint64 nonce;
65 }
66 // ADR 019 § Terms Acceptance — the registration binding signature
67 // additionally commits to the accepted `termsHash`. Distinct typehash
68 // from `BindNodeId`: rebinding (key rotation) stays on `BindNodeId` and
69 // does not re-accept terms.
70 #[allow(non_snake_case, missing_debug_implementations)]
71 struct RegisterNode {
72 bytes32 nodeId;
73 uint64 nonce;
74 bytes32 termsHash;
75 }
76 }
77}
78
79use sol_types::BindNodeId as BindNodeIdSol;
80use sol_types::RegisterNode as RegisterNodeSol;
81
82/// Construct the EIP-712 domain used to verify `BindNodeId` signatures for a
83/// given `CapacityBond` deployment.
84#[must_use]
85pub fn bind_node_id_domain(chain_id: u64, verifying_contract: Address) -> Eip712Domain {
86 eip712_domain! {
87 name: CAPACITY_BOND_DOMAIN_NAME,
88 version: CAPACITY_BOND_DOMAIN_VERSION,
89 chain_id: chain_id,
90 verifying_contract: verifying_contract,
91 }
92}
93
94/// EIP-712 signing hash for a `BindNodeId(nodeId, nonce)` under `domain`.
95///
96/// Used for the ephemeral client binding (ADR 003 §Off-Chain Ephemeral Binding)
97/// and on-chain rebinding (`bindNodeId`, key rotation). Initial node
98/// registration signs [`register_node_signing_hash`] instead.
99#[must_use]
100pub fn binding_signing_hash(node_id: B256, nonce: u64, domain: &Eip712Domain) -> B256 {
101 BindNodeIdSol {
102 nodeId: node_id,
103 nonce,
104 }
105 .eip712_signing_hash(domain)
106}
107
108/// EIP-712 signing hash for a `RegisterNode(nodeId, nonce, termsHash)` under
109/// `domain` — the initial on-chain node registration binding (ADR 019 § Terms
110/// Acceptance). `terms_hash` MUST equal the contract's `currentTermsHash`, and
111/// the signature cryptographically binds the operator's acceptance to it.
112#[must_use]
113pub fn register_node_signing_hash(
114 node_id: B256,
115 nonce: u64,
116 terms_hash: B256,
117 domain: &Eip712Domain,
118) -> B256 {
119 RegisterNodeSol {
120 nodeId: node_id,
121 nonce,
122 termsHash: terms_hash,
123 }
124 .eip712_signing_hash(domain)
125}
126
127/// Verify an ephemeral client binding and recover the attesting Ethereum
128/// address (EOA `ecrecover`).
129///
130/// `node_id` is the client's 32-byte iroh `NodeId` (the connection's
131/// authenticated remote id); `nonce` is [`EPHEMERAL_BINDING_NONCE`] for
132/// off-chain bindings. The recovered address is what the node uses for voucher
133/// attribution; the caller MUST further check it equals the lane's `signer`
134/// before accepting vouchers.
135///
136/// # Errors
137///
138/// - [`BindError::BadLength`] — `signature` is not 65 bytes (the EOA form).
139/// - [`BindError::Malformed`] — well-formed length but non-canonical `s` /
140/// invalid recovery id, so no address can be recovered.
141pub fn verify_binding(
142 node_id: B256,
143 nonce: u64,
144 signature: &[u8],
145 domain: &Eip712Domain,
146) -> Result<Address, BindError> {
147 // EOA-only: a fixed 65-byte (r‖s‖v) signature. ERC-1271 (variable length)
148 // is deferred (see module docs).
149 if signature.len() != 65 {
150 return Err(BindError::BadLength {
151 len: signature.len(),
152 });
153 }
154 let sig = Signature::from_raw(signature).map_err(|_| BindError::Malformed)?;
155 // Reject non-canonical high-`s` so the off-chain accept-set matches the
156 // on-chain verifiable-set (#836).
157 if crate::sig_canon::is_high_s(&sig) {
158 return Err(BindError::Malformed);
159 }
160 let hash = binding_signing_hash(node_id, nonce, domain);
161 sig.recover_address_from_prehash(&hash)
162 .map_err(|_| BindError::Malformed)
163}
164
165/// Failure modes for [`verify_binding`].
166#[derive(Debug, thiserror::Error, PartialEq, Eq)]
167pub enum BindError {
168 /// Signature is not the 65-byte EOA form. ERC-1271 smart-account bindings
169 /// (variable length) are not supported off-chain; a smart-account client
170 /// binds through a capability-delegated EOA `signer` instead (ADR 024
171 /// §Off-Chain Signature Verification — EOA Recovery Only).
172 #[error("binding signature has invalid length {len} (EOA form is 65 bytes)")]
173 BadLength {
174 /// The length that was offered.
175 len: usize,
176 },
177 /// Signature is 65 bytes but malformed — non-canonical `s` or invalid
178 /// recovery id; no address could be recovered.
179 #[error("binding signature is malformed")]
180 Malformed,
181}
182
183#[cfg(test)]
184#[allow(clippy::similar_names)] // signer/signed pair up clearly here
185mod tests;