sc_hop/types.rs
1// Copyright (C) Parity Technologies (UK) Ltd.
2// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0
3
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with this program. If not, see <https://www.gnu.org/licenses/>.
16
17//! HOP types and data structures.
18
19use codec::{Decode, Encode};
20use polkadot_primitives::{BlockNumber, Hash};
21use serde::{Deserialize, Serialize};
22use sp_core::{bounded_vec::BoundedVec, ConstU32};
23use sp_crypto_hashing::blake2_256;
24use sp_runtime::{MultiSignature, MultiSigner};
25
26/// Block number type used by HOP.
27pub type HopBlockNumber = BlockNumber;
28
29/// Hash type used by HOP.
30pub type HopHash = Hash;
31
32/// Sender identity derived from the account that signed the submission.
33pub type SenderId = [u8; 32];
34
35/// One intended recipient of a HOP entry: the ephemeral public key the sender
36/// generated for this handoff, paired with the `claimed` flag that tracks whether
37/// this recipient has acked. Fusing the two into a single struct (and a single
38/// `BoundedVec<Recipient, ...>`) makes it impossible — by construction and on
39/// disk — for the key list and the ack state to drift out of sync.
40#[derive(Debug, Clone, Encode, Decode)]
41pub struct Recipient {
42 /// Ephemeral public key (MultiSigner: ed25519, sr25519, or ecdsa).
43 pub signer: MultiSigner,
44 /// Whether this recipient has acked receipt.
45 pub claimed: bool,
46}
47
48/// On-disk format version for `HopEntryMeta` records. Startup recovery rejects
49/// `.meta` files whose `version` field doesn't match, so same-shape schema
50/// changes (e.g. semantic reinterpretation of an existing field) can be rolled
51/// out by bumping this constant; shape changes are caught by SCALE decode failure.
52pub const HOP_META_VERSION: u8 = 2;
53
54/// Metadata for a pool entry (stored in-memory index and on-disk .meta files).
55#[derive(Debug, Clone, Encode, Decode)]
56pub struct HopEntryMeta {
57 /// On-disk format version; see `HOP_META_VERSION`.
58 pub version: u8,
59 /// Unix timestamp (seconds) at which this entry expires.
60 pub expires_at: u64,
61 /// Size in bytes
62 pub size: u64,
63 /// Intended recipients and their per-recipient ack state.
64 ///
65 /// Using a `BoundedVec` means a corrupted / hostile on-disk `.meta` file with
66 /// too many recipients fails to SCALE-decode and is discarded during startup
67 /// recovery rather than being loaded into the in-memory index.
68 pub recipients: RecipientVec,
69 /// Account ID of the sender who submitted this entry.
70 pub sender_id: SenderId,
71 /// Whether this entry has been promoted to permanent on-chain storage.
72 pub promoted: bool,
73 /// `MultiSigner` of the account that signed the submission. The runtime pallet
74 /// re-verifies the submit signature using this key when the unsigned promotion
75 /// extrinsic lands on-chain.
76 pub signer: MultiSigner,
77 /// The user's `hop_submit` signature over `submit_signing_payload(blake2_256(data),
78 /// submit_timestamp)`. Carried along for the runtime to re-verify; "submit implies
79 /// consent to promote" is the protocol semantic.
80 pub signature: MultiSignature,
81 /// Submit-time wall-clock timestamp (ms since unix epoch) bound into the
82 /// signing payload. The runtime rejects promotions whose timestamp is too far
83 /// from on-chain time, so old `(data, signer, signature)` tuples cannot be
84 /// replayed indefinitely.
85 pub submit_timestamp: u64,
86 /// Number of times the maintenance task has tried (and failed) to promote
87 /// this entry. Used together with `next_promotion_attempt_at` for
88 /// exponential back-off. Reset behavior: never reset — once an entry hits
89 /// `MAX_PROMOTION_ATTEMPTS` it is left to expire normally.
90 pub promotion_attempts: u8,
91 /// Block height at which the next promotion attempt becomes eligible.
92 /// `0` means "any tick"; non-zero means the maintenance task should skip
93 /// this entry until the chain reaches this block.
94 pub next_promotion_attempt_at: HopBlockNumber,
95}
96
97impl HopEntryMeta {
98 /// Create a new entry metadata (without data blob)
99 pub fn new(
100 size: u64,
101 expires_at: u64,
102 recipients: RecipientVec,
103 sender_id: SenderId,
104 signer: MultiSigner,
105 signature: MultiSignature,
106 submit_timestamp: u64,
107 ) -> Self {
108 Self {
109 version: HOP_META_VERSION,
110 expires_at,
111 size,
112 recipients,
113 sender_id,
114 promoted: false,
115 signer,
116 signature,
117 submit_timestamp,
118 promotion_attempts: 0,
119 next_promotion_attempt_at: 0,
120 }
121 }
122}
123
124/// Maximum number of promotion attempts per entry before the maintenance
125/// task gives up and lets the entry expire naturally. With the back-off
126/// schedule below this caps wasted work at 1+2+4+8+16 = 31 check
127/// intervals (~2.6 h at the default 5 min cadence) per stuck entry. The
128/// first 5 attempts fit inside the default 2 h promotion buffer; the 6th
129/// is an upper bound that may land past expiry on a stuck entry.
130pub const MAX_PROMOTION_ATTEMPTS: u8 = 6;
131
132/// Compute the back-off in blocks to wait before the next promotion attempt
133/// after `attempts` consecutive failures. The first failure triggers a 1×
134/// wait, doubling each subsequent failure: `1×, 2×, 4×, 8×, 16×, 32×` the
135/// check interval, with the shift saturated to keep multiplication safe.
136pub fn promotion_backoff_blocks(attempts: u8, check_interval_blocks: u32) -> u32 {
137 let shift = attempts.saturating_sub(1).min(5) as u32;
138 check_interval_blocks.saturating_mul(1u32 << shift)
139}
140
141/// Pool statistics
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct PoolStatus {
145 /// Number of entries in the pool
146 pub entry_count: usize,
147 /// Total bytes used
148 pub total_bytes: u64,
149 /// Maximum bytes allowed
150 pub max_bytes: u64,
151}
152
153/// Result of a successful `hop_submit` call
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct SubmitResult {
157 /// Current pool status after the submission
158 pub pool_status: PoolStatus,
159}
160
161/// HOP errors
162#[derive(Debug, thiserror::Error)]
163pub enum HopError {
164 #[error("Data too large: {0} bytes (max: {1})")]
165 DataTooLarge(usize, u32),
166
167 #[error("Pool full: {0}/{1} bytes used")]
168 PoolFull(u64, u64),
169
170 #[error("Data already exists in pool")]
171 DuplicateEntry,
172
173 #[error("Data not found")]
174 NotFound,
175
176 #[error("Invalid data: size cannot be zero")]
177 EmptyData,
178
179 #[error("Invalid signature")]
180 InvalidSignature,
181
182 #[error("Not an intended recipient")]
183 NotRecipient,
184
185 #[error("At least one recipient public key is required")]
186 NoRecipients,
187
188 #[error("Invalid recipient: failed to SCALE-decode MultiSigner")]
189 InvalidRecipientKey,
190
191 #[error("User quota exceeded: using {used} of {limit} bytes")]
192 UserQuotaExceeded { used: u64, limit: u64 },
193
194 #[error("Account does not have a valid authorization")]
195 NotAuthorized,
196
197 #[error("Invalid signer: failed to SCALE-decode MultiSigner")]
198 InvalidSigner,
199
200 #[error("I/O error: {0}")]
201 IoError(#[from] std::io::Error),
202
203 #[error("Recipient already acknowledged, data may have been deleted")]
204 AlreadyClaimed,
205
206 #[error("Invalid hash length: expected 32 bytes, got {0}")]
207 InvalidHashLength(usize),
208
209 #[error("Runtime API error: {0}")]
210 RuntimeApiError(#[from] sp_api::ApiError),
211
212 #[error("Too many recipients: {provided} (max {limit})")]
213 TooManyRecipients { provided: usize, limit: usize },
214
215 #[error("Duplicate recipient in list")]
216 DuplicateRecipient,
217
218 #[error("Rate limited: retry after {retry_after_secs}s")]
219 RateLimited { retry_after_secs: u64 },
220
221 #[error("No database path available and --hop-data-dir not specified")]
222 MissingDataDir,
223}
224
225impl From<HopError> for jsonrpsee::types::ErrorObjectOwned {
226 fn from(err: HopError) -> Self {
227 let code = match err {
228 HopError::DataTooLarge(_, _) => 1001,
229 HopError::PoolFull(_, _) => 1002,
230 HopError::DuplicateEntry => 1003,
231 HopError::NotFound => 1004,
232 HopError::EmptyData => 1005,
233 HopError::InvalidSignature => 1007,
234 HopError::NotRecipient => 1008,
235 HopError::NoRecipients => 1009,
236 HopError::InvalidRecipientKey => 1010,
237 HopError::UserQuotaExceeded { .. } => 1011,
238 HopError::NotAuthorized => 1012,
239 HopError::IoError(_) => 1013,
240 HopError::InvalidSigner => 1014,
241 HopError::AlreadyClaimed => 1015,
242 HopError::InvalidHashLength(_) => 1016,
243 HopError::RuntimeApiError(_) => 1017,
244 HopError::TooManyRecipients { .. } => 1018,
245 HopError::DuplicateRecipient => 1019,
246 HopError::RateLimited { .. } => 1020,
247 HopError::MissingDataDir => 1021,
248 };
249
250 jsonrpsee::types::ErrorObject::owned(code, err.to_string(), None::<()>)
251 }
252}
253
254/// Default retention period in seconds (24 hours).
255pub const DEFAULT_RETENTION_SECS: u64 = 86_400;
256
257/// Default maximum pool size in bytes (10 GiB)
258pub const DEFAULT_MAX_POOL_SIZE: u64 = 10 * 1024 * 1024 * 1024;
259
260/// Default maximum pool size in MiB (10 GiB = 10240 MiB)
261pub const DEFAULT_MAX_POOL_SIZE_MIB: u64 = DEFAULT_MAX_POOL_SIZE / (1024 * 1024);
262
263/// Default maintenance interval in seconds (5 minutes)
264pub const DEFAULT_CHECK_INTERVAL_SECS: u64 = 300;
265
266/// Block-time assumption used when translating the wall-clock maintenance
267/// interval into block deltas for the promotion back-off scheduler.
268pub const HOP_BLOCK_TIME_SECS: u64 = 6;
269
270/// Maximum number of recipients allowed per submission.
271///
272/// Caps the fan-out so that per-entry metadata (both RAM and disk) is bounded
273/// and `find_recipient`'s signature-verification scan is bounded.
274pub const MAX_RECIPIENTS: u32 = 256;
275
276/// A `Vec<Recipient>` that SCALE-decode rejects if it exceeds `MAX_RECIPIENTS`,
277/// enforcing the fan-out cap at the type level instead of via scattered runtime checks.
278pub type RecipientVec = BoundedVec<Recipient, ConstU32<MAX_RECIPIENTS>>;
279
280/// Default per-user quota in MiB (256 MiB). Hard cap, not scaled by active users.
281pub const DEFAULT_MAX_USER_SIZE_MIB: u64 = 256;
282
283/// Default buffer before expiry at which to start promoting entries on-chain (2 h).
284pub const DEFAULT_PROMOTION_BUFFER_SECS: u64 = 7200;
285
286/// Default sustained submit rate per account (requests per minute).
287pub const DEFAULT_SUBMIT_RATE_PER_MIN: u32 = 60;
288
289/// Default submit burst per account (requests).
290pub const DEFAULT_SUBMIT_BURST: u32 = 120;
291
292/// Default sustained bandwidth per account in MiB per minute.
293pub const DEFAULT_BANDWIDTH_PER_MIN_MIB: u64 = 128;
294
295/// Default bandwidth burst per account in MiB.
296pub const DEFAULT_BANDWIDTH_BURST_MIB: u64 = 256;
297
298/// Domain-separator prefix for `hop_submit` signatures.
299pub const HOP_SUBMIT_CONTEXT: &[u8] = b"hop-submit-v1:";
300
301/// Domain-separator prefix for `hop_claim` signatures.
302pub const HOP_CLAIM_CONTEXT: &[u8] = b"hop-claim-v1:";
303
304/// Domain-separator prefix for `hop_ack` signatures.
305pub const HOP_ACK_CONTEXT: &[u8] = b"hop-ack-v1:";
306
307/// Compute the 32-byte payload that HOP recipients / submitters sign for a given
308/// operation. This is `blake2_256(context || hash)` and ensures signatures from
309/// one operation cannot be replayed in another.
310pub fn signing_payload(context: &[u8], hash: &HopHash) -> [u8; 32] {
311 let mut buf = Vec::with_capacity(context.len() + 32);
312 buf.extend_from_slice(context);
313 buf.extend_from_slice(hash.as_bytes());
314 blake2_256(&buf)
315}
316
317/// Compute the 32-byte payload signed at `hop_submit` time.
318///
319/// The runtime pallet re-derives this exact byte sequence to verify the
320/// signature on-chain, so the construction must remain byte-identical to the
321/// pallet's `signing_payload(data, submit_timestamp)`:
322/// `blake2_256(HOP_SUBMIT_CONTEXT || blake2_256(data) || submit_timestamp.to_le_bytes())`.
323pub fn submit_signing_payload(hash: &HopHash, submit_timestamp: u64) -> [u8; 32] {
324 let mut buf = [0u8; HOP_SUBMIT_CONTEXT.len() + 32 + 8];
325 buf[..HOP_SUBMIT_CONTEXT.len()].copy_from_slice(HOP_SUBMIT_CONTEXT);
326 buf[HOP_SUBMIT_CONTEXT.len()..HOP_SUBMIT_CONTEXT.len() + 32].copy_from_slice(hash.as_bytes());
327 buf[HOP_SUBMIT_CONTEXT.len() + 32..].copy_from_slice(&submit_timestamp.to_le_bytes());
328 blake2_256(&buf)
329}
330
331/// Per-recipient overhead charged against pool capacity and per-user quota, in bytes.
332/// Covers the in-memory `Recipient` (a `MultiSigner` plus a `bool`). Kept as a
333/// small constant that over-approximates `size_of::<Recipient>()`.
334pub const METADATA_COST_PER_RECIPIENT: u64 = 40;
335
336/// Total bytes an entry charges against pool capacity: the blob plus bounded
337/// per-recipient metadata overhead.
338pub fn entry_accounted_size(data_size: u64, num_recipients: usize) -> u64 {
339 data_size.saturating_add((num_recipients as u64).saturating_mul(METADATA_COST_PER_RECIPIENT))
340}