diff --git a/modules/stablecoin/README.md b/modules/stablecoin/README.md index be55838c..181247f3 100644 --- a/modules/stablecoin/README.md +++ b/modules/stablecoin/README.md @@ -66,6 +66,23 @@ The result contains `accumulatedRateAtLastAccrual`, `lastAccruedAt`, string. Projection uses saturating timestamp subtraction and the on-chain seven-day compounding-window clamp. The method accepts no caller-provided time. +### `redemptionRateUpdateQuote()` + +Reads Protocol Parameters, Redemption Price State, the configured market-price +oracle, and canonical `CLOCK_01` account, then quotes the next controller tick +without submitting a transaction. It projects the current redemption price +with the stored rate before calling the same pure controller used on-chain. + +Ready quotes return `canSubmit: true`, `code: "ready"`, the current redemption +and market prices, elapsed milliseconds, next redemption rate, next controller +integral term, and integral/rate clamp bounds. All numeric values are exact +decimal strings. + +A stale oracle, zero oracle price, or not-yet-due update returns a successful +read-only quote with `canSubmit: false`, `code: "blocked"`, machine-readable +`errors`, and explicit `null` next-controller values. Multiple blockers are +reported in on-chain gate order. The frozen flag does not block this operation. + ### `initializeProgram(request)` Required request fields: diff --git a/modules/stablecoin/ffi/include/stablecoin_ffi.h b/modules/stablecoin/ffi/include/stablecoin_ffi.h index 0cd6dd8d..d7c344a4 100644 --- a/modules/stablecoin/ffi/include/stablecoin_ffi.h +++ b/modules/stablecoin/ffi/include/stablecoin_ffi.h @@ -54,6 +54,14 @@ char *stablecoin_decode_redemption_price_state(const char *request_json); */ char *stablecoin_current_global_state(const char *request_json); +/** + * Quotes the next redemption-rate controller update without submitting it. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_redemption_rate_update_quote(const char *request_json); + /** * Builds the exact wallet submission plan for `InitializeProgram`. * diff --git a/modules/stablecoin/ffi/src/api/mod.rs b/modules/stablecoin/ffi/src/api/mod.rs index ded021ab..ea00facb 100644 --- a/modules/stablecoin/ffi/src/api/mod.rs +++ b/modules/stablecoin/ffi/src/api/mod.rs @@ -4,6 +4,7 @@ mod decode; mod plan; mod program; mod projection; +mod quote; mod request; #[cfg(test)] @@ -17,9 +18,11 @@ pub use decode::{ pub use plan::initialize_program_plan; pub use program::program_info; pub use projection::current_global_state; +pub use quote::redemption_rate_update_quote; pub use request::{ CurrentGlobalStateRequest, DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, InitializeProgramPlanRequest, ProgramInfoRequest, + RedemptionRateUpdateQuoteRequest, }; use serde_json::Value; diff --git a/modules/stablecoin/ffi/src/api/projection.rs b/modules/stablecoin/ffi/src/api/projection.rs index d22c150b..b18c03f8 100644 --- a/modules/stablecoin/ffi/src/api/projection.rs +++ b/modules/stablecoin/ffi/src/api/projection.rs @@ -49,7 +49,7 @@ pub fn current_global_state(request: CurrentGlobalStateRequest) -> StablecoinRes })) } -fn clock_timestamp(read: &crate::AccountRead) -> Result { +pub(super) 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 { diff --git a/modules/stablecoin/ffi/src/api/quote.rs b/modules/stablecoin/ffi/src/api/quote.rs new file mode 100644 index 00000000..fdf258c0 --- /dev/null +++ b/modules/stablecoin/ffi/src/api/quote.rs @@ -0,0 +1,637 @@ +use serde_json::{json, Value}; +use stablecoin_core::{ + math::compute_current_redemption_price, run_controller_tick, INTEGRAL_CLAMP, RATE_DELTA_CLAMP, +}; +use twap_oracle_core::OraclePriceAccount; + +use super::{ + decode::{validated_protocol_parameters, validated_redemption_price_state}, + parse_stablecoin_program_id, + projection::clock_timestamp, + RedemptionRateUpdateQuoteRequest, StablecoinApiError, StablecoinResult, +}; +use crate::account::decode_account; + +pub fn redemption_rate_update_quote(request: RedemptionRateUpdateQuoteRequest) -> 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 (_, redemption) = + validated_redemption_price_state(stablecoin_program_id, &request.redemption_price_state)?; + let oracle = validated_market_price_oracle( + &request.market_price_oracle, + parameters.market_price_oracle_id, + )?; + let now = clock_timestamp(&request.clock)?; + + let elapsed_milliseconds = now.saturating_sub(redemption.last_updated_at); + let oracle_age_milliseconds = now.saturating_sub(oracle.timestamp); + let current_redemption_price = compute_current_redemption_price( + redemption.redemption_price_at_last_update, + redemption.redemption_rate_per_millisecond, + redemption.last_updated_at, + now, + ); + + let mut errors = Vec::new(); + if oracle_age_milliseconds > parameters.maximum_oracle_price_age_milliseconds { + errors.push(blocker( + "oracle_stale", + json!({ + "oracleAgeMilliseconds": oracle_age_milliseconds.to_string(), + "maximumOraclePriceAgeMilliseconds": + parameters.maximum_oracle_price_age_milliseconds.to_string(), + }), + )); + } + if oracle.price == 0 { + errors.push(blocker("oracle_price_zero", json!({"marketPrice": "0"}))); + } + if elapsed_milliseconds < parameters.minimum_milliseconds_between_rate_updates { + errors.push(blocker( + "rate_update_too_soon", + json!({ + "elapsedMilliseconds": elapsed_milliseconds.to_string(), + "minimumMillisecondsBetweenRateUpdates": + parameters.minimum_milliseconds_between_rate_updates.to_string(), + }), + )); + } + + let can_submit = errors.is_empty(); + let (next_rate, next_integral) = if can_submit { + let output = run_controller_tick( + current_redemption_price, + oracle.price, + redemption.controller_integral_term, + parameters.controller_proportional_gain, + parameters.controller_integral_gain, + elapsed_milliseconds, + ); + ( + Value::String(output.redemption_rate_per_millisecond.to_string()), + Value::String(output.controller_integral_term.to_string()), + ) + } else { + (Value::Null, Value::Null) + }; + + Ok(json!({ + "canSubmit": can_submit, + "code": if can_submit { "ready" } else { "blocked" }, + "currentRedemptionPrice": current_redemption_price.to_string(), + "marketPrice": oracle.price.to_string(), + "elapsedMilliseconds": elapsed_milliseconds.to_string(), + "nextRedemptionRatePerMillisecond": next_rate, + "nextControllerIntegralTerm": next_integral, + "clampMetadata": { + "integralMinimum": (-INTEGRAL_CLAMP).to_string(), + "integralMaximum": INTEGRAL_CLAMP.to_string(), + "rateDeltaMinimum": (-RATE_DELTA_CLAMP).to_string(), + "rateDeltaMaximum": RATE_DELTA_CLAMP.to_string(), + }, + "errors": errors, + "warnings": [], + })) +} + +fn validated_market_price_oracle( + read: &crate::AccountRead, + expected_id: lee_core::account::AccountId, +) -> Result { + let (account_id, account) = + decode_account(read).map_err(|_| StablecoinApiError::new("account_read_failed"))?; + if account_id != expected_id { + return Err(StablecoinApiError::new("market_price_oracle_mismatch")); + } + OraclePriceAccount::try_from(&account.data) + .map_err(|_| StablecoinApiError::new("invalid_market_price_oracle")) +} + +fn blocker(code: &'static str, details: Value) -> Value { + json!({ + "code": code, + "recoverable": true, + "blockingFields": [], + "details": details, + }) +} + +#[cfg(test)] +mod tests { + use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; + use lee_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, + }; + use stablecoin_core::{ + compute_protocol_parameters_pda, compute_redemption_price_state_pda, + math::{FIXED_POINT_ONE, MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS}, + ProtocolParameters, RedemptionPriceState, + }; + + use super::*; + use crate::account::{account_id_hex, account_read, program_id_bytes}; + + const STABLECOIN_PROGRAM_ID: ProgramId = [0x11_u32; 8]; + const ORACLE_PROGRAM_ID: ProgramId = [0x22_u32; 8]; + const CLOCK_PROGRAM_ID: ProgramId = [0x33_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 parameters(oracle_id: AccountId) -> 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: oracle_id, + stability_fee_per_millisecond: FIXED_POINT_ONE, + controller_proportional_gain: FIXED_POINT_ONE as i128, + 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, + } + } + + fn redemption(price: u128, integral: i128, last_updated_at: u64) -> RedemptionPriceState { + RedemptionPriceState { + redemption_price_at_last_update: price, + redemption_rate_per_millisecond: FIXED_POINT_ONE, + controller_integral_term: integral, + last_updated_at, + } + } + + fn oracle(oracle_id: AccountId, price: u128, timestamp: u64) -> (AccountId, Account) { + ( + oracle_id, + account( + ORACLE_PROGRAM_ID, + Data::from(&OraclePriceAccount { + base_asset: id(8), + quote_asset: id(9), + price, + timestamp, + source_id: id(10), + confidence_interval: 0, + }), + ), + ) + } + + fn request( + parameters: &ProtocolParameters, + redemption: &RedemptionPriceState, + oracle: &(AccountId, Account), + now: u64, + ) -> RedemptionRateUpdateQuoteRequest { + let clock = ClockAccountData { + block_id: 1, + timestamp: now, + }; + RedemptionRateUpdateQuoteRequest { + 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(parameters)), + ), + redemption_price_state: account_read( + compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID), + &account(STABLECOIN_PROGRAM_ID, Data::from(redemption)), + ), + market_price_oracle: account_read(oracle.0, &oracle.1), + clock: account_read( + CLOCK_01_PROGRAM_ACCOUNT_ID, + &account( + CLOCK_PROGRAM_ID, + Data::try_from(clock.to_bytes()).expect("clock data fits"), + ), + ), + } + } + + fn value(request: RedemptionRateUpdateQuoteRequest) -> Value { + redemption_rate_update_quote(request).expect("quote succeeds") + } + + fn assert_error(request: RedemptionRateUpdateQuoteRequest, expected: &str) { + let error = redemption_rate_update_quote(request).expect_err("quote must fail"); + assert_eq!(error.code(), expected); + } + + #[test] + fn ready_quotes_match_controller_for_all_error_and_gain_modes() { + let oracle_id = id(5); + let cases = [ + ( + FIXED_POINT_ONE * 2, + FIXED_POINT_ONE, + 0, + FIXED_POINT_ONE as i128, + 0, + 10, + ), + ( + FIXED_POINT_ONE, + FIXED_POINT_ONE, + 0, + FIXED_POINT_ONE as i128, + 0, + 10, + ), + ( + FIXED_POINT_ONE, + FIXED_POINT_ONE * 2, + 0, + FIXED_POINT_ONE as i128, + 0, + 10, + ), + ( + FIXED_POINT_ONE * 2, + FIXED_POINT_ONE, + 17, + (FIXED_POINT_ONE / 10) as i128, + (FIXED_POINT_ONE / 100) as i128, + 10, + ), + ( + FIXED_POINT_ONE, + FIXED_POINT_ONE, + INTEGRAL_CLAMP / 4, + 0, + FIXED_POINT_ONE as i128, + 10, + ), + ]; + + for (price, market_price, integral, proportional_gain, integral_gain, elapsed) in cases { + let mut params = parameters(oracle_id); + params.controller_proportional_gain = proportional_gain; + params.controller_integral_gain = integral_gain; + let state = redemption(price, integral, 100); + let observation = oracle(oracle_id, market_price, 100 + elapsed); + let quoted = value(request(¶ms, &state, &observation, 100 + elapsed)); + let expected = run_controller_tick( + price, + market_price, + integral, + proportional_gain, + integral_gain, + elapsed, + ); + + assert_eq!(quoted["canSubmit"], true); + assert_eq!(quoted["code"], "ready"); + assert_eq!(quoted["currentRedemptionPrice"], price.to_string()); + assert_eq!(quoted["marketPrice"], market_price.to_string()); + assert_eq!(quoted["elapsedMilliseconds"], elapsed.to_string()); + assert_eq!( + quoted["nextRedemptionRatePerMillisecond"], + expected.redemption_rate_per_millisecond.to_string() + ); + assert_eq!( + quoted["nextControllerIntegralTerm"], + expected.controller_integral_term.to_string() + ); + assert_eq!(quoted["errors"], json!([])); + assert_eq!(quoted["warnings"], json!([])); + } + } + + #[test] + fn quotes_match_both_integral_and_rate_clamps_and_bounded_extremes() { + let oracle_id = id(5); + let cases = [ + ( + FIXED_POINT_ONE * 2, + FIXED_POINT_ONE, + 0, + 0, + FIXED_POINT_ONE as i128, + 1_000_001, + ), + ( + FIXED_POINT_ONE, + FIXED_POINT_ONE * 2, + 0, + 0, + FIXED_POINT_ONE as i128, + 1_000_001, + ), + ( + FIXED_POINT_ONE * 2, + FIXED_POINT_ONE, + 0, + (FIXED_POINT_ONE as i128) * 1_000, + 0, + 1, + ), + ( + FIXED_POINT_ONE, + FIXED_POINT_ONE * 2, + 0, + (FIXED_POINT_ONE as i128) * 1_000, + 0, + 1, + ), + ( + u128::MAX / 2, + 1, + INTEGRAL_CLAMP, + (FIXED_POINT_ONE as i128) * 1_000, + FIXED_POINT_ONE as i128, + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS, + ), + ]; + + for (price, market_price, integral, proportional_gain, integral_gain, elapsed) in cases { + let mut params = parameters(oracle_id); + params.controller_proportional_gain = proportional_gain; + params.controller_integral_gain = integral_gain; + params.maximum_oracle_price_age_milliseconds = elapsed; + let state = redemption(price, integral, 0); + let observation = oracle(oracle_id, market_price, elapsed); + let quoted = value(request(¶ms, &state, &observation, elapsed)); + let expected = run_controller_tick( + price, + market_price, + integral, + proportional_gain, + integral_gain, + elapsed, + ); + + assert_eq!( + quoted["nextRedemptionRatePerMillisecond"], + expected.redemption_rate_per_millisecond.to_string() + ); + assert_eq!( + quoted["nextControllerIntegralTerm"], + expected.controller_integral_term.to_string() + ); + } + } + + #[test] + fn quote_uses_raw_elapsed_for_controller_after_clamped_price_projection() { + let oracle_id = id(5); + let elapsed = MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS + 123; + let mut params = parameters(oracle_id); + params.controller_proportional_gain = 0; + params.controller_integral_gain = FIXED_POINT_ONE as i128; + let price = FIXED_POINT_ONE + 1; + let state = redemption(price, 0, 0); + let observation = oracle(oracle_id, FIXED_POINT_ONE, elapsed); + + let quoted = value(request(¶ms, &state, &observation, elapsed)); + let expected = run_controller_tick( + price, + FIXED_POINT_ONE, + 0, + 0, + FIXED_POINT_ONE as i128, + elapsed, + ); + + assert_eq!(quoted["elapsedMilliseconds"], elapsed.to_string()); + assert_eq!( + quoted["nextControllerIntegralTerm"], + expected.controller_integral_term.to_string() + ); + assert_ne!( + quoted["nextControllerIntegralTerm"], + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS.to_string() + ); + } + + #[test] + fn gate_boundaries_and_combined_blockers_match_host_order() { + let oracle_id = id(5); + let mut params = parameters(oracle_id); + params.minimum_milliseconds_between_rate_updates = 10; + params.maximum_oracle_price_age_milliseconds = 20; + + let exact_state = redemption(FIXED_POINT_ONE, 0, 90); + let exact_observation = oracle(oracle_id, FIXED_POINT_ONE, 80); + assert_eq!( + value(request(¶ms, &exact_state, &exact_observation, 100))["canSubmit"], + true + ); + + let early = redemption(FIXED_POINT_ONE, 0, 91); + let early_quote = value(request(¶ms, &early, &exact_observation, 100)); + assert_eq!(early_quote["errors"][0]["code"], "rate_update_too_soon"); + assert_eq!( + early_quote["errors"][0]["details"], + json!({ + "elapsedMilliseconds": "9", + "minimumMillisecondsBetweenRateUpdates": "10", + }) + ); + + let stale = oracle(oracle_id, FIXED_POINT_ONE, 79); + let stale_quote = value(request(¶ms, &exact_state, &stale, 100)); + assert_eq!(stale_quote["errors"][0]["code"], "oracle_stale"); + assert_eq!( + stale_quote["errors"][0]["details"], + json!({ + "oracleAgeMilliseconds": "21", + "maximumOraclePriceAgeMilliseconds": "20", + }) + ); + + let zero = oracle(oracle_id, 0, 100); + let zero_quote = value(request(¶ms, &exact_state, &zero, 100)); + assert_eq!(zero_quote["errors"][0]["code"], "oracle_price_zero"); + assert_eq!( + zero_quote["errors"][0]["details"], + json!({"marketPrice": "0"}) + ); + + let combined = oracle(oracle_id, 0, 79); + let combined_quote = value(request(¶ms, &early, &combined, 100)); + assert_eq!(combined_quote["canSubmit"], false); + assert_eq!(combined_quote["code"], "blocked"); + assert_eq!( + combined_quote["errors"] + .as_array() + .expect("errors are an array") + .iter() + .map(|error| error["code"].as_str().expect("code is a string")) + .collect::>(), + vec!["oracle_stale", "oracle_price_zero", "rate_update_too_soon"] + ); + for error in combined_quote["errors"] + .as_array() + .expect("errors are an array") + { + assert_eq!(error["recoverable"], true); + assert_eq!(error["blockingFields"], json!([])); + } + assert!(combined_quote["nextRedemptionRatePerMillisecond"].is_null()); + assert!(combined_quote["nextControllerIntegralTerm"].is_null()); + } + + #[test] + fn inverted_timestamps_saturate_and_frozen_state_does_not_block() { + let oracle_id = id(5); + let params = parameters(oracle_id); + let future_state = redemption(FIXED_POINT_ONE, 0, 200); + let future_observation = oracle(oracle_id, FIXED_POINT_ONE, 300); + let inverted = value(request(¶ms, &future_state, &future_observation, 100)); + assert_eq!(inverted["elapsedMilliseconds"], "0"); + assert_eq!(inverted["errors"][0]["code"], "rate_update_too_soon"); + + let mut frozen = params; + frozen.is_frozen = true; + let due_state = redemption(FIXED_POINT_ONE, 0, 99); + let current_observation = oracle(oracle_id, FIXED_POINT_ONE, 100); + assert_eq!( + value(request(&frozen, &due_state, ¤t_observation, 100))["canSubmit"], + true + ); + } + + #[test] + fn response_preserves_exact_clamp_strings() { + let oracle_id = id(5); + let params = parameters(oracle_id); + let state = redemption(FIXED_POINT_ONE, 0, 0); + let observation = oracle(oracle_id, FIXED_POINT_ONE, 1); + let quoted = value(request(¶ms, &state, &observation, 1)); + + assert_eq!( + quoted["clampMetadata"], + json!({ + "integralMinimum": (-INTEGRAL_CLAMP).to_string(), + "integralMaximum": INTEGRAL_CLAMP.to_string(), + "rateDeltaMinimum": (-RATE_DELTA_CLAMP).to_string(), + "rateDeltaMaximum": RATE_DELTA_CLAMP.to_string(), + }) + ); + for key in [ + "currentRedemptionPrice", + "marketPrice", + "elapsedMilliseconds", + "nextRedemptionRatePerMillisecond", + "nextControllerIntegralTerm", + ] { + assert!(quoted[key].is_string(), "{key} must remain an exact string"); + } + } + + #[test] + fn quote_validates_global_oracle_and_clock_inputs() { + let oracle_id = id(5); + let params = parameters(oracle_id); + let state = redemption(FIXED_POINT_ONE, 0, 0); + let observation = oracle(oracle_id, FIXED_POINT_ONE, 1); + let base = request(¶ms, &state, &observation, 1); + + let mut invalid_program = base.clone(); + invalid_program.stablecoin_program_id = String::from("not-a-program-id"); + assert_error(invalid_program, "invalid_program_id"); + + let mut wrong_parameters_pda = base.clone(); + wrong_parameters_pda.protocol_parameters.id = account_id_hex(id(20)); + assert_error(wrong_parameters_pda, "protocol_parameters_pda_mismatch"); + + let mut wrong_parameters_owner = base.clone(); + wrong_parameters_owner + .protocol_parameters + .account + .as_mut() + .expect("account exists") + .program_owner = hex::encode(program_id_bytes(ORACLE_PROGRAM_ID)); + assert_error(wrong_parameters_owner, "stablecoin_program_mismatch"); + + let mut malformed_parameters = base.clone(); + malformed_parameters + .protocol_parameters + .account + .as_mut() + .expect("account exists") + .data = String::from("00"); + assert_error(malformed_parameters, "invalid_protocol_parameters_data"); + + let mut wrong_redemption_pda = base.clone(); + wrong_redemption_pda.redemption_price_state.id = account_id_hex(id(21)); + assert_error(wrong_redemption_pda, "redemption_price_state_pda_mismatch"); + + let mut wrong_redemption_owner = base.clone(); + wrong_redemption_owner + .redemption_price_state + .account + .as_mut() + .expect("account exists") + .program_owner = hex::encode(program_id_bytes(ORACLE_PROGRAM_ID)); + assert_error(wrong_redemption_owner, "stablecoin_program_mismatch"); + + let mut malformed_redemption = base.clone(); + malformed_redemption + .redemption_price_state + .account + .as_mut() + .expect("account exists") + .data = String::from("00"); + assert_error(malformed_redemption, "invalid_redemption_price_state_data"); + + let mut wrong_oracle_id = base.clone(); + wrong_oracle_id.market_price_oracle.id = account_id_hex(id(22)); + assert_error(wrong_oracle_id, "market_price_oracle_mismatch"); + + let mut malformed_oracle = base.clone(); + malformed_oracle + .market_price_oracle + .account + .as_mut() + .expect("account exists") + .data = String::from("00"); + assert_error(malformed_oracle, "invalid_market_price_oracle"); + + let mut failed_read = base.clone(); + failed_read.market_price_oracle.status = String::from("not_found"); + failed_read.market_price_oracle.account = None; + assert_error(failed_read, "account_read_failed"); + + let mut wrong_clock_id = base.clone(); + wrong_clock_id.clock.id = account_id_hex(id(23)); + assert_error(wrong_clock_id, "invalid_clock"); + + let mut malformed_clock = base; + malformed_clock + .clock + .account + .as_mut() + .expect("account exists") + .data = String::from("00"); + assert_error(malformed_clock, "invalid_clock"); + } + + #[test] + fn oracle_owner_and_asset_pair_are_deliberately_not_pinned() { + let oracle_id = id(5); + let params = parameters(oracle_id); + let state = redemption(FIXED_POINT_ONE, 0, 0); + let mut observation = oracle(oracle_id, FIXED_POINT_ONE, 1); + observation.1.program_owner = [0xFF_u32; 8]; + + let quoted = value(request(¶ms, &state, &observation, 1)); + assert_eq!(quoted["canSubmit"], true); + } +} diff --git a/modules/stablecoin/ffi/src/api/request.rs b/modules/stablecoin/ffi/src/api/request.rs index f5de6510..3797b189 100644 --- a/modules/stablecoin/ffi/src/api/request.rs +++ b/modules/stablecoin/ffi/src/api/request.rs @@ -43,6 +43,16 @@ pub struct CurrentGlobalStateRequest { pub clock: AccountRead, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RedemptionRateUpdateQuoteRequest { + pub stablecoin_program_id: String, + pub protocol_parameters: AccountRead, + pub redemption_price_state: AccountRead, + pub market_price_oracle: 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 2ebe5b0e..4ed37f62 100644 --- a/modules/stablecoin/ffi/src/ffi.rs +++ b/modules/stablecoin/ffi/src/ffi.rs @@ -8,7 +8,8 @@ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ self, CurrentGlobalStateRequest, DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, - InitializeProgramPlanRequest, ProgramInfoRequest, StablecoinResult, + InitializeProgramPlanRequest, ProgramInfoRequest, RedemptionRateUpdateQuoteRequest, + StablecoinResult, }; #[derive(Serialize)] @@ -153,6 +154,20 @@ pub unsafe extern "C" fn stablecoin_current_global_state( unsafe { call::(request_json, api::current_global_state) } } +#[unsafe(no_mangle)] +/// Quotes the next redemption-rate controller update without submitting it. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_redemption_rate_update_quote( + request_json: *const c_char, +) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { + call::(request_json, api::redemption_rate_update_quote) + } +} + #[unsafe(no_mangle)] /// Builds the exact wallet submission plan for `InitializeProgram`. /// @@ -236,6 +251,20 @@ mod tests { unsafe { assert_failure_response(response, "bad_request") }; } + #[test] + fn redemption_rate_quote_rejects_malformed_and_float_requests_at_the_boundary() { + for input in ["{", r#"{"stablecoinProgramId":1.5}"#] { + let request = match CString::new(input) { + Ok(value) => value, + Err(error) => panic!("{error}"), + }; + // SAFETY: request is a live NUL-terminated CString for this call. + let response = unsafe { stablecoin_redemption_rate_update_quote(request.as_ptr()) }; + // SAFETY: response came from the quote operation 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 1e473956..7c2040a2 100644 --- a/modules/stablecoin/ffi/src/lib.rs +++ b/modules/stablecoin/ffi/src/lib.rs @@ -9,7 +9,8 @@ pub use account::{AccountRead, WalletAccount}; pub use api::{ 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, + redemption_rate_update_quote, CurrentGlobalStateRequest, DecodeProtocolParametersRequest, + DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, + InitializeProgramPlanRequest, ProgramInfoRequest, RedemptionRateUpdateQuoteRequest, StablecoinApiError, StablecoinResponse, StablecoinResult, }; diff --git a/modules/stablecoin/ffi/tests/public_api.rs b/modules/stablecoin/ffi/tests/public_api.rs index 9b6b605f..621e75f6 100644 --- a/modules/stablecoin/ffi/tests/public_api.rs +++ b/modules/stablecoin/ffi/tests/public_api.rs @@ -1,8 +1,9 @@ use stablecoin_ffi::{ 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, + redemption_rate_update_quote, CurrentGlobalStateRequest, DecodeProtocolParametersRequest, + DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, + InitializeProgramPlanRequest, ProgramInfoRequest, RedemptionRateUpdateQuoteRequest, StablecoinResult, }; @@ -17,5 +18,7 @@ fn crate_root_reexports_stablecoin_surface() { decode_redemption_price_state; let _current_global_state: fn(CurrentGlobalStateRequest) -> StablecoinResult = current_global_state; + let _redemption_rate_quote: fn(RedemptionRateUpdateQuoteRequest) -> StablecoinResult = + redemption_rate_update_quote; 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 bae60509..55a96afa 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.cpp +++ b/modules/stablecoin/src/stablecoin_module_impl.cpp @@ -375,6 +375,66 @@ LogosMap StablecoinModuleImpl::currentGlobalState() { }); } +LogosMap StablecoinModuleImpl::redemptionRateUpdateQuote() { + 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 std::string parameters_status = jsonString(parameters, "status"); + if (parameters_status == "not_found") return publicError("not_initialized"); + if (parameters_status != "ok") return publicError("account_read_failed"); + + const FfiResult decoded_parameters = callStablecoin( + stablecoin_decode_protocol_parameters, + { + {"stablecoinProgramId", info["programIdHex"]}, + {"protocolParameters", parameters}, + }); + if (!decoded_parameters.ok) { + return publicError( + stablecoin_module::detail::stableFfiError(decoded_parameters.error)); + } + const std::string oracle_id = + jsonString(decoded_parameters.value, "marketPriceOracleIdHex"); + if (!stablecoin_module::detail::isValidAccountIdHex(oracle_id)) { + return publicError("backend_error"); + } + + const json redemption = readPublicAccount(jsonString(info, "redemptionPriceStateIdHex")); + const json oracle = readPublicAccount(oracle_id); + const json clock = readPublicAccount(jsonString(info, "clockIdHex")); + + if (jsonString(redemption, "status") == "not_found") { + return publicError("not_initialized"); + } + if (jsonString(redemption, "status") != "ok" + || jsonString(oracle, "status") != "ok" + || jsonString(clock, "status") != "ok") { + return publicError("account_read_failed"); + } + + const FfiResult quoted = callStablecoin( + stablecoin_redemption_rate_update_quote, + { + {"stablecoinProgramId", info["programIdHex"]}, + {"protocolParameters", parameters}, + {"redemptionPriceState", redemption}, + {"marketPriceOracle", oracle}, + {"clock", clock}, + }); + if (!quoted.ok) { + return publicError(stablecoin_module::detail::stableFfiError(quoted.error)); + } + + LogosMap result = quoted.value; + result["status"] = "ok"; + result["error"] = ""; + 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 8092af9b..f14d1cb6 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.h +++ b/modules/stablecoin/src/stablecoin_module_impl.h @@ -36,6 +36,11 @@ class StablecoinModuleImpl : public LogosModuleContext { /// at the canonical CLOCK_01 timestamp. LogosMap currentGlobalState(); + /// Quotes the next redemption-rate controller tick from live protocol, + /// redemption-price, configured oracle, and CLOCK_01 state. Never submits + /// a transaction; soft gates return `canSubmit: false` with blockers. + LogosMap redemptionRateUpdateQuote(); + /// 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 0c1e9258..df2236a9 100644 --- a/modules/stablecoin/src/stablecoin_module_support.cpp +++ b/modules/stablecoin/src/stablecoin_module_support.cpp @@ -168,6 +168,7 @@ std::string stableFfiError(const std::string& error) { "invalid_redemption_price_state_data", "invalid_stability_fee_accumulator_data", "invalid_stablecoin_name", + "market_price_oracle_mismatch", "oracle_asset_mismatch", "program_id_mismatch", "protocol_parameters_pda_mismatch", diff --git a/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp index dba025d7..610410a7 100644 --- a/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp +++ b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp @@ -43,6 +43,10 @@ extern "C" char* stablecoin_current_global_state(const char*) { return copyMockResponse("stablecoin_current_global_state"); } +extern "C" char* stablecoin_redemption_rate_update_quote(const char*) { + return copyMockResponse("stablecoin_redemption_rate_update_quote"); +} + 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 92d9a837..5e3f3473 100644 --- a/modules/stablecoin/tests/stablecoin_module_impl_test.cpp +++ b/modules/stablecoin/tests/stablecoin_module_impl_test.cpp @@ -20,6 +20,7 @@ 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 ORACLE_ID_HEX(64, '8'); const std::string CLOCK_ID_HEX(64, '7'); class ScopedEnvironment { @@ -400,3 +401,161 @@ LOGOS_TEST(current_global_state_preserves_stable_projection_errors) { LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 4); LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_current_global_state"), 1); } + +LOGOS_TEST(redemption_rate_update_quote_reads_configured_sources_and_returns_flat_quote) { + 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_parameters = {{"marketPriceOracleIdHex", ORACLE_ID_HEX}}; + const json quote = { + {"canSubmit", true}, + {"code", "ready"}, + {"currentRedemptionPrice", "1000000000000000000000000000"}, + {"marketPrice", "900000000000000000000000000"}, + {"elapsedMilliseconds", "300000"}, + {"nextRedemptionRatePerMillisecond", "1000010000000000000000000000"}, + {"nextControllerIntegralTerm", "42"}, + {"clampMetadata", { + {"integralMinimum", "-1000000000000000000000000000000000"}, + {"integralMaximum", "1000000000000000000000000000000000"}, + {"rateDeltaMinimum", "-10000000000000000000000"}, + {"rateDeltaMaximum", "10000000000000000000000"}, + }}, + {"errors", json::array()}, + {"warnings", json::array()}, + }; + const std::string program_info_response = successEnvelope(programInfoValue()); + const std::string decoder_response = successEnvelope(decoded_parameters); + const std::string quote_response = successEnvelope(quote); + context.mockCFunction("stablecoin_program_info") + .returns(program_info_response); + context.mockCFunction("stablecoin_decode_protocol_parameters") + .returns(decoder_response); + context.mockCFunction("stablecoin_redemption_rate_update_quote") + .returns(quote_response); + context.mockModule("lez_core", "get_account_public").returns(initializedAccount()); + + const LogosMap response = module.redemptionRateUpdateQuote(); + + LOGOS_ASSERT_EQ(response["status"].get(), std::string("ok")); + LOGOS_ASSERT_EQ(response["error"].get(), std::string()); + for (auto field = quote.begin(); field != quote.end(); ++field) { + LOGOS_ASSERT_EQ(response[field.key()], field.value()); + } + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 4); + for (const auto& account_id : { + PROTOCOL_PARAMETERS_ID_HEX, + REDEMPTION_STATE_ID_HEX, + ORACLE_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_decode_protocol_parameters"), 1); + LOGOS_ASSERT_EQ( + context.cFunctionCallCount("stablecoin_redemption_rate_update_quote"), 1); + LOGOS_ASSERT_EQ( + context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); +} + +LOGOS_TEST(redemption_rate_update_quote_keeps_soft_blockers_as_success) { + 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_parameters = {{"marketPriceOracleIdHex", ORACLE_ID_HEX}}; + const json blocker = { + {"code", "oracle_price_zero"}, + {"recoverable", true}, + {"blockingFields", json::array()}, + {"details", {{"marketPrice", "0"}}}, + }; + const json quote = { + {"canSubmit", false}, + {"code", "blocked"}, + {"currentRedemptionPrice", "100"}, + {"marketPrice", "0"}, + {"elapsedMilliseconds", "9"}, + {"nextRedemptionRatePerMillisecond", nullptr}, + {"nextControllerIntegralTerm", nullptr}, + {"clampMetadata", json::object()}, + {"errors", json::array({blocker})}, + {"warnings", json::array()}, + }; + const std::string program_info_response = successEnvelope(programInfoValue()); + const std::string decoder_response = successEnvelope(decoded_parameters); + const std::string quote_response = successEnvelope(quote); + context.mockCFunction("stablecoin_program_info") + .returns(program_info_response); + context.mockCFunction("stablecoin_decode_protocol_parameters") + .returns(decoder_response); + context.mockCFunction("stablecoin_redemption_rate_update_quote") + .returns(quote_response); + context.mockModule("lez_core", "get_account_public").returns(initializedAccount()); + + const LogosMap response = module.redemptionRateUpdateQuote(); + + LOGOS_ASSERT_EQ(response["status"].get(), std::string("ok")); + LOGOS_ASSERT_EQ(response["canSubmit"].get(), false); + LOGOS_ASSERT_TRUE(response["nextRedemptionRatePerMillisecond"].is_null()); + LOGOS_ASSERT_TRUE(response["nextControllerIntegralTerm"].is_null()); + LOGOS_ASSERT_EQ( + context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); +} + +LOGOS_TEST(redemption_rate_update_quote_maps_missing_globals_and_hard_ffi_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()); + context.mockCFunction("stablecoin_program_info") + .returns(program_info_response); + context.mockModule("lez_core", "get_account_public").returns(""); + + assertError(module.redemptionRateUpdateQuote(), "not_initialized"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 1); + LOGOS_ASSERT_EQ( + context.cFunctionCallCount("stablecoin_decode_protocol_parameters"), 0); + } + + { + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + const json decoded_parameters = {{"marketPriceOracleIdHex", ORACLE_ID_HEX}}; + const std::string program_info_response = successEnvelope(programInfoValue()); + const std::string decoder_response = successEnvelope(decoded_parameters); + const std::string quote_response = + failureEnvelope("market_price_oracle_mismatch"); + context.mockCFunction("stablecoin_program_info") + .returns(program_info_response); + context.mockCFunction("stablecoin_decode_protocol_parameters") + .returns(decoder_response); + context.mockCFunction("stablecoin_redemption_rate_update_quote") + .returns(quote_response); + context.mockModule("lez_core", "get_account_public").returns(initializedAccount()); + + assertError(module.redemptionRateUpdateQuote(), "market_price_oracle_mismatch"); + LOGOS_ASSERT_EQ( + context.cFunctionCallCount("stablecoin_redemption_rate_update_quote"), 1); + LOGOS_ASSERT_EQ( + context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } +}