mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(token): add Logos token API module
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(TokenModulePlugin LANGUAGES CXX)
|
||||
|
||||
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
|
||||
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
|
||||
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake")
|
||||
include(cmake/LogosModule.cmake)
|
||||
else()
|
||||
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
|
||||
endif()
|
||||
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/metadata.json" METADATA_JSON)
|
||||
string(JSON MODULE_NAME GET ${METADATA_JSON} name)
|
||||
|
||||
logos_module(
|
||||
NAME ${MODULE_NAME}
|
||||
SOURCES
|
||||
src/token_module_impl.h
|
||||
src/token_module_impl.cpp
|
||||
src/token_instruction_words.h
|
||||
src/token_instruction_words.cpp
|
||||
EXTERNAL_LIBS
|
||||
token_ffi
|
||||
)
|
||||
@@ -0,0 +1,265 @@
|
||||
# Token core module
|
||||
|
||||
`token_module` is a headless Logos `core` module for the LEZ Token Program. It
|
||||
has no UI. Basecamp UI modules and `logoscore` use the same generated API.
|
||||
|
||||
The module supports all current Token Program instructions, including fixed or
|
||||
mintable fungibles, metadata-backed fungibles, non-fungible definitions, NFT
|
||||
printing, transfers, minting, burning, and authority changes. It also decodes
|
||||
Token Definition, Token Holding, and Token Metadata accounts.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Basecamp UI / logoscore
|
||||
|
|
||||
token_module Qt-free C++ transport/orchestration
|
||||
/ \
|
||||
token_ffi logos_execution_zone
|
||||
Rust codecs shared wallet reads, account listing, transaction submission
|
||||
and planners
|
||||
```
|
||||
|
||||
`token_ffi` constructs `token_core::Instruction` values directly and serializes
|
||||
them with RISC Zero. This is intentional: the current Token IDL cannot fully
|
||||
describe `new_definition_with_metadata`, and its final three instruction
|
||||
indexes do not match the Rust enum's serialized order.
|
||||
|
||||
The module reuses the host-loaded `logos_execution_zone` instance. It never
|
||||
opens a second wallet and never creates account keys. Callers create fresh
|
||||
public accounts through the wallet module, then pass their IDs to Token
|
||||
operations.
|
||||
|
||||
## API
|
||||
|
||||
Every method returns a map. Success starts with:
|
||||
|
||||
```json
|
||||
{ "status": "ok", "error": "" }
|
||||
```
|
||||
|
||||
Failure is:
|
||||
|
||||
```json
|
||||
{ "status": "error", "error": "<stable_code>" }
|
||||
```
|
||||
|
||||
Mutating success adds `transactionId`.
|
||||
|
||||
### Read and discovery
|
||||
|
||||
| Method | Arguments | Result payload |
|
||||
| --- | --- | --- |
|
||||
| `programInfo` | none | Token Program ID in base58 and hex |
|
||||
| `inspectDefinition` | definition ID | decoded fungible or non-fungible definition |
|
||||
| `inspectHolding` | holding ID | decoded fungible, NFT master, or NFT printed-copy holding |
|
||||
| `inspectMetadata` | metadata ID | decoded standard, URI, creators, and primary-sale value |
|
||||
| `walletTokenAccounts` | none | all uniquely decodable Token-owned accounts in the connected wallet |
|
||||
|
||||
Read results expose operator-facing base58 IDs and matching lowercase `*Hex`
|
||||
fields. Raw supplies, balances, print balances, and primary-sale values are
|
||||
decimal strings.
|
||||
|
||||
There is no global token registry. Explicit inspect methods can read any public
|
||||
account. `walletTokenAccounts` discovers only accounts present in the connected
|
||||
wallet.
|
||||
|
||||
### Definition creation
|
||||
|
||||
| Method | Arguments |
|
||||
| --- | --- |
|
||||
| `createFungible` | definition target, holding target, name, total supply raw, mint authority |
|
||||
| `createFungibleWithMetadata` | definition target, holding target, metadata target, name, total supply raw, mint authority, standard, URI, creators |
|
||||
| `createNonFungible` | definition target, master holding target, metadata target, name, printable supply raw, standard, URI, creators |
|
||||
|
||||
Mint-authority values:
|
||||
|
||||
- `none` creates a permanently fixed supply;
|
||||
- `self` uses the definition account itself;
|
||||
- an account ID assigns an external authority.
|
||||
|
||||
Metadata standard is `simple` or `expanded`.
|
||||
|
||||
### Holding and supply operations
|
||||
|
||||
| Method | Arguments | Notes |
|
||||
| --- | --- | --- |
|
||||
| `initializeHolding` | definition, fresh holding target | NFT definitions create an unowned printed-copy holding |
|
||||
| `transfer` | sender holding, recipient holding, amount raw | supports fungible, NFT master, and printed-copy rules |
|
||||
| `burn` | definition, holding, amount raw | supports fungible and NFT variants |
|
||||
| `mint` | definition, holding, amount raw | self-authority fungible path |
|
||||
| `mintWithAuthority` | definition, holding, current authority, amount raw | external-authority fungible path |
|
||||
| `setAuthority` | definition, new authority | self-authority path |
|
||||
| `setAuthorityWithAuthority` | definition, current authority, new authority | external-authority path |
|
||||
| `printNft` | master holding, fresh printed holding target | target must not be initialized first |
|
||||
|
||||
`new authority` accepts the same `none`, `self`, or account-ID values as
|
||||
definition creation. Revocation with `none` is permanent.
|
||||
|
||||
## Account ID and amount conventions
|
||||
|
||||
Account inputs accept base58 or 64 hexadecimal characters. Hex is normalized
|
||||
to lowercase.
|
||||
|
||||
Raw `u128` arguments are exposed as JSON-compatible values:
|
||||
|
||||
- small values may be passed as bare integers, for example `1000`;
|
||||
- large values must be passed to `logoscore` as quote-wrapped decimal strings,
|
||||
for example `'"12345678901234567890123456"'`.
|
||||
|
||||
The CLI converts large bare numbers to floating point. The module rejects all
|
||||
floats instead of submitting a rounded amount. A UI should always pass raw
|
||||
amounts as decimal strings.
|
||||
|
||||
## Fresh account workflow
|
||||
|
||||
Creation, explicit initialization, and NFT printing require fresh public wallet
|
||||
accounts. Create each one before calling the token method:
|
||||
|
||||
```bash
|
||||
logoscore call logos_execution_zone create_account_public --json
|
||||
```
|
||||
|
||||
For transfer and mint, an initialized destination needs no destination
|
||||
signature. A fresh destination is accepted only when the connected wallet owns
|
||||
its key, so it can authorize the Token Program claim.
|
||||
|
||||
The execution-zone provider may represent an absent public account as an
|
||||
all-zero owner/balance/nonce with empty data. The module treats that exact
|
||||
response as `not_found`, then still requires the target ID to belong to the
|
||||
connected wallet before submitting.
|
||||
|
||||
## Build and test
|
||||
|
||||
Build from repository root; the root flake supplies `token_ffi`:
|
||||
|
||||
```bash
|
||||
RISC0_DEV_MODE=1 cargo +1.94.0 test -p token_ffi
|
||||
RISC0_SKIP_BUILD=1 cargo +1.94.0 clippy -p token_ffi --all-targets -- -D warnings
|
||||
nix build path:.#token_ffi -L
|
||||
nix build path:.#token-module -L
|
||||
```
|
||||
|
||||
`path:.` is useful while new files are untracked. After files are tracked,
|
||||
`nix build .#token-module` is equivalent.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
Set either `TOKEN_PROGRAM_ID` or `TOKEN_PROGRAM_BIN` on the process hosting the
|
||||
module:
|
||||
|
||||
```bash
|
||||
# Use the deployed Token Program ID directly (base58 or 64-character hex).
|
||||
TOKEN_PROGRAM_ID=F8sGbDbjcxvJHpUQJcArEaY7EbLMVmqZgRm3fXPw3jb3 \
|
||||
logoscore -D -m ./modules
|
||||
|
||||
# Or derive the ID from the exact deployable binary.
|
||||
TOKEN_PROGRAM_BIN=/absolute/path/to/token.bin logoscore -D -m ./modules
|
||||
```
|
||||
|
||||
If both variables are set, they must resolve to the same program ID. The binary
|
||||
must be the exact deployable `.bin` running on the target sequencer. Its RISC
|
||||
Zero image ID is the Token Program ID. Rebuilding the guest changes that
|
||||
identity; accounts owned by an older deployment must be read with the matching
|
||||
program-ID configuration.
|
||||
|
||||
Set `TOKEN_DEBUG=1` on the daemon to emit safe adapter diagnostics to module
|
||||
stderr. Debug logging never includes wallet storage or recovery material.
|
||||
|
||||
## Headless `logoscore` smoke test
|
||||
|
||||
### 1. Build both modules
|
||||
|
||||
```bash
|
||||
# Token module (from the repo root; output under result/lib/).
|
||||
nix build .#token-module -L
|
||||
ls result/lib/ # token_module_plugin.dylib libtoken_ffi.dylib
|
||||
|
||||
# The wallet module it depends on — the SAME pin the repo-root flake and
|
||||
# amm_module use, with its inner monorepo input overridden to the rev the target
|
||||
# sequencer runs (415964d7). See apps/amm/README.md for the fuller build notes.
|
||||
nix build 'github:gravityblast/logos-execution-zone-module?ref=fix/generic-tx-instruction-bstr' \
|
||||
--override-input logos-execution-zone \
|
||||
'github:logos-blockchain/logos-execution-zone?rev=415964d7f9043a1bfe28da8d0e8b3a6f64abb258' \
|
||||
--out-link result-lez
|
||||
ls result-lez/lib/ # logos_execution_zone_plugin.dylib libwallet_ffi.dylib
|
||||
```
|
||||
|
||||
### 2. Stage a modules directory
|
||||
|
||||
Core modules get no `.lgx` from the builder, so stage a directory by hand — one
|
||||
subdir per module with `manifest.json` + `variant` + the plugin dylib and its
|
||||
sibling FFI dylib (rpath is `@loader_path`, so the FFI lib must sit beside the
|
||||
plugin). The daemon discovers modules from this layout:
|
||||
|
||||
```
|
||||
modules/
|
||||
token_module/
|
||||
token_module_plugin.dylib
|
||||
libtoken_ffi.dylib
|
||||
variant # one line: darwin-arm64-dev
|
||||
manifest.json
|
||||
logos_execution_zone/
|
||||
logos_execution_zone_plugin.dylib
|
||||
libwallet_ffi.dylib
|
||||
variant
|
||||
manifest.json
|
||||
```
|
||||
|
||||
`token_module/manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "token_module", "type": "core", "version": "0.1.0",
|
||||
"manifestVersion": "0.2.0", "dependencies": ["logos_execution_zone"],
|
||||
"main": { "darwin-arm64-dev": "token_module_plugin.dylib" }
|
||||
}
|
||||
```
|
||||
|
||||
Copy the dylibs into place (`result/lib/*` → `modules/token_module/`,
|
||||
`result-lez/lib/*` → `modules/logos_execution_zone/`) and write each `variant`
|
||||
as a single line (`darwin-arm64-dev` on arm64 macOS). The
|
||||
`logos_execution_zone/manifest.json` mirrors this with its own name and plugin.
|
||||
|
||||
### 3. Start the daemon and load (dependency first)
|
||||
|
||||
Set `TOKEN_PROGRAM_ID` or `TOKEN_PROGRAM_BIN` **on the daemon**, then load the
|
||||
dependency before the module:
|
||||
|
||||
```bash
|
||||
TOKEN_PROGRAM_ID=F8sGbDbjcxvJHpUQJcArEaY7EbLMVmqZgRm3fXPw3jb3 \
|
||||
logoscore -D -m ./modules --persistence-path ./data
|
||||
|
||||
logoscore load-module logos_execution_zone # dependency first
|
||||
logoscore load-module token_module
|
||||
logoscore module-info token_module --json
|
||||
logoscore call token_module programInfo --json
|
||||
logoscore call token_module inspectDefinition deadbeef --json
|
||||
```
|
||||
|
||||
The final call must return `invalid_account_id` without crashing, and
|
||||
`programInfo` should return the configured or binary-derived ID.
|
||||
|
||||
### 4. Chain reads
|
||||
|
||||
Open a wallet configured for the target sequencer, then pass a real account ID:
|
||||
|
||||
```bash
|
||||
logoscore call logos_execution_zone open \
|
||||
/path/to/wallet_config.json /path/to/storage.json "$WALLET_PASSWORD" --json
|
||||
|
||||
logoscore call token_module inspectDefinition \
|
||||
7b464ff9dd0d3bc07f7e2e0b0667ccd066d85ad12be4c79fc55687a863910aa6 --json
|
||||
```
|
||||
|
||||
That example ID was a fixed-supply fungible on a historical testnet deployment;
|
||||
verify `programInfo` matches the intended deployment before interpreting it.
|
||||
Do not run mutating examples on a shared network without explicit operator
|
||||
authorization.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- Public wallet accounts only; no private/shielded token flow.
|
||||
- No token registry, HTTP metadata fetch, symbol, or decimals model.
|
||||
- On-chain state can change between a read/preflight and transaction inclusion;
|
||||
the Token Program remains final authority.
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "token_ffi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
hex = "0.4"
|
||||
nssa_core = { workspace = true }
|
||||
risc0-binfmt = { version = "=3.0.4", default-features = false }
|
||||
risc0-zkvm = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
token_core = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
cbindgen = "0.28"
|
||||
@@ -0,0 +1,9 @@
|
||||
fn main() {
|
||||
let crate_dir =
|
||||
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo");
|
||||
cbindgen::generate(&crate_dir)
|
||||
.expect("cbindgen")
|
||||
.write_to_file(format!("{crate_dir}/include/token_ffi.h"));
|
||||
println!("cargo:rerun-if-changed=src");
|
||||
println!("cargo:rerun-if-changed=cbindgen.toml");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
language = "C"
|
||||
include_guard = "TOKEN_FFI_H"
|
||||
pragma_once = true
|
||||
cpp_compat = true
|
||||
autogen_warning = "/* Generated by cbindgen. Do not edit. */"
|
||||
|
||||
[export]
|
||||
prefix = ""
|
||||
@@ -0,0 +1,125 @@
|
||||
#ifndef TOKEN_FFI_H
|
||||
#define TOKEN_FFI_H
|
||||
|
||||
#pragma once
|
||||
|
||||
/* Generated by cbindgen. Do not edit. */
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif // __cplusplus
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_program_id(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_decode_definition(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_decode_holding(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_decode_metadata(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_decode_account(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_create_fungible_plan(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_create_fungible_with_metadata_plan(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_create_non_fungible_plan(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_initialize_holding_plan(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_transfer_plan(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_burn_plan(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_mint_plan(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_mint_with_authority_plan(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_set_authority_plan(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_set_authority_with_authority_plan(const char *request_json);
|
||||
|
||||
/**
|
||||
* # Safety
|
||||
* `request_json` must be null or point to a live NUL-terminated byte string.
|
||||
*/
|
||||
char *token_print_nft_plan(const char *request_json);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
void token_free(char *value);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif // __cplusplus
|
||||
|
||||
#endif /* TOKEN_FFI_H */
|
||||
@@ -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()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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()) };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
use token_ffi::{
|
||||
burn_plan, create_fungible_plan, decode_definition, print_nft_plan, transfer_plan,
|
||||
BurnPlanRequest, CreateFungiblePlanRequest, DecodeDefinitionRequest, PrintNftPlanRequest,
|
||||
TokenResult, TransferPlanRequest,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn crate_root_reexports_token_surface() {
|
||||
let _decode: fn(DecodeDefinitionRequest) -> TokenResult = decode_definition;
|
||||
let _create: fn(CreateFungiblePlanRequest) -> TokenResult = create_fungible_plan;
|
||||
let _transfer: fn(TransferPlanRequest) -> TokenResult = transfer_plan;
|
||||
let _burn: fn(BurnPlanRequest) -> TokenResult = burn_plan;
|
||||
let _print: fn(PrintNftPlanRequest) -> TokenResult = print_nft_plan;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "token_module",
|
||||
"version": "0.1.0",
|
||||
"type": "core",
|
||||
"interface": "universal",
|
||||
"category": "token",
|
||||
"description": "Universal Logos core module for LEZ token account reads and transactions",
|
||||
"main": "token_module_plugin",
|
||||
"dependencies": ["logos_execution_zone"],
|
||||
"nix": {
|
||||
"packages": {
|
||||
"build": [],
|
||||
"runtime": []
|
||||
},
|
||||
"external_libraries": [
|
||||
{
|
||||
"name": "token_ffi"
|
||||
}
|
||||
],
|
||||
"cmake": {
|
||||
"find_packages": [],
|
||||
"extra_sources": [],
|
||||
"extra_include_dirs": [],
|
||||
"extra_link_libraries": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "token_instruction_words.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace token_module::detail {
|
||||
|
||||
std::vector<std::uint8_t> jsonInstructionLeBytes(const nlohmann::json& input) {
|
||||
std::vector<std::uint8_t> result;
|
||||
if (!input.is_array()) return result;
|
||||
result.reserve(input.size() * sizeof(std::uint32_t));
|
||||
for (const auto& item : input) {
|
||||
if (!item.is_number_unsigned() && !item.is_number_integer()) return {};
|
||||
std::uint64_t raw = 0;
|
||||
if (item.is_number_unsigned()) {
|
||||
raw = item.get<std::uint64_t>();
|
||||
} else {
|
||||
const std::int64_t signed_raw = item.get<std::int64_t>();
|
||||
if (signed_raw < 0) return {};
|
||||
raw = static_cast<std::uint64_t>(signed_raw);
|
||||
}
|
||||
if (raw > std::numeric_limits<std::uint32_t>::max()) return {};
|
||||
const auto word = static_cast<std::uint32_t>(raw);
|
||||
result.push_back(static_cast<std::uint8_t>(word & 0xff));
|
||||
result.push_back(static_cast<std::uint8_t>((word >> 8) & 0xff));
|
||||
result.push_back(static_cast<std::uint8_t>((word >> 16) & 0xff));
|
||||
result.push_back(static_cast<std::uint8_t>((word >> 24) & 0xff));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace token_module::detail
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
|
||||
namespace token_module::detail {
|
||||
|
||||
// Decodes a plan's `instruction` word array (u32 values) into the little-endian
|
||||
// byte string the wallet module's send_generic_public_transaction expects (its
|
||||
// `instruction` param is a byte-string IPC type). Returns {} on any non-array
|
||||
// input or a word that is negative, fractional, or exceeds u32.
|
||||
std::vector<std::uint8_t> jsonInstructionLeBytes(const nlohmann::json& input);
|
||||
|
||||
} // namespace token_module::detail
|
||||
@@ -0,0 +1,808 @@
|
||||
#include "token_module_impl.h"
|
||||
#include "token_instruction_words.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
// Generated by logos-module-builder. Kept out of the universal API header.
|
||||
#include "logos_sdk.h"
|
||||
|
||||
extern "C" {
|
||||
#include "token_ffi.h"
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
constexpr char TOKEN_PROGRAM_BIN_ENV[] = "TOKEN_PROGRAM_BIN";
|
||||
constexpr char TOKEN_PROGRAM_ID_ENV[] = "TOKEN_PROGRAM_ID";
|
||||
std::mutex programInfoMutex;
|
||||
|
||||
bool tokenDebug() {
|
||||
static const bool enabled = std::getenv("TOKEN_DEBUG") != nullptr;
|
||||
return enabled;
|
||||
}
|
||||
|
||||
#define TOKEN_TRACE(message) \
|
||||
do { \
|
||||
if (tokenDebug()) std::cerr << "[token-debug] " << message << '\n'; \
|
||||
} while (false)
|
||||
|
||||
int hexValue(char value) {
|
||||
if (value >= '0' && value <= '9') return value - '0';
|
||||
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
|
||||
if (value >= 'A' && value <= 'F') return value - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool isHexLength(const std::string& value, std::size_t length) {
|
||||
if (value.size() != length) return false;
|
||||
return std::all_of(value.begin(), value.end(), [](char c) { return hexValue(c) >= 0; });
|
||||
}
|
||||
|
||||
bool isEvenHex(const std::string& value) {
|
||||
return value.size() % 2 == 0
|
||||
&& std::all_of(value.begin(), value.end(), [](char c) { return hexValue(c) >= 0; });
|
||||
}
|
||||
|
||||
std::string lowercase(std::string value) {
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string trim(const std::string& value) {
|
||||
std::size_t begin = 0;
|
||||
std::size_t end = value.size();
|
||||
while (begin < end && std::isspace(static_cast<unsigned char>(value[begin]))) ++begin;
|
||||
while (end > begin && std::isspace(static_cast<unsigned char>(value[end - 1]))) --end;
|
||||
return value.substr(begin, end - begin);
|
||||
}
|
||||
|
||||
bool isZeroId(const std::string& value) {
|
||||
return value.size() == 64
|
||||
&& std::all_of(value.begin(), value.end(), [](char c) { return c == '0'; });
|
||||
}
|
||||
|
||||
bool isAllZero(const std::string& value) {
|
||||
return !value.empty()
|
||||
&& std::all_of(value.begin(), value.end(), [](char c) { return c == '0'; });
|
||||
}
|
||||
|
||||
bool isDefaultPublicAccount(const std::string& owner,
|
||||
const std::string& balance,
|
||||
const std::string& nonce,
|
||||
const std::string& data) {
|
||||
// The execution-zone read API represents an absent public account with
|
||||
// Account::default(): zero owner, balance, and nonce, with no data.
|
||||
return isZeroId(owner) && isAllZero(balance) && isAllZero(nonce) && data.empty();
|
||||
}
|
||||
|
||||
std::string jsonString(const json& object, const char* key) {
|
||||
const auto it = object.find(key);
|
||||
return it != object.end() && it->is_string() ? it->get<std::string>() : std::string();
|
||||
}
|
||||
|
||||
std::string bytesToHex(const std::uint8_t* bytes, std::size_t length) {
|
||||
static constexpr char digits[] = "0123456789abcdef";
|
||||
std::string result;
|
||||
result.reserve(length * 2);
|
||||
for (std::size_t i = 0; i < length; ++i) {
|
||||
result.push_back(digits[bytes[i] >> 4]);
|
||||
result.push_back(digits[bytes[i] & 0x0f]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
LogosMap publicOk() {
|
||||
return {{"status", "ok"}, {"error", ""}};
|
||||
}
|
||||
|
||||
LogosMap publicError(const std::string& error) {
|
||||
return {{"status", "error"}, {"error", error}};
|
||||
}
|
||||
|
||||
template <typename Operation>
|
||||
LogosMap guarded(Operation&& operation) noexcept {
|
||||
try {
|
||||
return operation();
|
||||
} catch (const std::exception& error) {
|
||||
TOKEN_TRACE("caught exception: " << error.what());
|
||||
} catch (...) {
|
||||
TOKEN_TRACE("caught non-standard exception");
|
||||
}
|
||||
return publicError("backend_error");
|
||||
}
|
||||
|
||||
struct FfiResult {
|
||||
bool ok = false;
|
||||
json value;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
FfiResult callToken(char* (*operation)(const char*), const json& request) {
|
||||
const std::string payload = request.dump();
|
||||
std::unique_ptr<char, decltype(&token_free)> response(
|
||||
operation(payload.c_str()),
|
||||
&token_free);
|
||||
if (!response) {
|
||||
TOKEN_TRACE("token_ffi returned null");
|
||||
return {};
|
||||
}
|
||||
|
||||
const json document = json::parse(response.get(), nullptr, false);
|
||||
if (!document.is_object()) {
|
||||
TOKEN_TRACE("token_ffi returned malformed JSON");
|
||||
return {};
|
||||
}
|
||||
const auto ok = document.find("ok");
|
||||
if (ok == document.end() || !ok->is_boolean()) return {};
|
||||
if (!ok->get<bool>()) {
|
||||
const std::string error = jsonString(document, "error");
|
||||
TOKEN_TRACE("token_ffi failure: " << error);
|
||||
return {false, json(), error};
|
||||
}
|
||||
const auto value = document.find("value");
|
||||
if (value == document.end() || !value->is_object()) return {};
|
||||
return {true, *value, {}};
|
||||
}
|
||||
|
||||
bool isStableError(const std::string& error) {
|
||||
static const std::set<std::string> stable = {
|
||||
"bad_request",
|
||||
"bad_amount",
|
||||
"invalid_account_id",
|
||||
"invalid_authority",
|
||||
"invalid_metadata_standard",
|
||||
"account_read_failed",
|
||||
"token_program_mismatch",
|
||||
"invalid_definition_data",
|
||||
"invalid_holding_data",
|
||||
"invalid_metadata_data",
|
||||
"backend_error",
|
||||
};
|
||||
return stable.find(error) != stable.end();
|
||||
}
|
||||
|
||||
std::string ffiError(const FfiResult& result, const std::string& fallback = "backend_error") {
|
||||
return isStableError(result.error) ? result.error : fallback;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint8_t> TokenModuleImpl::loadTokenBinary() const {
|
||||
const char* path = std::getenv(TOKEN_PROGRAM_BIN_ENV);
|
||||
if (path == nullptr || *path == '\0') return {};
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file) return {};
|
||||
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(file),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
std::string TokenModuleImpl::loadTokenProgramId() const {
|
||||
const char* value = std::getenv(TOKEN_PROGRAM_ID_ENV);
|
||||
return value == nullptr ? std::string() : trim(value);
|
||||
}
|
||||
|
||||
nlohmann::json TokenModuleImpl::tokenProgramInfo() {
|
||||
const std::lock_guard<std::mutex> lock(programInfoMutex);
|
||||
if (programInfoResolved_) {
|
||||
return {{"programId", programId_}, {"programIdHex", programIdHex_}};
|
||||
}
|
||||
|
||||
const std::string configured_program = loadTokenProgramId();
|
||||
const bool program_id_configured = !configured_program.empty();
|
||||
const char* binary_path = std::getenv(TOKEN_PROGRAM_BIN_ENV);
|
||||
const bool binary_configured = binary_path != nullptr && *binary_path != '\0';
|
||||
const std::string configured_program_hex = program_id_configured
|
||||
? normalizeAccountId(configured_program)
|
||||
: std::string();
|
||||
if (program_id_configured && configured_program_hex.empty()) {
|
||||
TOKEN_TRACE("TOKEN_PROGRAM_ID is not a valid account ID");
|
||||
return json();
|
||||
}
|
||||
|
||||
const std::vector<std::uint8_t> binary = loadTokenBinary();
|
||||
if (binary_configured && binary.empty()) {
|
||||
TOKEN_TRACE("TOKEN_PROGRAM_BIN is configured but unreadable");
|
||||
return json();
|
||||
}
|
||||
|
||||
std::string derived_program;
|
||||
std::string derived_program_hex;
|
||||
if (!binary.empty()) {
|
||||
const FfiResult result = callToken(
|
||||
token_program_id,
|
||||
{{"elf", bytesToHex(binary.data(), binary.size())}});
|
||||
if (!result.ok) return json();
|
||||
|
||||
derived_program_hex = lowercase(jsonString(result.value, "programId"));
|
||||
derived_program = jsonString(result.value, "programIdBase58");
|
||||
if (derived_program.empty() || !isHexLength(derived_program_hex, 64)
|
||||
|| isZeroId(derived_program_hex)) {
|
||||
return json();
|
||||
}
|
||||
}
|
||||
|
||||
if (!program_id_configured && !binary_configured) return json();
|
||||
if (program_id_configured && binary_configured
|
||||
&& configured_program_hex != derived_program_hex) {
|
||||
TOKEN_TRACE("TOKEN_PROGRAM_ID does not match TOKEN_PROGRAM_BIN");
|
||||
return json();
|
||||
}
|
||||
|
||||
const std::string program_id_hex = program_id_configured
|
||||
? configured_program_hex
|
||||
: derived_program_hex;
|
||||
const std::string program_id = binary_configured
|
||||
? derived_program
|
||||
: modules().logos_execution_zone.account_id_to_base58(program_id_hex);
|
||||
if (program_id.empty() || !isHexLength(program_id_hex, 64) || isZeroId(program_id_hex)) {
|
||||
return json();
|
||||
}
|
||||
|
||||
// Cache successful resolution only: an early missing/unreadable
|
||||
// configuration may become available later during process startup.
|
||||
programId_ = program_id;
|
||||
programIdHex_ = program_id_hex;
|
||||
programInfoResolved_ = true;
|
||||
return {{"programId", programId_}, {"programIdHex", programIdHex_}};
|
||||
}
|
||||
|
||||
std::string TokenModuleImpl::normalizeAccountId(const std::string& id) {
|
||||
std::string normalized = trim(id);
|
||||
if (isHexLength(normalized, 64)) {
|
||||
normalized = lowercase(std::move(normalized));
|
||||
return isZeroId(normalized) ? std::string() : normalized;
|
||||
}
|
||||
if (normalized.empty()) return {};
|
||||
|
||||
normalized = lowercase(
|
||||
modules().logos_execution_zone.account_id_from_base58(normalized));
|
||||
return isHexLength(normalized, 64) && !isZeroId(normalized)
|
||||
? normalized
|
||||
: std::string();
|
||||
}
|
||||
|
||||
nlohmann::json TokenModuleImpl::readPublicAccount(const std::string& account_id) {
|
||||
json read = {{"id", account_id}, {"status", "not_found"}};
|
||||
logos::CallError call_error;
|
||||
const std::string raw =
|
||||
modules().logos_execution_zone.get_account_public(account_id, &call_error);
|
||||
if (!call_error.ok()) {
|
||||
TOKEN_TRACE("logos_execution_zone account read transport failure: " << call_error.code);
|
||||
read["status"] = "backend_error";
|
||||
return read;
|
||||
}
|
||||
if (raw.empty()) return read;
|
||||
|
||||
const json account = json::parse(raw, nullptr, false);
|
||||
if (!account.is_object()) {
|
||||
read["status"] = "backend_error";
|
||||
return read;
|
||||
}
|
||||
|
||||
std::string owner = lowercase(jsonString(account, "program_owner"));
|
||||
std::string balance = lowercase(jsonString(account, "balance"));
|
||||
std::string nonce = lowercase(jsonString(account, "nonce"));
|
||||
std::string data = lowercase(jsonString(account, "data"));
|
||||
if (!isHexLength(owner, 64) || !isHexLength(balance, 32)
|
||||
|| !isHexLength(nonce, 32) || !isEvenHex(data)) {
|
||||
read["status"] = "backend_error";
|
||||
return read;
|
||||
}
|
||||
|
||||
if (isDefaultPublicAccount(owner, balance, nonce, data)) return read;
|
||||
|
||||
read["status"] = "ok";
|
||||
read["account"] = {
|
||||
{"program_owner", std::move(owner)},
|
||||
{"balance", std::move(balance)},
|
||||
{"nonce", std::move(nonce)},
|
||||
{"data", std::move(data)},
|
||||
};
|
||||
return read;
|
||||
}
|
||||
|
||||
nlohmann::json TokenModuleImpl::walletAccountIds() {
|
||||
logos::CallError call_error;
|
||||
const json accounts = modules().logos_execution_zone.list_accounts(&call_error);
|
||||
if (!call_error.ok()) {
|
||||
TOKEN_TRACE("logos_execution_zone account list transport failure: " << call_error.code);
|
||||
return json();
|
||||
}
|
||||
if (!accounts.is_array()) return json();
|
||||
|
||||
std::set<std::string> unique;
|
||||
for (const auto& account : accounts) {
|
||||
if (!account.is_object()) continue;
|
||||
const auto is_public = account.find("is_public");
|
||||
if (is_public == account.end() || !is_public->is_boolean() || !is_public->get<bool>()) {
|
||||
continue;
|
||||
}
|
||||
const std::string id = normalizeAccountId(jsonString(account, "account_id"));
|
||||
if (!id.empty()) unique.insert(id);
|
||||
}
|
||||
return json(unique);
|
||||
}
|
||||
|
||||
bool TokenModuleImpl::isFreshOwnedAccount(const std::string& account_id,
|
||||
bool& fresh,
|
||||
std::string& error) {
|
||||
const json read = readPublicAccount(account_id);
|
||||
if (jsonString(read, "status") == "ok") {
|
||||
const json info = tokenProgramInfo();
|
||||
if (!info.is_object()) {
|
||||
error = "config_missing";
|
||||
return false;
|
||||
}
|
||||
const FfiResult decoded = callToken(token_decode_holding, {
|
||||
{"tokenProgramId", info["programIdHex"]},
|
||||
{"holding", read},
|
||||
});
|
||||
if (!decoded.ok) {
|
||||
error = ffiError(decoded);
|
||||
return false;
|
||||
}
|
||||
fresh = false;
|
||||
return true;
|
||||
}
|
||||
if (jsonString(read, "status") != "not_found") {
|
||||
error = "backend_error";
|
||||
return false;
|
||||
}
|
||||
|
||||
const json wallet_ids = walletAccountIds();
|
||||
if (!wallet_ids.is_array()) {
|
||||
error = "backend_error";
|
||||
return false;
|
||||
}
|
||||
if (std::find(wallet_ids.begin(), wallet_ids.end(), json(account_id)) == wallet_ids.end()) {
|
||||
error = "account_read_failed";
|
||||
return false;
|
||||
}
|
||||
fresh = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TokenModuleImpl::requireFreshOwnedAccount(const std::string& account_id,
|
||||
std::string& error) {
|
||||
bool fresh = false;
|
||||
if (!isFreshOwnedAccount(account_id, fresh, error)) return false;
|
||||
if (!fresh) {
|
||||
error = "account_read_failed";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::programInfo() {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const json info = tokenProgramInfo();
|
||||
if (!info.is_object()) return publicError("config_missing");
|
||||
LogosMap result = publicOk();
|
||||
result["programId"] = info["programId"];
|
||||
result["programIdHex"] = info["programIdHex"];
|
||||
result["networkFingerprint"] = info["programIdHex"];
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::inspectAccount(const std::string& account_id,
|
||||
TokenOperation operation,
|
||||
const char* payload_key) {
|
||||
const json info = tokenProgramInfo();
|
||||
if (!info.is_object()) return publicError("config_missing");
|
||||
|
||||
const std::string normalized = normalizeAccountId(account_id);
|
||||
if (normalized.empty()) return publicError("invalid_account_id");
|
||||
const json read = readPublicAccount(normalized);
|
||||
const std::string read_status = jsonString(read, "status");
|
||||
if (read_status == "backend_error") return publicError("backend_error");
|
||||
if (read_status != "ok") return publicError("account_read_failed");
|
||||
|
||||
json request = {{"tokenProgramId", info["programIdHex"]}};
|
||||
request[payload_key] = read;
|
||||
const FfiResult decoded = callToken(operation, request);
|
||||
if (!decoded.ok) return publicError(ffiError(decoded));
|
||||
|
||||
LogosMap result = publicOk();
|
||||
result[payload_key] = decoded.value;
|
||||
return result;
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::inspectDefinition(const std::string& definition_id) {
|
||||
return guarded([&]() {
|
||||
return inspectAccount(definition_id, token_decode_definition, "definition");
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::inspectHolding(const std::string& holding_id) {
|
||||
return guarded([&]() {
|
||||
return inspectAccount(holding_id, token_decode_holding, "holding");
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::inspectMetadata(const std::string& metadata_id) {
|
||||
return guarded([&]() {
|
||||
return inspectAccount(metadata_id, token_decode_metadata, "metadata");
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::walletTokenAccounts() {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const json info = tokenProgramInfo();
|
||||
if (!info.is_object()) return publicError("config_missing");
|
||||
const std::string program_id = jsonString(info, "programIdHex");
|
||||
|
||||
const json ids = walletAccountIds();
|
||||
if (!ids.is_array()) return publicError("backend_error");
|
||||
|
||||
json accounts = json::array();
|
||||
for (const auto& id_value : ids) {
|
||||
if (!id_value.is_string()) continue;
|
||||
const std::string id = id_value.get<std::string>();
|
||||
const json read = readPublicAccount(id);
|
||||
if (jsonString(read, "status") != "ok") continue;
|
||||
const auto account = read.find("account");
|
||||
if (account == read.end() || !account->is_object()
|
||||
|| lowercase(jsonString(*account, "program_owner")) != program_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const FfiResult decoded = callToken(token_decode_account, {
|
||||
{"tokenProgramId", program_id},
|
||||
{"account", read},
|
||||
});
|
||||
if (!decoded.ok || !isHexLength(jsonString(decoded.value, "accountIdHex"), 64)) {
|
||||
continue;
|
||||
}
|
||||
accounts.push_back(decoded.value);
|
||||
}
|
||||
|
||||
std::sort(accounts.begin(), accounts.end(), [](const json& left, const json& right) {
|
||||
return lowercase(jsonString(left, "accountIdHex"))
|
||||
< lowercase(jsonString(right, "accountIdHex"));
|
||||
});
|
||||
LogosMap result = publicOk();
|
||||
result["accounts"] = std::move(accounts);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::submitPlan(const nlohmann::json& plan) {
|
||||
const auto account_ids_it = plan.find("accountIds");
|
||||
const auto signers_it = plan.find("signingRequirements");
|
||||
const auto instruction_it = plan.find("instruction");
|
||||
const std::string program_id = lowercase(jsonString(plan, "programId"));
|
||||
if (account_ids_it == plan.end() || signers_it == plan.end()
|
||||
|| instruction_it == plan.end() || !isHexLength(program_id, 64)) {
|
||||
return publicError("backend_error");
|
||||
}
|
||||
|
||||
const std::vector<std::string> account_ids =
|
||||
account_ids_it->get<std::vector<std::string>>();
|
||||
const std::vector<bool> signers = signers_it->get<std::vector<bool>>();
|
||||
const std::vector<std::uint8_t> instruction =
|
||||
token_module::detail::jsonInstructionLeBytes(*instruction_it);
|
||||
if (account_ids.empty() || account_ids.size() != signers.size()
|
||||
|| instruction.empty()
|
||||
|| std::any_of(account_ids.begin(), account_ids.end(), [](const std::string& id) {
|
||||
return !isHexLength(id, 64) || isZeroId(id);
|
||||
})) {
|
||||
return publicError("backend_error");
|
||||
}
|
||||
|
||||
logos::CallError call_error;
|
||||
const std::string raw = modules().logos_execution_zone.send_generic_public_transaction(
|
||||
account_ids,
|
||||
signers,
|
||||
instruction,
|
||||
program_id,
|
||||
&call_error);
|
||||
if (!call_error.ok()) {
|
||||
TOKEN_TRACE("logos_execution_zone submission transport failure: " << call_error.code);
|
||||
return publicError("wallet_submission_failed");
|
||||
}
|
||||
const json reply = json::parse(raw, nullptr, false);
|
||||
if (!reply.is_object()) return publicError("wallet_submission_failed");
|
||||
const auto success = reply.find("success");
|
||||
const std::string transaction_id = jsonString(reply, "tx_hash");
|
||||
if (success == reply.end() || !success->is_boolean() || !success->get<bool>()
|
||||
|| transaction_id.empty()) {
|
||||
return publicError("wallet_submission_failed");
|
||||
}
|
||||
|
||||
LogosMap result = publicOk();
|
||||
result["transactionId"] = transaction_id;
|
||||
return result;
|
||||
}
|
||||
|
||||
bool TokenModuleImpl::normalizeAuthority(const std::string& authority,
|
||||
std::string& normalized) {
|
||||
const std::string sentinel = lowercase(trim(authority));
|
||||
if (sentinel == "none" || sentinel == "self") {
|
||||
normalized = sentinel;
|
||||
return true;
|
||||
}
|
||||
normalized = normalizeAccountId(authority);
|
||||
return !normalized.empty();
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::planAndSubmit(TokenOperation planner, nlohmann::json request) {
|
||||
const json info = tokenProgramInfo();
|
||||
if (!info.is_object()) return publicError("config_missing");
|
||||
const std::string configured_program = jsonString(info, "programIdHex");
|
||||
request["tokenProgramId"] = configured_program;
|
||||
|
||||
const FfiResult result = callToken(planner, request);
|
||||
if (!result.ok) return publicError(ffiError(result));
|
||||
if (lowercase(jsonString(result.value, "programId")) != configured_program) {
|
||||
return publicError("backend_error");
|
||||
}
|
||||
return submitPlan(result.value);
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::createFungible(const std::string& definition_target_id,
|
||||
const std::string& holding_target_id,
|
||||
const std::string& name,
|
||||
const nlohmann::json& total_supply_raw,
|
||||
const std::string& mint_authority) {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const std::string definition = normalizeAccountId(definition_target_id);
|
||||
const std::string holding = normalizeAccountId(holding_target_id);
|
||||
if (definition.empty() || holding.empty()) return publicError("invalid_account_id");
|
||||
std::string error;
|
||||
if (!requireFreshOwnedAccount(definition, error)
|
||||
|| !requireFreshOwnedAccount(holding, error)) {
|
||||
return publicError(error);
|
||||
}
|
||||
std::string authority;
|
||||
if (!normalizeAuthority(mint_authority, authority)) {
|
||||
return publicError("invalid_authority");
|
||||
}
|
||||
return planAndSubmit(token_create_fungible_plan, {
|
||||
{"definitionTargetId", definition},
|
||||
{"holdingTargetId", holding},
|
||||
{"name", name},
|
||||
{"totalSupplyRaw", total_supply_raw},
|
||||
{"mintAuthority", authority},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::createFungibleWithMetadata(
|
||||
const std::string& definition_target_id,
|
||||
const std::string& holding_target_id,
|
||||
const std::string& metadata_target_id,
|
||||
const std::string& name,
|
||||
const nlohmann::json& total_supply_raw,
|
||||
const std::string& mint_authority,
|
||||
const std::string& metadata_standard,
|
||||
const std::string& uri,
|
||||
const std::string& creators) {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const std::string definition = normalizeAccountId(definition_target_id);
|
||||
const std::string holding = normalizeAccountId(holding_target_id);
|
||||
const std::string metadata = normalizeAccountId(metadata_target_id);
|
||||
if (definition.empty() || holding.empty() || metadata.empty()) {
|
||||
return publicError("invalid_account_id");
|
||||
}
|
||||
std::string error;
|
||||
if (!requireFreshOwnedAccount(definition, error)
|
||||
|| !requireFreshOwnedAccount(holding, error)
|
||||
|| !requireFreshOwnedAccount(metadata, error)) {
|
||||
return publicError(error);
|
||||
}
|
||||
std::string authority;
|
||||
if (!normalizeAuthority(mint_authority, authority)) {
|
||||
return publicError("invalid_authority");
|
||||
}
|
||||
return planAndSubmit(token_create_fungible_with_metadata_plan, {
|
||||
{"definitionTargetId", definition},
|
||||
{"holdingTargetId", holding},
|
||||
{"metadataTargetId", metadata},
|
||||
{"name", name},
|
||||
{"totalSupplyRaw", total_supply_raw},
|
||||
{"mintAuthority", authority},
|
||||
{"metadataStandard", metadata_standard},
|
||||
{"uri", uri},
|
||||
{"creators", creators},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::createNonFungible(
|
||||
const std::string& definition_target_id,
|
||||
const std::string& master_holding_target_id,
|
||||
const std::string& metadata_target_id,
|
||||
const std::string& name,
|
||||
const nlohmann::json& printable_supply_raw,
|
||||
const std::string& metadata_standard,
|
||||
const std::string& uri,
|
||||
const std::string& creators) {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const std::string definition = normalizeAccountId(definition_target_id);
|
||||
const std::string master = normalizeAccountId(master_holding_target_id);
|
||||
const std::string metadata = normalizeAccountId(metadata_target_id);
|
||||
if (definition.empty() || master.empty() || metadata.empty()) {
|
||||
return publicError("invalid_account_id");
|
||||
}
|
||||
std::string error;
|
||||
if (!requireFreshOwnedAccount(definition, error)
|
||||
|| !requireFreshOwnedAccount(master, error)
|
||||
|| !requireFreshOwnedAccount(metadata, error)) {
|
||||
return publicError(error);
|
||||
}
|
||||
return planAndSubmit(token_create_non_fungible_plan, {
|
||||
{"definitionTargetId", definition},
|
||||
{"masterHoldingTargetId", master},
|
||||
{"metadataTargetId", metadata},
|
||||
{"name", name},
|
||||
{"printableSupplyRaw", printable_supply_raw},
|
||||
{"metadataStandard", metadata_standard},
|
||||
{"uri", uri},
|
||||
{"creators", creators},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::initializeHolding(const std::string& definition_id,
|
||||
const std::string& holding_target_id) {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const std::string definition = normalizeAccountId(definition_id);
|
||||
const std::string holding = normalizeAccountId(holding_target_id);
|
||||
if (definition.empty() || holding.empty()) return publicError("invalid_account_id");
|
||||
std::string error;
|
||||
if (!requireFreshOwnedAccount(holding, error)) return publicError(error);
|
||||
return planAndSubmit(token_initialize_holding_plan, {
|
||||
{"definitionId", definition},
|
||||
{"holdingTargetId", holding},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::transfer(const std::string& sender_holding_id,
|
||||
const std::string& recipient_holding_id,
|
||||
const nlohmann::json& amount_raw) {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const std::string sender = normalizeAccountId(sender_holding_id);
|
||||
const std::string recipient = normalizeAccountId(recipient_holding_id);
|
||||
if (sender.empty() || recipient.empty()) return publicError("invalid_account_id");
|
||||
bool recipient_fresh = false;
|
||||
std::string error;
|
||||
if (!isFreshOwnedAccount(recipient, recipient_fresh, error)) {
|
||||
return publicError(error);
|
||||
}
|
||||
return planAndSubmit(token_transfer_plan, {
|
||||
{"senderHoldingId", sender},
|
||||
{"recipientHoldingId", recipient},
|
||||
{"amountRaw", amount_raw},
|
||||
{"recipientIsFresh", recipient_fresh},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::burn(const std::string& definition_id,
|
||||
const std::string& holding_id,
|
||||
const nlohmann::json& amount_raw) {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const std::string definition = normalizeAccountId(definition_id);
|
||||
const std::string holding = normalizeAccountId(holding_id);
|
||||
if (definition.empty() || holding.empty()) return publicError("invalid_account_id");
|
||||
return planAndSubmit(token_burn_plan, {
|
||||
{"definitionId", definition},
|
||||
{"holdingId", holding},
|
||||
{"amountRaw", amount_raw},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::mint(const std::string& definition_id,
|
||||
const std::string& holding_id,
|
||||
const nlohmann::json& amount_raw) {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const std::string definition = normalizeAccountId(definition_id);
|
||||
const std::string holding = normalizeAccountId(holding_id);
|
||||
if (definition.empty() || holding.empty()) return publicError("invalid_account_id");
|
||||
bool holding_fresh = false;
|
||||
std::string error;
|
||||
if (!isFreshOwnedAccount(holding, holding_fresh, error)) return publicError(error);
|
||||
return planAndSubmit(token_mint_plan, {
|
||||
{"definitionId", definition},
|
||||
{"holdingId", holding},
|
||||
{"amountRaw", amount_raw},
|
||||
{"holdingIsFresh", holding_fresh},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::mintWithAuthority(const std::string& definition_id,
|
||||
const std::string& holding_id,
|
||||
const std::string& authority_id,
|
||||
const nlohmann::json& amount_raw) {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const std::string definition = normalizeAccountId(definition_id);
|
||||
const std::string holding = normalizeAccountId(holding_id);
|
||||
const std::string authority = normalizeAccountId(authority_id);
|
||||
if (definition.empty() || holding.empty()) return publicError("invalid_account_id");
|
||||
if (authority.empty()) return publicError("invalid_authority");
|
||||
bool holding_fresh = false;
|
||||
std::string error;
|
||||
if (!isFreshOwnedAccount(holding, holding_fresh, error)) return publicError(error);
|
||||
return planAndSubmit(token_mint_with_authority_plan, {
|
||||
{"definitionId", definition},
|
||||
{"holdingId", holding},
|
||||
{"authorityId", authority},
|
||||
{"amountRaw", amount_raw},
|
||||
{"holdingIsFresh", holding_fresh},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::setAuthority(const std::string& definition_id,
|
||||
const std::string& new_authority) {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const std::string definition = normalizeAccountId(definition_id);
|
||||
if (definition.empty()) return publicError("invalid_account_id");
|
||||
std::string authority;
|
||||
if (!normalizeAuthority(new_authority, authority)) {
|
||||
return publicError("invalid_authority");
|
||||
}
|
||||
return planAndSubmit(token_set_authority_plan, {
|
||||
{"definitionId", definition},
|
||||
{"newAuthority", authority},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::setAuthorityWithAuthority(
|
||||
const std::string& definition_id,
|
||||
const std::string& authority_id,
|
||||
const std::string& new_authority) {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const std::string definition = normalizeAccountId(definition_id);
|
||||
const std::string current_authority = normalizeAccountId(authority_id);
|
||||
if (definition.empty()) return publicError("invalid_account_id");
|
||||
if (current_authority.empty()) return publicError("invalid_authority");
|
||||
std::string next_authority;
|
||||
if (!normalizeAuthority(new_authority, next_authority)) {
|
||||
return publicError("invalid_authority");
|
||||
}
|
||||
return planAndSubmit(token_set_authority_with_authority_plan, {
|
||||
{"definitionId", definition},
|
||||
{"authorityId", current_authority},
|
||||
{"newAuthority", next_authority},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LogosMap TokenModuleImpl::printNft(const std::string& master_holding_id,
|
||||
const std::string& printed_holding_target_id) {
|
||||
return guarded([&]() -> LogosMap {
|
||||
const std::string master = normalizeAccountId(master_holding_id);
|
||||
const std::string printed = normalizeAccountId(printed_holding_target_id);
|
||||
if (master.empty() || printed.empty()) return publicError("invalid_account_id");
|
||||
std::string error;
|
||||
if (!requireFreshOwnedAccount(printed, error)) return publicError(error);
|
||||
return planAndSubmit(token_print_nft_plan, {
|
||||
{"masterHoldingId", master},
|
||||
{"printedHoldingTargetId", printed},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <logos_json.h>
|
||||
#include <logos_module_context.h>
|
||||
|
||||
// Universal Logos core module for the LEZ Token program. Public methods form
|
||||
// the generated API, so this header deliberately uses only standard C++ and
|
||||
// Logos JSON types. Rust token_ffi owns token codecs and instruction encoding;
|
||||
// logos_execution_zone owns wallet/account I/O and transaction submission.
|
||||
class TokenModuleImpl : public LogosModuleContext {
|
||||
public:
|
||||
TokenModuleImpl() = default;
|
||||
~TokenModuleImpl() = default;
|
||||
|
||||
/// Returns `{status,error,programId,programIdHex,networkFingerprint}`.
|
||||
/// Configure TOKEN_PROGRAM_ID or TOKEN_PROGRAM_BIN. If both are set, they
|
||||
/// must identify the same program.
|
||||
LogosMap programInfo();
|
||||
|
||||
/// Reads and decodes any public token definition account. `definition_id`
|
||||
/// accepts 32-byte hex or base58. Success adds `definition`; failures use
|
||||
/// invalid_account_id, account_read_failed, token_program_mismatch,
|
||||
/// invalid_definition_data, config_missing, or backend_error.
|
||||
LogosMap inspectDefinition(const std::string& definition_id);
|
||||
|
||||
/// Reads and decodes any public token holding account. Success adds
|
||||
/// `holding`; failures follow the same stable read envelope.
|
||||
LogosMap inspectHolding(const std::string& holding_id);
|
||||
|
||||
/// Reads and decodes any public token metadata account. Success adds
|
||||
/// `metadata`; failures follow the same stable read envelope.
|
||||
LogosMap inspectMetadata(const std::string& metadata_id);
|
||||
|
||||
/// Discovers wallet-owned public token accounts from live reads. Invalid,
|
||||
/// foreign, unreadable, and ambiguously decoded rows are skipped. Success
|
||||
/// adds `accounts`, sorted by lowercase accountIdHex.
|
||||
LogosMap walletTokenAccounts();
|
||||
|
||||
/// Creates a fungible definition and initial holding. Both target IDs must
|
||||
/// be fresh public wallet accounts and sign. `total_supply_raw` is an exact
|
||||
/// non-negative u128 decimal. `mint_authority`: none, self, or account ID.
|
||||
LogosMap createFungible(const std::string& definition_target_id,
|
||||
const std::string& holding_target_id,
|
||||
const std::string& name,
|
||||
const nlohmann::json& total_supply_raw,
|
||||
const std::string& mint_authority);
|
||||
|
||||
/// Creates a metadata-backed fungible definition. All three target IDs are
|
||||
/// fresh public wallet accounts and sign. Standard is simple or expanded.
|
||||
LogosMap createFungibleWithMetadata(const std::string& definition_target_id,
|
||||
const std::string& holding_target_id,
|
||||
const std::string& metadata_target_id,
|
||||
const std::string& name,
|
||||
const nlohmann::json& total_supply_raw,
|
||||
const std::string& mint_authority,
|
||||
const std::string& metadata_standard,
|
||||
const std::string& uri,
|
||||
const std::string& creators);
|
||||
|
||||
/// Creates a non-fungible definition, master holding, and metadata. All
|
||||
/// targets must be fresh public wallet accounts and sign.
|
||||
LogosMap createNonFungible(const std::string& definition_target_id,
|
||||
const std::string& master_holding_target_id,
|
||||
const std::string& metadata_target_id,
|
||||
const std::string& name,
|
||||
const nlohmann::json& printable_supply_raw,
|
||||
const std::string& metadata_standard,
|
||||
const std::string& uri,
|
||||
const std::string& creators);
|
||||
|
||||
/// Initializes a fresh wallet-owned holding; holding target signs.
|
||||
LogosMap initializeHolding(const std::string& definition_id,
|
||||
const std::string& holding_target_id);
|
||||
|
||||
/// Transfers exact raw amount. Sender signs. Recipient signs only when its
|
||||
/// live account is empty and wallet ownership proves it is fresh.
|
||||
LogosMap transfer(const std::string& sender_holding_id,
|
||||
const std::string& recipient_holding_id,
|
||||
const nlohmann::json& amount_raw);
|
||||
|
||||
/// Burns exact raw amount. Holding signs.
|
||||
LogosMap burn(const std::string& definition_id,
|
||||
const std::string& holding_id,
|
||||
const nlohmann::json& amount_raw);
|
||||
|
||||
/// Mints through self-authority. Definition signs; holding signs only when
|
||||
/// live state and wallet ownership prove it is fresh.
|
||||
LogosMap mint(const std::string& definition_id,
|
||||
const std::string& holding_id,
|
||||
const nlohmann::json& amount_raw);
|
||||
|
||||
/// Mints through explicit external authority. Authority signs; holding
|
||||
/// signs only when live state and wallet ownership prove it is fresh.
|
||||
LogosMap mintWithAuthority(const std::string& definition_id,
|
||||
const std::string& holding_id,
|
||||
const std::string& authority_id,
|
||||
const nlohmann::json& amount_raw);
|
||||
|
||||
/// Rotates/revokes self authority. `new_authority`: none, self, or account
|
||||
/// ID. Definition signs.
|
||||
LogosMap setAuthority(const std::string& definition_id,
|
||||
const std::string& new_authority);
|
||||
|
||||
/// Rotates/revokes explicit external authority. Current authority signs.
|
||||
LogosMap setAuthorityWithAuthority(const std::string& definition_id,
|
||||
const std::string& authority_id,
|
||||
const std::string& new_authority);
|
||||
|
||||
/// Prints an NFT copy. Master and fully fresh printed target both sign.
|
||||
LogosMap printNft(const std::string& master_holding_id,
|
||||
const std::string& printed_holding_target_id);
|
||||
|
||||
private:
|
||||
using TokenOperation = char* (*)(const char*);
|
||||
|
||||
std::vector<std::uint8_t> loadTokenBinary() const;
|
||||
std::string loadTokenProgramId() const;
|
||||
nlohmann::json tokenProgramInfo();
|
||||
std::string normalizeAccountId(const std::string& id);
|
||||
nlohmann::json readPublicAccount(const std::string& account_id);
|
||||
nlohmann::json walletAccountIds();
|
||||
bool isFreshOwnedAccount(const std::string& account_id,
|
||||
bool& fresh,
|
||||
std::string& error);
|
||||
bool requireFreshOwnedAccount(const std::string& account_id,
|
||||
std::string& error);
|
||||
LogosMap inspectAccount(const std::string& account_id,
|
||||
TokenOperation operation,
|
||||
const char* payload_key);
|
||||
bool normalizeAuthority(const std::string& authority,
|
||||
std::string& normalized);
|
||||
LogosMap planAndSubmit(TokenOperation planner, nlohmann::json request);
|
||||
LogosMap submitPlan(const nlohmann::json& plan);
|
||||
|
||||
bool programInfoResolved_ = false;
|
||||
std::string programId_;
|
||||
std::string programIdHex_;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(TokenModuleTests LANGUAGES CXX)
|
||||
|
||||
include(LogosTest)
|
||||
|
||||
logos_test(
|
||||
NAME token_instruction_transport_tests
|
||||
MODULE_SOURCES
|
||||
../src/token_instruction_words.cpp
|
||||
TEST_SOURCES
|
||||
main.cpp
|
||||
token_instruction_words_test.cpp
|
||||
EXTRA_INCLUDES
|
||||
../src
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
#include <logos_test.h>
|
||||
|
||||
LOGOS_TEST_MAIN()
|
||||
@@ -0,0 +1,49 @@
|
||||
#include "token_instruction_words.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
|
||||
#include <logos_test.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
LOGOS_TEST(instruction_words_encodes_words_little_endian) {
|
||||
const nlohmann::json input = nlohmann::json::array(
|
||||
{std::uint64_t{0}, std::uint64_t{1},
|
||||
std::uint64_t{std::numeric_limits<std::uint32_t>::max()}});
|
||||
|
||||
const auto actual = token_module::detail::jsonInstructionLeBytes(input);
|
||||
const std::vector<std::uint8_t> expected = {
|
||||
0x00, 0x00, 0x00, 0x00, // 0
|
||||
0x01, 0x00, 0x00, 0x00, // 1 (little-endian)
|
||||
0xff, 0xff, 0xff, 0xff, // u32::MAX
|
||||
};
|
||||
LOGOS_ASSERT_EQ(actual.size(), expected.size());
|
||||
for (std::size_t i = 0; i < expected.size(); ++i) {
|
||||
LOGOS_ASSERT_EQ(actual[i], expected[i]);
|
||||
}
|
||||
}
|
||||
|
||||
LOGOS_TEST(instruction_words_rejects_negative_word) {
|
||||
const nlohmann::json input = nlohmann::json::array({std::int64_t{-1}});
|
||||
|
||||
LOGOS_ASSERT_TRUE(token_module::detail::jsonInstructionLeBytes(input).empty());
|
||||
}
|
||||
|
||||
LOGOS_TEST(instruction_words_rejects_overflow_word) {
|
||||
const nlohmann::json input = nlohmann::json::array(
|
||||
{std::uint64_t{std::numeric_limits<std::uint32_t>::max()} + 1});
|
||||
|
||||
LOGOS_ASSERT_TRUE(token_module::detail::jsonInstructionLeBytes(input).empty());
|
||||
}
|
||||
|
||||
LOGOS_TEST(instruction_words_rejects_partial_invalid_input) {
|
||||
const nlohmann::json input = nlohmann::json::array({std::uint64_t{1}, 1.5});
|
||||
|
||||
LOGOS_ASSERT_TRUE(token_module::detail::jsonInstructionLeBytes(input).empty());
|
||||
}
|
||||
|
||||
LOGOS_TEST(instruction_words_rejects_non_array) {
|
||||
const nlohmann::json input = nlohmann::json::object({{"word", 1}});
|
||||
|
||||
LOGOS_ASSERT_TRUE(token_module::detail::jsonInstructionLeBytes(input).empty());
|
||||
}
|
||||
Reference in New Issue
Block a user