diff --git a/modules/stablecoin/README.md b/modules/stablecoin/README.md index 181247f3..953a303b 100644 --- a/modules/stablecoin/README.md +++ b/modules/stablecoin/README.md @@ -83,6 +83,31 @@ 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. +### Permissionless maintenance transactions + +`accrueStabilityFee(callerId)`, `updateRedemptionRate(callerId)`, and +`refreshGlobals(callerId)` submit permissionless protocol-maintenance +transactions. `callerId` accepts base58 or 64-character hexadecimal form, must +be a public account controlled by the connected wallet, and is the sole signer. +Success adds `transactionId` to the standard response envelope. + +The module reads live protocol state, derives every singleton account and the +canonical `CLOCK_01` account internally, then submits these exact account +orders: + +| Method | Accounts | +| --- | --- | +| `accrueStabilityFee` | caller, Protocol Parameters, Stability Fee Accumulator, `CLOCK_01` | +| `updateRedemptionRate` | caller, Protocol Parameters, Redemption Price State, configured market-price oracle, `CLOCK_01` | +| `refreshGlobals` | caller, Protocol Parameters, Stability Fee Accumulator, Redemption Price State, configured market-price oracle, `CLOCK_01` | + +`updateRedemptionRate` runs the live quote preflight and does not submit when +the first on-chain gate is `oracle_stale`, `oracle_price_zero`, or +`rate_update_too_soon`. `refreshGlobals` intentionally submits under those soft +gates: its fee-accrual half still runs while the on-chain instruction may skip +the controller update. A frozen protocol does not block any of the three +maintenance methods. + ### `initializeProgram(request)` Required request fields: diff --git a/modules/stablecoin/ffi/include/stablecoin_ffi.h b/modules/stablecoin/ffi/include/stablecoin_ffi.h index d7c344a4..17626aba 100644 --- a/modules/stablecoin/ffi/include/stablecoin_ffi.h +++ b/modules/stablecoin/ffi/include/stablecoin_ffi.h @@ -62,6 +62,30 @@ char *stablecoin_current_global_state(const char *request_json); */ char *stablecoin_redemption_rate_update_quote(const char *request_json); +/** + * Builds the exact wallet submission plan for `AccrueStabilityFee`. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_accrue_stability_fee_plan(const char *request_json); + +/** + * Builds a preflighted wallet submission plan for `UpdateRedemptionRate`. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_update_redemption_rate_plan(const char *request_json); + +/** + * Builds the best-effort wallet submission plan for `RefreshGlobals`. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_refresh_globals_plan(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 ea00facb..0bcb0035 100644 --- a/modules/stablecoin/ffi/src/api/mod.rs +++ b/modules/stablecoin/ffi/src/api/mod.rs @@ -15,14 +15,18 @@ use std::{error::Error, fmt}; pub use decode::{ decode_protocol_parameters, decode_redemption_price_state, decode_stability_fee_accumulator, }; -pub use plan::initialize_program_plan; +pub use plan::{ + accrue_stability_fee_plan, initialize_program_plan, refresh_globals_plan, + update_redemption_rate_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, + AccrueStabilityFeePlanRequest, CurrentGlobalStateRequest, DecodeProtocolParametersRequest, + DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, + InitializeProgramPlanRequest, ProgramInfoRequest, RedemptionRateUpdateQuoteRequest, + RefreshGlobalsPlanRequest, UpdateRedemptionRatePlanRequest, }; use serde_json::Value; diff --git a/modules/stablecoin/ffi/src/api/plan.rs b/modules/stablecoin/ffi/src/api/plan.rs index 0922adac..69b2f36e 100644 --- a/modules/stablecoin/ffi/src/api/plan.rs +++ b/modules/stablecoin/ffi/src/api/plan.rs @@ -11,7 +11,16 @@ use token_core::TokenDefinition; use twap_oracle_core::OraclePriceAccount; use super::{ - parse_stablecoin_program_id, InitializeProgramPlanRequest, StablecoinApiError, StablecoinResult, + decode::{ + validated_protocol_parameters, validated_redemption_price_state, + validated_stability_fee_accumulator, + }, + parse_stablecoin_program_id, + projection::clock_timestamp, + quote::{redemption_rate_update_quote, validated_market_price_oracle}, + AccrueStabilityFeePlanRequest, InitializeProgramPlanRequest, RedemptionRateUpdateQuoteRequest, + RefreshGlobalsPlanRequest, StablecoinApiError, StablecoinResult, + UpdateRedemptionRatePlanRequest, }; use crate::account::{ account_id_from_hex, account_id_hex, decode_account, program_id_bytes, AccountRead, @@ -95,6 +104,101 @@ pub fn initialize_program_plan(request: InitializeProgramPlanRequest) -> Stablec ) } +pub fn accrue_stability_fee_plan(request: AccrueStabilityFeePlanRequest) -> StablecoinResult { + let program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; + let caller = parse_account_id(&request.caller_id)?; + validated_protocol_parameters(program_id, &request.protocol_parameters)?; + validated_stability_fee_accumulator(program_id, &request.stability_fee_accumulator)?; + clock_timestamp(&request.clock)?; + + plan_response( + program_id, + [ + caller, + compute_protocol_parameters_pda(program_id), + compute_stability_fee_accumulator_pda(program_id), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + [true, false, false, false], + Instruction::AccrueStabilityFee, + ) +} + +pub fn update_redemption_rate_plan(request: UpdateRedemptionRatePlanRequest) -> StablecoinResult { + let program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; + let caller = parse_account_id(&request.caller_id)?; + let (_, parameters) = validated_protocol_parameters(program_id, &request.protocol_parameters)?; + + let quote = redemption_rate_update_quote(RedemptionRateUpdateQuoteRequest { + stablecoin_program_id: request.stablecoin_program_id, + protocol_parameters: request.protocol_parameters, + redemption_price_state: request.redemption_price_state, + market_price_oracle: request.market_price_oracle, + clock: request.clock, + })?; + require_ready_quote("e)?; + + plan_response( + program_id, + [ + caller, + compute_protocol_parameters_pda(program_id), + compute_redemption_price_state_pda(program_id), + parameters.market_price_oracle_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + [true, false, false, false, false], + Instruction::UpdateRedemptionRate, + ) +} + +pub fn refresh_globals_plan(request: RefreshGlobalsPlanRequest) -> StablecoinResult { + let program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; + let caller = parse_account_id(&request.caller_id)?; + let (_, parameters) = validated_protocol_parameters(program_id, &request.protocol_parameters)?; + validated_stability_fee_accumulator(program_id, &request.stability_fee_accumulator)?; + validated_redemption_price_state(program_id, &request.redemption_price_state)?; + validated_market_price_oracle( + &request.market_price_oracle, + parameters.market_price_oracle_id, + )?; + clock_timestamp(&request.clock)?; + + plan_response( + program_id, + [ + caller, + compute_protocol_parameters_pda(program_id), + compute_stability_fee_accumulator_pda(program_id), + compute_redemption_price_state_pda(program_id), + parameters.market_price_oracle_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + [true, false, false, false, false, false], + Instruction::RefreshGlobals, + ) +} + +fn require_ready_quote(quote: &Value) -> Result<(), StablecoinApiError> { + if quote.get("canSubmit").and_then(Value::as_bool) == Some(true) { + return Ok(()); + } + + let blocker = quote + .get("errors") + .and_then(Value::as_array) + .and_then(|errors| errors.first()) + .and_then(|error| error.get("code")) + .and_then(Value::as_str); + let code = match blocker { + Some("oracle_stale") => "oracle_stale", + Some("oracle_price_zero") => "oracle_price_zero", + Some("rate_update_too_soon") => "rate_update_too_soon", + _ => "backend_error", + }; + Err(StablecoinApiError::new(code)) +} + fn required_account( read: &AccountRead, ) -> Result<(AccountId, lee_core::account::Account), StablecoinApiError> { @@ -177,10 +281,10 @@ fn parse_i128(value: &Value) -> Result { } } -fn plan_response( +fn plan_response( program_id: lee_core::program::ProgramId, - account_ids: [AccountId; 9], - signing_requirements: [bool; 9], + account_ids: [AccountId; ACCOUNT_COUNT], + signing_requirements: [bool; ACCOUNT_COUNT], instruction: Instruction, ) -> StablecoinResult { let instruction = risc0_zkvm::serde::to_vec(&instruction) @@ -188,7 +292,7 @@ fn plan_response( Ok(json!({ "programId": hex::encode(program_id_bytes(program_id)), "accountIds": account_ids.into_iter().map(account_id_hex).collect::>(), - "signingRequirements": signing_requirements, + "signingRequirements": signing_requirements.into_iter().collect::>(), "instruction": instruction, })) } diff --git a/modules/stablecoin/ffi/src/api/quote.rs b/modules/stablecoin/ffi/src/api/quote.rs index fdf258c0..0d5bfc62 100644 --- a/modules/stablecoin/ffi/src/api/quote.rs +++ b/modules/stablecoin/ffi/src/api/quote.rs @@ -95,7 +95,7 @@ pub fn redemption_rate_update_quote(request: RedemptionRateUpdateQuoteRequest) - })) } -fn validated_market_price_oracle( +pub(super) fn validated_market_price_oracle( read: &crate::AccountRead, expected_id: lee_core::account::AccountId, ) -> Result { diff --git a/modules/stablecoin/ffi/src/api/request.rs b/modules/stablecoin/ffi/src/api/request.rs index 3797b189..0feeaeff 100644 --- a/modules/stablecoin/ffi/src/api/request.rs +++ b/modules/stablecoin/ffi/src/api/request.rs @@ -53,6 +53,39 @@ pub struct RedemptionRateUpdateQuoteRequest { pub clock: AccountRead, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AccrueStabilityFeePlanRequest { + pub stablecoin_program_id: String, + pub caller_id: String, + pub protocol_parameters: AccountRead, + pub stability_fee_accumulator: AccountRead, + pub clock: AccountRead, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UpdateRedemptionRatePlanRequest { + pub stablecoin_program_id: String, + pub caller_id: String, + pub protocol_parameters: AccountRead, + pub redemption_price_state: AccountRead, + pub market_price_oracle: AccountRead, + pub clock: AccountRead, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RefreshGlobalsPlanRequest { + pub stablecoin_program_id: String, + pub caller_id: String, + pub protocol_parameters: AccountRead, + pub stability_fee_accumulator: 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/api/tests.rs b/modules/stablecoin/ffi/src/api/tests.rs index aedf497e..2ef5af9c 100644 --- a/modules/stablecoin/ffi/src/api/tests.rs +++ b/modules/stablecoin/ffi/src/api/tests.rs @@ -8,17 +8,19 @@ use serde_json::{json, Value}; use stablecoin_core::{ compute_protocol_parameters_pda, compute_redemption_price_state_pda, compute_stability_fee_accumulator_pda, compute_stablecoin_definition_pda, - compute_stablecoin_master_holding_pda, Instruction, ProtocolParameters, RedemptionPriceState, - StabilityFeeAccumulator, + compute_stablecoin_master_holding_pda, math::FIXED_POINT_ONE, Instruction, ProtocolParameters, + RedemptionPriceState, StabilityFeeAccumulator, }; use token_core::TokenDefinition; use twap_oracle_core::OraclePriceAccount; use super::{ - decode_protocol_parameters, decode_redemption_price_state, decode_stability_fee_accumulator, - initialize_program_plan, program_info, DecodeProtocolParametersRequest, + accrue_stability_fee_plan, decode_protocol_parameters, decode_redemption_price_state, + decode_stability_fee_accumulator, initialize_program_plan, program_info, refresh_globals_plan, + update_redemption_rate_plan, AccrueStabilityFeePlanRequest, DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, - InitializeProgramPlanRequest, ProgramInfoRequest, StablecoinResult, + InitializeProgramPlanRequest, ProgramInfoRequest, RefreshGlobalsPlanRequest, StablecoinResult, + UpdateRedemptionRatePlanRequest, }; use crate::account::{account_id_hex, account_read, program_id_bytes}; @@ -185,6 +187,122 @@ fn initialize_request() -> InitializeProgramPlanRequest { } } +const POKE_NOW: u64 = 1_000; +const POKE_LAST_UPDATE: u64 = 900; + +fn poke_parameters(is_frozen: bool) -> ProtocolParameters { + ProtocolParameters { + admin_account_id: id(1), + freeze_authority_account_id: id(2), + stablecoin_definition_id: id(3), + collateral_definition_id: id(4), + market_price_oracle_id: id(5), + stability_fee_per_millisecond: 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: 50, + maximum_oracle_price_age_milliseconds: 50, + is_frozen, + } +} + +fn poke_parameters_read(parameters: &ProtocolParameters) -> crate::AccountRead { + account_read( + compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID), + &account(STABLECOIN_PROGRAM_ID, Data::from(parameters)), + ) +} + +fn poke_accumulator_read() -> crate::AccountRead { + account_read( + compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID), + &account( + STABLECOIN_PROGRAM_ID, + Data::from(&StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: FIXED_POINT_ONE, + last_accrued_at: POKE_LAST_UPDATE, + }), + ), + ) +} + +fn poke_redemption_read(last_updated_at: u64) -> crate::AccountRead { + account_read( + compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID), + &account( + STABLECOIN_PROGRAM_ID, + Data::from(&RedemptionPriceState { + redemption_price_at_last_update: FIXED_POINT_ONE, + redemption_rate_per_millisecond: FIXED_POINT_ONE, + controller_integral_term: 0, + last_updated_at, + }), + ), + ) +} + +fn poke_oracle_read(price: u128, timestamp: u64) -> crate::AccountRead { + account_read( + id(5), + &account( + ORACLE_PROGRAM_ID, + Data::from(&OraclePriceAccount { + base_asset: id(3), + quote_asset: id(4), + price, + timestamp, + source_id: id(6), + confidence_interval: 0, + }), + ), + ) +} + +fn poke_clock_read(timestamp: u64) -> crate::AccountRead { + let clock = ClockAccountData { + block_id: 1, + timestamp, + }; + account_read( + CLOCK_01_PROGRAM_ACCOUNT_ID, + &account(CLOCK_PROGRAM_ID, ok(Data::try_from(clock.to_bytes()))), + ) +} + +fn accrue_request(is_frozen: bool) -> AccrueStabilityFeePlanRequest { + AccrueStabilityFeePlanRequest { + stablecoin_program_id: program_id_hex(), + caller_id: account_id_hex(id(15)), + protocol_parameters: poke_parameters_read(&poke_parameters(is_frozen)), + stability_fee_accumulator: poke_accumulator_read(), + clock: poke_clock_read(POKE_NOW), + } +} + +fn update_request(is_frozen: bool) -> UpdateRedemptionRatePlanRequest { + UpdateRedemptionRatePlanRequest { + stablecoin_program_id: program_id_hex(), + caller_id: account_id_hex(id(15)), + protocol_parameters: poke_parameters_read(&poke_parameters(is_frozen)), + redemption_price_state: poke_redemption_read(POKE_LAST_UPDATE), + market_price_oracle: poke_oracle_read(FIXED_POINT_ONE, POKE_NOW), + clock: poke_clock_read(POKE_NOW), + } +} + +fn refresh_request(is_frozen: bool) -> RefreshGlobalsPlanRequest { + RefreshGlobalsPlanRequest { + stablecoin_program_id: program_id_hex(), + caller_id: account_id_hex(id(15)), + protocol_parameters: poke_parameters_read(&poke_parameters(is_frozen)), + stability_fee_accumulator: poke_accumulator_read(), + redemption_price_state: poke_redemption_read(POKE_LAST_UPDATE), + market_price_oracle: poke_oracle_read(FIXED_POINT_ONE, POKE_NOW), + clock: poke_clock_read(POKE_NOW), + } +} + fn decode_instruction(value: &Value) -> Instruction { let words: Vec = ok(serde_json::from_value(value.clone())); ok(risc0_zkvm::serde::from_slice::(&words)) @@ -623,3 +741,141 @@ fn initialize_plan_validates_required_account_shapes_and_assets() { failed_read.collateral_definition.account = None; assert_error(initialize_program_plan(failed_read), "account_read_failed"); } + +#[test] +fn poke_plans_pin_instruction_words_accounts_and_caller_only_signing() { + let caller = account_id_hex(id(15)); + let parameters = account_id_hex(compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)); + let accumulator = account_id_hex(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)); + let redemption = account_id_hex(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)); + let oracle = account_id_hex(id(5)); + let clock = account_id_hex(CLOCK_01_PROGRAM_ACCOUNT_ID); + + let accrue = ok(accrue_stability_fee_plan(accrue_request(false))); + assert_eq!(accrue["programId"], program_id_hex()); + assert_eq!( + accrue["accountIds"], + json!([caller, parameters, accumulator, clock]) + ); + assert_eq!( + accrue["signingRequirements"], + json!([true, false, false, false]) + ); + assert_eq!(accrue["instruction"], json!([1])); + assert!(matches!( + decode_instruction(&accrue["instruction"]), + Instruction::AccrueStabilityFee + )); + + let update = ok(update_redemption_rate_plan(update_request(false))); + assert_eq!(update["programId"], program_id_hex()); + assert_eq!( + update["accountIds"], + json!([caller, parameters, redemption, oracle, clock]) + ); + assert_eq!( + update["signingRequirements"], + json!([true, false, false, false, false]) + ); + assert_eq!(update["instruction"], json!([2])); + assert!(matches!( + decode_instruction(&update["instruction"]), + Instruction::UpdateRedemptionRate + )); + + let refresh = ok(refresh_globals_plan(refresh_request(false))); + assert_eq!(refresh["programId"], program_id_hex()); + assert_eq!( + refresh["accountIds"], + json!([caller, parameters, accumulator, redemption, oracle, clock]) + ); + assert_eq!( + refresh["signingRequirements"], + json!([true, false, false, false, false, false]) + ); + assert_eq!(refresh["instruction"], json!([3])); + assert!(matches!( + decode_instruction(&refresh["instruction"]), + Instruction::RefreshGlobals + )); +} + +#[test] +fn strict_update_plan_reuses_quote_gate_order() { + let mut stale = update_request(false); + stale.market_price_oracle = poke_oracle_read(FIXED_POINT_ONE, POKE_NOW - 51); + assert_error(update_redemption_rate_plan(stale), "oracle_stale"); + + let mut zero = update_request(false); + zero.market_price_oracle = poke_oracle_read(0, POKE_NOW); + assert_error(update_redemption_rate_plan(zero), "oracle_price_zero"); + + let mut too_soon = update_request(false); + too_soon.redemption_price_state = poke_redemption_read(POKE_NOW - 49); + assert_error( + update_redemption_rate_plan(too_soon), + "rate_update_too_soon", + ); + + let mut combined = update_request(false); + combined.market_price_oracle = poke_oracle_read(0, POKE_NOW - 51); + combined.redemption_price_state = poke_redemption_read(POKE_NOW - 49); + assert_error(update_redemption_rate_plan(combined), "oracle_stale"); +} + +#[test] +fn refresh_plan_keeps_controller_quote_gates_soft() { + let mut stale = refresh_request(false); + stale.market_price_oracle = poke_oracle_read(FIXED_POINT_ONE, POKE_NOW - 51); + assert!(refresh_globals_plan(stale).is_ok()); + + let mut zero = refresh_request(false); + zero.market_price_oracle = poke_oracle_read(0, POKE_NOW); + assert!(refresh_globals_plan(zero).is_ok()); + + let mut too_soon = refresh_request(false); + too_soon.redemption_price_state = poke_redemption_read(POKE_NOW - 49); + assert!(refresh_globals_plan(too_soon).is_ok()); +} + +#[test] +fn all_poke_plans_submit_while_protocol_is_frozen() { + assert!(accrue_stability_fee_plan(accrue_request(true)).is_ok()); + assert!(update_redemption_rate_plan(update_request(true)).is_ok()); + assert!(refresh_globals_plan(refresh_request(true)).is_ok()); +} + +#[test] +fn poke_plans_reject_invalid_callers_and_hard_account_mismatches() { + let mut invalid_caller = accrue_request(false); + invalid_caller.caller_id = String::from("not-an-account"); + assert_error( + accrue_stability_fee_plan(invalid_caller), + "invalid_account_id", + ); + + let mut wrong_accumulator = accrue_request(false); + wrong_accumulator.stability_fee_accumulator.id = account_id_hex(id(30)); + assert_error( + accrue_stability_fee_plan(wrong_accumulator), + "stability_fee_accumulator_pda_mismatch", + ); + + let mut wrong_redemption = update_request(false); + wrong_redemption.redemption_price_state.id = account_id_hex(id(31)); + assert_error( + update_redemption_rate_plan(wrong_redemption), + "redemption_price_state_pda_mismatch", + ); + + let mut wrong_oracle = refresh_request(false); + wrong_oracle.market_price_oracle.id = account_id_hex(id(32)); + assert_error( + refresh_globals_plan(wrong_oracle), + "market_price_oracle_mismatch", + ); + + let mut wrong_clock = refresh_request(false); + wrong_clock.clock.id = account_id_hex(id(33)); + assert_error(refresh_globals_plan(wrong_clock), "invalid_clock"); +} diff --git a/modules/stablecoin/ffi/src/ffi.rs b/modules/stablecoin/ffi/src/ffi.rs index 4ed37f62..77303b05 100644 --- a/modules/stablecoin/ffi/src/ffi.rs +++ b/modules/stablecoin/ffi/src/ffi.rs @@ -6,10 +6,11 @@ use std::{ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ - self, CurrentGlobalStateRequest, DecodeProtocolParametersRequest, - DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, - InitializeProgramPlanRequest, ProgramInfoRequest, RedemptionRateUpdateQuoteRequest, - StablecoinResult, + self, AccrueStabilityFeePlanRequest, CurrentGlobalStateRequest, + DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, + DecodeStabilityFeeAccumulatorRequest, InitializeProgramPlanRequest, ProgramInfoRequest, + RedemptionRateUpdateQuoteRequest, RefreshGlobalsPlanRequest, StablecoinResult, + UpdateRedemptionRatePlanRequest, }; #[derive(Serialize)] @@ -168,6 +169,44 @@ pub unsafe extern "C" fn stablecoin_redemption_rate_update_quote( } } +#[unsafe(no_mangle)] +/// Builds the exact wallet submission plan for `AccrueStabilityFee`. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_accrue_stability_fee_plan( + request_json: *const c_char, +) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { call::(request_json, api::accrue_stability_fee_plan) } +} + +#[unsafe(no_mangle)] +/// Builds a preflighted wallet submission plan for `UpdateRedemptionRate`. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_update_redemption_rate_plan( + request_json: *const c_char, +) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { + call::(request_json, api::update_redemption_rate_plan) + } +} + +#[unsafe(no_mangle)] +/// Builds the best-effort wallet submission plan for `RefreshGlobals`. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_refresh_globals_plan( + request_json: *const c_char, +) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { call::(request_json, api::refresh_globals_plan) } +} + #[unsafe(no_mangle)] /// Builds the exact wallet submission plan for `InitializeProgram`. /// @@ -265,6 +304,25 @@ mod tests { } } + #[test] + fn poke_plans_reject_malformed_requests_at_the_boundary() { + let operations: [unsafe extern "C" fn(*const c_char) -> *mut c_char; 3] = [ + stablecoin_accrue_stability_fee_plan, + stablecoin_update_redemption_rate_plan, + stablecoin_refresh_globals_plan, + ]; + for operation in operations { + let request = match CString::new("{") { + Ok(value) => value, + Err(error) => panic!("{error}"), + }; + // SAFETY: request is a live NUL-terminated CString for this call. + let response = unsafe { operation(request.as_ptr()) }; + // SAFETY: response came from the selected poke-plan 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 7c2040a2..5a8822b8 100644 --- a/modules/stablecoin/ffi/src/lib.rs +++ b/modules/stablecoin/ffi/src/lib.rs @@ -7,10 +7,12 @@ pub mod api; 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, - redemption_rate_update_quote, CurrentGlobalStateRequest, DecodeProtocolParametersRequest, + accrue_stability_fee_plan, current_global_state, decode_protocol_parameters, + decode_redemption_price_state, decode_stability_fee_accumulator, initialize_program_plan, + program_info, redemption_rate_update_quote, refresh_globals_plan, update_redemption_rate_plan, + AccrueStabilityFeePlanRequest, CurrentGlobalStateRequest, DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, InitializeProgramPlanRequest, ProgramInfoRequest, RedemptionRateUpdateQuoteRequest, - StablecoinApiError, StablecoinResponse, StablecoinResult, + RefreshGlobalsPlanRequest, StablecoinApiError, StablecoinResponse, StablecoinResult, + UpdateRedemptionRatePlanRequest, }; diff --git a/modules/stablecoin/ffi/tests/public_api.rs b/modules/stablecoin/ffi/tests/public_api.rs index 621e75f6..14351a56 100644 --- a/modules/stablecoin/ffi/tests/public_api.rs +++ b/modules/stablecoin/ffi/tests/public_api.rs @@ -1,10 +1,11 @@ use stablecoin_ffi::{ - current_global_state, decode_protocol_parameters, decode_redemption_price_state, - decode_stability_fee_accumulator, initialize_program_plan, program_info, - redemption_rate_update_quote, CurrentGlobalStateRequest, DecodeProtocolParametersRequest, + accrue_stability_fee_plan, current_global_state, decode_protocol_parameters, + decode_redemption_price_state, decode_stability_fee_accumulator, initialize_program_plan, + program_info, redemption_rate_update_quote, refresh_globals_plan, update_redemption_rate_plan, + AccrueStabilityFeePlanRequest, CurrentGlobalStateRequest, DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, InitializeProgramPlanRequest, ProgramInfoRequest, RedemptionRateUpdateQuoteRequest, - StablecoinResult, + RefreshGlobalsPlanRequest, StablecoinResult, UpdateRedemptionRatePlanRequest, }; #[test] @@ -20,5 +21,9 @@ fn crate_root_reexports_stablecoin_surface() { current_global_state; let _redemption_rate_quote: fn(RedemptionRateUpdateQuoteRequest) -> StablecoinResult = redemption_rate_update_quote; + let _accrue: fn(AccrueStabilityFeePlanRequest) -> StablecoinResult = accrue_stability_fee_plan; + let _update: fn(UpdateRedemptionRatePlanRequest) -> StablecoinResult = + update_redemption_rate_plan; + let _refresh: fn(RefreshGlobalsPlanRequest) -> StablecoinResult = refresh_globals_plan; 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 55a96afa..25d26193 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.cpp +++ b/modules/stablecoin/src/stablecoin_module_impl.cpp @@ -218,6 +218,31 @@ std::string StablecoinModuleImpl::normalizeAccountId(const std::string& id) { }); } +bool StablecoinModuleImpl::requireWalletCaller(const std::string& caller_id, + std::string& error) { + logos::CallError call_error; + const json accounts = modules().lez_core.list_accounts(&call_error); + if (!call_error.ok() || !accounts.is_array()) { + STABLECOIN_TRACE( + "lez_core account inventory failure: " << call_error.code); + error = "backend_error"; + return false; + } + + for (const auto& account : accounts) { + if (!account.is_object()) continue; + const auto public_field = account.find("is_public"); + if (public_field == account.end() || !public_field->is_boolean() + || !public_field->get()) { + continue; + } + if (normalizeAccountId(jsonString(account, "account_id")) == caller_id) return true; + } + + error = "account_read_failed"; + return false; +} + nlohmann::json StablecoinModuleImpl::readPublicAccount(const std::string& account_id) { logos::CallError call_error; const std::string raw = @@ -435,7 +460,182 @@ LogosMap StablecoinModuleImpl::redemptionRateUpdateQuote() { }); } -LogosMap StablecoinModuleImpl::submitPlan(const nlohmann::json& plan) { +LogosMap StablecoinModuleImpl::accrueStabilityFee(const std::string& caller_id) { + return guarded([&]() -> LogosMap { + std::string error; + const json info = stablecoinProgramInfo(error); + if (!info.is_object()) return publicError(error.empty() ? "backend_error" : error); + + const std::string caller = normalizeAccountId(caller_id); + if (caller.empty()) return publicError("invalid_account_id"); + if (!requireWalletCaller(caller, error)) return publicError(error); + + const json parameters = readPublicAccount(jsonString(info, "protocolParametersIdHex")); + const json accumulator = + readPublicAccount(jsonString(info, "stabilityFeeAccumulatorIdHex")); + const json clock = readPublicAccount(jsonString(info, "clockIdHex")); + if (jsonString(parameters, "status") == "not_found" + || jsonString(accumulator, "status") == "not_found") { + return publicError("not_initialized"); + } + if (jsonString(parameters, "status") != "ok" + || jsonString(accumulator, "status") != "ok" + || jsonString(clock, "status") != "ok") { + return publicError("account_read_failed"); + } + + return planAndSubmit( + stablecoin_accrue_stability_fee_plan, + { + {"stablecoinProgramId", info["programIdHex"]}, + {"callerId", caller}, + {"protocolParameters", parameters}, + {"stabilityFeeAccumulator", accumulator}, + {"clock", clock}, + }, + jsonString(info, "programIdHex"), + 4); + }); +} + +LogosMap StablecoinModuleImpl::updateRedemptionRate(const std::string& caller_id) { + return guarded([&]() -> LogosMap { + std::string error; + const json info = stablecoinProgramInfo(error); + if (!info.is_object()) return publicError(error.empty() ? "backend_error" : error); + + const std::string caller = normalizeAccountId(caller_id); + if (caller.empty()) return publicError("invalid_account_id"); + if (!requireWalletCaller(caller, error)) return publicError(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"); + } + + return planAndSubmit( + stablecoin_update_redemption_rate_plan, + { + {"stablecoinProgramId", info["programIdHex"]}, + {"callerId", caller}, + {"protocolParameters", parameters}, + {"redemptionPriceState", redemption}, + {"marketPriceOracle", oracle}, + {"clock", clock}, + }, + jsonString(info, "programIdHex"), + 5); + }); +} + +LogosMap StablecoinModuleImpl::refreshGlobals(const std::string& caller_id) { + return guarded([&]() -> LogosMap { + std::string error; + const json info = stablecoinProgramInfo(error); + if (!info.is_object()) return publicError(error.empty() ? "backend_error" : error); + + const std::string caller = normalizeAccountId(caller_id); + if (caller.empty()) return publicError("invalid_account_id"); + if (!requireWalletCaller(caller, error)) return publicError(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 accumulator = + readPublicAccount(jsonString(info, "stabilityFeeAccumulatorIdHex")); + const json redemption = readPublicAccount(jsonString(info, "redemptionPriceStateIdHex")); + const json oracle = readPublicAccount(oracle_id); + const json clock = readPublicAccount(jsonString(info, "clockIdHex")); + if (jsonString(accumulator, "status") == "not_found" + || jsonString(redemption, "status") == "not_found") { + return publicError("not_initialized"); + } + if (jsonString(accumulator, "status") != "ok" + || jsonString(redemption, "status") != "ok" + || jsonString(oracle, "status") != "ok" + || jsonString(clock, "status") != "ok") { + return publicError("account_read_failed"); + } + + return planAndSubmit( + stablecoin_refresh_globals_plan, + { + {"stablecoinProgramId", info["programIdHex"]}, + {"callerId", caller}, + {"protocolParameters", parameters}, + {"stabilityFeeAccumulator", accumulator}, + {"redemptionPriceState", redemption}, + {"marketPriceOracle", oracle}, + {"clock", clock}, + }, + jsonString(info, "programIdHex"), + 6); + }); +} + +LogosMap StablecoinModuleImpl::planAndSubmit( + StablecoinOperation planner, + const nlohmann::json& request, + const std::string& expected_program_id, + std::size_t expected_account_count) { + const FfiResult planned = callStablecoin(planner, request); + if (!planned.ok) { + return publicError(stablecoin_module::detail::stableFfiError(planned.error)); + } + if (jsonString(planned.value, "programId") != expected_program_id) { + return publicError("backend_error"); + } + return submitPlan(planned.value, expected_account_count); +} + +LogosMap StablecoinModuleImpl::submitPlan(const nlohmann::json& plan, + std::size_t expected_account_count) { const auto accounts_field = plan.find("accountIds"); const auto signers_field = plan.find("signingRequirements"); const auto instruction_field = plan.find("instruction"); @@ -453,11 +653,14 @@ LogosMap StablecoinModuleImpl::submitPlan(const nlohmann::json& plan) { signers_field->get>(); const std::vector instruction = stablecoin_module::detail::jsonInstructionLeBytes(*instruction_field); - if (account_ids.size() != 9 || signing_requirements.size() != 9 + if (expected_account_count == 0 || account_ids.size() != expected_account_count + || signing_requirements.size() != expected_account_count || !std::all_of(account_ids.begin(), account_ids.end(), stablecoin_module::detail::isValidAccountIdHex) - || signing_requirements != std::vector({true, false, false, false, false, - false, false, false, false}) + || !signing_requirements.front() + || !std::all_of(std::next(signing_requirements.begin()), + signing_requirements.end(), + [](bool required) { return !required; }) || instruction.empty()) { return publicError("backend_error"); } @@ -540,7 +743,7 @@ LogosMap StablecoinModuleImpl::initializeProgram(const LogosMap& request) { return publicError("account_read_failed"); } - const FfiResult planned = callStablecoin(stablecoin_initialize_program_plan, { + return planAndSubmit(stablecoin_initialize_program_plan, { {"stablecoinProgramId", info["programIdHex"]}, {"adminId", admin}, {"freezeAuthorityId", freeze_authority}, @@ -560,13 +763,6 @@ LogosMap StablecoinModuleImpl::initializeProgram(const LogosMap& request) { request["maximumOraclePriceAgeMilliseconds"]}, {"initialRedemptionPrice", request["initialRedemptionPrice"]}, {"stablecoinName", request["stablecoinName"]}, - }); - if (!planned.ok) { - return publicError(stablecoin_module::detail::stableFfiError(planned.error)); - } - if (jsonString(planned.value, "programId") != jsonString(info, "programIdHex")) { - return publicError("backend_error"); - } - return submitPlan(planned.value); + }, jsonString(info, "programIdHex"), 9); }); } diff --git a/modules/stablecoin/src/stablecoin_module_impl.h b/modules/stablecoin/src/stablecoin_module_impl.h index f14d1cb6..86f1dd2d 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.h +++ b/modules/stablecoin/src/stablecoin_module_impl.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -41,6 +42,18 @@ class StablecoinModuleImpl : public LogosModuleContext { /// a transaction; soft gates return `canSubmit: false` with blockers. LogosMap redemptionRateUpdateQuote(); + /// Advances the stability-fee accumulator. `caller_id` must identify a + /// public account controlled by the connected wallet and is the sole signer. + LogosMap accrueStabilityFee(const std::string& caller_id); + + /// Runs one strict redemption-rate controller tick. The live quote preflight + /// blocks stale/zero oracle data and updates attempted before the interval. + LogosMap updateRedemptionRate(const std::string& caller_id); + + /// Advances the fee accumulator and best-effort redemption-rate state. The + /// redemption half may be skipped on soft gates; the transaction still runs. + LogosMap refreshGlobals(const std::string& caller_id); + /// Initializes the stablecoin protocol. Request fields are `adminId`, /// `freezeAuthorityId`, `collateralDefinitionId`, `marketPriceOracleId`, /// `initialStabilityFeePerMillisecond`, @@ -53,12 +66,19 @@ class StablecoinModuleImpl : public LogosModuleContext { LogosMap initializeProgram(const LogosMap& request); private: + using StablecoinOperation = char* (*)(const char*); + std::vector loadStablecoinBinary() const; nlohmann::json stablecoinProgramInfo(std::string& error); std::string normalizeAccountId(const std::string& id); + bool requireWalletCaller(const std::string& caller_id, std::string& error); nlohmann::json readPublicAccount(const std::string& account_id); bool requireUninitialized(const std::string& account_id, std::string& error); - LogosMap submitPlan(const nlohmann::json& plan); + LogosMap planAndSubmit(StablecoinOperation planner, + const nlohmann::json& request, + const std::string& expected_program_id, + std::size_t expected_account_count); + LogosMap submitPlan(const nlohmann::json& plan, std::size_t expected_account_count); bool programInfoResolved_ = false; std::string programInfoJson_; diff --git a/modules/stablecoin/src/stablecoin_module_support.cpp b/modules/stablecoin/src/stablecoin_module_support.cpp index df2236a9..92a34cda 100644 --- a/modules/stablecoin/src/stablecoin_module_support.cpp +++ b/modules/stablecoin/src/stablecoin_module_support.cpp @@ -169,9 +169,12 @@ std::string stableFfiError(const std::string& error) { "invalid_stability_fee_accumulator_data", "invalid_stablecoin_name", "market_price_oracle_mismatch", + "oracle_price_zero", + "oracle_stale", "oracle_asset_mismatch", "program_id_mismatch", "protocol_parameters_pda_mismatch", + "rate_update_too_soon", "redemption_price_state_pda_mismatch", "stability_fee_accumulator_pda_mismatch", "stablecoin_program_mismatch", diff --git a/modules/stablecoin/tests/logos_sdk.h b/modules/stablecoin/tests/logos_sdk.h index 45dbb53f..97118910 100644 --- a/modules/stablecoin/tests/logos_sdk.h +++ b/modules/stablecoin/tests/logos_sdk.h @@ -9,6 +9,9 @@ #include #include #include +#include + +#include #include "lez_core_api.h" @@ -16,6 +19,10 @@ // Qt test framework's generated dependency client. class UniversalLezCore { public: + static std::string transportErrorSentinel() { + return "__logos_test_transport_error__"; + } + explicit UniversalLezCore(LogosAPI* api) : qt_(api) { } @@ -23,6 +30,28 @@ class UniversalLezCore { return qt_.account_id_from_base58(QString::fromStdString(base58)).toStdString(); } + nlohmann::json list_accounts(logos::CallError* error = nullptr) { + const QVariantList accounts = qt_.list_accounts(error); + if (accounts.size() == 1 + && accounts.front().toString().toStdString() == transportErrorSentinel()) { + if (error != nullptr) { + error->code = "transport_error"; + error->message = "simulated transport failure"; + error->origin = "lez_core"; + } + return nlohmann::json::array(); + } + nlohmann::json result = nlohmann::json::array(); + for (const QVariant& account : accounts) { + const QVariantMap fields = account.toMap(); + result.push_back({ + {"account_id", fields.value("account_id").toString().toStdString()}, + {"is_public", fields.value("is_public").toBool()}, + }); + } + return result; + } + 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(); @@ -50,13 +79,20 @@ class UniversalLezCore { 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(); + const std::string result = qt_.send_generic_public_transaction( + qt_account_ids, + qt_signing_requirements, + QVariant(qt_instruction), + QString::fromStdString(program_id), + error) + .toStdString(); + if (result == transportErrorSentinel() && error != nullptr) { + error->code = "transport_error"; + error->message = "simulated transport failure"; + error->origin = "lez_core"; + return {}; + } + return result; } private: diff --git a/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp index 610410a7..e0817c8c 100644 --- a/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp +++ b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp @@ -47,6 +47,18 @@ extern "C" char* stablecoin_redemption_rate_update_quote(const char*) { return copyMockResponse("stablecoin_redemption_rate_update_quote"); } +extern "C" char* stablecoin_accrue_stability_fee_plan(const char*) { + return copyMockResponse("stablecoin_accrue_stability_fee_plan"); +} + +extern "C" char* stablecoin_update_redemption_rate_plan(const char*) { + return copyMockResponse("stablecoin_update_redemption_rate_plan"); +} + +extern "C" char* stablecoin_refresh_globals_plan(const char*) { + return copyMockResponse("stablecoin_refresh_globals_plan"); +} + 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 5e3f3473..0760ef86 100644 --- a/modules/stablecoin/tests/stablecoin_module_impl_test.cpp +++ b/modules/stablecoin/tests/stablecoin_module_impl_test.cpp @@ -4,9 +4,14 @@ #include #include #include +#include +#include +#include +#include #include #include +#include #include #include @@ -22,6 +27,9 @@ 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'); +const std::string CALLER_ID_HEX(64, '9'); +const std::string OTHER_CALLER_ID_HEX(64, 'a'); +const std::string TRANSACTION_ID_HEX(64, 'b'); class ScopedEnvironment { public: @@ -89,6 +97,80 @@ std::string initializedAccount() { }.dump(); } +QVariantList walletAccounts(const std::string& account_id) { + QVariantMap account; + account.insert("account_id", QString::fromStdString(account_id)); + account.insert("is_public", true); + return QVariantList{QVariant(account)}; +} + +std::vector accrueAccounts() { + return { + CALLER_ID_HEX, + PROTOCOL_PARAMETERS_ID_HEX, + ACCUMULATOR_ID_HEX, + CLOCK_ID_HEX, + }; +} + +std::vector updateAccounts() { + return { + CALLER_ID_HEX, + PROTOCOL_PARAMETERS_ID_HEX, + REDEMPTION_STATE_ID_HEX, + ORACLE_ID_HEX, + CLOCK_ID_HEX, + }; +} + +std::vector refreshAccounts() { + return { + CALLER_ID_HEX, + PROTOCOL_PARAMETERS_ID_HEX, + ACCUMULATOR_ID_HEX, + REDEMPTION_STATE_ID_HEX, + ORACLE_ID_HEX, + CLOCK_ID_HEX, + }; +} + +json submissionPlan(const std::vector& account_ids, + std::uint32_t instruction_word) { + std::vector signing_requirements(account_ids.size(), false); + signing_requirements.front() = true; + return { + {"programId", PROGRAM_ID_HEX}, + {"accountIds", account_ids}, + {"signingRequirements", signing_requirements}, + {"instruction", json::array({instruction_word})}, + }; +} + +QVariantList submissionArguments(const std::vector& account_ids, + std::uint32_t instruction_word) { + QStringList qt_account_ids; + QVariantList signing_requirements; + for (std::size_t index = 0; index < account_ids.size(); ++index) { + qt_account_ids.push_back(QString::fromStdString(account_ids[index])); + signing_requirements.push_back(index == 0); + } + const QByteArray instruction( + 1, + static_cast(instruction_word)); + QByteArray instruction_le = instruction; + instruction_le.append(3, '\0'); + return { + QVariant(qt_account_ids), + QVariant(signing_requirements), + QVariant(instruction_le), + QVariant(QString::fromStdString(PROGRAM_ID_HEX)), + }; +} + +std::string successfulTransaction() { + return json{{"success", true}, {"tx_hash", TRANSACTION_ID_HEX}}.dump(); +} + void attachModules(StablecoinModuleImpl& module, LogosModules& modules) { module._logosCoreSetLogosModulesPtr_(&modules); } @@ -559,3 +641,242 @@ LOGOS_TEST(redemption_rate_update_quote_maps_missing_globals_and_hard_ffi_errors context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); } } + +LOGOS_TEST(poke_methods_validate_caller_wallet_ownership_before_reads) { + 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); + + assertError(module.accrueStabilityFee("not-an-account"), "invalid_account_id"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "list_accounts"), 0); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 0); + LOGOS_ASSERT_EQ( + context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } + + { + 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", "list_accounts") + .returnsVariant(QVariant(walletAccounts(OTHER_CALLER_ID_HEX))); + + assertError(module.accrueStabilityFee(CALLER_ID_HEX), "account_read_failed"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "list_accounts"), 1); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 0); + LOGOS_ASSERT_EQ( + context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } + + { + 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", "list_accounts") + .returnsVariant(QVariant(QVariantList{ + QVariant(QString::fromStdString( + UniversalLezCore::transportErrorSentinel())), + })); + + assertError(module.accrueStabilityFee(CALLER_ID_HEX), "backend_error"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "list_accounts"), 1); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 0); + LOGOS_ASSERT_EQ( + context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } +} + +LOGOS_TEST(poke_methods_submit_exact_plans_while_protocol_is_frozen) { + 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 decoded_parameters_response = successEnvelope({ + {"marketPriceOracleIdHex", ORACLE_ID_HEX}, + {"isFrozen", true}, + }); + const std::string accrue_plan_response = + successEnvelope(submissionPlan(accrueAccounts(), 1)); + const std::string update_plan_response = + successEnvelope(submissionPlan(updateAccounts(), 2)); + const std::string refresh_plan_response = + successEnvelope(submissionPlan(refreshAccounts(), 3)); + const std::string account_response = initializedAccount(); + const std::string transaction_response = successfulTransaction(); + + context.mockCFunction("stablecoin_program_info").returns(program_info_response); + context.mockCFunction("stablecoin_decode_protocol_parameters") + .returns(decoded_parameters_response); + context.mockCFunction("stablecoin_accrue_stability_fee_plan") + .returns(accrue_plan_response); + context.mockCFunction("stablecoin_update_redemption_rate_plan") + .returns(update_plan_response); + context.mockCFunction("stablecoin_refresh_globals_plan") + .returns(refresh_plan_response); + context.mockModule("lez_core", "list_accounts") + .returnsVariant(QVariant(walletAccounts(CALLER_ID_HEX))); + context.mockModule("lez_core", "get_account_public").returns(account_response); + context.mockModule("lez_core", "send_generic_public_transaction") + .returns(transaction_response); + + const LogosMap accrued = module.accrueStabilityFee(CALLER_ID_HEX); + const LogosMap updated = module.updateRedemptionRate(CALLER_ID_HEX); + const LogosMap refreshed = module.refreshGlobals(CALLER_ID_HEX); + + for (const LogosMap* response : {&accrued, &updated, &refreshed}) { + LOGOS_ASSERT_EQ((*response)["status"].get(), std::string("ok")); + LOGOS_ASSERT_EQ((*response)["error"].get(), std::string()); + LOGOS_ASSERT_EQ( + (*response)["transactionId"].get(), TRANSACTION_ID_HEX); + } + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "list_accounts"), 3); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 12); + LOGOS_ASSERT_EQ( + context.moduleCallCount("lez_core", "send_generic_public_transaction"), 3); + LOGOS_ASSERT_TRUE(context.moduleCalledWith( + "lez_core", + "send_generic_public_transaction", + submissionArguments(accrueAccounts(), 1))); + LOGOS_ASSERT_TRUE(context.moduleCalledWith( + "lez_core", + "send_generic_public_transaction", + submissionArguments(updateAccounts(), 2))); + LOGOS_ASSERT_TRUE(context.moduleCalledWith( + "lez_core", + "send_generic_public_transaction", + submissionArguments(refreshAccounts(), 3))); +} + +LOGOS_TEST(update_redemption_rate_blocks_each_ordered_quote_gate_without_submit) { + ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); + ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); + + for (const std::string& blocker : { + std::string("oracle_stale"), + std::string("oracle_price_zero"), + std::string("rate_update_too_soon"), + }) { + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + + const std::string program_info_response = successEnvelope(programInfoValue()); + const std::string decoded_parameters_response = + successEnvelope({{"marketPriceOracleIdHex", ORACLE_ID_HEX}}); + const std::string plan_response = failureEnvelope(blocker); + const std::string account_response = initializedAccount(); + context.mockCFunction("stablecoin_program_info").returns(program_info_response); + context.mockCFunction("stablecoin_decode_protocol_parameters") + .returns(decoded_parameters_response); + context.mockCFunction("stablecoin_update_redemption_rate_plan") + .returns(plan_response); + context.mockModule("lez_core", "list_accounts") + .returnsVariant(QVariant(walletAccounts(CALLER_ID_HEX))); + context.mockModule("lez_core", "get_account_public").returns(account_response); + + assertError(module.updateRedemptionRate(CALLER_ID_HEX), blocker); + LOGOS_ASSERT_EQ( + context.cFunctionCallCount("stablecoin_update_redemption_rate_plan"), 1); + LOGOS_ASSERT_EQ( + context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } +} + +LOGOS_TEST(poke_methods_map_missing_globals_and_oracle_mismatch) { + 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", "list_accounts") + .returnsVariant(QVariant(walletAccounts(CALLER_ID_HEX))); + context.mockModule("lez_core", "get_account_public").returns(""); + + assertError(module.accrueStabilityFee(CALLER_ID_HEX), "not_initialized"); + LOGOS_ASSERT_EQ( + context.cFunctionCallCount("stablecoin_accrue_stability_fee_plan"), 0); + LOGOS_ASSERT_EQ( + context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } + + { + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + const std::string program_info_response = successEnvelope(programInfoValue()); + const std::string decoded_parameters_response = + successEnvelope({{"marketPriceOracleIdHex", ORACLE_ID_HEX}}); + const std::string plan_response = + failureEnvelope("market_price_oracle_mismatch"); + const std::string account_response = initializedAccount(); + context.mockCFunction("stablecoin_program_info").returns(program_info_response); + context.mockCFunction("stablecoin_decode_protocol_parameters") + .returns(decoded_parameters_response); + context.mockCFunction("stablecoin_refresh_globals_plan").returns(plan_response); + context.mockModule("lez_core", "list_accounts") + .returnsVariant(QVariant(walletAccounts(CALLER_ID_HEX))); + context.mockModule("lez_core", "get_account_public").returns(account_response); + + assertError( + module.refreshGlobals(CALLER_ID_HEX), "market_price_oracle_mismatch"); + LOGOS_ASSERT_EQ( + context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } +} + +LOGOS_TEST(poke_submission_maps_wallet_rejection_and_transport_failure) { + ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); + ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); + + for (const std::string& wallet_response : { + json{{"success", false}, {"tx_hash", TRANSACTION_ID_HEX}}.dump(), + UniversalLezCore::transportErrorSentinel(), + }) { + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + + const std::string program_info_response = successEnvelope(programInfoValue()); + const std::string plan_response = + successEnvelope(submissionPlan(accrueAccounts(), 1)); + const std::string account_response = initializedAccount(); + context.mockCFunction("stablecoin_program_info").returns(program_info_response); + context.mockCFunction("stablecoin_accrue_stability_fee_plan") + .returns(plan_response); + context.mockModule("lez_core", "list_accounts") + .returnsVariant(QVariant(walletAccounts(CALLER_ID_HEX))); + context.mockModule("lez_core", "get_account_public").returns(account_response); + context.mockModule("lez_core", "send_generic_public_transaction") + .returns(wallet_response); + + assertError( + module.accrueStabilityFee(CALLER_ID_HEX), "wallet_submission_failed"); + LOGOS_ASSERT_EQ( + context.moduleCallCount("lez_core", "send_generic_public_transaction"), 1); + } +}