use bip39::Mnemonic;
use clap::{Parser, Subcommand, ValueEnum};
use fips204::traits::{SerDes, Signer};
use include_dir::{include_dir, Dir};
use rand::RngCore;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::collections::HashSet;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use truthlinked_core::*;
use truthlinked_state::parse_amount as parse_trth_amount;
fn default_keyfile_path() -> String {
dirs::home_dir()
.map(|p| p.join(".truthlinked").join("default.keys"))
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_else(|| "default.keys".to_string())
}
#[derive(Deserialize, Default)]
struct CliConfig {
rpc: Option<String>,
rpc_by_network: Option<HashMap<String, String>>,
default_keyfile: Option<String>,
}
fn load_cli_config() -> Option<CliConfig> {
let path = dirs::home_dir().map(|p| p.join(".truthlinked").join("config.json"))?;
let raw = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
}
fn resolve_keyfile_path(config: Option<&CliConfig>) -> String {
config
.and_then(|c| c.default_keyfile.as_ref())
.cloned()
.unwrap_or_else(default_keyfile_path)
}
fn resolve_relative_to_config(path: &str, config_path: &std::path::Path) -> String {
let key_path = std::path::Path::new(path);
if key_path.is_absolute() {
return path.to_string();
}
config_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join(key_path)
.to_string_lossy()
.to_string()
}
fn resolve_keyfile_from_config_file(
path: &std::path::Path,
) -> Result<String, Box<dyn std::error::Error>> {
let raw = std::fs::read_to_string(path)?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(format!("{} does not define a keyfile", path.display()).into());
}
if let Ok(config) = serde_json::from_str::<CliConfig>(trimmed) {
if let Some(default_keyfile) = config.default_keyfile {
return Ok(resolve_relative_to_config(&default_keyfile, path));
}
return Err(format!(
"{} is a config file but has no default_keyfile",
path.display()
)
.into());
}
Ok(resolve_relative_to_config(trimmed, path))
}
fn resolve_signing_keyfile_arg(
from: Option<&str>,
config: Option<&CliConfig>,
) -> Result<String, Box<dyn std::error::Error>> {
if let Some(raw) = from {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("--from cannot be empty".into());
}
let path = std::path::Path::new(trimmed);
if path.exists() && path.is_file() {
if path.file_name().and_then(|name| name.to_str()) == Some("config") {
return resolve_keyfile_from_config_file(path);
}
}
return Ok(trimmed.to_string());
}
let local_config = std::path::Path::new("axiom/config");
if local_config.exists() && local_config.is_file() {
return resolve_keyfile_from_config_file(local_config);
}
Ok(resolve_keyfile_path(config))
}
fn resolve_rpc(cli: &Cli, config: Option<&CliConfig>) -> String {
if let Ok(rpc) = std::env::var("TRUTHLINKED_RPC") {
if !rpc.trim().is_empty() {
return rpc;
}
}
if let Some(rpc) = cli.rpc.as_ref() {
return rpc.clone();
}
if let Some(network) = cli.network.as_ref() {
if let Some(cfg) = config.and_then(|c| c.rpc_by_network.as_ref()) {
if let Some(rpc) = cfg.get(&network.to_string()) {
return rpc.clone();
}
}
return network.default_rpc().to_string();
}
if let Some(rpc) = config.and_then(|c| c.rpc.as_ref()) {
return rpc.clone();
}
"https://devnet.truthlinked.org".to_string()
}
fn resolve_output(cli: &Cli) -> OutputFormat {
if cli.json {
OutputFormat::Json
} else {
cli.output.unwrap_or(OutputFormat::Pretty)
}
}
fn parse_amount_str(input: &str) -> Result<u128, String> {
parse_trth_amount(input)
}
fn is_hex(s: &str) -> bool {
s.len() % 2 == 0 && s.chars().all(|c| c.is_ascii_hexdigit())
}
enum RecipientInput {
AccountId([u8; 32]),
Pubkey(Vec<u8>),
Name(String),
}
fn parse_recipient_input(raw: &str) -> Result<RecipientInput, String> {
if raw.ends_with(".tl") {
return Ok(RecipientInput::Name(raw.to_string()));
}
if !is_hex(raw) {
return Err("Recipient must be hex (pubkey/account ID) or name (.tl)".to_string());
}
if raw.len() == 64 {
let bytes = hex::decode(raw).map_err(|_| "Invalid account ID hex".to_string())?;
let mut id = [0u8; 32];
id.copy_from_slice(&bytes);
return Ok(RecipientInput::AccountId(id));
}
if raw.len() == 3904 {
let bytes = hex::decode(raw).map_err(|_| "Invalid pubkey hex".to_string())?;
return Ok(RecipientInput::Pubkey(bytes));
}
Err("Recipient hex length not recognized".to_string())
}
fn parse_hex_32(label: &str, raw: &str) -> Result<[u8; 32], String> {
let bytes = hex::decode(raw).map_err(|_| format!("Invalid hex for {}", label))?;
if bytes.len() != 32 {
return Err(format!("{} must be 32-byte hex", label));
}
let mut out = [0u8; 32];
out.copy_from_slice(&bytes);
Ok(out)
}
fn parse_hex_array<const N: usize>(label: &str, raw: &str) -> Result<[u8; N], String> {
let bytes = hex::decode(raw).map_err(|_| format!("Invalid hex for {}", label))?;
if bytes.len() != N {
return Err(format!("{} must be {}-byte hex", label, N));
}
let mut out = [0u8; N];
out.copy_from_slice(&bytes);
Ok(out)
}
fn private_balance_material(
balance: u128,
aes_seed_hex: &str,
enc_nonce_hex: &str,
commit_nonce_hex: &str,
) -> Result<(Vec<u8>, [u8; 32], [u8; 16]), Box<dyn std::error::Error>> {
let seed = parse_hex_bytes("aes_seed_hex", aes_seed_hex)?;
let enc_nonce = parse_hex_array::<12>("enc_nonce_hex", enc_nonce_hex)?;
let commit_nonce = parse_hex_array::<16>("commit_nonce_hex", commit_nonce_hex)?;
let aes_key = truthlinked_mcp::private_balance::derive_aes_key(&seed);
let encrypted_balance =
truthlinked_mcp::private_balance::encrypt_balance(balance, &aes_key, &enc_nonce)?;
let commitment = truthlinked_mcp::private_balance::compute_commitment(
balance,
u128::from_le_bytes(commit_nonce),
&encrypted_balance,
);
Ok((encrypted_balance, commitment, commit_nonce))
}
fn submit_signed_intent(
client: &reqwest::blocking::Client,
rpc: &str,
retries: u32,
sender_id: AccountId,
sender_keys: &pq_identity::DualKeypair,
intent: TransactionIntent,
) -> Result<Value, Box<dyn std::error::Error>> {
let genesis_hash = fetch_genesis_hash(client, rpc, retries)?;
let nonce = next_nonce(client, rpc, &sender_id, retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let tx = Transaction {
sender: sender_id,
intent,
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
submit_transaction_with_nonce_retry(client, rpc, retries, sender_keys, tx)
}
fn attach_private_balance_output(
res: &mut Value,
cell_id: &AccountId,
agent_id: &AccountId,
balance: u128,
encrypted_balance: &[u8],
commitment: &[u8; 32],
commit_nonce: &[u8; 16],
) {
if let Some(map) = res.as_object_mut() {
map.insert(
"private_balance".to_string(),
serde_json::json!({
"cell_id": hex::encode(cell_id),
"agent_id": hex::encode(agent_id),
"balance_units": balance.to_string(),
"encrypted_balance_hex": hex::encode(encrypted_balance),
"commitment": hex::encode(commitment),
"commit_nonce_hex": hex::encode(commit_nonce),
}),
);
}
}
fn parse_hex_bytes(label: &str, raw: &str) -> Result<Vec<u8>, String> {
hex::decode(raw).map_err(|_| format!("Invalid hex for {}", label))
}
fn parse_hex_bytes_exact(label: &str, raw: &str, expected_len: usize) -> Result<Vec<u8>, String> {
let bytes = parse_hex_bytes(label, raw)?;
if bytes.len() != expected_len {
return Err(format!("{} must be {}-byte hex", label, expected_len));
}
Ok(bytes)
}
fn load_bytes(path: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
Ok(std::fs::read(path)?)
}
fn load_optional_bytes(path: Option<String>) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
if let Some(p) = path {
Ok(std::fs::read(p)?)
} else {
Ok(Vec::new())
}
}
fn prompt_line(label: &str) -> Result<String, Box<dyn std::error::Error>> {
eprint!("{}: ", label);
use std::io::Write;
std::io::stdout().flush()?;
let mut buf = String::new();
std::io::stdin().read_line(&mut buf)?;
Ok(buf.trim().to_string())
}
fn confirm_or_abort(
yes: bool,
output: OutputFormat,
message: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if yes || output == OutputFormat::Json {
return Ok(());
}
eprint!("{} [y/N]: ", message);
use std::io::Write;
std::io::stdout().flush()?;
let mut buf = String::new();
std::io::stdin().read_line(&mut buf)?;
let ok = matches!(buf.trim().to_lowercase().as_str(), "y" | "yes");
if ok {
Ok(())
} else {
Err("Aborted by user".into())
}
}
fn get_json(
client: &reqwest::blocking::Client,
url: &str,
retries: u32,
) -> Result<Value, Box<dyn std::error::Error>> {
for attempt in 0..=retries {
match client.get(url).send().and_then(|r| r.error_for_status()) {
Ok(resp) => return Ok(resp.json()?),
Err(_err) if attempt < retries => continue,
Err(err) => return Err(err.into()),
}
}
Err("unreachable".into())
}
fn post_json(
client: &reqwest::blocking::Client,
url: &str,
body: Value,
retries: u32,
) -> Result<Value, Box<dyn std::error::Error>> {
for attempt in 0..=retries {
match client
.post(url)
.json(&body)
.send()
.and_then(|r| r.error_for_status())
{
Ok(resp) => return Ok(resp.json()?),
Err(_err) if attempt < retries => continue,
Err(err) => return Err(err.into()),
}
}
Err("unreachable".into())
}
fn post_bytes(
client: &reqwest::blocking::Client,
url: &str,
body: Vec<u8>,
retries: u32,
) -> Result<Value, Box<dyn std::error::Error>> {
let mut last_err = String::new();
for attempt in 0..=retries {
match client
.post(url)
.header("Content-Type", "application/octet-stream")
.body(body.clone())
.send()
{
Ok(resp) => {
let status = resp.status();
let bytes = resp.bytes()?;
if !status.is_success() {
last_err = format!(
"POST {} returned {}: {}",
url,
status,
String::from_utf8_lossy(&bytes)
);
} else {
return Ok(serde_json::from_slice(&bytes)?);
}
}
Err(err) => last_err = err.to_string(),
}
if attempt < retries {
continue;
}
}
Err(format!("post failed: {}", last_err).into())
}
fn expected_nonce_from_submit_response(res: &Value) -> Option<u64> {
if res.get("success").and_then(|v| v.as_bool()) != Some(false) {
return None;
}
let err = res.get("error")?.as_str()?;
if let Some(start) = err.find("missing nonce ") {
let rest = &err[start + "missing nonce ".len()..];
let end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
if let Ok(nonce) = rest[..end].parse::<u64>() {
return Some(nonce);
}
}
let marker = "expected ";
let start = err.find(marker)? + marker.len();
let rest = &err[start..];
let end = rest.find("..").or_else(|| rest.find(","))?;
rest[..end].parse::<u64>().ok()
}
enum SubmittedTxOutcome {
Confirmed,
Rejected(String),
}
fn wait_for_submitted_tx_outcome(
client: &reqwest::blocking::Client,
rpc: &str,
retries: u32,
account_id: &[u8; 32],
tx_hash: &str,
nonce: u64,
) -> Result<SubmittedTxOutcome, String> {
let wait_timeout = nonce_queue_wait_timeout();
let wait_start = std::time::Instant::now();
loop {
let tx = get_json(client, &format!("{}/tx/{}", rpc, tx_hash), retries).ok();
let status = tx
.as_ref()
.and_then(|v| v.get("status"))
.and_then(|v| v.as_str())
.map(str::to_string);
match status.as_deref() {
Some("rejected") => {
let reason = tx
.as_ref()
.and_then(|v| v.get("reason").or_else(|| v.get("error")))
.and_then(|v| v.as_str())
.unwrap_or("transaction rejected")
.to_string();
return Ok(SubmittedTxOutcome::Rejected(reason));
}
Some("confirmed") => {
let chain_nonce = fetch_account_nonce(client, rpc, account_id, retries)?;
if chain_nonce >= nonce {
return Ok(SubmittedTxOutcome::Confirmed);
}
}
_ => {}
}
if wait_start.elapsed() >= wait_timeout {
return Err(format!(
"Submitted transaction {} did not confirm within {}s; retry later or increase AXIOM_NONCE_QUEUE_WAIT_SECS",
tx_hash,
wait_timeout.as_secs()
));
}
std::thread::sleep(Duration::from_millis(250));
}
}
fn submit_transaction_with_nonce_retry(
client: &reqwest::blocking::Client,
rpc: &str,
retries: u32,
sender_keys: &pq_identity::DualKeypair,
mut tx: Transaction,
) -> Result<Value, Box<dyn std::error::Error>> {
let submit_url = format!("{}/submit_raw", rpc);
for attempt in 0..=3 {
tx.signature.clear();
let signed = sender_keys.sign_transaction(&tx)?;
let bytes = postcard::to_allocvec(&signed)?;
let res = post_bytes(client, &submit_url, bytes, retries)?;
if res.get("success").and_then(|v| v.as_bool()) == Some(true) {
if let Some(hash) = res.get("tx_hash").and_then(|v| v.as_str()) {
set_nonce_queue_last_tx(rpc, &tx.sender, hash, tx.nonce)?;
match wait_for_submitted_tx_outcome(
client, rpc, retries, &tx.sender, hash, tx.nonce,
)? {
SubmittedTxOutcome::Confirmed => return Ok(res),
SubmittedTxOutcome::Rejected(reason) => {
let next = fetch_account_nonce(client, rpc, &tx.sender, retries)?
.saturating_add(1);
set_nonce_queue_next(rpc, &tx.sender, next)?;
let mut rejected = res;
if let Some(map) = rejected.as_object_mut() {
map.insert("success".to_string(), serde_json::json!(false));
map.insert("error".to_string(), serde_json::json!(reason));
}
return Ok(rejected);
}
}
}
return Ok(res);
}
if let Some(expected_nonce) = expected_nonce_from_submit_response(&res) {
if expected_nonce != tx.nonce && attempt < 3 {
set_nonce_queue_next(rpc, &tx.sender, expected_nonce.saturating_add(1))?;
tx.nonce = expected_nonce;
tx.timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
continue;
}
}
return Ok(res);
}
unreachable!()
}
fn load_keypair_and_pubkey(
path: &str,
) -> Result<(pq_identity::DualKeypair, Vec<u8>), Box<dyn std::error::Error>> {
let keypair = pq_identity::DualKeypair::load(path)?;
let pubkey = keypair.dilithium_pk.clone().into_bytes().to_vec();
Ok((keypair, pubkey))
}
fn load_account_id_and_keypair(
path: &str,
) -> Result<(AccountId, pq_identity::DualKeypair), Box<dyn std::error::Error>> {
let keypair = pq_identity::DualKeypair::load(path)?;
let pubkey = keypair.dilithium_pk.clone().into_bytes().to_vec();
let account_id = pq_identity::account_id_from_pubkey(&pubkey);
Ok((account_id, keypair))
}
fn get_expiration_height(
client: &reqwest::blocking::Client,
rpc: &str,
retries: u32,
) -> Result<u64, Box<dyn std::error::Error>> {
let info = get_json(client, &format!("{}/chain_info", rpc), retries)?;
let height = info
.get("height")
.and_then(|v| v.as_u64())
.ok_or("Missing height")?;
Ok(height.saturating_add(100))
}
static SDK_TEMPLATE_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/template");
const LARGE_TRANSFER_TLKD: u64 = 1_000;
#[derive(Deserialize)]
#[allow(dead_code)]
struct RpcAccountInfo {
account_id: String,
found: bool,
balance: String,
balance_trth: String,
is_cell: bool,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct RpcCellInfo {
cell_id: String,
found: bool,
is_token: bool,
immutable: bool,
}
#[derive(Parser)]
#[command(name = "axiom")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "TruthLinked command-line interface")]
struct Cli {
#[arg(long)]
rpc: Option<String>,
#[arg(long, short = 'n', value_enum)]
network: Option<Network>,
#[arg(long, value_enum)]
output: Option<OutputFormat>,
#[arg(long, short = 'j')]
json: bool,
#[arg(long, short = 'y')]
yes: bool,
#[arg(long, default_value = "30")]
timeout: u64,
#[arg(long, default_value = "2")]
retries: u32,
#[command(subcommand)]
command: Commands,
}
#[derive(ValueEnum, Clone, Copy, PartialEq, Eq)]
enum OutputFormat {
Pretty,
Json,
}
#[derive(ValueEnum, Clone, Copy)]
enum Network {
Local,
Devnet,
Testnet,
Mainnet,
}
impl Network {
fn default_rpc(self) -> &'static str {
match self {
Network::Local => "http://localhost:19944",
Network::Devnet => "https://devnet.truthlinked.org",
Network::Testnet => "https://devnet.truthlinked.org",
Network::Mainnet => "https://devnet.truthlinked.org",
}
}
}
impl std::fmt::Display for Network {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Network::Local => "local",
Network::Devnet => "devnet",
Network::Testnet => "testnet",
Network::Mainnet => "mainnet",
};
write!(f, "{}", s)
}
}
#[derive(Subcommand)]
enum Commands {
ChainInfo,
TokenInfo,
NetworkInfo,
Validators,
Mempool,
Status {
#[arg(long)]
from: Option<String>,
#[arg(long, short = 'f')]
full: bool,
},
Resolve { query: String },
ListCellProposals,
TxStatus { hash: String },
Tx { hash: String },
Balance {
account_id: String,
#[arg(long, short = 'f')]
full: bool,
},
BalanceByPubkey {
pubkey: String,
#[arg(long, short = 'f')]
full: bool,
},
AccountId {
#[arg(long)]
from: Option<String>,
#[arg(long)]
pubkey: Option<String>,
},
ImportMnemonic {
#[arg(long)]
mnemonic: String,
#[arg(long, default_value_t = default_keyfile_path())]
output: String,
#[arg(long)]
passphrase: Option<String>,
},
#[command(visible_alias = "keygen")]
AccountCreate {
#[arg(long, default_value_t = default_keyfile_path())]
output: String,
#[arg(long)]
encrypt: bool,
#[arg(long)]
passphrase: Option<String>,
},
Faucet {
#[arg(long)]
from: Option<String>,
#[arg(long, default_value = "15000")]
amount: String,
},
GenesisValidator {
#[arg(long)]
keys_file: String,
#[arg(long)]
allocation: String,
},
Mcp {
#[command(subcommand)]
command: McpCommand,
},
TreasuryProposeSpend {
#[arg(long)]
from: String,
#[arg(long)]
recipient: String,
#[arg(long)]
amount: String,
#[arg(long)]
timelock_blocks: u64,
#[arg(long)]
proposal_id: Option<String>,
},
TreasuryVoteSpend {
#[arg(long)]
from: String,
#[arg(long)]
proposal_id: String,
#[arg(long)]
approve: bool,
},
TreasuryExecuteSpend {
#[arg(long)]
from: String,
#[arg(long)]
proposal_id: String,
},
TreasuryProposalInfo {
#[arg(long)]
proposal_id: String,
},
Transfer {
#[arg(long)]
from: String,
#[arg(long)]
to_pubkey: Option<String>,
#[arg(long, conflicts_with = "to_pubkey")]
to_name: Option<String>,
#[arg(long)]
amount: String,
},
Send {
#[arg(value_name = "recipient")]
recipient: Option<String>,
#[arg(value_name = "amount")]
amount: Option<String>,
#[arg(long)]
from: Option<String>,
},
DepositCompute {
#[arg(long)]
from: String,
#[arg(long)]
amount: String,
},
WithdrawCompute {
#[arg(long)]
from: String,
#[arg(long)]
amount: String,
},
BatchTransfer {
#[arg(long)]
from: String,
#[arg(long)]
to_pubkeys: String,
#[arg(long)]
amounts: String,
},
ValidatorSetup {
#[arg(long)]
keys: String,
#[arg(long)]
amount: String,
},
Bond {
#[arg(long)]
from: String,
#[arg(long)]
amount: String,
},
Stake {
#[arg(value_name = "amount")]
amount: Option<String>,
#[arg(long)]
from: Option<String>,
},
Unbond {
#[arg(long)]
from: String,
#[arg(long)]
amount: String,
},
Withdraw {
#[arg(long)]
from: String,
},
Unjail {
#[arg(long)]
from: String,
},
DelegateAdd {
#[arg(long)]
from: String,
#[arg(long)]
delegate_pubkey: String,
},
DelegateRemove {
#[arg(long)]
from: String,
#[arg(long)]
delegate_pubkey: String,
},
StakeFor {
#[arg(long)]
from: String,
#[arg(long)]
owner_pubkey: String,
#[arg(long)]
amount: String,
},
UnstakeFor {
#[arg(long)]
from: String,
#[arg(long)]
owner_pubkey: String,
#[arg(long)]
amount: String,
},
WithdrawFor {
#[arg(long)]
from: String,
#[arg(long)]
owner_pubkey: String,
},
UnjailFor {
#[arg(long)]
from: String,
#[arg(long)]
owner_pubkey: String,
},
#[command(name = "staked-tlkd-lock")]
StakedTrthLock {
#[arg(long)]
from: String,
#[arg(long)]
amount: String,
#[arg(long)]
lock_blocks: u64,
},
#[command(name = "staked-tlkd-extend")]
StakedTrthExtend {
#[arg(long)]
from: String,
#[arg(long)]
lock_blocks: u64,
},
#[command(name = "staked-tlkd-unlock")]
StakedTrthUnlock {
#[arg(long)]
from: String,
},
MintNft {
#[arg(long)]
from: String,
#[arg(long)]
nft_id: String,
#[arg(long)]
name: String,
#[arg(long)]
metadata_uri: String,
#[arg(long)]
collection: Option<String>,
#[arg(long, default_value = "0")]
royalty_bps: u16,
#[arg(long)]
royalty_recipient: Option<String>,
},
TransferNft {
#[arg(long)]
from: String,
#[arg(long)]
nft_id: String,
#[arg(long)]
to_pubkey: String,
#[arg(long)]
sale_price: Option<String>,
},
BurnNft {
#[arg(long)]
from: String,
#[arg(long)]
nft_id: String,
},
ApproveNft {
#[arg(long)]
from: String,
#[arg(long)]
nft_id: String,
#[arg(long)]
approved_pubkey: Option<String>,
},
GetNft {
#[arg(long)]
nft_id: String,
},
MyNFTs {
#[arg(long)]
account: String,
},
DeployCell {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long)]
source: Option<String>,
#[arg(long)]
bytecode_file: Option<String>,
#[arg(long, default_value = "0")]
initial_balance: u64,
#[arg(long)]
manifest_file: Option<String>,
},
Deploy {
#[arg(value_name = "cell_id")]
cell_id: Option<String>,
#[arg(value_name = "source")]
source: Option<String>,
#[arg(long)]
from: Option<String>,
},
DeployToken {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long)]
name: String,
#[arg(long)]
symbol: String,
#[arg(long)]
decimals: u8,
#[arg(long)]
supply: u128,
},
CallCell {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long)]
calldata: String,
#[arg(long, default_value = "0")]
value: u64,
#[arg(long, default_value = "1000000")]
gas_limit: u64,
#[arg(long, alias = "dry-run")]
simulate: bool,
},
UpgradeCell {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long)]
source: Option<String>,
#[arg(long)]
bytecode_file: Option<String>,
#[arg(long)]
manifest_file: Option<String>,
},
RotateKey {
#[arg(long)]
from: String,
#[arg(long)]
new_pubkey: String,
},
AcceptOwnership {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
},
MakeImmutable {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
},
CloseCell {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
},
ProposeCellUpgrade {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long)]
source: Option<String>,
#[arg(long)]
bytecode_file: Option<String>,
#[arg(long)]
manifest_file: Option<String>,
#[arg(long, default_value = "7200")]
timelock_blocks: u64,
},
ProposeCellOwnershipTransfer {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long)]
new_owner: String,
#[arg(long, default_value = "7200")]
timelock_blocks: u64,
},
ProposeCellMakeImmutable {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long, default_value = "7200")]
timelock_blocks: u64,
},
VoteCellProposal {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long)]
approve: bool,
},
ExecuteCellProposal {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
},
TokenTransfer {
#[arg(long)]
from: String,
#[arg(long)]
token: String,
#[arg(long)]
to: String,
#[arg(long)]
amount: u128,
},
TokenMint {
#[arg(long)]
from: String,
#[arg(long)]
token: String,
#[arg(long)]
to: String,
#[arg(long)]
amount: u128,
},
TokenBurn {
#[arg(long)]
from: String,
#[arg(long)]
token: String,
#[arg(long)]
amount: u128,
},
ProposeTokenAuthority {
#[arg(long)]
from: String,
#[arg(long)]
token: String,
#[arg(long)]
mint_authority: Option<String>,
#[arg(long)]
clear_mint_authority: bool,
#[arg(long)]
freeze_authority: Option<String>,
#[arg(long)]
clear_freeze_authority: bool,
#[arg(long, default_value = "7200")]
voting_period_blocks: u64,
},
VoteTokenAuthority {
#[arg(long)]
from: String,
#[arg(long)]
token: String,
#[arg(long)]
approve: bool,
},
CallChain {
#[arg(long)]
from: String,
#[arg(long)]
calls: String,
#[arg(long, default_value = "5000000")]
gas_limit: u64,
#[arg(long, alias = "dry-run")]
simulate: bool,
},
ProposeName {
#[arg(long)]
from: String,
#[arg(long)]
name: String,
#[arg(long)]
target: String,
#[arg(long)]
owner: String,
},
VoteName {
#[arg(long)]
from: String,
#[arg(long)]
name: String,
#[arg(long)]
approve: bool,
},
RenewName {
#[arg(long)]
from: String,
#[arg(long)]
name: String,
},
TransferName {
#[arg(long)]
from: String,
#[arg(long)]
name: String,
#[arg(long)]
new_owner: String,
},
ProposeUrl {
#[arg(long)]
from: String,
#[arg(long = "url-pattern")]
url_pattern: String,
#[arg(long)]
bond: String,
#[arg(long, default_value = "7200")]
voting_period_blocks: u64,
},
VoteUrl {
#[arg(long)]
from: String,
#[arg(long = "url-pattern")]
url_pattern: String,
#[arg(long)]
approve: bool,
},
ReportMaliciousUrl {
#[arg(long)]
from: String,
#[arg(long = "url-pattern")]
url_pattern: String,
#[arg(long, default_value = "malicious behavior")]
evidence: String,
},
UpgradeVisibility {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long, default_value_t = true)]
public: bool,
},
Build {
#[arg(long)]
source: String,
#[arg(long)]
output: Option<String>,
},
SDKNew {
#[arg(long)]
path: String,
},
SDKBuild {
#[arg(long)]
path: String,
#[arg(long)]
output: Option<String>,
},
SDKDeploy {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long)]
path: String,
#[arg(long)]
bytecode_file: Option<String>,
#[arg(long, default_value = "0")]
initial_balance: u64,
#[arg(long)]
manifest_file: Option<String>,
#[arg(long, default_value_t = false)]
skip_build: bool,
},
ManifestInit {
#[arg(long)]
bytecode_file: String,
},
ManifestVerify {
#[arg(long)]
bytecode_file: String,
#[arg(long)]
manifest_file: String,
},
ManifestHash {
#[arg(long)]
bytecode_file: String,
#[arg(long)]
manifest_file: String,
},
}
#[derive(Subcommand)]
enum McpCommand {
RegisterAgent {
#[arg(long)]
from: String,
#[arg(long)]
agent_keyfile: String,
#[arg(long)]
policy_cell_id: String,
#[arg(long)]
agent_registry_id: Option<String>,
},
RegisterTool {
#[arg(long)]
from: String,
#[arg(long)]
tool_id: String,
#[arg(long)]
name: String,
#[arg(long, default_value = "0")]
category: u8,
#[arg(long)]
bytecode_file: String,
#[arg(long)]
manifest_file: String,
#[arg(long)]
schema_file: String,
#[arg(long)]
registry_id: Option<String>,
},
RegisterResource {
#[arg(long)]
from: String,
#[arg(long)]
resource_id: String,
#[arg(long)]
name: String,
#[arg(long)]
uri_scheme: String,
#[arg(long)]
mime_type: String,
#[arg(long)]
bytecode_file: Option<String>,
#[arg(long)]
manifest_file: Option<String>,
#[arg(long)]
initial_data_json: Option<String>,
#[arg(long)]
registry_id: Option<String>,
},
RegisterPrompt {
#[arg(long)]
from: String,
#[arg(long)]
prompt_id: String,
#[arg(long)]
name: String,
#[arg(long)]
template_file: String,
#[arg(long)]
arg: Vec<String>,
#[arg(long)]
registry_id: Option<String>,
},
SetPolicy {
#[arg(long)]
from: String,
#[arg(long)]
policy_cell_id: String,
#[arg(long, default_value = "0")]
status: u8,
#[arg(long, default_value = "1")]
allow_reads: u8,
#[arg(long, default_value = "1")]
allow_writes: u8,
#[arg(long, default_value = "0")]
allow_admin: u8,
#[arg(long, default_value = "0")]
rate_limit: u32,
#[arg(long, default_value = "0")]
spend_per_tx: u128,
#[arg(long, default_value = "0")]
spend_epoch: u128,
#[arg(long, default_value = "0")]
hitl_threshold: u128,
},
SetToolPermission {
#[arg(long)]
from: String,
#[arg(long)]
policy_cell_id: String,
#[arg(long)]
tool_id: String,
#[arg(long)]
enabled: u8,
},
PrivateBalanceInit {
#[arg(long)]
from: String,
#[arg(long)]
agent_id: String,
#[arg(long)]
cell_id: Option<String>,
#[arg(long, default_value = "0")]
balance: String,
#[arg(long)]
aes_seed_hex: String,
#[arg(long)]
enc_nonce_hex: String,
#[arg(long)]
commit_nonce_hex: String,
},
PrivateBalanceDeposit {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long)]
agent_id: String,
#[arg(long)]
amount: String,
#[arg(long)]
new_balance: String,
#[arg(long)]
old_commitment: String,
#[arg(long)]
aes_seed_hex: String,
#[arg(long)]
enc_nonce_hex: String,
#[arg(long)]
commit_nonce_hex: String,
},
PrivateBalanceWithdraw {
#[arg(long)]
from: String,
#[arg(long)]
cell_id: String,
#[arg(long)]
agent_id: String,
#[arg(long)]
amount: String,
#[arg(long)]
recipient: String,
#[arg(long)]
new_balance: String,
#[arg(long)]
old_commitment: String,
#[arg(long)]
aes_seed_hex: String,
#[arg(long)]
enc_nonce_hex: String,
#[arg(long)]
commit_nonce_hex: String,
},
PrivateBalanceConfidentialTransfer {
#[arg(long)]
from: String,
#[arg(long)]
sender_cell_id: String,
#[arg(long)]
sender_agent_id: String,
#[arg(long)]
recipient_cell_id: String,
#[arg(long)]
amount_commitment: String,
#[arg(long)]
proof_hex: Option<String>,
#[arg(long)]
proof_file: Option<String>,
#[arg(long)]
sender_new_encrypted: String,
#[arg(long)]
sender_new_commitment: String,
#[arg(long)]
sender_new_commit_nonce: String,
#[arg(long)]
sender_old_commitment: String,
#[arg(long)]
recipient_new_encrypted: String,
#[arg(long)]
recipient_new_commitment: String,
#[arg(long)]
recipient_new_commit_nonce: String,
#[arg(long)]
recipient_old_commitment: String,
},
ToolCall {
#[arg(long)]
from: String,
#[arg(long)]
tool_id: String,
#[arg(long)]
policy_cell_id: String,
#[arg(long)]
action_log_id: Option<String>,
#[arg(long)]
calldata_hex: Option<String>,
#[arg(long)]
calldata_file: Option<String>,
#[arg(long, default_value = "0")]
value: u128,
#[arg(long, default_value = "500000")]
gas_limit: u64,
},
}
fn resolve_cargo_binary() -> String {
if let Ok(path) = std::env::var("CARGO") {
return path;
}
let root_cargo = std::path::Path::new("/root/.cargo/bin/cargo");
if root_cargo.exists() {
return root_cargo.to_string_lossy().to_string();
}
"cargo".to_string()
}
fn build_cell(
source: &str,
output: Option<&str>,
) -> Result<(String, String), Box<dyn std::error::Error>> {
use std::process::Command;
let source_path = std::path::Path::new(source);
let default_stem = source_path.with_extension("").to_string_lossy().to_string();
let stem = output.unwrap_or(default_stem.as_str());
let output_axiom = stem.to_string() + ".axiom";
let output_manifest = stem.to_string() + ".manifest.json";
if let Some(parent) = std::path::Path::new(&output_axiom).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
if let Some(parent) = std::path::Path::new(&output_manifest).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
println!(" Building Axiom cell: {} → {}", source, output_axiom);
if source_path.extension().and_then(|ext| ext.to_str()) == Some("cell") {
let compiled = truthlinked_axiom_compiler::compile_file(source_path)?;
std::fs::write(&output_axiom, &compiled.bytecode)?;
std::fs::write(
&output_manifest,
serde_json::to_string_pretty(&compiled.manifest)?,
)?;
} else {
let project_root = source_path
.parent()
.and_then(|p| {
if p.ends_with("src") {
p.parent()
} else {
Some(p)
}
})
.ok_or("Cannot determine project root")?;
let manifest_path = project_root.join("Cargo.toml").canonicalize()?;
let result = Command::new(resolve_cargo_binary())
.args([
"run",
"--release",
"--quiet",
"--manifest-path",
manifest_path.to_string_lossy().as_ref(),
])
.current_dir(project_root)
.output()?;
if !result.status.success() {
eprintln!(
" Build failed:\n{}",
String::from_utf8_lossy(&result.stderr)
);
return Err("SDK build failed".into());
}
let produced = project_root.join("cell.axiom");
if !produced.exists() {
return Err(format!(
"Build succeeded but {} was not produced",
produced.display()
)
.into());
}
std::fs::copy(&produced, &output_axiom)?;
}
println!(" Built successfully");
let bytecode = std::fs::read(&output_axiom)?;
let analysis = truthlinked_core::cells::CellAccount::analyze_bytecode(&bytecode)
.unwrap_or_else(|_| truthlinked_core::cells::ManifestAnalysis {
static_read_slots: vec![],
static_write_slots: vec![],
has_storage_reads: false,
has_storage_writes: false,
fully_resolved: false,
});
let manifest = serde_json::json!({
"declared_reads": analysis.static_read_slots.iter().map(hex::encode).collect::<Vec<_>>(),
"declared_writes": analysis.static_write_slots.iter().map(hex::encode).collect::<Vec<_>>(),
"commutative_keys": [],
"storage_key_specs": [],
});
std::fs::write(&output_manifest, serde_json::to_string_pretty(&manifest)?)?;
println!(" Generated manifest: {}", output_manifest);
if !analysis.fully_resolved {
println!(" Warning: Some storage keys are dynamic. Review declared_reads/writes.");
}
Ok((output_axiom, output_manifest))
}
fn require_account_exists(
client: &reqwest::blocking::Client,
rpc: &str,
account_id: &str,
) -> Result<RpcAccountInfo, Box<dyn std::error::Error>> {
let info: RpcAccountInfo = client
.get(format!("{}/account/{}", rpc, account_id))
.send()?
.json()?;
if !info.found {
return Err(format!("account {} not found", account_id).into());
}
Ok(info)
}
fn require_cell_exists(
client: &reqwest::blocking::Client,
rpc: &str,
cell_id: &str,
retries: u32,
) -> Result<RpcCellInfo, Box<dyn std::error::Error>> {
let info_val = get_json(client, &format!("{}/cell/{}", rpc, cell_id), retries)?;
let info: RpcCellInfo = serde_json::from_value(info_val)?;
if !info.found {
return Err(format!("cell {} not found", cell_id).into());
}
Ok(info)
}
fn require_token_cell(
client: &reqwest::blocking::Client,
rpc: &str,
token_id: &str,
retries: u32,
) -> Result<RpcCellInfo, Box<dyn std::error::Error>> {
let info = require_cell_exists(client, rpc, token_id, retries)?;
if !info.is_token {
return Err(format!("cell {} is not a token", token_id).into());
}
Ok(info)
}
fn fetch_genesis_hash(
client: &reqwest::blocking::Client,
rpc: &str,
retries: u32,
) -> Result<[u8; 32], Box<dyn std::error::Error>> {
let chain_info = get_json(client, &format!("{}/chain_info", rpc), retries)?;
let genesis_hash_hex = chain_info["genesis_hash"]
.as_str()
.ok_or("No genesis hash")?;
let mut genesis_hash = [0u8; 32];
hex::decode_to_slice(genesis_hash_hex, &mut genesis_hash)?;
Ok(genesis_hash)
}
fn fetch_account_nonce(
client: &reqwest::blocking::Client,
rpc: &str,
account_id: &[u8; 32],
retries: u32,
) -> Result<u64, String> {
let res = get_json(
client,
&format!("{}/account/{}", rpc, hex::encode(account_id)),
retries,
)
.map_err(|e| format!("Failed to fetch account nonce: {e}"))?;
Ok(res.get("nonce").and_then(|v| v.as_u64()).unwrap_or(0))
}
fn nonce_queue_dir() -> Result<std::path::PathBuf, String> {
let base = dirs::home_dir()
.ok_or_else(|| "Unable to locate home directory for nonce queue".to_string())?
.join(".truthlinked")
.join("nonce-queue");
std::fs::create_dir_all(&base)
.map_err(|e| format!("Failed to create nonce queue directory: {e}"))?;
Ok(base)
}
fn nonce_queue_key(rpc: &str, account_id: &[u8; 32]) -> String {
let mut input = Vec::with_capacity(rpc.len() + account_id.len() + 1);
input.extend_from_slice(rpc.trim_end_matches('/').as_bytes());
input.push(b':');
input.extend_from_slice(account_id);
blake3::hash(&input).to_hex()[..32].to_string()
}
struct NonceQueueLock {
path: std::path::PathBuf,
}
impl Drop for NonceQueueLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn acquire_nonce_queue_lock(path: std::path::PathBuf) -> Result<NonceQueueLock, String> {
let stale_after = Duration::from_secs(120);
let start = std::time::Instant::now();
loop {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write;
let _ = writeln!(file, "{}", std::process::id());
return Ok(NonceQueueLock { path });
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
let stale = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.map(|age| age > stale_after)
.unwrap_or(false);
if stale {
let _ = std::fs::remove_file(&path);
continue;
}
if start.elapsed() > Duration::from_secs(20) {
return Err(format!(
"Timed out waiting for nonce queue lock {}",
path.display()
));
}
std::thread::sleep(Duration::from_millis(25));
}
Err(err) => return Err(format!("Failed to acquire nonce queue lock: {err}")),
}
}
}
fn read_queued_next_nonce(path: &std::path::Path) -> Option<u64> {
std::fs::read_to_string(path)
.ok()?
.trim()
.parse::<u64>()
.ok()
}
fn write_queued_next_nonce(path: &std::path::Path, next_nonce: u64) -> Result<(), String> {
let tmp = path.with_extension("tmp");
std::fs::write(
&tmp,
format!(
"{}
",
next_nonce
),
)
.map_err(|e| format!("Failed to write nonce queue: {e}"))?;
std::fs::rename(&tmp, path).map_err(|e| format!("Failed to commit nonce queue: {e}"))
}
fn set_nonce_queue_next(rpc: &str, account_id: &[u8; 32], next_nonce: u64) -> Result<(), String> {
let dir = nonce_queue_dir()?;
let key = nonce_queue_key(rpc, account_id);
let path = dir.join(format!("{}.next", key));
let _lock = acquire_nonce_queue_lock(dir.join(format!("{}.lock", key)))?;
write_queued_next_nonce(&path, next_nonce)
}
fn nonce_queue_wait_timeout() -> Duration {
std::env::var("AXIOM_NONCE_QUEUE_WAIT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(120))
}
fn set_nonce_queue_last_tx(
rpc: &str,
account_id: &[u8; 32],
tx_hash: &str,
nonce: u64,
) -> Result<(), String> {
let dir = nonce_queue_dir()?;
let key = nonce_queue_key(rpc, account_id);
let path = dir.join(format!("{}.last", key));
let _lock = acquire_nonce_queue_lock(dir.join(format!("{}.lock", key)))?;
std::fs::write(
&path,
format!(
"{} {}
",
tx_hash.trim(),
nonce
),
)
.map_err(|e| format!("Failed to write nonce queue last tx: {e}"))
}
fn read_nonce_queue_last_tx(path: &std::path::Path) -> Option<(String, Option<u64>)> {
let raw = std::fs::read_to_string(path).ok()?;
let mut parts = raw.trim().split_whitespace();
let hash = parts.next()?.to_string();
if hash.is_empty() {
return None;
}
let nonce = parts.next().and_then(|v| v.parse::<u64>().ok());
Some((hash, nonce))
}
fn wait_for_previous_nonce_tx(
client: &reqwest::blocking::Client,
rpc: &str,
account_id: &[u8; 32],
retries: u32,
last_path: &std::path::Path,
) -> Result<(), String> {
let Some((hash, nonce)) = read_nonce_queue_last_tx(last_path) else {
return Ok(());
};
let wait_timeout = nonce_queue_wait_timeout();
let wait_start = std::time::Instant::now();
loop {
let status = get_json(client, &format!("{}/tx/{}", rpc, hash), retries)
.ok()
.and_then(|v| v.get("status").and_then(|s| s.as_str()).map(str::to_string));
match status.as_deref() {
Some("rejected") => {
let _ = std::fs::remove_file(last_path);
return Ok(());
}
Some("confirmed") => {
if let Some(nonce) = nonce {
let chain_nonce = fetch_account_nonce(client, rpc, account_id, retries)?;
if chain_nonce >= nonce {
let _ = std::fs::remove_file(last_path);
return Ok(());
}
} else {
let _ = std::fs::remove_file(last_path);
return Ok(());
}
}
_ => {}
}
if wait_start.elapsed() >= wait_timeout {
return Err(format!(
"Previous transaction {} did not finalize and advance nonce within {}s; retry later or increase AXIOM_NONCE_QUEUE_WAIT_SECS",
hash,
wait_timeout.as_secs()
));
}
std::thread::sleep(Duration::from_millis(250));
}
}
fn next_nonce(
client: &reqwest::blocking::Client,
rpc: &str,
account_id: &[u8; 32],
retries: u32,
) -> Result<u64, String> {
let dir = nonce_queue_dir()?;
let key = nonce_queue_key(rpc, account_id);
let path = dir.join(format!("{}.next", key));
let last_path = dir.join(format!("{}.last", key));
let _lock = acquire_nonce_queue_lock(dir.join(format!("{}.lock", key)))?;
wait_for_previous_nonce_tx(client, rpc, account_id, retries, &last_path)?;
let wait_timeout = nonce_queue_wait_timeout();
let wait_start = std::time::Instant::now();
let mut chain_next = fetch_account_nonce(client, rpc, account_id, retries)?.saturating_add(1);
let queued_next = read_queued_next_nonce(&path).unwrap_or(chain_next);
if queued_next > chain_next {
while wait_start.elapsed() < wait_timeout {
std::thread::sleep(Duration::from_millis(250));
chain_next = fetch_account_nonce(client, rpc, account_id, retries)?.saturating_add(1);
if chain_next >= queued_next {
break;
}
}
}
if queued_next > chain_next && wait_start.elapsed() >= wait_timeout {
return Err(format!(
"Nonce queue is waiting for committed nonce {} but chain is still at next nonce {}; retry later or increase AXIOM_NONCE_QUEUE_WAIT_SECS",
queued_next, chain_next
));
}
let nonce = chain_next.max(queued_next);
write_queued_next_nonce(&path, nonce.saturating_add(1))?;
Ok(nonce)
}
fn json_string(value: &Value, output: OutputFormat) -> Result<String, Box<dyn std::error::Error>> {
match output {
OutputFormat::Pretty => Ok(serde_json::to_string_pretty(value)?),
OutputFormat::Json => Ok(serde_json::to_string(value)?),
}
}
fn print_human(value: &Value, indent: usize, output: OutputFormat) {
let pad = " ".repeat(indent);
match value {
Value::Object(map) => {
if map.is_empty() {
println!("{}(empty)", pad);
return;
}
for (key, val) in map {
match val {
Value::Object(_) | Value::Array(_) => {
println!("{}{}:", pad, key);
print_human(val, indent + 2, output);
}
_ => {
println!("{}{}: {}", pad, key, format_scalar(val, output));
}
}
}
}
Value::Array(items) => {
if items.is_empty() {
println!("{}(empty)", pad);
return;
}
for (idx, item) in items.iter().enumerate() {
match item {
Value::Object(_) | Value::Array(_) => {
println!("{}{}:", pad, idx);
print_human(item, indent + 2, output);
}
_ => {
println!("{}{}: {}", pad, idx, format_scalar(item, output));
}
}
}
}
_ => {
println!("{}{}", pad, format_scalar(value, output));
}
}
}
fn format_scalar(value: &Value, output: OutputFormat) -> String {
match value {
Value::Null => "null".to_string(),
Value::Bool(v) => v.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => {
if matches!(output, OutputFormat::Pretty) {
format_address(s)
} else {
s.to_string()
}
}
_ => value.to_string(),
}
}
fn format_address(value: &str) -> String {
if !is_probably_hex(value) {
return value.to_string();
}
if value.len() < 64 {
return value.to_string();
}
let prefix_len = 16.min(value.len());
let suffix_len = 8.min(value.len().saturating_sub(prefix_len));
let prefix = &value[..prefix_len];
let suffix = &value[value.len() - suffix_len..];
format!("{}...{}", prefix, suffix)
}
fn is_probably_hex(value: &str) -> bool {
!value.is_empty()
&& value
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F'))
}
fn print_output(value: &Value, output: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match output {
OutputFormat::Json => {
println!("{}", json_string(value, output)?);
}
OutputFormat::Pretty => {
print_human(value, 0, output);
}
}
Ok(())
}
fn print_balance_pretty(
balance: &Value,
tokens: Option<&Value>,
full: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let balance_trth = balance
.get("balance_trth")
.and_then(|v| v.as_str())
.unwrap_or("0 TLKD");
if !full {
println!("{}", balance_trth);
return Ok(());
}
let account_id = balance
.get("account_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let compute_escrow = balance
.get("compute_escrow_trth_formatted")
.and_then(|v| v.as_str())
.or_else(|| balance.get("compute_escrow_trth").and_then(|v| v.as_str()))
.unwrap_or("0");
let staking_balance = balance
.get("staking_balance_trth")
.and_then(|v| v.as_str())
.unwrap_or("0 TLKD");
if !account_id.is_empty() {
println!("Account: {}", format_address(account_id));
}
println!("Balance: {}", balance_trth);
println!("Compute escrow: {}", compute_escrow);
println!("staking: {}", staking_balance);
match tokens
.and_then(|v| v.get("balances"))
.and_then(|v| v.as_array())
{
Some(list) if !list.is_empty() => {
println!("Token balances:");
for entry in list {
let cell_id = entry.get("cell_id").and_then(|v| v.as_str()).unwrap_or("");
let amount = entry.get("balance").and_then(|v| v.as_str()).unwrap_or("0");
let formatted = entry.get("balance_formatted").and_then(|v| v.as_str());
if let Some(token) = entry.get("token") {
let name = token
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
let symbol = token.get("symbol").and_then(|v| v.as_str()).unwrap_or("");
if let Some(pretty) = formatted {
println!(
"{} {} ({}) {}",
format_address(cell_id),
name,
symbol,
pretty
);
} else {
println!(
"{} {} ({}) {}",
format_address(cell_id),
name,
symbol,
amount
);
}
} else if let Some(pretty) = formatted {
println!("{} {}", format_address(cell_id), pretty);
} else {
println!("{} {}", format_address(cell_id), amount);
}
}
}
_ => {
println!("Token balances: none");
}
}
Ok(())
}
fn write_embedded_dir(
dir: &Dir,
target: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
for subdir in dir.dirs() {
write_embedded_dir(subdir, target)?;
}
for file in dir.files() {
let mut relative_path = file.path().to_path_buf();
if relative_path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml.tmpl") {
relative_path.set_file_name("Cargo.toml");
}
let out_path = target.join(relative_path);
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out_path, file.contents())?;
}
Ok(())
}
fn parse_call_chain_json(
calls: &str,
) -> Result<Vec<pq_execution::CellCall>, Box<dyn std::error::Error>> {
let value: Value = serde_json::from_str(calls)?;
let calls_arr = value.as_array().ok_or("call-chain JSON must be an array")?;
if calls_arr.len() > constants::MAX_CALL_CHAIN_CALLS {
return Err(format!(
"call-chain has {} calls, max allowed is {}",
calls_arr.len(),
constants::MAX_CALL_CHAIN_CALLS
)
.into());
}
let mut cell_calls = Vec::with_capacity(calls_arr.len());
let allowed_fields: HashSet<&str> = ["cell", "calldata", "value", "use_result_from"]
.into_iter()
.collect();
let mut total_calldata = 0usize;
for (idx, call) in calls_arr.iter().enumerate() {
let obj = call.as_object().ok_or("each call must be a JSON object")?;
for key in obj.keys() {
if !allowed_fields.contains(key.as_str()) {
return Err(format!("call {} has unknown field '{}'", idx, key).into());
}
}
let cell_hex = obj
.get("cell")
.and_then(|v| v.as_str())
.ok_or("Missing cell")?;
let cell_bytes = hex::decode(cell_hex)?;
if cell_bytes.len() != 32 {
return Err(format!(
"cell must be 32 bytes (64 hex chars), got {}",
cell_hex.len()
)
.into());
}
let mut cell_arr = [0u8; 32];
cell_arr.copy_from_slice(&cell_bytes);
let calldata_hex = obj
.get("calldata")
.and_then(|v| v.as_str())
.ok_or("Missing calldata")?;
let calldata = hex::decode(calldata_hex)?;
if calldata.len() > constants::MAX_CALLDATA_SIZE {
return Err(format!(
"calldata too large: {} bytes (max: {})",
calldata.len(),
constants::MAX_CALLDATA_SIZE
)
.into());
}
total_calldata = total_calldata.saturating_add(calldata.len());
if total_calldata > constants::MAX_CALL_CHAIN_TOTAL_CALLDATA {
return Err(format!(
"call-chain total calldata too large: {} bytes (max: {})",
total_calldata,
constants::MAX_CALL_CHAIN_TOTAL_CALLDATA
)
.into());
}
let value = match obj.get("value") {
Some(v) => v.as_u64().ok_or("value must be a non-negative integer")? as u128,
None => 0,
};
let use_result_from = match obj.get("use_result_from") {
Some(v) => {
let idx_val = v
.as_u64()
.ok_or("use_result_from must be a non-negative integer")?
as usize;
if idx_val >= idx {
return Err(format!(
"use_result_from must reference a prior call ({} >= {})",
idx_val, idx
)
.into());
}
Some(idx_val)
}
None => None,
};
cell_calls.push(pq_execution::CellCall {
cell_id: cell_arr,
calldata,
value,
use_result_from,
});
}
Ok(cell_calls)
}
const SYSTEM_CONTROLLER_GAS_LIMIT: u64 = 200_000;
const VALIDATOR_PUBKEY_LEN: usize = 1952;
fn selector_of(name: &str) -> [u8; 4] {
let mut hash: u32 = 0x811c9dc5;
for b in name.as_bytes() {
hash ^= *b as u32;
hash = hash.wrapping_mul(0x01000193);
}
hash.to_le_bytes()
}
fn encode_name_registry_propose(
name: &str,
target: [u8; 32],
owner: [u8; 32],
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let name_bytes = name.as_bytes();
if name_bytes.len() > u8::MAX as usize {
return Err("name too long (max 255 bytes)".into());
}
let mut calldata = Vec::with_capacity(4 + 1 + name_bytes.len() + 64);
calldata.extend_from_slice(&selector_of("propose_name"));
calldata.push(name_bytes.len() as u8);
calldata.extend_from_slice(name_bytes);
calldata.extend_from_slice(&target);
calldata.extend_from_slice(&owner);
Ok(calldata)
}
fn encode_name_registry_vote(
name: &str,
approve: bool,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let name_bytes = name.as_bytes();
if name_bytes.len() > u8::MAX as usize {
return Err("name too long (max 255 bytes)".into());
}
let mut calldata = Vec::with_capacity(4 + 1 + name_bytes.len() + 1);
calldata.extend_from_slice(&selector_of("vote_name"));
calldata.push(name_bytes.len() as u8);
calldata.extend_from_slice(name_bytes);
calldata.push(if approve { 1 } else { 0 });
Ok(calldata)
}
fn encode_name_registry_renew(name: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let name_bytes = name.as_bytes();
if name_bytes.len() > u8::MAX as usize {
return Err("name too long (max 255 bytes)".into());
}
let mut calldata = Vec::with_capacity(4 + 1 + name_bytes.len());
calldata.extend_from_slice(&selector_of("renew_name"));
calldata.push(name_bytes.len() as u8);
calldata.extend_from_slice(name_bytes);
Ok(calldata)
}
fn encode_name_registry_transfer(
name: &str,
new_owner: [u8; 32],
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let name_bytes = name.as_bytes();
if name_bytes.len() > u8::MAX as usize {
return Err("name too long (max 255 bytes)".into());
}
let mut calldata = Vec::with_capacity(4 + 1 + name_bytes.len() + 32);
calldata.extend_from_slice(&selector_of("transfer_name"));
calldata.push(name_bytes.len() as u8);
calldata.extend_from_slice(name_bytes);
calldata.extend_from_slice(&new_owner);
Ok(calldata)
}
fn encode_token_authority_propose(
token_cell: [u8; 32],
set_mint: bool,
mint_authority: [u8; 32],
set_freeze: bool,
freeze_authority: [u8; 32],
voting_period_blocks: u64,
) -> Vec<u8> {
let mut calldata = Vec::with_capacity(4 + 32 + 1 + 32 + 1 + 32 + 8);
calldata.extend_from_slice(&selector_of("propose_authority"));
calldata.extend_from_slice(&token_cell);
calldata.push(if set_mint { 1 } else { 0 });
calldata.extend_from_slice(&mint_authority);
calldata.push(if set_freeze { 1 } else { 0 });
calldata.extend_from_slice(&freeze_authority);
calldata.extend_from_slice(&voting_period_blocks.to_le_bytes());
calldata
}
fn encode_token_authority_vote(token_cell: [u8; 32], approve: bool) -> Vec<u8> {
let mut calldata = Vec::with_capacity(4 + 32 + 1);
calldata.extend_from_slice(&selector_of("vote_authority"));
calldata.extend_from_slice(&token_cell);
calldata.push(if approve { 1 } else { 0 });
calldata
}
fn encode_staking_stake(pubkey: &[u8], amount: u64) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
if pubkey.len() != VALIDATOR_PUBKEY_LEN {
return Err("validator pubkey must be 1952 bytes".into());
}
let mut calldata = Vec::with_capacity(4 + VALIDATOR_PUBKEY_LEN + 8);
calldata.extend_from_slice(&selector_of("stake"));
calldata.extend_from_slice(pubkey);
calldata.extend_from_slice(&amount.to_le_bytes());
Ok(calldata)
}
fn encode_staking_unstake(
pubkey: &[u8],
amount: u64,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
if pubkey.len() != VALIDATOR_PUBKEY_LEN {
return Err("validator pubkey must be 1952 bytes".into());
}
let mut calldata = Vec::with_capacity(4 + VALIDATOR_PUBKEY_LEN + 8);
calldata.extend_from_slice(&selector_of("unstake"));
calldata.extend_from_slice(pubkey);
calldata.extend_from_slice(&amount.to_le_bytes());
Ok(calldata)
}
fn encode_staking_withdraw(pubkey: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
if pubkey.len() != VALIDATOR_PUBKEY_LEN {
return Err("validator pubkey must be 1952 bytes".into());
}
let mut calldata = Vec::with_capacity(4 + VALIDATOR_PUBKEY_LEN);
calldata.extend_from_slice(&selector_of("withdraw"));
calldata.extend_from_slice(pubkey);
Ok(calldata)
}
fn encode_staking_unjail(pubkey: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
if pubkey.len() != VALIDATOR_PUBKEY_LEN {
return Err("validator pubkey must be 1952 bytes".into());
}
let mut calldata = Vec::with_capacity(4 + VALIDATOR_PUBKEY_LEN);
calldata.extend_from_slice(&selector_of("unjail"));
calldata.extend_from_slice(pubkey);
Ok(calldata)
}
fn encode_staking_lock(owner: [u8; 32], lock_blocks: u64) -> Vec<u8> {
let mut calldata = Vec::with_capacity(4 + 32 + 8);
calldata.extend_from_slice(&selector_of("lock"));
calldata.extend_from_slice(&owner);
calldata.extend_from_slice(&lock_blocks.to_le_bytes());
calldata
}
fn encode_staking_extend(owner: [u8; 32], lock_blocks: u64) -> Vec<u8> {
let mut calldata = Vec::with_capacity(4 + 32 + 8);
calldata.extend_from_slice(&selector_of("extend"));
calldata.extend_from_slice(&owner);
calldata.extend_from_slice(&lock_blocks.to_le_bytes());
calldata
}
fn encode_staking_unlock(owner: [u8; 32]) -> Vec<u8> {
let mut calldata = Vec::with_capacity(4 + 32);
calldata.extend_from_slice(&selector_of("unlock"));
calldata.extend_from_slice(&owner);
calldata
}
fn encode_treasury_propose(
proposal_id: [u8; 32],
recipient: [u8; 32],
amount: u128,
timelock_blocks: u64,
) -> Vec<u8> {
let mut calldata = Vec::with_capacity(4 + 32 + 32 + 16 + 8);
calldata.extend_from_slice(&selector_of("propose_spend"));
calldata.extend_from_slice(&proposal_id);
calldata.extend_from_slice(&recipient);
calldata.extend_from_slice(&amount.to_le_bytes());
calldata.extend_from_slice(&timelock_blocks.to_le_bytes());
calldata
}
fn encode_treasury_vote(proposal_id: [u8; 32], approve: bool) -> Vec<u8> {
let mut calldata = Vec::with_capacity(4 + 32 + 1);
calldata.extend_from_slice(&selector_of("vote_spend"));
calldata.extend_from_slice(&proposal_id);
calldata.push(if approve { 1 } else { 0 });
calldata
}
fn encode_treasury_execute(proposal_id: [u8; 32]) -> Vec<u8> {
let mut calldata = Vec::with_capacity(4 + 32);
calldata.extend_from_slice(&selector_of("execute_spend"));
calldata.extend_from_slice(&proposal_id);
calldata
}
fn parse_account_id_hex(value: &str) -> Result<[u8; 32], Box<dyn std::error::Error>> {
let bytes = hex::decode(value)?;
if bytes.len() != 32 {
return Err("account_id must be 32 bytes (64 hex chars)".into());
}
let mut out = [0u8; 32];
out.copy_from_slice(&bytes);
Ok(out)
}
fn encode_staking_delegate_add(delegate: [u8; 32]) -> Vec<u8> {
let mut calldata = Vec::with_capacity(4 + 32);
calldata.extend_from_slice(&selector_of("delegate_add"));
calldata.extend_from_slice(&delegate);
calldata
}
fn encode_staking_delegate_remove(delegate: [u8; 32]) -> Vec<u8> {
let mut calldata = Vec::with_capacity(4 + 32);
calldata.extend_from_slice(&selector_of("delegate_remove"));
calldata.extend_from_slice(&delegate);
calldata
}
fn encode_staking_stake_for(
owner: [u8; 32],
pubkey: &[u8],
amount: u64,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
if pubkey.len() != VALIDATOR_PUBKEY_LEN {
return Err("validator pubkey must be 1952 bytes".into());
}
let mut calldata = Vec::with_capacity(4 + 32 + VALIDATOR_PUBKEY_LEN + 8);
calldata.extend_from_slice(&selector_of("stake_for"));
calldata.extend_from_slice(&owner);
calldata.extend_from_slice(pubkey);
calldata.extend_from_slice(&amount.to_le_bytes());
Ok(calldata)
}
fn encode_staking_unstake_for(
owner: [u8; 32],
pubkey: &[u8],
amount: u64,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
if pubkey.len() != VALIDATOR_PUBKEY_LEN {
return Err("validator pubkey must be 1952 bytes".into());
}
let mut calldata = Vec::with_capacity(4 + 32 + VALIDATOR_PUBKEY_LEN + 8);
calldata.extend_from_slice(&selector_of("unstake_for"));
calldata.extend_from_slice(&owner);
calldata.extend_from_slice(pubkey);
calldata.extend_from_slice(&amount.to_le_bytes());
Ok(calldata)
}
fn encode_staking_withdraw_for(
owner: [u8; 32],
pubkey: &[u8],
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
if pubkey.len() != VALIDATOR_PUBKEY_LEN {
return Err("validator pubkey must be 1952 bytes".into());
}
let mut calldata = Vec::with_capacity(4 + 32 + VALIDATOR_PUBKEY_LEN);
calldata.extend_from_slice(&selector_of("withdraw_for"));
calldata.extend_from_slice(&owner);
calldata.extend_from_slice(pubkey);
Ok(calldata)
}
fn encode_staking_unjail_for(
owner: [u8; 32],
pubkey: &[u8],
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
if pubkey.len() != VALIDATOR_PUBKEY_LEN {
return Err("validator pubkey must be 1952 bytes".into());
}
let mut calldata = Vec::with_capacity(4 + 32 + VALIDATOR_PUBKEY_LEN);
calldata.extend_from_slice(&selector_of("unjail_for"));
calldata.extend_from_slice(&owner);
calldata.extend_from_slice(pubkey);
Ok(calldata)
}
fn parse_manifest_slots(
manifest: &serde_json::Value,
field: &str,
) -> Result<Vec<[u8; 32]>, Box<dyn std::error::Error>> {
manifest[field]
.as_array()
.ok_or_else(|| format!("Missing {} in manifest", field))?
.iter()
.map(|v| {
let hex = v
.as_str()
.ok_or_else(|| format!("Invalid {} entry", field))?;
let bytes = hex::decode(hex)?;
if bytes.len() != 32 {
return Err(format!("{} entry must be 32 bytes", field).into());
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(arr)
})
.collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()
}
fn load_manifest_sets(
manifest_path: &str,
) -> Result<
(
Vec<[u8; 32]>,
Vec<[u8; 32]>,
Vec<[u8; 32]>,
Vec<truthlinked_core::cells::StorageKeySpec>,
Vec<[u8; 32]>,
),
Box<dyn std::error::Error>,
> {
let manifest_json = std::fs::read_to_string(manifest_path)?;
load_manifest_sets_from_json(&manifest_json)
}
fn parse_manifest_specs(
manifest: &serde_json::Value,
) -> Result<Vec<truthlinked_core::cells::StorageKeySpec>, Box<dyn std::error::Error>> {
let specs = manifest
.get("storage_key_specs")
.and_then(|v| v.as_array())
.ok_or("Missing storage_key_specs in manifest")?;
let mut out = Vec::new();
for item in specs {
let offset = item
.get("offset")
.and_then(|v| v.as_u64())
.ok_or("Invalid storage_key_specs.offset")?;
let len = item
.get("len")
.and_then(|v| v.as_u64())
.ok_or("Invalid storage_key_specs.len")?;
out.push(truthlinked_core::cells::StorageKeySpec {
offset: offset as usize,
len: len as usize,
});
}
Ok(out)
}
fn parse_manifest_schema_ids(
manifest: &serde_json::Value,
) -> Result<Vec<[u8; 32]>, Box<dyn std::error::Error>> {
let empty = Vec::new();
let arr = manifest
.get("oracle_schema_ids")
.and_then(|v| v.as_array())
.unwrap_or(&empty);
let mut out = Vec::new();
for item in arr {
let hex = item
.as_str()
.ok_or_else(|| "Invalid oracle_schema_ids entry".to_string())?;
let bytes = hex::decode(hex)?;
if bytes.len() != 32 {
return Err("oracle_schema_ids entry must be 32 bytes".into());
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
out.push(arr);
}
Ok(out)
}
fn load_manifest_sets_from_json(
manifest_json: &str,
) -> Result<
(
Vec<[u8; 32]>,
Vec<[u8; 32]>,
Vec<[u8; 32]>,
Vec<truthlinked_core::cells::StorageKeySpec>,
Vec<[u8; 32]>,
),
Box<dyn std::error::Error>,
> {
let manifest: serde_json::Value = serde_json::from_str(manifest_json)?;
let declared_reads = parse_manifest_slots(&manifest, "declared_reads")?;
let declared_writes = parse_manifest_slots(&manifest, "declared_writes")?;
let commutative_keys = parse_manifest_slots(&manifest, "commutative_keys")?;
let storage_key_specs = parse_manifest_specs(&manifest)?;
let oracle_schema_ids = parse_manifest_schema_ids(&manifest)?;
Ok((
declared_reads,
declared_writes,
commutative_keys,
storage_key_specs,
oracle_schema_ids,
))
}
#[allow(dead_code)]
fn encode_uleb_u32(mut value: u32, out: &mut Vec<u8>) {
loop {
let byte = (value & 0x7f) as u8;
value >>= 7;
if value == 0 {
out.push(byte);
break;
} else {
out.push(byte | 0x80);
}
}
}
fn parse_package_name(cargo_toml: &std::path::Path) -> Result<String, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(cargo_toml)?;
let parsed: toml::Value = toml::from_str(&content)?;
let name = parsed
.get("package")
.and_then(|pkg| pkg.get("name"))
.and_then(|v| v.as_str())
.ok_or("Could not parse package.name from Cargo.toml")?;
Ok(name.to_string())
}
fn sdk_build_project(
project_path: &str,
output: Option<&str>,
) -> Result<(String, String), Box<dyn std::error::Error>> {
let root = std::path::Path::new(project_path);
let lib_source = root.join("src/lib.rs");
let main_source = root.join("src/main.rs");
let source = if lib_source.exists() {
lib_source
} else if main_source.exists() {
main_source
} else {
return Err(format!(
"SDK source not found: expected {} or {}",
root.join("src/lib.rs").display(),
root.join("src/main.rs").display()
)
.into());
};
let _ = sdk_generate_manifest(root);
let output_base = if let Some(explicit) = output {
explicit.to_string()
} else {
let build_dir = root.join("build");
std::fs::create_dir_all(&build_dir)?;
build_dir.join("cell").to_string_lossy().to_string()
};
build_cell(source.to_string_lossy().as_ref(), Some(&output_base))
}
fn sdk_generate_manifest(root: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
use std::process::Command;
let manifest_bin = root.join("src/bin/manifest.rs");
if !manifest_bin.exists() {
return Ok(());
}
let out_path = root.join("manifest.auto.json");
let status = Command::new(resolve_cargo_binary())
.args(&["run", "--quiet", "--bin", "manifest", "--release"])
.current_dir(root)
.env(
"TRUTHLINKED_MANIFEST_OUT",
out_path.to_string_lossy().to_string(),
)
.status()?;
if !status.success() {
eprintln!(" Warning: manifest auto-generation failed; continuing with fallback.");
return Ok(());
}
let _ = sdk_augment_manifest_from_source(root, &out_path);
Ok(())
}
fn sdk_augment_manifest_from_source(
root: &std::path::Path,
manifest_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let src_root = root.join("src");
if !src_root.exists() || !manifest_path.exists() {
return Ok(());
}
let sources = read_project_sources(&src_root)?;
if sources.is_empty() {
return Ok(());
}
let joined = sources.join("\n");
let consts = parse_const_offsets(&joined);
let mut specs = Vec::new();
let var_offsets = collect_var_key_bindings(&joined, &consts);
for prefix in [
"abi::read_account",
"abi::read_account_id",
"abi::read_bytes32",
"truthlinked_sdk::abi::read_account",
"crate::abi::read_account",
] {
collect_key_specs(&joined, prefix, &consts, &mut specs);
}
collect_key_specs_from_storage_calls(&joined, &consts, &var_offsets, &mut specs);
if specs.is_empty() {
return Ok(());
}
let manifest_json = std::fs::read_to_string(manifest_path)?;
let mut manifest: serde_json::Value = serde_json::from_str(&manifest_json)?;
let array = manifest
.get_mut("storage_key_specs")
.and_then(|v| v.as_array_mut())
.ok_or("manifest storage_key_specs missing or not array")?;
let mut existing = std::collections::HashSet::new();
for item in array.iter() {
if let (Some(offset), Some(len)) = (item.get("offset"), item.get("len")) {
if let (Some(offset), Some(len)) = (offset.as_u64(), len.as_u64()) {
existing.insert((offset as usize, len as usize));
}
}
}
for (offset, len) in specs {
if existing.insert((offset, len)) {
array.push(serde_json::json!({ "offset": offset, "len": len }));
}
}
array.sort_by(|a, b| {
let ao = a.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
let bo = b.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
let al = a.get("len").and_then(|v| v.as_u64()).unwrap_or(0);
let bl = b.get("len").and_then(|v| v.as_u64()).unwrap_or(0);
ao.cmp(&bo).then(al.cmp(&bl))
});
std::fs::write(manifest_path, serde_json::to_string_pretty(&manifest)?)?;
Ok(())
}
fn read_project_sources(root: &std::path::Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
fn visit(
dir: &std::path::Path,
out: &mut Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
visit(&path, out)?;
} else if let Some(ext) = path.extension() {
if ext == "rs" {
if let Ok(text) = std::fs::read_to_string(&path) {
out.push(text);
}
}
}
}
Ok(())
}
let mut out = Vec::new();
visit(root, &mut out)?;
Ok(out)
}
#[derive(Debug)]
enum RecipientSpec {
Name(String),
AccountId([u8; 32]),
Pubkey(Vec<u8>),
}
fn is_valid_name(name: &str) -> bool {
if !name.is_ascii() {
return false;
}
let lower = name.to_ascii_lowercase();
if lower != name {
return false;
}
if !name.ends_with(".tl") {
return false;
}
let root = &name[..name.len().saturating_sub(3)];
if root.is_empty() || root.starts_with('.') || root.ends_with('.') {
return false;
}
let mut letters = 0usize;
let mut prev_dot = false;
for ch in root.chars() {
if ch == '.' {
if prev_dot {
return false;
}
prev_dot = true;
continue;
}
if !(ch >= 'a' && ch <= 'z') {
return false;
}
letters += 1;
prev_dot = false;
}
letters > 0 && letters <= 12
}
fn parse_recipient_spec(input: &str) -> Result<RecipientSpec, Box<dyn std::error::Error>> {
if input.ends_with(".tl") {
if !is_valid_name(input) {
return Err("Invalid name format (lowercase .tl, 12 letters max, dots allowed)".into());
}
return Ok(RecipientSpec::Name(input.to_string()));
}
let bytes = hex::decode(input)?;
if bytes.len() == 32 {
let mut id = [0u8; 32];
id.copy_from_slice(&bytes);
return Ok(RecipientSpec::AccountId(id));
}
if bytes.len() == 1952 {
return Ok(RecipientSpec::Pubkey(bytes));
}
Err("Recipient must be a .tl name, 64-hex account ID, or 3904-hex pubkey".into())
}
fn parse_const_offsets(source: &str) -> std::collections::HashMap<String, usize> {
use regex::Regex;
let mut map = std::collections::HashMap::new();
let mut raw_map = std::collections::HashMap::new();
let re = Regex::new(r"(?m)^\s*(?:pub\s+)?(?:const|static)\s+([A-Z0-9_]+)\s*:\s*(?:usize|u32|u64)\s*=\s*([^;]+);").ok();
if let Some(re) = re {
for caps in re.captures_iter(source) {
let name = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
let raw = caps
.get(2)
.map(|m| m.as_str())
.unwrap_or("")
.trim()
.to_string();
raw_map.insert(name, raw);
}
}
for _ in 0..4 {
let mut progressed = false;
for (name, raw) in raw_map.iter() {
if map.contains_key(name) {
continue;
}
if let Some(value) = resolve_offset_expr(raw, &map) {
map.insert(name.clone(), value);
progressed = true;
}
}
if !progressed {
break;
}
}
map
}
fn parse_usize_literal(raw: &str) -> Option<usize> {
let s = raw.replace('_', "");
if let Some(hex) = s.strip_prefix("0x") {
usize::from_str_radix(hex, 16).ok()
} else {
s.parse::<usize>().ok()
}
}
fn resolve_offset_expr(
expr: &str,
consts: &std::collections::HashMap<String, usize>,
) -> Option<usize> {
let mut cleaned = expr.replace(' ', "");
while cleaned.starts_with('(') && cleaned.ends_with(')') && cleaned.len() > 2 {
cleaned = cleaned[1..cleaned.len() - 1].to_string();
}
if let Some(v) = parse_usize_literal(&cleaned) {
return Some(v);
}
if let Some(v) = consts.get(&cleaned) {
return Some(*v);
}
if let Some((a, b)) = cleaned.split_once('+') {
let left = resolve_offset_expr(a, consts)?;
let right = resolve_offset_expr(b, consts)?;
return left.checked_add(right);
}
None
}
fn collect_key_specs(
source: &str,
fn_prefix: &str,
consts: &std::collections::HashMap<String, usize>,
out: &mut Vec<(usize, usize)>,
) {
use regex::Regex;
let pattern = format!(
r"(?s){}\s*\(\s*[^,]+,\s*([A-Za-z0-9_+\s]+)\s*\)",
regex::escape(fn_prefix)
);
let re = match Regex::new(&pattern) {
Ok(v) => v,
Err(_) => return,
};
for caps in re.captures_iter(source) {
let expr = caps.get(1).map(|m| m.as_str()).unwrap_or("");
if let Some(offset) = resolve_offset_expr(expr, consts) {
out.push((offset, 32));
}
}
}
fn collect_var_key_bindings(
source: &str,
consts: &std::collections::HashMap<String, usize>,
) -> std::collections::HashMap<String, usize> {
use regex::Regex;
let mut out = std::collections::HashMap::new();
let prefixes = [
"abi::read_account",
"abi::read_account_id",
"abi::read_bytes32",
"truthlinked_sdk::abi::read_account",
"crate::abi::read_account",
];
for prefix in prefixes {
let pattern = format!(
r"(?s)let\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*{}\s*\(\s*[^,]+,\s*([A-Za-z0-9_+\s]+)\s*\)",
regex::escape(prefix)
);
let re = match Regex::new(&pattern) {
Ok(v) => v,
Err(_) => continue,
};
for caps in re.captures_iter(source) {
let name = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
let expr = caps.get(2).map(|m| m.as_str()).unwrap_or("");
if let Some(offset) = resolve_offset_expr(expr, consts) {
out.insert(name, offset);
}
}
}
out
}
fn normalize_key_expr(expr: &str) -> String {
let mut s = expr.trim().to_string();
if let Some(stripped) = s.strip_prefix('&') {
s = stripped.trim().to_string();
}
for suffix in [".as_bytes()", ".as_ref()", ".as_slice()"] {
if let Some(stripped) = s.strip_suffix(suffix) {
s = stripped.trim().to_string();
}
}
if s.starts_with('(') && s.ends_with(')') && s.len() > 2 {
s = s[1..s.len() - 1].trim().to_string();
}
s
}
fn collect_key_specs_from_storage_calls(
source: &str,
consts: &std::collections::HashMap<String, usize>,
var_offsets: &std::collections::HashMap<String, usize>,
out: &mut Vec<(usize, usize)>,
) {
use regex::Regex;
let patterns = [
r"(?s)storage::slot_for\s*\(\s*[^,]+,\s*([^\)]+)\)",
r"(?s)hashing::derive_slot\s*\(\s*[^,]+,\s*&?\s*\[\s*([^\]]+)\]\s*\)",
r"(?s)hashing::derive_slot\s*\(\s*[^,]+,\s*&?\s*vec!\s*\[\s*([^\]]+)\]\s*\)",
r"(?s)hashing::derive_slot\s*\(\s*[^,]+,\s*vec!\s*\[\s*([^\]]+)\]\s*\)",
r"(?s)Slot::derived\s*\(\s*[^,]+,\s*&?\s*\[\s*([^\]]+)\]\s*\)",
r"(?s)Slot::derived\s*\(\s*[^,]+,\s*&?\s*vec!\s*\[\s*([^\]]+)\]\s*\)",
r"(?s)storage::Slot::derived\s*\(\s*[^,]+,\s*&?\s*\[\s*([^\]]+)\]\s*\)",
r"(?s)storage::Slot::derived\s*\(\s*[^,]+,\s*&?\s*vec!\s*\[\s*([^\]]+)\]\s*\)",
r"(?s)\.\s*(?:get|insert|remove|contains)_typed_key\s*\(\s*([^\),]+)",
r"(?s)\.\s*(?:get|insert|remove|contains)_key\s*\(\s*([^\),]+)",
r"(?s)\.\s*slots_for_key\s*\(\s*([^\)]+)\)",
];
for pat in patterns {
let re = match Regex::new(pat) {
Ok(v) => v,
Err(_) => continue,
};
for caps in re.captures_iter(source) {
let raw = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let mut expr = normalize_key_expr(raw);
expr = extract_key_expr_from_list(&expr);
for prefix in [
"abi::read_account",
"abi::read_account_id",
"abi::read_bytes32",
"truthlinked_sdk::abi::read_account",
"crate::abi::read_account",
] {
if expr.starts_with(prefix) {
if let Some(idx) = expr.find(',') {
let after = &expr[idx + 1..];
if let Some(end) = after.find(')') {
let offset_expr = after[..end].trim();
if let Some(offset) = resolve_offset_expr(offset_expr, consts) {
out.push((offset, 32));
}
}
}
}
}
if let Some(offset) = var_offsets.get(&expr) {
out.push((*offset, 32));
}
}
}
}
fn extract_key_expr_from_list(expr: &str) -> String {
if expr.contains(',') {
let mut parts = expr.split(',');
let mut last = "";
for part in parts.by_ref() {
last = part;
}
return normalize_key_expr(last);
}
expr.to_string()
}
fn sdk_locate_axiom(project_path: &str) -> Result<String, Box<dyn std::error::Error>> {
let root = std::path::Path::new(project_path);
let build_candidate = root.join("build/cell.axiom");
if build_candidate.exists() {
return Ok(build_candidate.to_string_lossy().to_string());
}
let cargo_toml = root.join("Cargo.toml");
if !cargo_toml.exists() {
return Err(format!("Cargo.toml not found in {}", root.display()).into());
}
let crate_name = parse_package_name(&cargo_toml)?.replace('-', "_");
let release_candidate = root
.join("target/truthlinked-cells/release")
.join(format!("{}.axiom", crate_name));
if release_candidate.exists() {
return Ok(release_candidate.to_string_lossy().to_string());
}
Err(format!(
"No axiom artifact found for SDK project {}. Run `axiom sdk-build --path {}` first.",
root.display(),
project_path
)
.into())
}
fn resolve_manifest_path(axiom_path: &str, manifest_override: Option<String>) -> Option<String> {
if manifest_override.is_some() {
return manifest_override;
}
let inferred = axiom_path.replace(".axiom", ".manifest.json");
if std::path::Path::new(&inferred).exists() {
Some(inferred)
} else {
None
}
}
fn submit_cell_deploy(
client: &reqwest::blocking::Client,
rpc: &str,
from: &str,
cell_id: &str,
axiom_path: &str,
manifest_path: Option<String>,
initial_balance: u64,
output: OutputFormat,
retries: u32,
) -> Result<(), Box<dyn std::error::Error>> {
let sender_keys = DualKeypair::load(from)?;
let pubkey = sender_keys.dilithium_pk.clone().into_bytes();
let sender_account_id = truthlinked_core::pq_identity::account_id_from_pubkey(&pubkey);
let cell_id_bytes = hex::decode(cell_id)?;
if cell_id_bytes.len() != 32 {
return Err("cell_id must be 32 bytes hex".into());
}
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let bytecode = std::fs::read(axiom_path)?;
let (declared_reads, declared_writes, commutative_keys, storage_key_specs, oracle_schema_ids) =
if let Some(manifest_path) = manifest_path {
let (reads, writes, commutative, specs, schema_ids) =
load_manifest_sets(&manifest_path)?;
truthlinked_core::cells::CellAccount::verify_manifest_against_bytecode(
&bytecode, &reads, &writes, &specs,
)?;
eprintln!(" Manifest verified locally: {}", manifest_path);
(reads, writes, commutative, specs, schema_ids)
} else {
let analysis = truthlinked_core::cells::CellAccount::analyze_bytecode(&bytecode)
.map_err(|e| format!("Axiom static analysis failed: {}", e))?;
if !analysis.fully_resolved {
eprintln!(" Warning: Some storage keys are dynamic. Provide --manifest-file for full conflict detection.");
}
(
analysis.static_read_slots,
analysis.static_write_slots,
vec![],
vec![],
vec![],
)
};
let genesis_hash = fetch_genesis_hash(client, rpc, retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(client, rpc, &sender_account_id, retries)?;
let tx = Transaction {
sender: sender_account_id,
intent: TransactionIntent::DeployCell {
cell_id: cell_id_arr,
bytecode,
initial_balance: initial_balance as u128,
declared_reads,
declared_writes,
commutative_keys,
storage_key_specs,
oracle_schema_ids,
},
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
eprintln!("Submitting cell deployment...");
let res: Value = post_bytes(client, &format!("{}/submit_raw", rpc), tx_bytes, retries)?;
print_output(&res, output)?;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let handle = std::thread::Builder::new()
.name("axiom-cli".to_string())
.stack_size(64 * 1024 * 1024)
.spawn(|| run_cli().map_err(|e| e.to_string()))?;
match handle.join() {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e.into()),
Err(_) => Err("axiom cli thread panicked".into()),
}
}
fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let config = load_cli_config();
let output = resolve_output(&cli);
let rpc = resolve_rpc(&cli, config.as_ref());
let client = reqwest::blocking::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(cli.timeout))
.connection_verbose(false)
.tcp_nodelay(true)
.http1_only()
.build()?;
match cli.command {
Commands::ChainInfo => {
let res = get_json(&client, &format!("{}/chain_info", rpc), cli.retries)?;
print_output(&res, output)?;
}
Commands::TokenInfo => {
let res = get_json(&client, &format!("{}/token_info", rpc), cli.retries)?;
print_output(&res, output)?;
}
Commands::NetworkInfo => {
let res = get_json(&client, &format!("{}/network_info", rpc), cli.retries)?;
print_output(&res, output)?;
}
Commands::Validators => {
let res = get_json(&client, &format!("{}/validators", rpc), cli.retries)?;
print_output(&res, output)?;
}
Commands::Mempool => {
let res = get_json(&client, &format!("{}/mempool", rpc), cli.retries)?;
print_output(&res, output)?;
}
Commands::Status { from, full } => {
let keyfile = from.unwrap_or_else(|| resolve_keyfile_path(config.as_ref()));
let (account_id, _) = load_account_id_and_keypair(&keyfile)?;
let chain = get_json(&client, &format!("{}/chain_info", rpc), cli.retries)?;
let balance = post_json(
&client,
&format!("{}/balance", rpc),
serde_json::json!({
"account_id": hex::encode(account_id),
"full": full
}),
cli.retries,
)?;
let res = serde_json::json!({
"chain": chain,
"balance": balance
});
print_output(&res, output)?;
}
Commands::Resolve { query } => {
let q = urlencoding::encode(&query);
let res = get_json(&client, &format!("{}/resolve/{}", rpc, q), cli.retries)?;
print_output(&res, output)?;
}
Commands::ListCellProposals => {
let res = get_json(&client, &format!("{}/cell_proposals", rpc), cli.retries)?;
print_output(&res, output)?;
}
Commands::TxStatus { hash } | Commands::Tx { hash } => {
let resp = client.get(format!("{}/tx/{}", rpc, hash)).send()?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
let pending = get_json(
&client,
&format!("{}/mempool/tx/{}", rpc, hash),
cli.retries,
)?;
let found = pending
.get("found")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let out = if found {
serde_json::json!({
"status": "pending",
"tx": pending.get("transaction").cloned().unwrap_or(serde_json::Value::Null)
})
} else {
serde_json::json!({
"status": "not_found",
"tx": serde_json::Value::Null
})
};
print_output(&out, output)?;
return Ok(());
}
if !resp.status().is_success() {
return Err(format!("RPC error: status {}", resp.status()).into());
}
let confirmed: Value = resp.json()?;
if let Some(err) = confirmed.get("error").filter(|v| !v.is_null()) {
return Err(format!("RPC error: {}", err).into());
}
if !confirmed.is_null() {
let out = serde_json::json!({
"status": "confirmed",
"tx": confirmed
});
print_output(&out, output)?;
} else {
let pending = get_json(
&client,
&format!("{}/mempool/tx/{}", rpc, hash),
cli.retries,
)?;
let found = pending
.get("found")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let out = if found {
serde_json::json!({
"status": "pending",
"tx": pending.get("transaction").cloned().unwrap_or(serde_json::Value::Null)
})
} else {
serde_json::json!({
"status": "not_found",
"tx": serde_json::Value::Null
})
};
print_output(&out, output)?;
}
}
Commands::Balance { account_id, full } => {
let res = post_json(
&client,
&format!("{}/balance", rpc),
serde_json::json!({"account_id": account_id}),
cli.retries,
)?;
if full {
let tokens = post_json(
&client,
&format!("{}/token_balances", rpc),
serde_json::json!({"account_id": account_id, "include_metadata": true}),
cli.retries,
)?;
if matches!(output, OutputFormat::Json) {
let out = serde_json::json!({
"account_id": res.get("account_id").cloned().unwrap_or(Value::Null),
"balance": res.get("balance").cloned().unwrap_or(Value::Null),
"balance_trth": res.get("balance_trth").cloned().unwrap_or(Value::Null),
"compute_escrow_trth": res.get("compute_escrow_trth").cloned().unwrap_or(Value::Null),
"compute_escrow_trth_formatted": res.get("compute_escrow_trth_formatted").cloned().unwrap_or(Value::Null),
"staking_balance": res.get("staking_balance").cloned().unwrap_or(Value::Null),
"staking_balance_trth": res.get("staking_balance_trth").cloned().unwrap_or(Value::Null),
"token_balances": tokens.get("balances").cloned().unwrap_or(Value::Null),
});
print_output(&out, output)?;
} else {
print_balance_pretty(&res, Some(&tokens), true)?;
}
} else if matches!(output, OutputFormat::Json) {
print_output(&res, output)?;
} else {
print_balance_pretty(&res, None, false)?;
}
}
Commands::BalanceByPubkey { pubkey, full } => {
let res = post_json(
&client,
&format!("{}/balance_by_pubkey", rpc),
serde_json::json!({"pubkey": pubkey}),
cli.retries,
)?;
if full {
let account_id = res.get("account_id").and_then(|v| v.as_str()).unwrap_or("");
let tokens: Value = if account_id.is_empty() {
serde_json::json!({ "balances": [] })
} else {
post_json(
&client,
&format!("{}/token_balances", rpc),
serde_json::json!({"account_id": account_id, "include_metadata": true}),
cli.retries,
)?
};
if matches!(output, OutputFormat::Json) {
let out = serde_json::json!({
"account_id": res.get("account_id").cloned().unwrap_or(Value::Null),
"balance": res.get("balance").cloned().unwrap_or(Value::Null),
"balance_trth": res.get("balance_trth").cloned().unwrap_or(Value::Null),
"compute_escrow_trth": res.get("compute_escrow_trth").cloned().unwrap_or(Value::Null),
"compute_escrow_trth_formatted": res.get("compute_escrow_trth_formatted").cloned().unwrap_or(Value::Null),
"staking_balance": res.get("staking_balance").cloned().unwrap_or(Value::Null),
"staking_balance_trth": res.get("staking_balance_trth").cloned().unwrap_or(Value::Null),
"token_balances": tokens.get("balances").cloned().unwrap_or(Value::Null),
});
print_output(&out, output)?;
} else {
print_balance_pretty(&res, Some(&tokens), true)?;
}
} else if matches!(output, OutputFormat::Json) {
print_output(&res, output)?;
} else {
print_balance_pretty(&res, None, false)?;
}
}
Commands::AccountId { from, pubkey } => {
let pk_bytes = if let Some(keyfile) = from {
let keypair = pq_identity::DualKeypair::load(&keyfile)?;
keypair.dilithium_pk.into_bytes().to_vec()
} else if let Some(pk_hex) = pubkey {
hex::decode(&pk_hex)?
} else {
let default_path = resolve_keyfile_path(config.as_ref());
if std::path::Path::new(&default_path).exists() {
let keypair = pq_identity::DualKeypair::load(&default_path)?;
keypair.dilithium_pk.into_bytes().to_vec()
} else {
return Err(format!("No keyfile found. Provide --from or --pubkey, or create default keyfile at {}", default_path).into());
}
};
if pk_bytes.len() != 1952 {
return Err(format!(
"Invalid public key length: {} (expected 1952 bytes)",
pk_bytes.len()
)
.into());
}
let account_id = pq_identity::account_id_from_pubkey(&pk_bytes);
if matches!(output, OutputFormat::Json) {
let out = serde_json::json!({
"account_id": hex::encode(&account_id),
"public_key": hex::encode(&pk_bytes),
});
print_output(&out, output)?;
} else {
println!("{}", format_address(&hex::encode(&account_id)));
}
}
Commands::ImportMnemonic {
mnemonic,
output: output_path,
passphrase,
} => {
let keyfile_password = passphrase.clone();
let keypair = if let Some(pass) = passphrase {
pq_identity::DualKeypair::from_mnemonic_with_passphrase(mnemonic, &pass)
} else {
pq_identity::DualKeypair::from_mnemonic(mnemonic)
};
let password = if let Some(password) = keyfile_password {
if password.len() < 8 {
return Err("Password must be at least 8 characters".into());
}
password
} else {
rpassword::prompt_password("Enter password to encrypt keyfile: ")?
};
if output_path == default_keyfile_path() {
if let Some(parent) = std::path::Path::new(&output_path).parent() {
std::fs::create_dir_all(parent)?;
}
}
keypair.save_with_password(&output_path, Some(&password))?;
let pubkey = keypair.dilithium_pk.clone().into_bytes();
let account_id = pq_identity::account_id_from_pubkey(&pubkey);
if matches!(output, OutputFormat::Json) {
let out = serde_json::json!({
"status": "imported",
"keyfile": output_path,
"account_id": hex::encode(&account_id),
"public_key": hex::encode(&pubkey),
});
print_output(&out, output)?;
} else {
println!(" Keypair imported successfully");
println!(" Keyfile: {}", output_path);
println!(
" Account ID: {}",
format_address(&hex::encode(&account_id))
);
println!(" Public Key: {}", format_address(&hex::encode(&pubkey)));
}
}
Commands::AccountCreate {
output: output_path,
encrypt,
passphrase,
} => {
let mut entropy = [0u8; 32];
rand::thread_rng().fill_bytes(&mut entropy);
let mnemonic = Mnemonic::from_entropy(&entropy)?;
let keypair = if let Some(pass) = &passphrase {
pq_identity::DualKeypair::from_mnemonic_with_passphrase(mnemonic.to_string(), pass)
} else {
pq_identity::DualKeypair::from_mnemonic(mnemonic.to_string())
};
let password = if encrypt {
let password = if let Some(password) = passphrase.clone() {
password
} else {
let password =
rpassword::prompt_password("Enter password to encrypt keyfile: ")?;
let confirm = rpassword::prompt_password("Confirm password: ")?;
if password != confirm {
return Err("Passwords do not match".into());
}
password
};
if password.len() < 8 {
return Err("Password must be at least 8 characters".into());
}
Some(password)
} else {
eprintln!(" Warning: Keyfile will be saved unencrypted");
None
};
if output_path == default_keyfile_path() {
if let Some(parent) = std::path::Path::new(&output_path).parent() {
std::fs::create_dir_all(parent)?;
}
}
keypair.save_with_password(&output_path, password.as_deref())?;
let pubkey = keypair.dilithium_pk.clone().into_bytes();
let account_id = pq_identity::account_id_from_pubkey(&pubkey);
if matches!(output, OutputFormat::Json) {
let out = serde_json::json!({
"status": "created",
"keyfile": output_path,
"account_id": hex::encode(&account_id),
"public_key": hex::encode(&pubkey),
"mnemonic": mnemonic.to_string(),
"passphrase_provided": passphrase.is_some(),
});
print_output(&out, output)?;
eprintln!(" Warning: The mnemonic was printed to stdout. Treat it as sensitive.");
} else {
println!(" Keypair created");
println!(" Keyfile: {}", output_path);
println!(
" Account ID: {}",
format_address(&hex::encode(&account_id))
);
println!(" Public Key: {}", format_address(&hex::encode(&pubkey)));
eprintln!(" Warning: The mnemonic below controls this account.");
eprintln!(" Do not paste it into terminals, logs, or CI.");
eprintln!(" Mnemonic: {}", mnemonic);
if passphrase.is_some() {
eprintln!(" Passphrase: (provided, must be remembered for recovery)");
}
}
}
Commands::Faucet { from, amount } => {
let keyfile = resolve_signing_keyfile_arg(from.as_deref(), config.as_ref())?;
let sender_keys = pq_identity::DualKeypair::load(&keyfile)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let amount_raw = parse_amount_str(&amount)?;
if amount_raw == 0 {
return Err("Amount must be > 0".into());
}
if amount_raw > constants::MAX_AIRDROP_AMOUNT {
return Err("Faucet amount exceeds devnet maximum (15,000 TLKD)".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let genesis_hash_hex = hex::encode(genesis_hash);
let is_local_rpc = rpc.contains("localhost") || rpc.contains("127.0.0.1");
if genesis_hash[0..4] != [0, 0, 0, 0] && !is_local_rpc {
return Err("Faucet is only available on devnet".into());
}
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce: u64 = rand::random();
let mut msg = Vec::new();
msg.extend_from_slice(&genesis_hash);
msg.extend_from_slice(&sender_account_id);
msg.extend_from_slice(&sender_pubkey);
msg.extend_from_slice(&amount_raw.to_le_bytes());
msg.extend_from_slice(×tamp.to_le_bytes());
msg.extend_from_slice(&nonce.to_le_bytes());
let signature = sender_keys
.dilithium_sk
.try_sign(&msg, b"truthlinked-faucet-v1")?;
let base = std::env::var("TLKD_FAUCET_URL")
.unwrap_or_else(|_| "https://faucet.truthlinked.org".to_string());
let req = serde_json::json!({
"account_id": hex::encode(&sender_account_id),
"pubkey": hex::encode(&sender_pubkey),
"amount": amount_raw.to_string(),
"amount_trth": amount,
"timestamp": timestamp,
"nonce": nonce,
"genesis_fingerprint": genesis_hash_hex,
"signature": hex::encode(&signature),
});
let parsed = post_json(&client, &format!("{}/faucet", base), req, cli.retries)?;
print_output(&parsed, output)?;
}
Commands::GenesisValidator {
keys_file,
allocation,
} => {
let allocation_raw = parse_amount_str(&allocation)?;
if allocation_raw == 0 {
return Err("Allocation must be > 0".into());
}
let keys = pq_identity::DualKeypair::load(&keys_file)?;
let pubkey = keys.dilithium_pk.clone().into_bytes().to_vec();
let account_id = pq_identity::account_id_from_pubkey(&pubkey);
let entry = serde_json::json!({
"keys_file": keys_file,
"allocation": allocation_raw,
"allocation_trth": allocation,
"account_id": hex::encode(account_id),
"public_key": hex::encode(pubkey),
});
print_output(&entry, output)?;
}
Commands::Mcp { command } => match command {
McpCommand::RegisterAgent {
from,
agent_keyfile,
policy_cell_id,
agent_registry_id,
} => {
let (sender_id, sender_keys) = load_account_id_and_keypair(&from)?;
let agent_keys = pq_identity::DualKeypair::load(&agent_keyfile)?;
let agent_pubkey = agent_keys.dilithium_pk.clone().into_bytes().to_vec();
let agent_id = pq_identity::account_id_from_pubkey(&agent_pubkey);
let policy_cell = parse_hex_32("policy_cell_id", &policy_cell_id)?;
let registry = if let Some(id) = agent_registry_id {
parse_hex_32("agent_registry_id", &id)?
} else {
truthlinked_mcp::protocol_addresses::agent_registry()
};
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let nonce = next_nonce(&client, &rpc, &sender_id, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let tx = Transaction {
sender: sender_id,
intent: TransactionIntent::RegisterAgent {
agent_id,
policy_cell_id: policy_cell,
agent_registry_id: registry,
},
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed = sender_keys.sign_transaction(&tx)?;
let bytes = postcard::to_allocvec(&signed)?;
let res: Value =
post_bytes(&client, &format!("{}/submit_raw", rpc), bytes, cli.retries)?;
print_output(&res, output)?;
}
McpCommand::RegisterTool {
from,
tool_id,
name,
category,
bytecode_file,
manifest_file,
schema_file,
registry_id,
} => {
let (sender_id, sender_keys) = load_account_id_and_keypair(&from)?;
let tool_id = parse_hex_32("tool_id", &tool_id)?;
let registry = if let Some(id) = registry_id {
parse_hex_32("registry_id", &id)?
} else {
truthlinked_mcp::protocol_addresses::mcp_registry()
};
let bytecode = load_bytes(&bytecode_file)?;
let (reads, writes, commutative, specs, schema_ids) =
load_manifest_sets(&manifest_file)?;
truthlinked_core::cells::CellAccount::verify_manifest_against_bytecode(
&bytecode, &reads, &writes, &specs,
)?;
let input_schema_json = load_bytes(&schema_file)?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let nonce = next_nonce(&client, &rpc, &sender_id, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let tx = Transaction {
sender: sender_id,
intent: TransactionIntent::RegisterMcpTool {
tool_id,
bytecode,
name,
input_schema_json,
category,
declared_reads: reads,
declared_writes: writes,
commutative_keys: commutative,
oracle_schema_ids: schema_ids,
registry_id: registry,
},
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed = sender_keys.sign_transaction(&tx)?;
let bytes = postcard::to_allocvec(&signed)?;
let res: Value =
post_bytes(&client, &format!("{}/submit_raw", rpc), bytes, cli.retries)?;
print_output(&res, output)?;
}
McpCommand::RegisterResource {
from,
resource_id,
name,
uri_scheme,
mime_type,
bytecode_file,
manifest_file,
initial_data_json,
registry_id,
} => {
let (sender_id, sender_keys) = load_account_id_and_keypair(&from)?;
let resource_id = parse_hex_32("resource_id", &resource_id)?;
let registry = if let Some(id) = registry_id {
parse_hex_32("registry_id", &id)?
} else {
truthlinked_mcp::protocol_addresses::mcp_registry()
};
let bytecode = load_optional_bytes(bytecode_file)?;
let (reads, writes, schema_ids) = if !bytecode.is_empty() {
let manifest = manifest_file
.ok_or("manifest_file required when bytecode_file is provided")?;
let (reads, writes, _commutative, specs, schema_ids) =
load_manifest_sets(&manifest)?;
truthlinked_core::cells::CellAccount::verify_manifest_against_bytecode(
&bytecode, &reads, &writes, &specs,
)?;
(reads, writes, schema_ids)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
let initial_data = if let Some(path) = initial_data_json {
let raw = std::fs::read_to_string(path)?;
let parsed: Vec<serde_json::Value> = serde_json::from_str(&raw)?;
let mut out = Vec::new();
for entry in parsed {
let k = entry
.get("key_hex")
.and_then(|v| v.as_str())
.ok_or("initial_data missing key_hex")?;
let v = entry
.get("value_hex")
.and_then(|v| v.as_str())
.ok_or("initial_data missing value_hex")?;
out.push((
parse_hex_bytes("key_hex", k)?,
parse_hex_bytes("value_hex", v)?,
));
}
out
} else {
Vec::new()
};
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let nonce = next_nonce(&client, &rpc, &sender_id, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let tx = Transaction {
sender: sender_id,
intent: TransactionIntent::RegisterMcpResource {
resource_id,
bytecode,
name,
uri_scheme,
mime_type,
initial_data,
declared_reads: reads,
declared_writes: writes,
oracle_schema_ids: schema_ids,
registry_id: registry,
},
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed = sender_keys.sign_transaction(&tx)?;
let bytes = postcard::to_allocvec(&signed)?;
let res: Value =
post_bytes(&client, &format!("{}/submit_raw", rpc), bytes, cli.retries)?;
print_output(&res, output)?;
}
McpCommand::RegisterPrompt {
from,
prompt_id,
name,
template_file,
arg,
registry_id,
} => {
let (sender_id, sender_keys) = load_account_id_and_keypair(&from)?;
let prompt_id = parse_hex_32("prompt_id", &prompt_id)?;
let registry = if let Some(id) = registry_id {
parse_hex_32("registry_id", &id)?
} else {
truthlinked_mcp::protocol_addresses::mcp_registry()
};
let template_bytes = load_bytes(&template_file)?;
let mut args = Vec::new();
for raw in arg {
let parts: Vec<&str> = raw.splitn(3, ':').collect();
if parts.len() != 3 {
return Err("arg must be name:desc:required".into());
}
let required = matches!(parts[2], "1" | "true" | "yes" | "required");
args.push((parts[0].to_string(), parts[1].to_string(), required));
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let nonce = next_nonce(&client, &rpc, &sender_id, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let tx = Transaction {
sender: sender_id,
intent: TransactionIntent::RegisterMcpPrompt {
prompt_id,
name,
template_bytes,
arguments: args,
registry_id: registry,
},
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed = sender_keys.sign_transaction(&tx)?;
let bytes = postcard::to_allocvec(&signed)?;
let res: Value =
post_bytes(&client, &format!("{}/submit_raw", rpc), bytes, cli.retries)?;
print_output(&res, output)?;
}
McpCommand::SetPolicy {
from,
policy_cell_id,
status,
allow_reads,
allow_writes,
allow_admin,
rate_limit,
spend_per_tx,
spend_epoch,
hitl_threshold,
} => {
let (sender_id, sender_keys) = load_account_id_and_keypair(&from)?;
let policy_cell = parse_hex_32("policy_cell_id", &policy_cell_id)?;
let mut calldata = Vec::with_capacity(88);
calldata.extend_from_slice(&sender_id);
calldata.push(status);
calldata.push(allow_reads);
calldata.push(allow_writes);
calldata.push(allow_admin);
calldata.extend_from_slice(&rate_limit.to_le_bytes());
calldata.extend_from_slice(&spend_per_tx.to_le_bytes());
calldata.extend_from_slice(&spend_epoch.to_le_bytes());
calldata.extend_from_slice(&hitl_threshold.to_le_bytes());
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let nonce = next_nonce(&client, &rpc, &sender_id, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let tx = Transaction {
sender: sender_id,
intent: TransactionIntent::CallCell {
cell_id: policy_cell,
calldata,
value: 0,
gas_limit: 300_000,
},
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed = sender_keys.sign_transaction(&tx)?;
let bytes = postcard::to_allocvec(&signed)?;
let res: Value =
post_bytes(&client, &format!("{}/submit_raw", rpc), bytes, cli.retries)?;
print_output(&res, output)?;
}
McpCommand::SetToolPermission {
from,
policy_cell_id,
tool_id,
enabled,
} => {
let (sender_id, sender_keys) = load_account_id_and_keypair(&from)?;
let policy_cell = parse_hex_32("policy_cell_id", &policy_cell_id)?;
let tool_id = parse_hex_32("tool_id", &tool_id)?;
let mut calldata = Vec::with_capacity(65);
calldata.extend_from_slice(&sender_id);
calldata.extend_from_slice(&tool_id);
calldata.push(enabled);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let nonce = next_nonce(&client, &rpc, &sender_id, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let tx = Transaction {
sender: sender_id,
intent: TransactionIntent::CallCell {
cell_id: policy_cell,
calldata,
value: 0,
gas_limit: 200_000,
},
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed = sender_keys.sign_transaction(&tx)?;
let bytes = postcard::to_allocvec(&signed)?;
let res: Value =
post_bytes(&client, &format!("{}/submit_raw", rpc), bytes, cli.retries)?;
print_output(&res, output)?;
}
McpCommand::PrivateBalanceInit {
from,
agent_id,
cell_id,
balance,
aes_seed_hex,
enc_nonce_hex,
commit_nonce_hex,
} => {
let (sender_id, sender_keys) = load_account_id_and_keypair(&from)?;
let agent_id = parse_hex_32("agent_id", &agent_id)?;
let cell_id = if let Some(raw) = cell_id {
parse_hex_32("cell_id", &raw)?
} else {
truthlinked_mcp::private_balance::pb_keys::cell_for_agent(&agent_id)
};
let balance = parse_amount_str(&balance)?;
let (encrypted_balance, commitment, commit_nonce) = private_balance_material(
balance,
&aes_seed_hex,
&enc_nonce_hex,
&commit_nonce_hex,
)?;
let mut res = submit_signed_intent(
&client,
&rpc,
cli.retries,
sender_id,
&sender_keys,
TransactionIntent::PrivateBalanceInit {
cell_id,
agent_id,
encrypted_balance: encrypted_balance.clone(),
commitment,
commit_nonce,
},
)?;
attach_private_balance_output(
&mut res,
&cell_id,
&agent_id,
balance,
&encrypted_balance,
&commitment,
&commit_nonce,
);
print_output(&res, output)?;
}
McpCommand::PrivateBalanceDeposit {
from,
cell_id,
agent_id,
amount,
new_balance,
old_commitment,
aes_seed_hex,
enc_nonce_hex,
commit_nonce_hex,
} => {
let (sender_id, sender_keys) = load_account_id_and_keypair(&from)?;
let cell_id = parse_hex_32("cell_id", &cell_id)?;
let agent_id = parse_hex_32("agent_id", &agent_id)?;
let amount = parse_amount_str(&amount)?;
let new_balance = parse_amount_str(&new_balance)?;
let old_commitment = parse_hex_32("old_commitment", &old_commitment)?;
let (new_encrypted_balance, new_commitment, new_commit_nonce) =
private_balance_material(
new_balance,
&aes_seed_hex,
&enc_nonce_hex,
&commit_nonce_hex,
)?;
let mut res = submit_signed_intent(
&client,
&rpc,
cli.retries,
sender_id,
&sender_keys,
TransactionIntent::PrivateBalanceDeposit {
cell_id,
agent_id,
amount,
new_encrypted_balance: new_encrypted_balance.clone(),
new_commitment,
new_commit_nonce,
old_commitment,
},
)?;
attach_private_balance_output(
&mut res,
&cell_id,
&agent_id,
new_balance,
&new_encrypted_balance,
&new_commitment,
&new_commit_nonce,
);
print_output(&res, output)?;
}
McpCommand::PrivateBalanceWithdraw {
from,
cell_id,
agent_id,
amount,
recipient,
new_balance,
old_commitment,
aes_seed_hex,
enc_nonce_hex,
commit_nonce_hex,
} => {
let (sender_id, sender_keys) = load_account_id_and_keypair(&from)?;
let cell_id = parse_hex_32("cell_id", &cell_id)?;
let agent_id = parse_hex_32("agent_id", &agent_id)?;
let recipient = parse_hex_32("recipient", &recipient)?;
let amount = parse_amount_str(&amount)?;
let new_balance = parse_amount_str(&new_balance)?;
let old_commitment = parse_hex_32("old_commitment", &old_commitment)?;
let (new_encrypted_balance, new_commitment, new_commit_nonce) =
private_balance_material(
new_balance,
&aes_seed_hex,
&enc_nonce_hex,
&commit_nonce_hex,
)?;
let mut res = submit_signed_intent(
&client,
&rpc,
cli.retries,
sender_id,
&sender_keys,
TransactionIntent::PrivateBalanceWithdraw {
cell_id,
agent_id,
amount,
recipient,
new_encrypted_balance: new_encrypted_balance.clone(),
new_commitment,
new_commit_nonce,
old_commitment,
},
)?;
attach_private_balance_output(
&mut res,
&cell_id,
&agent_id,
new_balance,
&new_encrypted_balance,
&new_commitment,
&new_commit_nonce,
);
print_output(&res, output)?;
}
McpCommand::PrivateBalanceConfidentialTransfer {
from,
sender_cell_id,
sender_agent_id,
recipient_cell_id,
amount_commitment,
proof_hex,
proof_file,
sender_new_encrypted,
sender_new_commitment,
sender_new_commit_nonce,
sender_old_commitment,
recipient_new_encrypted,
recipient_new_commitment,
recipient_new_commit_nonce,
recipient_old_commitment,
} => {
let (sender_id, sender_keys) = load_account_id_and_keypair(&from)?;
let sender_cell_id = parse_hex_32("sender_cell_id", &sender_cell_id)?;
let sender_agent_id = parse_hex_32("sender_agent_id", &sender_agent_id)?;
let recipient_cell_id = parse_hex_32("recipient_cell_id", &recipient_cell_id)?;
let amount_commitment = parse_hex_32("amount_commitment", &amount_commitment)?;
let sender_new_encrypted = parse_hex_bytes_exact(
"sender_new_encrypted",
&sender_new_encrypted,
truthlinked_mcp::private_balance::CIPHERTEXT_LEN,
)?;
let sender_new_commitment =
parse_hex_32("sender_new_commitment", &sender_new_commitment)?;
let sender_new_commit_nonce =
parse_hex_array::<16>("sender_new_commit_nonce", &sender_new_commit_nonce)?;
let sender_old_commitment =
parse_hex_32("sender_old_commitment", &sender_old_commitment)?;
let recipient_new_encrypted = parse_hex_bytes_exact(
"recipient_new_encrypted",
&recipient_new_encrypted,
truthlinked_mcp::private_balance::CIPHERTEXT_LEN,
)?;
let recipient_new_commitment =
parse_hex_32("recipient_new_commitment", &recipient_new_commitment)?;
let recipient_new_commit_nonce = parse_hex_array::<16>(
"recipient_new_commit_nonce",
&recipient_new_commit_nonce,
)?;
let recipient_old_commitment =
parse_hex_32("recipient_old_commitment", &recipient_old_commitment)?;
let stark_proof = match (proof_hex, proof_file) {
(Some(_), Some(_)) => {
return Err("Use either --proof-hex or --proof-file, not both".into());
}
(Some(raw), None) => parse_hex_bytes("proof_hex", &raw)?,
(None, Some(path)) => std::fs::read(path)?,
(None, None) => return Err("Provide --proof-hex or --proof-file".into()),
};
let proof_hash = hex::encode(blake3::hash(&stark_proof).as_bytes());
let mut res = submit_signed_intent(
&client,
&rpc,
cli.retries,
sender_id,
&sender_keys,
TransactionIntent::PrivateBalanceConfidentialTransfer {
sender_cell_id,
sender_agent_id,
recipient_cell_id,
amount_commitment,
stark_proof: stark_proof.clone(),
sender_new_encrypted: sender_new_encrypted.clone(),
sender_new_commitment,
sender_new_commit_nonce,
sender_old_commitment,
recipient_new_encrypted: recipient_new_encrypted.clone(),
recipient_new_commitment,
recipient_new_commit_nonce,
recipient_old_commitment,
},
)?;
if let Some(map) = res.as_object_mut() {
map.insert(
"private_balance_confidential_transfer".to_string(),
serde_json::json!({
"sender_cell_id": hex::encode(sender_cell_id),
"sender_agent_id": hex::encode(sender_agent_id),
"recipient_cell_id": hex::encode(recipient_cell_id),
"amount_commitment": hex::encode(amount_commitment),
"sender_new_commitment": hex::encode(sender_new_commitment),
"recipient_new_commitment": hex::encode(recipient_new_commitment),
"proof_len": stark_proof.len(),
"proof_hash": proof_hash,
"fee_multiplier": 3
}),
);
}
print_output(&res, output)?;
}
McpCommand::ToolCall {
from,
tool_id,
policy_cell_id,
action_log_id,
calldata_hex,
calldata_file,
value,
gas_limit,
} => {
let (sender_id, sender_keys) = load_account_id_and_keypair(&from)?;
let tool_id = parse_hex_32("tool_id", &tool_id)?;
let policy_cell = parse_hex_32("policy_cell_id", &policy_cell_id)?;
let action_log = if let Some(id) = action_log_id {
parse_hex_32("action_log_id", &id)?
} else {
truthlinked_mcp::protocol_addresses::action_log()
};
let calldata = if let Some(hex) = calldata_hex {
parse_hex_bytes("calldata_hex", &hex)?
} else if let Some(path) = calldata_file {
load_bytes(&path)?
} else {
Vec::new()
};
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let nonce = next_nonce(&client, &rpc, &sender_id, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let tx = Transaction {
sender: sender_id,
intent: TransactionIntent::McpToolCall {
agent_id: sender_id,
tool_id,
tool_calldata: calldata,
value,
gas_limit,
policy_cell_id: policy_cell,
action_log_id: Some(action_log),
timestamp,
},
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed = sender_keys.sign_transaction(&tx)?;
let bytes = postcard::to_allocvec(&signed)?;
let res: Value =
post_bytes(&client, &format!("{}/submit_raw", rpc), bytes, cli.retries)?;
print_output(&res, output)?;
}
},
Commands::Send {
recipient,
amount,
from,
} => {
let recipient = match recipient {
Some(r) => r,
None => prompt_line("Recipient (account ID / pubkey / name)")?,
};
let amount_raw = match amount {
Some(a) => a,
None => prompt_line("Amount (TLKD)")?,
};
let amount_raw = parse_amount_str(&amount_raw)?;
let from_path = from.unwrap_or_else(|| resolve_keyfile_path(config.as_ref()));
if amount_raw == 0 {
return Err("Amount must be > 0".into());
}
if amount_raw >= (LARGE_TRANSFER_TLKD as u128) * truthlinked_core::ONE_TRTH {
confirm_or_abort(cli.yes, output, "Large transfer; confirm")?;
}
let recipient_spec = parse_recipient_input(&recipient)?;
let (sender_keys, sender_pubkey) = load_keypair_and_pubkey(&from_path)?;
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let amount_units = amount_raw;
let intent = match recipient_spec {
RecipientInput::Name(name) => pq_execution::TransactionIntent::TransferToName {
name,
amount: amount_units,
},
RecipientInput::AccountId(recipient_id) => {
pq_execution::TransactionIntent::Transfer {
recipient: recipient_id,
recipient_pubkey: None,
amount: amount_units,
}
}
RecipientInput::Pubkey(pubkey) => {
let recipient_id = pq_identity::account_id_from_pubkey(&pubkey);
pq_execution::TransactionIntent::Transfer {
recipient: recipient_id,
recipient_pubkey: Some(pubkey),
amount: amount_units,
}
}
};
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
sender: sender_account_id,
intent,
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: get_expiration_height(&client, &rpc, cli.retries)?,
};
let signed = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed)?;
let res = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::Transfer {
from,
to_pubkey,
to_name,
amount,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let amount_raw = parse_amount_str(&amount)?;
if amount_raw == 0 {
return Err("Amount must be > 0".into());
}
if amount_raw >= (LARGE_TRANSFER_TLKD as u128) * truthlinked_core::ONE_TRTH {
confirm_or_abort(cli.yes, output, "Large transfer; confirm")?;
}
let recipient_raw = match (to_pubkey, to_name) {
(Some(pubkey), None) => pubkey,
(None, Some(name)) => name,
(None, None) => {
return Err("Recipient required: use --to-pubkey or --to-name".into())
}
(Some(_), Some(_)) => return Err("Use only one of --to-pubkey or --to-name".into()),
};
let recipient = parse_recipient_spec(&recipient_raw)?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let intent = match recipient {
RecipientSpec::Name(name) => pq_execution::TransactionIntent::TransferToName {
name,
amount: amount_raw,
},
RecipientSpec::AccountId(recipient_id) => {
pq_execution::TransactionIntent::Transfer {
recipient: recipient_id,
recipient_pubkey: None,
amount: amount_raw,
}
}
RecipientSpec::Pubkey(recipient_pk) => {
let recipient_id = pq_identity::account_id_from_pubkey(&recipient_pk);
pq_execution::TransactionIntent::Transfer {
recipient: recipient_id,
recipient_pubkey: Some(recipient_pk),
amount: amount_raw,
}
}
};
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
sender: sender_account_id,
intent,
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let res: Value =
submit_transaction_with_nonce_retry(&client, &rpc, cli.retries, &sender_keys, tx)?;
print_output(&res, output)?;
}
Commands::DepositCompute { from, amount } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let amount_raw = parse_amount_str(&amount)?;
if amount_raw == 0 {
return Err("amount must be > 0".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::DepositCompute { amount: amount_raw },
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::WithdrawCompute { from, amount } => {
confirm_or_abort(cli.yes, output, "Withdraw compute escrow; confirm")?;
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let amount_raw = parse_amount_str(&amount)?;
if amount_raw == 0 {
return Err("amount must be > 0".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::WithdrawCompute { amount: amount_raw },
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = client
.post(format!("{}/submit_raw", rpc))
.header("Content-Type", "application/octet-stream")
.body(tx_bytes)
.send()?
.json()?;
print_output(&res, output)?;
}
Commands::BatchTransfer {
from,
to_pubkeys,
amounts,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let recipients: Vec<&str> = to_pubkeys.split(',').map(|s| s.trim()).collect();
let amounts_vec: Vec<u128> = amounts
.split(',')
.map(|s| {
let s = s.trim();
let amt = parse_trth_amount(s)?;
Ok(amt)
})
.collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?;
if recipients.len() != amounts_vec.len() {
return Err("Number of recipients must match number of amounts".into());
}
if recipients.is_empty() {
return Err("No recipients provided".into());
}
if recipients.len() > constants::MAX_BATCH_TRANSFER_RECIPIENTS {
return Err(format!(
"Too many recipients: {} (max: {})",
recipients.len(),
constants::MAX_BATCH_TRANSFER_RECIPIENTS
)
.into());
}
eprintln!(" Batch transfer: {} transactions", recipients.len());
let balance_res = post_json(
&client,
&format!("{}/balance", rpc),
serde_json::json!({"account_id": hex::encode(&sender_account_id)}),
cli.retries,
)?;
let sender_balance: u128 = balance_res["balance"]
.as_str()
.and_then(|v| v.parse::<u128>().ok())
.unwrap_or(0);
let mut total_amount: u128 = 0;
let mut parsed_recipients = Vec::with_capacity(recipients.len());
for (i, (recipient_raw, amount)) in
recipients.iter().zip(amounts_vec.iter()).enumerate()
{
if *amount == 0 {
return Err(format!("Amount at index {} must be > 0", i).into());
}
total_amount = total_amount.saturating_add(*amount);
let spec = parse_recipient_spec(recipient_raw)?;
parsed_recipients.push((spec, *amount));
}
if total_amount >= (LARGE_TRANSFER_TLKD as u128) * ONE_TRTH {
confirm_or_abort(cli.yes, output, "Large batch transfer; confirm")?;
}
let est_gas =
(recipients.len() as u128) * (truthlinked_core::constants::GAS_TRANSFER as u128);
let est_total = total_amount.saturating_add(est_gas);
if sender_balance < est_total {
return Err(format!(
"Insufficient balance for batch transfer: need {}, have {}",
est_total, sender_balance
)
.into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let has_names = parsed_recipients
.iter()
.any(|(spec, _)| matches!(spec, RecipientSpec::Name(_)));
let intent = if has_names {
if parsed_recipients
.iter()
.any(|(spec, _)| !matches!(spec, RecipientSpec::Name(_)))
{
return Err(
"BatchTransferToName requires all recipients to be .tl names".into(),
);
}
let transfers = parsed_recipients
.into_iter()
.map(|(spec, amount)| match spec {
RecipientSpec::Name(name) => {
pq_execution::NameTransferEntry { name, amount }
}
_ => unreachable!(),
})
.collect();
pq_execution::TransactionIntent::BatchTransferToName { transfers }
} else {
let transfers = parsed_recipients
.into_iter()
.map(|(spec, amount)| match spec {
RecipientSpec::AccountId(recipient) => pq_execution::BatchTransferEntry {
recipient,
recipient_pubkey: None,
amount,
},
RecipientSpec::Pubkey(recipient_pubkey) => {
let recipient = pq_identity::account_id_from_pubkey(&recipient_pubkey);
pq_execution::BatchTransferEntry {
recipient,
recipient_pubkey: Some(recipient_pubkey),
amount,
}
}
RecipientSpec::Name(_) => unreachable!(),
})
.collect();
pq_execution::TransactionIntent::BatchTransfer { transfers }
};
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
sender: sender_account_id,
intent,
signature: vec![],
nonce,
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
eprintln!("Submitting batch transfer...");
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::ValidatorSetup { keys, amount } => {
let sender_keys = pq_identity::DualKeypair::load(&keys)?;
if output == OutputFormat::Pretty {
eprintln!(" Bonding {} TLKD...", amount);
}
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let amount_raw = parse_amount_str(&amount)?;
if amount_raw == 0 {
return Err("Amount must be > 0".into());
}
let amount_u64 = u64::try_from(amount_raw).map_err(|_| "Amount too large")?;
let calldata = encode_staking_stake(&sender_pubkey, amount_u64)?;
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
if res
.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let tx_hash = res
.get("tx_hash")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
if output == OutputFormat::Pretty {
eprintln!(" Bonded {} TLKD", amount);
eprintln!(" TX Hash: {}", tx_hash);
eprintln!("\n Validator setup complete!");
eprintln!(" Status: Active");
eprintln!(" Stake: {} TLKD", amount);
}
} else if output == OutputFormat::Pretty {
eprintln!(" Bonding failed: {}", json_string(&res, output)?);
}
print_output(&res, output)?;
}
Commands::Bond { from, amount } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let amount_raw = parse_amount_str(&amount)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
if amount_raw == 0 {
return Err("Amount must be > 0".into());
}
let amount_u64 = u64::try_from(amount_raw).map_err(|_| "Amount too large")?;
let calldata = encode_staking_stake(&sender_pubkey, amount_u64)?;
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::Stake { amount, from } => {
let amount_raw = match amount {
Some(a) => a,
None => prompt_line("Amount (TLKD)")?,
};
let amount_raw = parse_amount_str(&amount_raw)?;
let from_path = from.unwrap_or_else(|| resolve_keyfile_path(config.as_ref()));
if amount_raw == 0 {
return Err("Amount must be > 0".into());
}
let sender_keys = pq_identity::DualKeypair::load(&from_path)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let amount_u64 = u64::try_from(amount_raw).map_err(|_| "Amount too large")?;
let calldata = encode_staking_stake(&sender_pubkey, amount_u64)?;
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::Unbond { from, amount } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let amount_raw = parse_amount_str(&amount)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
if amount_raw == 0 {
return Err("Amount must be > 0".into());
}
confirm_or_abort(cli.yes, output, "Unbond stake; confirm")?;
let amount_u64 = u64::try_from(amount_raw).map_err(|_| "Amount too large")?;
let calldata = encode_staking_unstake(&sender_pubkey, amount_u64)?;
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::Withdraw { from } => {
confirm_or_abort(cli.yes, output, "Withdraw stake; confirm")?;
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_staking_withdraw(&sender_pubkey)?;
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::Unjail { from } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_staking_unjail(&sender_pubkey)?;
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::DelegateAdd {
from,
delegate_pubkey,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let delegate_bytes = hex::decode(&delegate_pubkey)?;
if delegate_bytes.len() != VALIDATOR_PUBKEY_LEN {
return Err("delegate_pubkey must be 1952 bytes".into());
}
let delegate_account = pq_identity::account_id_from_pubkey(&delegate_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_staking_delegate_add(delegate_account);
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::DelegateRemove {
from,
delegate_pubkey,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let delegate_bytes = hex::decode(&delegate_pubkey)?;
if delegate_bytes.len() != VALIDATOR_PUBKEY_LEN {
return Err("delegate_pubkey must be 1952 bytes".into());
}
let delegate_account = pq_identity::account_id_from_pubkey(&delegate_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_staking_delegate_remove(delegate_account);
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::StakeFor {
from,
owner_pubkey,
amount,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let owner_pubkey_bytes = hex::decode(&owner_pubkey)?;
if owner_pubkey_bytes.len() != VALIDATOR_PUBKEY_LEN {
return Err("owner_pubkey must be 1952 bytes".into());
}
let owner_account = pq_identity::account_id_from_pubkey(&owner_pubkey_bytes);
let amount_raw = parse_amount_str(&amount)?;
let amount_u64 = u64::try_from(amount_raw).map_err(|_| "Amount too large")?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata =
encode_staking_stake_for(owner_account, &owner_pubkey_bytes, amount_u64)?;
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::UnstakeFor {
from,
owner_pubkey,
amount,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let owner_pubkey_bytes = hex::decode(&owner_pubkey)?;
if owner_pubkey_bytes.len() != VALIDATOR_PUBKEY_LEN {
return Err("owner_pubkey must be 1952 bytes".into());
}
let owner_account = pq_identity::account_id_from_pubkey(&owner_pubkey_bytes);
let amount_raw = parse_amount_str(&amount)?;
let amount_u64 = u64::try_from(amount_raw).map_err(|_| "Amount too large")?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata =
encode_staking_unstake_for(owner_account, &owner_pubkey_bytes, amount_u64)?;
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::WithdrawFor { from, owner_pubkey } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let owner_pubkey_bytes = hex::decode(&owner_pubkey)?;
if owner_pubkey_bytes.len() != VALIDATOR_PUBKEY_LEN {
return Err("owner_pubkey must be 1952 bytes".into());
}
let owner_account = pq_identity::account_id_from_pubkey(&owner_pubkey_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_staking_withdraw_for(owner_account, &owner_pubkey_bytes)?;
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::UnjailFor { from, owner_pubkey } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let owner_pubkey_bytes = hex::decode(&owner_pubkey)?;
if owner_pubkey_bytes.len() != VALIDATOR_PUBKEY_LEN {
return Err("owner_pubkey must be 1952 bytes".into());
}
let owner_account = pq_identity::account_id_from_pubkey(&owner_pubkey_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_staking_unjail_for(owner_account, &owner_pubkey_bytes)?;
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::StakedTrthLock {
from,
amount,
lock_blocks,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let owner = sender_account_id;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let amount_raw = parse_amount_str(&amount)?;
if amount_raw == 0 {
return Err("Amount must be > 0".into());
}
let calldata = encode_staking_lock(owner, lock_blocks);
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: amount_raw,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::StakedTrthExtend { from, lock_blocks } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let owner = sender_account_id;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_staking_extend(owner, lock_blocks);
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::StakedTrthUnlock { from } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let owner = sender_account_id;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_staking_unlock(owner);
let cell_id = truthlinked_core::pq_execution::staking_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::TreasuryProposeSpend {
from,
recipient,
amount,
timelock_blocks,
proposal_id,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let recipient_id = parse_account_id_hex(&recipient)?;
let proposal_id = if let Some(hex_val) = proposal_id {
parse_account_id_hex(&hex_val)?
} else {
let mut id = [0u8; 32];
rand::thread_rng().fill_bytes(&mut id);
id
};
let amount_raw = parse_amount_str(&amount)?;
if amount_raw == 0 {
return Err("Amount must be > 0".into());
}
let calldata =
encode_treasury_propose(proposal_id, recipient_id, amount_raw, timelock_blocks);
let cell_id = truthlinked_core::pq_execution::treasury_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
if output == OutputFormat::Pretty {
eprintln!(
" Proposal ID: {}",
format_address(&hex::encode(proposal_id))
);
}
print_output(&res, output)?;
}
Commands::TreasuryVoteSpend {
from,
proposal_id,
approve,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let proposal_id = parse_account_id_hex(&proposal_id)?;
let calldata = encode_treasury_vote(proposal_id, approve);
let cell_id = truthlinked_core::pq_execution::treasury_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::TreasuryExecuteSpend { from, proposal_id } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let proposal_id = parse_account_id_hex(&proposal_id)?;
let calldata = encode_treasury_execute(proposal_id);
let cell_id = truthlinked_core::pq_execution::treasury_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::TreasuryProposalInfo { proposal_id } => {
let _ = parse_account_id_hex(&proposal_id)?;
let res: Value = client
.get(format!("{}/treasury_proposal/{}", rpc, proposal_id))
.send()?
.json()?;
print_output(&res, output)?;
}
Commands::MintNft {
from,
nft_id,
name,
metadata_uri,
collection,
royalty_bps,
royalty_recipient,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let nft_id_bytes = hex::decode(&nft_id)?;
if nft_id_bytes.len() != 32 {
return Err("nft_id must be 32 bytes (64 hex chars)".into());
}
let mut nft_id_arr = [0u8; 32];
nft_id_arr.copy_from_slice(&nft_id_bytes);
let collection_arr = if let Some(c) = &collection {
let c_bytes = hex::decode(c)?;
if c_bytes.len() != 32 {
return Err("collection must be 32 bytes (64 hex chars)".into());
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&c_bytes);
Some(arr)
} else {
None
};
let royalty_recipient_id = if let Some(r) = &royalty_recipient {
let r_bytes = hex::decode(r)?;
if r_bytes.len() != 32 {
return Err("royalty_recipient must be 32 bytes (64 hex chars)".into());
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&r_bytes);
Some(arr)
} else {
None
};
if royalty_bps > 10_000 {
return Err("royalty_bps must be between 0 and 10000".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::MintNFT {
nft_id: nft_id_arr,
name,
metadata_uri,
collection: collection_arr,
royalty_bps,
royalty_recipient: royalty_recipient_id,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::TransferNft {
from,
nft_id,
to_pubkey,
sale_price,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let nft_id_bytes = hex::decode(&nft_id)?;
if nft_id_bytes.len() != 32 {
return Err("nft_id must be 32 bytes (64 hex chars)".into());
}
let mut nft_id_arr = [0u8; 32];
nft_id_arr.copy_from_slice(&nft_id_bytes);
let sale_price = if let Some(p) = sale_price {
let parsed = parse_amount_str(&p)?;
if parsed == 0 {
return Err("sale_price must be > 0".into());
}
Some(parsed)
} else {
None
};
let recipient_pubkey = hex::decode(&to_pubkey)?;
if recipient_pubkey.len() != 1952 {
return Err("Recipient pubkey must be 1952 bytes (3904 hex chars)".into());
}
let recipient_account_id = pq_identity::account_id_from_pubkey(&recipient_pubkey);
let nft_info: Value =
get_json(&client, &format!("{}/nft/{}", rpc, nft_id), cli.retries)?;
if !nft_info
.get("found")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Err("NFT not found".into());
}
let owner = nft_info["nft"]["owner"].as_str().unwrap_or("");
let approved = nft_info["nft"]["approved"].as_str().unwrap_or("");
let sender_hex = hex::encode(&sender_account_id);
if owner != sender_hex && approved != sender_hex {
return Err("Sender is not owner or approved operator".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::TransferNFT {
nft_id: nft_id_arr,
recipient: recipient_account_id,
recipient_pubkey: Some(recipient_pubkey),
sale_price,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::BurnNft { from, nft_id } => {
confirm_or_abort(cli.yes, output, "Burn NFT; confirm")?;
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let nft_id_bytes = hex::decode(&nft_id)?;
if nft_id_bytes.len() != 32 {
return Err("nft_id must be 32 bytes (64 hex chars)".into());
}
let mut nft_id_arr = [0u8; 32];
nft_id_arr.copy_from_slice(&nft_id_bytes);
let nft_info: Value =
get_json(&client, &format!("{}/nft/{}", rpc, nft_id), cli.retries)?;
if !nft_info
.get("found")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Err("NFT not found".into());
}
let owner = nft_info["nft"]["owner"].as_str().unwrap_or("");
let sender_hex = hex::encode(&sender_account_id);
if owner != sender_hex {
return Err("Sender is not owner".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::BurnNFT { nft_id: nft_id_arr },
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::ApproveNft {
from,
nft_id,
approved_pubkey,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let nft_id_bytes = hex::decode(&nft_id)?;
if nft_id_bytes.len() != 32 {
return Err("nft_id must be 32 bytes (64 hex chars)".into());
}
let mut nft_id_arr = [0u8; 32];
nft_id_arr.copy_from_slice(&nft_id_bytes);
let approved_id = if let Some(pk) = approved_pubkey {
let pk_bytes = hex::decode(&pk)?;
if pk_bytes.len() != 1952 {
return Err("approved_pubkey must be 1952 bytes (3904 hex chars)".into());
}
let id = pq_identity::account_id_from_pubkey(&pk_bytes);
Some(id)
} else {
None
};
let nft_info: Value =
get_json(&client, &format!("{}/nft/{}", rpc, nft_id), cli.retries)?;
if !nft_info
.get("found")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Err("NFT not found".into());
}
let owner = nft_info["nft"]["owner"].as_str().unwrap_or("");
let sender_hex = hex::encode(&sender_account_id);
if owner != sender_hex {
return Err("Sender is not owner".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::ApproveNFT {
nft_id: nft_id_arr,
approved: approved_id,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::GetNft { nft_id } => {
let nft_id_bytes = hex::decode(&nft_id)?;
if nft_id_bytes.len() != 32 {
return Err("nft_id must be 32 bytes (64 hex chars)".into());
}
let res: Value = get_json(&client, &format!("{}/nft/{}", rpc, nft_id), cli.retries)?;
print_output(&res, output)?;
}
Commands::MyNFTs { account } => {
let account_id = if account.ends_with(".json") {
let keys = pq_identity::DualKeypair::load(&account)?;
let pk = keys.dilithium_pk.into_bytes();
pq_identity::account_id_from_pubkey(&pk)
} else {
let bytes = hex::decode(&account)?;
if bytes.len() != 32 {
return Err("account must be 32 bytes (64 hex chars)".into());
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
arr
};
let account_hex = hex::encode(&account_id);
let res = get_json(
&client,
&format!("{}/nfts/{}", rpc, account_hex),
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::DeployCell {
from,
cell_id,
source,
bytecode_file,
initial_balance,
manifest_file,
} => {
let (axiom_path, manifest_path) = if let Some(src) = source {
eprintln!(" Building cell from source...");
let (wasm, manifest) = build_cell(&src, None)?;
(wasm, Some(manifest))
} else if let Some(wasm) = bytecode_file {
let manifest = manifest_file.or_else(|| {
let auto_manifest_path = wasm.replace(".axiom", ".manifest.json");
if std::path::Path::new(&auto_manifest_path).exists() {
eprintln!(" Found manifest: {}", auto_manifest_path);
Some(auto_manifest_path)
} else {
None
}
});
(wasm, manifest)
} else {
return Err("Must provide either --source or --bytecode-file".into());
};
submit_cell_deploy(
&client,
&rpc,
&from,
&cell_id,
&axiom_path,
manifest_path,
initial_balance,
output,
cli.retries,
)?;
}
Commands::Deploy {
cell_id,
source,
from,
} => {
let cell_id = match cell_id {
Some(c) => c,
None => prompt_line("Cell id (hex)")?,
};
let source = match source {
Some(s) => s,
None => prompt_line("Source (file path)")?,
};
let from = from.unwrap_or_else(|| resolve_keyfile_path(config.as_ref()));
submit_cell_deploy(
&client,
&rpc,
&from,
&cell_id,
&source,
None,
0,
output,
cli.retries,
)?;
}
Commands::DeployToken {
from,
cell_id,
name,
symbol,
decimals,
supply,
} => {
let sender_keys = DualKeypair::load(&from)?;
let pubkey = sender_keys.dilithium_pk.clone().into_bytes();
let sender_account_id = truthlinked_core::pq_identity::account_id_from_pubkey(&pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = Transaction {
nonce,
sender: sender_account_id,
intent: TransactionIntent::DeployToken {
cell_id: cell_id_arr,
name,
symbol,
decimals,
total_supply: supply,
transfer_fee_bps: 0,
transfer_fee_recipient: None,
non_transferable: false,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
eprintln!("Submitting token deployment...");
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::CallCell {
from,
cell_id,
calldata,
value,
gas_limit,
simulate,
} => {
let sender_keys = DualKeypair::load(&from)?;
let pubkey = sender_keys.dilithium_pk.clone().into_bytes();
let sender_account_id = truthlinked_core::pq_identity::account_id_from_pubkey(&pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let calldata_bytes = hex::decode(&calldata)?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = Transaction {
nonce,
sender: sender_account_id,
intent: TransactionIntent::CallCell {
cell_id: cell_id_arr,
calldata: calldata_bytes,
value: value as u128,
gas_limit,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let endpoint = if simulate {
"simulate_raw"
} else {
"submit_raw"
};
if simulate {
eprintln!("Simulating cell call...");
eprintln!(" Simulation validates the signed transaction without committing state.");
} else {
eprintln!("Calling cell...");
}
let res: Value = post_bytes(
&client,
&format!("{}/{}", rpc, endpoint),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::UpgradeCell {
from,
cell_id,
source,
bytecode_file,
manifest_file,
} => {
let (axiom_path, manifest_path) = if let Some(src) = source {
eprintln!(" Building cell from source...");
let (wasm, manifest) = build_cell(&src, None)?;
(wasm, Some(manifest))
} else if let Some(wasm) = bytecode_file {
let manifest = manifest_file.or_else(|| {
let auto_manifest = wasm.replace(".axiom", ".manifest.json");
if std::path::Path::new(&auto_manifest).exists() {
eprintln!(" Found manifest: {}", auto_manifest);
Some(auto_manifest)
} else {
None
}
});
(wasm, manifest)
} else {
return Err("Must provide either --source or --bytecode-file".into());
};
let sender_keys = DualKeypair::load(&from)?;
let pubkey = sender_keys.dilithium_pk.clone().into_bytes();
let sender_account_id = truthlinked_core::pq_identity::account_id_from_pubkey(&pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
if cell_id_bytes.len() != 32 {
return Err("cell_id must be 32 bytes (64 hex chars)".into());
}
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let new_bytecode = std::fs::read(&axiom_path)?;
let (
new_declared_reads,
new_declared_writes,
new_commutative_keys,
new_storage_key_specs,
new_oracle_schema_ids,
) = if let Some(manifest_path) = manifest_path {
let (
new_declared_reads,
new_declared_writes,
new_commutative_keys,
new_storage_key_specs,
new_oracle_schema_ids,
) = load_manifest_sets(&manifest_path)?;
truthlinked_core::cells::CellAccount::verify_manifest_against_bytecode(
&new_bytecode,
&new_declared_reads,
&new_declared_writes,
&new_storage_key_specs,
)?;
eprintln!(" Manifest verified locally");
(
new_declared_reads,
new_declared_writes,
new_commutative_keys,
new_storage_key_specs,
new_oracle_schema_ids,
)
} else {
let analysis =
truthlinked_core::cells::CellAccount::analyze_bytecode(&new_bytecode)
.map_err(|e| format!("Axiom static analysis failed: {}", e))?;
(
analysis.static_read_slots,
analysis.static_write_slots,
vec![],
vec![],
vec![],
)
};
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = Transaction {
nonce,
sender: sender_account_id,
intent: TransactionIntent::UpgradeCell {
cell_id: cell_id_arr,
new_bytecode,
new_declared_reads,
new_declared_writes,
new_commutative_keys,
new_storage_key_specs,
new_oracle_schema_ids,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
eprintln!("Upgrading cell...");
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::RotateKey { from, new_pubkey } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let new_pubkey_bytes = hex::decode(&new_pubkey)?;
if new_pubkey_bytes.len() != 1952 {
return Err("new_pubkey must be 1952 bytes (3904 hex chars)".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::RotateKey {
new_pubkey: new_pubkey_bytes,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::AcceptOwnership { from, cell_id } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
if cell_id_bytes.len() != 32 {
return Err("cell_id must be 32 bytes (64 hex chars)".into());
}
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::AcceptOwnership {
cell_id: cell_id_arr,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::MakeImmutable { from, cell_id } => {
confirm_or_abort(cli.yes, output, "Make cell immutable; confirm")?;
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
if cell_id_bytes.len() != 32 {
return Err("cell_id must be 32 bytes (64 hex chars)".into());
}
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let cell_info = require_cell_exists(&client, &rpc, &cell_id, cli.retries)?;
if cell_info.immutable {
return Err("cell is already immutable".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::MakeImmutable {
cell_id: cell_id_arr,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::CloseCell { from, cell_id } => {
confirm_or_abort(cli.yes, output, "Close cell; confirm")?;
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
if cell_id_bytes.len() != 32 {
return Err("cell_id must be 32 bytes (64 hex chars)".into());
}
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let cell_info = require_cell_exists(&client, &rpc, &cell_id, cli.retries)?;
if cell_info.immutable {
return Err("cell is immutable".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CloseCell {
cell_id: cell_id_arr,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::ProposeCellUpgrade {
from,
cell_id,
source,
bytecode_file,
manifest_file,
timelock_blocks,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
if cell_id_bytes.len() != 32 {
return Err("cell_id must be 32 bytes (64 hex chars)".into());
}
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let (axiom_path, manifest_path) = if let Some(src) = source {
eprintln!(" Building cell from source...");
let (wasm, manifest) = build_cell(&src, None)?;
(wasm, Some(manifest))
} else if let Some(wasm) = bytecode_file {
let manifest = manifest_file.or_else(|| {
let auto_manifest = wasm.replace(".axiom", ".manifest.json");
if std::path::Path::new(&auto_manifest).exists() {
eprintln!(" Found manifest: {}", auto_manifest);
Some(auto_manifest)
} else {
None
}
});
(wasm, manifest)
} else {
return Err("Must provide either --source or --bytecode-file".into());
};
let new_bytecode = std::fs::read(&axiom_path)?;
let (
declared_reads,
declared_writes,
commutative_keys,
storage_key_specs,
new_oracle_schema_ids,
) = if let Some(manifest_path) = manifest_path {
let (reads, writes, commutative, specs, schema_ids) =
load_manifest_sets(&manifest_path)?;
truthlinked_core::cells::CellAccount::verify_manifest_against_bytecode(
&new_bytecode,
&reads,
&writes,
&specs,
)?;
(reads, writes, commutative, specs, schema_ids)
} else {
let analysis =
truthlinked_core::cells::CellAccount::analyze_bytecode(&new_bytecode)
.map_err(|e| format!("Axiom static analysis failed: {}", e))?;
(
analysis.static_read_slots,
analysis.static_write_slots,
vec![],
vec![],
vec![],
)
};
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::ProposeCellUpgrade {
cell_id: cell_id_arr,
new_bytecode,
new_declared_reads: declared_reads,
new_declared_writes: declared_writes,
new_commutative_keys: commutative_keys,
new_storage_key_specs: storage_key_specs,
new_oracle_schema_ids,
timelock_blocks,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::ProposeCellOwnershipTransfer {
from,
cell_id,
new_owner,
timelock_blocks,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
if cell_id_bytes.len() != 32 {
return Err("cell_id must be 32 bytes (64 hex chars)".into());
}
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let new_owner_bytes = hex::decode(&new_owner)?;
if new_owner_bytes.len() != 32 {
return Err("new_owner must be 32 bytes (64 hex chars)".into());
}
let mut new_owner_arr = [0u8; 32];
new_owner_arr.copy_from_slice(&new_owner_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::ProposeCellOwnershipTransfer {
cell_id: cell_id_arr,
new_owner: new_owner_arr,
timelock_blocks,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::ProposeCellMakeImmutable {
from,
cell_id,
timelock_blocks,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
if cell_id_bytes.len() != 32 {
return Err("cell_id must be 32 bytes (64 hex chars)".into());
}
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::ProposeCellMakeImmutable {
cell_id: cell_id_arr,
timelock_blocks,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::VoteCellProposal {
from,
cell_id,
approve,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
if cell_id_bytes.len() != 32 {
return Err("cell_id must be 32 bytes (64 hex chars)".into());
}
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::VoteCellProposal {
cell_id: cell_id_arr,
approve,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::ExecuteCellProposal { from, cell_id } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
if cell_id_bytes.len() != 32 {
return Err("cell_id must be 32 bytes (64 hex chars)".into());
}
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::ExecuteCellProposal {
cell_id: cell_id_arr,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::TokenTransfer {
from,
token,
to,
amount,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
if amount == 0 {
return Err("amount must be > 0".into());
}
let token_bytes = hex::decode(&token)?;
if token_bytes.len() != 32 {
return Err("token must be 32 bytes (64 hex chars)".into());
}
let mut token_arr = [0u8; 32];
token_arr.copy_from_slice(&token_bytes);
let to_bytes = hex::decode(&to)?;
if to_bytes.len() != 32 {
return Err("to must be 32 bytes (64 hex chars)".into());
}
let mut to_arr = [0u8; 32];
to_arr.copy_from_slice(&to_bytes);
require_token_cell(&client, &rpc, &token, cli.retries)?;
require_account_exists(&client, &rpc, &to)?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::TokenTransfer {
token_cell: token_arr,
recipient: to_arr,
amount,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::TokenMint {
from,
token,
to,
amount,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
if amount == 0 {
return Err("amount must be > 0".into());
}
let token_bytes = hex::decode(&token)?;
if token_bytes.len() != 32 {
return Err("token must be 32 bytes (64 hex chars)".into());
}
let mut token_arr = [0u8; 32];
token_arr.copy_from_slice(&token_bytes);
let to_bytes = hex::decode(&to)?;
if to_bytes.len() != 32 {
return Err("to must be 32 bytes (64 hex chars)".into());
}
let mut to_arr = [0u8; 32];
to_arr.copy_from_slice(&to_bytes);
require_token_cell(&client, &rpc, &token, cli.retries)?;
require_account_exists(&client, &rpc, &to)?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::TokenMint {
token_cell: token_arr,
recipient: to_arr,
amount,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::TokenBurn {
from,
token,
amount,
} => {
confirm_or_abort(cli.yes, output, "Burn token supply; confirm")?;
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
if amount == 0 {
return Err("amount must be > 0".into());
}
let token_bytes = hex::decode(&token)?;
if token_bytes.len() != 32 {
return Err("token must be 32 bytes (64 hex chars)".into());
}
let mut token_arr = [0u8; 32];
token_arr.copy_from_slice(&token_bytes);
require_token_cell(&client, &rpc, &token, cli.retries)?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::TokenBurn {
token_cell: token_arr,
amount,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::ProposeTokenAuthority {
from,
token,
mint_authority,
clear_mint_authority,
freeze_authority,
clear_freeze_authority,
voting_period_blocks,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
if mint_authority.is_some() && clear_mint_authority {
return Err("Cannot set and clear mint authority at the same time".into());
}
if freeze_authority.is_some() && clear_freeze_authority {
return Err("Cannot set and clear freeze authority at the same time".into());
}
let set_mint_authority = mint_authority.is_some() || clear_mint_authority;
let set_freeze_authority = freeze_authority.is_some() || clear_freeze_authority;
if !set_mint_authority && !set_freeze_authority {
return Err("Must set at least one authority (mint or freeze)".into());
}
let token_bytes = hex::decode(&token)?;
if token_bytes.len() != 32 {
return Err("token must be 32 bytes (64 hex chars)".into());
}
let mut token_arr = [0u8; 32];
token_arr.copy_from_slice(&token_bytes);
let new_mint_authority = if let Some(hex_val) = mint_authority {
let bytes = hex::decode(&hex_val)?;
if bytes.len() != 32 {
return Err("mint_authority must be 32 bytes (64 hex chars)".into());
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Some(arr)
} else if clear_mint_authority {
None
} else {
None
};
let new_freeze_authority = if let Some(hex_val) = freeze_authority {
let bytes = hex::decode(&hex_val)?;
if bytes.len() != 32 {
return Err("freeze_authority must be 32 bytes (64 hex chars)".into());
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Some(arr)
} else if clear_freeze_authority {
None
} else {
None
};
require_token_cell(&client, &rpc, &token, cli.retries)?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let mint_arr = new_mint_authority.unwrap_or([0u8; 32]);
let freeze_arr = new_freeze_authority.unwrap_or([0u8; 32]);
let calldata = encode_token_authority_propose(
token_arr,
set_mint_authority,
mint_arr,
set_freeze_authority,
freeze_arr,
voting_period_blocks,
);
let cell_id = truthlinked_core::pq_execution::token_governance_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::VoteTokenAuthority {
from,
token,
approve,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let token_bytes = hex::decode(&token)?;
if token_bytes.len() != 32 {
return Err("token must be 32 bytes (64 hex chars)".into());
}
let mut token_arr = [0u8; 32];
token_arr.copy_from_slice(&token_bytes);
require_token_cell(&client, &rpc, &token, cli.retries)?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_token_authority_vote(token_arr, approve);
let cell_id = truthlinked_core::pq_execution::token_governance_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::CallChain {
from,
calls,
gas_limit,
simulate,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let cell_calls = parse_call_chain_json(&calls)?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCellChain {
calls: cell_calls,
gas_limit,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let endpoint = if simulate {
"simulate_raw"
} else {
"submit_raw"
};
if simulate {
eprintln!("Simulating call chain...");
eprintln!(" Simulation validates the signed transaction without committing state.");
} else {
eprintln!("Submitting call chain...");
}
let res: Value = post_bytes(
&client,
&format!("{}/{}", rpc, endpoint),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::ProposeName {
from,
name,
target,
owner,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
if name.trim().is_empty() {
return Err("name must not be empty".into());
}
let target_bytes = hex::decode(&target)?;
if target_bytes.len() != 32 {
return Err("target must be 32 bytes (64 hex chars)".into());
}
let mut target_arr = [0u8; 32];
target_arr.copy_from_slice(&target_bytes);
let owner_bytes = hex::decode(&owner)?;
if owner_bytes.len() != 32 {
return Err("owner must be 32 bytes (64 hex chars)".into());
}
let mut owner_arr = [0u8; 32];
owner_arr.copy_from_slice(&owner_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_name_registry_propose(&name, target_arr, owner_arr)?;
let cell_id = truthlinked_core::pq_execution::name_registry_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::VoteName {
from,
name,
approve,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
if name.trim().is_empty() {
return Err("name must not be empty".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_name_registry_vote(&name, approve)?;
let cell_id = truthlinked_core::pq_execution::name_registry_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::RenewName { from, name } => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
if name.trim().is_empty() {
return Err("name must not be empty".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_name_registry_renew(&name)?;
let cell_id = truthlinked_core::pq_execution::name_registry_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::TransferName {
from,
name,
new_owner,
} => {
confirm_or_abort(cli.yes, output, "Transfer name; confirm")?;
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
if name.trim().is_empty() {
return Err("name must not be empty".into());
}
let new_owner_bytes = hex::decode(&new_owner)?;
if new_owner_bytes.len() != 32 {
return Err("new_owner must be 32 bytes (64 hex chars)".into());
}
let mut new_owner_arr = [0u8; 32];
new_owner_arr.copy_from_slice(&new_owner_bytes);
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let calldata = encode_name_registry_transfer(&name, new_owner_arr)?;
let cell_id = truthlinked_core::pq_execution::name_registry_system_cell_id();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = pq_execution::Transaction {
nonce,
sender: sender_account_id,
intent: pq_execution::TransactionIntent::CallCell {
cell_id,
calldata,
value: 0,
gas_limit: SYSTEM_CONTROLLER_GAS_LIMIT,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::ProposeUrl {
from,
url_pattern,
bond,
voting_period_blocks,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
if url_pattern.trim().is_empty() {
return Err("URL_pattern must not be empty".into());
}
let bond_amount = parse_amount_str(&bond)?;
if bond_amount == 0 {
return Err("bond must be > 0".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = Transaction {
nonce,
sender: sender_account_id,
intent: TransactionIntent::ProposeUrl {
url_pattern: url_pattern.clone(),
bond_amount,
voting_period_blocks,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
eprintln!("Submitting URL proposal...");
let res: Value = post_bytes(
&client,
&format!("{}/submit_raw", rpc),
tx_bytes,
cli.retries,
)?;
print_output(&res, output)?;
}
Commands::VoteUrl {
from,
url_pattern,
approve,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
if url_pattern.trim().is_empty() {
return Err("URL_pattern must not be empty".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = Transaction {
nonce,
sender: sender_account_id,
intent: TransactionIntent::VoteUrl {
url_pattern: url_pattern.clone(),
approve,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
eprintln!("Submitting URL vote...");
let res: Value = client
.post(format!("{}/submit_raw", rpc))
.body(tx_bytes)
.send()?
.json()?;
print_output(&res, output)?;
}
Commands::ReportMaliciousUrl {
from,
url_pattern,
evidence,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
if url_pattern.trim().is_empty() {
return Err("URL_pattern must not be empty".into());
}
if evidence.trim().is_empty() {
return Err("evidence must not be empty".into());
}
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = Transaction {
nonce,
sender: sender_account_id,
intent: TransactionIntent::ReportMaliciousUrl {
url_pattern: url_pattern.clone(),
evidence: evidence.clone(),
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
eprintln!("Submitting malicious URL report...");
let res: Value = client
.post(format!("{}/submit_raw", rpc))
.body(tx_bytes)
.send()?
.json()?;
print_output(&res, output)?;
}
Commands::UpgradeVisibility {
from,
cell_id,
public,
} => {
let sender_keys = pq_identity::DualKeypair::load(&from)?;
let sender_pubkey = sender_keys.dilithium_pk.clone().into_bytes().to_vec();
let sender_account_id = pq_identity::account_id_from_pubkey(&sender_pubkey);
let cell_id_bytes = hex::decode(&cell_id)?;
if cell_id_bytes.len() != 32 {
return Err("cell_id must be 32-byte hex".into());
}
let mut cell_id_arr = [0u8; 32];
cell_id_arr.copy_from_slice(&cell_id_bytes);
require_cell_exists(&client, &rpc, &cell_id, cli.retries)?;
let genesis_hash = fetch_genesis_hash(&client, &rpc, cli.retries)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let visibility = if public { 1 } else { 0 };
let nonce = next_nonce(&client, &rpc, &sender_account_id, cli.retries)?;
let tx = Transaction {
nonce,
sender: sender_account_id,
intent: TransactionIntent::SetCellVisibility {
cell_id: cell_id_arr,
visibility,
},
signature: vec![],
timestamp,
genesis_fingerprint: genesis_hash,
expiration_height: u64::MAX,
};
let signed_tx = sender_keys.sign_transaction(&tx)?;
let tx_bytes = postcard::to_allocvec(&signed_tx)?;
eprintln!("Submitting visibility upgrade...");
let res: Value = client
.post(format!("{}/submit_raw", rpc))
.body(tx_bytes)
.send()?
.json()?;
print_output(&res, output)?;
}
Commands::Build { source, output } => {
build_cell(&source, output.as_deref())?;
}
Commands::SDKNew { path } => {
let target_dir = std::path::Path::new(&path);
if target_dir.exists() {
return Err(format!("Target path already exists: {}", path).into());
}
std::fs::create_dir_all(target_dir)?;
write_embedded_dir(&SDK_TEMPLATE_DIR, target_dir)?;
println!(" SDK cell template created at {}", path);
println!(" Build: axiom sdk-build --path {}", path);
}
Commands::SDKBuild { path, output } => {
let (axiom_path, manifest_path) = sdk_build_project(&path, output.as_deref())?;
println!(" SDK build complete");
println!(" Bytecode: {}", axiom_path);
println!(" Manifest: {}", manifest_path);
}
Commands::SDKDeploy {
from,
cell_id,
path,
bytecode_file,
initial_balance,
manifest_file,
skip_build,
} => {
let (axiom_path, built_manifest_path) = if let Some(wasm) = bytecode_file {
(wasm, None)
} else if skip_build {
(sdk_locate_axiom(&path)?, None)
} else {
let (wasm, manifest) = sdk_build_project(&path, None)?;
(wasm, Some(manifest))
};
let manifest_path =
resolve_manifest_path(&axiom_path, manifest_file.or(built_manifest_path));
submit_cell_deploy(
&client,
&rpc,
&from,
&cell_id,
&axiom_path,
manifest_path,
initial_balance,
output,
cli.retries,
)?;
}
Commands::ManifestInit { bytecode_file } => {
let bytecode = std::fs::read(&bytecode_file)?;
let analysis = truthlinked_core::cells::CellAccount::analyze_bytecode(&bytecode)?;
let reads_json: Vec<serde_json::Value> = analysis
.static_read_slots
.iter()
.map(|s| serde_json::Value::String(hex::encode(s)))
.collect();
let writes_json: Vec<serde_json::Value> = analysis
.static_write_slots
.iter()
.map(|s| serde_json::Value::String(hex::encode(s)))
.collect();
let resolution_note = if !analysis.has_storage_reads && !analysis.has_storage_writes {
"No storage operations detected. Empty manifest is valid."
} else if analysis.fully_resolved {
"All storage slot addresses resolved from bytecode data section. Manifest is complete."
} else {
"PARTIAL: some storage calls use dynamic slot addresses that cannot be resolved statically. \
Review and complete declared_reads/declared_writes manually, \
or use the TruthLinked Rust SDK (storage_slot! macro) for full static resolution."
};
let manifest = serde_json::json!({
"declared_reads": reads_json,
"declared_writes": writes_json,
"commutative_keys": [],
"storage_key_specs": [],
"oracle_schema_ids": [],
"_resolution": resolution_note,
"_static_analysis": {
"has_storage_reads": analysis.has_storage_reads,
"has_storage_writes": analysis.has_storage_writes,
"fully_resolved": analysis.fully_resolved,
"resolved_read_count": analysis.static_read_slots.len(),
"resolved_write_count": analysis.static_write_slots.len(),
}
});
let manifest_path = bytecode_file.replace(".axiom", ".manifest.json");
std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?;
println!("Manifest written: {}", manifest_path);
if analysis.fully_resolved {
println!("Static resolution: COMPLETE");
println!(
" Read slots resolved: {}",
analysis.static_read_slots.len()
);
println!(
" Write slots resolved: {}",
analysis.static_write_slots.len()
);
} else if analysis.has_storage_reads || analysis.has_storage_writes {
println!("Static resolution: PARTIAL");
println!(" Resolved reads: {}", analysis.static_read_slots.len());
println!(" Resolved writes: {}", analysis.static_write_slots.len());
println!(
" Action: fill remaining slots manually or use the SDK storage_slot! macro."
);
} else {
println!("No storage operations. Manifest is complete.");
}
}
Commands::ManifestVerify {
bytecode_file,
manifest_file,
} => {
let bytecode = std::fs::read(&bytecode_file)?;
let (
declared_reads,
declared_writes,
_commutative_keys,
storage_key_specs,
_oracle_schema_ids,
) = load_manifest_sets(&manifest_file)?;
match truthlinked_core::cells::CellAccount::verify_manifest_against_bytecode(
&bytecode,
&declared_reads,
&declared_writes,
&storage_key_specs,
) {
Ok(()) => {
let analysis =
truthlinked_core::cells::CellAccount::analyze_bytecode(&bytecode)?;
println!("Manifest verification PASSED");
println!(" Bytecode: {}", bytecode_file);
println!(" Manifest: {}", manifest_file);
println!(" Declared reads: {}", declared_reads.len());
println!(" Declared writes: {}", declared_writes.len());
println!(" Key specs: {}", storage_key_specs.len());
if analysis.fully_resolved {
println!(" Static analysis: FULL ({} read / {} write slots confirmed in bytecode)",
analysis.static_read_slots.len(),
analysis.static_write_slots.len());
} else if analysis.has_storage_reads || analysis.has_storage_writes {
println!(" Static analysis: PARTIAL - dynamic calls accepted on declared trust.");
println!(" Use the SDK storage_slot! macro to enable full bytecode enforcement.");
} else {
println!(" Static analysis: No storage operations detected.");
}
}
Err(e) => {
eprintln!("Manifest verification FAILED");
eprintln!(" Reason: {}", e);
std::process::exit(1);
}
}
}
Commands::ManifestHash {
bytecode_file,
manifest_file,
} => {
let bytecode = std::fs::read(&bytecode_file)?;
let (
declared_reads,
declared_writes,
commutative_keys,
_storage_key_specs,
oracle_schema_ids,
) = load_manifest_sets(&manifest_file)?;
let manifest_hash = truthlinked_core::cells::CellAccount::compute_manifest_hash(
&bytecode,
&declared_reads,
&declared_writes,
&commutative_keys,
&oracle_schema_ids,
);
println!(" Manifest Hash: {}", hex::encode(&manifest_hash));
println!(" Bytecode: {}", bytecode_file);
println!(" Manifest: {}", manifest_file);
println!(" This hash will be stored on-chain at deploy/upgrade.");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_cmd(args: &[&str]) -> (OutputFormat, Commands) {
let mut full = vec!["axiom"];
full.extend_from_slice(args);
let cli = Cli::parse_from(full);
(resolve_output(&cli), cli.command)
}
#[test]
fn parse_package_name_uses_toml() {
let tmp = std::env::temp_dir().join(format!(
"axiom_pkg_{}.toml",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let content = r#"
[package]
name = "my_cell"
version = "0.1.0"
[package.metadata]
name = "not_this_one"
"#;
std::fs::write(&tmp, content).unwrap();
let name = parse_package_name(&tmp).unwrap();
assert_eq!(name, "my_cell");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn json_string_respects_output_format() {
let value = serde_json::json!({"ok": true, "n": 1});
let compact = json_string(&value, OutputFormat::Json).unwrap();
let pretty = json_string(&value, OutputFormat::Pretty).unwrap();
assert!(compact.contains("\"ok\":true"));
assert!(pretty.contains("\n"));
}
#[test]
fn call_chain_total_calldata_limit_enforced() {
let max = constants::MAX_CALL_CHAIN_TOTAL_CALLDATA;
let call1 = serde_json::json!({
"cell": "00".repeat(32),
"calldata": hex::encode(vec![0u8; max]),
"value": 0
});
let call2 = serde_json::json!({
"cell": "11".repeat(32),
"calldata": "00",
"value": 0
});
let calls = serde_json::json!([call1, call2]).to_string();
let err = parse_call_chain_json(&calls).expect_err("should exceed total calldata");
assert!(err.to_string().contains("total calldata"));
}
#[test]
fn parse_output_format_flag() {
let (output, cmd) = parse_cmd(&["--output", "json", "chain-info"]);
assert!(matches!(output, OutputFormat::Json));
assert!(matches!(cmd, Commands::ChainInfo));
let (output, cmd) = parse_cmd(&["-j", "chain-info"]);
assert!(matches!(output, OutputFormat::Json));
assert!(matches!(cmd, Commands::ChainInfo));
}
#[test]
fn parse_basic_queries() {
assert!(matches!(parse_cmd(&["chain-info"]).1, Commands::ChainInfo));
assert!(matches!(parse_cmd(&["token-info"]).1, Commands::TokenInfo));
assert!(matches!(
parse_cmd(&["network-info"]).1,
Commands::NetworkInfo
));
assert!(matches!(parse_cmd(&["validators"]).1, Commands::Validators));
assert!(matches!(parse_cmd(&["mempool"]).1, Commands::Mempool));
assert!(matches!(parse_cmd(&["status"]).1, Commands::Status { .. }));
assert!(matches!(
parse_cmd(&["list-cell-proposals"]).1,
Commands::ListCellProposals
));
assert!(matches!(
parse_cmd(&["tx-status", "aa"]).1,
Commands::TxStatus { .. }
));
assert!(matches!(parse_cmd(&["tx", "aa"]).1, Commands::Tx { .. }));
assert!(matches!(
parse_cmd(&["balance", "aa"]).1,
Commands::Balance { .. }
));
assert!(matches!(
parse_cmd(&["balance-by-pubkey", "bb"]).1,
Commands::BalanceByPubkey { .. }
));
}
#[test]
fn parse_identity_commands() {
let cmd = parse_cmd(&["account-id", "--pubkey", "aa"]).1;
assert!(matches!(cmd, Commands::AccountId { .. }));
let cmd = parse_cmd(&["import-mnemonic", "--mnemonic", "word1 word2 word3"]).1;
assert!(matches!(cmd, Commands::ImportMnemonic { .. }));
let cmd = parse_cmd(&["account-create", "--encrypt"]).1;
assert!(matches!(cmd, Commands::AccountCreate { .. }));
}
#[test]
fn parse_transfer_commands() {
let cmd = parse_cmd(&[
"transfer",
"--from",
"k",
"--to-pubkey",
"aa",
"--amount",
"1",
])
.1;
assert!(matches!(cmd, Commands::Transfer { .. }));
let cmd = parse_cmd(&[
"transfer",
"--from",
"k",
"--to-name",
"alice.tl",
"--amount",
"1",
])
.1;
assert!(matches!(cmd, Commands::Transfer { .. }));
let cmd = parse_cmd(&["send", "alice.tl", "1"]).1;
assert!(matches!(cmd, Commands::Send { .. }));
let cmd = parse_cmd(&["deposit-compute", "--from", "k", "--amount", "1"]).1;
assert!(matches!(cmd, Commands::DepositCompute { .. }));
let cmd = parse_cmd(&["withdraw-compute", "--from", "k", "--amount", "1"]).1;
assert!(matches!(cmd, Commands::WithdrawCompute { .. }));
let cmd = parse_cmd(&[
"batch-transfer",
"--from",
"k",
"--to-pubkeys",
"aa,bb",
"--amounts",
"1,2",
])
.1;
assert!(matches!(cmd, Commands::BatchTransfer { .. }));
let cmd = parse_cmd(&["faucet", "--from", "k"]).1;
assert!(matches!(cmd, Commands::Faucet { from: Some(_), .. }));
let cmd = parse_cmd(&["faucet"]).1;
assert!(matches!(cmd, Commands::Faucet { from: None, .. }));
}
#[test]
fn parse_staking_commands() {
assert!(matches!(
parse_cmd(&["validator-setup", "--keys", "k", "--amount", "10"]).1,
Commands::ValidatorSetup { .. }
));
assert!(matches!(
parse_cmd(&["bond", "--from", "k", "--amount", "10"]).1,
Commands::Bond { .. }
));
assert!(matches!(
parse_cmd(&["stake", "10"]).1,
Commands::Stake { .. }
));
assert!(matches!(
parse_cmd(&["unbond", "--from", "k", "--amount", "10"]).1,
Commands::Unbond { .. }
));
assert!(matches!(
parse_cmd(&["withdraw", "--from", "k"]).1,
Commands::Withdraw { .. }
));
assert!(matches!(
parse_cmd(&["unjail", "--from", "k"]).1,
Commands::Unjail { .. }
));
}
#[test]
fn parse_nft_commands() {
assert!(matches!(
parse_cmd(&[
"mint-nft",
"--from",
"k",
"--nft-id",
"aa",
"--name",
"n",
"--metadata-uri",
"ipfs://x"
])
.1,
Commands::MintNft { .. }
));
assert!(matches!(
parse_cmd(&[
"transfer-nft",
"--from",
"k",
"--nft-id",
"aa",
"--to-pubkey",
"bb"
])
.1,
Commands::TransferNft { .. }
));
assert!(matches!(
parse_cmd(&["burn-nft", "--from", "k", "--nft-id", "aa"]).1,
Commands::BurnNft { .. }
));
assert!(matches!(
parse_cmd(&["approve-nft", "--from", "k", "--nft-id", "aa"]).1,
Commands::ApproveNft { .. }
));
}
#[test]
fn parse_cell_commands() {
assert!(matches!(
parse_cmd(&[
"deploy-cell",
"--from",
"k",
"--cell-id",
"aa",
"--source",
"src/lib.rs"
])
.1,
Commands::DeployCell { .. }
));
assert!(matches!(
parse_cmd(&["deploy", "aa", "src/lib.rs"]).1,
Commands::Deploy { .. }
));
assert!(matches!(
parse_cmd(&[
"deploy-token",
"--from",
"k",
"--cell-id",
"aa",
"--name",
"T",
"--symbol",
"T",
"--decimals",
"9",
"--supply",
"100"
])
.1,
Commands::DeployToken { .. }
));
assert!(matches!(
parse_cmd(&[
"call-cell",
"--from",
"k",
"--cell-id",
"aa",
"--calldata",
"00",
"--simulate"
])
.1,
Commands::CallCell { simulate: true, .. }
));
assert!(matches!(
parse_cmd(&[
"upgrade-cell",
"--from",
"k",
"--cell-id",
"aa",
"--bytecode-file",
"c.axiom"
])
.1,
Commands::UpgradeCell { .. }
));
assert!(matches!(
parse_cmd(&["rotate-key", "--from", "k", "--new-pubkey", "aa"]).1,
Commands::RotateKey { .. }
));
assert!(matches!(
parse_cmd(&["accept-ownership", "--from", "k", "--cell-id", "aa"]).1,
Commands::AcceptOwnership { .. }
));
assert!(matches!(
parse_cmd(&["make-immutable", "--from", "k", "--cell-id", "aa"]).1,
Commands::MakeImmutable { .. }
));
assert!(matches!(
parse_cmd(&["close-cell", "--from", "k", "--cell-id", "aa"]).1,
Commands::CloseCell { .. }
));
assert!(matches!(
parse_cmd(&[
"propose-cell-upgrade",
"--from",
"k",
"--cell-id",
"aa",
"--bytecode-file",
"c.axiom"
])
.1,
Commands::ProposeCellUpgrade { .. }
));
assert!(matches!(
parse_cmd(&[
"propose-cell-ownership-transfer",
"--from",
"k",
"--cell-id",
"aa",
"--new-owner",
"bb"
])
.1,
Commands::ProposeCellOwnershipTransfer { .. }
));
assert!(matches!(
parse_cmd(&[
"propose-cell-make-immutable",
"--from",
"k",
"--cell-id",
"aa"
])
.1,
Commands::ProposeCellMakeImmutable { .. }
));
assert!(matches!(
parse_cmd(&[
"vote-cell-proposal",
"--from",
"k",
"--cell-id",
"aa",
"--approve"
])
.1,
Commands::VoteCellProposal { .. }
));
assert!(matches!(
parse_cmd(&["execute-cell-proposal", "--from", "k", "--cell-id", "aa"]).1,
Commands::ExecuteCellProposal { .. }
));
}
#[test]
fn parse_token_commands() {
assert!(matches!(
parse_cmd(&[
"token-transfer",
"--from",
"k",
"--token",
"aa",
"--to",
"bb",
"--amount",
"1"
])
.1,
Commands::TokenTransfer { .. }
));
assert!(matches!(
parse_cmd(&[
"token-mint",
"--from",
"k",
"--token",
"aa",
"--to",
"bb",
"--amount",
"1"
])
.1,
Commands::TokenMint { .. }
));
assert!(matches!(
parse_cmd(&[
"token-burn",
"--from",
"k",
"--token",
"aa",
"--amount",
"1"
])
.1,
Commands::TokenBurn { .. }
));
assert!(matches!(
parse_cmd(&[
"propose-token-authority",
"--from",
"k",
"--token",
"aa",
"--mint-authority",
"bb"
])
.1,
Commands::ProposeTokenAuthority { .. }
));
assert!(matches!(
parse_cmd(&[
"vote-token-authority",
"--from",
"k",
"--token",
"aa",
"--approve"
])
.1,
Commands::VoteTokenAuthority { .. }
));
}
#[test]
fn parse_call_chain_and_names() {
assert!(matches!(
parse_cmd(&["call-chain", "--from", "k", "--calls", "[]", "--simulate"]).1,
Commands::CallChain { simulate: true, .. }
));
assert!(matches!(
parse_cmd(&[
"propose-name",
"--from",
"k",
"--name",
"n",
"--target",
"aa",
"--owner",
"bb"
])
.1,
Commands::ProposeName { .. }
));
assert!(matches!(
parse_cmd(&["vote-name", "--from", "k", "--name", "n", "--approve"]).1,
Commands::VoteName { .. }
));
assert!(matches!(
parse_cmd(&["renew-name", "--from", "k", "--name", "n"]).1,
Commands::RenewName { .. }
));
assert!(matches!(
parse_cmd(&[
"transfer-name",
"--from",
"k",
"--name",
"n",
"--new-owner",
"aa"
])
.1,
Commands::TransferName { .. }
));
}
#[test]
fn parse_url_governance_and_visibility() {
assert!(matches!(
parse_cmd(&[
"propose-url",
"--from",
"k",
"--url-pattern",
"https://x/*",
"--bond",
"1"
])
.1,
Commands::ProposeUrl { .. }
));
assert!(matches!(
parse_cmd(&[
"vote-url",
"--from",
"k",
"--url-pattern",
"https://x/*",
"--approve"
])
.1,
Commands::VoteUrl { .. }
));
assert!(matches!(
parse_cmd(&[
"report-malicious-url",
"--from",
"k",
"--url-pattern",
"https://x/*"
])
.1,
Commands::ReportMaliciousUrl { .. }
));
assert!(matches!(
parse_cmd(&["upgrade-visibility", "--from", "k", "--cell-id", "aa"]).1,
Commands::UpgradeVisibility { .. }
));
}
#[test]
fn parse_mcp_private_balance_commands() {
let sender_cell = "11".repeat(32);
let sender_agent = "22".repeat(32);
let recipient_cell = "33".repeat(32);
let amount_commitment = "44".repeat(32);
let sender_enc = "55".repeat(44);
let sender_new_commitment = "66".repeat(32);
let sender_nonce = "77".repeat(16);
let sender_old_commitment = "88".repeat(32);
let recipient_enc = "99".repeat(44);
let recipient_new_commitment = "aa".repeat(32);
let recipient_nonce = "bb".repeat(16);
let recipient_old_commitment = "cc".repeat(32);
assert!(matches!(
parse_cmd(&[
"mcp",
"private-balance-confidential-transfer",
"--from",
"k",
"--sender-cell-id",
&sender_cell,
"--sender-agent-id",
&sender_agent,
"--recipient-cell-id",
&recipient_cell,
"--amount-commitment",
&amount_commitment,
"--proof-hex",
"aa",
"--sender-new-encrypted",
&sender_enc,
"--sender-new-commitment",
&sender_new_commitment,
"--sender-new-commit-nonce",
&sender_nonce,
"--sender-old-commitment",
&sender_old_commitment,
"--recipient-new-encrypted",
&recipient_enc,
"--recipient-new-commitment",
&recipient_new_commitment,
"--recipient-new-commit-nonce",
&recipient_nonce,
"--recipient-old-commitment",
&recipient_old_commitment,
])
.1,
Commands::Mcp { .. }
));
}
#[test]
fn parse_sdk_and_manifest_commands() {
assert!(matches!(
parse_cmd(&["build", "--source", "src/lib.rs"]).1,
Commands::Build { .. }
));
assert!(matches!(
parse_cmd(&["sdk-new", "--path", "p"]).1,
Commands::SDKNew { .. }
));
assert!(matches!(
parse_cmd(&["sdk-build", "--path", "p"]).1,
Commands::SDKBuild { .. }
));
assert!(matches!(
parse_cmd(&[
"sdk-deploy",
"--from",
"k",
"--cell-id",
"aa",
"--path",
"p"
])
.1,
Commands::SDKDeploy { .. }
));
assert!(matches!(
parse_cmd(&["manifest-init", "--bytecode-file", "c.axiom"]).1,
Commands::ManifestInit { .. }
));
assert!(matches!(
parse_cmd(&[
"manifest-verify",
"--bytecode-file",
"c.axiom",
"--manifest-file",
"m.json"
])
.1,
Commands::ManifestVerify { .. }
));
assert!(matches!(
parse_cmd(&[
"manifest-hash",
"--bytecode-file",
"c.axiom",
"--manifest-file",
"m.json"
])
.1,
Commands::ManifestHash { .. }
));
}
}