Skip to main content

choreo_content/
orchestrate.rs

1//! High-level operations — the pipelines the daemon's thin `Tool` wrappers call.
2//!
3//! These compose the lower-level [`crate::chain`], [`crate::indexer`], and
4//! [`crate::ipfs`] modules into the read/write flows of the Coordination
5//! Platform:
6//!
7//! - **Read**: indexer-first (cheap, keyed event resolution) with on-chain
8//!   authority for single-source control state, content fetched from IPFS.
9//! - **Write**: encode content → upload to IPFS (pin) → submit the extrinsic
10//!   on-chain, returning the derived item id.
11//!
12//! Every function is blocking (the daemon stays thread-only); only the `subxt`
13//! submit path rides the [tokio sidecar] inside the functions they call.
14//!
15//! [tokio sidecar]: crate::runtime
16
17use crate::ContentError;
18use crate::chain::{self, ChainAccount};
19use crate::encode::{self, ContentInput, DecodedItem, ImageInput, PreparedContent};
20use crate::indexer::{self, DecodedEvent, QueryKey};
21use rand::Rng;
22
23// ── Read: shape of what the tools return ─────────────────────────────────────
24
25/// A single revision of an item, resolved from indexed `PublishRevision` events.
26#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
27pub struct RevisionEntry {
28    /// On-chain revision counter (starts at 0, increments per revision).
29    pub revision_id: u32,
30    /// IPFS content hash (`0x`-prefixed sha2-256 digest).
31    pub ipfs_hash_hex: String,
32    /// Block height the revision was published in.
33    pub block_number: Option<u32>,
34    /// Milliseconds since Unix epoch.
35    pub timestamp: Option<u64>,
36}
37
38/// A fully resolved item (content + revision + on-chain control state).
39#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
40pub struct ResolvedItem {
41    /// Item id (`0x`-prefixed hex).
42    pub item_id: String,
43    /// The decoded content of the requested (default latest) revision.
44    pub content: DecodedItem,
45    /// Revision id that `content` was decoded from.
46    pub revision_id: u32,
47    /// IPFS hash of that revision.
48    pub ipfs_hash_hex: String,
49    /// On-chain owner account id (`0x` hex).
50    pub owner: String,
51    /// Lifecycle flag bitmask.
52    pub flags: u8,
53}
54
55/// A content item pinned to an account (`pallet-account-content`).
56#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
57pub struct AccountItem {
58    /// Item id (`0x`-prefixed hex).
59    pub item_id: String,
60    /// Title, if one could be resolved for display.
61    pub title: Option<String>,
62}
63
64/// A resolved account profile.
65#[derive(Clone, Debug, Default, serde::Serialize, schemars::JsonSchema)]
66pub struct ProfileResult {
67    /// Whether a profile item is set on-chain for the account.
68    pub exists: bool,
69    /// Profile item id (`0x` hex), if set.
70    pub item_id: Option<String>,
71    /// Decoded content fields.
72    pub name: Option<String>,
73    pub bio: Option<String>,
74    pub location: Option<String>,
75    pub account_type: Option<i32>,
76}
77
78/// Aggregated status of the three platform services.
79#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
80pub struct CoordStatus {
81    pub chain: Option<ChainStatus>,
82    pub indexer: Option<IndexerStatus>,
83    pub ipfs: Option<ipfs::IpfsStatus>,
84}
85
86/// A snapshot of the event indexer's indexed spans.
87#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
88pub struct IndexerStatus {
89    pub spans: Vec<Span>,
90}
91
92/// An indexed block span.
93#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
94pub struct Span {
95    pub start: u32,
96    pub end: u32,
97}
98
99impl From<indexer::IndexStatusResult> for IndexerStatus {
100    fn from(r: indexer::IndexStatusResult) -> Self {
101        IndexerStatus {
102            spans: r
103                .spans
104                .into_iter()
105                .map(|s| Span {
106                    start: s.start,
107                    end: s.end,
108                })
109                .collect(),
110        }
111    }
112}
113
114/// Chain/status snapshot.
115#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
116pub struct ChainStatus {
117    pub genesis_hash: String,
118    pub ss58_prefix: u16,
119    pub best_block: u64,
120    pub finalized_block: u64,
121    pub item_id_namespace: u32,
122}
123
124impl From<chain::ChainStatus> for ChainStatus {
125    /// Adapt the live probe's snapshot into the tool-facing status shape.
126    fn from(s: chain::ChainStatus) -> Self {
127        ChainStatus {
128            genesis_hash: s.genesis_hash,
129            ss58_prefix: s.ss58_prefix,
130            best_block: s.best_block,
131            finalized_block: s.finalized_block,
132            item_id_namespace: s.item_id_namespace,
133        }
134    }
135}
136
137// ── Read: operations ─────────────────────────────────────────────────────────
138
139/// Resolve one item: latest (or a specific) revision, decoded from IPFS,
140/// merged with the on-chain control state.
141///
142/// # Errors
143///
144/// Fails with [`ContentError::Cid`] when `item_id_hex` is not 32 bytes of
145/// hex; with [`ContentError::Substrate`]/[`ContentError::Content`] from the
146/// on-chain state probe (unreachable node, missing item); with
147/// [`ContentError::Indexer`] when the revision lookup fails and
148/// [`ContentError::Content`] when no indexed revision (or the requested one)
149/// exists; with [`ContentError::Ipfs`] when the content cannot be fetched;
150/// and with [`ContentError::Content`] when the payload does not decode.
151pub fn item(item_id_hex: &str, revision_id: Option<u32>) -> Result<ResolvedItem, ContentError> {
152    let item_id = encode::hex_to_bytes(item_id_hex)?;
153
154    // On-chain authority: owner, latest revision counter, and flags.
155    let state = chain::item_state(item_id)?;
156
157    // Resolve the requested (or latest) revision's IPFS hash from the indexer.
158    let (revision, ipfs_hash) = resolve_revision(item_id_hex, revision_id)?;
159
160    // Fetch the content from IPFS and decode it.
161    let bytes = crate::ipfs::cat(&ipfs_hash)?;
162    let content = encode::decode_item(&bytes)?;
163
164    Ok(ResolvedItem {
165        item_id: encode::bytes_to_hex(&item_id),
166        content,
167        revision_id: revision,
168        ipfs_hash_hex: ipfs_hash,
169        owner: state.owner,
170        flags: state.flags,
171    })
172}
173
174/// An item's embedded image fetched from IPFS, ready for display.
175#[derive(Clone, Debug)]
176pub struct ItemImage {
177    /// Full-resolution dimensions declared in the image mixin.
178    pub width: u32,
179    pub height: u32,
180    /// The mipmap level that was fetched (0 = full resolution).
181    pub level: u32,
182    /// IPFS CID (Base58) of the fetched level.
183    pub cid: String,
184    /// Declared byte size of this level.
185    pub filesize: u64,
186    /// The raw image bytes (JPEG, per the reference encoder).
187    pub data: Vec<u8>,
188}
189
190/// Pick a mipmap level from a decoded [`ImageSpec`], validating the request
191/// against the available levels. Pure so it can be unit-tested without IPFS.
192fn select_image_level(
193    image: &encode::ImageSpec,
194    level: Option<u32>,
195) -> Result<(usize, encode::MipmapLevel), crate::ContentError> {
196    if image.mipmap_levels.is_empty() {
197        // The reference protocol encodes images purely as a mipmap pyramid
198        // (level 0 = full-res); an image mixin without levels carries nothing
199        // retrievable.
200        return Err(crate::ContentError::Content(
201            "image mixin has no mipmap levels".into(),
202        ));
203    }
204    let index = level.unwrap_or(0) as usize;
205    let spec = image.mipmap_levels.get(index).ok_or_else(|| {
206        crate::ContentError::Content(format!(
207            "mipmap level {} out of range (item has {} levels)",
208            index,
209            image.mipmap_levels.len()
210        ))
211    })?;
212    Ok((index, spec.clone()))
213}
214
215/// Fetch an item's embedded image from IPFS: resolve the revision's content
216/// hash, decode the image mixin, and `cat` the requested mipmap level
217/// (`level = 0` is the full-resolution encode; `None` defaults to it).
218///
219/// # Errors
220///
221/// Fails with [`ContentError::Indexer`] when the revision lookup fails and
222/// [`ContentError::Content`] when no (or the requested) revision is indexed,
223/// the item has no embedded image, or the mipmap level is out of range; with
224/// [`ContentError::Ipfs`] when the content or the level's CID cannot be
225/// fetched; with [`ContentError::Cid`] when `item_id_hex` is not 32 bytes of
226/// hex; and with [`ContentError::Content`] when the payload does not decode.
227pub fn item_image(
228    item_id_hex: &str,
229    revision_id: Option<u32>,
230    level: Option<u32>,
231) -> Result<ItemImage, crate::ContentError> {
232    let (_revision, ipfs_hash) = resolve_revision(item_id_hex, revision_id)?;
233    let bytes = crate::ipfs::cat(&ipfs_hash)?;
234    let content = encode::decode_item(&bytes)?;
235    let image = content.image.ok_or_else(|| {
236        crate::ContentError::Content(format!("item {item_id_hex} has no embedded image"))
237    })?;
238    let (index, spec) = select_image_level(&image, level)?;
239    let data = crate::ipfs::cat_by_cid(&spec.cid)?;
240    // `index` comes from `select_image_level`, which only returns valid
241    // indices into `mipmap_levels` (a Vec, far below u32::MAX in any real
242    // pyramid); keep the reference's narrowing cast semantics.
243    #[allow(clippy::cast_possible_truncation)]
244    let level = index as u32;
245    Ok(ItemImage {
246        width: image.width,
247        height: image.height,
248        level,
249        cid: spec.cid,
250        filesize: spec.filesize,
251        data,
252    })
253}
254
255/// Build the revision history for an item from decoded indexer events,
256/// sorted newest-first (revision id, then block height as tie-breaker).
257///
258/// The indexer tags each event by every `item_id` it references — its own
259/// `item_id` plus any ids in `links`/`parents` — so a linked item's revision
260/// events appear alongside this item's own. Only events whose `item_id`
261/// field matches the queried item are revisions of it; all others are edges
262/// of unrelated items and must be excluded.
263/// Pure so it can be unit-tested without the indexer.
264fn revision_entries_from_events(item_id_hex: &str, events: &[DecodedEvent]) -> Vec<RevisionEntry> {
265    events
266        .iter()
267        .filter(|e| e.pallet_name() == "Content" && e.event_name() == "PublishRevision")
268        // The event's own item_id must match the queried item; hex case is
269        // normalized by the indexer but compare leniently regardless.
270        .filter(|e| {
271            e.field_str("item_id")
272                .is_some_and(|id| id.eq_ignore_ascii_case(item_id_hex))
273        })
274        .filter_map(|e| {
275            // The indexer renders `revision_id` as a u32 scalar (see the
276            // `custom_scalar("u32", …)` key encoding); keep the reference's
277            // wrapping truncation semantics for out-of-range values.
278            #[allow(clippy::cast_possible_truncation)]
279            let rev = e.field_u64("revision_id")? as u32;
280            let hash = e.field_str("ipfs_hash")?.to_string();
281            Some(RevisionEntry {
282                revision_id: rev,
283                ipfs_hash_hex: hash,
284                block_number: Some(e.block_number),
285                timestamp: Some(e.timestamp),
286            })
287        })
288        .collect()
289}
290
291/// Sort revisions newest-first. Block height breaks ties so that equal
292/// revision ids (e.g. revision 0 across different items) resolve
293/// deterministically.
294fn sort_revisions_newest_first(revisions: &mut [RevisionEntry]) {
295    revisions.sort_by(|a, b| {
296        b.revision_id
297            .cmp(&a.revision_id)
298            .then_with(|| b.block_number.cmp(&a.block_number))
299    });
300}
301
302/// Resolve a single revision's IPFS hash (and its revision counter) from the
303/// indexer, defaulting to the latest revision when none is requested.
304fn resolve_revision(
305    item_id_hex: &str,
306    revision_id: Option<u32>,
307) -> Result<(u32, String), ContentError> {
308    let events = indexer::get_events(&indexer::item_id_key(item_id_hex)?, 512, None)?;
309    let mut revisions = revision_entries_from_events(item_id_hex, &events);
310    sort_revisions_newest_first(&mut revisions);
311
312    let entry = match revision_id {
313        Some(target) => revisions
314            .into_iter()
315            .find(|r| r.revision_id == target)
316            .ok_or_else(|| {
317                ContentError::Content(format!(
318                    "revision {target} not found for item {item_id_hex}"
319                ))
320            })?,
321        None => revisions.into_iter().next().ok_or_else(|| {
322            ContentError::Content(format!("no indexed revision found for item {item_id_hex}"))
323        })?,
324    };
325    Ok((entry.revision_id, entry.ipfs_hash_hex))
326}
327
328/// Full revision history of an item, newest-first.
329///
330/// # Errors
331///
332/// Fails with [`ContentError::Cid`] when `item_id_hex` is not 32 bytes of
333/// hex, and with [`ContentError::Indexer`] when the indexer WebSocket
334/// connection or `acuity_getEvents` round-trip fails. An item with no
335/// indexed revisions yields an empty list, not an error.
336pub fn revisions(item_id_hex: &str) -> Result<Vec<RevisionEntry>, ContentError> {
337    let events = indexer::get_events(&indexer::item_id_key(item_id_hex)?, 512, None)?;
338    let mut list = revision_entries_from_events(item_id_hex, &events);
339    sort_revisions_newest_first(&mut list);
340    Ok(list)
341}
342
343/// Query the indexer for events matching a key (low-level read primitive).
344///
345/// # Errors
346///
347/// Fails with [`ContentError::Indexer`] when the WebSocket connection to the
348/// indexer or the `acuity_getEvents` JSON-RPC round-trip fails, or the
349/// result cannot be decoded.
350pub fn events(
351    key: &QueryKey,
352    limit: u16,
353    before: Option<(u32, u32)>,
354) -> Result<Vec<DecodedEvent>, ContentError> {
355    indexer::get_events(key, limit, before)
356}
357
358/// List an account's pinned content items, resolving each title via the
359/// indexer+IPFS (items that fail to resolve fall back to id-only).
360///
361/// # Errors
362///
363/// Fails with [`ContentError::InvalidArgument`] when `account_addr` is not a
364/// valid SS58 address; with [`ContentError::Substrate`] when the pinned item
365/// ids cannot be read from chain; with [`ContentError::Indexer`] when the
366/// indexer is unreachable (titles then degrade to `None` per item).
367pub fn account_items(account_addr: &str) -> Result<Vec<AccountItem>, ContentError> {
368    let account = chain::account_id_from_address(account_addr)?;
369    let ids = chain::account_item_ids(account)?;
370    let mut out = Vec::with_capacity(ids.len());
371    for id in ids {
372        let id_hex = encode::bytes_to_hex(&id);
373        let title = resolve_title(&id_hex).unwrap_or_default();
374        out.push(AccountItem {
375            item_id: id_hex,
376            title,
377        });
378    }
379    Ok(out)
380}
381
382/// Resolve an item's title from its latest revision, if possible.
383fn resolve_title(item_id_hex: &str) -> Result<Option<String>, ContentError> {
384    let (_, ipfs_hash) = resolve_revision(item_id_hex, None)?;
385    let bytes = crate::ipfs::cat(&ipfs_hash)?;
386    let content = encode::decode_item(&bytes)?;
387    Ok(content.title)
388}
389
390/// Resolve an account's profile.
391///
392/// # Errors
393///
394/// Fails with [`ContentError::InvalidArgument`] when `account_addr` is not a
395/// valid SS58 address; with [`ContentError::Substrate`] when the profile
396/// item id cannot be read from chain. Content-resolution failures degrade
397/// to a default [`ProfileResult`] with just the item id set.
398pub fn profile(account_addr: &str) -> Result<ProfileResult, ContentError> {
399    let account = chain::account_id_from_address(account_addr)?;
400    let Some(profile_item_id) = chain::profile_item(account)? else {
401        return Ok(ProfileResult::default());
402    };
403    let id_hex = encode::bytes_to_hex(&profile_item_id);
404    let resolved = item(&id_hex, None).unwrap_or_else(|_| -> ResolvedItem {
405        // Degrade gracefully: if content can't be resolved, at least report the
406        // profile item id.
407        ResolvedItem {
408            item_id: id_hex.clone(),
409            content: DecodedItem::default(),
410            revision_id: 0,
411            ipfs_hash_hex: String::new(),
412            owner: String::new(),
413            flags: 0,
414        }
415    });
416    Ok(ProfileResult {
417        exists: true,
418        item_id: Some(id_hex),
419        name: resolved.content.title,
420        bio: resolved.content.body,
421        location: resolved
422            .content
423            .profile
424            .as_ref()
425            .map(|p| p.location.clone()),
426        account_type: resolved.content.profile.as_ref().map(|p| p.account_type),
427    })
428}
429
430/// Decode arbitrary content bytes from IPFS (by digest hex or CID) — no chain.
431///
432/// # Errors
433///
434/// Fails with [`ContentError::Cid`] when a `0x`-prefixed input is not 32
435/// bytes of hex; with [`ContentError::Ipfs`] when the IPFS fetch fails; and
436/// with [`ContentError::Content`] when the bytes do not decode as an item.
437pub fn decode_content(ipfs_hash_or_cid: &str) -> Result<DecodedItem, ContentError> {
438    let bytes = if ipfs_hash_or_cid.starts_with("0x") {
439        crate::ipfs::cat(ipfs_hash_or_cid)?
440    } else {
441        crate::ipfs::cat_by_cid(ipfs_hash_or_cid)?
442    };
443    encode::decode_item(&bytes)
444}
445
446/// Aggregate status across all three services (each is best-effort).
447///
448/// # Errors
449///
450/// Practically infallible: each service probe is downgraded to `None` in the
451/// returned [`CoordStatus`] when it fails, so this only errors via
452/// [`ContentError::RuntimeNotInitialized`] if the sidecar runtime was never
453/// initialized (the chain probe needs it even to report unavailability).
454pub fn status() -> Result<CoordStatus, ContentError> {
455    let indexer = indexer::index_status().ok().map(IndexerStatus::from);
456    let ipfs = crate::ipfs::id().ok().map(|peer| ipfs::IpfsStatus {
457        peer_id: peer.peer_id,
458        addresses: peer.addresses,
459    });
460    // Chain status is a live probe: connect (verifying the genesis hash) and
461    // read the SS58 prefix. A down node or mismatched chain surfaces as `None`
462    // (reported "unavailable"), matching the indexer/IPFS arms, instead of a
463    // healthy snapshot fabricated from pinned configuration.
464    let chain = chain::chain_status().ok().map(ChainStatus::from);
465    Ok(CoordStatus {
466        chain,
467        indexer,
468        ipfs,
469    })
470}
471
472// ── Write: operations ────────────────────────────────────────────────────────
473
474/// Resolve a publish's content into the fully-specified [`PreparedContent`]
475/// that [`encode_item`] consumes.
476///
477/// The only field needing work is a `path`-based image input: the referenced
478/// file is read, its JPEG mipmap pyramid built and pinned to IPFS, and the
479/// full [`ImageSpec`] derived (the ported `acuity-dioxus` publishing
480/// pipeline — callers never compute dimensions, digests, or CIDs themselves).
481/// A `spec`-based input is passed through verbatim; supplying both `path`
482/// and `spec` is rejected as ambiguous, as is an input with neither.
483fn resolve_content(input: &ContentInput) -> Result<PreparedContent, ContentError> {
484    let image = match &input.image {
485        Some(ImageInput {
486            path: Some(path),
487            filename,
488            spec: None,
489        }) => Some(crate::image::build_image_spec(
490            std::path::Path::new(path),
491            filename.as_deref(),
492        )?),
493        Some(ImageInput {
494            path: None,
495            spec: Some(spec),
496            ..
497        }) => Some(spec.clone()),
498        Some(ImageInput { path, spec, .. }) => {
499            return Err(ContentError::InvalidArgument(match (path, spec) {
500                (Some(_), Some(_)) => {
501                    "image input: supply either `path` or `spec`, not both".into()
502                }
503                _ => "image input: one of `path` or `spec` is required".into(),
504            }));
505        }
506        None => None,
507    };
508    Ok(input.to_prepared(image))
509}
510
511/// Publish a brand-new item: encode → IPFS → derive id → submit.
512///
513/// # Errors
514///
515/// Fails with [`ContentError::InvalidArgument`] when `flags` is outside
516/// [`crate::config::VALID_PUBLISH_FLAGS`] or the image input is ambiguous
517/// (via [`resolve_content`]); with [`ContentError::Image`]/[`ContentError::Ipfs`]/[`ContentError::Cid`]
518/// when a `path`-based image cannot be prepared or the encoded payload
519/// cannot be uploaded to IPFS; with [`ContentError::Cid`] when the returned
520/// digest is malformed; and with [`ContentError::Transaction`],
521/// [`ContentError::Substrate`], [`ContentError::Account`], or
522/// [`ContentError::RuntimeNotInitialized`] from the chain submission (see
523/// [`chain::publish_item`]).
524pub fn publish_item(
525    account: &ChainAccount,
526    content: &ContentInput,
527    parents: &[[u8; 32]],
528    links: &[[u8; 32]],
529    mentions: &[[u8; 32]],
530    flags: Option<u8>,
531    nonce: Option<[u8; 32]>,
532) -> Result<chain::TxOutcome, ContentError> {
533    let flags = flags.unwrap_or(crate::config::DEFAULT_ITEM_FLAGS);
534    if flags & !crate::config::VALID_PUBLISH_FLAGS != 0 {
535        return Err(ContentError::InvalidArgument(format!(
536            "invalid item flags: {flags:#x}"
537        )));
538    }
539    let bytes = encode::encode_item(&resolve_content(content)?)?;
540    let ipfs_hash = crate::ipfs::add(&bytes, "content.bin")?;
541    let digest = encode::hex_to_bytes(&ipfs_hash)?;
542
543    // Derive the item id deterministically. If no nonce is supplied, generate one.
544    let nonce = nonce.unwrap_or_else(|| {
545        let mut n = [0u8; 32];
546        rand::rng().fill_bytes(&mut n);
547        n
548    });
549    let item_id = encode::derive_item_id(account.account_id, nonce);
550
551    let outcome = chain::publish_item(account, nonce, parents, flags, links, mentions, digest)?;
552    // The chain returns the created item id; verify it matches our derivation.
553    if let Some(got) = &outcome.item_id
554        && got != &encode::bytes_to_hex(&item_id)
555    {
556        tracing::warn!(
557            derived = %encode::bytes_to_hex(&item_id),
558            on_chain = %got,
559            "published item id differs from client derivation"
560        );
561    }
562    Ok(outcome)
563}
564
565/// Publish a new revision of an existing item.
566///
567/// # Errors
568///
569/// Fails with [`ContentError::Image`], [`ContentError::Ipfs`], or
570/// [`ContentError::Cid`] when a `path`-based image cannot be prepared (via
571/// [`resolve_content`]) or the encoded payload cannot be uploaded to IPFS;
572/// with [`ContentError::Content`] when the payload cannot be encoded; and
573/// with [`ContentError::Transaction`], [`ContentError::Substrate`],
574/// [`ContentError::Account`], or [`ContentError::RuntimeNotInitialized`]
575/// from the chain submission (see [`chain::publish_revision`]).
576pub fn publish_revision(
577    account: &ChainAccount,
578    item_id: [u8; 32],
579    content: &ContentInput,
580    links: &[[u8; 32]],
581    mentions: &[[u8; 32]],
582) -> Result<chain::TxOutcome, ContentError> {
583    let bytes = encode::encode_item(&resolve_content(content)?)?;
584    let ipfs_hash = crate::ipfs::add(&bytes, "content.bin")?;
585    let digest = encode::hex_to_bytes(&ipfs_hash)?;
586    chain::publish_revision(account, item_id, links, mentions, digest)
587}
588
589/// Apply a lifecycle state transition (retract / freeze flags).
590///
591/// # Errors
592///
593/// Fails with [`ContentError::Transaction`] when the chosen extrinsic cannot
594/// be submitted or is not finalized successfully; with
595/// [`ContentError::Substrate`] when the node is unreachable or the genesis
596/// hash mismatches; with [`ContentError::Account`] when the stored secret
597/// cannot be rebuilt into a signer; with [`ContentError::RuntimeNotInitialized`]
598/// when the sidecar runtime was never initialized.
599pub fn lifecycle(
600    account: &ChainAccount,
601    action: LifecycleAction,
602    item_id: [u8; 32],
603) -> Result<(), ContentError> {
604    match action {
605        LifecycleAction::Retract => chain::retract_item(account, item_id),
606        LifecycleAction::SetNotRevisionable => chain::set_not_revisionable(account, item_id),
607        LifecycleAction::SetNotRetractable => chain::set_not_retractable(account, item_id),
608    }
609}
610
611/// Pin/unpin an item to/from an account.
612///
613/// # Errors
614///
615/// Fails with [`ContentError::Transaction`] when the chosen extrinsic cannot
616/// be submitted or is not finalized successfully; with
617/// [`ContentError::Substrate`] when the node is unreachable or the genesis
618/// hash mismatches; with [`ContentError::Account`] when the stored secret
619/// cannot be rebuilt into a signer; with
620/// [`ContentError::RuntimeNotInitialized`] when the sidecar runtime was
621/// never initialized.
622pub fn account_link(
623    account: &ChainAccount,
624    action: AccountLinkAction,
625    item_id: [u8; 32],
626) -> Result<(), ContentError> {
627    match action {
628        AccountLinkAction::Add => chain::add_account_item(account, item_id),
629        AccountLinkAction::Remove => chain::remove_account_item(account, item_id),
630    }
631}
632
633/// Point an account's profile at a freshly published profile item.
634///
635/// # Errors
636///
637/// Fails with [`ContentError::Image`]/[`ContentError::Ipfs`]/[`ContentError::Cid`]
638/// when a `path`-based image cannot be prepared (via [`resolve_content`]) or
639/// the encoded payload cannot be uploaded to IPFS; with
640/// [`ContentError::Content`] when the payload cannot be encoded; and with
641/// [`ContentError::Transaction`], [`ContentError::Substrate`],
642/// [`ContentError::Account`], or [`ContentError::RuntimeNotInitialized`]
643/// from the profile publish or `set_profile` chain submission.
644pub fn set_profile(
645    account: &ChainAccount,
646    content: &ContentInput,
647) -> Result<chain::TxOutcome, ContentError> {
648    // Publish the profile item, then link it as the account's profile.
649    let bytes = encode::encode_item(&resolve_content(content)?)?;
650    let ipfs_hash = crate::ipfs::add(&bytes, "profile.bin")?;
651    let digest = encode::hex_to_bytes(&ipfs_hash)?;
652    let nonce: [u8; 32] = {
653        let mut n = [0u8; 32];
654        rand::rng().fill_bytes(&mut n);
655        n
656    };
657    let item_id = encode::derive_item_id(account.account_id, nonce);
658    let outcome = chain::publish_item(
659        account,
660        nonce,
661        &[],
662        crate::config::DEFAULT_ITEM_FLAGS,
663        &[],
664        &[],
665        digest,
666    )?;
667    chain::set_profile(account, item_id)?;
668    Ok(outcome)
669}
670
671/// Lifecycle actions for [`lifecycle`].
672#[derive(
673    Clone, Copy, Debug, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
674)]
675#[serde(rename_all = "snake_case")]
676pub enum LifecycleAction {
677    Retract,
678    SetNotRevisionable,
679    SetNotRetractable,
680}
681
682/// Account pin/unpin actions for [`account_link`].
683#[derive(
684    Clone, Copy, Debug, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, serde::Deserialize,
685)]
686#[serde(rename_all = "snake_case")]
687pub enum AccountLinkAction {
688    Add,
689    Remove,
690}
691
692// ── IPFS status wrapper (re-exported shape) ──────────────────────────────────
693pub mod ipfs {
694    /// A resolved IPFS peer identity.
695    #[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
696    pub struct IpfsStatus {
697        pub peer_id: String,
698        pub addresses: Vec<String>,
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    fn sample_image(levels: u32) -> encode::ImageSpec {
707        encode::ImageSpec {
708            width: 1012,
709            height: 1012,
710            mipmap_levels: (0..levels)
711                .map(|i| encode::MipmapLevel {
712                    filesize: 121_846 >> i,
713                    cid: format!("QmSample{i}"),
714                })
715                .collect(),
716            ..Default::default()
717        }
718    }
719
720    #[test]
721    fn select_image_level_defaults_to_full_res_and_validates() {
722        let image = sample_image(3);
723        // None defaults to level 0 (full resolution).
724        let (i, spec) = select_image_level(&image, None).unwrap();
725        assert_eq!(i, 0);
726        assert_eq!(spec.filesize, 121_846);
727        // An explicit level resolves.
728        let (i, _) = select_image_level(&image, Some(2)).unwrap();
729        assert_eq!(i, 2);
730        // Out-of-range levels are rejected, not silently clamped.
731        assert!(select_image_level(&image, Some(3)).is_err());
732        // A level-less image mixin carries nothing retrievable.
733        assert!(select_image_level(&sample_image(0), None).is_err());
734    }
735
736    #[test]
737    fn lifecycle_and_account_action_serde() {
738        assert_eq!(
739            serde_json::to_value(LifecycleAction::Retract).unwrap(),
740            "retract"
741        );
742        assert_eq!(serde_json::to_value(AccountLinkAction::Add).unwrap(), "add");
743    }
744
745    #[test]
746    fn publish_item_generates_a_nonce_and_derives_id() {
747        // Nonce generation + id derivtion are pure; verify derivation differs
748        // across nonces and is deterministic for a fixed nonce.
749        let account = [9u8; 32];
750        let nonce = [1u8; 32];
751        let id_a = encode::derive_item_id(account, nonce);
752        let id_b = encode::derive_item_id(account, [2u8; 32]);
753        assert_ne!(id_a, id_b);
754        assert_eq!(id_a, encode::derive_item_id(account, nonce));
755    }
756
757    #[test]
758    fn decode_content_yes_validates_digest_requires_hex() {
759        // Decode only needs a valid-looking 32-byte digest; this checks the
760        // hex prefix routing without hitting IPFS.
761        assert!(encode::hex_to_bytes("0x1234").is_err());
762    }
763
764    fn publish_revision_event(
765        block_number: u32,
766        item_id: &str,
767        revision_id: u32,
768        ipfs_hash: &str,
769    ) -> DecodedEvent {
770        DecodedEvent {
771            block_number,
772            event_index: 1,
773            timestamp: 1_700_000_000_000,
774            event: crate::indexer::StoredEvent {
775                pallet_name: "Content".into(),
776                event_name: "PublishRevision".into(),
777                pallet_index: 7,
778                variant_index: 3,
779                event_index: 1,
780                fields: serde_json::json!({
781                    "item_id": item_id,
782                    "ipfs_hash": ipfs_hash,
783                    "revision_id": revision_id,
784                }),
785            },
786        }
787    }
788
789    #[test]
790    fn revision_entries_exclude_linked_items_revisions() {
791        // Mirrors the feed collision: a linked item's PublishRevision is also
792        // tagged with this item's id and carries the same revision_id 0.
793        let own_id = format!("0x{}", "aa".repeat(32));
794        let linked_id = format!("0x{}", "bb".repeat(32));
795        let own_hash = format!("0x{}", "11".repeat(32));
796        let linked_hash = format!("0x{}", "22".repeat(32));
797
798        // Newest-first input, as the indexer returns events.
799        let events = vec![
800            publish_revision_event(2804, &linked_id, 0, &linked_hash),
801            publish_revision_event(2324, &own_id, 0, &own_hash),
802        ];
803
804        let entries = revision_entries_from_events(&own_id, &events);
805
806        assert_eq!(entries.len(), 1);
807        assert_eq!(entries[0].ipfs_hash_hex, own_hash);
808        assert_eq!(entries[0].revision_id, 0);
809        assert_eq!(entries[0].block_number, Some(2324));
810    }
811
812    #[test]
813    fn revision_entries_ignore_non_revision_and_mismatched_events() {
814        let own_id = format!("0x{}", "aa".repeat(32));
815        let other_id = format!("0x{}", "bb".repeat(32));
816        let mut other_pallet =
817            publish_revision_event(3000, &own_id, 1, &format!("0x{}", "33".repeat(32)));
818        other_pallet.event.pallet_name = "Balances".into();
819
820        let events = vec![
821            other_pallet,
822            publish_revision_event(2900, &other_id, 1, &format!("0x{}", "44".repeat(32))),
823            publish_revision_event(100, &own_id, 0, &format!("0x{}", "11".repeat(32))),
824        ];
825
826        let entries = revision_entries_from_events(&own_id, &events);
827
828        assert_eq!(entries.len(), 1);
829        assert_eq!(entries[0].revision_id, 0);
830    }
831
832    #[test]
833    fn sort_revisions_breaks_equal_revision_id_ties_by_block() {
834        let mut revisions = vec![
835            RevisionEntry {
836                revision_id: 1,
837                ipfs_hash_hex: format!("0x{}", "22".repeat(32)),
838                block_number: Some(2804),
839                timestamp: None,
840            },
841            RevisionEntry {
842                revision_id: 1,
843                ipfs_hash_hex: format!("0x{}", "33".repeat(32)),
844                block_number: Some(3000),
845                timestamp: None,
846            },
847            RevisionEntry {
848                revision_id: 2,
849                ipfs_hash_hex: format!("0x{}", "44".repeat(32)),
850                block_number: Some(100),
851                timestamp: None,
852            },
853        ];
854
855        sort_revisions_newest_first(&mut revisions);
856
857        let hashes: Vec<&str> = revisions.iter().map(|r| r.ipfs_hash_hex.as_str()).collect();
858        // revision 2 first, then revision 1 by descending block height.
859        assert_eq!(
860            hashes,
861            vec![
862                format!("0x{}", "44".repeat(32)),
863                format!("0x{}", "33".repeat(32)),
864                format!("0x{}", "22".repeat(32)),
865            ]
866        );
867    }
868}