feat(token): add Logos token API module

This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-19 17:25:46 +02:00
committed by r4bbit
parent 72a3e741a0
commit 508e0c123f
27 changed files with 3627 additions and 81 deletions
+138
View File
@@ -0,0 +1,138 @@
use nssa_core::{
account::{Account, AccountId, Data, Nonce},
program::ProgramId,
};
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AccountRead {
pub id: String,
pub status: String,
#[serde(default)]
pub account: Option<WalletAccount>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct WalletAccount {
pub program_owner: String,
pub balance: String,
pub nonce: String,
pub data: String,
}
pub(crate) fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> {
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(format!(
"{label} must be 64 lowercase hexadecimal characters"
));
}
let mut bytes = [0_u8; 32];
hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?;
Ok(bytes)
}
pub(crate) fn parse_program_id(value: &str) -> Result<ProgramId, String> {
let bytes = parse_hex_32(value, "program id")?;
let mut program_id = [0_u32; 8];
for (word, chunk) in program_id.iter_mut().zip(bytes.chunks_exact(4)) {
let chunk: [u8; 4] = chunk
.try_into()
.map_err(|_| String::from("program id word has invalid length"))?;
*word = u32::from_le_bytes(chunk);
}
Ok(program_id)
}
pub(crate) fn program_id_bytes(program_id: ProgramId) -> [u8; 32] {
let mut bytes = [0_u8; 32];
for (chunk, word) in bytes.chunks_exact_mut(4).zip(program_id) {
chunk.copy_from_slice(&word.to_le_bytes());
}
bytes
}
pub(crate) fn account_id_from_hex(value: &str, label: &str) -> Result<AccountId, String> {
Ok(AccountId::new(parse_hex_32(value, label)?))
}
pub(crate) fn account_id_hex(account_id: AccountId) -> String {
hex::encode(account_id.into_value())
}
#[cfg(test)]
pub(crate) fn program_id_hex(program_id: ProgramId) -> String {
hex::encode(program_id_bytes(program_id))
}
fn parse_le_u128(value: &str, label: &str) -> Result<u128, String> {
if value.len() != 32
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(format!(
"{label} must be 32 lowercase hexadecimal characters"
));
}
let mut bytes = [0_u8; 16];
hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?;
Ok(u128::from_le_bytes(bytes))
}
pub(crate) fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String> {
if read.status != "ok" {
return Err(String::from("account read failed"));
}
let account_id = account_id_from_hex(&read.id, "account id")?;
let source = read
.account
.as_ref()
.ok_or_else(|| String::from("successful account read has no account"))?;
let program_owner = parse_program_id(&source.program_owner)?;
let balance = parse_le_u128(&source.balance, "account balance")?;
let nonce = parse_le_u128(&source.nonce, "account nonce")?;
if source.data.len() % 2 != 0
|| !source
.data
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(String::from(
"account data must be lowercase even-length hexadecimal",
));
}
let data =
hex::decode(&source.data).map_err(|error| format!("invalid account data: {error}"))?;
let data =
Data::try_from(data).map_err(|error| format!("account data is too large: {error}"))?;
Ok((
account_id,
Account {
program_owner,
balance,
data,
nonce: Nonce(nonce),
},
))
}
#[cfg(test)]
pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead {
AccountRead {
id: account_id_hex(id),
status: String::from("ok"),
account: Some(WalletAccount {
program_owner: program_id_hex(account.program_owner),
balance: hex::encode(account.balance.to_le_bytes()),
nonce: hex::encode(account.nonce.0.to_le_bytes()),
data: hex::encode(account.data.as_ref()),
}),
}
}
+178
View File
@@ -0,0 +1,178 @@
use nssa_core::{
account::{Account, AccountId},
program::ProgramId,
};
use serde_json::{json, Value};
use token_core::{MetadataStandard, TokenDefinition, TokenHolding, TokenMetadata};
use super::{
parse_token_program_id,
request::{
DecodeAccountRequest, DecodeDefinitionRequest, DecodeHoldingRequest, DecodeMetadataRequest,
},
TokenApiError, TokenResult,
};
use crate::account::{account_id_hex, decode_account as decode_wallet_account};
pub fn decode_definition(request: DecodeDefinitionRequest) -> TokenResult {
let token_program_id = parse_token_program_id(&request.token_program_id)?;
let (account_id, account) = parse_and_validate_account(&request.definition, token_program_id)?;
let definition = TokenDefinition::try_from(&account.data)
.map_err(|_| TokenApiError::new("invalid_definition_data"))?;
Ok(definition_json(account_id, &definition))
}
pub fn decode_holding(request: DecodeHoldingRequest) -> TokenResult {
let token_program_id = parse_token_program_id(&request.token_program_id)?;
let (account_id, account) = parse_and_validate_account(&request.holding, token_program_id)?;
let holding = TokenHolding::try_from(&account.data)
.map_err(|_| TokenApiError::new("invalid_holding_data"))?;
Ok(holding_json(account_id, &holding))
}
pub fn decode_metadata(request: DecodeMetadataRequest) -> TokenResult {
let token_program_id = parse_token_program_id(&request.token_program_id)?;
let (account_id, account) = parse_and_validate_account(&request.metadata, token_program_id)?;
let metadata = TokenMetadata::try_from(&account.data)
.map_err(|_| TokenApiError::new("invalid_metadata_data"))?;
Ok(metadata_json(account_id, &metadata))
}
pub fn decode_account(request: DecodeAccountRequest) -> TokenResult {
let token_program_id = parse_token_program_id(&request.token_program_id)?;
let (account_id, account) = parse_and_validate_account(&request.account, token_program_id)?;
let definition = TokenDefinition::try_from(&account.data).ok();
let holding = TokenHolding::try_from(&account.data).ok();
let metadata = TokenMetadata::try_from(&account.data).ok();
let matches = usize::from(definition.is_some())
+ usize::from(holding.is_some())
+ usize::from(metadata.is_some());
match (matches, definition, holding, metadata) {
(1, Some(value), None, None) => Ok(definition_json(account_id, &value)),
(1, None, Some(value), None) => Ok(holding_json(account_id, &value)),
(1, None, None, Some(value)) => Ok(metadata_json(account_id, &value)),
(0, None, None, None) => Err(TokenApiError::new("invalid_account_data")),
_ => Err(TokenApiError::new("ambiguous_account_type")),
}
}
fn parse_and_validate_account(
read: &crate::account::AccountRead,
token_program_id: ProgramId,
) -> Result<(AccountId, Account), TokenApiError> {
let (account_id, account) = decode_wallet_account(read).map_err(map_account_read_error)?;
if account.program_owner != token_program_id {
return Err(TokenApiError::new("token_program_mismatch"));
}
Ok((account_id, account))
}
fn map_account_read_error(error: String) -> TokenApiError {
if error == "account read failed" {
TokenApiError::new("account_read_failed")
} else {
TokenApiError::new("bad_request")
}
}
fn definition_json(account_id: AccountId, definition: &TokenDefinition) -> Value {
let account_hex = account_id_hex(account_id);
match definition {
TokenDefinition::Fungible {
name,
total_supply,
metadata_id,
authority,
} => json!({
"accountType": "definition",
"kind": "fungible",
"accountId": account_id.to_string(),
"accountIdHex": account_hex,
"name": name,
"totalSupplyRaw": total_supply.to_string(),
"metadataId": metadata_id.map(|value| value.to_string()),
"metadataIdHex": metadata_id.map(account_id_hex),
"mintAuthorityId": authority.map(|value| value.to_string()),
"mintAuthorityIdHex": authority.map(account_id_hex),
}),
TokenDefinition::NonFungible {
name,
printable_supply,
metadata_id,
} => json!({
"accountType": "definition",
"kind": "nonFungible",
"accountId": account_id.to_string(),
"accountIdHex": account_hex,
"name": name,
"printableSupplyRaw": printable_supply.to_string(),
"metadataId": metadata_id.to_string(),
"metadataIdHex": account_id_hex(*metadata_id),
}),
}
}
fn holding_json(account_id: AccountId, holding: &TokenHolding) -> Value {
let account_hex = account_id_hex(account_id);
match holding {
TokenHolding::Fungible {
definition_id,
balance,
} => json!({
"accountType": "holding",
"kind": "fungible",
"accountId": account_id.to_string(),
"accountIdHex": account_hex,
"definitionId": definition_id.to_string(),
"definitionIdHex": account_id_hex(*definition_id),
"balanceRaw": balance.to_string(),
}),
TokenHolding::NftMaster {
definition_id,
print_balance,
} => json!({
"accountType": "holding",
"kind": "nftMaster",
"accountId": account_id.to_string(),
"accountIdHex": account_hex,
"definitionId": definition_id.to_string(),
"definitionIdHex": account_id_hex(*definition_id),
"printBalanceRaw": print_balance.to_string(),
}),
TokenHolding::NftPrintedCopy {
definition_id,
owned,
} => json!({
"accountType": "holding",
"kind": "nftPrintedCopy",
"accountId": account_id.to_string(),
"accountIdHex": account_hex,
"definitionId": definition_id.to_string(),
"definitionIdHex": account_id_hex(*definition_id),
"owned": owned,
}),
}
}
fn metadata_json(account_id: AccountId, metadata: &TokenMetadata) -> Value {
json!({
"accountType": "metadata",
"accountId": account_id.to_string(),
"accountIdHex": account_id_hex(account_id),
"definitionId": metadata.definition_id.to_string(),
"definitionIdHex": account_id_hex(metadata.definition_id),
"standard": metadata_standard_name(&metadata.standard),
"uri": metadata.uri,
"creators": metadata.creators,
"primarySaleDateRaw": metadata.primary_sale_date.to_string(),
})
}
fn metadata_standard_name(value: &MetadataStandard) -> &'static str {
match value {
MetadataStandard::Simple => "simple",
MetadataStandard::Expanded => "expanded",
}
}
+59
View File
@@ -0,0 +1,59 @@
//! Transport-independent token client operations.
mod decode;
mod plan;
mod request;
#[cfg(test)]
mod tests;
use std::{error::Error, fmt};
pub use decode::{decode_account, decode_definition, decode_holding, decode_metadata};
pub use plan::{
burn_plan, create_fungible_plan, create_fungible_with_metadata_plan, create_non_fungible_plan,
initialize_holding_plan, mint_plan, mint_with_authority_plan, print_nft_plan, program_id,
set_authority_plan, set_authority_with_authority_plan, transfer_plan,
};
pub use request::{
BurnPlanRequest, CreateFungiblePlanRequest, CreateFungibleWithMetadataPlanRequest,
CreateNonFungiblePlanRequest, DecodeAccountRequest, DecodeDefinitionRequest,
DecodeHoldingRequest, DecodeMetadataRequest, InitializeHoldingPlanRequest, MintPlanRequest,
MintWithAuthorityPlanRequest, PrintNftPlanRequest, ProgramIdRequest, SetAuthorityPlanRequest,
SetAuthorityWithAuthorityPlanRequest, TransferPlanRequest,
};
use serde_json::Value;
use crate::account::parse_program_id;
pub type TokenResponse = Value;
pub type TokenResult = Result<TokenResponse, TokenApiError>;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TokenApiError {
code: &'static str,
}
impl TokenApiError {
#[must_use]
pub const fn new(code: &'static str) -> Self {
Self { code }
}
#[must_use]
pub const fn code(&self) -> &'static str {
self.code
}
}
impl fmt::Display for TokenApiError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.code)
}
}
impl Error for TokenApiError {}
fn parse_token_program_id(value: &str) -> Result<nssa_core::program::ProgramId, TokenApiError> {
parse_program_id(value).map_err(|_| TokenApiError::new("bad_request"))
}
+314
View File
@@ -0,0 +1,314 @@
use nssa_core::account::AccountId;
use risc0_binfmt::ProgramBinary;
use serde_json::{json, Value};
use token_core::{Instruction, MetadataStandard, NewTokenDefinition, NewTokenMetadata};
use super::{
parse_token_program_id,
request::{
BurnPlanRequest, CreateFungiblePlanRequest, CreateFungibleWithMetadataPlanRequest,
CreateNonFungiblePlanRequest, InitializeHoldingPlanRequest, MintPlanRequest,
MintWithAuthorityPlanRequest, PrintNftPlanRequest, ProgramIdRequest,
SetAuthorityPlanRequest, SetAuthorityWithAuthorityPlanRequest, TransferPlanRequest,
},
TokenApiError, TokenResult,
};
use crate::account::{account_id_from_hex, account_id_hex, program_id_bytes};
pub fn program_id(request: ProgramIdRequest) -> TokenResult {
let elf = hex::decode(&request.elf).map_err(|_| TokenApiError::new("bad_request"))?;
let binary = ProgramBinary::decode(&elf).map_err(|_| TokenApiError::new("bad_request"))?;
let image_id: nssa_core::program::ProgramId = binary
.compute_image_id()
.map_err(|_| TokenApiError::new("backend_error"))?
.into();
let program_id = AccountId::new(program_id_bytes(image_id));
Ok(json!({
"programId": hex::encode(program_id.into_value()),
"programIdBase58": program_id.to_string(),
}))
}
pub fn create_fungible_plan(request: CreateFungiblePlanRequest) -> TokenResult {
let program_id = parse_token_program_id(&request.token_program_id)?;
let definition_target =
parse_account_id(&request.definition_target_id, "definition target id")?;
let holding_target = parse_account_id(&request.holding_target_id, "holding target id")?;
let total_supply = parse_amount(&request.total_supply_raw)?;
let mint_authority = parse_authority_sentinel(&request.mint_authority, definition_target)?;
let instruction = Instruction::NewFungibleDefinition {
name: request.name,
total_supply,
mint_authority,
};
plan_response(
program_id,
[definition_target, holding_target],
[true, true],
instruction,
)
}
pub fn create_fungible_with_metadata_plan(
request: CreateFungibleWithMetadataPlanRequest,
) -> TokenResult {
let program_id = parse_token_program_id(&request.token_program_id)?;
let definition_target =
parse_account_id(&request.definition_target_id, "definition target id")?;
let holding_target = parse_account_id(&request.holding_target_id, "holding target id")?;
let metadata_target = parse_account_id(&request.metadata_target_id, "metadata target id")?;
let total_supply = parse_amount(&request.total_supply_raw)?;
let mint_authority = parse_authority_sentinel(&request.mint_authority, definition_target)?;
let metadata_standard = parse_metadata_standard(&request.metadata_standard)?;
let instruction = Instruction::NewDefinitionWithMetadata {
new_definition: NewTokenDefinition::Fungible {
name: request.name,
total_supply,
mint_authority,
},
metadata: Box::new(NewTokenMetadata {
standard: metadata_standard,
uri: request.uri,
creators: request.creators,
}),
};
plan_response(
program_id,
[definition_target, holding_target, metadata_target],
[true, true, true],
instruction,
)
}
pub fn create_non_fungible_plan(request: CreateNonFungiblePlanRequest) -> TokenResult {
let program_id = parse_token_program_id(&request.token_program_id)?;
let definition_target =
parse_account_id(&request.definition_target_id, "definition target id")?;
let master_target = parse_account_id(
&request.master_holding_target_id,
"master holding target id",
)?;
let metadata_target = parse_account_id(&request.metadata_target_id, "metadata target id")?;
let printable_supply = parse_amount(&request.printable_supply_raw)?;
let metadata_standard = parse_metadata_standard(&request.metadata_standard)?;
let instruction = Instruction::NewDefinitionWithMetadata {
new_definition: NewTokenDefinition::NonFungible {
name: request.name,
printable_supply,
},
metadata: Box::new(NewTokenMetadata {
standard: metadata_standard,
uri: request.uri,
creators: request.creators,
}),
};
plan_response(
program_id,
[definition_target, master_target, metadata_target],
[true, true, true],
instruction,
)
}
pub fn initialize_holding_plan(request: InitializeHoldingPlanRequest) -> TokenResult {
let program_id = parse_token_program_id(&request.token_program_id)?;
let definition_id = parse_account_id(&request.definition_id, "definition id")?;
let holding_target = parse_account_id(&request.holding_target_id, "holding target id")?;
plan_response(
program_id,
[definition_id, holding_target],
[false, true],
Instruction::InitializeAccount,
)
}
pub fn transfer_plan(request: TransferPlanRequest) -> TokenResult {
let program_id = parse_token_program_id(&request.token_program_id)?;
let sender = parse_account_id(&request.sender_holding_id, "sender holding id")?;
let recipient = parse_account_id(&request.recipient_holding_id, "recipient holding id")?;
let amount = parse_amount(&request.amount_raw)?;
plan_response(
program_id,
[sender, recipient],
[true, request.recipient_is_fresh],
Instruction::Transfer {
amount_to_transfer: amount,
},
)
}
pub fn burn_plan(request: BurnPlanRequest) -> TokenResult {
let program_id = parse_token_program_id(&request.token_program_id)?;
let definition = parse_account_id(&request.definition_id, "definition id")?;
let holding = parse_account_id(&request.holding_id, "holding id")?;
let amount = parse_amount(&request.amount_raw)?;
plan_response(
program_id,
[definition, holding],
[false, true],
Instruction::Burn {
amount_to_burn: amount,
},
)
}
pub fn mint_plan(request: MintPlanRequest) -> TokenResult {
let program_id = parse_token_program_id(&request.token_program_id)?;
let definition = parse_account_id(&request.definition_id, "definition id")?;
let holding = parse_account_id(&request.holding_id, "holding id")?;
let amount = parse_amount(&request.amount_raw)?;
plan_response(
program_id,
[definition, holding],
[true, request.holding_is_fresh],
Instruction::Mint {
amount_to_mint: amount,
},
)
}
pub fn mint_with_authority_plan(request: MintWithAuthorityPlanRequest) -> TokenResult {
let program_id = parse_token_program_id(&request.token_program_id)?;
let definition = parse_account_id(&request.definition_id, "definition id")?;
let holding = parse_account_id(&request.holding_id, "holding id")?;
let authority = parse_account_id(&request.authority_id, "authority id")?;
reject_zero_account_id(authority, "invalid_authority")?;
let amount = parse_amount(&request.amount_raw)?;
plan_response(
program_id,
[definition, holding, authority],
[false, request.holding_is_fresh, true],
Instruction::MintWithAuthority {
amount_to_mint: amount,
},
)
}
pub fn set_authority_plan(request: SetAuthorityPlanRequest) -> TokenResult {
let program_id = parse_token_program_id(&request.token_program_id)?;
let definition = parse_account_id(&request.definition_id, "definition id")?;
let new_authority = parse_authority_sentinel(&request.new_authority, definition)?;
plan_response(
program_id,
[definition],
[true],
Instruction::SetAuthority { new_authority },
)
}
pub fn set_authority_with_authority_plan(
request: SetAuthorityWithAuthorityPlanRequest,
) -> TokenResult {
let program_id = parse_token_program_id(&request.token_program_id)?;
let definition = parse_account_id(&request.definition_id, "definition id")?;
let authority = parse_account_id(&request.authority_id, "authority id")?;
reject_zero_account_id(authority, "invalid_authority")?;
let new_authority = parse_authority_sentinel(&request.new_authority, definition)?;
plan_response(
program_id,
[definition, authority],
[false, true],
Instruction::SetAuthorityWithAuthority { new_authority },
)
}
pub fn print_nft_plan(request: PrintNftPlanRequest) -> TokenResult {
let program_id = parse_token_program_id(&request.token_program_id)?;
let master = parse_account_id(&request.master_holding_id, "master holding id")?;
let printed = parse_account_id(
&request.printed_holding_target_id,
"printed holding target id",
)?;
plan_response(
program_id,
[master, printed],
[true, true],
Instruction::PrintNft,
)
}
fn parse_account_id(value: &str, label: &str) -> Result<AccountId, TokenApiError> {
account_id_from_hex(value, label).map_err(|_| TokenApiError::new("invalid_account_id"))
}
fn parse_amount(value: &Value) -> Result<u128, TokenApiError> {
match value {
Value::String(raw) => parse_amount_string(raw),
Value::Number(raw) => raw
.as_u64()
.map(u128::from)
.ok_or_else(|| TokenApiError::new("bad_amount")),
_ => Err(TokenApiError::new("bad_amount")),
}
}
fn parse_amount_string(value: &str) -> Result<u128, TokenApiError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(TokenApiError::new("bad_amount"));
}
let normalized = if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
}
.trim();
if normalized.is_empty() || !normalized.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(TokenApiError::new("bad_amount"));
}
normalized
.parse::<u128>()
.map_err(|_| TokenApiError::new("bad_amount"))
}
fn parse_metadata_standard(value: &str) -> Result<MetadataStandard, TokenApiError> {
match value.trim().to_ascii_lowercase().as_str() {
"simple" => Ok(MetadataStandard::Simple),
"expanded" => Ok(MetadataStandard::Expanded),
_ => Err(TokenApiError::new("invalid_metadata_standard")),
}
}
fn parse_authority_sentinel(
value: &str,
self_id: AccountId,
) -> Result<Option<AccountId>, TokenApiError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(TokenApiError::new("invalid_authority"));
}
if trimmed.eq_ignore_ascii_case("none") {
return Ok(None);
}
if trimmed.eq_ignore_ascii_case("self") {
reject_zero_account_id(self_id, "invalid_authority")?;
return Ok(Some(self_id));
}
let authority = parse_account_id(trimmed, "authority id")
.map_err(|_| TokenApiError::new("invalid_authority"))?;
reject_zero_account_id(authority, "invalid_authority")?;
Ok(Some(authority))
}
fn reject_zero_account_id(account_id: AccountId, code: &'static str) -> Result<(), TokenApiError> {
if account_id.value() == &[0_u8; 32] {
return Err(TokenApiError::new(code));
}
Ok(())
}
fn plan_response<const N: usize>(
program_id: nssa_core::program::ProgramId,
account_ids: [AccountId; N],
signing_requirements: [bool; N],
instruction: Instruction,
) -> TokenResult {
let instruction =
risc0_zkvm::serde::to_vec(&instruction).map_err(|_| TokenApiError::new("backend_error"))?;
Ok(json!({
"programId": hex::encode(program_id_bytes(program_id)),
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
"signingRequirements": signing_requirements.into_iter().collect::<Vec<_>>(),
"instruction": instruction,
}))
}
+154
View File
@@ -0,0 +1,154 @@
use serde::Deserialize;
use serde_json::Value;
use crate::account::AccountRead;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProgramIdRequest {
pub elf: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DecodeDefinitionRequest {
pub token_program_id: String,
pub definition: AccountRead,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DecodeHoldingRequest {
pub token_program_id: String,
pub holding: AccountRead,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DecodeMetadataRequest {
pub token_program_id: String,
pub metadata: AccountRead,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DecodeAccountRequest {
pub token_program_id: String,
pub account: AccountRead,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreateFungiblePlanRequest {
pub token_program_id: String,
pub definition_target_id: String,
pub holding_target_id: String,
pub name: String,
pub total_supply_raw: Value,
pub mint_authority: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreateFungibleWithMetadataPlanRequest {
pub token_program_id: String,
pub definition_target_id: String,
pub holding_target_id: String,
pub metadata_target_id: String,
pub name: String,
pub total_supply_raw: Value,
pub mint_authority: String,
pub metadata_standard: String,
pub uri: String,
pub creators: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreateNonFungiblePlanRequest {
pub token_program_id: String,
pub definition_target_id: String,
pub master_holding_target_id: String,
pub metadata_target_id: String,
pub name: String,
pub printable_supply_raw: Value,
pub metadata_standard: String,
pub uri: String,
pub creators: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct InitializeHoldingPlanRequest {
pub token_program_id: String,
pub definition_id: String,
pub holding_target_id: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TransferPlanRequest {
pub token_program_id: String,
pub sender_holding_id: String,
pub recipient_holding_id: String,
pub amount_raw: Value,
#[serde(default)]
pub recipient_is_fresh: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BurnPlanRequest {
pub token_program_id: String,
pub definition_id: String,
pub holding_id: String,
pub amount_raw: Value,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MintPlanRequest {
pub token_program_id: String,
pub definition_id: String,
pub holding_id: String,
pub amount_raw: Value,
#[serde(default)]
pub holding_is_fresh: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MintWithAuthorityPlanRequest {
pub token_program_id: String,
pub definition_id: String,
pub holding_id: String,
pub authority_id: String,
pub amount_raw: Value,
#[serde(default)]
pub holding_is_fresh: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SetAuthorityPlanRequest {
pub token_program_id: String,
pub definition_id: String,
pub new_authority: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SetAuthorityWithAuthorityPlanRequest {
pub token_program_id: String,
pub definition_id: String,
pub authority_id: String,
pub new_authority: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PrintNftPlanRequest {
pub token_program_id: String,
pub master_holding_id: String,
pub printed_holding_target_id: String,
}
+800
View File
@@ -0,0 +1,800 @@
use nssa_core::{
account::{Account, AccountId, Data, Nonce},
program::ProgramId,
};
use serde_json::{json, Value};
use token_core::{
Instruction, MetadataStandard, NewTokenDefinition, TokenDefinition, TokenHolding, TokenMetadata,
};
use super::{
burn_plan, create_fungible_plan, create_fungible_with_metadata_plan, create_non_fungible_plan,
decode_account, decode_definition, decode_holding, decode_metadata, initialize_holding_plan,
mint_plan, mint_with_authority_plan, print_nft_plan, set_authority_plan,
set_authority_with_authority_plan, transfer_plan, BurnPlanRequest, CreateFungiblePlanRequest,
CreateFungibleWithMetadataPlanRequest, CreateNonFungiblePlanRequest, DecodeAccountRequest,
DecodeDefinitionRequest, DecodeHoldingRequest, DecodeMetadataRequest,
InitializeHoldingPlanRequest, MintPlanRequest, MintWithAuthorityPlanRequest,
PrintNftPlanRequest, SetAuthorityPlanRequest, SetAuthorityWithAuthorityPlanRequest,
TransferPlanRequest,
};
use crate::account::{account_id_hex, account_read, program_id_bytes};
const TOKEN_PROGRAM_ID: ProgramId = [0x11_u32; 8];
fn account(owner: ProgramId, data: Data) -> Account {
Account {
program_owner: owner,
balance: 0,
data,
nonce: Nonce(0),
}
}
fn definition_id(seed: u8) -> AccountId {
AccountId::new([seed; 32])
}
fn id_hex(seed: u8) -> String {
account_id_hex(definition_id(seed))
}
fn token_program_id_hex() -> String {
hex::encode(program_id_bytes(TOKEN_PROGRAM_ID))
}
fn ok<T, E: core::fmt::Display>(result: Result<T, E>) -> T {
match result {
Ok(value) => value,
Err(error) => panic!("{error}"),
}
}
fn assert_error(result: super::TokenResult, expected: &str) {
match result {
Ok(value) => panic!("expected {expected}, got {value}"),
Err(error) => assert_eq!(error.code(), expected),
}
}
fn decode_instruction(value: &Value) -> Result<Instruction, String> {
let words: Vec<u32> =
serde_json::from_value(value.clone()).map_err(|error| error.to_string())?;
risc0_zkvm::serde::from_slice::<Instruction, u32>(&words).map_err(|error| error.to_string())
}
fn assert_plan<const N: usize>(
plan: &Value,
expected_account_ids: [String; N],
expected_signers: [bool; N],
) -> Instruction {
assert_eq!(plan["programId"], token_program_id_hex());
assert_eq!(
plan["accountIds"],
json!(Vec::from(expected_account_ids.clone()))
);
assert_eq!(
plan["signingRequirements"],
json!(Vec::from(expected_signers))
);
match plan.get("instruction") {
Some(value) => ok(decode_instruction(value)),
None => panic!("plan instruction is required"),
}
}
fn decode_definition_request(definition: TokenDefinition) -> DecodeDefinitionRequest {
DecodeDefinitionRequest {
token_program_id: token_program_id_hex(),
definition: account_read(
definition_id(1),
&account(TOKEN_PROGRAM_ID, Data::from(&definition)),
),
}
}
fn definition_read_from_bytes(seed: u8, bytes: Vec<u8>) -> DecodeDefinitionRequest {
DecodeDefinitionRequest {
token_program_id: token_program_id_hex(),
definition: account_read(
definition_id(seed),
&account(TOKEN_PROGRAM_ID, ok(Data::try_from(bytes))),
),
}
}
fn transfer_request(amount_raw: Value, recipient_is_fresh: bool) -> TransferPlanRequest {
TransferPlanRequest {
token_program_id: token_program_id_hex(),
sender_holding_id: id_hex(80),
recipient_holding_id: id_hex(81),
amount_raw,
recipient_is_fresh,
}
}
#[test]
fn definition_decode_reports_fungible_optionals_and_exact_values() {
let metadata = definition_id(2);
let authority = definition_id(3);
let populated = ok(decode_definition(decode_definition_request(
TokenDefinition::Fungible {
name: String::from("Pebble"),
total_supply: u128::MAX,
metadata_id: Some(metadata),
authority: Some(authority),
},
)));
assert_eq!(populated["accountType"], "definition");
assert_eq!(populated["kind"], "fungible");
assert_eq!(populated["name"], "Pebble");
assert_eq!(populated["totalSupplyRaw"], u128::MAX.to_string());
assert_eq!(populated["metadataId"], metadata.to_string());
assert_eq!(populated["metadataIdHex"], account_id_hex(metadata));
assert_eq!(populated["mintAuthorityId"], authority.to_string());
assert_eq!(populated["mintAuthorityIdHex"], account_id_hex(authority));
let fixed = ok(decode_definition(decode_definition_request(
TokenDefinition::Fungible {
name: String::from("Fixed"),
total_supply: 0,
metadata_id: None,
authority: None,
},
)));
assert!(fixed["metadataId"].is_null());
assert!(fixed["metadataIdHex"].is_null());
assert!(fixed["mintAuthorityId"].is_null());
assert!(fixed["mintAuthorityIdHex"].is_null());
}
#[test]
fn definition_decode_reports_non_fungible_fields() {
let metadata = definition_id(4);
let value = ok(decode_definition(decode_definition_request(
TokenDefinition::NonFungible {
name: String::from("One of many"),
printable_supply: u128::MAX,
metadata_id: metadata,
},
)));
assert_eq!(value["accountType"], "definition");
assert_eq!(value["kind"], "nonFungible");
assert_eq!(value["name"], "One of many");
assert_eq!(value["printableSupplyRaw"], u128::MAX.to_string());
assert_eq!(value["metadataId"], metadata.to_string());
assert_eq!(value["metadataIdHex"], account_id_hex(metadata));
}
#[test]
fn holding_decode_reports_all_variants_and_ownership_states() {
let definition = definition_id(9);
let fungible = ok(decode_holding(DecodeHoldingRequest {
token_program_id: token_program_id_hex(),
holding: account_read(
definition_id(5),
&account(
TOKEN_PROGRAM_ID,
Data::from(&TokenHolding::Fungible {
definition_id: definition,
balance: u128::MAX,
}),
),
),
}));
assert_eq!(fungible["accountType"], "holding");
assert_eq!(fungible["kind"], "fungible");
assert_eq!(fungible["definitionIdHex"], account_id_hex(definition));
assert_eq!(fungible["balanceRaw"], u128::MAX.to_string());
let master = ok(decode_holding(DecodeHoldingRequest {
token_program_id: token_program_id_hex(),
holding: account_read(
definition_id(6),
&account(
TOKEN_PROGRAM_ID,
Data::from(&TokenHolding::NftMaster {
definition_id: definition,
print_balance: 7,
}),
),
),
}));
assert_eq!(master["kind"], "nftMaster");
assert_eq!(master["printBalanceRaw"], "7");
for (account_seed, owned) in [(7, false), (8, true)] {
let copy = ok(decode_holding(DecodeHoldingRequest {
token_program_id: token_program_id_hex(),
holding: account_read(
definition_id(account_seed),
&account(
TOKEN_PROGRAM_ID,
Data::from(&TokenHolding::NftPrintedCopy {
definition_id: definition,
owned,
}),
),
),
}));
assert_eq!(copy["kind"], "nftPrintedCopy");
assert_eq!(copy["owned"], owned);
}
}
#[test]
fn metadata_decode_reports_both_standards_and_exact_u64() {
let definition = definition_id(10);
for (account_seed, standard, expected_name, primary_sale_date) in [
(11, MetadataStandard::Simple, "simple", 0),
(12, MetadataStandard::Expanded, "expanded", u64::MAX),
] {
let value = ok(decode_metadata(DecodeMetadataRequest {
token_program_id: token_program_id_hex(),
metadata: account_read(
definition_id(account_seed),
&account(
TOKEN_PROGRAM_ID,
Data::from(&TokenMetadata {
definition_id: definition,
standard,
uri: String::from("ipfs://hash"),
creators: String::from("alice,bob"),
primary_sale_date,
}),
),
),
}));
assert_eq!(value["accountType"], "metadata");
assert_eq!(value["standard"], expected_name);
assert_eq!(value["uri"], "ipfs://hash");
assert_eq!(value["creators"], "alice,bob");
assert_eq!(value["primarySaleDateRaw"], primary_sale_date.to_string());
}
}
#[test]
fn definition_decode_rejects_malformed_truncated_trailing_and_wrong_type_data() {
let definition = TokenDefinition::Fungible {
name: String::from("Exact"),
total_supply: 17,
metadata_id: None,
authority: None,
};
let valid = Data::from(&definition).as_ref().to_vec();
let mut truncated = valid.clone();
assert!(truncated.pop().is_some());
assert_error(
decode_definition(definition_read_from_bytes(13, truncated)),
"invalid_definition_data",
);
let mut trailing = valid;
trailing.push(0);
assert_error(
decode_definition(definition_read_from_bytes(14, trailing)),
"invalid_definition_data",
);
assert_error(
decode_definition(definition_read_from_bytes(15, vec![u8::MAX])),
"invalid_definition_data",
);
let holding = TokenHolding::Fungible {
definition_id: definition_id(16),
balance: 1,
};
assert_error(
decode_definition(definition_read_from_bytes(
17,
Data::from(&holding).as_ref().to_vec(),
)),
"invalid_definition_data",
);
}
#[test]
fn discovery_requires_one_exact_account_type_match() {
let definition = definition_id(18);
let value = ok(decode_account(DecodeAccountRequest {
token_program_id: token_program_id_hex(),
account: account_read(
definition_id(19),
&account(
TOKEN_PROGRAM_ID,
Data::from(&TokenHolding::Fungible {
definition_id: definition,
balance: 1,
}),
),
),
}));
assert_eq!(value["accountType"], "holding");
assert_error(
decode_account(DecodeAccountRequest {
token_program_id: token_program_id_hex(),
account: account_read(
definition_id(20),
&account(TOKEN_PROGRAM_ID, Data::default()),
),
}),
"invalid_account_data",
);
// Exact-valid as both forms: the holding AccountId prefix encodes a
// 26-byte definition name, and its zero balance terminates the definition.
let mut ambiguous = Vec::new();
ambiguous.push(0);
ambiguous.extend_from_slice(&26_u32.to_le_bytes());
ambiguous.extend_from_slice(&[b'a'; 26]);
ambiguous.extend_from_slice(&[0_u8; 2]);
ambiguous.extend_from_slice(&[0_u8; 16]);
assert_error(
decode_account(DecodeAccountRequest {
token_program_id: token_program_id_hex(),
account: account_read(
definition_id(21),
&account(TOKEN_PROGRAM_ID, ok(Data::try_from(ambiguous))),
),
}),
"ambiguous_account_type",
);
}
#[test]
fn decode_rejects_wrong_program_owner_failed_reads_and_bad_identifiers() {
assert_error(
decode_definition(DecodeDefinitionRequest {
token_program_id: token_program_id_hex(),
definition: account_read(
definition_id(22),
&account(
[0x22_u32; 8],
Data::from(&TokenDefinition::Fungible {
name: String::from("Wrong"),
total_supply: 1,
metadata_id: None,
authority: None,
}),
),
),
}),
"token_program_mismatch",
);
assert_error(
decode_holding(DecodeHoldingRequest {
token_program_id: token_program_id_hex(),
holding: crate::AccountRead {
id: id_hex(23),
status: String::from("read_failed"),
account: None,
},
}),
"account_read_failed",
);
assert_error(
decode_metadata(DecodeMetadataRequest {
token_program_id: String::from("not-a-program-id"),
metadata: crate::AccountRead {
id: id_hex(24),
status: String::from("read_failed"),
account: None,
},
}),
"bad_request",
);
}
#[test]
fn fungible_creation_plans_cover_fixed_self_and_external_authority() {
let definition = definition_id(25);
let self_plan = ok(create_fungible_plan(CreateFungiblePlanRequest {
token_program_id: token_program_id_hex(),
definition_target_id: account_id_hex(definition),
holding_target_id: id_hex(26),
name: String::from("Self"),
total_supply_raw: json!(u64::MAX),
mint_authority: String::from("self"),
}));
let instruction = assert_plan(
&self_plan,
[account_id_hex(definition), id_hex(26)],
[true, true],
);
let Instruction::NewFungibleDefinition {
name,
total_supply,
mint_authority,
} = instruction
else {
panic!("expected NewFungibleDefinition");
};
assert_eq!(name, "Self");
assert_eq!(total_supply, u128::from(u64::MAX));
assert_eq!(mint_authority, Some(definition));
let fixed_plan = ok(create_fungible_plan(CreateFungiblePlanRequest {
token_program_id: token_program_id_hex(),
definition_target_id: id_hex(27),
holding_target_id: id_hex(28),
name: String::from("Fixed"),
total_supply_raw: json!(0),
mint_authority: String::from("none"),
}));
let instruction = assert_plan(&fixed_plan, [id_hex(27), id_hex(28)], [true, true]);
let Instruction::NewFungibleDefinition {
total_supply,
mint_authority,
..
} = instruction
else {
panic!("expected NewFungibleDefinition");
};
assert_eq!(total_supply, 0);
assert!(mint_authority.is_none());
let authority = definition_id(29);
let external_plan = ok(create_fungible_plan(CreateFungiblePlanRequest {
token_program_id: token_program_id_hex(),
definition_target_id: id_hex(30),
holding_target_id: id_hex(31),
name: String::from("External"),
total_supply_raw: json!("340282366920938463463374607431768211455"),
mint_authority: account_id_hex(authority),
}));
let instruction = assert_plan(&external_plan, [id_hex(30), id_hex(31)], [true, true]);
let Instruction::NewFungibleDefinition {
name,
total_supply,
mint_authority,
} = instruction
else {
panic!("expected NewFungibleDefinition");
};
assert_eq!(name, "External");
assert_eq!(total_supply, u128::MAX);
assert_eq!(mint_authority, Some(authority));
}
#[test]
fn metadata_creation_plans_round_trip_all_fields_and_account_contracts() {
let fungible_plan = ok(create_fungible_with_metadata_plan(
CreateFungibleWithMetadataPlanRequest {
token_program_id: token_program_id_hex(),
definition_target_id: id_hex(32),
holding_target_id: id_hex(33),
metadata_target_id: id_hex(34),
name: String::from("Meta"),
total_supply_raw: json!(7),
mint_authority: String::from("none"),
metadata_standard: String::from("simple"),
uri: String::from("ipfs://fungible"),
creators: String::from("alice,bob"),
},
));
let instruction = assert_plan(
&fungible_plan,
[id_hex(32), id_hex(33), id_hex(34)],
[true, true, true],
);
let Instruction::NewDefinitionWithMetadata {
new_definition,
metadata,
} = instruction
else {
panic!("expected NewDefinitionWithMetadata");
};
let NewTokenDefinition::Fungible {
name,
total_supply,
mint_authority,
} = new_definition
else {
panic!("expected fungible definition");
};
assert_eq!(name, "Meta");
assert_eq!(total_supply, 7);
assert!(mint_authority.is_none());
assert_eq!(metadata.standard, MetadataStandard::Simple);
assert_eq!(metadata.uri, "ipfs://fungible");
assert_eq!(metadata.creators, "alice,bob");
let nft_plan = ok(create_non_fungible_plan(CreateNonFungiblePlanRequest {
token_program_id: token_program_id_hex(),
definition_target_id: id_hex(35),
master_holding_target_id: id_hex(36),
metadata_target_id: id_hex(37),
name: String::from("NFT"),
printable_supply_raw: json!("340282366920938463463374607431768211455"),
metadata_standard: String::from("expanded"),
uri: String::from("ipfs://nft"),
creators: String::from("carol"),
}));
let instruction = assert_plan(
&nft_plan,
[id_hex(35), id_hex(36), id_hex(37)],
[true, true, true],
);
let Instruction::NewDefinitionWithMetadata {
new_definition,
metadata,
} = instruction
else {
panic!("expected NewDefinitionWithMetadata");
};
let NewTokenDefinition::NonFungible {
name,
printable_supply,
} = new_definition
else {
panic!("expected non-fungible definition");
};
assert_eq!(name, "NFT");
assert_eq!(printable_supply, u128::MAX);
assert_eq!(metadata.standard, MetadataStandard::Expanded);
assert_eq!(metadata.uri, "ipfs://nft");
assert_eq!(metadata.creators, "carol");
}
#[test]
fn initialize_transfer_and_burn_plans_round_trip_exact_contracts() {
let initialize = ok(initialize_holding_plan(InitializeHoldingPlanRequest {
token_program_id: token_program_id_hex(),
definition_id: id_hex(38),
holding_target_id: id_hex(39),
}));
let instruction = assert_plan(&initialize, [id_hex(38), id_hex(39)], [false, true]);
assert!(matches!(instruction, Instruction::InitializeAccount));
for (fresh, signers) in [(false, [true, false]), (true, [true, true])] {
let transfer = ok(transfer_plan(TransferPlanRequest {
token_program_id: token_program_id_hex(),
sender_holding_id: id_hex(40),
recipient_holding_id: id_hex(41),
amount_raw: json!("9"),
recipient_is_fresh: fresh,
}));
let instruction = assert_plan(&transfer, [id_hex(40), id_hex(41)], signers);
let Instruction::Transfer { amount_to_transfer } = instruction else {
panic!("expected Transfer");
};
assert_eq!(amount_to_transfer, 9);
}
let burn = ok(burn_plan(BurnPlanRequest {
token_program_id: token_program_id_hex(),
definition_id: id_hex(42),
holding_id: id_hex(43),
amount_raw: json!("11"),
}));
let instruction = assert_plan(&burn, [id_hex(42), id_hex(43)], [false, true]);
let Instruction::Burn { amount_to_burn } = instruction else {
panic!("expected Burn");
};
assert_eq!(amount_to_burn, 11);
}
#[test]
fn mint_plans_cover_initialized_and_fresh_holding_signers() {
for (fresh, signers) in [(false, [true, false]), (true, [true, true])] {
let mint = ok(mint_plan(MintPlanRequest {
token_program_id: token_program_id_hex(),
definition_id: id_hex(44),
holding_id: id_hex(45),
amount_raw: json!("13"),
holding_is_fresh: fresh,
}));
let instruction = assert_plan(&mint, [id_hex(44), id_hex(45)], signers);
let Instruction::Mint { amount_to_mint } = instruction else {
panic!("expected Mint");
};
assert_eq!(amount_to_mint, 13);
}
for (fresh, signers) in [(false, [false, false, true]), (true, [false, true, true])] {
let mint = ok(mint_with_authority_plan(MintWithAuthorityPlanRequest {
token_program_id: token_program_id_hex(),
definition_id: id_hex(46),
holding_id: id_hex(47),
authority_id: id_hex(48),
amount_raw: json!("17"),
holding_is_fresh: fresh,
}));
let instruction = assert_plan(&mint, [id_hex(46), id_hex(47), id_hex(48)], signers);
let Instruction::MintWithAuthority { amount_to_mint } = instruction else {
panic!("expected MintWithAuthority");
};
assert_eq!(amount_to_mint, 17);
}
}
#[test]
fn authority_and_print_plans_round_trip_exact_contracts() {
let external_new_authority = definition_id(49);
let rotate = ok(set_authority_plan(SetAuthorityPlanRequest {
token_program_id: token_program_id_hex(),
definition_id: id_hex(50),
new_authority: account_id_hex(external_new_authority),
}));
let instruction = assert_plan(&rotate, [id_hex(50)], [true]);
let Instruction::SetAuthority { new_authority } = instruction else {
panic!("expected SetAuthority");
};
assert_eq!(new_authority, Some(external_new_authority));
let revoke = ok(set_authority_plan(SetAuthorityPlanRequest {
token_program_id: token_program_id_hex(),
definition_id: id_hex(51),
new_authority: String::from("none"),
}));
let instruction = assert_plan(&revoke, [id_hex(51)], [true]);
let Instruction::SetAuthority { new_authority } = instruction else {
panic!("expected SetAuthority");
};
assert!(new_authority.is_none());
let definition = definition_id(52);
let rotate_with_external = ok(set_authority_with_authority_plan(
SetAuthorityWithAuthorityPlanRequest {
token_program_id: token_program_id_hex(),
definition_id: account_id_hex(definition),
authority_id: id_hex(53),
new_authority: String::from("self"),
},
));
let instruction = assert_plan(
&rotate_with_external,
[account_id_hex(definition), id_hex(53)],
[false, true],
);
let Instruction::SetAuthorityWithAuthority { new_authority } = instruction else {
panic!("expected SetAuthorityWithAuthority");
};
assert_eq!(new_authority, Some(definition));
let print = ok(print_nft_plan(PrintNftPlanRequest {
token_program_id: token_program_id_hex(),
master_holding_id: id_hex(54),
printed_holding_target_id: id_hex(55),
}));
let instruction = assert_plan(&print, [id_hex(54), id_hex(55)], [true, true]);
assert!(matches!(instruction, Instruction::PrintNft));
}
#[test]
fn tail_variants_use_token_core_enum_order() {
let print = ok(print_nft_plan(PrintNftPlanRequest {
token_program_id: token_program_id_hex(),
master_holding_id: id_hex(56),
printed_holding_target_id: id_hex(57),
}));
let set = ok(set_authority_plan(SetAuthorityPlanRequest {
token_program_id: token_program_id_hex(),
definition_id: id_hex(58),
new_authority: String::from("none"),
}));
let set_with = ok(set_authority_with_authority_plan(
SetAuthorityWithAuthorityPlanRequest {
token_program_id: token_program_id_hex(),
definition_id: id_hex(59),
authority_id: id_hex(60),
new_authority: String::from("none"),
},
));
for (plan, expected_discriminant) in [(print, 7), (set, 8), (set_with, 9)] {
let words: Vec<u32> = ok(serde_json::from_value(plan["instruction"].clone()));
assert_eq!(words.first().copied(), Some(expected_discriminant));
}
}
#[test]
fn amount_parser_accepts_exact_boundaries_and_cli_quote_wrapper() {
for (raw, expected) in [
(json!(0), 0_u128),
(json!(1), 1_u128),
(json!(u64::MAX), u128::from(u64::MAX)),
(json!("340282366920938463463374607431768211455"), u128::MAX),
(json!("\"42\""), 42_u128),
(json!("\" 42 \""), 42_u128),
] {
let plan = ok(transfer_plan(transfer_request(raw, false)));
let instruction = assert_plan(&plan, [id_hex(80), id_hex(81)], [true, false]);
let Instruction::Transfer { amount_to_transfer } = instruction else {
panic!("expected Transfer");
};
assert_eq!(amount_to_transfer, expected);
}
}
#[test]
fn amount_parser_rejects_overflow_negative_float_exponent_letters_and_empty() {
let exponent_number: Value = ok(serde_json::from_str("1e3"));
for invalid in [
json!("340282366920938463463374607431768211456"),
json!(-1),
json!(1.5),
exponent_number,
json!("1e3"),
json!("abc"),
json!(""),
json!("\"\""),
] {
assert_error(
transfer_plan(transfer_request(invalid, false)),
"bad_amount",
);
}
}
#[test]
fn planner_validation_rejects_invalid_authorities_standard_and_account_ids() {
for authority in [String::new(), String::from("invalid"), "00".repeat(32)] {
assert_error(
create_fungible_plan(CreateFungiblePlanRequest {
token_program_id: token_program_id_hex(),
definition_target_id: id_hex(61),
holding_target_id: id_hex(62),
name: String::from("Bad authority"),
total_supply_raw: json!(1),
mint_authority: authority,
}),
"invalid_authority",
);
}
assert_error(
create_fungible_plan(CreateFungiblePlanRequest {
token_program_id: token_program_id_hex(),
definition_target_id: "00".repeat(32),
holding_target_id: id_hex(63),
name: String::from("Bad self"),
total_supply_raw: json!(1),
mint_authority: String::from("self"),
}),
"invalid_authority",
);
assert_error(
mint_with_authority_plan(MintWithAuthorityPlanRequest {
token_program_id: token_program_id_hex(),
definition_id: id_hex(64),
holding_id: id_hex(65),
authority_id: "00".repeat(32),
amount_raw: json!(1),
holding_is_fresh: false,
}),
"invalid_authority",
);
assert_error(
create_non_fungible_plan(CreateNonFungiblePlanRequest {
token_program_id: token_program_id_hex(),
definition_target_id: id_hex(66),
master_holding_target_id: id_hex(67),
metadata_target_id: id_hex(68),
name: String::from("NFT"),
printable_supply_raw: json!(1),
metadata_standard: String::from("bad"),
uri: String::from("uri"),
creators: String::from("creators"),
}),
"invalid_metadata_standard",
);
assert_error(
initialize_holding_plan(InitializeHoldingPlanRequest {
token_program_id: token_program_id_hex(),
definition_id: String::from("not-an-id"),
holding_target_id: id_hex(69),
}),
"invalid_account_id",
);
}
+301
View File
@@ -0,0 +1,301 @@
use std::{
ffi::{c_char, CStr, CString},
panic::{catch_unwind, AssertUnwindSafe},
};
use serde::{de::DeserializeOwned, Serialize};
use crate::api::{
self, BurnPlanRequest, CreateFungiblePlanRequest, CreateFungibleWithMetadataPlanRequest,
CreateNonFungiblePlanRequest, DecodeAccountRequest, DecodeDefinitionRequest,
DecodeHoldingRequest, DecodeMetadataRequest, InitializeHoldingPlanRequest, MintPlanRequest,
MintWithAuthorityPlanRequest, PrintNftPlanRequest, ProgramIdRequest, SetAuthorityPlanRequest,
SetAuthorityWithAuthorityPlanRequest, TokenResult, TransferPlanRequest,
};
#[derive(Serialize)]
struct Envelope {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
impl Envelope {
fn success(value: serde_json::Value) -> Self {
Self {
ok: true,
value: Some(value),
error: None,
}
}
fn failure(error: impl Into<String>) -> Self {
Self {
ok: false,
value: None,
error: Some(error.into()),
}
}
}
/// # Safety
/// `request` must be null or point to a live NUL-terminated byte string for
/// the duration of this call.
unsafe fn call<T: DeserializeOwned>(
request: *const c_char,
operation: fn(T) -> TokenResult,
) -> *mut c_char {
let result = catch_unwind(AssertUnwindSafe(|| {
// SAFETY: Forwarded from the exported C function's caller contract.
let request = unsafe { request_text(request) }?;
let request =
serde_json::from_str::<T>(&request).map_err(|_| String::from("bad_request"))?;
operation(request).map_err(|error| error.to_string())
}));
let envelope = match result {
Ok(Ok(value)) => Envelope::success(value),
Ok(Err(error)) => Envelope::failure(error),
Err(_) => Envelope::failure("backend_error"),
};
encode_envelope(&envelope)
}
/// # Safety
/// `request` must be null or point to a live NUL-terminated byte string for
/// the duration of this call.
unsafe fn request_text(request: *const c_char) -> Result<String, String> {
if request.is_null() {
return Err(String::from("bad_request"));
}
// SAFETY: The caller passes a live NUL-terminated UTF-8 buffer for this call.
let request = unsafe { CStr::from_ptr(request) };
request
.to_str()
.map(String::from)
.map_err(|_| String::from("bad_request"))
}
fn encode_envelope(envelope: &Envelope) -> *mut c_char {
let json = serde_json::to_string(envelope)
.unwrap_or_else(|_| String::from(r#"{"ok":false,"error":"backend_error"}"#));
match CString::new(json) {
Ok(value) => value.into_raw(),
Err(_) => CString::new(r#"{"ok":false,"error":"backend_error"}"#)
.map_or(std::ptr::null_mut(), CString::into_raw),
}
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_program_id(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<ProgramIdRequest>(request_json, api::program_id) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_decode_definition(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<DecodeDefinitionRequest>(request_json, api::decode_definition) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_decode_holding(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<DecodeHoldingRequest>(request_json, api::decode_holding) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_decode_metadata(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<DecodeMetadataRequest>(request_json, api::decode_metadata) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_decode_account(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<DecodeAccountRequest>(request_json, api::decode_account) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_create_fungible_plan(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<CreateFungiblePlanRequest>(request_json, api::create_fungible_plan) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_create_fungible_with_metadata_plan(
request_json: *const c_char,
) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe {
call::<CreateFungibleWithMetadataPlanRequest>(
request_json,
api::create_fungible_with_metadata_plan,
)
}
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_create_non_fungible_plan(
request_json: *const c_char,
) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<CreateNonFungiblePlanRequest>(request_json, api::create_non_fungible_plan) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_initialize_holding_plan(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<InitializeHoldingPlanRequest>(request_json, api::initialize_holding_plan) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_transfer_plan(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<TransferPlanRequest>(request_json, api::transfer_plan) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_burn_plan(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<BurnPlanRequest>(request_json, api::burn_plan) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_mint_plan(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<MintPlanRequest>(request_json, api::mint_plan) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_mint_with_authority_plan(
request_json: *const c_char,
) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<MintWithAuthorityPlanRequest>(request_json, api::mint_with_authority_plan) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_set_authority_plan(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<SetAuthorityPlanRequest>(request_json, api::set_authority_plan) }
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_set_authority_with_authority_plan(
request_json: *const c_char,
) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe {
call::<SetAuthorityWithAuthorityPlanRequest>(
request_json,
api::set_authority_with_authority_plan,
)
}
}
#[unsafe(no_mangle)]
/// # Safety
/// `request_json` must be null or point to a live NUL-terminated byte string.
pub unsafe extern "C" fn token_print_nft_plan(request_json: *const c_char) -> *mut c_char {
// SAFETY: Forwarded from this function's caller contract.
unsafe { call::<PrintNftPlanRequest>(request_json, api::print_nft_plan) }
}
/// Releases a string returned by a `token_*` operation.
///
/// # Safety
/// `value` must be null or a pointer returned by this library that has not been freed.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn token_free(value: *mut c_char) {
if value.is_null() {
return;
}
// SAFETY: The caller contract requires a pointer produced by CString::into_raw above.
drop(unsafe { CString::from_raw(value) });
}
#[cfg(test)]
mod tests {
use std::ffi::CString;
use super::*;
/// # Safety
/// `response` must be a live pointer returned by a `token_*` operation.
unsafe fn assert_failure_response(response: *mut c_char, expected: &str) {
assert!(!response.is_null());
// SAFETY: Forwarded from this helper's caller contract.
let text = unsafe { CStr::from_ptr(response) };
let text = match text.to_str() {
Ok(value) => value,
Err(error) => panic!("{error}"),
};
let value: serde_json::Value = match serde_json::from_str(text) {
Ok(value) => value,
Err(error) => panic!("{error}"),
};
assert_eq!(value["ok"], false);
assert_eq!(value["error"], expected);
// SAFETY: response came from this library and has not been freed.
unsafe { token_free(response) };
}
#[test]
fn malformed_json_uses_boundary_failure_envelope() {
let request = match CString::new("{") {
Ok(value) => value,
Err(error) => panic!("{error}"),
};
// SAFETY: request is a live NUL-terminated CString for this call.
let response = unsafe { token_program_id(request.as_ptr()) };
// SAFETY: response was returned by token_program_id and remains live.
unsafe { assert_failure_response(response, "bad_request") };
}
#[test]
fn null_request_uses_boundary_failure_envelope() {
// SAFETY: null is explicitly accepted and mapped to bad_request.
let response = unsafe { token_program_id(std::ptr::null()) };
// SAFETY: response was returned by token_program_id and remains live.
unsafe { assert_failure_response(response, "bad_request") };
}
#[test]
fn null_free_is_safe() {
// SAFETY: null is explicitly allowed by the function contract.
unsafe { token_free(std::ptr::null_mut()) };
}
}
+20
View File
@@ -0,0 +1,20 @@
#![deny(unsafe_op_in_unsafe_fn)]
mod account;
mod ffi;
pub mod api;
pub use account::{AccountRead, WalletAccount};
pub use api::{
burn_plan, create_fungible_plan, create_fungible_with_metadata_plan, create_non_fungible_plan,
decode_account, decode_definition, decode_holding, decode_metadata, initialize_holding_plan,
mint_plan, mint_with_authority_plan, print_nft_plan, program_id, set_authority_plan,
set_authority_with_authority_plan, transfer_plan, BurnPlanRequest, CreateFungiblePlanRequest,
CreateFungibleWithMetadataPlanRequest, CreateNonFungiblePlanRequest, DecodeAccountRequest,
DecodeDefinitionRequest, DecodeHoldingRequest, DecodeMetadataRequest,
InitializeHoldingPlanRequest, MintPlanRequest, MintWithAuthorityPlanRequest,
PrintNftPlanRequest, ProgramIdRequest, SetAuthorityPlanRequest,
SetAuthorityWithAuthorityPlanRequest, TokenApiError, TokenResponse, TokenResult,
TransferPlanRequest,
};