mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-26 14:41:12 +00:00
feat(stablecoin): add Logos API module
This commit is contained in:
committed by
r4bbit
parent
154b41ba5c
commit
a7faa92b48
Generated
+19
@@ -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"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"modules/amm/ffi",
|
||||
"modules/stablecoin/ffi",
|
||||
"modules/token/ffi",
|
||||
"programs/token/core",
|
||||
"programs/token",
|
||||
|
||||
@@ -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" ])
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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": "<stable_code>" }
|
||||
```
|
||||
|
||||
### `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=<base58-or-hex-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.
|
||||
@@ -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" }
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 = ""
|
||||
@@ -0,0 +1,53 @@
|
||||
#ifndef STABLECOIN_FFI_H
|
||||
#define STABLECOIN_FFI_H
|
||||
|
||||
#pragma once
|
||||
|
||||
/* Generated by cbindgen. Do not edit. */
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif // __cplusplus
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
@@ -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<WalletAccount>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
pub struct WalletAccount {
|
||||
pub program_owner: String,
|
||||
pub balance: String,
|
||||
pub nonce: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> {
|
||||
if value.len() != 64
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
return Err(format!(
|
||||
"{label} must be 64 lowercase hexadecimal characters"
|
||||
));
|
||||
}
|
||||
|
||||
let mut bytes = [0_u8; 32];
|
||||
hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_program_id(value: &str) -> Result<ProgramId, String> {
|
||||
let bytes = parse_hex_32(value, "program id")?;
|
||||
let mut program_id = [0_u32; 8];
|
||||
for (word, chunk) in program_id.iter_mut().zip(bytes.chunks_exact(4)) {
|
||||
let chunk: [u8; 4] = chunk
|
||||
.try_into()
|
||||
.map_err(|_| String::from("program id word has invalid length"))?;
|
||||
*word = u32::from_le_bytes(chunk);
|
||||
}
|
||||
Ok(program_id)
|
||||
}
|
||||
|
||||
pub(crate) fn program_id_bytes(program_id: ProgramId) -> [u8; 32] {
|
||||
let mut bytes = [0_u8; 32];
|
||||
for (chunk, word) in bytes.chunks_exact_mut(4).zip(program_id) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
pub(crate) fn account_id_from_hex(value: &str, label: &str) -> Result<AccountId, String> {
|
||||
Ok(AccountId::new(parse_hex_32(value, label)?))
|
||||
}
|
||||
|
||||
pub(crate) fn account_id_hex(account_id: AccountId) -> String {
|
||||
hex::encode(account_id.into_value())
|
||||
}
|
||||
|
||||
fn parse_le_u128(value: &str, label: &str) -> Result<u128, String> {
|
||||
if value.len() != 32
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
return Err(format!(
|
||||
"{label} must be 32 lowercase hexadecimal characters"
|
||||
));
|
||||
}
|
||||
let mut bytes = [0_u8; 16];
|
||||
hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?;
|
||||
Ok(u128::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
pub(crate) fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String> {
|
||||
if read.status != "ok" {
|
||||
return Err(String::from("account read failed"));
|
||||
}
|
||||
let account_id = account_id_from_hex(&read.id, "account id")?;
|
||||
let source = read
|
||||
.account
|
||||
.as_ref()
|
||||
.ok_or_else(|| String::from("successful account read has no account"))?;
|
||||
let program_owner = parse_program_id(&source.program_owner)?;
|
||||
let balance = parse_le_u128(&source.balance, "account balance")?;
|
||||
let nonce = parse_le_u128(&source.nonce, "account nonce")?;
|
||||
if source.data.len() % 2 != 0
|
||||
|| !source
|
||||
.data
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
return Err(String::from(
|
||||
"account data must be lowercase even-length hexadecimal",
|
||||
));
|
||||
}
|
||||
let data =
|
||||
hex::decode(&source.data).map_err(|error| format!("invalid account data: {error}"))?;
|
||||
let data =
|
||||
Data::try_from(data).map_err(|error| format!("account data is too large: {error}"))?;
|
||||
|
||||
Ok((
|
||||
account_id,
|
||||
Account {
|
||||
program_owner,
|
||||
balance,
|
||||
data,
|
||||
nonce: Nonce(nonce),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead {
|
||||
AccountRead {
|
||||
id: account_id_hex(id),
|
||||
status: String::from("ok"),
|
||||
account: Some(WalletAccount {
|
||||
program_owner: 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()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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<StablecoinResponse, StablecoinApiError>;
|
||||
|
||||
#[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<lee_core::program::ProgramId, StablecoinApiError> {
|
||||
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)
|
||||
}
|
||||
@@ -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::<ClockAccountData>(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<AccountId, StablecoinApiError> {
|
||||
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<u128, StablecoinApiError> {
|
||||
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::<u128>()
|
||||
.map_err(|_| StablecoinApiError::new("invalid_numeric_value"))
|
||||
}
|
||||
_ => Err(StablecoinApiError::new("invalid_numeric_value")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_u64(value: &Value) -> Result<u64, StablecoinApiError> {
|
||||
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::<u64>()
|
||||
.map_err(|_| StablecoinApiError::new("invalid_numeric_value"))
|
||||
}
|
||||
_ => Err(StablecoinApiError::new("invalid_numeric_value")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_i128(value: &Value) -> Result<i128, StablecoinApiError> {
|
||||
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::<i128>()
|
||||
.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::<Vec<_>>(),
|
||||
"signingRequirements": signing_requirements,
|
||||
"instruction": instruction,
|
||||
}))
|
||||
}
|
||||
@@ -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<ProgramId, StablecoinApiError> {
|
||||
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<String, Value>,
|
||||
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)));
|
||||
}
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub elf: Option<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
@@ -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<T, E: core::fmt::Display>(result: Result<T, E>) -> 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<u32> = ok(serde_json::from_value(value.clone()));
|
||||
ok(risc0_zkvm::serde::from_slice::<Instruction, u32>(&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");
|
||||
}
|
||||
@@ -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_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
fn success(value: serde_json::Value) -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
value: Some(value),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
value: None,
|
||||
error: Some(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// `request` must be null or point to a live NUL-terminated byte string for
|
||||
/// the duration of this call.
|
||||
unsafe fn call<T: DeserializeOwned>(
|
||||
request: *const c_char,
|
||||
operation: fn(T) -> 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::<T>(&request).map_err(|_| String::from("bad_request"))?;
|
||||
operation(request).map_err(|error| error.to_string())
|
||||
}));
|
||||
|
||||
let envelope = match result {
|
||||
Ok(Ok(value)) => Envelope::success(value),
|
||||
Ok(Err(error)) => Envelope::failure(error),
|
||||
Err(_) => Envelope::failure("backend_error"),
|
||||
};
|
||||
encode_envelope(&envelope)
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// `request` must be null or point to a live NUL-terminated byte string for
|
||||
/// the duration of this call.
|
||||
unsafe fn request_text(request: *const c_char) -> Result<String, String> {
|
||||
if request.is_null() {
|
||||
return Err(String::from("bad_request"));
|
||||
}
|
||||
// SAFETY: The caller passes a live NUL-terminated UTF-8 buffer for this call.
|
||||
let request = unsafe { CStr::from_ptr(request) };
|
||||
request
|
||||
.to_str()
|
||||
.map(String::from)
|
||||
.map_err(|_| String::from("bad_request"))
|
||||
}
|
||||
|
||||
fn encode_envelope(envelope: &Envelope) -> *mut c_char {
|
||||
let json = serde_json::to_string(envelope)
|
||||
.unwrap_or_else(|_| String::from(r#"{"ok":false,"error":"backend_error"}"#));
|
||||
match CString::new(json) {
|
||||
Ok(value) => value.into_raw(),
|
||||
Err(_) => CString::new(r#"{"ok":false,"error":"backend_error"}"#)
|
||||
.map_or(std::ptr::null_mut(), CString::into_raw),
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
/// 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::<ProgramInfoRequest>(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::<DecodeProtocolParametersRequest>(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::<InitializeProgramPlanRequest>(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()) };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
#include "stablecoin_module_impl.h"
|
||||
#include "stablecoin_module_support.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
// Generated by logos-module-builder. Kept out of the universal API header.
|
||||
#include "logos_sdk.h"
|
||||
|
||||
extern "C" {
|
||||
#include "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<unsigned char>(value[begin]))) ++begin;
|
||||
while (end > begin && std::isspace(static_cast<unsigned char>(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();
|
||||
}
|
||||
|
||||
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 <typename Operation>
|
||||
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<char, decltype(&stablecoin_free)> 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<bool>()) {
|
||||
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<std::uint8_t> 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::uint8_t>(std::istreambuf_iterator<char>(file),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
nlohmann::json StablecoinModuleImpl::stablecoinProgramInfo(std::string& error) {
|
||||
const std::lock_guard<std::mutex> 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<std::uint8_t> 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<std::string> account_ids =
|
||||
accounts_field->get<std::vector<std::string>>();
|
||||
const std::vector<bool> signing_requirements =
|
||||
signers_field->get<std::vector<bool>>();
|
||||
const std::vector<std::uint8_t> 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<bool>({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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <logos_json.h>
|
||||
#include <logos_module_context.h>
|
||||
|
||||
// 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<std::uint8_t> 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_;
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
#include "stablecoin_module_support.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
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<char>(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<unsigned char>(value[begin]))) ++begin;
|
||||
while (end > begin && std::isspace(static_cast<unsigned char>(value[end - 1]))) --end;
|
||||
return value.substr(begin, end - begin);
|
||||
}
|
||||
|
||||
bool isZeroId(const std::string& value) {
|
||||
return value.size() == 64
|
||||
&& std::all_of(value.begin(), value.end(), [](char 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>()
|
||||
: 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<std::uint8_t> jsonInstructionLeBytes(const nlohmann::json& input) {
|
||||
std::vector<std::uint8_t> result;
|
||||
if (!input.is_array()) return result;
|
||||
result.reserve(input.size() * sizeof(std::uint32_t));
|
||||
for (const auto& item : input) {
|
||||
if (!item.is_number_unsigned() && !item.is_number_integer()) return {};
|
||||
std::uint64_t raw = 0;
|
||||
if (item.is_number_unsigned()) {
|
||||
raw = item.get<std::uint64_t>();
|
||||
} else {
|
||||
const std::int64_t signed_raw = item.get<std::int64_t>();
|
||||
if (signed_raw < 0) return {};
|
||||
raw = static_cast<std::uint64_t>(signed_raw);
|
||||
}
|
||||
if (raw > std::numeric_limits<std::uint32_t>::max()) return {};
|
||||
const auto word = static_cast<std::uint32_t>(raw);
|
||||
result.push_back(static_cast<std::uint8_t>(word & 0xff));
|
||||
result.push_back(static_cast<std::uint8_t>((word >> 8) & 0xff));
|
||||
result.push_back(static_cast<std::uint8_t>((word >> 16) & 0xff));
|
||||
result.push_back(static_cast<std::uint8_t>((word >> 24) & 0xff));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string stableFfiError(const std::string& error) {
|
||||
static const std::set<std::string> 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<bool>()) return {};
|
||||
std::string transaction_id = lowercase(jsonString(reply, "tx_hash"));
|
||||
return isHexLength(transaction_id, 64) ? transaction_id : std::string();
|
||||
}
|
||||
|
||||
} // namespace stablecoin_module::detail
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
|
||||
namespace stablecoin_module::detail {
|
||||
|
||||
using Base58Decoder = std::function<std::string(const std::string&)>;
|
||||
|
||||
// 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<std::uint8_t> 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
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
#include <logos_test.h>
|
||||
|
||||
LOGOS_TEST_MAIN()
|
||||
@@ -0,0 +1,132 @@
|
||||
#include "stablecoin_module_support.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <logos_test.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
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>(), std::string("ok"));
|
||||
LOGOS_ASSERT_EQ(
|
||||
valid["account"]["program_owner"].get<std::string>(),
|
||||
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>(), 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>(), 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<std::uint32_t>::max()}});
|
||||
const std::vector<std::uint8_t> 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());
|
||||
}
|
||||
Reference in New Issue
Block a user