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