referrerpolicy=no-referrer-when-downgrade

sc_hop/
rpc.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 (Hand-Off protocol) RPC interface implementation.
18//!
19//! Two layers of rate limiting apply:
20//! - The node's global per-connection limit configured via `--rpc-rate-limit`.
21//! - HOP-specific per-account token buckets (request rate + bandwidth) enforced inside the pool;
22//!   see [`crate::rate_limit`] and the `--hop-*-rate` / `--hop-*-burst` CLI flags.
23
24use crate::{
25	metrics::rpc_methods,
26	pool::HopDataPool,
27	runtime_api,
28	types::{
29		submit_signing_payload, HopError, HopHash, PoolStatus, Recipient, RecipientVec,
30		SubmitResult, MAX_RECIPIENTS,
31	},
32};
33use codec::Decode;
34use jsonrpsee::{
35	core::{async_trait, RpcResult},
36	proc_macros::rpc,
37};
38use sp_api::CallApiAt;
39use sp_blockchain::HeaderBackend;
40use sp_core::{Bytes, H256};
41use sp_crypto_hashing::blake2_256;
42use sp_runtime::{
43	traits::{Block as BlockT, IdentifyAccount, Verify},
44	AccountId32, MultiSignature, MultiSigner,
45};
46use std::{marker::PhantomData, sync::Arc};
47
48/// HOP RPC methods.
49#[rpc(client, server)]
50pub trait HopApi<BlockHash> {
51	/// Submit data to the data pool.
52	///
53	/// # Arguments
54	/// * `data`: The data to store, in bytes
55	/// * `recipients`: List of SCALE-encoded `MultiSigner` (ed25519, sr25519, or ecdsa)
56	/// * `signature`: SCALE-encoded `MultiSignature` over the submit signing payload
57	///   (`blake2_256(HOP_SUBMIT_CONTEXT || blake2_256(data) || submit_timestamp.to_le_bytes())`).
58	/// * `signer`: SCALE-encoded `MultiSigner` of the account signing the submission
59	/// * `submit_timestamp`: Wall-clock timestamp (ms since unix epoch) bound into the signed
60	///   payload. The runtime rejects promotions whose timestamp is too far from on-chain time.
61	///
62	/// `data.len()` must not exceed `HopRuntimeApi::max_promotion_size()`, and
63	/// the signer must be authorized by the runtime (checked via
64	/// `HopRuntimeApi::can_account_promote`).
65	///
66	/// # Returns
67	/// The current pool status
68	#[method(name = "hop_submit", blocking)]
69	fn submit(
70		&self,
71		data: Bytes,
72		recipients: Vec<Bytes>,
73		signature: Bytes,
74		signer: Bytes,
75		submit_timestamp: u64,
76	) -> RpcResult<SubmitResult>;
77
78	/// Claim data from the data pool by hash (read-only download).
79	///
80	/// This does NOT mark the recipient as claimed. After receiving the data,
81	/// call `hop_ack` with the same arguments to confirm receipt.
82	///
83	/// The blob may be deleted concurrently by another recipient's ack once all
84	/// recipients have acknowledged; callers must be prepared for `NotFound`
85	/// and should not assume availability between successive calls.
86	///
87	/// Requires a SCALE-encoded `MultiSignature` over the hash using the ephemeral
88	/// private key corresponding to one of the recipient public keys.
89	///
90	/// # Arguments
91	/// * `hash`: The hash of the data, in bytes (32 bytes)
92	/// * `signature`: SCALE-encoded `MultiSignature` over the hash
93	///
94	/// # Returns
95	/// The data if the signature matches a recipient that hasn't yet acked
96	#[method(name = "hop_claim", blocking)]
97	fn claim(&self, raw_hash: Bytes, signature: Bytes) -> RpcResult<Bytes>;
98
99	/// Acknowledge receipt of claimed data.
100	///
101	/// Marks the recipient as claimed and triggers cleanup when all recipients
102	/// have acknowledged. Idempotent: acking twice succeeds silently, but if the
103	/// entry has already been deleted (either because all recipients have
104	/// acknowledged or because it expired) the call returns `NotFound` — callers
105	/// should treat `NotFound` as a benign terminal state rather than an error.
106	///
107	/// # Arguments
108	/// * `raw_hash`: The hash of the data, in bytes (32 bytes)
109	/// * `signature`: SCALE-encoded `MultiSignature` over the hash
110	#[method(name = "hop_ack", blocking)]
111	fn ack(&self, raw_hash: Bytes, signature: Bytes) -> RpcResult<()>;
112
113	/// Get data pool status
114	///
115	/// # Returns
116	/// Pool statistics including entry count and size
117	#[method(name = "hop_poolStatus")]
118	fn pool_status(&self) -> RpcResult<PoolStatus>;
119}
120
121/// HOP RPC server implementation.
122pub struct HopRpcServer<C, Block> {
123	pool: Arc<HopDataPool>,
124	client: Arc<C>,
125	_phantom: PhantomData<Block>,
126}
127
128impl<C, Block> HopRpcServer<C, Block> {
129	/// Create a new HOP RPC server.
130	pub fn new(pool: Arc<HopDataPool>, client: Arc<C>) -> Self {
131		Self { pool, client, _phantom: Default::default() }
132	}
133
134	/// Decode an RPC `hash` argument: 32 raw bytes (not hex).
135	fn decode_hash(bytes: Bytes) -> Result<HopHash, HopError> {
136		let hash_bytes: [u8; 32] = bytes
137			.0
138			.as_slice()
139			.try_into()
140			.map_err(|_| HopError::InvalidHashLength(bytes.0.len()))?;
141		Ok(HopHash::from(hash_bytes))
142	}
143}
144
145#[async_trait]
146impl<C, Block> HopApiServer<<Block as BlockT>::Hash> for HopRpcServer<C, Block>
147where
148	Block: BlockT,
149	C: HeaderBackend<Block> + CallApiAt<Block> + Send + Sync + 'static,
150{
151	fn submit(
152		&self,
153		data: Bytes,
154		recipients: Vec<Bytes>,
155		signature: Bytes,
156		signer: Bytes,
157		submit_timestamp: u64,
158	) -> RpcResult<SubmitResult> {
159		let result = self.do_submit(data, recipients, signature, signer, submit_timestamp);
160		if let Err(e) = &result {
161			self.pool.metrics().record_rpc_error(rpc_methods::SUBMIT, e);
162		}
163		Ok(result?)
164	}
165
166	fn claim(&self, raw_hash: Bytes, signature: Bytes) -> RpcResult<Bytes> {
167		let result = Self::decode_hash(raw_hash)
168			.and_then(|hash| self.pool.claim(&hash, &signature.0).map(Bytes));
169		if let Err(e) = &result {
170			self.pool.metrics().record_rpc_error(rpc_methods::CLAIM, e);
171		}
172		Ok(result?)
173	}
174
175	fn ack(&self, raw_hash: Bytes, signature: Bytes) -> RpcResult<()> {
176		let result =
177			Self::decode_hash(raw_hash).and_then(|hash| self.pool.ack(&hash, &signature.0));
178		if let Err(e) = &result {
179			self.pool.metrics().record_rpc_error(rpc_methods::ACK, e);
180		}
181		Ok(result?)
182	}
183
184	fn pool_status(&self) -> RpcResult<PoolStatus> {
185		Ok(self.pool.status())
186	}
187}
188
189impl<C, Block> HopRpcServer<C, Block>
190where
191	Block: BlockT,
192	C: HeaderBackend<Block> + CallApiAt<Block> + Send + Sync + 'static,
193{
194	fn do_submit(
195		&self,
196		data: Bytes,
197		recipients: Vec<Bytes>,
198		signature: Bytes,
199		signer: Bytes,
200		submit_timestamp: u64,
201	) -> Result<SubmitResult, HopError> {
202		let recipient_keys: RecipientVec = recipients
203			.into_iter()
204			.map(|r| {
205				MultiSigner::decode(&mut &r.0[..])
206					.map(|signer| Recipient { signer, claimed: false })
207					.map_err(|_| HopError::InvalidRecipientKey)
208			})
209			.collect::<Result<Vec<_>, _>>()?
210			.try_into()
211			.map_err(|v: Vec<Recipient>| HopError::TooManyRecipients {
212				provided: v.len(),
213				limit: MAX_RECIPIENTS as usize,
214			})?;
215
216		let signer =
217			MultiSigner::decode(&mut &signer.0[..]).map_err(|_| HopError::InvalidSigner)?;
218		let multi_sig = MultiSignature::decode(&mut &signature.0[..])
219			.map_err(|_| HopError::InvalidSignature)?;
220
221		let chain_info = self.client.info();
222		let best_hash = chain_info.best_hash;
223
224		let data_len = data.0.len();
225
226		// Reject oversized payloads before the per-account authorization lookup so
227		// a flood of too-big submits cannot force runtime state reads. The cap is
228		// the runtime-declared `max_promotion_size`; the runtime is authoritative.
229		let runtime_max = runtime_api::max_promotion_size::<Block, _>(&*self.client, best_hash)
230			.map_err(HopError::from)?;
231		if data_len > runtime_max as usize {
232			return Err(HopError::DataTooLarge(data_len, runtime_max));
233		}
234
235		// Check authorization before verifying the signature: a flood of unauthorized
236		// requests must not force a signature verification per submit.
237		// `can_account_promote` returns false for any reason the runtime rejects:
238		// unauthorized account or exhausted per-account quota.
239		let account_id: AccountId32 = signer.clone().into_account();
240		let authorized = runtime_api::can_account_promote::<Block, _>(
241			&*self.client,
242			best_hash,
243			account_id.clone(),
244			data_len as u32,
245		)
246		.map_err(HopError::from)?;
247		if !authorized {
248			return Err(HopError::NotAuthorized);
249		}
250
251		// Domain-separated payload so a submit signature cannot be replayed as claim/ack,
252		// and bound to `submit_timestamp` so an old signature can't be replayed long
253		// after the fact (the runtime enforces a tolerance window on the timestamp).
254		let hash = H256(blake2_256(&data.0));
255		let submit_payload = submit_signing_payload(&hash, submit_timestamp);
256		if !multi_sig.verify(&submit_payload[..], &account_id) {
257			return Err(HopError::InvalidSignature);
258		}
259
260		let sender_id: [u8; 32] = account_id.into();
261		self.pool
262			.insert(data.0, recipient_keys, sender_id, signer, multi_sig, submit_timestamp)?;
263		Ok(SubmitResult { pool_status: self.pool.status() })
264	}
265}
266
267#[cfg(test)]
268mod tests {
269	use super::*;
270	use crate::pool::HopDataPool;
271	use codec::Encode;
272	use sp_api::{ApiError, CallApiAtParams};
273	use sp_blockchain::{self, Info};
274	use sp_core::{crypto::Pair, ed25519};
275	use sp_runtime::{
276		traits::{HashingFor, NumberFor},
277		MultiSigner,
278	};
279	use sp_state_machine::InMemoryBackend;
280	use sp_test_primitives::Block;
281	use std::sync::atomic::{AtomicBool, Ordering};
282	use tempfile::TempDir;
283
284	struct MockClient {
285		authorized: AtomicBool,
286	}
287
288	impl MockClient {
289		fn new(authorized: bool) -> Self {
290			Self { authorized: AtomicBool::new(authorized) }
291		}
292	}
293
294	impl HeaderBackend<Block> for MockClient {
295		fn header(
296			&self,
297			_hash: <Block as BlockT>::Hash,
298		) -> sp_blockchain::Result<Option<<Block as BlockT>::Header>> {
299			Ok(None)
300		}
301
302		fn info(&self) -> Info<Block> {
303			Info {
304				best_hash: Default::default(),
305				best_number: 0u64,
306				genesis_hash: Default::default(),
307				finalized_hash: Default::default(),
308				finalized_number: 0u64,
309				finalized_state: None,
310				number_leaves: 0,
311				block_gap: None,
312			}
313		}
314
315		fn status(
316			&self,
317			_hash: <Block as BlockT>::Hash,
318		) -> sp_blockchain::Result<sp_blockchain::BlockStatus> {
319			Ok(sp_blockchain::BlockStatus::Unknown)
320		}
321
322		fn number(
323			&self,
324			_hash: <Block as BlockT>::Hash,
325		) -> sp_blockchain::Result<Option<NumberFor<Block>>> {
326			Ok(None)
327		}
328
329		fn hash(
330			&self,
331			_number: NumberFor<Block>,
332		) -> sp_blockchain::Result<Option<<Block as BlockT>::Hash>> {
333			Ok(None)
334		}
335	}
336
337	impl CallApiAt<Block> for MockClient {
338		type StateBackend = InMemoryBackend<HashingFor<Block>>;
339
340		fn call_api_at(&self, params: CallApiAtParams<Block>) -> Result<Vec<u8>, ApiError> {
341			match params.function {
342				"HopRuntimeApi_max_promotion_size" => Ok((2u32 * 1024 * 1024).encode()),
343				"HopRuntimeApi_can_account_promote" => {
344					Ok(self.authorized.load(Ordering::Relaxed).encode())
345				},
346				"HopRuntimeApi_is_promoted_on_chain" => Ok(false.encode()),
347				other => Err(ApiError::Application(
348					format!("MockClient: unimplemented runtime API call {}", other).into(),
349				)),
350			}
351		}
352
353		fn runtime_version_at(
354			&self,
355			_at_hash: <Block as BlockT>::Hash,
356			_call_context: sp_api::CallContext,
357		) -> Result<sp_version::RuntimeVersion, ApiError> {
358			unimplemented!("MockClient::runtime_version_at not used by tests")
359		}
360
361		fn state_at(&self, _at: <Block as BlockT>::Hash) -> Result<Self::StateBackend, ApiError> {
362			unimplemented!("MockClient::state_at not used by tests")
363		}
364
365		fn initialize_extensions(
366			&self,
367			_at: <Block as BlockT>::Hash,
368			_extensions: &mut sp_externalities::Extensions,
369		) -> Result<(), ApiError> {
370			Ok(())
371		}
372	}
373
374	fn setup(authorized: bool) -> (HopRpcServer<MockClient, Block>, Arc<HopDataPool>, TempDir) {
375		let dir = TempDir::new().unwrap();
376		let pool = Arc::new(
377			HopDataPool::new(
378				1024 * 1024,
379				1024 * 1024,
380				100,
381				dir.path().to_path_buf(),
382				crate::rate_limit::RateLimitConfig::disabled(),
383				crate::metrics::HopMetrics::disabled(),
384			)
385			.unwrap(),
386		);
387		let client = Arc::new(MockClient::new(authorized));
388		let rpc = HopRpcServer::new(pool.clone(), client);
389		(rpc, pool, dir)
390	}
391
392	/// Same as [`setup`] but with metrics registered.
393	fn setup_metered() -> (HopRpcServer<MockClient, Block>, Arc<HopDataPool>, TempDir) {
394		let registry = prometheus_endpoint::Registry::new();
395		let dir = TempDir::new().unwrap();
396		let pool = Arc::new(
397			HopDataPool::new(
398				1024 * 1024,
399				1024 * 1024,
400				100,
401				dir.path().to_path_buf(),
402				crate::rate_limit::RateLimitConfig::disabled(),
403				crate::metrics::HopMetrics::new(Some(&registry)).unwrap(),
404			)
405			.unwrap(),
406		);
407		let client = Arc::new(MockClient::new(true));
408		let rpc = HopRpcServer::new(pool.clone(), client);
409		(rpc, pool, dir)
410	}
411
412	fn make_keypair() -> (ed25519::Pair, MultiSigner) {
413		let pair = ed25519::Pair::from_seed(&[1u8; 32]);
414		let signer = MultiSigner::Ed25519(pair.public());
415		(pair, signer)
416	}
417
418	/// Fixed submit timestamp used in tests where the actual value is irrelevant.
419	const TEST_SUBMIT_TS: u64 = 1_700_000_000_000;
420
421	/// Produce a domain-separated submit signature for `data` bound to a timestamp.
422	fn submit_sig(pair: &ed25519::Pair, data: &[u8], submit_timestamp: u64) -> Bytes {
423		let hash = H256(blake2_256(data));
424		let payload = submit_signing_payload(&hash, submit_timestamp);
425		let multi_sig = MultiSignature::Ed25519(pair.sign(&payload));
426		Bytes(multi_sig.encode())
427	}
428
429	fn claim_sig(pair: &ed25519::Pair, hash: &H256) -> Bytes {
430		use crate::types::{signing_payload, HOP_CLAIM_CONTEXT};
431		let payload = signing_payload(HOP_CLAIM_CONTEXT, hash);
432		Bytes(MultiSignature::Ed25519(pair.sign(&payload)).encode())
433	}
434
435	fn ack_sig(pair: &ed25519::Pair, hash: &H256) -> Bytes {
436		use crate::types::{signing_payload, HOP_ACK_CONTEXT};
437		let payload = signing_payload(HOP_ACK_CONTEXT, hash);
438		Bytes(MultiSignature::Ed25519(pair.sign(&payload)).encode())
439	}
440
441	#[test]
442	fn rpc_metrics_record_errors() {
443		let (rpc, pool, _dir) = setup_metered();
444		let (pair, _) = make_keypair();
445
446		// Reaches the pool and comes back `not_found`.
447		let unknown = H256([9u8; 32]);
448		assert!(rpc.claim(Bytes(unknown.0.to_vec()), claim_sig(&pair, &unknown)).is_err());
449		assert_eq!(pool.metrics().rpc_error_count(rpc_methods::CLAIM, "not_found"), 1);
450
451		// Never reaches the pool, still counted.
452		assert!(rpc.ack(Bytes(vec![0u8; 4]), Bytes(vec![0u8; 64])).is_err());
453		assert_eq!(pool.metrics().rpc_error_count(rpc_methods::ACK, "invalid_hash_length"), 1);
454	}
455
456	#[test]
457	fn submit_invalid_scale_signer_returns_error() {
458		let (rpc, _, _dir) = setup(true);
459		// One valid recipient so the RecipientVec step passes; then the SCALE-invalid
460		// signer bytes trigger `InvalidSigner`.
461		let (_, valid_signer) = make_keypair();
462		let result = rpc.submit(
463			Bytes(vec![1, 2, 3]),
464			vec![Bytes(valid_signer.encode())],
465			Bytes(vec![0u8; 3]),
466			Bytes(vec![0u8; 3]),
467			TEST_SUBMIT_TS,
468		);
469		assert!(result.is_err());
470		let err = result.unwrap_err();
471		assert!(err.message().contains("SCALE-decode MultiSigner"), "got: {}", err.message());
472	}
473
474	#[test]
475	fn submit_invalid_scale_signature_returns_error() {
476		let (rpc, _, _dir) = setup(true);
477		let (_, signer) = make_keypair();
478		let result = rpc.submit(
479			Bytes(vec![1, 2, 3]),
480			vec![Bytes(signer.encode())],
481			Bytes(vec![0u8; 3]),
482			Bytes(signer.encode()),
483			TEST_SUBMIT_TS,
484		);
485		assert!(result.is_err());
486		let err = result.unwrap_err();
487		assert!(err.message().contains("Invalid signature"), "got: {}", err.message());
488	}
489
490	#[test]
491	fn submit_bad_signature_returns_error() {
492		let (rpc, _, _dir) = setup(true);
493		let (_, signer) = make_keypair();
494		// Sign with a different key.
495		let wrong_pair = ed25519::Pair::from_seed(&[99u8; 32]);
496		let data = vec![1, 2, 3];
497		let sig = submit_sig(&wrong_pair, &data, TEST_SUBMIT_TS);
498
499		let result = rpc.submit(
500			Bytes(data),
501			vec![Bytes(signer.encode())],
502			sig,
503			Bytes(signer.encode()),
504			TEST_SUBMIT_TS,
505		);
506		assert!(result.is_err());
507		let err = result.unwrap_err();
508		assert!(err.message().contains("Invalid signature"), "got: {}", err.message());
509	}
510
511	#[test]
512	fn submit_unauthorized_account_returns_error() {
513		let (rpc, _, _dir) = setup(false);
514		let (pair, signer) = make_keypair();
515		let data = vec![1, 2, 3];
516		let sig = submit_sig(&pair, &data, TEST_SUBMIT_TS);
517
518		let result = rpc.submit(
519			Bytes(data),
520			vec![Bytes(signer.encode())],
521			sig,
522			Bytes(signer.encode()),
523			TEST_SUBMIT_TS,
524		);
525		assert!(result.is_err());
526		let err = result.unwrap_err();
527		assert!(err.message().contains("authorization"), "got: {}", err.message());
528	}
529
530	#[test]
531	fn submit_success() {
532		let (rpc, pool, _dir) = setup(true);
533		let (pair, signer) = make_keypair();
534		let data = vec![1, 2, 3, 4, 5];
535		let sig = submit_sig(&pair, &data, TEST_SUBMIT_TS);
536
537		let result = rpc.submit(
538			Bytes(data),
539			vec![Bytes(signer.encode())],
540			sig,
541			Bytes(signer.encode()),
542			TEST_SUBMIT_TS,
543		);
544		assert!(result.is_ok(), "submit failed: {:?}", result.err());
545		let submit_result = result.unwrap();
546		assert_eq!(submit_result.pool_status.entry_count, 1);
547		// Accounted bytes include per-recipient metadata overhead, not just the blob.
548		assert_eq!(submit_result.pool_status.total_bytes, crate::types::entry_accounted_size(5, 1),);
549		assert_eq!(pool.status().entry_count, 1);
550	}
551
552	#[test]
553	fn submit_rejects_oversized_recipient_list() {
554		let (rpc, _, _dir) = setup(true);
555		let (pair, signer) = make_keypair();
556		let data = vec![1, 2, 3];
557		let sig = submit_sig(&pair, &data, TEST_SUBMIT_TS);
558
559		let oversized: Vec<Bytes> = std::iter::repeat_with(|| Bytes(signer.encode()))
560			.take(MAX_RECIPIENTS as usize + 1)
561			.collect();
562
563		let result =
564			rpc.submit(Bytes(data), oversized, sig, Bytes(signer.encode()), TEST_SUBMIT_TS);
565		assert!(result.is_err());
566		let err = result.unwrap_err();
567		assert!(err.message().contains("Too many recipients"), "got: {}", err.message());
568	}
569
570	#[test]
571	fn claim_invalid_hash_length() {
572		let (rpc, _, _dir) = setup(true);
573		let result = rpc.claim(Bytes(vec![0u8; 31]), Bytes(vec![0u8; 64]));
574		assert!(result.is_err());
575		let err = result.unwrap_err();
576		assert!(err.message().contains("expected 32 bytes"), "got: {}", err.message());
577	}
578
579	#[test]
580	fn claim_and_ack_through_rpc() {
581		let (rpc, _, _dir) = setup(true);
582		let (pair, signer) = make_keypair();
583		let data = vec![10, 20, 30];
584		let sig = submit_sig(&pair, &data, TEST_SUBMIT_TS);
585
586		rpc.submit(
587			Bytes(data.clone()),
588			vec![Bytes(signer.encode())],
589			sig,
590			Bytes(signer.encode()),
591			TEST_SUBMIT_TS,
592		)
593		.unwrap();
594
595		let hash = H256(blake2_256(&data));
596		let claimed = rpc.claim(Bytes(hash.0.to_vec()), claim_sig(&pair, &hash)).unwrap();
597		assert_eq!(claimed.0, data);
598
599		rpc.ack(Bytes(hash.0.to_vec()), ack_sig(&pair, &hash)).unwrap();
600
601		let status = rpc.pool_status().unwrap();
602		assert_eq!(status.entry_count, 0);
603	}
604
605	#[test]
606	fn pool_status_returns_correct_values() {
607		let (rpc, _, _dir) = setup(true);
608		let status = rpc.pool_status().unwrap();
609		assert_eq!(status.entry_count, 0);
610		assert_eq!(status.total_bytes, 0);
611		assert_eq!(status.max_bytes, 1024 * 1024);
612	}
613}