diff --git a/modules/stablecoin/README.md b/modules/stablecoin/README.md index 41381aca..e51fcb41 100644 --- a/modules/stablecoin/README.md +++ b/modules/stablecoin/README.md @@ -36,6 +36,14 @@ 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. +### `stabilityFeeAccumulator()` + +Reads the singleton Stability Fee Accumulator account through `lez_core`, +verifies its PDA and owner, and exactly decodes its stored snapshot. The result +includes the account ID in base58 and lowercase hexadecimal form plus +`accumulatedRateAtLastAccrual` and `lastAccruedAt` as decimal strings. It does +not project the accumulator to the current time. + ### `initializeProgram(request)` Required request fields: diff --git a/modules/stablecoin/ffi/include/stablecoin_ffi.h b/modules/stablecoin/ffi/include/stablecoin_ffi.h index faaf02ba..5a023ac4 100644 --- a/modules/stablecoin/ffi/include/stablecoin_ffi.h +++ b/modules/stablecoin/ffi/include/stablecoin_ffi.h @@ -30,6 +30,14 @@ char *stablecoin_program_info(const char *request_json); */ char *stablecoin_decode_protocol_parameters(const char *request_json); +/** + * Decodes and validates the singleton `StabilityFeeAccumulator` account. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_decode_stability_fee_accumulator(const char *request_json); + /** * Builds the exact wallet submission plan for `InitializeProgram`. * diff --git a/modules/stablecoin/ffi/src/api/decode.rs b/modules/stablecoin/ffi/src/api/decode.rs index 2162c9f5..17ab3020 100644 --- a/modules/stablecoin/ffi/src/api/decode.rs +++ b/modules/stablecoin/ffi/src/api/decode.rs @@ -1,9 +1,12 @@ use serde_json::{json, Value}; -use stablecoin_core::{compute_protocol_parameters_pda, ProtocolParameters}; +use stablecoin_core::{ + compute_protocol_parameters_pda, compute_stability_fee_accumulator_pda, ProtocolParameters, + StabilityFeeAccumulator, +}; use super::{ - parse_stablecoin_program_id, DecodeProtocolParametersRequest, StablecoinApiError, - StablecoinResult, + parse_stablecoin_program_id, DecodeProtocolParametersRequest, + DecodeStabilityFeeAccumulatorRequest, StablecoinApiError, StablecoinResult, }; use crate::account::{account_id_hex, decode_account}; @@ -24,6 +27,27 @@ pub fn decode_protocol_parameters(request: DecodeProtocolParametersRequest) -> S Ok(parameters_value(account_id, ¶meters)) } +pub fn decode_stability_fee_accumulator( + request: DecodeStabilityFeeAccumulatorRequest, +) -> StablecoinResult { + let stablecoin_program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; + let (account_id, account) = decode_account(&request.stability_fee_accumulator) + .map_err(|_| StablecoinApiError::new("account_read_failed"))?; + + if account_id != compute_stability_fee_accumulator_pda(stablecoin_program_id) { + return Err(StablecoinApiError::new( + "stability_fee_accumulator_pda_mismatch", + )); + } + if account.program_owner != stablecoin_program_id { + return Err(StablecoinApiError::new("stablecoin_program_mismatch")); + } + + let accumulator = StabilityFeeAccumulator::try_from(&account.data) + .map_err(|_| StablecoinApiError::new("invalid_stability_fee_accumulator_data"))?; + Ok(stability_fee_accumulator_value(account_id, &accumulator)) +} + fn parameters_value( account_id: lee_core::account::AccountId, parameters: &ProtocolParameters, @@ -52,3 +76,16 @@ fn parameters_value( "isFrozen": parameters.is_frozen, }) } + +fn stability_fee_accumulator_value( + account_id: lee_core::account::AccountId, + accumulator: &StabilityFeeAccumulator, +) -> Value { + json!({ + "accountId": account_id.to_string(), + "accountIdHex": account_id_hex(account_id), + "accumulatedRateAtLastAccrual": + accumulator.accumulated_rate_at_last_accrual.to_string(), + "lastAccruedAt": accumulator.last_accrued_at.to_string(), + }) +} diff --git a/modules/stablecoin/ffi/src/api/mod.rs b/modules/stablecoin/ffi/src/api/mod.rs index 0f0ccdad..9971f8f4 100644 --- a/modules/stablecoin/ffi/src/api/mod.rs +++ b/modules/stablecoin/ffi/src/api/mod.rs @@ -10,11 +10,12 @@ mod tests; use std::{error::Error, fmt}; -pub use decode::decode_protocol_parameters; +pub use decode::{decode_protocol_parameters, decode_stability_fee_accumulator}; pub use plan::initialize_program_plan; pub use program::program_info; pub use request::{ - DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, + DecodeProtocolParametersRequest, DecodeStabilityFeeAccumulatorRequest, + InitializeProgramPlanRequest, ProgramInfoRequest, }; use serde_json::Value; diff --git a/modules/stablecoin/ffi/src/api/request.rs b/modules/stablecoin/ffi/src/api/request.rs index f659238c..277f82a5 100644 --- a/modules/stablecoin/ffi/src/api/request.rs +++ b/modules/stablecoin/ffi/src/api/request.rs @@ -19,6 +19,13 @@ pub struct DecodeProtocolParametersRequest { pub protocol_parameters: AccountRead, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DecodeStabilityFeeAccumulatorRequest { + pub stablecoin_program_id: String, + pub stability_fee_accumulator: AccountRead, +} + #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct InitializeProgramPlanRequest { diff --git a/modules/stablecoin/ffi/src/api/tests.rs b/modules/stablecoin/ffi/src/api/tests.rs index 20677be5..00a3c397 100644 --- a/modules/stablecoin/ffi/src/api/tests.rs +++ b/modules/stablecoin/ffi/src/api/tests.rs @@ -9,14 +9,15 @@ 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, + StabilityFeeAccumulator, }; use token_core::TokenDefinition; use twap_oracle_core::OraclePriceAccount; use super::{ - decode_protocol_parameters, initialize_program_plan, program_info, - DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, - StablecoinResult, + decode_protocol_parameters, decode_stability_fee_accumulator, initialize_program_plan, + program_info, DecodeProtocolParametersRequest, DecodeStabilityFeeAccumulatorRequest, + InitializeProgramPlanRequest, ProgramInfoRequest, StablecoinResult, }; use crate::account::{account_id_hex, account_read, program_id_bytes}; @@ -91,6 +92,26 @@ fn protocol_request(parameters: &ProtocolParameters) -> DecodeProtocolParameters } } +fn stability_fee_accumulator() -> StabilityFeeAccumulator { + StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: u128::MAX, + last_accrued_at: u64::MAX, + } +} + +fn accumulator_request( + accumulator: &StabilityFeeAccumulator, +) -> DecodeStabilityFeeAccumulatorRequest { + let account_id = compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID); + DecodeStabilityFeeAccumulatorRequest { + stablecoin_program_id: program_id_hex(), + stability_fee_accumulator: account_read( + account_id, + &account(STABLECOIN_PROGRAM_ID, Data::from(accumulator)), + ), + } +} + fn initialize_request() -> InitializeProgramPlanRequest { let collateral_id = id(10); let stablecoin_definition_id = compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID); @@ -292,6 +313,79 @@ fn protocol_parameters_decode_rejects_wrong_pda_owner_and_non_exact_data() { ); } +#[test] +fn stability_fee_accumulator_decode_preserves_fixed_id_and_boundary_values() { + let accumulator = stability_fee_accumulator(); + let value = ok(decode_stability_fee_accumulator(accumulator_request( + &accumulator, + ))); + + assert_eq!( + value["accountId"], + "E4tfkjjkPz2g1G3bpgkXQx4M7e7g4Lr2Mr8bxvAsSmzE" + ); + assert_eq!( + value["accountIdHex"], + "c22718073968c322725e4ba774b270036c294663a547f43285a214614c1c258f" + ); + assert_eq!(value["accumulatedRateAtLastAccrual"], u128::MAX.to_string()); + assert_eq!(value["lastAccruedAt"], u64::MAX.to_string()); +} + +#[test] +fn stability_fee_accumulator_decode_rejects_failed_reads_and_wrong_identity() { + let accumulator = stability_fee_accumulator(); + + let mut missing = accumulator_request(&accumulator); + missing.stability_fee_accumulator.status = String::from("not_found"); + missing.stability_fee_accumulator.account = None; + assert_error( + decode_stability_fee_accumulator(missing), + "account_read_failed", + ); + + let mut wrong_pda = accumulator_request(&accumulator); + wrong_pda.stability_fee_accumulator.id = account_id_hex(id(20)); + assert_error( + decode_stability_fee_accumulator(wrong_pda), + "stability_fee_accumulator_pda_mismatch", + ); + + let mut wrong_owner = accumulator_request(&accumulator); + if let Some(account) = &mut wrong_owner.stability_fee_accumulator.account { + account.program_owner = hex::encode(program_id_bytes(TOKEN_PROGRAM_ID)); + } + assert_error( + decode_stability_fee_accumulator(wrong_owner), + "stablecoin_program_mismatch", + ); +} + +#[test] +fn stability_fee_accumulator_decode_rejects_truncated_and_trailing_data() { + let accumulator = stability_fee_accumulator(); + let account_id = compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID); + let encoded = Data::from(&accumulator).as_ref().to_vec(); + + for malformed in [encoded[..encoded.len() - 1].to_vec(), { + let mut trailing = encoded.clone(); + trailing.push(0); + trailing + }] { + let request = DecodeStabilityFeeAccumulatorRequest { + stablecoin_program_id: program_id_hex(), + stability_fee_accumulator: account_read( + account_id, + &account(STABLECOIN_PROGRAM_ID, ok(Data::try_from(malformed))), + ), + }; + assert_error( + decode_stability_fee_accumulator(request), + "invalid_stability_fee_accumulator_data", + ); + } +} + #[test] fn initialize_plan_round_trips_all_boundary_values_and_exact_account_contract() { let request = initialize_request(); diff --git a/modules/stablecoin/ffi/src/ffi.rs b/modules/stablecoin/ffi/src/ffi.rs index 4ac65568..d7903d40 100644 --- a/modules/stablecoin/ffi/src/ffi.rs +++ b/modules/stablecoin/ffi/src/ffi.rs @@ -6,8 +6,8 @@ use std::{ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ - self, DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, - StablecoinResult, + self, DecodeProtocolParametersRequest, DecodeStabilityFeeAccumulatorRequest, + InitializeProgramPlanRequest, ProgramInfoRequest, StablecoinResult, }; #[derive(Serialize)] @@ -109,6 +109,23 @@ pub unsafe extern "C" fn stablecoin_decode_protocol_parameters( } } +#[unsafe(no_mangle)] +/// Decodes and validates the singleton `StabilityFeeAccumulator` account. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_decode_stability_fee_accumulator( + request_json: *const c_char, +) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { + call::( + request_json, + api::decode_stability_fee_accumulator, + ) + } +} + #[unsafe(no_mangle)] /// Builds the exact wallet submission plan for `InitializeProgram`. /// diff --git a/modules/stablecoin/ffi/src/lib.rs b/modules/stablecoin/ffi/src/lib.rs index 703bfa1a..81af49da 100644 --- a/modules/stablecoin/ffi/src/lib.rs +++ b/modules/stablecoin/ffi/src/lib.rs @@ -7,7 +7,8 @@ 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, + decode_protocol_parameters, decode_stability_fee_accumulator, initialize_program_plan, + program_info, DecodeProtocolParametersRequest, DecodeStabilityFeeAccumulatorRequest, + InitializeProgramPlanRequest, ProgramInfoRequest, StablecoinApiError, StablecoinResponse, + StablecoinResult, }; diff --git a/modules/stablecoin/ffi/tests/public_api.rs b/modules/stablecoin/ffi/tests/public_api.rs index 5db657fa..28e07e29 100644 --- a/modules/stablecoin/ffi/tests/public_api.rs +++ b/modules/stablecoin/ffi/tests/public_api.rs @@ -1,7 +1,7 @@ use stablecoin_ffi::{ - decode_protocol_parameters, initialize_program_plan, program_info, - DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, - StablecoinResult, + decode_protocol_parameters, decode_stability_fee_accumulator, initialize_program_plan, + program_info, DecodeProtocolParametersRequest, DecodeStabilityFeeAccumulatorRequest, + InitializeProgramPlanRequest, ProgramInfoRequest, StablecoinResult, }; #[test] @@ -9,5 +9,7 @@ fn crate_root_reexports_stablecoin_surface() { let _program_info: fn(ProgramInfoRequest) -> StablecoinResult = program_info; let _decode: fn(DecodeProtocolParametersRequest) -> StablecoinResult = decode_protocol_parameters; + let _decode_accumulator: fn(DecodeStabilityFeeAccumulatorRequest) -> StablecoinResult = + decode_stability_fee_accumulator; let _initialize: fn(InitializeProgramPlanRequest) -> StablecoinResult = initialize_program_plan; } diff --git a/modules/stablecoin/src/stablecoin_module_impl.cpp b/modules/stablecoin/src/stablecoin_module_impl.cpp index 8ed864b3..f39e208a 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.cpp +++ b/modules/stablecoin/src/stablecoin_module_impl.cpp @@ -278,6 +278,33 @@ LogosMap StablecoinModuleImpl::protocolParameters() { }); } +LogosMap StablecoinModuleImpl::stabilityFeeAccumulator() { + 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, "stabilityFeeAccumulatorIdHex")); + 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_stability_fee_accumulator, + { + {"stablecoinProgramId", info["programIdHex"]}, + {"stabilityFeeAccumulator", read}, + }); + if (!decoded.ok) { + return publicError(stablecoin_module::detail::stableFfiError(decoded.error)); + } + + LogosMap result = publicOk(); + result["stabilityFeeAccumulator"] = 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"); diff --git a/modules/stablecoin/src/stablecoin_module_impl.h b/modules/stablecoin/src/stablecoin_module_impl.h index c87d5ba0..172cba59 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.h +++ b/modules/stablecoin/src/stablecoin_module_impl.h @@ -24,6 +24,10 @@ class StablecoinModuleImpl : public LogosModuleContext { /// Success adds `protocolParameters`; failures use stable error codes. LogosMap protocolParameters(); + /// Reads and exactly decodes the singleton StabilityFeeAccumulator account. + /// Returns the stored snapshot without projecting it to the current time. + LogosMap stabilityFeeAccumulator(); + /// Initializes the stablecoin protocol. Request fields are `adminId`, /// `freezeAuthorityId`, `collateralDefinitionId`, `marketPriceOracleId`, /// `initialStabilityFeePerMillisecond`, diff --git a/modules/stablecoin/src/stablecoin_module_support.cpp b/modules/stablecoin/src/stablecoin_module_support.cpp index a8a46e5f..d2ad08b4 100644 --- a/modules/stablecoin/src/stablecoin_module_support.cpp +++ b/modules/stablecoin/src/stablecoin_module_support.cpp @@ -165,10 +165,12 @@ std::string stableFfiError(const std::string& error) { "invalid_program_binary", "invalid_program_id", "invalid_protocol_parameters_data", + "invalid_stability_fee_accumulator_data", "invalid_stablecoin_name", "oracle_asset_mismatch", "program_id_mismatch", "protocol_parameters_pda_mismatch", + "stability_fee_accumulator_pda_mismatch", "stablecoin_program_mismatch", }; return stable.find(error) == stable.end() ? std::string("backend_error") : error; diff --git a/modules/stablecoin/tests/CMakeLists.txt b/modules/stablecoin/tests/CMakeLists.txt index a54ff781..2582524c 100644 --- a/modules/stablecoin/tests/CMakeLists.txt +++ b/modules/stablecoin/tests/CMakeLists.txt @@ -4,12 +4,20 @@ project(StablecoinModuleTests LANGUAGES CXX) include(LogosTest) logos_test( - NAME stablecoin_module_support_tests + NAME stablecoin_module_tests MODULE_SOURCES + ../src/stablecoin_module_impl.cpp ../src/stablecoin_module_support.cpp TEST_SOURCES main.cpp + stablecoin_module_impl_test.cpp stablecoin_module_support_test.cpp + MOCK_C_SOURCES + mocks/mock_stablecoin_ffi.cpp + GENERATED_SOURCES + ../generated_code/lez_core_api.cpp EXTRA_INCLUDES ../src + ../ffi/include + ../generated_code ) diff --git a/modules/stablecoin/tests/logos_sdk.h b/modules/stablecoin/tests/logos_sdk.h new file mode 100644 index 00000000..45dbb53f --- /dev/null +++ b/modules/stablecoin/tests/logos_sdk.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "lez_core_api.h" + +// Test adapter between the universal module's std-based generated API and the +// Qt test framework's generated dependency client. +class UniversalLezCore { +public: + explicit UniversalLezCore(LogosAPI* api) + : qt_(api) { } + + std::string account_id_from_base58(const std::string& base58) { + return qt_.account_id_from_base58(QString::fromStdString(base58)).toStdString(); + } + + std::string get_account_public(const std::string& account_id, + logos::CallError* error = nullptr) { + return qt_.get_account_public(QString::fromStdString(account_id), error).toStdString(); + } + + std::string send_generic_public_transaction( + const std::vector& account_ids, + const std::vector& signing_requirements, + const std::vector& instruction, + const std::string& program_id, + logos::CallError* error = nullptr) { + QStringList qt_account_ids; + qt_account_ids.reserve(static_cast(account_ids.size())); + for (const auto& account_id : account_ids) { + qt_account_ids.push_back(QString::fromStdString(account_id)); + } + + QVariantList qt_signing_requirements; + qt_signing_requirements.reserve( + static_cast(signing_requirements.size())); + for (const bool required : signing_requirements) { + qt_signing_requirements.push_back(required); + } + + const QByteArray qt_instruction( + reinterpret_cast(instruction.data()), + static_cast(instruction.size())); + return qt_.send_generic_public_transaction( + qt_account_ids, + qt_signing_requirements, + QVariant(qt_instruction), + QString::fromStdString(program_id), + error) + .toStdString(); + } + +private: + LezCore qt_; +}; + +struct LogosModules { + explicit LogosModules(LogosAPI* api) + : lez_core(api) { } + + UniversalLezCore lez_core; +}; diff --git a/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp new file mode 100644 index 00000000..913aa70a --- /dev/null +++ b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp @@ -0,0 +1,45 @@ +#include +#include + +#include + +extern "C" { +#include "stablecoin_ffi.h" +} + +namespace { + +char* copyMockResponse(const char* function_name) { + LOGOS_CMOCK_RECORD(function_name); + const char* response = LOGOS_CMOCK_RETURN_STRING(function_name); + if (response == nullptr) return nullptr; + + const std::size_t size = std::strlen(response) + 1; + auto* copy = static_cast(std::malloc(size)); + if (copy == nullptr) return nullptr; + std::memcpy(copy, response, size); + return copy; +} + +} // namespace + +extern "C" char* stablecoin_program_info(const char*) { + return copyMockResponse("stablecoin_program_info"); +} + +extern "C" char* stablecoin_decode_protocol_parameters(const char*) { + return copyMockResponse("stablecoin_decode_protocol_parameters"); +} + +extern "C" char* stablecoin_decode_stability_fee_accumulator(const char*) { + return copyMockResponse("stablecoin_decode_stability_fee_accumulator"); +} + +extern "C" char* stablecoin_initialize_program_plan(const char*) { + return copyMockResponse("stablecoin_initialize_program_plan"); +} + +extern "C" void stablecoin_free(char* value) { + LOGOS_CMOCK_RECORD("stablecoin_free"); + std::free(value); +} diff --git a/modules/stablecoin/tests/stablecoin_module_impl_test.cpp b/modules/stablecoin/tests/stablecoin_module_impl_test.cpp new file mode 100644 index 00000000..0c886823 --- /dev/null +++ b/modules/stablecoin/tests/stablecoin_module_impl_test.cpp @@ -0,0 +1,196 @@ +#include "stablecoin_module_impl.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "logos_sdk.h" + +namespace { + +using json = nlohmann::json; + +const std::string PROGRAM_ID_HEX(64, '1'); +const std::string ACCUMULATOR_ID_HEX(64, '2'); + +class ScopedEnvironment { +public: + ScopedEnvironment(std::string name, const char* value) + : name_(std::move(name)) { + if (const char* previous = std::getenv(name_.c_str()); previous != nullptr) { + previous_ = previous; + } + if (value == nullptr) { + unsetenv(name_.c_str()); + } else { + setenv(name_.c_str(), value, 1); + } + } + + ~ScopedEnvironment() { + if (previous_.has_value()) { + setenv(name_.c_str(), previous_->c_str(), 1); + } else { + unsetenv(name_.c_str()); + } + } + + ScopedEnvironment(const ScopedEnvironment&) = delete; + ScopedEnvironment& operator=(const ScopedEnvironment&) = delete; + +private: + std::string name_; + std::optional previous_; +}; + +json programInfoValue() { + return { + {"programId", "program-id"}, + {"programIdHex", PROGRAM_ID_HEX}, + {"protocolParametersId", "protocol-parameters-id"}, + {"protocolParametersIdHex", std::string(64, '3')}, + {"stabilityFeeAccumulatorId", "stability-fee-accumulator-id"}, + {"stabilityFeeAccumulatorIdHex", ACCUMULATOR_ID_HEX}, + {"redemptionPriceStateId", "redemption-price-state-id"}, + {"redemptionPriceStateIdHex", std::string(64, '4')}, + {"stablecoinDefinitionId", "stablecoin-definition-id"}, + {"stablecoinDefinitionIdHex", std::string(64, '5')}, + {"stablecoinMasterHoldingId", "stablecoin-master-holding-id"}, + {"stablecoinMasterHoldingIdHex", std::string(64, '6')}, + {"clockId", "clock-id"}, + {"clockIdHex", std::string(64, '7')}, + }; +} + +std::string successEnvelope(const json& value) { + return json{{"ok", true}, {"value", value}}.dump(); +} + +std::string failureEnvelope(const std::string& error) { + return json{{"ok", false}, {"error", error}}.dump(); +} + +std::string initializedAccount() { + return json{ + {"program_owner", PROGRAM_ID_HEX}, + {"balance", std::string(32, '0')}, + {"nonce", std::string(32, '0')}, + {"data", "00"}, + }.dump(); +} + +void attachModules(StablecoinModuleImpl& module, LogosModules& modules) { + module._logosCoreSetLogosModulesPtr_(&modules); +} + +void assertError(const LogosMap& response, const std::string& error) { + LOGOS_ASSERT_EQ(response["status"].get(), std::string("error")); + LOGOS_ASSERT_EQ(response["error"].get(), error); +} + +} // namespace + +LOGOS_TEST(stability_fee_accumulator_reads_once_and_returns_exact_snapshot) { + ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); + ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + + const json decoded = { + {"accountId", "E4tfkjjkPz2g1G3bpgkXQx4M7e7g4Lr2Mr8bxvAsSmzE"}, + {"accountIdHex", ACCUMULATOR_ID_HEX}, + {"accumulatedRateAtLastAccrual", "340282366920938463463374607431768211455"}, + {"lastAccruedAt", "18446744073709551615"}, + }; + const std::string program_info_response = successEnvelope(programInfoValue()); + const std::string decoder_response = successEnvelope(decoded); + context.mockCFunction("stablecoin_program_info").returns(program_info_response); + context.mockCFunction("stablecoin_decode_stability_fee_accumulator") + .returns(decoder_response); + context.mockModule("lez_core", "get_account_public").returns(initializedAccount()); + + const LogosMap response = module.stabilityFeeAccumulator(); + + LOGOS_ASSERT_EQ(response["status"].get(), std::string("ok")); + LOGOS_ASSERT_EQ(response["error"].get(), std::string()); + LOGOS_ASSERT_EQ(response["stabilityFeeAccumulator"], decoded); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 1); + LOGOS_ASSERT_TRUE(context.moduleCalledWith( + "lez_core", + "get_account_public", + QVariantList{QVariant(QString::fromStdString(ACCUMULATOR_ID_HEX))})); + LOGOS_ASSERT_EQ( + context.cFunctionCallCount("stablecoin_decode_stability_fee_accumulator"), 1); +} + +LOGOS_TEST(stability_fee_accumulator_maps_missing_account_to_not_initialized) { + ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); + ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + + const std::string program_info_response = successEnvelope(programInfoValue()); + context.mockCFunction("stablecoin_program_info").returns(program_info_response); + context.mockModule("lez_core", "get_account_public").returns(""); + + const LogosMap response = module.stabilityFeeAccumulator(); + + assertError(response, "not_initialized"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 1); + LOGOS_ASSERT_EQ( + context.cFunctionCallCount("stablecoin_decode_stability_fee_accumulator"), 0); +} + +LOGOS_TEST(stability_fee_accumulator_rejects_malformed_account_response) { + ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); + ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + + const std::string program_info_response = successEnvelope(programInfoValue()); + context.mockCFunction("stablecoin_program_info").returns(program_info_response); + context.mockModule("lez_core", "get_account_public").returns("not-json"); + + const LogosMap response = module.stabilityFeeAccumulator(); + + assertError(response, "account_read_failed"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 1); + LOGOS_ASSERT_EQ( + context.cFunctionCallCount("stablecoin_decode_stability_fee_accumulator"), 0); +} + +LOGOS_TEST(stability_fee_accumulator_preserves_stable_decoder_errors) { + ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); + ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + + const std::string program_info_response = successEnvelope(programInfoValue()); + const std::string decoder_response = failureEnvelope( + "stability_fee_accumulator_pda_mismatch"); + context.mockCFunction("stablecoin_program_info").returns(program_info_response); + context.mockCFunction("stablecoin_decode_stability_fee_accumulator") + .returns(decoder_response); + context.mockModule("lez_core", "get_account_public").returns(initializedAccount()); + + const LogosMap response = module.stabilityFeeAccumulator(); + + assertError(response, "stability_fee_accumulator_pda_mismatch"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 1); + LOGOS_ASSERT_EQ( + context.cFunctionCallCount("stablecoin_decode_stability_fee_accumulator"), 1); +} diff --git a/modules/stablecoin/tests/stablecoin_module_support_test.cpp b/modules/stablecoin/tests/stablecoin_module_support_test.cpp index e300039e..3d17d40f 100644 --- a/modules/stablecoin/tests/stablecoin_module_support_test.cpp +++ b/modules/stablecoin/tests/stablecoin_module_support_test.cpp @@ -100,6 +100,14 @@ 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( + "invalid_stability_fee_accumulator_data"), + std::string("invalid_stability_fee_accumulator_data")); + LOGOS_ASSERT_EQ( + stablecoin_module::detail::stableFfiError( + "stability_fee_accumulator_pda_mismatch"), + std::string("stability_fee_accumulator_pda_mismatch")); LOGOS_ASSERT_EQ( stablecoin_module::detail::stableFfiError("internal parse detail"), std::string("backend_error"));