choreo_content/chain.rs
1//! Substrate chain client — subxt against the Coordination Platform node.
2//!
3//! This is the crate's only async path: it drives `subxt` (node RPC + tx
4//! submission) on the [tokio sidecar] so the daemon stays thread-only. IPFS and
5//! the indexer are synchronous and never touch this runtime.
6//!
7//! ## Signing
8//!
9//! A Polkadot account is stored in the keystore as its **expanded ed25519
10//! secret** (the 64 bytes that Polkadot-JS stores in its scrypt+XSalsa20
11//! envelope, see `choreo-keystore::substrate`). `subxt-signer` cannot rebuild a
12//! keypair from that form, so this module reconstructs a `schnorrkel::Keypair`
13//! and wraps it in a [`ChoreoSigner`] implementing `subxt::tx::Signer<PolkadotConfig>`.
14//!
15//! [tokio sidecar]: crate::runtime
16
17use sp_core::crypto::Ss58Codec;
18use sp_core::sr25519::Public;
19use subxt::OnlineClient;
20use subxt::PolkadotConfig;
21use subxt::config::Config;
22use subxt::config::Header as SubxtHeader;
23use subxt::utils::{AccountId32, MultiAddress, MultiSignature};
24
25use crate::ContentError;
26use crate::acuity_runtime::api;
27use crate::config::CHAIN_WS_URL;
28use zeroize::Zeroizing;
29
30/// A signing keypair rebuilt from a stored expanded ed25519 secret.
31struct ChoreoSigner(schnorrkel::Keypair);
32
33impl ChoreoSigner {
34 /// Build a signer from a 64-byte expanded ed25519 secret key.
35 fn from_expanded_secret(secret: &[u8]) -> Result<Self, ContentError> {
36 let secret_key: [u8; 64] = secret
37 .try_into()
38 .map_err(|_| ContentError::Account("expected a 64-byte ed25519 secret".into()))?;
39 let secret = schnorrkel::SecretKey::from_ed25519_bytes(&secret_key)
40 .map_err(|e| ContentError::Account(format!("invalid ed25519 secret: {e}")))?;
41 let public = secret.to_public();
42 Ok(Self(schnorrkel::Keypair { public, secret }))
43 }
44
45 /// The raw 32-byte public key (=== account id).
46 fn account_id(&self) -> [u8; 32] {
47 self.0.public.to_bytes()
48 }
49}
50
51impl subxt::tx::Signer<PolkadotConfig> for ChoreoSigner {
52 fn account_id(&self) -> <PolkadotConfig as Config>::AccountId {
53 AccountId32(self.account_id())
54 }
55 fn sign(&self, signer_payload: &[u8]) -> <PolkadotConfig as Config>::Signature {
56 // sr25519 signs over a Schnorrkel signing context (matching subxt-signer
57 // and Substrate's on-chain verification).
58 let context = schnorrkel::signing_context(b"substrate");
59 let sig = self.0.sign(context.bytes(signer_payload));
60 MultiSignature::Sr25519(sig.to_bytes())
61 }
62}
63
64/// Parse an SS58 account address into its raw 32-byte account id.
65///
66/// # Errors
67///
68/// Fails with [`ContentError::InvalidArgument`] when `address` is not a valid
69/// SS58-check encoding of a 32-byte sr25519 public key.
70pub fn account_id_from_address(address: &str) -> Result<[u8; 32], ContentError> {
71 let public = Public::from_ss58check(address).map_err(|e| {
72 ContentError::InvalidArgument(format!("invalid SS58 address {address}: {e}"))
73 })?;
74 Ok(public.0)
75}
76
77/// Render a raw 32-byte account id as an SS58 address (chain prefix).
78#[must_use]
79pub fn account_address(bytes: [u8; 32]) -> String {
80 AccountId32(bytes).to_string()
81}
82
83/// A chain account (public id + expanded secret), ready to sign & submit.
84pub struct ChainAccount {
85 /// SS58 address.
86 pub address: String,
87 /// Raw 32-byte account id.
88 pub account_id: [u8; 32],
89 /// Expanded ed25519 secret (64 bytes), zeroized on drop.
90 ///
91 /// The secret is cloned out of the `#[zeroize(drop)]` `ServiceCredential`
92 /// into this per-call account, so it is held here in a `Zeroizing` buffer
93 /// to ensure the private key is wiped from memory when the account is
94 /// dropped (not left to the allocator).
95 secret: Zeroizing<Vec<u8>>,
96}
97
98impl ChainAccount {
99 /// Build a chain account from a 32-byte account id + 64-byte expanded secret.
100 #[must_use]
101 pub fn from_parts(account_id: [u8; 32], secret: Vec<u8>) -> Self {
102 Self {
103 address: account_address(account_id),
104 account_id,
105 secret: Zeroizing::new(secret),
106 }
107 }
108 /// Build a chain account from an SS58 address + 64-byte expanded secret
109 /// (validates that the address matches the secret's public key).
110 ///
111 /// # Errors
112 ///
113 /// Fails with [`ContentError::Account`] when `secret` is not a valid
114 /// 64-byte expanded ed25519 secret, or when the address does not match
115 /// the secret's derived public key; with
116 /// [`ContentError::InvalidArgument`] when `address` is not valid SS58
117 /// (see [`account_id_from_address`]).
118 pub fn from_address(address: &str, secret: Vec<u8>) -> Result<Self, ContentError> {
119 let signer = ChoreoSigner::from_expanded_secret(&secret)?;
120 let account_id = account_id_from_address(address)?;
121 if signer.account_id() != account_id {
122 return Err(ContentError::Account(format!(
123 "address {address} does not match the supplied secret"
124 )));
125 }
126 Ok(Self {
127 address: address.to_string(),
128 account_id,
129 secret: Zeroizing::new(secret),
130 })
131 }
132 fn signer(&self) -> Result<ChoreoSigner, ContentError> {
133 ChoreoSigner::from_expanded_secret(&self.secret)
134 }
135}
136
137/// Connect to the node (async; run on the sidecar).
138///
139/// Verifies the connected node is the Coordination Platform by comparing its
140/// reported genesis hash to the pinned [`crate::config::GENESIS_HASH`], so a
141/// different chain at the same endpoint is rejected before any call is issued.
142async fn connect() -> Result<OnlineClient<PolkadotConfig>, ContentError> {
143 let client = OnlineClient::<PolkadotConfig>::from_insecure_url(CHAIN_WS_URL)
144 .await
145 .map_err(|e| {
146 ContentError::Substrate(format!("failed to connect to {CHAIN_WS_URL}: {e}"))
147 })?;
148
149 let actual = crate::encode::bytes_to_hex(client.genesis_hash().as_ref());
150 if actual != crate::config::GENESIS_HASH {
151 return Err(ContentError::Substrate(format!(
152 "node genesis {actual} does not match expected {}",
153 crate::config::GENESIS_HASH
154 )));
155 }
156 Ok(client)
157}
158
159/// Per-chain-operation wall-clock budget. A hung node (or an RPC that never
160/// answers) must not block a daemon tool thread indefinitely, so every subxt
161/// future runs inside this timeout on the sidecar.
162const CHAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
163
164/// Run a chain async operation, bounding it with [`CHAIN_TIMEOUT`]. The future
165/// must resolve to a `ContentError`-bearing `Result` so a timeout maps to a
166/// [`ContentError::Substrate`] that flows through the caller's `?`.
167async fn with_chain_timeout<T, F>(fut: F) -> Result<T, ContentError>
168where
169 F: std::future::Future<Output = Result<T, ContentError>>,
170{
171 match tokio::time::timeout(CHAIN_TIMEOUT, fut).await {
172 Ok(res) => res,
173 Err(_) => Err(ContentError::Substrate("chain RPC timed out".into())),
174 }
175}
176
177/// A finalized transaction result (what write tools report).
178#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
179pub struct TxOutcome {
180 /// The item id emitted by `Content::PublishItem`, if this call created one.
181 pub item_id: Option<String>,
182}
183
184/// Submit a call, wait for finalized success, and return the finalized events.
185/// Shared by every write extrinsic; the publish wrappers additionally parse the
186/// created item id from those events (see [`submit_publish`]).
187async fn submit_and_wait<Call>(
188 call: &Call,
189 account: &ChainAccount,
190) -> Result<subxt::extrinsics::ExtrinsicEvents<PolkadotConfig>, ContentError>
191where
192 Call: subxt::tx::Payload,
193{
194 let client = connect().await?;
195 let at = client
196 .at_current_block()
197 .await
198 .map_err(|e| ContentError::Substrate(format!("failed to get block for tx: {e}")))?;
199 let signer = account.signer()?;
200 at.tx()
201 .sign_and_submit_then_watch_default(call, &signer)
202 .await
203 .map_err(|e| ContentError::Transaction(format!("submit failed: {e}")))?
204 .wait_for_finalized_success()
205 .await
206 .map_err(|e| ContentError::Transaction(format!("finalized failed: {e}")))
207}
208
209/// Submit a publish call and capture the `Content::PublishItem` item id.
210async fn submit_publish<Call>(
211 call: &Call,
212 account: &ChainAccount,
213) -> Result<TxOutcome, ContentError>
214where
215 Call: subxt::tx::Payload,
216{
217 let tx_events = submit_and_wait(call, account).await?;
218 let item_id = tx_events
219 .find_first::<api::content::events::PublishItem>()
220 .transpose()
221 .map_err(|e| ContentError::Transaction(format!("failed to decode PublishItem: {e}")))?
222 .map(|evt| crate::encode::bytes_to_hex(&evt.item_id.0));
223 Ok(TxOutcome { item_id })
224}
225
226// ── Read (on-chain authoritative state) ──────────────────────────────────────
227
228/// A live chain status snapshot, read from a connected node.
229#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
230pub struct ChainStatus {
231 /// Genesis hash as reported by the node (hex, `0x`-prefixed). Verified
232 /// against the pinned [`crate::config::GENESIS_HASH`] by [`connect`].
233 pub genesis_hash: String,
234 /// The chain's designated SS58 prefix (`System::SS58Prefix` constant).
235 pub ss58_prefix: u16,
236 /// The node's current best (head) block number.
237 pub best_block: u64,
238 /// The node's latest finalized block number.
239 pub finalized_block: u64,
240 /// Item-id derivation namespace (pinned config; not an on-chain constant).
241 pub item_id_namespace: u32,
242}
243
244/// Probe the node and return a live chain status snapshot.
245///
246/// Connects (which itself verifies the node's genesis hash matches the pinned
247/// [`crate::config::GENESIS_HASH`]), reads the SS58 prefix from the on-chain
248/// `System` constants, and reads the best + finalized block headers. A down
249/// node, a mismatched chain, or an RPC that never answers surfaces as an error
250/// (bounded by [`CHAIN_TIMEOUT`]) so the caller's status report can show the
251/// chain as unavailable rather than fabricating a healthy snapshot from pinned
252/// configuration.
253///
254/// # Errors
255///
256/// Fails with [`ContentError::Substrate`] when the node cannot be reached at
257/// [`crate::config::CHAIN_WS_URL`], its genesis hash does not match the pinned
258/// [`crate::config::GENESIS_HASH`], the `System::SS58Prefix` constant or best
259/// block header cannot be read, the node returns no best header, or the whole
260/// probe exceeds [`CHAIN_TIMEOUT`]; with
261/// [`ContentError::RuntimeNotInitialized`] when the sidecar runtime was never
262/// initialized.
263pub fn chain_status() -> Result<ChainStatus, ContentError> {
264 crate::runtime::block_on(with_chain_timeout(async move {
265 let client = connect().await?;
266 let at = client
267 .at_current_block()
268 .await
269 .map_err(|e| ContentError::Substrate(format!("failed to get block: {e}")))?;
270
271 // `at_current_block` pins to the latest finalized block, so its number
272 // is the finalized height; verify it's available before reporting.
273 let finalized_block = at.block_number();
274
275 // Read the chain's designated SS58 prefix from the on-chain `System`
276 // constant (a static address resolved by the generated API).
277 let addr = api::constants().system().ss58_prefix();
278 let ss58_prefix = at
279 .constants()
280 .entry(addr)
281 .map_err(|e| ContentError::Substrate(format!("failed to read ss58 prefix: {e}")))?;
282
283 // Fetch the best (head) block via the node RPC: `chain_getHeader` with
284 // no hash returns the latest best block. subxt does not expose a
285 // one-shot best-block accessor on the `OnlineClient` channel, and the
286 // finalized number above comes from `at_current_block` (which pins to
287 // the latest finalized block), so a short-lived RPC client is opened
288 // here just to read the head. It rides the same sidecar runtime and is
289 // bounded by `CHAIN_TIMEOUT` like everything else in this probe.
290 //
291 // The generic is pinned to the Polkadot RPC config so header decoding
292 // matches the chain config; the header's `number()` comes from
293 // `subxt::config::Header`.
294 let rpc = subxt::rpcs::RpcClient::from_insecure_url(crate::config::CHAIN_WS_URL)
295 .await
296 .map_err(|e| ContentError::Substrate(format!("failed to open rpc: {e}")))?;
297 let methods =
298 subxt::rpcs::LegacyRpcMethods::<subxt::config::RpcConfigFor<PolkadotConfig>>::new(rpc);
299 let best_header = methods
300 .chain_get_header(None)
301 .await
302 .map_err(|e| ContentError::Substrate(format!("failed to read best block header: {e}")))?
303 .ok_or_else(|| ContentError::Substrate("node returned no best block header".into()))?;
304 let best_block = best_header.number();
305
306 Ok(ChainStatus {
307 genesis_hash: crate::encode::bytes_to_hex(client.genesis_hash().as_ref()),
308 ss58_prefix,
309 best_block,
310 finalized_block,
311 item_id_namespace: crate::config::ITEM_ID_NAMESPACE,
312 })
313 }))?
314}
315
316/// Read an item's on-chain control state (owner, revision, flags).
317///
318/// # Errors
319///
320/// Fails with [`ContentError::Content`] when the item does not exist on-chain;
321/// with [`ContentError::Substrate`] when the node is unreachable, the genesis
322/// hash mismatches, the storage query or decode fails, or [`CHAIN_TIMEOUT`]
323/// is exceeded; with [`ContentError::RuntimeNotInitialized`] when the sidecar
324/// runtime was never initialized.
325pub fn item_state(item_id: [u8; 32]) -> Result<ItemState, ContentError> {
326 crate::runtime::block_on(with_chain_timeout(async move {
327 let client = connect().await?;
328 let at = client
329 .at_current_block()
330 .await
331 .map_err(|e| ContentError::Substrate(format!("failed to get block: {e}")))?;
332 let addr = api::storage().content().item_state();
333 let thunk = at
334 .storage()
335 .try_fetch(
336 addr,
337 (api::runtime_types::pallet_content::pallet::ItemId(item_id),),
338 )
339 .await
340 .map_err(|e| ContentError::Substrate(format!("item state query failed: {e}")))?;
341 match thunk {
342 Some(thunk) => {
343 let item = thunk
344 .decode()
345 .map_err(|e| ContentError::Substrate(format!("decode item failed: {e}")))?;
346 Ok(ItemState {
347 owner: crate::encode::bytes_to_hex(&item.owner.0),
348 revision_id: item.revision_id,
349 flags: item.flags,
350 })
351 }
352 None => Err(ContentError::Content("item not found on-chain".into())),
353 }
354 }))?
355}
356
357/// Read the list of item ids an account has pinned in `pallet-account-content`.
358///
359/// # Errors
360///
361/// Fails with [`ContentError::Substrate`] when the node is unreachable, the
362/// genesis hash mismatches, the storage query or bounded-vec decode fails, or
363/// [`CHAIN_TIMEOUT`] is exceeded; with
364/// [`ContentError::RuntimeNotInitialized`] when the sidecar runtime was never
365/// initialized. An account with no pinned items yields `Ok(vec![])`.
366pub fn account_item_ids(account: [u8; 32]) -> Result<Vec<[u8; 32]>, ContentError> {
367 crate::runtime::block_on(with_chain_timeout(async move {
368 let client = connect().await?;
369 let at = client
370 .at_current_block()
371 .await
372 .map_err(|e| ContentError::Substrate(format!("failed to get block: {e}")))?;
373 let addr = api::storage().account_content().account_item_ids();
374 let thunk = at
375 .storage()
376 .try_fetch(addr, (AccountId32(account),))
377 .await
378 .map_err(|e| ContentError::Substrate(format!("account items query failed: {e}")))?;
379 match thunk {
380 Some(thunk) => {
381 let bounded = thunk
382 .decode()
383 .map_err(|e| ContentError::Substrate(format!("decode items failed: {e}")))?;
384 Ok(bounded.0.into_iter().map(|id| id.0).collect())
385 }
386 None => Ok(Vec::new()),
387 }
388 }))?
389}
390
391/// Read the profile item id an account has set (`pallet-account-profile`).
392///
393/// # Errors
394///
395/// Fails with [`ContentError::Substrate`] when the node is unreachable, the
396/// genesis hash mismatches, the storage query or decode fails, or
397/// [`CHAIN_TIMEOUT`] is exceeded; with
398/// [`ContentError::RuntimeNotInitialized`] when the sidecar runtime was never
399/// initialized. `Ok(None)` means the account has no profile set.
400pub fn profile_item(account: [u8; 32]) -> Result<Option<[u8; 32]>, ContentError> {
401 crate::runtime::block_on(with_chain_timeout(async move {
402 let client = connect().await?;
403 let at = client
404 .at_current_block()
405 .await
406 .map_err(|e| ContentError::Substrate(format!("failed to get block: {e}")))?;
407 let addr = api::storage().account_profile().account_profile();
408 let thunk = at
409 .storage()
410 .try_fetch(addr, (AccountId32(account),))
411 .await
412 .map_err(|e| ContentError::Substrate(format!("profile query failed: {e}")))?;
413 match thunk {
414 Some(thunk) => {
415 let decoded = thunk
416 .decode()
417 .map_err(|e| ContentError::Substrate(format!("decode profile failed: {e}")))?;
418 Ok(Some(decoded.0))
419 }
420 None => Ok(None),
421 }
422 }))?
423}
424
425/// On-chain control state of an item.
426#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
427pub struct ItemState {
428 /// Owner account id (hex `0x`).
429 pub owner: String,
430 /// Latest revision counter.
431 pub revision_id: u32,
432 /// Lifecycle flag bitmask.
433 pub flags: u8,
434}
435
436// ── Bounded-collection helpers ───────────────────────────────────────────────
437
438use api::runtime_types::bounded_collections::bounded_vec::BoundedVec;
439use api::runtime_types::pallet_content::pallet::ItemId;
440
441/// Build a content `BoundedVec<ItemId>` from raw item-id bytes.
442fn item_bounded(list: &[[u8; 32]]) -> BoundedVec<ItemId> {
443 BoundedVec(list.iter().map(|b| ItemId(*b)).collect())
444}
445
446/// Build a `BoundedVec<AccountId32>` from raw account-id bytes.
447fn account_bounded(list: &[[u8; 32]]) -> BoundedVec<AccountId32> {
448 BoundedVec(list.iter().map(|b| AccountId32(*b)).collect())
449}
450
451// ── Write (submit extrinsics) ────────────────────────────────────────────────
452
453/// Publish a brand-new item (optionally batched with an account add).
454///
455/// # Errors
456///
457/// Fails with [`ContentError::Transaction`] when the extrinsic cannot be
458/// submitted, is not finalized successfully, or the `PublishItem` event
459/// cannot be decoded; with [`ContentError::Substrate`] when the node is
460/// unreachable, the genesis hash mismatches, or [`CHAIN_TIMEOUT`] is
461/// exceeded; with [`ContentError::Account`] when the stored secret cannot be
462/// rebuilt into a signer; with [`ContentError::RuntimeNotInitialized`] when
463/// the sidecar runtime was never initialized.
464pub fn publish_item(
465 account: &ChainAccount,
466 nonce: [u8; 32],
467 parents: &[[u8; 32]],
468 flags: u8,
469 links: &[[u8; 32]],
470 mentions: &[[u8; 32]],
471 ipfs_hash: [u8; 32],
472) -> Result<TxOutcome, ContentError> {
473 let call = api::tx().content().publish_item(
474 api::runtime_types::pallet_content::Nonce(nonce),
475 item_bounded(parents),
476 flags,
477 item_bounded(links),
478 account_bounded(mentions),
479 api::runtime_types::pallet_content::pallet::IpfsHash(ipfs_hash),
480 );
481 crate::runtime::block_on(with_chain_timeout(submit_publish(&call, account)))?
482}
483
484/// Publish a new revision of an existing item.
485///
486/// # Errors
487///
488/// Fails with [`ContentError::Transaction`] when the extrinsic cannot be
489/// submitted, is not finalized successfully, or the `PublishItem` event
490/// cannot be decoded; with [`ContentError::Substrate`] when the node is
491/// unreachable, the genesis hash mismatches, or [`CHAIN_TIMEOUT`] is
492/// exceeded; with [`ContentError::Account`] when the stored secret cannot be
493/// rebuilt into a signer; with [`ContentError::RuntimeNotInitialized`] when
494/// the sidecar runtime was never initialized.
495pub fn publish_revision(
496 account: &ChainAccount,
497 item_id: [u8; 32],
498 links: &[[u8; 32]],
499 mentions: &[[u8; 32]],
500 ipfs_hash: [u8; 32],
501) -> Result<TxOutcome, ContentError> {
502 let call = api::tx().content().publish_revision(
503 ItemId(item_id),
504 item_bounded(links),
505 account_bounded(mentions),
506 api::runtime_types::pallet_content::pallet::IpfsHash(ipfs_hash),
507 );
508 crate::runtime::block_on(with_chain_timeout(submit_publish(&call, account)))?
509}
510
511/// Retract an item.
512///
513/// # Errors
514///
515/// Fails with [`ContentError::Transaction`] when the extrinsic cannot be
516/// submitted or is not finalized successfully; with
517/// [`ContentError::Substrate`] when the node is unreachable, the genesis
518/// hash mismatches, or [`CHAIN_TIMEOUT`] is exceeded; with
519/// [`ContentError::Account`] when the stored secret cannot be rebuilt into a
520/// signer; with [`ContentError::RuntimeNotInitialized`] when the sidecar
521/// runtime was never initialized.
522pub fn retract_item(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
523 let call = api::tx()
524 .content()
525 .retract_item(api::runtime_types::pallet_content::pallet::ItemId(item_id));
526 let _ = crate::runtime::block_on(with_chain_timeout(submit_and_wait(&call, account)))?;
527 Ok(())
528}
529
530/// Clear the REVISIONABLE flag on an item.
531///
532/// # Errors
533///
534/// Fails with [`ContentError::Transaction`] when the extrinsic cannot be
535/// submitted or is not finalized successfully; with
536/// [`ContentError::Substrate`] when the node is unreachable, the genesis
537/// hash mismatches, or [`CHAIN_TIMEOUT`] is exceeded; with
538/// [`ContentError::Account`] when the stored secret cannot be rebuilt into a
539/// signer; with [`ContentError::RuntimeNotInitialized`] when the sidecar
540/// runtime was never initialized.
541pub fn set_not_revisionable(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
542 let call = api::tx()
543 .content()
544 .set_not_revisionable(api::runtime_types::pallet_content::pallet::ItemId(item_id));
545 let _ = crate::runtime::block_on(with_chain_timeout(submit_and_wait(&call, account)))?;
546 Ok(())
547}
548
549/// Clear the RETRACTABLE flag on an item.
550///
551/// # Errors
552///
553/// Fails with [`ContentError::Transaction`] when the extrinsic cannot be
554/// submitted or is not finalized successfully; with
555/// [`ContentError::Substrate`] when the node is unreachable, the genesis
556/// hash mismatches, or [`CHAIN_TIMEOUT`] is exceeded; with
557/// [`ContentError::Account`] when the stored secret cannot be rebuilt into a
558/// signer; with [`ContentError::RuntimeNotInitialized`] when the sidecar
559/// runtime was never initialized.
560pub fn set_not_retractable(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
561 let call = api::tx()
562 .content()
563 .set_not_retractable(api::runtime_types::pallet_content::pallet::ItemId(item_id));
564 let _ = crate::runtime::block_on(with_chain_timeout(submit_and_wait(&call, account)))?;
565 Ok(())
566}
567
568/// Pin an item to an account (`account_content::add_item`).
569///
570/// # Errors
571///
572/// Fails with [`ContentError::Transaction`] when the extrinsic cannot be
573/// submitted or is not finalized successfully; with
574/// [`ContentError::Substrate`] when the node is unreachable, the genesis
575/// hash mismatches, or [`CHAIN_TIMEOUT`] is exceeded; with
576/// [`ContentError::Account`] when the stored secret cannot be rebuilt into a
577/// signer; with [`ContentError::RuntimeNotInitialized`] when the sidecar
578/// runtime was never initialized.
579pub fn add_account_item(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
580 let call = api::tx()
581 .account_content()
582 .add_item(api::runtime_types::pallet_content::pallet::ItemId(item_id));
583 let _ = crate::runtime::block_on(with_chain_timeout(submit_and_wait(&call, account)))?;
584 Ok(())
585}
586
587/// Unpin an item from an account (`account_content::remove_item`).
588///
589/// # Errors
590///
591/// Fails with [`ContentError::Transaction`] when the extrinsic cannot be
592/// submitted or is not finalized successfully; with
593/// [`ContentError::Substrate`] when the node is unreachable, the genesis
594/// hash mismatches, or [`CHAIN_TIMEOUT`] is exceeded; with
595/// [`ContentError::Account`] when the stored secret cannot be rebuilt into a
596/// signer; with [`ContentError::RuntimeNotInitialized`] when the sidecar
597/// runtime was never initialized.
598pub fn remove_account_item(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
599 let call = api::tx()
600 .account_content()
601 .remove_item(api::runtime_types::pallet_content::pallet::ItemId(item_id));
602 let _ = crate::runtime::block_on(submit_publish(&call, account))?;
603 Ok(())
604}
605
606/// Point an account's profile at an item (`account_profile::set_profile`).
607///
608/// # Errors
609///
610/// Fails with [`ContentError::Transaction`] when the extrinsic cannot be
611/// submitted or is not finalized successfully; with
612/// [`ContentError::Substrate`] when the node is unreachable, the genesis
613/// hash mismatches, or [`CHAIN_TIMEOUT`] is exceeded; with
614/// [`ContentError::Account`] when the stored secret cannot be rebuilt into a
615/// signer; with [`ContentError::RuntimeNotInitialized`] when the sidecar
616/// runtime was never initialized.
617pub fn set_profile(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
618 let call = api::tx()
619 .account_profile()
620 .set_profile(api::runtime_types::pallet_content::pallet::ItemId(item_id));
621 let _ = crate::runtime::block_on(with_chain_timeout(submit_and_wait(&call, account)))?;
622 Ok(())
623}
624
625/// The `MultiAddress` identity for an account (for extrinsics that need it).
626#[allow(dead_code)]
627pub(crate) fn multi_address(account: [u8; 32]) -> MultiAddress<AccountId32, ()> {
628 MultiAddress::Id(AccountId32(account))
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
636 fn account_id_from_address_round_trips() {
637 let mut bytes = [0u8; 32];
638 bytes[0] = 9;
639 let address = account_address(bytes);
640 assert_eq!(account_id_from_address(&address).unwrap(), bytes);
641 }
642
643 #[test]
644 fn account_id_from_address_rejects_garbage() {
645 assert!(account_id_from_address("not-an-address").is_err());
646 }
647}