referrerpolicy=no-referrer-when-downgrade

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}