decdn_incentive/node_register.rs
1//! Off-chain construction of the `CapacityBond.registerNode` signing inputs
2//! (ADR 019 § Step 2.3).
3//!
4//! Two byte-exact encodings the on-chain `CapacityBond` depends on:
5//!
6//! - [`ownership_message_digest`] reproduces
7//! `CapacityBond._verifyEd25519OwnershipSignature`'s message hash
8//! `keccak256(abi.encodePacked(nodeId, msg.sender, block.chainid, registrationNonce))`.
9//! The operator signs this 32-byte digest with the iroh node key; the
10//! contract's `Ed25519Verifier` treats the digest as the ed25519 message.
11//! A drift here makes `registerNode` revert `InvalidEd25519Signature`.
12//! - [`pack_multiaddrs`] builds the on-chain `multiaddrs` `bytes` field:
13//! a sequence of `(uint16 length, bytes data)` entries (ADR 019 §
14//! Multiaddr encoding). The length prefix is big-endian, matching the
15//! `uint16` reading of the ADR. Nodes learn addresses via `cdn/dht/v1`
16//! discovery (ADR 022) rather than from this field, so the ADR — not a consumer —
17//! is the authority for the format. [`unpack_multiaddrs`] is its inverse,
18//! read back only by `decdn node rotate-key --key eth` when it re-registers
19//! an operator's existing addresses from a new Ethereum address.
20//!
21//! The EIP-712 `BindNodeId` half of registration lives in [`crate::bind_sig`]
22//! and is reused as-is — `binding_signing_hash` produces the digest the
23//! operator signs with the Ethereum key.
24
25use alloy::primitives::{Address, B256, U256, keccak256};
26
27/// On-chain ownership-proof digest signed by the iroh node key during
28/// `registerNode` (ADR 019 § Step 2.3).
29///
30/// Byte-matches `CapacityBond._verifyEd25519OwnershipSignature`:
31/// `keccak256(abi.encodePacked(nodeId, operator, chainId, nonce))` over the
32/// 92-byte preimage `nodeId(32) ‖ operator(20) ‖ chainId(uint256, 32B BE) ‖
33/// nonce(uint64, 8B BE)`. `nonce` is `registrationNonce[nodeId]` read from
34/// the contract (0 for a never-registered nodeId).
35#[must_use]
36pub fn ownership_message_digest(
37 node_id: B256,
38 operator: Address,
39 chain_id: u64,
40 registration_nonce: u64,
41) -> B256 {
42 let mut preimage = Vec::with_capacity(92);
43 preimage.extend_from_slice(node_id.as_slice()); // bytes32 → 32
44 preimage.extend_from_slice(operator.as_slice()); // address → 20
45 preimage.extend_from_slice(&U256::from(chain_id).to_be_bytes::<32>()); // uint256 → 32 BE
46 preimage.extend_from_slice(®istration_nonce.to_be_bytes()); // uint64 → 8 BE
47 keccak256(preimage)
48}
49
50/// Pack QUIC multiaddr strings into the on-chain `multiaddrs` `bytes` field:
51/// a sequence of `(uint16 length, bytes data)` entries with big-endian length
52/// prefixes (ADR 019 § Multiaddr encoding). An empty slice yields empty bytes,
53/// which the contract accepts (a node may rely on the iroh relay / `cdn/dht/v1`
54/// discovery for reachability until it promotes direct addresses via
55/// `updateMultiaddrs`).
56///
57/// # Errors
58///
59/// Returns an error if any single multiaddr exceeds `u16::MAX` bytes (it could
60/// not be length-prefixed). The contract separately enforces a total-size
61/// ceiling (`maxMultiaddrSize`, default 1,024 bytes); that bound is reported by
62/// the on-chain revert rather than re-checked here.
63pub fn pack_multiaddrs(addrs: &[String]) -> anyhow::Result<Vec<u8>> {
64 // Each entry is a 2-byte length prefix + its bytes; pre-size to avoid
65 // reallocating as entries are appended.
66 let mut out = Vec::with_capacity(addrs.iter().map(|a| a.len() + 2).sum());
67 for addr in addrs {
68 let bytes = addr.as_bytes();
69 let len = u16::try_from(bytes.len()).map_err(|_| {
70 anyhow::anyhow!(
71 "multiaddr is {} bytes, exceeds the uint16 length prefix max of {}: {addr}",
72 bytes.len(),
73 u16::MAX,
74 )
75 })?;
76 out.extend_from_slice(&len.to_be_bytes());
77 out.extend_from_slice(bytes);
78 }
79 Ok(out)
80}
81
82/// Inverse of [`pack_multiaddrs`]: decode the on-chain `multiaddrs` `bytes`
83/// field back into the strings that produced it.
84///
85/// Needed by `decdn node rotate-key --key eth`, which re-registers an operator's
86/// existing multiaddrs from a new Ethereum address. Without this the migration
87/// would either silently drop them — leaving the node reachable only through
88/// relay / `cdn/dht/v1` discovery — or force the operator to retype a set the
89/// chain already holds.
90///
91/// # Errors
92///
93/// Returns an error if the buffer is truncated (a length prefix with no body,
94/// or a trailing odd byte) or if an entry is not UTF-8. Both mean the field was
95/// not produced by [`pack_multiaddrs`], and guessing at a repair would put an
96/// address the operator never registered back on-chain.
97pub fn unpack_multiaddrs(packed: &[u8]) -> anyhow::Result<Vec<String>> {
98 let mut out = Vec::new();
99 let mut rest = packed;
100 while !rest.is_empty() {
101 let (prefix, body) = rest.split_at_checked(2).ok_or_else(|| {
102 anyhow::anyhow!(
103 "truncated multiaddr length prefix ({} byte(s) left)",
104 rest.len()
105 )
106 })?;
107 // `split_at_checked` guarantees exactly 2 bytes, so the array conversion
108 // cannot fail; `get`/`try_into` keeps it out of the indexing lint.
109 let len =
110 usize::from(u16::from_be_bytes(prefix.try_into().map_err(|_| {
111 anyhow::anyhow!("multiaddr length prefix is not 2 bytes")
112 })?));
113 let (entry, tail) = body.split_at_checked(len).ok_or_else(|| {
114 anyhow::anyhow!(
115 "multiaddr entry claims {len} bytes but only {} remain",
116 body.len()
117 )
118 })?;
119 out.push(
120 std::str::from_utf8(entry)
121 .map_err(|e| anyhow::anyhow!("multiaddr entry is not UTF-8: {e}"))?
122 .to_string(),
123 );
124 rest = tail;
125 }
126 Ok(out)
127}
128
129/// Decode the on-chain `multiaddrs` field into dialable UDP socket addresses,
130/// for use as iroh direct-address hints (ADR 001 § Node Discovery). Leniency is
131/// per entry: a non-UTF8 entry, or one that is not a
132/// `/ip4|ip6/<addr>/udp/<port>/quic-v1` QUIC multiaddr, is skipped on its own
133/// while the valid entries around it survive. This walks the `pack_multiaddrs`
134/// framing directly rather than through [`unpack_multiaddrs`], whose all-or-
135/// nothing contract would drop every entry on the first bad one. Only a length
136/// prefix that overruns the buffer stops the walk — past that point the next
137/// entry's position is unknowable — so the entries decoded before it still
138/// count. A self-attested address is only ever one dial path among several, so
139/// a malformed or partial record can only fail to add a direct path, never
140/// remove a peer from the candidate set, leaving iroh discovery and the relay
141/// fallback intact.
142#[must_use]
143pub fn decode_dial_addrs(packed: &[u8]) -> Vec<std::net::SocketAddr> {
144 let mut out = Vec::new();
145 let mut rest = packed;
146 while let Some((prefix, body)) = rest.split_at_checked(2) {
147 // `split_at_checked(2)` guarantees exactly two bytes, so the array
148 // conversion cannot fail; keeping it fallible stays out of the indexing
149 // lint without a panic path.
150 let Ok(len_bytes) = <[u8; 2]>::try_from(prefix) else {
151 break;
152 };
153 let len = usize::from(u16::from_be_bytes(len_bytes));
154 let Some((entry, tail)) = body.split_at_checked(len) else {
155 // Length prefix overruns the buffer: the next entry's offset is
156 // unknowable, so stop — but keep whatever decoded before here.
157 break;
158 };
159 if let Ok(s) = std::str::from_utf8(entry)
160 && let Some(sock) = parse_quic_multiaddr(s)
161 {
162 out.push(sock);
163 }
164 rest = tail;
165 }
166 out
167}
168
169/// Parse one `/ip4/<addr>/udp/<port>/quic-v1` (or `/ip6/…`) QUIC multiaddr into
170/// a [`std::net::SocketAddr`]. Returns `None` for any string that is not that
171/// shape; the caller reads `None` as "no direct hint from this entry", never an
172/// error. Trailing segments (e.g. `/p2p/<id>`) are ignored — the socket address
173/// is fully determined by the ip and udp segments.
174fn parse_quic_multiaddr(s: &str) -> Option<std::net::SocketAddr> {
175 // Split on '/', dropping the empty element the leading slash produces:
176 // ["ip4", "<addr>", "udp", "<port>", "quic-v1", ..].
177 let mut segs = s.strip_prefix('/')?.split('/');
178 let proto = segs.next()?;
179 if proto != "ip4" && proto != "ip6" {
180 return None;
181 }
182 let ip: std::net::IpAddr = segs.next()?.parse().ok()?;
183 // Reject a family mismatch (`/ip4/::1/…`): the declared proto must match the
184 // parsed address family, or the record is malformed and dropped.
185 if proto == "ip4" && !ip.is_ipv4() || proto == "ip6" && !ip.is_ipv6() {
186 return None;
187 }
188 if segs.next()? != "udp" {
189 return None;
190 }
191 let port: u16 = segs.next()?.parse().ok()?;
192 if segs.next()? != "quic-v1" {
193 return None;
194 }
195 Some(std::net::SocketAddr::new(ip, port))
196}
197
198#[cfg(test)]
199#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
200mod tests;