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