pezkuwi-subxt-lightclient 0.44.0

Light Client for chain interaction
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
// Copyright 2019-2024 Parity Technologies (UK) Ltd.
// This file is dual-licensed as Apache-2.0 or GPL-3.0.
// see LICENSE for license details.

use crate::{rpc::RpcResponse, shared_client::SharedClient, JsonRpcError, LightClientRpcError};
use futures::{stream::StreamExt, FutureExt};
use serde_json::value::RawValue;
use smoldot_light::platform::PlatformRef;
use std::{collections::HashMap, str::FromStr};
use tokio::sync::{mpsc, oneshot};
use tokio_stream::wrappers::UnboundedReceiverStream;

const LOG_TARGET: &str = "subxt-light-client-background-task";

/// Response from [`BackgroundTaskHandle::request()`].
pub type MethodResponse = Result<Box<RawValue>, LightClientRpcError>;

/// Response from [`BackgroundTaskHandle::subscribe()`].
pub type SubscriptionResponse = Result<
	(SubscriptionId, mpsc::UnboundedReceiver<Result<Box<RawValue>, JsonRpcError>>),
	LightClientRpcError,
>;

/// Type of subscription IDs we can get back.
pub type SubscriptionId = String;

/// Message protocol between the front-end client that submits the RPC requests
/// and the background task which fetches responses from Smoldot. Hidden behind
/// the [`BackgroundTaskHandle`].
#[derive(Debug)]
enum Message {
	/// The RPC method request.
	Request {
		/// The method of the request.
		method: String,
		/// The parameters of the request.
		params: Option<Box<RawValue>>,
		/// Channel used to send back the method response.
		sender: oneshot::Sender<MethodResponse>,
	},
	/// The RPC subscription (pub/sub) request.
	Subscription {
		/// The method of the request.
		method: String,
		/// The method to unsubscribe.
		unsubscribe_method: String,
		/// The parameters of the request.
		params: Option<Box<RawValue>>,
		/// Channel used to send back the subscription response.
		sender: oneshot::Sender<SubscriptionResponse>,
	},
}

/// A handle to communicate with the background task.
#[derive(Clone, Debug)]
pub struct BackgroundTaskHandle {
	to_backend: mpsc::UnboundedSender<Message>,
}

impl BackgroundTaskHandle {
	/// Make an RPC request via the background task.
	pub async fn request(&self, method: String, params: Option<Box<RawValue>>) -> MethodResponse {
		let (tx, rx) = oneshot::channel();
		self.to_backend
			.send(Message::Request { method, params, sender: tx })
			.map_err(|_e| LightClientRpcError::BackgroundTaskDropped)?;

		match rx.await {
			Err(_e) => Err(LightClientRpcError::BackgroundTaskDropped),
			Ok(response) => response,
		}
	}

	/// Subscribe to some RPC method via the background task.
	pub async fn subscribe(
		&self,
		method: String,
		params: Option<Box<RawValue>>,
		unsubscribe_method: String,
	) -> SubscriptionResponse {
		let (tx, rx) = oneshot::channel();
		self.to_backend
			.send(Message::Subscription { method, params, unsubscribe_method, sender: tx })
			.map_err(|_e| LightClientRpcError::BackgroundTaskDropped)?;

		match rx.await {
			Err(_e) => Err(LightClientRpcError::BackgroundTaskDropped),
			Ok(response) => response,
		}
	}
}

/// A background task which runs with [`BackgroundTask::run()`] and manages messages
/// coming to/from Smoldot.
#[allow(clippy::type_complexity)]
pub struct BackgroundTask<TPlatform: PlatformRef, TChain> {
	channels: BackgroundTaskChannels<TPlatform>,
	data: BackgroundTaskData<TPlatform, TChain>,
}

impl<TPlatform: PlatformRef, TChain> BackgroundTask<TPlatform, TChain> {
	/// Constructs a new [`BackgroundTask`].
	pub(crate) fn new(
		client: SharedClient<TPlatform, TChain>,
		chain_id: smoldot_light::ChainId,
		from_back: smoldot_light::JsonRpcResponses<TPlatform>,
	) -> (BackgroundTask<TPlatform, TChain>, BackgroundTaskHandle) {
		let (tx, rx) = mpsc::unbounded_channel();

		let bg_task = BackgroundTask {
			channels: BackgroundTaskChannels {
				from_front: UnboundedReceiverStream::new(rx),
				from_back,
			},
			data: BackgroundTaskData {
				client,
				chain_id,
				last_request_id: 0,
				pending_subscriptions: HashMap::new(),
				requests: HashMap::new(),
				subscriptions: HashMap::new(),
			},
		};

		let bg_handle = BackgroundTaskHandle { to_backend: tx };

		(bg_task, bg_handle)
	}

	/// Run the background task, which:
	/// - Forwards messages/subscription requests to Smoldot from the front end.
	/// - Forwards responses back from Smoldot to the front end.
	pub async fn run(self) {
		let chain_id = self.data.chain_id;
		let mut channels = self.channels;
		let mut data = self.data;

		loop {
			tokio::pin! {
				let from_front_fut = channels.from_front.next().fuse();
				let from_back_fut = channels.from_back.next().fuse();
			}

			futures::select! {
				// Message coming from the front end/client.
				front_message = from_front_fut => {
					let Some(message) = front_message else {
						tracing::trace!(target: LOG_TARGET, "Subxt channel closed");
						break;
					};
					tracing::trace!(
						target: LOG_TARGET,
						"Received register message {:?}",
						message
					);

					data.handle_requests(message).await;
				},
				// Message coming from Smoldot.
				back_message = from_back_fut => {
					let Some(back_message) = back_message else {
						tracing::trace!(target: LOG_TARGET, "Smoldot RPC responses channel closed");
						break;
					};

					tracing::trace!(
						target: LOG_TARGET,
						"Received smoldot RPC chain {chain_id:?} result {}",
						trim_message(&back_message),
					);

					data.handle_rpc_response(back_message);
				}
			}
		}

		tracing::trace!(target: LOG_TARGET, "Task closed");
	}
}

struct BackgroundTaskChannels<TPlatform: PlatformRef> {
	/// Messages sent into this background task from the front end.
	from_front: UnboundedReceiverStream<Message>,
	/// Messages sent into the background task from Smoldot.
	from_back: smoldot_light::JsonRpcResponses<TPlatform>,
}

struct BackgroundTaskData<TPlatform: PlatformRef, TChain> {
	/// A smoldot light client that can be shared.
	client: SharedClient<TPlatform, TChain>,
	/// Knowing the chain ID helps with debugging, but isn't otherwise necessary.
	chain_id: smoldot_light::ChainId,
	/// Know which Id to use next for new requests/subscriptions.
	last_request_id: usize,
	/// Map the request ID of a RPC method to the frontend `Sender`.
	requests: HashMap<usize, oneshot::Sender<MethodResponse>>,
	/// Subscription calls first need to make a plain RPC method
	/// request to obtain the subscription ID.
	///
	/// The RPC method request is made in the background and the response should
	/// not be sent back to the user.
	/// Map the request ID of a RPC method to the frontend `Sender`.
	pending_subscriptions: HashMap<usize, PendingSubscription>,
	/// Map the subscription ID to the frontend `Sender`.
	///
	/// The subscription ID is entirely generated by the node (smoldot). Therefore, it is
	/// possible for two distinct subscriptions of different chains to have the same subscription
	/// ID.
	subscriptions: HashMap<String, ActiveSubscription>,
}

/// The state needed to resolve the subscription ID and send
/// back the response to frontend.
struct PendingSubscription {
	/// Send the method response ID back to the user.
	///
	/// It contains the subscription ID if successful, or an JSON RPC error object.
	response_sender: oneshot::Sender<SubscriptionResponse>,
	/// The unsubscribe method to call when the user drops the receiver
	/// part of the channel.
	unsubscribe_method: String,
}

/// The state of the subscription.
struct ActiveSubscription {
	/// Channel to send the subscription notifications back to frontend.
	notification_sender: mpsc::UnboundedSender<Result<Box<RawValue>, JsonRpcError>>,
	/// The unsubscribe method to call when the user drops the receiver
	/// part of the channel.
	unsubscribe_method: String,
}

fn trim_message(s: &str) -> &str {
	const MAX_SIZE: usize = 512;
	if s.len() < MAX_SIZE {
		return s;
	}

	match s.char_indices().nth(MAX_SIZE) {
		None => s,
		Some((idx, _)) => &s[..idx],
	}
}

impl<TPlatform: PlatformRef, TChain> BackgroundTaskData<TPlatform, TChain> {
	/// Fetch and increment the request ID.
	fn next_id(&mut self) -> usize {
		self.last_request_id = self.last_request_id.wrapping_add(1);
		self.last_request_id
	}

	/// Handle the registration messages received from the user.
	async fn handle_requests(&mut self, message: Message) {
		match message {
			Message::Request { method, params, sender } => {
				let id = self.next_id();
				let chain_id = self.chain_id;

				let params = match &params {
					Some(params) => params.get(),
					None => "null",
				};
				let request = format!(
					r#"{{"jsonrpc":"2.0","id":"{id}", "method":"{method}","params":{params}}}"#
				);

				self.requests.insert(id, sender);
				tracing::trace!(target: LOG_TARGET, "Tracking request id={id} chain={chain_id:?}");

				let result = self.client.json_rpc_request(request, chain_id);
				if let Err(err) = result {
					tracing::warn!(
						target: LOG_TARGET,
						"Cannot send RPC request to lightclient {:?}",
						err.to_string()
					);

					let sender = self.requests.remove(&id).expect("Channel is inserted above; qed");

					// Send the error back to frontend.
					if sender.send(Err(LightClientRpcError::SmoldotError(err.to_string()))).is_err()
					{
						tracing::warn!(
							target: LOG_TARGET,
							"Cannot send RPC request error to id={id}",
						);
					}
				} else {
					tracing::trace!(target: LOG_TARGET, "Submitted to smoldot request with id={id}");
				}
			},
			Message::Subscription { method, unsubscribe_method, params, sender } => {
				let id = self.next_id();
				let chain_id = self.chain_id;

				// For subscriptions we need to make a plain RPC request to the subscription method.
				// The server will return as a result the subscription ID.
				let params = match &params {
					Some(params) => params.get(),
					None => "null",
				};
				let request = format!(
					r#"{{"jsonrpc":"2.0","id":"{id}", "method":"{method}","params":{params}}}"#
				);

				tracing::trace!(target: LOG_TARGET, "Tracking subscription request id={id} chain={chain_id:?}");
				let pending_subscription =
					PendingSubscription { response_sender: sender, unsubscribe_method };
				self.pending_subscriptions.insert(id, pending_subscription);

				let result = self.client.json_rpc_request(request, chain_id);
				if let Err(err) = result {
					tracing::warn!(
						target: LOG_TARGET,
						"Cannot send RPC request to lightclient {:?}",
						err.to_string()
					);
					let subscription_id_state = self
						.pending_subscriptions
						.remove(&id)
						.expect("Channels are inserted above; qed");

					// Send the error back to frontend.
					if subscription_id_state
						.response_sender
						.send(Err(LightClientRpcError::SmoldotError(err.to_string())))
						.is_err()
					{
						tracing::warn!(
							target: LOG_TARGET,
							"Cannot send RPC request error to id={id}",
						);
					}
				} else {
					tracing::trace!(target: LOG_TARGET, "Submitted to smoldot subscription request with id={id}");
				}
			},
		};
	}

	/// Parse the response received from the light client and sent it to the appropriate user.
	fn handle_rpc_response(&mut self, response: String) {
		let chain_id = self.chain_id;
		tracing::trace!(target: LOG_TARGET, "Received from smoldot response='{}' chain={chain_id:?}", trim_message(&response));

		match RpcResponse::from_str(&response) {
			Ok(RpcResponse::Method { id, result }) => {
				let Ok(id) = id.parse::<usize>() else {
					tracing::warn!(target: LOG_TARGET, "Cannot send response. Id={id} chain={chain_id:?} is not a valid number");
					return;
				};

				// Send the response back.
				if let Some(sender) = self.requests.remove(&id) {
					if sender.send(Ok(result)).is_err() {
						tracing::warn!(
							target: LOG_TARGET,
							"Cannot send method response to id={id} chain={chain_id:?}",
						);
					}
				} else if let Some(pending_subscription) = self.pending_subscriptions.remove(&id) {
					let Ok(sub_id) = serde_json::from_str::<SubscriptionId>(result.get()) else {
						tracing::warn!(
							target: LOG_TARGET,
							"Subscription id='{result}' chain={chain_id:?} is not a valid string",
						);
						return;
					};

					tracing::trace!(target: LOG_TARGET, "Received subscription id={sub_id} chain={chain_id:?}");

					let (sub_tx, sub_rx) = mpsc::unbounded_channel();

					// Send the method response and a channel to receive notifications back.
					if pending_subscription
						.response_sender
						.send(Ok((sub_id.clone(), sub_rx)))
						.is_err()
					{
						tracing::warn!(
							target: LOG_TARGET,
							"Cannot send subscription ID response to id={id} chain={chain_id:?}",
						);
						return;
					}

					// Store the other end of the notif channel to send future subscription
					// notifications to.
					self.subscriptions.insert(
						sub_id,
						ActiveSubscription {
							notification_sender: sub_tx,
							unsubscribe_method: pending_subscription.unsubscribe_method,
						},
					);
				} else {
					tracing::warn!(
						target: LOG_TARGET,
						"Response id={id} chain={chain_id:?} is not tracked",
					);
				}
			},
			Ok(RpcResponse::MethodError { id, error }) => {
				let Ok(id) = id.parse::<usize>() else {
					tracing::warn!(target: LOG_TARGET, "Cannot send error. Id={id} chain={chain_id:?} is not a valid number");
					return;
				};

				if let Some(sender) = self.requests.remove(&id) {
					if sender
						.send(Err(LightClientRpcError::JsonRpcError(JsonRpcError(error))))
						.is_err()
					{
						tracing::warn!(
							target: LOG_TARGET,
							"Cannot send method response to id={id} chain={chain_id:?}",
						);
					}
				} else if let Some(subscription_id_state) = self.pending_subscriptions.remove(&id) {
					if subscription_id_state
						.response_sender
						.send(Err(LightClientRpcError::JsonRpcError(JsonRpcError(error))))
						.is_err()
					{
						tracing::warn!(
							target: LOG_TARGET,
							"Cannot send method response to id {id} chain={chain_id:?}",
						);
					}
				}
			},
			Ok(RpcResponse::Notification { method, subscription_id, result }) => {
				let Some(active_subscription) = self.subscriptions.get_mut(&subscription_id) else {
					tracing::warn!(
						target: LOG_TARGET,
						"Subscription response id={subscription_id} chain={chain_id:?} method={method} is not tracked",
					);
					return;
				};
				if active_subscription.notification_sender.send(Ok(result)).is_err() {
					self.unsubscribe(&subscription_id, chain_id);
				}
			},
			Ok(RpcResponse::NotificationError { method, subscription_id, error }) => {
				let Some(active_subscription) = self.subscriptions.get_mut(&subscription_id) else {
					tracing::warn!(
						target: LOG_TARGET,
						"Subscription error id={subscription_id} chain={chain_id:?} method={method} is not tracked",
					);
					return;
				};
				if active_subscription.notification_sender.send(Err(JsonRpcError(error))).is_err() {
					self.unsubscribe(&subscription_id, chain_id);
				}
			},
			Err(err) => {
				tracing::warn!(target: LOG_TARGET, "cannot decode RPC response {:?}", err);
			},
		}
	}

	// Unsubscribe from a subscription.
	fn unsubscribe(&mut self, subscription_id: &str, chain_id: smoldot_light::ChainId) {
		let Some(active_subscription) = self.subscriptions.remove(subscription_id) else {
			// Subscription doesn't exist so nothing more to do.
			return;
		};

		// Build a call to unsubscribe from this method.
		let unsub_id = self.next_id();
		let request = format!(
			r#"{{"jsonrpc":"2.0","id":"{}", "method":"{}","params":["{}"]}}"#,
			unsub_id, active_subscription.unsubscribe_method, subscription_id
		);

		// Submit it.
		if let Err(err) = self.client.json_rpc_request(request, chain_id) {
			tracing::warn!(
				target: LOG_TARGET,
				"Failed to unsubscribe id={subscription_id} chain={chain_id:?} method={:?} err={err:?}", active_subscription.unsubscribe_method
			);
		} else {
			tracing::debug!(target: LOG_TARGET,"Unsubscribe id={subscription_id} chain={chain_id:?} method={:?}", active_subscription.unsubscribe_method);
		}
	}
}