From 508e0c123f3f09c811da2b06d2aec6bd1363c7c8 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 19 Aug 2026 01:36:02 -0300 Subject: [PATCH] feat(token): add Logos token API module --- Cargo.lock | 14 + Cargo.toml | 1 + apps/amm/flake.lock | 49 +- flake.nix | 122 ++- modules/token/CMakeLists.txt | 24 + modules/token/README.md | 265 ++++++ modules/token/ffi/Cargo.toml | 19 + modules/token/ffi/build.rs | 9 + modules/token/ffi/cbindgen.toml | 8 + modules/token/ffi/include/token_ffi.h | 125 +++ modules/token/ffi/src/account.rs | 138 +++ modules/token/ffi/src/api/decode.rs | 178 ++++ modules/token/ffi/src/api/mod.rs | 59 ++ modules/token/ffi/src/api/plan.rs | 314 +++++++ modules/token/ffi/src/api/request.rs | 154 ++++ modules/token/ffi/src/api/tests.rs | 800 +++++++++++++++++ modules/token/ffi/src/ffi.rs | 301 +++++++ modules/token/ffi/src/lib.rs | 20 + modules/token/ffi/tests/public_api.rs | 14 + modules/token/metadata.json | 27 + modules/token/src/token_instruction_words.cpp | 34 + modules/token/src/token_instruction_words.h | 16 + modules/token/src/token_module_impl.cpp | 808 ++++++++++++++++++ modules/token/src/token_module_impl.h | 142 +++ modules/token/tests/CMakeLists.txt | 15 + modules/token/tests/main.cpp | 3 + .../tests/token_instruction_words_test.cpp | 49 ++ 27 files changed, 3627 insertions(+), 81 deletions(-) create mode 100644 modules/token/CMakeLists.txt create mode 100644 modules/token/README.md create mode 100644 modules/token/ffi/Cargo.toml create mode 100644 modules/token/ffi/build.rs create mode 100644 modules/token/ffi/cbindgen.toml create mode 100644 modules/token/ffi/include/token_ffi.h create mode 100644 modules/token/ffi/src/account.rs create mode 100644 modules/token/ffi/src/api/decode.rs create mode 100644 modules/token/ffi/src/api/mod.rs create mode 100644 modules/token/ffi/src/api/plan.rs create mode 100644 modules/token/ffi/src/api/request.rs create mode 100644 modules/token/ffi/src/api/tests.rs create mode 100644 modules/token/ffi/src/ffi.rs create mode 100644 modules/token/ffi/src/lib.rs create mode 100644 modules/token/ffi/tests/public_api.rs create mode 100644 modules/token/metadata.json create mode 100644 modules/token/src/token_instruction_words.cpp create mode 100644 modules/token/src/token_instruction_words.h create mode 100644 modules/token/src/token_module_impl.cpp create mode 100644 modules/token/src/token_module_impl.h create mode 100644 modules/token/tests/CMakeLists.txt create mode 100644 modules/token/tests/main.cpp create mode 100644 modules/token/tests/token_instruction_words_test.cpp diff --git a/Cargo.lock b/Cargo.lock index 33bb2f9..8d9cc87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4164,6 +4164,20 @@ dependencies = [ "spel-framework-macros", ] +[[package]] +name = "token_ffi" +version = "0.1.0" +dependencies = [ + "cbindgen", + "hex", + "lee_core", + "risc0-binfmt", + "risc0-zkvm", + "serde", + "serde_json", + "token_core", +] + [[package]] name = "token_program" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 76b127f..db61bb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "modules/amm/ffi", + "modules/token/ffi", "programs/token/core", "programs/token", "programs/token/methods", diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index c272fd5..8f79e67 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -1,22 +1,6 @@ { "nodes": { "crane": { - "locked": { - "lastModified": 1779041105, - "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", - "owner": "ipetkov", - "repo": "crane", - "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.23.4", - "repo": "crane", - "type": "github" - } - }, - "crane_2": { "locked": { "lastModified": 1780532242, "narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", @@ -2241,7 +2225,7 @@ }, "logos-execution-zone": { "inputs": { - "crane": "crane_2", + "crane": "crane", "logos-blockchain-circuits": "logos-blockchain-circuits", "logos-liblogos": "logos-liblogos_4", "nixpkgs": [ @@ -25424,22 +25408,6 @@ "type": "github" } }, - "nixpkgs_399": { - "locked": { - "lastModified": 1782841183, - "narHash": "sha256-Ndt/5R7UN4rBdhFR1lxHZZZ42cD6vGlnuxC2VvvsKE4=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "c9bfd86ed684d27e63b0ff9ebb18699f84f27a3b", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-25.11-small", - "repo": "nixpkgs", - "type": "github" - } - }, "nixpkgs_4": { "locked": { "lastModified": 1759036355, @@ -26794,10 +26762,9 @@ }, "root": { "inputs": { - "crane": "crane", "logos-module-builder": "logos-module-builder", "logos_execution_zone": "logos_execution_zone", - "nixpkgs": "nixpkgs_399" + "shared_wallet": "shared_wallet" } }, "rust-overlay": { @@ -26883,6 +26850,18 @@ "rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f", "type": "github" } + }, + "shared_wallet": { + "flake": false, + "locked": { + "path": "../shared/wallet", + "type": "path" + }, + "original": { + "path": "../shared/wallet", + "type": "path" + }, + "parent": [] } }, "root": "root", diff --git a/flake.nix b/flake.nix index 1adcc93..ce07db0 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "LEZ programs — AMM client FFI"; + description = "LEZ programs — host client modules and FFIs"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; @@ -37,6 +37,7 @@ inputs.logos-execution-zone.url = "github:logos-blockchain/logos-execution-zone?rev=415964d7f9043a1bfe28da8d0e8b3a6f64abb258"; }; + }; outputs = @@ -53,58 +54,67 @@ rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; - # Whole workspace: crane needs Cargo.lock + all path deps (amm_core, - # twap_oracle_core, token_core, ...) to resolve `-p amm_ffi`. - src = ./.; + # Whole Cargo workspace, excluding build outputs and non-Rust assets. + # Crane still sees Cargo.lock and every path dependency, while local + # target directories cannot become multi-gigabyte Nix source snapshots. + src = craneLib.cleanCargoSource ./.; - commonArgs = { - inherit src; - strictDeps = true; - pname = "amm_ffi"; - version = "0.1.0"; - # CRITICAL: scope to ONLY this crate. The workspace also contains - # `amm/methods` etc. whose build.rs compiles the risc0 guest (which - # WOULD invoke Metal on darwin). `-p amm_ffi` never builds - # those crates or their build scripts. - cargoExtraArgs = "-p amm_ffi"; - doCheck = false; - # NOTE: cbindgen is used here as a Cargo *build-dependency* - # (invoked from build.rs via its Rust library API), not as the - # standalone CLI package — so no nativeBuildInputs entry is - # needed; crane builds it as part of the normal cargo graph. + # Scope every build to one host FFI package. The workspace also has + # methods crates whose build scripts compile RISC Zero guests; package + # scoping keeps those target-specific builds out of host module builds. + mkHostFfi = + { + package, + sourceDir, + header, + }: + let + commonArgs = { + inherit src; + strictDeps = true; + pname = package; + version = "0.1.0"; + cargoExtraArgs = "-p ${package}"; + doCheck = false; + # cbindgen runs as a Rust build-dependency from build.rs. + }; + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + in + craneLib.buildPackage ( + commonArgs + // { + inherit cargoArtifacts; + postInstall = + '' + mkdir -p $out/include + cp ${sourceDir}/include/${header} $out/include/ + '' + + pkgs.lib.optionalString pkgs.stdenv.isDarwin '' + # Use an absolute store install-name because the module + # builder does not stage external dylibs beside the plugin. + if [ -f $out/lib/lib${package}.dylib ]; then + install_name_tool -id "$out/lib/lib${package}.dylib" $out/lib/lib${package}.dylib + fi + ''; + } + ); + + ammFfi = mkHostFfi { + package = "amm_ffi"; + sourceDir = "modules/amm/ffi"; + header = "amm_ffi.h"; }; - cargoArtifacts = craneLib.buildDepsOnly commonArgs; - - # The single AMM host FFI crate (modules/amm/ffi): swap + new-position - # operations behind one JSON C ABI. Its header is cbindgen-generated - # into include/amm_ffi.h at build time. - ammFfi = craneLib.buildPackage ( - commonArgs - // { - inherit cargoArtifacts; - postInstall = - '' - mkdir -p $out/include - cp modules/amm/ffi/include/amm_ffi.h $out/include/ - '' - + pkgs.lib.optionalString pkgs.stdenv.isDarwin '' - # Set the dylib's install-name to its ABSOLUTE store path (NOT - # @rpath): the logos module builder links this lib into the plugin - # but never stages it into the plugin's runtime rpath, so an - # @rpath id fails to dlopen at launch. An absolute /nix/store id - # is recorded in the plugin's LC_LOAD_DYLIB, kept in the closure - # by Nix, and resolved directly at runtime — no rpath needed. - if [ -f $out/lib/libamm_ffi.dylib ]; then - install_name_tool -id "$out/lib/libamm_ffi.dylib" $out/lib/libamm_ffi.dylib - fi - ''; - } - ); + tokenFfi = mkHostFfi { + package = "token_ffi"; + sourceDir = "modules/token/ffi"; + header = "token_ffi.h"; + }; in { packages.default = ammFfi; packages.amm_ffi = ammFfi; + packages.token_ffi = tokenFfi; } ); @@ -174,6 +184,23 @@ }; ammModulePkgs = ammModuleOutputs.packages or { }; + # Token core module (modules/token): complete Token Program inspection + # and transaction planning/orchestration surface. The Qt-free universal + # module links token_ffi and reuses the host-loaded wallet module. + tokenModuleOutputs = logos-module-builder.lib.mkLogosModule { + src = ./modules/token; + configFile = ./modules/token/metadata.json; + flakeInputs = inputs; + externalLibInputs = { + token_ffi = { input = self; packages.default = "token_ffi"; }; + }; + tests = { + dir = ./modules/token/tests; + mockCLibs = [ "token_ffi" ]; + }; + }; + tokenModulePkgs = tokenModuleOutputs.packages or { }; + # Wrap the app launcher to export DYLD_FALLBACK_LIBRARY_PATH pointing at the # amm_ffi lib. The logos module builder links the plugin against # @rpath/libamm_ffi.dylib but does NOT stage that dylib into the @@ -202,12 +229,15 @@ let appSysPkgs = appPkgs.${system} or { }; ammModSysPkgs = ammModulePkgs.${system} or { }; + tokenModSysPkgs = tokenModulePkgs.${system} or { }; in (builtins.removeAttrs cratePkgs [ "default" ]) // (builtins.removeAttrs appSysPkgs [ "default" ]) // (if appSysPkgs ? default then { amm-ui = appSysPkgs.default; } else { }) // (builtins.removeAttrs ammModSysPkgs [ "default" ]) // (if ammModSysPkgs ? default then { amm-module = ammModSysPkgs.default; } else { }) + // (builtins.removeAttrs tokenModSysPkgs [ "default" ]) + // (if tokenModSysPkgs ? default then { token-module = tokenModSysPkgs.default; } else { }) ) crateOutputs.packages; in (builtins.removeAttrs appOutputs [ "apps" "packages" ]) diff --git a/modules/token/CMakeLists.txt b/modules/token/CMakeLists.txt new file mode 100644 index 0000000..7125d44 --- /dev/null +++ b/modules/token/CMakeLists.txt @@ -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 +) diff --git a/modules/token/README.md b/modules/token/README.md new file mode 100644 index 0000000..976cd6a --- /dev/null +++ b/modules/token/README.md @@ -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": "" } +``` + +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. diff --git a/modules/token/ffi/Cargo.toml b/modules/token/ffi/Cargo.toml new file mode 100644 index 0000000..90bfb34 --- /dev/null +++ b/modules/token/ffi/Cargo.toml @@ -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" diff --git a/modules/token/ffi/build.rs b/modules/token/ffi/build.rs new file mode 100644 index 0000000..616a927 --- /dev/null +++ b/modules/token/ffi/build.rs @@ -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"); +} diff --git a/modules/token/ffi/cbindgen.toml b/modules/token/ffi/cbindgen.toml new file mode 100644 index 0000000..cad8fc2 --- /dev/null +++ b/modules/token/ffi/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 = "" diff --git a/modules/token/ffi/include/token_ffi.h b/modules/token/ffi/include/token_ffi.h new file mode 100644 index 0000000..ea79d64 --- /dev/null +++ b/modules/token/ffi/include/token_ffi.h @@ -0,0 +1,125 @@ +#ifndef TOKEN_FFI_H +#define TOKEN_FFI_H + +#pragma once + +/* Generated by cbindgen. Do not edit. */ + +#include +#include +#include +#include + +#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 */ diff --git a/modules/token/ffi/src/account.rs b/modules/token/ffi/src/account.rs new file mode 100644 index 0000000..a1ccbd7 --- /dev/null +++ b/modules/token/ffi/src/account.rs @@ -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, +} + +#[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 { + 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 { + 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 { + 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()), + }), + } +} diff --git a/modules/token/ffi/src/api/decode.rs b/modules/token/ffi/src/api/decode.rs new file mode 100644 index 0000000..fdf13a4 --- /dev/null +++ b/modules/token/ffi/src/api/decode.rs @@ -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", + } +} diff --git a/modules/token/ffi/src/api/mod.rs b/modules/token/ffi/src/api/mod.rs new file mode 100644 index 0000000..c25af00 --- /dev/null +++ b/modules/token/ffi/src/api/mod.rs @@ -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; + +#[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 { + parse_program_id(value).map_err(|_| TokenApiError::new("bad_request")) +} diff --git a/modules/token/ffi/src/api/plan.rs b/modules/token/ffi/src/api/plan.rs new file mode 100644 index 0000000..3623081 --- /dev/null +++ b/modules/token/ffi/src/api/plan.rs @@ -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 { + account_id_from_hex(value, label).map_err(|_| TokenApiError::new("invalid_account_id")) +} + +fn parse_amount(value: &Value) -> Result { + 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 { + 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::() + .map_err(|_| TokenApiError::new("bad_amount")) +} + +fn parse_metadata_standard(value: &str) -> Result { + 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, 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( + 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::>(), + "signingRequirements": signing_requirements.into_iter().collect::>(), + "instruction": instruction, + })) +} diff --git a/modules/token/ffi/src/api/request.rs b/modules/token/ffi/src/api/request.rs new file mode 100644 index 0000000..5d27bb4 --- /dev/null +++ b/modules/token/ffi/src/api/request.rs @@ -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, +} diff --git a/modules/token/ffi/src/api/tests.rs b/modules/token/ffi/src/api/tests.rs new file mode 100644 index 0000000..277b867 --- /dev/null +++ b/modules/token/ffi/src/api/tests.rs @@ -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(result: Result) -> 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 { + let words: Vec = + serde_json::from_value(value.clone()).map_err(|error| error.to_string())?; + risc0_zkvm::serde::from_slice::(&words).map_err(|error| error.to_string()) +} + +fn assert_plan( + 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) -> 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 = 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", + ); +} diff --git a/modules/token/ffi/src/ffi.rs b/modules/token/ffi/src/ffi.rs new file mode 100644 index 0000000..69d3100 --- /dev/null +++ b/modules/token/ffi/src/ffi.rs @@ -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(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl Envelope { + fn success(value: serde_json::Value) -> Self { + Self { + ok: true, + value: Some(value), + error: None, + } + } + + fn failure(error: impl Into) -> 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( + 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::(&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 { + 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::(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::(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::(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::(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::(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::(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::( + 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::(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::(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::(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::(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::(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::(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::(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::( + 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::(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()) }; + } +} diff --git a/modules/token/ffi/src/lib.rs b/modules/token/ffi/src/lib.rs new file mode 100644 index 0000000..e4170fe --- /dev/null +++ b/modules/token/ffi/src/lib.rs @@ -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, +}; diff --git a/modules/token/ffi/tests/public_api.rs b/modules/token/ffi/tests/public_api.rs new file mode 100644 index 0000000..5f6d950 --- /dev/null +++ b/modules/token/ffi/tests/public_api.rs @@ -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; +} diff --git a/modules/token/metadata.json b/modules/token/metadata.json new file mode 100644 index 0000000..c4ff6e0 --- /dev/null +++ b/modules/token/metadata.json @@ -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": [] + } + } +} diff --git a/modules/token/src/token_instruction_words.cpp b/modules/token/src/token_instruction_words.cpp new file mode 100644 index 0000000..b7f70be --- /dev/null +++ b/modules/token/src/token_instruction_words.cpp @@ -0,0 +1,34 @@ +#include "token_instruction_words.h" + +#include +#include + +#include + +namespace token_module::detail { + +std::vector jsonInstructionLeBytes(const nlohmann::json& input) { + std::vector 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(); + } else { + const std::int64_t signed_raw = item.get(); + if (signed_raw < 0) return {}; + raw = static_cast(signed_raw); + } + if (raw > std::numeric_limits::max()) return {}; + const auto word = static_cast(raw); + result.push_back(static_cast(word & 0xff)); + result.push_back(static_cast((word >> 8) & 0xff)); + result.push_back(static_cast((word >> 16) & 0xff)); + result.push_back(static_cast((word >> 24) & 0xff)); + } + return result; +} + +} // namespace token_module::detail diff --git a/modules/token/src/token_instruction_words.h b/modules/token/src/token_instruction_words.h new file mode 100644 index 0000000..5873346 --- /dev/null +++ b/modules/token/src/token_instruction_words.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +#include + +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 jsonInstructionLeBytes(const nlohmann::json& input); + +} // namespace token_module::detail diff --git a/modules/token/src/token_module_impl.cpp b/modules/token/src/token_module_impl.cpp new file mode 100644 index 0000000..396fef6 --- /dev/null +++ b/modules/token/src/token_module_impl.cpp @@ -0,0 +1,808 @@ +#include "token_module_impl.h" +#include "token_instruction_words.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +// 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(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(value[begin]))) ++begin; + while (end > begin && std::isspace(static_cast(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 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 +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 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()) { + 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 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 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::istreambuf_iterator(file), + std::istreambuf_iterator()); +} + +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 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 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 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()) { + 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(); + 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 account_ids = + account_ids_it->get>(); + const std::vector signers = signers_it->get>(); + const std::vector 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() + || 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}, + }); + }); +} diff --git a/modules/token/src/token_module_impl.h b/modules/token/src/token_module_impl.h new file mode 100644 index 0000000..f9a0695 --- /dev/null +++ b/modules/token/src/token_module_impl.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include +#include + +#include +#include + +// 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 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_; +}; diff --git a/modules/token/tests/CMakeLists.txt b/modules/token/tests/CMakeLists.txt new file mode 100644 index 0000000..6a93723 --- /dev/null +++ b/modules/token/tests/CMakeLists.txt @@ -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 +) diff --git a/modules/token/tests/main.cpp b/modules/token/tests/main.cpp new file mode 100644 index 0000000..93a8096 --- /dev/null +++ b/modules/token/tests/main.cpp @@ -0,0 +1,3 @@ +#include + +LOGOS_TEST_MAIN() diff --git a/modules/token/tests/token_instruction_words_test.cpp b/modules/token/tests/token_instruction_words_test.cpp new file mode 100644 index 0000000..7f8637c --- /dev/null +++ b/modules/token/tests/token_instruction_words_test.cpp @@ -0,0 +1,49 @@ +#include "token_instruction_words.h" + +#include +#include + +#include +#include + +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::max()}}); + + const auto actual = token_module::detail::jsonInstructionLeBytes(input); + const std::vector 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::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()); +}