Skip to main content

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;