Skip to main content

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}