use sp_core::crypto::Ss58Codec;
use sp_core::sr25519::Public;
use subxt::OnlineClient;
use subxt::PolkadotConfig;
use subxt::config::Config;
use subxt::config::Header as SubxtHeader;
use subxt::utils::{AccountId32, MultiAddress, MultiSignature};
use crate::ContentError;
use crate::acuity_runtime::api;
use crate::config::CHAIN_WS_URL;
use zeroize::Zeroizing;
struct ChoreoSigner(schnorrkel::Keypair);
impl ChoreoSigner {
fn from_expanded_secret(secret: &[u8]) -> Result<Self, ContentError> {
let secret_key: [u8; 64] = secret
.try_into()
.map_err(|_| ContentError::Account("expected a 64-byte ed25519 secret".into()))?;
let secret = schnorrkel::SecretKey::from_ed25519_bytes(&secret_key)
.map_err(|e| ContentError::Account(format!("invalid ed25519 secret: {e}")))?;
let public = secret.to_public();
Ok(Self(schnorrkel::Keypair { public, secret }))
}
fn account_id(&self) -> [u8; 32] {
self.0.public.to_bytes()
}
}
impl subxt::tx::Signer<PolkadotConfig> for ChoreoSigner {
fn account_id(&self) -> <PolkadotConfig as Config>::AccountId {
AccountId32(self.account_id())
}
fn sign(&self, signer_payload: &[u8]) -> <PolkadotConfig as Config>::Signature {
let context = schnorrkel::signing_context(b"substrate");
let sig = self.0.sign(context.bytes(signer_payload));
MultiSignature::Sr25519(sig.to_bytes())
}
}
pub fn account_id_from_address(address: &str) -> Result<[u8; 32], ContentError> {
let public = Public::from_ss58check(address).map_err(|e| {
ContentError::InvalidArgument(format!("invalid SS58 address {address}: {e}"))
})?;
Ok(public.0)
}
#[must_use]
pub fn account_address(bytes: [u8; 32]) -> String {
AccountId32(bytes).to_string()
}
pub struct ChainAccount {
pub address: String,
pub account_id: [u8; 32],
secret: Zeroizing<Vec<u8>>,
}
impl ChainAccount {
#[must_use]
pub fn from_parts(account_id: [u8; 32], secret: Vec<u8>) -> Self {
Self {
address: account_address(account_id),
account_id,
secret: Zeroizing::new(secret),
}
}
pub fn from_address(address: &str, secret: Vec<u8>) -> Result<Self, ContentError> {
let signer = ChoreoSigner::from_expanded_secret(&secret)?;
let account_id = account_id_from_address(address)?;
if signer.account_id() != account_id {
return Err(ContentError::Account(format!(
"address {address} does not match the supplied secret"
)));
}
Ok(Self {
address: address.to_string(),
account_id,
secret: Zeroizing::new(secret),
})
}
fn signer(&self) -> Result<ChoreoSigner, ContentError> {
ChoreoSigner::from_expanded_secret(&self.secret)
}
}
async fn connect() -> Result<OnlineClient<PolkadotConfig>, ContentError> {
let client = OnlineClient::<PolkadotConfig>::from_insecure_url(CHAIN_WS_URL)
.await
.map_err(|e| {
ContentError::Substrate(format!("failed to connect to {CHAIN_WS_URL}: {e}"))
})?;
let actual = crate::encode::bytes_to_hex(client.genesis_hash().as_ref());
if actual != crate::config::GENESIS_HASH {
return Err(ContentError::Substrate(format!(
"node genesis {actual} does not match expected {}",
crate::config::GENESIS_HASH
)));
}
Ok(client)
}
const CHAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
async fn with_chain_timeout<T, F>(fut: F) -> Result<T, ContentError>
where
F: std::future::Future<Output = Result<T, ContentError>>,
{
match tokio::time::timeout(CHAIN_TIMEOUT, fut).await {
Ok(res) => res,
Err(_) => Err(ContentError::Substrate("chain RPC timed out".into())),
}
}
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
pub struct TxOutcome {
pub item_id: Option<String>,
}
async fn submit_and_wait<Call>(
call: &Call,
account: &ChainAccount,
) -> Result<subxt::extrinsics::ExtrinsicEvents<PolkadotConfig>, ContentError>
where
Call: subxt::tx::Payload,
{
let client = connect().await?;
let at = client
.at_current_block()
.await
.map_err(|e| ContentError::Substrate(format!("failed to get block for tx: {e}")))?;
let signer = account.signer()?;
at.tx()
.sign_and_submit_then_watch_default(call, &signer)
.await
.map_err(|e| ContentError::Transaction(format!("submit failed: {e}")))?
.wait_for_finalized_success()
.await
.map_err(|e| ContentError::Transaction(format!("finalized failed: {e}")))
}
async fn submit_publish<Call>(
call: &Call,
account: &ChainAccount,
) -> Result<TxOutcome, ContentError>
where
Call: subxt::tx::Payload,
{
let tx_events = submit_and_wait(call, account).await?;
let item_id = tx_events
.find_first::<api::content::events::PublishItem>()
.transpose()
.map_err(|e| ContentError::Transaction(format!("failed to decode PublishItem: {e}")))?
.map(|evt| crate::encode::bytes_to_hex(&evt.item_id.0));
Ok(TxOutcome { item_id })
}
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
pub struct ChainStatus {
pub genesis_hash: String,
pub ss58_prefix: u16,
pub best_block: u64,
pub finalized_block: u64,
pub item_id_namespace: u32,
}
pub fn chain_status() -> Result<ChainStatus, ContentError> {
crate::runtime::block_on(with_chain_timeout(async move {
let client = connect().await?;
let at = client
.at_current_block()
.await
.map_err(|e| ContentError::Substrate(format!("failed to get block: {e}")))?;
let finalized_block = at.block_number();
let addr = api::constants().system().ss58_prefix();
let ss58_prefix = at
.constants()
.entry(addr)
.map_err(|e| ContentError::Substrate(format!("failed to read ss58 prefix: {e}")))?;
let rpc = subxt::rpcs::RpcClient::from_insecure_url(crate::config::CHAIN_WS_URL)
.await
.map_err(|e| ContentError::Substrate(format!("failed to open rpc: {e}")))?;
let methods =
subxt::rpcs::LegacyRpcMethods::<subxt::config::RpcConfigFor<PolkadotConfig>>::new(rpc);
let best_header = methods
.chain_get_header(None)
.await
.map_err(|e| ContentError::Substrate(format!("failed to read best block header: {e}")))?
.ok_or_else(|| ContentError::Substrate("node returned no best block header".into()))?;
let best_block = best_header.number();
Ok(ChainStatus {
genesis_hash: crate::encode::bytes_to_hex(client.genesis_hash().as_ref()),
ss58_prefix,
best_block,
finalized_block,
item_id_namespace: crate::config::ITEM_ID_NAMESPACE,
})
}))?
}
pub fn item_state(item_id: [u8; 32]) -> Result<ItemState, ContentError> {
crate::runtime::block_on(with_chain_timeout(async move {
let client = connect().await?;
let at = client
.at_current_block()
.await
.map_err(|e| ContentError::Substrate(format!("failed to get block: {e}")))?;
let addr = api::storage().content().item_state();
let thunk = at
.storage()
.try_fetch(
addr,
(api::runtime_types::pallet_content::pallet::ItemId(item_id),),
)
.await
.map_err(|e| ContentError::Substrate(format!("item state query failed: {e}")))?;
match thunk {
Some(thunk) => {
let item = thunk
.decode()
.map_err(|e| ContentError::Substrate(format!("decode item failed: {e}")))?;
Ok(ItemState {
owner: crate::encode::bytes_to_hex(&item.owner.0),
revision_id: item.revision_id,
flags: item.flags,
})
}
None => Err(ContentError::Content("item not found on-chain".into())),
}
}))?
}
pub fn account_item_ids(account: [u8; 32]) -> Result<Vec<[u8; 32]>, ContentError> {
crate::runtime::block_on(with_chain_timeout(async move {
let client = connect().await?;
let at = client
.at_current_block()
.await
.map_err(|e| ContentError::Substrate(format!("failed to get block: {e}")))?;
let addr = api::storage().account_content().account_item_ids();
let thunk = at
.storage()
.try_fetch(addr, (AccountId32(account),))
.await
.map_err(|e| ContentError::Substrate(format!("account items query failed: {e}")))?;
match thunk {
Some(thunk) => {
let bounded = thunk
.decode()
.map_err(|e| ContentError::Substrate(format!("decode items failed: {e}")))?;
Ok(bounded.0.into_iter().map(|id| id.0).collect())
}
None => Ok(Vec::new()),
}
}))?
}
pub fn profile_item(account: [u8; 32]) -> Result<Option<[u8; 32]>, ContentError> {
crate::runtime::block_on(with_chain_timeout(async move {
let client = connect().await?;
let at = client
.at_current_block()
.await
.map_err(|e| ContentError::Substrate(format!("failed to get block: {e}")))?;
let addr = api::storage().account_profile().account_profile();
let thunk = at
.storage()
.try_fetch(addr, (AccountId32(account),))
.await
.map_err(|e| ContentError::Substrate(format!("profile query failed: {e}")))?;
match thunk {
Some(thunk) => {
let decoded = thunk
.decode()
.map_err(|e| ContentError::Substrate(format!("decode profile failed: {e}")))?;
Ok(Some(decoded.0))
}
None => Ok(None),
}
}))?
}
#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)]
pub struct ItemState {
pub owner: String,
pub revision_id: u32,
pub flags: u8,
}
use api::runtime_types::bounded_collections::bounded_vec::BoundedVec;
use api::runtime_types::pallet_content::pallet::ItemId;
fn item_bounded(list: &[[u8; 32]]) -> BoundedVec<ItemId> {
BoundedVec(list.iter().map(|b| ItemId(*b)).collect())
}
fn account_bounded(list: &[[u8; 32]]) -> BoundedVec<AccountId32> {
BoundedVec(list.iter().map(|b| AccountId32(*b)).collect())
}
pub fn publish_item(
account: &ChainAccount,
nonce: [u8; 32],
parents: &[[u8; 32]],
flags: u8,
links: &[[u8; 32]],
mentions: &[[u8; 32]],
ipfs_hash: [u8; 32],
) -> Result<TxOutcome, ContentError> {
let call = api::tx().content().publish_item(
api::runtime_types::pallet_content::Nonce(nonce),
item_bounded(parents),
flags,
item_bounded(links),
account_bounded(mentions),
api::runtime_types::pallet_content::pallet::IpfsHash(ipfs_hash),
);
crate::runtime::block_on(with_chain_timeout(submit_publish(&call, account)))?
}
pub fn publish_revision(
account: &ChainAccount,
item_id: [u8; 32],
links: &[[u8; 32]],
mentions: &[[u8; 32]],
ipfs_hash: [u8; 32],
) -> Result<TxOutcome, ContentError> {
let call = api::tx().content().publish_revision(
ItemId(item_id),
item_bounded(links),
account_bounded(mentions),
api::runtime_types::pallet_content::pallet::IpfsHash(ipfs_hash),
);
crate::runtime::block_on(with_chain_timeout(submit_publish(&call, account)))?
}
pub fn retract_item(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
let call = api::tx()
.content()
.retract_item(api::runtime_types::pallet_content::pallet::ItemId(item_id));
let _ = crate::runtime::block_on(with_chain_timeout(submit_and_wait(&call, account)))?;
Ok(())
}
pub fn set_not_revisionable(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
let call = api::tx()
.content()
.set_not_revisionable(api::runtime_types::pallet_content::pallet::ItemId(item_id));
let _ = crate::runtime::block_on(with_chain_timeout(submit_and_wait(&call, account)))?;
Ok(())
}
pub fn set_not_retractable(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
let call = api::tx()
.content()
.set_not_retractable(api::runtime_types::pallet_content::pallet::ItemId(item_id));
let _ = crate::runtime::block_on(with_chain_timeout(submit_and_wait(&call, account)))?;
Ok(())
}
pub fn add_account_item(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
let call = api::tx()
.account_content()
.add_item(api::runtime_types::pallet_content::pallet::ItemId(item_id));
let _ = crate::runtime::block_on(with_chain_timeout(submit_and_wait(&call, account)))?;
Ok(())
}
pub fn remove_account_item(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
let call = api::tx()
.account_content()
.remove_item(api::runtime_types::pallet_content::pallet::ItemId(item_id));
let _ = crate::runtime::block_on(submit_publish(&call, account))?;
Ok(())
}
pub fn set_profile(account: &ChainAccount, item_id: [u8; 32]) -> Result<(), ContentError> {
let call = api::tx()
.account_profile()
.set_profile(api::runtime_types::pallet_content::pallet::ItemId(item_id));
let _ = crate::runtime::block_on(with_chain_timeout(submit_and_wait(&call, account)))?;
Ok(())
}
#[allow(dead_code)]
pub(crate) fn multi_address(account: [u8; 32]) -> MultiAddress<AccountId32, ()> {
MultiAddress::Id(AccountId32(account))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn account_id_from_address_round_trips() {
let mut bytes = [0u8; 32];
bytes[0] = 9;
let address = account_address(bytes);
assert_eq!(account_id_from_address(&address).unwrap(), bytes);
}
#[test]
fn account_id_from_address_rejects_garbage() {
assert!(account_id_from_address("not-an-address").is_err());
}
}