From a7faa92b48e5d46cbb23bcf261393f3f51cbb9af Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 26 Aug 2026 01:45:34 -0300 Subject: [PATCH] feat(stablecoin): add Logos API module --- Cargo.lock | 19 + Cargo.toml | 1 + flake.nix | 39 ++ modules/stablecoin/CMakeLists.txt | 24 + modules/stablecoin/README.md | 93 ++++ modules/stablecoin/ffi/Cargo.toml | 26 ++ modules/stablecoin/ffi/build.rs | 9 + modules/stablecoin/ffi/cbindgen.toml | 8 + .../stablecoin/ffi/include/stablecoin_ffi.h | 53 +++ modules/stablecoin/ffi/src/account.rs | 133 ++++++ modules/stablecoin/ffi/src/api/decode.rs | 54 +++ modules/stablecoin/ffi/src/api/mod.rs | 60 +++ modules/stablecoin/ffi/src/api/plan.rs | 194 ++++++++ modules/stablecoin/ffi/src/api/program.rs | 101 +++++ modules/stablecoin/ffi/src/api/request.rs | 39 ++ modules/stablecoin/ffi/src/api/tests.rs | 425 ++++++++++++++++++ modules/stablecoin/ffi/src/ffi.rs | 188 ++++++++ modules/stablecoin/ffi/src/lib.rs | 13 + modules/stablecoin/ffi/tests/public_api.rs | 13 + modules/stablecoin/flake.nix | 17 + modules/stablecoin/metadata.json | 27 ++ .../stablecoin/src/stablecoin_module_impl.cpp | 415 +++++++++++++++++ .../stablecoin/src/stablecoin_module_impl.h | 48 ++ .../src/stablecoin_module_support.cpp | 186 ++++++++ .../src/stablecoin_module_support.h | 39 ++ modules/stablecoin/tests/CMakeLists.txt | 15 + modules/stablecoin/tests/main.cpp | 3 + .../tests/stablecoin_module_support_test.cpp | 132 ++++++ 28 files changed, 2374 insertions(+) create mode 100644 modules/stablecoin/CMakeLists.txt create mode 100644 modules/stablecoin/README.md create mode 100644 modules/stablecoin/ffi/Cargo.toml create mode 100644 modules/stablecoin/ffi/build.rs create mode 100644 modules/stablecoin/ffi/cbindgen.toml create mode 100644 modules/stablecoin/ffi/include/stablecoin_ffi.h create mode 100644 modules/stablecoin/ffi/src/account.rs create mode 100644 modules/stablecoin/ffi/src/api/decode.rs create mode 100644 modules/stablecoin/ffi/src/api/mod.rs create mode 100644 modules/stablecoin/ffi/src/api/plan.rs create mode 100644 modules/stablecoin/ffi/src/api/program.rs create mode 100644 modules/stablecoin/ffi/src/api/request.rs create mode 100644 modules/stablecoin/ffi/src/api/tests.rs create mode 100644 modules/stablecoin/ffi/src/ffi.rs create mode 100644 modules/stablecoin/ffi/src/lib.rs create mode 100644 modules/stablecoin/ffi/tests/public_api.rs create mode 100644 modules/stablecoin/flake.nix create mode 100644 modules/stablecoin/metadata.json create mode 100644 modules/stablecoin/src/stablecoin_module_impl.cpp create mode 100644 modules/stablecoin/src/stablecoin_module_impl.h create mode 100644 modules/stablecoin/src/stablecoin_module_support.cpp create mode 100644 modules/stablecoin/src/stablecoin_module_support.h create mode 100644 modules/stablecoin/tests/CMakeLists.txt create mode 100644 modules/stablecoin/tests/main.cpp create mode 100644 modules/stablecoin/tests/stablecoin_module_support_test.cpp diff --git a/Cargo.lock b/Cargo.lock index 83891f6..551a456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4044,6 +4044,25 @@ dependencies = [ "twap_oracle_core", ] +[[package]] +name = "stablecoin_ffi" +version = "0.1.0" +dependencies = [ + "borsh", + "cbindgen", + "clock_core", + "hex", + "lee_core", + "risc0-binfmt", + "risc0-zkvm", + "serde", + "serde_json", + "stablecoin-methods", + "stablecoin_core", + "token_core", + "twap_oracle_core", +] + [[package]] name = "stablecoin_program" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6610dc1..baf0e02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "modules/amm/ffi", + "modules/stablecoin/ffi", "modules/token/ffi", "programs/token/core", "programs/token", diff --git a/flake.nix b/flake.nix index f1d1520..8e0aa4e 100644 --- a/flake.nix +++ b/flake.nix @@ -119,10 +119,17 @@ sourceDir = "modules/token/ffi"; header = "token_ffi.h"; }; + + stablecoinFfi = mkHostFfi { + package = "stablecoin_ffi"; + sourceDir = "modules/stablecoin/ffi"; + header = "stablecoin_ffi.h"; + }; in { packages.default = ammFfi; packages.amm_ffi = ammFfi; + packages.stablecoin_ffi = stablecoinFfi; packages.token_ffi = tokenFfi; } ); @@ -311,6 +318,33 @@ // (if attrs ? install then { token-module-install = attrs.install; } else { }) ) tokenModulePkgs; + # Stablecoin core module (modules/stablecoin): singleton discovery, + # ProtocolParameters decoding, and InitializeProgram orchestration. Rust + # owns the exact codecs and plan; the module reuses logos_execution_zone + # for live reads and submission. + stablecoinModuleOutputs = logos-module-builder.lib.mkLogosModule { + src = ./modules/stablecoin; + configFile = ./modules/stablecoin/metadata.json; + flakeInputs = inputs; + externalLibInputs = { + stablecoin_ffi = { input = self; packages.default = "stablecoin_ffi"; }; + }; + tests = { + dir = ./modules/stablecoin/tests; + mockCLibs = [ "stablecoin_ffi" ]; + }; + }; + stablecoinModulePkgs = stablecoinModuleOutputs.packages or { }; + + # Preserve module-specific names for install artifacts and tests; bare + # builder names collide when several core modules share one root flake. + stablecoinModuleAliases = builtins.mapAttrs ( + system: attrs: + (if attrs ? lgx then { stablecoin-module-lgx = attrs.lgx; } else { }) + // (if attrs ? install then { stablecoin-module-install = attrs.install; } else { }) + // (if attrs ? unit-tests then { stablecoin-module-tests = attrs.unit-tests; } else { }) + ) stablecoinModulePkgs; + # 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 @@ -384,6 +418,8 @@ ammModAliasPkgs = ammModuleAliases.${system} or { }; tokenModSysPkgs = tokenModulePkgs.${system} or { }; tokenModAliasPkgs = tokenModuleAliases.${system} or { }; + stablecoinModSysPkgs = stablecoinModulePkgs.${system} or { }; + stablecoinModAliasPkgs = stablecoinModuleAliases.${system} or { }; in (builtins.removeAttrs cratePkgs [ "default" ]) // (builtins.removeAttrs appSysPkgs [ "default" ]) @@ -398,6 +434,9 @@ // (builtins.removeAttrs tokenModSysPkgs [ "default" ]) // (if tokenModSysPkgs ? default then { token-module = tokenModSysPkgs.default; } else { }) // tokenModAliasPkgs + // (builtins.removeAttrs stablecoinModSysPkgs [ "default" ]) + // (if stablecoinModSysPkgs ? default then { stablecoin-module = stablecoinModSysPkgs.default; } else { }) + // stablecoinModAliasPkgs ) crateOutputs.packages; in (builtins.removeAttrs appOutputs [ "apps" "packages" ]) diff --git a/modules/stablecoin/CMakeLists.txt b/modules/stablecoin/CMakeLists.txt new file mode 100644 index 0000000..93db6de --- /dev/null +++ b/modules/stablecoin/CMakeLists.txt @@ -0,0 +1,24 @@ +cmake_minimum_required(VERSION 3.14) +project(StablecoinModulePlugin 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/stablecoin_module_impl.h + src/stablecoin_module_impl.cpp + src/stablecoin_module_support.h + src/stablecoin_module_support.cpp + EXTERNAL_LIBS + stablecoin_ffi +) diff --git a/modules/stablecoin/README.md b/modules/stablecoin/README.md new file mode 100644 index 0000000..41381ac --- /dev/null +++ b/modules/stablecoin/README.md @@ -0,0 +1,93 @@ +# Stablecoin core module + +`stablecoin_module` is a headless Logos `core` module for the LEZ Stablecoin +Program. It exposes deployment discovery, protocol-parameter reads, and +protocol initialization through the same universal API used by `logoscore` and +UI modules. + +The Qt-free C++ adapter handles live wallet reads and transaction submission. +`stablecoin_ffi` owns exact account decoding, PDA derivation, request +validation, and `stablecoin_core::Instruction` serialization. + +## API + +Every method returns a stable envelope. Success starts with: + +```json +{ "status": "ok", "error": "" } +``` + +Failure returns: + +```json +{ "status": "error", "error": "" } +``` + +### `programInfo()` + +Returns the configured Stablecoin Program ID and the derived singleton account +IDs for protocol parameters, stability-fee accumulator, redemption-price +state, stablecoin definition, stablecoin master holding, and `CLOCK_01`. Each +ID is returned in base58 and lowercase hexadecimal form. + +### `protocolParameters()` + +Reads the singleton Protocol Parameters account through +`lez_core`, verifies its PDA and owner, and exactly decodes its +data. All `u128`, `i128`, and `u64` values are returned as decimal strings. + +### `initializeProgram(request)` + +Required request fields: + +| Field | Type | +| --- | --- | +| `adminId` | base58 or 64-character hexadecimal account ID | +| `freezeAuthorityId` | base58 or 64-character hexadecimal account ID | +| `collateralDefinitionId` | base58 or 64-character hexadecimal account ID | +| `marketPriceOracleId` | base58 or 64-character hexadecimal account ID | +| `initialStabilityFeePerMillisecond` | exact `u128` decimal | +| `initialControllerProportionalGain` | exact `i128` decimal | +| `initialControllerIntegralGain` | exact `i128` decimal | +| `initialMinimumCollateralizationRatio` | exact `u128` decimal | +| `minimumMillisecondsBetweenRateUpdates` | exact `u64` decimal | +| `maximumOraclePriceAgeMilliseconds` | exact `u64` decimal | +| `initialRedemptionPrice` | exact `u128` decimal | +| `stablecoinName` | string accepted by the Stablecoin Program | + +The module verifies all five derived target PDAs are uninitialized, validates +the collateral definition, oracle asset pair, and clock accounts, then submits +the exact nine-account instruction. Only `adminId` signs. Success adds +`transactionId` to the response envelope. + +Pass numeric values as decimal strings. JSON integers are accepted when their +exact value survives parsing. JSON floating-point values are always rejected. + +## Runtime configuration + +Set either environment variable on the process hosting the module: + +```bash +STABLECOIN_PROGRAM_ID= +STABLECOIN_PROGRAM_BIN=/absolute/path/to/stablecoin.bin +``` + +When both are set, they must identify the same program. The binary must be the +exact deployable RISC Zero `.bin`; rebuilding it can change the program ID. + +Set `STABLECOIN_DEBUG=1` to emit adapter diagnostics to module stderr. + +## Build and test + +Run from repository root: + +```bash +RISC0_DEV_MODE=1 cargo +1.94.0 test -p stablecoin_ffi +RISC0_SKIP_BUILD=1 cargo +1.94.0 clippy -p stablecoin_ffi --all-targets -- -D warnings +nix build path:.#stablecoin_ffi -L +nix build path:.#stablecoin-module -L +nix build path:.#stablecoin-module-tests -L +``` + +Use `path:.` while files are untracked. Once tracked, `.#stablecoin-module` is +equivalent. diff --git a/modules/stablecoin/ffi/Cargo.toml b/modules/stablecoin/ffi/Cargo.toml new file mode 100644 index 0000000..741b824 --- /dev/null +++ b/modules/stablecoin/ffi/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "stablecoin_ffi" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +borsh = { workspace = true } +clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4" } +hex = "0.4" +lee_core = { workspace = true } +risc0-binfmt = { version = "=3.0.4", default-features = false } +risc0-zkvm = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +stablecoin_core = { workspace = true } +token_core = { workspace = true } +twap_oracle_core = { workspace = true } + +[build-dependencies] +cbindgen = "0.28" + +[dev-dependencies] +stablecoin-methods = { path = "../../../programs/stablecoin/methods" } diff --git a/modules/stablecoin/ffi/build.rs b/modules/stablecoin/ffi/build.rs new file mode 100644 index 0000000..102ef14 --- /dev/null +++ b/modules/stablecoin/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/stablecoin_ffi.h")); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=cbindgen.toml"); +} diff --git a/modules/stablecoin/ffi/cbindgen.toml b/modules/stablecoin/ffi/cbindgen.toml new file mode 100644 index 0000000..062c93f --- /dev/null +++ b/modules/stablecoin/ffi/cbindgen.toml @@ -0,0 +1,8 @@ +language = "C" +include_guard = "STABLECOIN_FFI_H" +pragma_once = true +cpp_compat = true +autogen_warning = "/* Generated by cbindgen. Do not edit. */" + +[export] +prefix = "" diff --git a/modules/stablecoin/ffi/include/stablecoin_ffi.h b/modules/stablecoin/ffi/include/stablecoin_ffi.h new file mode 100644 index 0000000..faaf02b --- /dev/null +++ b/modules/stablecoin/ffi/include/stablecoin_ffi.h @@ -0,0 +1,53 @@ +#ifndef STABLECOIN_FFI_H +#define STABLECOIN_FFI_H + +#pragma once + +/* Generated by cbindgen. Do not edit. */ + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/** + * Resolves the stablecoin program ID and derives all singleton account IDs. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_program_info(const char *request_json); + +/** + * Decodes and validates the singleton `ProtocolParameters` account. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_decode_protocol_parameters(const char *request_json); + +/** + * Builds the exact wallet submission plan for `InitializeProgram`. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_initialize_program_plan(const char *request_json); + +/** + * Releases a string returned by a `stablecoin_*` operation. + * + * # Safety + * `value` must be null or a pointer returned by this library that has not been freed. + */ +void stablecoin_free(char *value); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif /* STABLECOIN_FFI_H */ diff --git a/modules/stablecoin/ffi/src/account.rs b/modules/stablecoin/ffi/src/account.rs new file mode 100644 index 0000000..cae634b --- /dev/null +++ b/modules/stablecoin/ffi/src/account.rs @@ -0,0 +1,133 @@ +use lee_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()) +} + +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: hex::encode(program_id_bytes(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/stablecoin/ffi/src/api/decode.rs b/modules/stablecoin/ffi/src/api/decode.rs new file mode 100644 index 0000000..2162c9f --- /dev/null +++ b/modules/stablecoin/ffi/src/api/decode.rs @@ -0,0 +1,54 @@ +use serde_json::{json, Value}; +use stablecoin_core::{compute_protocol_parameters_pda, ProtocolParameters}; + +use super::{ + parse_stablecoin_program_id, DecodeProtocolParametersRequest, StablecoinApiError, + StablecoinResult, +}; +use crate::account::{account_id_hex, decode_account}; + +pub fn decode_protocol_parameters(request: DecodeProtocolParametersRequest) -> StablecoinResult { + let stablecoin_program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; + let (account_id, account) = decode_account(&request.protocol_parameters) + .map_err(|_| StablecoinApiError::new("account_read_failed"))?; + + if account_id != compute_protocol_parameters_pda(stablecoin_program_id) { + return Err(StablecoinApiError::new("protocol_parameters_pda_mismatch")); + } + if account.program_owner != stablecoin_program_id { + return Err(StablecoinApiError::new("stablecoin_program_mismatch")); + } + + let parameters = ProtocolParameters::try_from(&account.data) + .map_err(|_| StablecoinApiError::new("invalid_protocol_parameters_data"))?; + Ok(parameters_value(account_id, ¶meters)) +} + +fn parameters_value( + account_id: lee_core::account::AccountId, + parameters: &ProtocolParameters, +) -> Value { + json!({ + "accountId": account_id.to_string(), + "accountIdHex": account_id_hex(account_id), + "adminId": parameters.admin_account_id.to_string(), + "adminIdHex": account_id_hex(parameters.admin_account_id), + "freezeAuthorityId": parameters.freeze_authority_account_id.to_string(), + "freezeAuthorityIdHex": account_id_hex(parameters.freeze_authority_account_id), + "stablecoinDefinitionId": parameters.stablecoin_definition_id.to_string(), + "stablecoinDefinitionIdHex": account_id_hex(parameters.stablecoin_definition_id), + "collateralDefinitionId": parameters.collateral_definition_id.to_string(), + "collateralDefinitionIdHex": account_id_hex(parameters.collateral_definition_id), + "marketPriceOracleId": parameters.market_price_oracle_id.to_string(), + "marketPriceOracleIdHex": account_id_hex(parameters.market_price_oracle_id), + "stabilityFeePerMillisecond": parameters.stability_fee_per_millisecond.to_string(), + "controllerProportionalGain": parameters.controller_proportional_gain.to_string(), + "controllerIntegralGain": parameters.controller_integral_gain.to_string(), + "minimumCollateralizationRatio": parameters.minimum_collateralization_ratio.to_string(), + "minimumMillisecondsBetweenRateUpdates": + parameters.minimum_milliseconds_between_rate_updates.to_string(), + "maximumOraclePriceAgeMilliseconds": + parameters.maximum_oracle_price_age_milliseconds.to_string(), + "isFrozen": parameters.is_frozen, + }) +} diff --git a/modules/stablecoin/ffi/src/api/mod.rs b/modules/stablecoin/ffi/src/api/mod.rs new file mode 100644 index 0000000..0f0ccda --- /dev/null +++ b/modules/stablecoin/ffi/src/api/mod.rs @@ -0,0 +1,60 @@ +//! Transport-independent stablecoin client operations. + +mod decode; +mod plan; +mod program; +mod request; + +#[cfg(test)] +mod tests; + +use std::{error::Error, fmt}; + +pub use decode::decode_protocol_parameters; +pub use plan::initialize_program_plan; +pub use program::program_info; +pub use request::{ + DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, +}; +use serde_json::Value; + +use crate::account::parse_program_id; + +pub type StablecoinResponse = Value; +pub type StablecoinResult = Result; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StablecoinApiError { + code: &'static str, +} + +impl StablecoinApiError { + #[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 StablecoinApiError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.code) + } +} + +impl Error for StablecoinApiError {} + +fn parse_stablecoin_program_id( + value: &str, +) -> Result { + let program_id = + parse_program_id(value).map_err(|_| StablecoinApiError::new("invalid_program_id"))?; + if program_id == [0_u32; 8] { + return Err(StablecoinApiError::new("invalid_program_id")); + } + Ok(program_id) +} diff --git a/modules/stablecoin/ffi/src/api/plan.rs b/modules/stablecoin/ffi/src/api/plan.rs new file mode 100644 index 0000000..0922ada --- /dev/null +++ b/modules/stablecoin/ffi/src/api/plan.rs @@ -0,0 +1,194 @@ +use borsh::from_slice; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use lee_core::account::AccountId; +use serde_json::{json, Value}; +use stablecoin_core::{ + compute_protocol_parameters_pda, compute_redemption_price_state_pda, + compute_stability_fee_accumulator_pda, compute_stablecoin_definition_pda, + compute_stablecoin_master_holding_pda, Instruction, +}; +use token_core::TokenDefinition; +use twap_oracle_core::OraclePriceAccount; + +use super::{ + parse_stablecoin_program_id, InitializeProgramPlanRequest, StablecoinApiError, StablecoinResult, +}; +use crate::account::{ + account_id_from_hex, account_id_hex, decode_account, program_id_bytes, AccountRead, +}; + +pub fn initialize_program_plan(request: InitializeProgramPlanRequest) -> StablecoinResult { + let program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; + let admin = parse_account_id(&request.admin_id)?; + let freeze_authority = parse_account_id(&request.freeze_authority_id)?; + + let (collateral_definition_id, collateral_definition) = + required_account(&request.collateral_definition)?; + let collateral = TokenDefinition::try_from(&collateral_definition.data) + .map_err(|_| StablecoinApiError::new("invalid_collateral_definition"))?; + if !matches!(collateral, TokenDefinition::Fungible { .. }) { + return Err(StablecoinApiError::new("invalid_collateral_definition")); + } + + let (market_price_oracle_id, market_price_oracle) = + required_account(&request.market_price_oracle)?; + let oracle = OraclePriceAccount::try_from(&market_price_oracle.data) + .map_err(|_| StablecoinApiError::new("invalid_market_price_oracle"))?; + + let (clock_id, clock) = required_account(&request.clock)?; + if clock_id != CLOCK_01_PROGRAM_ACCOUNT_ID + || from_slice::(clock.data.as_ref()).is_err() + { + return Err(StablecoinApiError::new("invalid_clock")); + } + + let stablecoin_definition_id = compute_stablecoin_definition_pda(program_id); + if oracle.base_asset != stablecoin_definition_id + || oracle.quote_asset != collateral_definition_id + { + return Err(StablecoinApiError::new("oracle_asset_mismatch")); + } + + let initial_stability_fee_per_millisecond = + parse_u128(&request.initial_stability_fee_per_millisecond)?; + let initial_controller_proportional_gain = + parse_i128(&request.initial_controller_proportional_gain)?; + let initial_controller_integral_gain = parse_i128(&request.initial_controller_integral_gain)?; + let initial_minimum_collateralization_ratio = + parse_u128(&request.initial_minimum_collateralization_ratio)?; + let minimum_milliseconds_between_rate_updates = + parse_u64(&request.minimum_milliseconds_between_rate_updates)?; + let maximum_oracle_price_age_milliseconds = + parse_u64(&request.maximum_oracle_price_age_milliseconds)?; + let initial_redemption_price = parse_u128(&request.initial_redemption_price)?; + if request.stablecoin_name.is_empty() { + return Err(StablecoinApiError::new("invalid_stablecoin_name")); + } + + let instruction = Instruction::InitializeProgram { + freeze_authority_account_id: freeze_authority, + initial_stability_fee_per_millisecond, + initial_controller_proportional_gain, + initial_controller_integral_gain, + initial_minimum_collateralization_ratio, + minimum_milliseconds_between_rate_updates, + maximum_oracle_price_age_milliseconds, + initial_redemption_price, + stablecoin_name: request.stablecoin_name, + }; + + plan_response( + program_id, + [ + admin, + compute_protocol_parameters_pda(program_id), + compute_stability_fee_accumulator_pda(program_id), + compute_redemption_price_state_pda(program_id), + stablecoin_definition_id, + compute_stablecoin_master_holding_pda(program_id), + collateral_definition_id, + market_price_oracle_id, + clock_id, + ], + [true, false, false, false, false, false, false, false, false], + instruction, + ) +} + +fn required_account( + read: &AccountRead, +) -> Result<(AccountId, lee_core::account::Account), StablecoinApiError> { + decode_account(read).map_err(|_| StablecoinApiError::new("account_read_failed")) +} + +fn parse_account_id(value: &str) -> Result { + let account_id = account_id_from_hex(value, "account id") + .map_err(|_| StablecoinApiError::new("invalid_account_id"))?; + if account_id.value() == &[0_u8; 32] { + return Err(StablecoinApiError::new("invalid_account_id")); + } + Ok(account_id) +} + +fn decimal_text(value: &str) -> Result<&str, StablecoinApiError> { + let trimmed = value.trim(); + let unquoted = if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { + trimmed[1..trimmed.len() - 1].trim() + } else { + trimmed + }; + if unquoted.is_empty() { + return Err(StablecoinApiError::new("invalid_numeric_value")); + } + Ok(unquoted) +} + +fn parse_u128(value: &Value) -> Result { + match value { + Value::Number(number) => number + .as_u64() + .map(u128::from) + .ok_or_else(|| StablecoinApiError::new("invalid_numeric_value")), + Value::String(raw) => { + let raw = decimal_text(raw)?; + if !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(StablecoinApiError::new("invalid_numeric_value")); + } + raw.parse::() + .map_err(|_| StablecoinApiError::new("invalid_numeric_value")) + } + _ => Err(StablecoinApiError::new("invalid_numeric_value")), + } +} + +fn parse_u64(value: &Value) -> Result { + match value { + Value::Number(number) => number + .as_u64() + .ok_or_else(|| StablecoinApiError::new("invalid_numeric_value")), + Value::String(raw) => { + let raw = decimal_text(raw)?; + if !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(StablecoinApiError::new("invalid_numeric_value")); + } + raw.parse::() + .map_err(|_| StablecoinApiError::new("invalid_numeric_value")) + } + _ => Err(StablecoinApiError::new("invalid_numeric_value")), + } +} + +fn parse_i128(value: &Value) -> Result { + match value { + Value::Number(number) => number + .as_i64() + .map(i128::from) + .ok_or_else(|| StablecoinApiError::new("invalid_numeric_value")), + Value::String(raw) => { + let raw = decimal_text(raw)?; + let digits = raw.strip_prefix('-').unwrap_or(raw); + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(StablecoinApiError::new("invalid_numeric_value")); + } + raw.parse::() + .map_err(|_| StablecoinApiError::new("invalid_numeric_value")) + } + _ => Err(StablecoinApiError::new("invalid_numeric_value")), + } +} + +fn plan_response( + program_id: lee_core::program::ProgramId, + account_ids: [AccountId; 9], + signing_requirements: [bool; 9], + instruction: Instruction, +) -> StablecoinResult { + let instruction = risc0_zkvm::serde::to_vec(&instruction) + .map_err(|_| StablecoinApiError::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, + "instruction": instruction, + })) +} diff --git a/modules/stablecoin/ffi/src/api/program.rs b/modules/stablecoin/ffi/src/api/program.rs new file mode 100644 index 0000000..11ac3eb --- /dev/null +++ b/modules/stablecoin/ffi/src/api/program.rs @@ -0,0 +1,101 @@ +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use lee_core::{account::AccountId, program::ProgramId}; +use risc0_binfmt::ProgramBinary; +use serde_json::{json, Map, Value}; +use stablecoin_core::{ + compute_protocol_parameters_pda, compute_redemption_price_state_pda, + compute_stability_fee_accumulator_pda, compute_stablecoin_definition_pda, + compute_stablecoin_master_holding_pda, +}; + +use super::{ + parse_stablecoin_program_id, ProgramInfoRequest, StablecoinApiError, StablecoinResult, +}; +use crate::account::{account_id_hex, program_id_bytes}; + +pub fn program_info(request: ProgramInfoRequest) -> StablecoinResult { + let configured = request + .stablecoin_program_id + .as_deref() + .map(parse_stablecoin_program_id) + .transpose()?; + let derived = request + .elf + .as_deref() + .map(program_id_from_binary) + .transpose()?; + + let program_id = match (configured, derived) { + (Some(configured), Some(derived)) if configured != derived => { + return Err(StablecoinApiError::new("program_id_mismatch")); + } + (Some(program_id), _) | (_, Some(program_id)) => program_id, + (None, None) => return Err(StablecoinApiError::new("config_missing")), + }; + + Ok(program_info_value(program_id)) +} + +fn program_id_from_binary(value: &str) -> Result { + let bytes = + hex::decode(value).map_err(|_| StablecoinApiError::new("invalid_program_binary"))?; + let binary = ProgramBinary::decode(&bytes) + .map_err(|_| StablecoinApiError::new("invalid_program_binary"))?; + binary + .compute_image_id() + .map(Into::into) + .map_err(|_| StablecoinApiError::new("invalid_program_binary")) +} + +fn program_info_value(program_id: ProgramId) -> Value { + let mut result = Map::new(); + let program_account_id = AccountId::new(program_id_bytes(program_id)); + insert_id(&mut result, "programId", "programIdHex", program_account_id); + insert_id( + &mut result, + "protocolParametersId", + "protocolParametersIdHex", + compute_protocol_parameters_pda(program_id), + ); + insert_id( + &mut result, + "stabilityFeeAccumulatorId", + "stabilityFeeAccumulatorIdHex", + compute_stability_fee_accumulator_pda(program_id), + ); + insert_id( + &mut result, + "redemptionPriceStateId", + "redemptionPriceStateIdHex", + compute_redemption_price_state_pda(program_id), + ); + insert_id( + &mut result, + "stablecoinDefinitionId", + "stablecoinDefinitionIdHex", + compute_stablecoin_definition_pda(program_id), + ); + insert_id( + &mut result, + "stablecoinMasterHoldingId", + "stablecoinMasterHoldingIdHex", + compute_stablecoin_master_holding_pda(program_id), + ); + insert_id( + &mut result, + "clockId", + "clockIdHex", + CLOCK_01_PROGRAM_ACCOUNT_ID, + ); + Value::Object(result) +} + +fn insert_id( + result: &mut Map, + base58_key: &str, + hex_key: &str, + account_id: AccountId, +) { + result.insert(base58_key.to_owned(), json!(account_id.to_string())); + result.insert(hex_key.to_owned(), json!(account_id_hex(account_id))); +} diff --git a/modules/stablecoin/ffi/src/api/request.rs b/modules/stablecoin/ffi/src/api/request.rs new file mode 100644 index 0000000..f659238 --- /dev/null +++ b/modules/stablecoin/ffi/src/api/request.rs @@ -0,0 +1,39 @@ +use serde::Deserialize; +use serde_json::Value; + +use crate::AccountRead; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProgramInfoRequest { + #[serde(default)] + pub stablecoin_program_id: Option, + #[serde(default)] + pub elf: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DecodeProtocolParametersRequest { + pub stablecoin_program_id: String, + pub protocol_parameters: AccountRead, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct InitializeProgramPlanRequest { + pub stablecoin_program_id: String, + pub admin_id: String, + pub freeze_authority_id: String, + pub collateral_definition: AccountRead, + pub market_price_oracle: AccountRead, + pub clock: AccountRead, + pub initial_stability_fee_per_millisecond: Value, + pub initial_controller_proportional_gain: Value, + pub initial_controller_integral_gain: Value, + pub initial_minimum_collateralization_ratio: Value, + pub minimum_milliseconds_between_rate_updates: Value, + pub maximum_oracle_price_age_milliseconds: Value, + pub initial_redemption_price: Value, + pub stablecoin_name: String, +} diff --git a/modules/stablecoin/ffi/src/api/tests.rs b/modules/stablecoin/ffi/src/api/tests.rs new file mode 100644 index 0000000..20677be --- /dev/null +++ b/modules/stablecoin/ffi/src/api/tests.rs @@ -0,0 +1,425 @@ +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use lee_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use risc0_binfmt::ProgramBinary; +use serde_json::{json, Value}; +use stablecoin_core::{ + compute_protocol_parameters_pda, compute_redemption_price_state_pda, + compute_stability_fee_accumulator_pda, compute_stablecoin_definition_pda, + compute_stablecoin_master_holding_pda, Instruction, ProtocolParameters, +}; +use token_core::TokenDefinition; +use twap_oracle_core::OraclePriceAccount; + +use super::{ + decode_protocol_parameters, initialize_program_plan, program_info, + DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, + StablecoinResult, +}; +use crate::account::{account_id_hex, account_read, program_id_bytes}; + +const STABLECOIN_PROGRAM_ID: ProgramId = [0x11_u32; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [0x22_u32; 8]; +const ORACLE_PROGRAM_ID: ProgramId = [0x33_u32; 8]; +const CLOCK_PROGRAM_ID: ProgramId = [0x44_u32; 8]; + +fn account(owner: ProgramId, data: Data) -> Account { + Account { + program_owner: owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn id(seed: u8) -> AccountId { + AccountId::new([seed; 32]) +} + +fn program_id_hex() -> String { + hex::encode(program_id_bytes(STABLECOIN_PROGRAM_ID)) +} + +fn deployable_program_binary() -> (String, ProgramId) { + let encoded = stablecoin_methods::STABLECOIN_ELF; + let binary = ok(ProgramBinary::decode(encoded)); + let image_id = ok(binary.compute_image_id()).into(); + (hex::encode(encoded), image_id) +} + +fn ok(result: Result) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("{error}"), + } +} + +fn assert_error(result: StablecoinResult, expected: &str) { + match result { + Ok(value) => panic!("expected {expected}, got {value}"), + Err(error) => assert_eq!(error.code(), expected), + } +} + +fn protocol_parameters() -> ProtocolParameters { + ProtocolParameters { + admin_account_id: id(1), + freeze_authority_account_id: id(2), + stablecoin_definition_id: id(3), + collateral_definition_id: id(4), + market_price_oracle_id: id(5), + stability_fee_per_millisecond: u128::MAX, + controller_proportional_gain: i128::MIN, + controller_integral_gain: i128::MAX, + minimum_collateralization_ratio: u128::MAX - 1, + minimum_milliseconds_between_rate_updates: u64::MAX, + maximum_oracle_price_age_milliseconds: u64::MAX - 1, + is_frozen: true, + } +} + +fn protocol_request(parameters: &ProtocolParameters) -> DecodeProtocolParametersRequest { + let account_id = compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID); + DecodeProtocolParametersRequest { + stablecoin_program_id: program_id_hex(), + protocol_parameters: account_read( + account_id, + &account(STABLECOIN_PROGRAM_ID, Data::from(parameters)), + ), + } +} + +fn initialize_request() -> InitializeProgramPlanRequest { + let collateral_id = id(10); + let stablecoin_definition_id = compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID); + let collateral_definition = TokenDefinition::Fungible { + name: String::from("Collateral"), + total_supply: u128::MAX, + metadata_id: None, + authority: None, + }; + let oracle_id = id(11); + let oracle = OraclePriceAccount { + base_asset: stablecoin_definition_id, + quote_asset: collateral_id, + price: 1, + timestamp: 2, + source_id: id(12), + confidence_interval: 0, + }; + let clock = ClockAccountData { + block_id: 3, + timestamp: 4, + }; + + InitializeProgramPlanRequest { + stablecoin_program_id: program_id_hex(), + admin_id: account_id_hex(id(13)), + freeze_authority_id: account_id_hex(id(14)), + collateral_definition: account_read( + collateral_id, + &account(TOKEN_PROGRAM_ID, Data::from(&collateral_definition)), + ), + market_price_oracle: account_read( + oracle_id, + &account(ORACLE_PROGRAM_ID, Data::from(&oracle)), + ), + clock: account_read( + CLOCK_01_PROGRAM_ACCOUNT_ID, + &account(CLOCK_PROGRAM_ID, ok(Data::try_from(clock.to_bytes()))), + ), + initial_stability_fee_per_millisecond: json!(u128::MAX.to_string()), + initial_controller_proportional_gain: json!(i128::MIN.to_string()), + initial_controller_integral_gain: json!(i128::MAX.to_string()), + initial_minimum_collateralization_ratio: json!((u128::MAX - 1).to_string()), + minimum_milliseconds_between_rate_updates: json!(u64::MAX), + maximum_oracle_price_age_milliseconds: json!(u64::MAX.to_string()), + initial_redemption_price: json!("\"340282366920938463463374607431768211455\""), + stablecoin_name: String::from("Exact Stablecoin"), + } +} + +fn decode_instruction(value: &Value) -> Instruction { + let words: Vec = ok(serde_json::from_value(value.clone())); + ok(risc0_zkvm::serde::from_slice::(&words)) +} + +#[test] +fn program_info_derives_all_singleton_ids_from_program_id() { + let value = ok(program_info(ProgramInfoRequest { + stablecoin_program_id: Some(program_id_hex()), + elf: None, + })); + + let program_account = AccountId::new(program_id_bytes(STABLECOIN_PROGRAM_ID)); + assert_eq!(value["programId"], program_account.to_string()); + assert_eq!(value["programIdHex"], account_id_hex(program_account)); + assert_eq!( + value["protocolParametersIdHex"], + account_id_hex(compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)) + ); + assert_eq!( + value["stabilityFeeAccumulatorIdHex"], + account_id_hex(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)) + ); + assert_eq!( + value["redemptionPriceStateIdHex"], + account_id_hex(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)) + ); + assert_eq!( + value["stablecoinDefinitionIdHex"], + account_id_hex(compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID)) + ); + assert_eq!( + value["stablecoinMasterHoldingIdHex"], + account_id_hex(compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID)) + ); + assert_eq!( + value["clockIdHex"], + account_id_hex(CLOCK_01_PROGRAM_ACCOUNT_ID) + ); +} + +#[test] +fn program_info_derives_from_binary_and_rejects_mismatched_inputs() { + let (binary, derived_program_id) = deployable_program_binary(); + let derived_program_id_hex = hex::encode(program_id_bytes(derived_program_id)); + let value = ok(program_info(ProgramInfoRequest { + stablecoin_program_id: None, + elf: Some(binary.clone()), + })); + assert_eq!(value["programIdHex"], derived_program_id_hex); + + assert_error( + program_info(ProgramInfoRequest { + stablecoin_program_id: Some(program_id_hex()), + elf: Some(binary.clone()), + }), + "program_id_mismatch", + ); + + let value = ok(program_info(ProgramInfoRequest { + stablecoin_program_id: Some(derived_program_id_hex), + elf: Some(binary), + })); + assert_eq!( + value["programIdHex"], + hex::encode(program_id_bytes(derived_program_id)) + ); +} + +#[test] +fn program_info_rejects_missing_and_invalid_inputs() { + assert_error( + program_info(ProgramInfoRequest { + stablecoin_program_id: None, + elf: None, + }), + "config_missing", + ); + assert_error( + program_info(ProgramInfoRequest { + stablecoin_program_id: Some(String::from("not-an-id")), + elf: None, + }), + "invalid_program_id", + ); + assert_error( + program_info(ProgramInfoRequest { + stablecoin_program_id: None, + elf: Some(String::from("00")), + }), + "invalid_program_binary", + ); +} + +#[test] +fn protocol_parameters_decode_preserves_exact_numeric_and_id_fields() { + let parameters = protocol_parameters(); + let value = ok(decode_protocol_parameters(protocol_request(¶meters))); + + assert_eq!( + value["adminIdHex"], + account_id_hex(parameters.admin_account_id) + ); + assert_eq!( + value["freezeAuthorityIdHex"], + account_id_hex(parameters.freeze_authority_account_id) + ); + assert_eq!(value["stabilityFeePerMillisecond"], u128::MAX.to_string()); + assert_eq!(value["controllerProportionalGain"], i128::MIN.to_string()); + assert_eq!(value["controllerIntegralGain"], i128::MAX.to_string()); + assert_eq!( + value["minimumMillisecondsBetweenRateUpdates"], + u64::MAX.to_string() + ); + assert_eq!(value["isFrozen"], true); +} + +#[test] +fn protocol_parameters_decode_rejects_wrong_pda_owner_and_non_exact_data() { + let parameters = protocol_parameters(); + let mut wrong_pda = protocol_request(¶meters); + wrong_pda.protocol_parameters.id = account_id_hex(id(20)); + assert_error( + decode_protocol_parameters(wrong_pda), + "protocol_parameters_pda_mismatch", + ); + + let mut wrong_owner = protocol_request(¶meters); + if let Some(account) = &mut wrong_owner.protocol_parameters.account { + account.program_owner = hex::encode(program_id_bytes(TOKEN_PROGRAM_ID)); + } + assert_error( + decode_protocol_parameters(wrong_owner), + "stablecoin_program_mismatch", + ); + + let mut trailing = Data::from(¶meters).as_ref().to_vec(); + trailing.push(0); + let malformed = DecodeProtocolParametersRequest { + stablecoin_program_id: program_id_hex(), + protocol_parameters: account_read( + compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID), + &account(STABLECOIN_PROGRAM_ID, ok(Data::try_from(trailing))), + ), + }; + assert_error( + decode_protocol_parameters(malformed), + "invalid_protocol_parameters_data", + ); +} + +#[test] +fn initialize_plan_round_trips_all_boundary_values_and_exact_account_contract() { + let request = initialize_request(); + let admin = request.admin_id.clone(); + let freeze_authority = request.freeze_authority_id.clone(); + let collateral = request.collateral_definition.id.clone(); + let oracle = request.market_price_oracle.id.clone(); + let plan = ok(initialize_program_plan(request)); + + assert_eq!(plan["programId"], program_id_hex()); + assert_eq!( + plan["accountIds"], + json!([ + admin, + account_id_hex(compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)), + account_id_hex(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)), + account_id_hex(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)), + account_id_hex(compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID)), + account_id_hex(compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID)), + collateral, + oracle, + account_id_hex(CLOCK_01_PROGRAM_ACCOUNT_ID), + ]) + ); + assert_eq!( + plan["signingRequirements"], + json!([true, false, false, false, false, false, false, false, false]) + ); + + let Instruction::InitializeProgram { + freeze_authority_account_id, + initial_stability_fee_per_millisecond, + initial_controller_proportional_gain, + initial_controller_integral_gain, + initial_minimum_collateralization_ratio, + minimum_milliseconds_between_rate_updates, + maximum_oracle_price_age_milliseconds, + initial_redemption_price, + stablecoin_name, + } = decode_instruction(&plan["instruction"]) + else { + panic!("expected InitializeProgram"); + }; + assert_eq!( + account_id_hex(freeze_authority_account_id), + freeze_authority + ); + assert_eq!(initial_stability_fee_per_millisecond, u128::MAX); + assert_eq!(initial_controller_proportional_gain, i128::MIN); + assert_eq!(initial_controller_integral_gain, i128::MAX); + assert_eq!(initial_minimum_collateralization_ratio, u128::MAX - 1); + assert_eq!(minimum_milliseconds_between_rate_updates, u64::MAX); + assert_eq!(maximum_oracle_price_age_milliseconds, u64::MAX); + assert_eq!(initial_redemption_price, u128::MAX); + assert_eq!(stablecoin_name, "Exact Stablecoin"); +} + +#[test] +fn initialize_plan_rejects_lossy_or_out_of_range_numeric_values() { + let exponent: Value = ok(serde_json::from_str("1e3")); + for invalid in [ + json!(1.5), + exponent, + json!(-1), + json!("340282366920938463463374607431768211456"), + json!("1e3"), + json!(""), + ] { + let mut request = initialize_request(); + request.initial_redemption_price = invalid; + assert_error(initialize_program_plan(request), "invalid_numeric_value"); + } + + let mut signed_overflow = initialize_request(); + signed_overflow.initial_controller_integral_gain = + json!("170141183460469231731687303715884105728"); + assert_error( + initialize_program_plan(signed_overflow), + "invalid_numeric_value", + ); +} + +#[test] +fn initialize_plan_validates_required_account_shapes_and_assets() { + let mut nft_collateral = initialize_request(); + let collateral_id = id(10); + nft_collateral.collateral_definition = account_read( + collateral_id, + &account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::NonFungible { + name: String::from("NFT"), + printable_supply: 1, + metadata_id: id(30), + }), + ), + ); + assert_error( + initialize_program_plan(nft_collateral), + "invalid_collateral_definition", + ); + + let mut wrong_oracle = initialize_request(); + wrong_oracle.market_price_oracle = account_read( + id(11), + &account( + ORACLE_PROGRAM_ID, + Data::from(&OraclePriceAccount { + base_asset: id(31), + quote_asset: id(10), + price: 1, + timestamp: 2, + source_id: id(12), + confidence_interval: 0, + }), + ), + ); + assert_error( + initialize_program_plan(wrong_oracle), + "oracle_asset_mismatch", + ); + + let mut wrong_clock = initialize_request(); + wrong_clock.clock.id = account_id_hex(id(32)); + assert_error(initialize_program_plan(wrong_clock), "invalid_clock"); + + let mut failed_read = initialize_request(); + failed_read.collateral_definition.status = String::from("read_failed"); + failed_read.collateral_definition.account = None; + assert_error(initialize_program_plan(failed_read), "account_read_failed"); +} diff --git a/modules/stablecoin/ffi/src/ffi.rs b/modules/stablecoin/ffi/src/ffi.rs new file mode 100644 index 0000000..4ac6556 --- /dev/null +++ b/modules/stablecoin/ffi/src/ffi.rs @@ -0,0 +1,188 @@ +use std::{ + ffi::{c_char, CStr, CString}, + panic::{catch_unwind, AssertUnwindSafe}, +}; + +use serde::{de::DeserializeOwned, Serialize}; + +use crate::api::{ + self, DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, + StablecoinResult, +}; + +#[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) -> StablecoinResult, +) -> *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)] +/// Resolves the stablecoin program ID and derives all singleton account IDs. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_program_info(request_json: *const c_char) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { call::(request_json, api::program_info) } +} + +#[unsafe(no_mangle)] +/// Decodes and validates the singleton `ProtocolParameters` account. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_decode_protocol_parameters( + request_json: *const c_char, +) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { + call::(request_json, api::decode_protocol_parameters) + } +} + +#[unsafe(no_mangle)] +/// Builds the exact wallet submission plan for `InitializeProgram`. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_initialize_program_plan( + request_json: *const c_char, +) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { call::(request_json, api::initialize_program_plan) } +} + +/// Releases a string returned by a `stablecoin_*` 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 stablecoin_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 `stablecoin_*` 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 { stablecoin_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 { stablecoin_program_info(request.as_ptr()) }; + // SAFETY: response was returned by stablecoin_program_info 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 { stablecoin_program_info(std::ptr::null()) }; + // SAFETY: response was returned by stablecoin_program_info 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 { stablecoin_free(std::ptr::null_mut()) }; + } +} diff --git a/modules/stablecoin/ffi/src/lib.rs b/modules/stablecoin/ffi/src/lib.rs new file mode 100644 index 0000000..703bfa1 --- /dev/null +++ b/modules/stablecoin/ffi/src/lib.rs @@ -0,0 +1,13 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +mod account; +mod ffi; + +pub mod api; + +pub use account::{AccountRead, WalletAccount}; +pub use api::{ + decode_protocol_parameters, initialize_program_plan, program_info, + DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, + StablecoinApiError, StablecoinResponse, StablecoinResult, +}; diff --git a/modules/stablecoin/ffi/tests/public_api.rs b/modules/stablecoin/ffi/tests/public_api.rs new file mode 100644 index 0000000..5db657f --- /dev/null +++ b/modules/stablecoin/ffi/tests/public_api.rs @@ -0,0 +1,13 @@ +use stablecoin_ffi::{ + decode_protocol_parameters, initialize_program_plan, program_info, + DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, + StablecoinResult, +}; + +#[test] +fn crate_root_reexports_stablecoin_surface() { + let _program_info: fn(ProgramInfoRequest) -> StablecoinResult = program_info; + let _decode: fn(DecodeProtocolParametersRequest) -> StablecoinResult = + decode_protocol_parameters; + let _initialize: fn(InitializeProgramPlanRequest) -> StablecoinResult = initialize_program_plan; +} diff --git a/modules/stablecoin/flake.nix b/modules/stablecoin/flake.nix new file mode 100644 index 0000000..accc0b1 --- /dev/null +++ b/modules/stablecoin/flake.nix @@ -0,0 +1,17 @@ +{ + description = "Logos Stablecoin core module"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder"; + lez_core.url = "github:logos-blockchain/logos-execution-zone-module?ref=fix/generic-tx-instruction-bstr"; + }; + + # stablecoin_ffi lives in the repository root flake. Build this module from + # the repository root with `nix build .#stablecoin-module`. + outputs = inputs@{ logos-module-builder, ... }: + logos-module-builder.lib.mkLogosModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + }; +} diff --git a/modules/stablecoin/metadata.json b/modules/stablecoin/metadata.json new file mode 100644 index 0000000..d7bb94d --- /dev/null +++ b/modules/stablecoin/metadata.json @@ -0,0 +1,27 @@ +{ + "name": "stablecoin_module", + "version": "0.1.0", + "type": "core", + "interface": "universal", + "category": "stablecoin", + "description": "Universal Logos core module for LEZ stablecoin reads and transactions", + "main": "stablecoin_module_plugin", + "dependencies": ["lez_core"], + "nix": { + "packages": { + "build": [], + "runtime": [] + }, + "external_libraries": [ + { + "name": "stablecoin_ffi" + } + ], + "cmake": { + "find_packages": [], + "extra_sources": [], + "extra_include_dirs": [], + "extra_link_libraries": [] + } + } +} diff --git a/modules/stablecoin/src/stablecoin_module_impl.cpp b/modules/stablecoin/src/stablecoin_module_impl.cpp new file mode 100644 index 0000000..8ed864b --- /dev/null +++ b/modules/stablecoin/src/stablecoin_module_impl.cpp @@ -0,0 +1,415 @@ +#include "stablecoin_module_impl.h" +#include "stablecoin_module_support.h" + +#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 "stablecoin_ffi.h" +} + +namespace { + +using json = nlohmann::json; + +constexpr char STABLECOIN_PROGRAM_BIN_ENV[] = "STABLECOIN_PROGRAM_BIN"; +constexpr char STABLECOIN_PROGRAM_ID_ENV[] = "STABLECOIN_PROGRAM_ID"; +std::mutex programInfoMutex; + +bool stablecoinDebug() { + static const bool enabled = std::getenv("STABLECOIN_DEBUG") != nullptr; + return enabled; +} + +#define STABLECOIN_TRACE(message) \ + do { \ + if (stablecoinDebug()) std::cerr << "[stablecoin-debug] " << message << '\n'; \ + } while (false) + +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); +} + +std::string jsonString(const json& object, const char* key) { + const auto field = object.find(key); + return field != object.end() && field->is_string() + ? field->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 index = 0; index < length; ++index) { + result.push_back(digits[bytes[index] >> 4]); + result.push_back(digits[bytes[index] & 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) { + STABLECOIN_TRACE("caught exception: " << error.what()); + } catch (...) { + STABLECOIN_TRACE("caught non-standard exception"); + } + return publicError("backend_error"); +} + +struct FfiResult { + bool ok = false; + json value; + std::string error; +}; + +FfiResult callStablecoin(char* (*operation)(const char*), const json& request) { + const std::string payload = request.dump(); + std::unique_ptr response( + operation(payload.c_str()), + &stablecoin_free); + if (!response) { + STABLECOIN_TRACE("stablecoin_ffi returned null"); + return {}; + } + + const json document = json::parse(response.get(), nullptr, false); + if (!document.is_object()) { + STABLECOIN_TRACE("stablecoin_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"); + STABLECOIN_TRACE("stablecoin_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 hasString(const json& object, const char* key) { + const auto field = object.find(key); + return field != object.end() && field->is_string(); +} + +} // namespace + +std::vector StablecoinModuleImpl::loadStablecoinBinary() const { + const char* path = std::getenv(STABLECOIN_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()); +} + +nlohmann::json StablecoinModuleImpl::stablecoinProgramInfo(std::string& error) { + const std::lock_guard lock(programInfoMutex); + if (programInfoResolved_) { + const json cached = json::parse(programInfoJson_, nullptr, false); + if (cached.is_object()) return cached; + programInfoResolved_ = false; + programInfoJson_.clear(); + } + + const char* configured_value = std::getenv(STABLECOIN_PROGRAM_ID_ENV); + const char* binary_path = std::getenv(STABLECOIN_PROGRAM_BIN_ENV); + const std::string configured = configured_value == nullptr + ? std::string() + : trim(configured_value); + const bool program_id_configured = !configured.empty(); + const bool binary_configured = binary_path != nullptr && *binary_path != '\0'; + if (!program_id_configured && !binary_configured) { + error = "config_missing"; + return json(); + } + + json request = json::object(); + if (program_id_configured) { + const std::string normalized = normalizeAccountId(configured); + if (normalized.empty()) { + error = "invalid_program_id"; + return json(); + } + request["stablecoinProgramId"] = normalized; + } + + if (binary_configured) { + const std::vector binary = loadStablecoinBinary(); + if (binary.empty()) { + error = "invalid_program_binary"; + return json(); + } + request["elf"] = bytesToHex(binary.data(), binary.size()); + } + + const FfiResult result = callStablecoin(stablecoin_program_info, request); + if (!result.ok) { + error = stablecoin_module::detail::stableFfiError(result.error); + return json(); + } + + static constexpr const char* required_fields[] = { + "programId", + "programIdHex", + "protocolParametersId", + "protocolParametersIdHex", + "stabilityFeeAccumulatorId", + "stabilityFeeAccumulatorIdHex", + "redemptionPriceStateId", + "redemptionPriceStateIdHex", + "stablecoinDefinitionId", + "stablecoinDefinitionIdHex", + "stablecoinMasterHoldingId", + "stablecoinMasterHoldingIdHex", + "clockId", + "clockIdHex", + }; + if (std::any_of(std::begin(required_fields), std::end(required_fields), [&](const char* key) { + return !hasString(result.value, key) || jsonString(result.value, key).empty(); + })) { + error = "backend_error"; + return json(); + } + + programInfoJson_ = result.value.dump(); + programInfoResolved_ = true; + return result.value; +} + +std::string StablecoinModuleImpl::normalizeAccountId(const std::string& id) { + return stablecoin_module::detail::normalizeAccountId( + id, + [this](const std::string& base58) { + return modules().lez_core.account_id_from_base58(base58); + }); +} + +nlohmann::json StablecoinModuleImpl::readPublicAccount(const std::string& account_id) { + logos::CallError call_error; + const std::string raw = + modules().lez_core.get_account_public(account_id, &call_error); + if (!call_error.ok()) { + STABLECOIN_TRACE( + "lez_core account read transport failure: " << call_error.code); + return {{"id", account_id}, {"status", "backend_error"}}; + } + return stablecoin_module::detail::publicAccountRead(account_id, raw); +} + +bool StablecoinModuleImpl::requireUninitialized(const std::string& account_id, + std::string& error) { + const json read = readPublicAccount(account_id); + const std::string status = jsonString(read, "status"); + if (status == "not_found") return true; + error = status == "ok" ? "already_initialized" : "account_read_failed"; + return false; +} + +LogosMap StablecoinModuleImpl::programInfo() { + return guarded([&]() -> LogosMap { + std::string error; + const json info = stablecoinProgramInfo(error); + if (!info.is_object()) return publicError(error.empty() ? "backend_error" : error); + + LogosMap result = publicOk(); + for (auto field = info.begin(); field != info.end(); ++field) { + result[field.key()] = field.value(); + } + return result; + }); +} + +LogosMap StablecoinModuleImpl::protocolParameters() { + return guarded([&]() -> LogosMap { + std::string error; + const json info = stablecoinProgramInfo(error); + if (!info.is_object()) return publicError(error.empty() ? "backend_error" : error); + + const json read = readPublicAccount(jsonString(info, "protocolParametersIdHex")); + const std::string status = jsonString(read, "status"); + if (status == "not_found") return publicError("not_initialized"); + if (status != "ok") return publicError("account_read_failed"); + + const FfiResult decoded = callStablecoin(stablecoin_decode_protocol_parameters, { + {"stablecoinProgramId", info["programIdHex"]}, + {"protocolParameters", read}, + }); + if (!decoded.ok) { + return publicError(stablecoin_module::detail::stableFfiError(decoded.error)); + } + + LogosMap result = publicOk(); + result["protocolParameters"] = decoded.value; + return result; + }); +} + +LogosMap StablecoinModuleImpl::submitPlan(const nlohmann::json& plan) { + const auto accounts_field = plan.find("accountIds"); + const auto signers_field = plan.find("signingRequirements"); + const auto instruction_field = plan.find("instruction"); + const std::string program_id = jsonString(plan, "programId"); + if (accounts_field == plan.end() || !accounts_field->is_array() + || signers_field == plan.end() || !signers_field->is_array() + || instruction_field == plan.end() + || !stablecoin_module::detail::isValidAccountIdHex(program_id)) { + return publicError("backend_error"); + } + + const std::vector account_ids = + accounts_field->get>(); + const std::vector signing_requirements = + signers_field->get>(); + const std::vector instruction = + stablecoin_module::detail::jsonInstructionLeBytes(*instruction_field); + if (account_ids.size() != 9 || signing_requirements.size() != 9 + || !std::all_of(account_ids.begin(), account_ids.end(), + stablecoin_module::detail::isValidAccountIdHex) + || signing_requirements != std::vector({true, false, false, false, false, + false, false, false, false}) + || instruction.empty()) { + return publicError("backend_error"); + } + + logos::CallError call_error; + const std::string raw = modules().lez_core.send_generic_public_transaction( + account_ids, + signing_requirements, + instruction, + program_id, + &call_error); + if (!call_error.ok()) { + STABLECOIN_TRACE( + "lez_core submission transport failure: " << call_error.code); + return publicError("wallet_submission_failed"); + } + + const std::string transaction_id = stablecoin_module::detail::transactionId(raw); + if (transaction_id.empty()) return publicError("wallet_submission_failed"); + LogosMap result = publicOk(); + result["transactionId"] = transaction_id; + return result; +} + +LogosMap StablecoinModuleImpl::initializeProgram(const LogosMap& request) { + return guarded([&]() -> LogosMap { + if (!request.is_object()) return publicError("bad_request"); + for (const char* key : { + "adminId", + "freezeAuthorityId", + "collateralDefinitionId", + "marketPriceOracleId", + "stablecoinName", + }) { + if (!hasString(request, key)) return publicError("bad_request"); + } + for (const char* key : { + "initialStabilityFeePerMillisecond", + "initialControllerProportionalGain", + "initialControllerIntegralGain", + "initialMinimumCollateralizationRatio", + "minimumMillisecondsBetweenRateUpdates", + "maximumOraclePriceAgeMilliseconds", + "initialRedemptionPrice", + }) { + if (request.find(key) == request.end()) return publicError("bad_request"); + } + + std::string error; + const json info = stablecoinProgramInfo(error); + if (!info.is_object()) return publicError(error.empty() ? "backend_error" : error); + + const std::string admin = normalizeAccountId(jsonString(request, "adminId")); + const std::string freeze_authority = + normalizeAccountId(jsonString(request, "freezeAuthorityId")); + const std::string collateral = + normalizeAccountId(jsonString(request, "collateralDefinitionId")); + const std::string oracle = + normalizeAccountId(jsonString(request, "marketPriceOracleId")); + if (admin.empty() || freeze_authority.empty() || collateral.empty() || oracle.empty()) { + return publicError("invalid_account_id"); + } + + for (const char* key : { + "protocolParametersIdHex", + "stabilityFeeAccumulatorIdHex", + "redemptionPriceStateIdHex", + "stablecoinDefinitionIdHex", + "stablecoinMasterHoldingIdHex", + }) { + if (!requireUninitialized(jsonString(info, key), error)) return publicError(error); + } + + const json collateral_read = readPublicAccount(collateral); + const json oracle_read = readPublicAccount(oracle); + const json clock_read = readPublicAccount(jsonString(info, "clockIdHex")); + if (jsonString(collateral_read, "status") != "ok" + || jsonString(oracle_read, "status") != "ok" + || jsonString(clock_read, "status") != "ok") { + return publicError("account_read_failed"); + } + + const FfiResult planned = callStablecoin(stablecoin_initialize_program_plan, { + {"stablecoinProgramId", info["programIdHex"]}, + {"adminId", admin}, + {"freezeAuthorityId", freeze_authority}, + {"collateralDefinition", collateral_read}, + {"marketPriceOracle", oracle_read}, + {"clock", clock_read}, + {"initialStabilityFeePerMillisecond", + request["initialStabilityFeePerMillisecond"]}, + {"initialControllerProportionalGain", + request["initialControllerProportionalGain"]}, + {"initialControllerIntegralGain", request["initialControllerIntegralGain"]}, + {"initialMinimumCollateralizationRatio", + request["initialMinimumCollateralizationRatio"]}, + {"minimumMillisecondsBetweenRateUpdates", + request["minimumMillisecondsBetweenRateUpdates"]}, + {"maximumOraclePriceAgeMilliseconds", + request["maximumOraclePriceAgeMilliseconds"]}, + {"initialRedemptionPrice", request["initialRedemptionPrice"]}, + {"stablecoinName", request["stablecoinName"]}, + }); + if (!planned.ok) { + return publicError(stablecoin_module::detail::stableFfiError(planned.error)); + } + if (jsonString(planned.value, "programId") != jsonString(info, "programIdHex")) { + return publicError("backend_error"); + } + return submitPlan(planned.value); + }); +} diff --git a/modules/stablecoin/src/stablecoin_module_impl.h b/modules/stablecoin/src/stablecoin_module_impl.h new file mode 100644 index 0000000..c87d5ba --- /dev/null +++ b/modules/stablecoin/src/stablecoin_module_impl.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +#include +#include + +// Universal Logos core module for the LEZ Stablecoin Program. Rust +// stablecoin_ffi owns typed codecs, PDA derivation, validation, and instruction +// serialization. This Qt-free adapter owns live reads and wallet submission. +class StablecoinModuleImpl : public LogosModuleContext { +public: + StablecoinModuleImpl() = default; + ~StablecoinModuleImpl() = default; + + /// Returns stablecoin program IDs and all derived singleton account IDs. + /// Configure STABLECOIN_PROGRAM_ID or STABLECOIN_PROGRAM_BIN. When both are + /// configured, they must identify the same program. + LogosMap programInfo(); + + /// Reads and exactly decodes the singleton ProtocolParameters account. + /// Success adds `protocolParameters`; failures use stable error codes. + LogosMap protocolParameters(); + + /// Initializes the stablecoin protocol. Request fields are `adminId`, + /// `freezeAuthorityId`, `collateralDefinitionId`, `marketPriceOracleId`, + /// `initialStabilityFeePerMillisecond`, + /// `initialControllerProportionalGain`, `initialControllerIntegralGain`, + /// `initialMinimumCollateralizationRatio`, + /// `minimumMillisecondsBetweenRateUpdates`, + /// `maximumOraclePriceAgeMilliseconds`, `initialRedemptionPrice`, and + /// `stablecoinName`. Integer values accept exact decimal strings or JSON + /// integers; JSON floats are rejected. Only `adminId` signs. + LogosMap initializeProgram(const LogosMap& request); + +private: + std::vector loadStablecoinBinary() const; + nlohmann::json stablecoinProgramInfo(std::string& error); + std::string normalizeAccountId(const std::string& id); + nlohmann::json readPublicAccount(const std::string& account_id); + bool requireUninitialized(const std::string& account_id, std::string& error); + LogosMap submitPlan(const nlohmann::json& plan); + + bool programInfoResolved_ = false; + std::string programInfoJson_; +}; diff --git a/modules/stablecoin/src/stablecoin_module_support.cpp b/modules/stablecoin/src/stablecoin_module_support.cpp new file mode 100644 index 0000000..a8a46e5 --- /dev/null +++ b/modules/stablecoin/src/stablecoin_module_support.cpp @@ -0,0 +1,186 @@ +#include "stablecoin_module_support.h" + +#include +#include +#include +#include +#include +#include + +#include + +namespace stablecoin_module::detail { +namespace { + +using json = nlohmann::json; + +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) { + return value.size() == length + && std::all_of(value.begin(), value.end(), [](char character) { + return hexValue(character) >= 0; + }); +} + +bool isEvenHex(const std::string& value) { + return value.size() % 2 == 0 + && std::all_of(value.begin(), value.end(), [](char character) { + return hexValue(character) >= 0; + }); +} + +std::string lowercase(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + 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 character) { + return character == '0'; + }); +} + +bool isAllZero(const std::string& value) { + return !value.empty() + && std::all_of(value.begin(), value.end(), [](char character) { + return character == '0'; + }); +} + +std::string jsonString(const json& object, const char* key) { + const auto field = object.find(key); + return field != object.end() && field->is_string() + ? field->get() + : std::string(); +} + +} // namespace + +std::string normalizeAccountId(const std::string& value, + const Base58Decoder& base58_decoder) { + std::string normalized = trim(value); + if (isHexLength(normalized, 64)) { + normalized = lowercase(std::move(normalized)); + return isZeroId(normalized) ? std::string() : normalized; + } + if (normalized.empty()) return {}; + + normalized = lowercase(base58_decoder(normalized)); + return isHexLength(normalized, 64) && !isZeroId(normalized) + ? normalized + : std::string(); +} + +bool isValidAccountIdHex(const std::string& value) { + return isHexLength(value, 64) && !isZeroId(value); +} + +nlohmann::json publicAccountRead(const std::string& account_id, + const std::string& raw_response) { + json read = {{"id", account_id}, {"status", "not_found"}}; + if (raw_response.empty()) return read; + + const json account = json::parse(raw_response, 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 (isZeroId(owner) && isAllZero(balance) && isAllZero(nonce) && data.empty()) { + 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; +} + +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; +} + +std::string stableFfiError(const std::string& error) { + static const std::set stable = { + "account_read_failed", + "backend_error", + "bad_request", + "config_missing", + "invalid_account_id", + "invalid_clock", + "invalid_collateral_definition", + "invalid_market_price_oracle", + "invalid_numeric_value", + "invalid_program_binary", + "invalid_program_id", + "invalid_protocol_parameters_data", + "invalid_stablecoin_name", + "oracle_asset_mismatch", + "program_id_mismatch", + "protocol_parameters_pda_mismatch", + "stablecoin_program_mismatch", + }; + return stable.find(error) == stable.end() ? std::string("backend_error") : error; +} + +std::string transactionId(const std::string& raw_response) { + const json reply = json::parse(raw_response, nullptr, false); + if (!reply.is_object()) return {}; + const auto success = reply.find("success"); + if (success == reply.end() || !success->is_boolean() || !success->get()) return {}; + std::string transaction_id = lowercase(jsonString(reply, "tx_hash")); + return isHexLength(transaction_id, 64) ? transaction_id : std::string(); +} + +} // namespace stablecoin_module::detail diff --git a/modules/stablecoin/src/stablecoin_module_support.h b/modules/stablecoin/src/stablecoin_module_support.h new file mode 100644 index 0000000..3c85084 --- /dev/null +++ b/modules/stablecoin/src/stablecoin_module_support.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace stablecoin_module::detail { + +using Base58Decoder = std::function; + +// Accepts a 32-byte hex or base58 account ID and returns lowercase hex. Empty +// means invalid, zero, or rejected by the supplied base58 decoder. +std::string normalizeAccountId(const std::string& value, + const Base58Decoder& base58_decoder); + +// Accepts only a nonzero 32-byte hexadecimal account ID. +bool isValidAccountIdHex(const std::string& value); + +// Validates the wallet module's public-account response and converts it to the +// stablecoin_ffi AccountRead shape. Empty/default state becomes `not_found`; +// malformed state becomes `backend_error`. +nlohmann::json publicAccountRead(const std::string& account_id, + const std::string& raw_response); + +// Converts a RISC Zero word array to the byte string expected by the wallet +// module. Empty means malformed input. +std::vector jsonInstructionLeBytes(const nlohmann::json& input); + +// Preserves only documented FFI error codes at the public module boundary. +std::string stableFfiError(const std::string& error); + +// Returns a validated lowercase 32-byte transaction hash, or empty on any +// malformed/failed wallet response. +std::string transactionId(const std::string& raw_response); + +} // namespace stablecoin_module::detail diff --git a/modules/stablecoin/tests/CMakeLists.txt b/modules/stablecoin/tests/CMakeLists.txt new file mode 100644 index 0000000..a54ff78 --- /dev/null +++ b/modules/stablecoin/tests/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.14) +project(StablecoinModuleTests LANGUAGES CXX) + +include(LogosTest) + +logos_test( + NAME stablecoin_module_support_tests + MODULE_SOURCES + ../src/stablecoin_module_support.cpp + TEST_SOURCES + main.cpp + stablecoin_module_support_test.cpp + EXTRA_INCLUDES + ../src +) diff --git a/modules/stablecoin/tests/main.cpp b/modules/stablecoin/tests/main.cpp new file mode 100644 index 0000000..93a8096 --- /dev/null +++ b/modules/stablecoin/tests/main.cpp @@ -0,0 +1,3 @@ +#include + +LOGOS_TEST_MAIN() diff --git a/modules/stablecoin/tests/stablecoin_module_support_test.cpp b/modules/stablecoin/tests/stablecoin_module_support_test.cpp new file mode 100644 index 0000000..e300039 --- /dev/null +++ b/modules/stablecoin/tests/stablecoin_module_support_test.cpp @@ -0,0 +1,132 @@ +#include "stablecoin_module_support.h" + +#include +#include +#include +#include + +#include +#include + +namespace { + +std::string repeated(char value, std::size_t count) { + return std::string(count, value); +} + +nlohmann::json validAccount() { + return { + {"program_owner", repeated('A', 64)}, + {"balance", repeated('B', 32)}, + {"nonce", repeated('C', 32)}, + {"data", "00ff"}, + }; +} + +} // namespace + +LOGOS_TEST(account_id_normalization_accepts_hex_and_delegates_base58) { + const auto decoder = [](const std::string& value) { + return value == "base58-id" ? repeated('D', 64) : std::string(); + }; + + LOGOS_ASSERT_EQ( + stablecoin_module::detail::normalizeAccountId( + " AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ", + decoder), + repeated('a', 64)); + LOGOS_ASSERT_EQ( + stablecoin_module::detail::normalizeAccountId("base58-id", decoder), + repeated('d', 64)); + LOGOS_ASSERT_TRUE( + stablecoin_module::detail::normalizeAccountId("invalid", decoder).empty()); + LOGOS_ASSERT_TRUE( + stablecoin_module::detail::normalizeAccountId(repeated('0', 64), decoder).empty()); +} + +LOGOS_TEST(account_id_hex_validation_rejects_zero_and_malformed_values) { + LOGOS_ASSERT_TRUE( + stablecoin_module::detail::isValidAccountIdHex(repeated('A', 64))); + LOGOS_ASSERT_TRUE( + !stablecoin_module::detail::isValidAccountIdHex(repeated('0', 64))); + LOGOS_ASSERT_TRUE( + !stablecoin_module::detail::isValidAccountIdHex(repeated('g', 64))); + LOGOS_ASSERT_TRUE( + !stablecoin_module::detail::isValidAccountIdHex(repeated('1', 63))); +} + +LOGOS_TEST(public_account_response_validation_distinguishes_state_and_corruption) { + const std::string account_id = repeated('1', 64); + const auto valid = stablecoin_module::detail::publicAccountRead( + account_id, + validAccount().dump()); + LOGOS_ASSERT_EQ(valid["status"].get(), std::string("ok")); + LOGOS_ASSERT_EQ( + valid["account"]["program_owner"].get(), + repeated('a', 64)); + + const nlohmann::json empty = { + {"program_owner", repeated('0', 64)}, + {"balance", repeated('0', 32)}, + {"nonce", repeated('0', 32)}, + {"data", ""}, + }; + const auto missing = stablecoin_module::detail::publicAccountRead(account_id, empty.dump()); + LOGOS_ASSERT_EQ(missing["status"].get(), std::string("not_found")); + + auto malformed = validAccount(); + malformed["nonce"] = "short"; + const auto invalid = + stablecoin_module::detail::publicAccountRead(account_id, malformed.dump()); + LOGOS_ASSERT_EQ(invalid["status"].get(), std::string("backend_error")); +} + +LOGOS_TEST(wallet_submission_response_requires_success_and_full_hash) { + const std::string hash = repeated('A', 64); + LOGOS_ASSERT_EQ( + stablecoin_module::detail::transactionId( + nlohmann::json{{"success", true}, {"tx_hash", hash}}.dump()), + repeated('a', 64)); + LOGOS_ASSERT_TRUE(stablecoin_module::detail::transactionId( + nlohmann::json{{"success", false}, {"tx_hash", hash}}.dump()) + .empty()); + LOGOS_ASSERT_TRUE(stablecoin_module::detail::transactionId( + nlohmann::json{{"success", true}, {"tx_hash", "short"}}.dump()) + .empty()); + LOGOS_ASSERT_TRUE(stablecoin_module::detail::transactionId("not-json").empty()); +} + +LOGOS_TEST(ffi_error_mapping_preserves_only_public_codes) { + LOGOS_ASSERT_EQ( + stablecoin_module::detail::stableFfiError("invalid_numeric_value"), + std::string("invalid_numeric_value")); + LOGOS_ASSERT_EQ( + stablecoin_module::detail::stableFfiError("internal parse detail"), + std::string("backend_error")); + LOGOS_ASSERT_EQ( + stablecoin_module::detail::stableFfiError(""), + std::string("backend_error")); +} + +LOGOS_TEST(instruction_words_are_validated_and_encoded_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 std::vector expected = { + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, + }; + const auto actual = stablecoin_module::detail::jsonInstructionLeBytes(input); + LOGOS_ASSERT_EQ(actual.size(), expected.size()); + for (std::size_t index = 0; index < expected.size(); ++index) { + LOGOS_ASSERT_EQ(actual[index], expected[index]); + } + + LOGOS_ASSERT_TRUE(stablecoin_module::detail::jsonInstructionLeBytes( + nlohmann::json::array({std::int64_t{-1}})) + .empty()); + LOGOS_ASSERT_TRUE(stablecoin_module::detail::jsonInstructionLeBytes( + nlohmann::json::array({1.5})) + .empty()); +}