From 638424b39c12489e65fd66867b81cc32571e8a8e Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Thu, 27 Aug 2026 12:25:44 -0300 Subject: [PATCH] feat(stablecoin): expose current global state --- modules/stablecoin/README.md | 13 + .../stablecoin/ffi/include/stablecoin_ffi.h | 8 + modules/stablecoin/ffi/src/api/decode.rs | 73 ++-- modules/stablecoin/ffi/src/api/mod.rs | 4 +- modules/stablecoin/ffi/src/api/projection.rs | 321 ++++++++++++++++++ modules/stablecoin/ffi/src/api/request.rs | 10 + modules/stablecoin/ffi/src/ffi.rs | 30 +- modules/stablecoin/ffi/src/lib.rs | 10 +- modules/stablecoin/ffi/tests/public_api.rs | 11 +- .../stablecoin/src/stablecoin_module_impl.cpp | 43 +++ .../stablecoin/src/stablecoin_module_impl.h | 4 + .../tests/mocks/mock_stablecoin_ffi.cpp | 4 + .../tests/stablecoin_module_impl_test.cpp | 108 +++++- 13 files changed, 601 insertions(+), 38 deletions(-) create mode 100644 modules/stablecoin/ffi/src/api/projection.rs diff --git a/modules/stablecoin/README.md b/modules/stablecoin/README.md index 5e267b94..be55838c 100644 --- a/modules/stablecoin/README.md +++ b/modules/stablecoin/README.md @@ -53,6 +53,19 @@ includes the account ID in base58 and lowercase hexadecimal form plus `controllerIntegralTerm`, and `lastUpdatedAt` as decimal strings. It does not project the current redemption price or simulate a controller update. +### `currentGlobalState()` + +Reads Protocol Parameters, Stability Fee Accumulator, Redemption Price State, +and the canonical `CLOCK_01` account through `lez_core`. It verifies each +stablecoin singleton's PDA, owner, and data before projecting both current +values at the clock account's Unix-millisecond timestamp. + +The result contains `accumulatedRateAtLastAccrual`, `lastAccruedAt`, +`redemptionPriceAtLastUpdate`, `lastUpdatedAt`, `currentAccumulatedRate`, +`currentRedemptionPrice`, and `projectedAt`. Every value is an exact decimal +string. Projection uses saturating timestamp subtraction and the on-chain +seven-day compounding-window clamp. The method accepts no caller-provided 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 519e5fab..0cd6dd8d 100644 --- a/modules/stablecoin/ffi/include/stablecoin_ffi.h +++ b/modules/stablecoin/ffi/include/stablecoin_ffi.h @@ -46,6 +46,14 @@ char *stablecoin_decode_stability_fee_accumulator(const char *request_json); */ char *stablecoin_decode_redemption_price_state(const char *request_json); +/** + * Validates stablecoin global accounts and projects their current values at `CLOCK_01`. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_current_global_state(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 c88accf6..6fecaa4b 100644 --- a/modules/stablecoin/ffi/src/api/decode.rs +++ b/modules/stablecoin/ffi/src/api/decode.rs @@ -1,3 +1,4 @@ +use lee_core::{account::AccountId, program::ProgramId}; use serde_json::{json, Value}; use stablecoin_core::{ compute_protocol_parameters_pda, compute_redemption_price_state_pda, @@ -10,32 +11,61 @@ use super::{ DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, StablecoinApiError, StablecoinResult, }; -use crate::account::{account_id_hex, decode_account}; +use crate::{ + account::{account_id_hex, decode_account}, + AccountRead, +}; 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"))?; + let (account_id, parameters) = + validated_protocol_parameters(stablecoin_program_id, &request.protocol_parameters)?; + 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, accumulator) = validated_stability_fee_accumulator( + stablecoin_program_id, + &request.stability_fee_accumulator, + )?; + Ok(stability_fee_accumulator_value(account_id, &accumulator)) +} + +pub fn decode_redemption_price_state( + request: DecodeRedemptionPriceStateRequest, +) -> StablecoinResult { + let stablecoin_program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; + let (account_id, state) = + validated_redemption_price_state(stablecoin_program_id, &request.redemption_price_state)?; + Ok(redemption_price_state_value(account_id, &state)) +} +pub(super) fn validated_protocol_parameters( + stablecoin_program_id: ProgramId, + read: &AccountRead, +) -> Result<(AccountId, ProtocolParameters), StablecoinApiError> { + let (account_id, account) = + decode_account(read).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)) + Ok((account_id, parameters)) } -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"))?; - +pub(super) fn validated_stability_fee_accumulator( + stablecoin_program_id: ProgramId, + read: &AccountRead, +) -> Result<(AccountId, StabilityFeeAccumulator), StablecoinApiError> { + let (account_id, account) = + decode_account(read).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", @@ -44,19 +74,17 @@ pub fn decode_stability_fee_accumulator( 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)) + Ok((account_id, accumulator)) } -pub fn decode_redemption_price_state( - request: DecodeRedemptionPriceStateRequest, -) -> StablecoinResult { - let stablecoin_program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; - let (account_id, account) = decode_account(&request.redemption_price_state) - .map_err(|_| StablecoinApiError::new("account_read_failed"))?; - +pub(super) fn validated_redemption_price_state( + stablecoin_program_id: ProgramId, + read: &AccountRead, +) -> Result<(AccountId, RedemptionPriceState), StablecoinApiError> { + let (account_id, account) = + decode_account(read).map_err(|_| StablecoinApiError::new("account_read_failed"))?; if account_id != compute_redemption_price_state_pda(stablecoin_program_id) { return Err(StablecoinApiError::new( "redemption_price_state_pda_mismatch", @@ -65,10 +93,9 @@ pub fn decode_redemption_price_state( if account.program_owner != stablecoin_program_id { return Err(StablecoinApiError::new("stablecoin_program_mismatch")); } - let state = RedemptionPriceState::try_from(&account.data) .map_err(|_| StablecoinApiError::new("invalid_redemption_price_state_data"))?; - Ok(redemption_price_state_value(account_id, &state)) + Ok((account_id, state)) } fn parameters_value( diff --git a/modules/stablecoin/ffi/src/api/mod.rs b/modules/stablecoin/ffi/src/api/mod.rs index 8d8d2dbd..ded021ab 100644 --- a/modules/stablecoin/ffi/src/api/mod.rs +++ b/modules/stablecoin/ffi/src/api/mod.rs @@ -3,6 +3,7 @@ mod decode; mod plan; mod program; +mod projection; mod request; #[cfg(test)] @@ -15,8 +16,9 @@ pub use decode::{ }; pub use plan::initialize_program_plan; pub use program::program_info; +pub use projection::current_global_state; pub use request::{ - DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, + CurrentGlobalStateRequest, DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, InitializeProgramPlanRequest, ProgramInfoRequest, }; use serde_json::Value; diff --git a/modules/stablecoin/ffi/src/api/projection.rs b/modules/stablecoin/ffi/src/api/projection.rs new file mode 100644 index 00000000..d22c150b --- /dev/null +++ b/modules/stablecoin/ffi/src/api/projection.rs @@ -0,0 +1,321 @@ +use borsh::from_slice; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use serde_json::json; +use stablecoin_core::math::{compute_current_accumulated_rate, compute_current_redemption_price}; + +use super::{ + decode::{ + validated_protocol_parameters, validated_redemption_price_state, + validated_stability_fee_accumulator, + }, + parse_stablecoin_program_id, CurrentGlobalStateRequest, StablecoinApiError, StablecoinResult, +}; +use crate::account::decode_account; + +pub fn current_global_state(request: CurrentGlobalStateRequest) -> StablecoinResult { + let stablecoin_program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; + let (_, parameters) = + validated_protocol_parameters(stablecoin_program_id, &request.protocol_parameters)?; + let (_, accumulator) = validated_stability_fee_accumulator( + stablecoin_program_id, + &request.stability_fee_accumulator, + )?; + let (_, redemption) = + validated_redemption_price_state(stablecoin_program_id, &request.redemption_price_state)?; + let projected_at = clock_timestamp(&request.clock)?; + + let current_accumulated_rate = compute_current_accumulated_rate( + accumulator.accumulated_rate_at_last_accrual, + parameters.stability_fee_per_millisecond, + accumulator.last_accrued_at, + projected_at, + ); + let current_redemption_price = compute_current_redemption_price( + redemption.redemption_price_at_last_update, + redemption.redemption_rate_per_millisecond, + redemption.last_updated_at, + projected_at, + ); + + Ok(json!({ + "accumulatedRateAtLastAccrual": + accumulator.accumulated_rate_at_last_accrual.to_string(), + "lastAccruedAt": accumulator.last_accrued_at.to_string(), + "redemptionPriceAtLastUpdate": redemption.redemption_price_at_last_update.to_string(), + "lastUpdatedAt": redemption.last_updated_at.to_string(), + "currentAccumulatedRate": current_accumulated_rate.to_string(), + "currentRedemptionPrice": current_redemption_price.to_string(), + "projectedAt": projected_at.to_string(), + })) +} + +fn clock_timestamp(read: &crate::AccountRead) -> Result { + let (account_id, account) = + decode_account(read).map_err(|_| StablecoinApiError::new("account_read_failed"))?; + if account_id != CLOCK_01_PROGRAM_ACCOUNT_ID { + return Err(StablecoinApiError::new("invalid_clock")); + } + let clock = from_slice::(account.data.as_ref()) + .map_err(|_| StablecoinApiError::new("invalid_clock"))?; + Ok(clock.timestamp) +} + +#[cfg(test)] +mod tests { + use clock_core::ClockAccountData; + use lee_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, + }; + use stablecoin_core::{ + compute_protocol_parameters_pda, compute_redemption_price_state_pda, + compute_stability_fee_accumulator_pda, + math::{FIXED_POINT_ONE, MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS}, + ProtocolParameters, RedemptionPriceState, StabilityFeeAccumulator, + }; + + use super::*; + use crate::account::{account_id_hex, account_read, program_id_bytes}; + + const STABLECOIN_PROGRAM_ID: ProgramId = [0x11_u32; 8]; + const OTHER_PROGRAM_ID: ProgramId = [0x22_u32; 8]; + + fn id(seed: u8) -> AccountId { + AccountId::new([seed; 32]) + } + + fn account(owner: ProgramId, data: Data) -> Account { + Account { + program_owner: owner, + balance: 0, + data, + nonce: Nonce(0), + } + } + + fn request( + accumulated_rate_at_last_accrual: u128, + stability_fee_per_millisecond: u128, + last_accrued_at: u64, + redemption_price_at_last_update: u128, + redemption_rate_per_millisecond: u128, + last_updated_at: u64, + projected_at: u64, + ) -> CurrentGlobalStateRequest { + let parameters = 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, + controller_proportional_gain: 0, + controller_integral_gain: 0, + minimum_collateralization_ratio: FIXED_POINT_ONE, + minimum_milliseconds_between_rate_updates: 1, + maximum_oracle_price_age_milliseconds: 1, + is_frozen: false, + }; + let accumulator = StabilityFeeAccumulator { + accumulated_rate_at_last_accrual, + last_accrued_at, + }; + let redemption = RedemptionPriceState { + redemption_price_at_last_update, + redemption_rate_per_millisecond, + controller_integral_term: 0, + last_updated_at, + }; + let clock = ClockAccountData { + block_id: 1, + timestamp: projected_at, + }; + + CurrentGlobalStateRequest { + stablecoin_program_id: hex::encode(program_id_bytes(STABLECOIN_PROGRAM_ID)), + protocol_parameters: account_read( + compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID), + &account(STABLECOIN_PROGRAM_ID, Data::from(¶meters)), + ), + stability_fee_accumulator: account_read( + compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID), + &account(STABLECOIN_PROGRAM_ID, Data::from(&accumulator)), + ), + redemption_price_state: account_read( + compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID), + &account(STABLECOIN_PROGRAM_ID, Data::from(&redemption)), + ), + clock: account_read( + CLOCK_01_PROGRAM_ACCOUNT_ID, + &account( + OTHER_PROGRAM_ID, + Data::try_from(clock.to_bytes()).expect("clock data fits"), + ), + ), + } + } + + fn value(request: CurrentGlobalStateRequest) -> serde_json::Value { + current_global_state(request).expect("projection succeeds") + } + + fn assert_error(request: CurrentGlobalStateRequest, expected: &str) { + let error = current_global_state(request).expect_err("projection must fail"); + assert_eq!(error.code(), expected); + } + + #[test] + fn identity_projection_preserves_maximum_anchors_as_exact_strings() { + let projected = value(request( + u128::MAX, + FIXED_POINT_ONE, + 99, + u128::MAX, + FIXED_POINT_ONE, + 99, + 99, + )); + + assert_eq!( + projected["accumulatedRateAtLastAccrual"], + u128::MAX.to_string() + ); + assert_eq!( + projected["redemptionPriceAtLastUpdate"], + u128::MAX.to_string() + ); + assert_eq!(projected["currentAccumulatedRate"], u128::MAX.to_string()); + assert_eq!(projected["currentRedemptionPrice"], u128::MAX.to_string()); + assert_eq!(projected["lastAccruedAt"], "99"); + assert_eq!(projected["lastUpdatedAt"], "99"); + assert_eq!(projected["projectedAt"], "99"); + } + + #[test] + fn growth_and_decay_match_the_core_projection_helpers() { + let growth_rate = FIXED_POINT_ONE + 10_u128.pow(20); + let decay_rate = FIXED_POINT_ONE - 10_u128.pow(20); + let anchor = FIXED_POINT_ONE * 2; + let projected = value(request( + anchor, + growth_rate, + 100, + anchor, + decay_rate, + 100, + 110, + )); + + assert_eq!( + projected["currentAccumulatedRate"], + compute_current_accumulated_rate(anchor, growth_rate, 100, 110).to_string() + ); + assert_eq!( + projected["currentRedemptionPrice"], + compute_current_redemption_price(anchor, decay_rate, 100, 110).to_string() + ); + } + + #[test] + fn inverted_timestamps_collapse_both_projections_to_their_anchors() { + let accumulator_anchor = FIXED_POINT_ONE * 3; + let redemption_anchor = FIXED_POINT_ONE * 4; + let projected = value(request( + accumulator_anchor, + FIXED_POINT_ONE + 10_u128.pow(20), + 200, + redemption_anchor, + FIXED_POINT_ONE - 10_u128.pow(20), + 300, + 100, + )); + + assert_eq!( + projected["currentAccumulatedRate"], + accumulator_anchor.to_string() + ); + assert_eq!( + projected["currentRedemptionPrice"], + redemption_anchor.to_string() + ); + assert_eq!(projected["projectedAt"], "100"); + } + + #[test] + fn beyond_the_window_matches_projection_at_the_exact_seven_day_clamp() { + let rate = FIXED_POINT_ONE + 1; + let at_clamp = value(request( + FIXED_POINT_ONE, + rate, + 0, + FIXED_POINT_ONE, + rate, + 0, + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS, + )); + let beyond_clamp = value(request( + FIXED_POINT_ONE, + rate, + 0, + FIXED_POINT_ONE, + rate, + 0, + u64::MAX, + )); + + assert_eq!( + at_clamp["currentAccumulatedRate"], + beyond_clamp["currentAccumulatedRate"] + ); + assert_eq!( + at_clamp["currentRedemptionPrice"], + beyond_clamp["currentRedemptionPrice"] + ); + assert_eq!(beyond_clamp["projectedAt"], u64::MAX.to_string()); + } + + #[test] + fn projection_preserves_global_validation_errors() { + let mut wrong_pda = request(1, FIXED_POINT_ONE, 1, 1, FIXED_POINT_ONE, 1, 1); + wrong_pda.protocol_parameters.id = account_id_hex(id(0xAA)); + assert_error(wrong_pda, "protocol_parameters_pda_mismatch"); + + let mut wrong_owner = request(1, FIXED_POINT_ONE, 1, 1, FIXED_POINT_ONE, 1, 1); + wrong_owner + .stability_fee_accumulator + .account + .as_mut() + .expect("fixture account") + .program_owner = hex::encode(program_id_bytes(OTHER_PROGRAM_ID)); + assert_error(wrong_owner, "stablecoin_program_mismatch"); + + let mut malformed = request(1, FIXED_POINT_ONE, 1, 1, FIXED_POINT_ONE, 1, 1); + malformed + .redemption_price_state + .account + .as_mut() + .expect("fixture account") + .data = String::from("00"); + assert_error(malformed, "invalid_redemption_price_state_data"); + } + + #[test] + fn projection_rejects_failed_or_noncanonical_clock_reads() { + let mut failed_read = request(1, FIXED_POINT_ONE, 1, 1, FIXED_POINT_ONE, 1, 1); + failed_read.clock.status = String::from("backend_error"); + assert_error(failed_read, "account_read_failed"); + + let mut wrong_id = request(1, FIXED_POINT_ONE, 1, 1, FIXED_POINT_ONE, 1, 1); + wrong_id.clock.id = account_id_hex(id(0xCC)); + assert_error(wrong_id, "invalid_clock"); + + let mut malformed = request(1, FIXED_POINT_ONE, 1, 1, FIXED_POINT_ONE, 1, 1); + malformed + .clock + .account + .as_mut() + .expect("fixture account") + .data = String::from("00"); + assert_error(malformed, "invalid_clock"); + } +} diff --git a/modules/stablecoin/ffi/src/api/request.rs b/modules/stablecoin/ffi/src/api/request.rs index 735fc006..f5de6510 100644 --- a/modules/stablecoin/ffi/src/api/request.rs +++ b/modules/stablecoin/ffi/src/api/request.rs @@ -33,6 +33,16 @@ pub struct DecodeRedemptionPriceStateRequest { pub redemption_price_state: AccountRead, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CurrentGlobalStateRequest { + pub stablecoin_program_id: String, + pub protocol_parameters: AccountRead, + pub stability_fee_accumulator: AccountRead, + pub redemption_price_state: AccountRead, + pub clock: AccountRead, +} + #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct InitializeProgramPlanRequest { diff --git a/modules/stablecoin/ffi/src/ffi.rs b/modules/stablecoin/ffi/src/ffi.rs index 48845543..2ebe5b0e 100644 --- a/modules/stablecoin/ffi/src/ffi.rs +++ b/modules/stablecoin/ffi/src/ffi.rs @@ -6,9 +6,9 @@ use std::{ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ - self, DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, - DecodeStabilityFeeAccumulatorRequest, InitializeProgramPlanRequest, ProgramInfoRequest, - StablecoinResult, + self, CurrentGlobalStateRequest, DecodeProtocolParametersRequest, + DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, + InitializeProgramPlanRequest, ProgramInfoRequest, StablecoinResult, }; #[derive(Serialize)] @@ -141,6 +141,18 @@ pub unsafe extern "C" fn stablecoin_decode_redemption_price_state( } } +#[unsafe(no_mangle)] +/// Validates stablecoin global accounts and projects their current values at `CLOCK_01`. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_current_global_state( + request_json: *const c_char, +) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { call::(request_json, api::current_global_state) } +} + #[unsafe(no_mangle)] /// Builds the exact wallet submission plan for `InitializeProgram`. /// @@ -212,6 +224,18 @@ mod tests { unsafe { assert_failure_response(response, "bad_request") }; } + #[test] + fn current_global_state_rejects_json_floats_at_the_boundary() { + let request = match CString::new(r#"{"stablecoinProgramId":1.5}"#) { + Ok(value) => value, + Err(error) => panic!("{error}"), + }; + // SAFETY: request is a live NUL-terminated CString for this call. + let response = unsafe { stablecoin_current_global_state(request.as_ptr()) }; + // SAFETY: response was returned by stablecoin_current_global_state 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. diff --git a/modules/stablecoin/ffi/src/lib.rs b/modules/stablecoin/ffi/src/lib.rs index 93e62db7..1e473956 100644 --- a/modules/stablecoin/ffi/src/lib.rs +++ b/modules/stablecoin/ffi/src/lib.rs @@ -7,9 +7,9 @@ pub mod api; pub use account::{AccountRead, WalletAccount}; pub use api::{ - decode_protocol_parameters, decode_redemption_price_state, decode_stability_fee_accumulator, - initialize_program_plan, program_info, DecodeProtocolParametersRequest, - DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, - InitializeProgramPlanRequest, ProgramInfoRequest, StablecoinApiError, StablecoinResponse, - StablecoinResult, + current_global_state, decode_protocol_parameters, decode_redemption_price_state, + decode_stability_fee_accumulator, initialize_program_plan, program_info, + CurrentGlobalStateRequest, DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, + 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 04eaa8d7..9b6b605f 100644 --- a/modules/stablecoin/ffi/tests/public_api.rs +++ b/modules/stablecoin/ffi/tests/public_api.rs @@ -1,8 +1,9 @@ use stablecoin_ffi::{ - decode_protocol_parameters, decode_redemption_price_state, decode_stability_fee_accumulator, - initialize_program_plan, program_info, DecodeProtocolParametersRequest, - DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, - InitializeProgramPlanRequest, ProgramInfoRequest, StablecoinResult, + current_global_state, decode_protocol_parameters, decode_redemption_price_state, + decode_stability_fee_accumulator, initialize_program_plan, program_info, + CurrentGlobalStateRequest, DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, + DecodeStabilityFeeAccumulatorRequest, InitializeProgramPlanRequest, ProgramInfoRequest, + StablecoinResult, }; #[test] @@ -14,5 +15,7 @@ fn crate_root_reexports_stablecoin_surface() { decode_stability_fee_accumulator; let _decode_redemption_state: fn(DecodeRedemptionPriceStateRequest) -> StablecoinResult = decode_redemption_price_state; + let _current_global_state: fn(CurrentGlobalStateRequest) -> StablecoinResult = + current_global_state; 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 85c8cdfd..bae60509 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.cpp +++ b/modules/stablecoin/src/stablecoin_module_impl.cpp @@ -332,6 +332,49 @@ LogosMap StablecoinModuleImpl::redemptionPriceState() { }); } +LogosMap StablecoinModuleImpl::currentGlobalState() { + return guarded([&]() -> LogosMap { + std::string error; + const json info = stablecoinProgramInfo(error); + if (!info.is_object()) return publicError(error.empty() ? "backend_error" : error); + + const json parameters = readPublicAccount(jsonString(info, "protocolParametersIdHex")); + const json accumulator = + readPublicAccount(jsonString(info, "stabilityFeeAccumulatorIdHex")); + const json redemption = readPublicAccount(jsonString(info, "redemptionPriceStateIdHex")); + const json clock = readPublicAccount(jsonString(info, "clockIdHex")); + + if (jsonString(parameters, "status") == "not_found" + || jsonString(accumulator, "status") == "not_found" + || jsonString(redemption, "status") == "not_found") { + return publicError("not_initialized"); + } + if (jsonString(parameters, "status") != "ok" + || jsonString(accumulator, "status") != "ok" + || jsonString(redemption, "status") != "ok" + || jsonString(clock, "status") != "ok") { + return publicError("account_read_failed"); + } + + const FfiResult projected = callStablecoin( + stablecoin_current_global_state, + { + {"stablecoinProgramId", info["programIdHex"]}, + {"protocolParameters", parameters}, + {"stabilityFeeAccumulator", accumulator}, + {"redemptionPriceState", redemption}, + {"clock", clock}, + }); + if (!projected.ok) { + return publicError(stablecoin_module::detail::stableFfiError(projected.error)); + } + + LogosMap result = publicOk(); + result["currentGlobalState"] = projected.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 519728b4..8092af9b 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.h +++ b/modules/stablecoin/src/stablecoin_module_impl.h @@ -32,6 +32,10 @@ class StablecoinModuleImpl : public LogosModuleContext { /// Returns stored controller state without projecting the current price. LogosMap redemptionPriceState(); + /// Reads all global state and projects the accumulator and redemption price + /// at the canonical CLOCK_01 timestamp. + LogosMap currentGlobalState(); + /// Initializes the stablecoin protocol. Request fields are `adminId`, /// `freezeAuthorityId`, `collateralDefinitionId`, `marketPriceOracleId`, /// `initialStabilityFeePerMillisecond`, diff --git a/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp index d384d12f..dba025d7 100644 --- a/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp +++ b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp @@ -39,6 +39,10 @@ extern "C" char* stablecoin_decode_redemption_price_state(const char*) { return copyMockResponse("stablecoin_decode_redemption_price_state"); } +extern "C" char* stablecoin_current_global_state(const char*) { + return copyMockResponse("stablecoin_current_global_state"); +} + extern "C" char* stablecoin_initialize_program_plan(const char*) { return copyMockResponse("stablecoin_initialize_program_plan"); } diff --git a/modules/stablecoin/tests/stablecoin_module_impl_test.cpp b/modules/stablecoin/tests/stablecoin_module_impl_test.cpp index 8322bb2d..92d9a837 100644 --- a/modules/stablecoin/tests/stablecoin_module_impl_test.cpp +++ b/modules/stablecoin/tests/stablecoin_module_impl_test.cpp @@ -18,7 +18,9 @@ using json = nlohmann::json; const std::string PROGRAM_ID_HEX(64, '1'); const std::string ACCUMULATOR_ID_HEX(64, '2'); +const std::string PROTOCOL_PARAMETERS_ID_HEX(64, '3'); const std::string REDEMPTION_STATE_ID_HEX(64, '4'); +const std::string CLOCK_ID_HEX(64, '7'); class ScopedEnvironment { public: @@ -55,7 +57,7 @@ json programInfoValue() { {"programId", "program-id"}, {"programIdHex", PROGRAM_ID_HEX}, {"protocolParametersId", "protocol-parameters-id"}, - {"protocolParametersIdHex", std::string(64, '3')}, + {"protocolParametersIdHex", PROTOCOL_PARAMETERS_ID_HEX}, {"stabilityFeeAccumulatorId", "stability-fee-accumulator-id"}, {"stabilityFeeAccumulatorIdHex", ACCUMULATOR_ID_HEX}, {"redemptionPriceStateId", "redemption-price-state-id"}, @@ -65,7 +67,7 @@ json programInfoValue() { {"stablecoinMasterHoldingId", "stablecoin-master-holding-id"}, {"stablecoinMasterHoldingIdHex", std::string(64, '6')}, {"clockId", "clock-id"}, - {"clockIdHex", std::string(64, '7')}, + {"clockIdHex", CLOCK_ID_HEX}, }; } @@ -296,3 +298,105 @@ LOGOS_TEST(redemption_price_state_preserves_stable_decoder_errors) { LOGOS_ASSERT_EQ( context.cFunctionCallCount("stablecoin_decode_redemption_price_state"), 1); } + +LOGOS_TEST(current_global_state_reads_all_sources_and_preserves_exact_projection) { + 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 projected = { + {"accumulatedRateAtLastAccrual", "340282366920938463463374607431768211455"}, + {"lastAccruedAt", "18446744073709551611"}, + {"redemptionPriceAtLastUpdate", "340282366920938463463374607431768211454"}, + {"lastUpdatedAt", "18446744073709551612"}, + {"currentAccumulatedRate", "340282366920938463463374607431768211453"}, + {"currentRedemptionPrice", "340282366920938463463374607431768211452"}, + {"projectedAt", "18446744073709551615"}, + }; + const std::string program_info_response = successEnvelope(programInfoValue()); + const std::string projection_response = successEnvelope(projected); + context.mockCFunction("stablecoin_program_info").returns(program_info_response); + context.mockCFunction("stablecoin_current_global_state").returns(projection_response); + context.mockModule("lez_core", "get_account_public").returns(initializedAccount()); + + const LogosMap response = module.currentGlobalState(); + + LOGOS_ASSERT_EQ(response["status"].get(), std::string("ok")); + LOGOS_ASSERT_EQ(response["error"].get(), std::string()); + LOGOS_ASSERT_EQ(response["currentGlobalState"], projected); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 4); + for (const auto& account_id : { + PROTOCOL_PARAMETERS_ID_HEX, + ACCUMULATOR_ID_HEX, + REDEMPTION_STATE_ID_HEX, + CLOCK_ID_HEX, + }) { + LOGOS_ASSERT_TRUE(context.moduleCalledWith( + "lez_core", + "get_account_public", + QVariantList{QVariant(QString::fromStdString(account_id))})); + } + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_current_global_state"), 1); +} + +LOGOS_TEST(current_global_state_maps_missing_globals_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.currentGlobalState(); + + assertError(response, "not_initialized"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 4); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_current_global_state"), 0); +} + +LOGOS_TEST(current_global_state_maps_malformed_reads_to_account_read_failed) { + 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.currentGlobalState(); + + assertError(response, "account_read_failed"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 4); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_current_global_state"), 0); +} + +LOGOS_TEST(current_global_state_preserves_stable_projection_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 projection_response = failureEnvelope("invalid_clock"); + context.mockCFunction("stablecoin_program_info").returns(program_info_response); + context.mockCFunction("stablecoin_current_global_state").returns(projection_response); + context.mockModule("lez_core", "get_account_public").returns(initializedAccount()); + + const LogosMap response = module.currentGlobalState(); + + assertError(response, "invalid_clock"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 4); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_current_global_state"), 1); +}