diff --git a/modules/stablecoin/README.md b/modules/stablecoin/README.md index 41381aca..c9a06655 100644 --- a/modules/stablecoin/README.md +++ b/modules/stablecoin/README.md @@ -1,9 +1,9 @@ # Stablecoin core module `stablecoin_module` is a headless Logos `core` module for the LEZ Stablecoin -Program. It exposes deployment discovery, protocol-parameter reads, and -protocol initialization through the same universal API used by `logoscore` and -UI modules. +Program. It exposes deployment discovery, protocol-parameter and position +reads, and protocol initialization through the same universal API used by +`logoscore` and UI modules. The Qt-free C++ adapter handles live wallet reads and transaction submission. `stablecoin_ffi` owns exact account decoding, PDA derivation, request @@ -36,6 +36,30 @@ Reads the singleton Protocol Parameters account through `lez_core`, verifies its PDA and owner, and exactly decodes its data. All `u128`, `i128`, and `u64` values are returned as decimal strings. +### `positionAccount(request)` + +Required request fields: + +| Field | Type | +| --- | --- | +| `ownerId` | base58 or 64-character hexadecimal account ID | +| `positionNonce` | exact `u64` decimal string | + +The module derives the position PDA from `(ownerId, positionNonce)`, derives +the position's collateral-vault PDA, and performs one direct public-account +read. It does not enumerate wallet or global accounts. + +On success, the response adds a `position` object containing the owner, +position, and vault IDs in base58 and lowercase hexadecimal form. It also +returns `positionNonce`, `collateralAmount`, `normalizedDebtAmount`, and +`openedAt` as exact decimal strings. The account owner, address, stored owner, +stored nonce, and stored vault must all match the derived identity. + +When the derived position account does not exist, the method returns +`{ "status": "error", "error": "not_found" }` and still adds `position` with +the derived owner, position, and vault IDs. No account decoder runs for this +ordinary absence case. + ### `initializeProgram(request)` Required request fields: diff --git a/modules/stablecoin/ffi/include/stablecoin_ffi.h b/modules/stablecoin/ffi/include/stablecoin_ffi.h index faaf02ba..fc5c9190 100644 --- a/modules/stablecoin/ffi/include/stablecoin_ffi.h +++ b/modules/stablecoin/ffi/include/stablecoin_ffi.h @@ -30,6 +30,22 @@ char *stablecoin_program_info(const char *request_json); */ char *stablecoin_decode_protocol_parameters(const char *request_json); +/** + * Derives the position and collateral-vault account IDs for an owner and nonce. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_position_info(const char *request_json); + +/** + * Decodes and validates a stablecoin `Position` account. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_decode_position(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 0f0ccdad..f3483520 100644 --- a/modules/stablecoin/ffi/src/api/mod.rs +++ b/modules/stablecoin/ffi/src/api/mod.rs @@ -2,6 +2,7 @@ mod decode; mod plan; +mod position; mod program; mod request; @@ -12,9 +13,11 @@ use std::{error::Error, fmt}; pub use decode::decode_protocol_parameters; pub use plan::initialize_program_plan; +pub use position::{decode_position, position_info}; pub use program::program_info; pub use request::{ - DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, + DecodePositionRequest, DecodeProtocolParametersRequest, InitializeProgramPlanRequest, + PositionInfoRequest, ProgramInfoRequest, }; use serde_json::Value; diff --git a/modules/stablecoin/ffi/src/api/position.rs b/modules/stablecoin/ffi/src/api/position.rs new file mode 100644 index 00000000..716a4bcd --- /dev/null +++ b/modules/stablecoin/ffi/src/api/position.rs @@ -0,0 +1,100 @@ +use lee_core::account::AccountId; +use serde_json::{json, Value}; +use stablecoin_core::{compute_position_pda, compute_position_vault_pda, Position}; + +use super::{ + parse_stablecoin_program_id, DecodePositionRequest, PositionInfoRequest, StablecoinApiError, + StablecoinResult, +}; +use crate::account::{account_id_from_hex, account_id_hex, decode_account}; + +pub fn position_info(request: PositionInfoRequest) -> StablecoinResult { + let stablecoin_program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; + let owner_id = parse_owner_id(&request.owner_id)?; + let position_nonce = parse_position_nonce(&request.position_nonce)?; + let position_id = compute_position_pda(stablecoin_program_id, owner_id, position_nonce); + let vault_id = compute_position_vault_pda(stablecoin_program_id, position_id); + + Ok(position_identity_value( + owner_id, + position_nonce, + position_id, + vault_id, + )) +} + +pub fn decode_position(request: DecodePositionRequest) -> StablecoinResult { + let stablecoin_program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; + let owner_id = parse_owner_id(&request.owner_id)?; + let position_nonce = parse_position_nonce(&request.position_nonce)?; + let expected_position_id = + compute_position_pda(stablecoin_program_id, owner_id, position_nonce); + let expected_vault_id = compute_position_vault_pda(stablecoin_program_id, expected_position_id); + let (position_id, account) = decode_account(&request.position) + .map_err(|_| StablecoinApiError::new("account_read_failed"))?; + + if position_id != expected_position_id { + return Err(StablecoinApiError::new("position_pda_mismatch")); + } + if account.program_owner != stablecoin_program_id { + return Err(StablecoinApiError::new("stablecoin_program_mismatch")); + } + + let position = Position::try_from(&account.data) + .map_err(|_| StablecoinApiError::new("invalid_position_data"))?; + if position.owner_account_id != owner_id { + return Err(StablecoinApiError::new("position_owner_mismatch")); + } + if position.position_nonce != position_nonce { + return Err(StablecoinApiError::new("position_nonce_mismatch")); + } + if position.vault_account_id != expected_vault_id { + return Err(StablecoinApiError::new("position_vault_mismatch")); + } + + let mut value = position_identity_value( + owner_id, + position_nonce, + expected_position_id, + expected_vault_id, + ); + value["collateralAmount"] = json!(position.collateral_amount.to_string()); + value["normalizedDebtAmount"] = json!(position.normalized_debt_amount.to_string()); + value["openedAt"] = json!(position.opened_at.to_string()); + Ok(value) +} + +fn parse_owner_id(value: &str) -> Result { + let owner_id = account_id_from_hex(value, "owner id") + .map_err(|_| StablecoinApiError::new("invalid_account_id"))?; + if owner_id.value() == &[0_u8; 32] { + return Err(StablecoinApiError::new("invalid_account_id")); + } + Ok(owner_id) +} + +fn parse_position_nonce(value: &str) -> Result { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(StablecoinApiError::new("invalid_numeric_value")); + } + value + .parse::() + .map_err(|_| StablecoinApiError::new("invalid_numeric_value")) +} + +fn position_identity_value( + owner_id: AccountId, + position_nonce: u64, + position_id: AccountId, + vault_id: AccountId, +) -> Value { + json!({ + "ownerId": owner_id.to_string(), + "ownerIdHex": account_id_hex(owner_id), + "positionNonce": position_nonce.to_string(), + "positionId": position_id.to_string(), + "positionIdHex": account_id_hex(position_id), + "vaultId": vault_id.to_string(), + "vaultIdHex": account_id_hex(vault_id), + }) +} diff --git a/modules/stablecoin/ffi/src/api/request.rs b/modules/stablecoin/ffi/src/api/request.rs index f659238c..6e4e762b 100644 --- a/modules/stablecoin/ffi/src/api/request.rs +++ b/modules/stablecoin/ffi/src/api/request.rs @@ -19,6 +19,23 @@ pub struct DecodeProtocolParametersRequest { pub protocol_parameters: AccountRead, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PositionInfoRequest { + pub stablecoin_program_id: String, + pub owner_id: String, + pub position_nonce: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DecodePositionRequest { + pub stablecoin_program_id: String, + pub owner_id: String, + pub position_nonce: String, + pub position: AccountRead, +} + #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct InitializeProgramPlanRequest { diff --git a/modules/stablecoin/ffi/src/api/tests.rs b/modules/stablecoin/ffi/src/api/tests.rs index 20677be5..09783ebf 100644 --- a/modules/stablecoin/ffi/src/api/tests.rs +++ b/modules/stablecoin/ffi/src/api/tests.rs @@ -6,17 +6,18 @@ use lee_core::{ use risc0_binfmt::ProgramBinary; use serde_json::{json, Value}; use stablecoin_core::{ - compute_protocol_parameters_pda, compute_redemption_price_state_pda, - compute_stability_fee_accumulator_pda, compute_stablecoin_definition_pda, - compute_stablecoin_master_holding_pda, Instruction, ProtocolParameters, + compute_position_pda, compute_position_vault_pda, compute_protocol_parameters_pda, + compute_redemption_price_state_pda, compute_stability_fee_accumulator_pda, + compute_stablecoin_definition_pda, compute_stablecoin_master_holding_pda, Instruction, + Position, ProtocolParameters, }; use token_core::TokenDefinition; use twap_oracle_core::OraclePriceAccount; use super::{ - decode_protocol_parameters, initialize_program_plan, program_info, - DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, - StablecoinResult, + decode_position, decode_protocol_parameters, initialize_program_plan, position_info, + program_info, DecodePositionRequest, DecodeProtocolParametersRequest, + InitializeProgramPlanRequest, PositionInfoRequest, ProgramInfoRequest, StablecoinResult, }; use crate::account::{account_id_hex, account_read, program_id_bytes}; @@ -91,6 +92,43 @@ fn protocol_request(parameters: &ProtocolParameters) -> DecodeProtocolParameters } } +fn position_info_request(owner_id: AccountId, position_nonce: &str) -> PositionInfoRequest { + PositionInfoRequest { + stablecoin_program_id: program_id_hex(), + owner_id: account_id_hex(owner_id), + position_nonce: String::from(position_nonce), + } +} + +fn position(owner_id: AccountId, position_nonce: u64) -> Position { + let position_id = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id, position_nonce); + Position { + owner_account_id: owner_id, + position_nonce, + vault_account_id: compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position_id), + collateral_amount: u128::MAX, + normalized_debt_amount: u128::MAX, + opened_at: u64::MAX, + } +} + +fn position_decode_request( + owner_id: AccountId, + position_nonce: u64, + stored_position: &Position, +) -> DecodePositionRequest { + let position_id = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id, position_nonce); + DecodePositionRequest { + stablecoin_program_id: program_id_hex(), + owner_id: account_id_hex(owner_id), + position_nonce: position_nonce.to_string(), + position: account_read( + position_id, + &account(STABLECOIN_PROGRAM_ID, Data::from(stored_position)), + ), + } +} + fn initialize_request() -> InitializeProgramPlanRequest { let collateral_id = id(10); let stablecoin_definition_id = compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID); @@ -292,6 +330,169 @@ fn protocol_parameters_decode_rejects_wrong_pda_owner_and_non_exact_data() { ); } +#[test] +fn position_info_has_fixed_owner_nonce_and_domain_separated_vectors() { + let value = ok(position_info(position_info_request( + id(0x2a), + "18446744073709551615", + ))); + + assert_eq!( + value["ownerId"], + "3qbR1eZRqXUWroWKKYhbDmR3FfqTHfqSU8zZSxtANzYh" + ); + assert_eq!( + value["ownerIdHex"], + "2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a" + ); + assert_eq!(value["positionNonce"], "18446744073709551615"); + assert_eq!( + value["positionId"], + "DrRDBHo3NRFzMjT75JFiHoL1tXCdfCmv86sAvfq4bJar" + ); + assert_eq!( + value["positionIdHex"], + "bef513b932022feee48d0ab25aa2ffc935395fc473f5fc4148fa8f7d721e888f" + ); + assert_eq!( + value["vaultId"], + "2iAjtRxV42s9SFN8aLwJ4MTzo4BAxBhXqbkEbkWd9YWV" + ); + assert_eq!( + value["vaultIdHex"], + "19678333644b6bad9ced47c8dbb5424611069d5a93bd6f5840904a1e89e9bd6a" + ); + assert_ne!(value["positionIdHex"], value["vaultIdHex"]); +} + +#[test] +fn position_info_rejects_invalid_owner_and_non_exact_nonce() { + for owner_id in [String::from("not-an-id"), "0".repeat(64)] { + let mut request = position_info_request(id(0x2a), "1"); + request.owner_id = owner_id; + assert_error(position_info(request), "invalid_account_id"); + } + + for position_nonce in ["", "-1", "+1", "1.0", "1e3", " 1", "18446744073709551616"] { + assert_error( + position_info(position_info_request(id(0x2a), position_nonce)), + "invalid_numeric_value", + ); + } +} + +#[test] +fn position_decode_preserves_full_integer_range_and_exact_ids() { + let owner_id = id(0x2a); + let stored_position = position(owner_id, u64::MAX); + let value = ok(decode_position(position_decode_request( + owner_id, + u64::MAX, + &stored_position, + ))); + + assert_eq!(value["ownerIdHex"], account_id_hex(owner_id)); + assert_eq!(value["positionNonce"], u64::MAX.to_string()); + assert_eq!(value["collateralAmount"], u128::MAX.to_string()); + assert_eq!(value["normalizedDebtAmount"], u128::MAX.to_string()); + assert_eq!(value["openedAt"], u64::MAX.to_string()); + assert_eq!( + value["positionIdHex"], + account_id_hex(compute_position_pda( + STABLECOIN_PROGRAM_ID, + owner_id, + u64::MAX + )) + ); + assert_eq!( + value["vaultIdHex"], + account_id_hex(stored_position.vault_account_id) + ); +} + +#[test] +fn position_decode_rejects_wrong_address_program_and_stored_identity() { + let owner_id = id(0x2a); + let stored_position = position(owner_id, 7); + + let mut wrong_address = position_decode_request(owner_id, 7, &stored_position); + wrong_address.position.id = account_id_hex(id(0x40)); + assert_error(decode_position(wrong_address), "position_pda_mismatch"); + + let mut wrong_program = position_decode_request(owner_id, 7, &stored_position); + if let Some(account) = &mut wrong_program.position.account { + account.program_owner = hex::encode(program_id_bytes(TOKEN_PROGRAM_ID)); + } + assert_error( + decode_position(wrong_program), + "stablecoin_program_mismatch", + ); + + let wrong_owner = Position { + owner_account_id: id(0x41), + ..stored_position.clone() + }; + assert_error( + decode_position(position_decode_request(owner_id, 7, &wrong_owner)), + "position_owner_mismatch", + ); + + let wrong_nonce = Position { + position_nonce: 8, + ..stored_position.clone() + }; + assert_error( + decode_position(position_decode_request(owner_id, 7, &wrong_nonce)), + "position_nonce_mismatch", + ); + + let wrong_vault = Position { + vault_account_id: id(0x42), + ..stored_position + }; + assert_error( + decode_position(position_decode_request(owner_id, 7, &wrong_vault)), + "position_vault_mismatch", + ); +} + +#[test] +fn position_decode_rejects_failed_reads_and_non_exact_data() { + let owner_id = id(0x2a); + let stored_position = position(owner_id, 7); + + let mut failed_read = position_decode_request(owner_id, 7, &stored_position); + failed_read.position.status = String::from("not_found"); + failed_read.position.account = None; + assert_error(decode_position(failed_read), "account_read_failed"); + + let mut truncated = Data::from(&stored_position).as_ref().to_vec(); + let _ = truncated.pop(); + let truncated_request = DecodePositionRequest { + stablecoin_program_id: program_id_hex(), + owner_id: account_id_hex(owner_id), + position_nonce: String::from("7"), + position: account_read( + compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id, 7), + &account(STABLECOIN_PROGRAM_ID, ok(Data::try_from(truncated))), + ), + }; + assert_error(decode_position(truncated_request), "invalid_position_data"); + + let mut trailing = Data::from(&stored_position).as_ref().to_vec(); + trailing.push(0); + let trailing_request = DecodePositionRequest { + stablecoin_program_id: program_id_hex(), + owner_id: account_id_hex(owner_id), + position_nonce: String::from("7"), + position: account_read( + compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id, 7), + &account(STABLECOIN_PROGRAM_ID, ok(Data::try_from(trailing))), + ), + }; + assert_error(decode_position(trailing_request), "invalid_position_data"); +} + #[test] fn initialize_plan_round_trips_all_boundary_values_and_exact_account_contract() { let request = initialize_request(); diff --git a/modules/stablecoin/ffi/src/ffi.rs b/modules/stablecoin/ffi/src/ffi.rs index 4ac65568..75467d35 100644 --- a/modules/stablecoin/ffi/src/ffi.rs +++ b/modules/stablecoin/ffi/src/ffi.rs @@ -6,8 +6,8 @@ use std::{ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ - self, DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, - StablecoinResult, + self, DecodePositionRequest, DecodeProtocolParametersRequest, InitializeProgramPlanRequest, + PositionInfoRequest, ProgramInfoRequest, StablecoinResult, }; #[derive(Serialize)] @@ -109,6 +109,26 @@ pub unsafe extern "C" fn stablecoin_decode_protocol_parameters( } } +#[unsafe(no_mangle)] +/// Derives the position and collateral-vault account IDs for an owner and nonce. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_position_info(request_json: *const c_char) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { call::(request_json, api::position_info) } +} + +#[unsafe(no_mangle)] +/// Decodes and validates a stablecoin `Position` account. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_decode_position(request_json: *const c_char) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { call::(request_json, api::decode_position) } +} + #[unsafe(no_mangle)] /// Builds the exact wallet submission plan for `InitializeProgram`. /// @@ -142,22 +162,29 @@ mod tests { /// # Safety /// `response` must be a live pointer returned by a `stablecoin_*` operation. - unsafe fn assert_failure_response(response: *mut c_char, expected: &str) { + unsafe fn take_response(response: *mut c_char) -> serde_json::Value { assert!(!response.is_null()); // SAFETY: Forwarded from this helper's caller contract. let text = unsafe { CStr::from_ptr(response) }; let text = match text.to_str() { - Ok(value) => value, + Ok(value) => String::from(value), Err(error) => panic!("{error}"), }; - let value: serde_json::Value = match serde_json::from_str(text) { + // SAFETY: response came from this library and has not been freed. + unsafe { stablecoin_free(response) }; + match serde_json::from_str(&text) { Ok(value) => value, Err(error) => panic!("{error}"), - }; + } + } + + /// # Safety + /// `response` must be a live pointer returned by a `stablecoin_*` operation. + unsafe fn assert_failure_response(response: *mut c_char, expected: &str) { + // SAFETY: Forwarded from this helper's caller contract. + let value = unsafe { take_response(response) }; assert_eq!(value["ok"], false); assert_eq!(value["error"], expected); - // SAFETY: response came from this library and has not been freed. - unsafe { stablecoin_free(response) }; } #[test] @@ -180,6 +207,46 @@ mod tests { unsafe { assert_failure_response(response, "bad_request") }; } + #[test] + fn position_nonce_requires_a_json_string_at_the_c_boundary() { + let request = match CString::new(format!( + r#"{{"stablecoinProgramId":"{}","ownerId":"{}","positionNonce":1}}"#, + "11".repeat(32), + "22".repeat(32), + )) { + Ok(value) => value, + Err(error) => panic!("{error}"), + }; + // SAFETY: request is a live NUL-terminated CString for this call. + let response = unsafe { stablecoin_position_info(request.as_ptr()) }; + // SAFETY: response was returned by stablecoin_position_info and remains live. + unsafe { assert_failure_response(response, "bad_request") }; + } + + #[test] + fn position_info_preserves_max_nonce_through_the_c_boundary() { + let request = match CString::new(format!( + r#"{{"stablecoinProgramId":"{}","ownerId":"{}","positionNonce":"{}"}}"#, + "11".repeat(32), + "2a".repeat(32), + u64::MAX, + )) { + Ok(value) => value, + Err(error) => panic!("{error}"), + }; + // SAFETY: request is a live NUL-terminated CString for this call. + let response = unsafe { stablecoin_position_info(request.as_ptr()) }; + // SAFETY: response was returned by stablecoin_position_info and remains live. + let document = unsafe { take_response(response) }; + + assert_eq!(document["ok"], true); + assert_eq!(document["value"]["positionNonce"], u64::MAX.to_string()); + assert!(document["value"]["positionId"].is_string()); + assert!(document["value"]["positionIdHex"].is_string()); + assert!(document["value"]["vaultId"].is_string()); + assert!(document["value"]["vaultIdHex"].is_string()); + } + #[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 703bfa1a..33c803f9 100644 --- a/modules/stablecoin/ffi/src/lib.rs +++ b/modules/stablecoin/ffi/src/lib.rs @@ -7,7 +7,8 @@ pub mod api; pub use account::{AccountRead, WalletAccount}; pub use api::{ - decode_protocol_parameters, initialize_program_plan, program_info, - DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, - StablecoinApiError, StablecoinResponse, StablecoinResult, + decode_position, decode_protocol_parameters, initialize_program_plan, position_info, + program_info, DecodePositionRequest, DecodeProtocolParametersRequest, + InitializeProgramPlanRequest, PositionInfoRequest, ProgramInfoRequest, StablecoinApiError, + StablecoinResponse, StablecoinResult, }; diff --git a/modules/stablecoin/ffi/tests/public_api.rs b/modules/stablecoin/ffi/tests/public_api.rs index 5db657fa..df73cd74 100644 --- a/modules/stablecoin/ffi/tests/public_api.rs +++ b/modules/stablecoin/ffi/tests/public_api.rs @@ -1,7 +1,7 @@ use stablecoin_ffi::{ - decode_protocol_parameters, initialize_program_plan, program_info, - DecodeProtocolParametersRequest, InitializeProgramPlanRequest, ProgramInfoRequest, - StablecoinResult, + decode_position, decode_protocol_parameters, initialize_program_plan, position_info, + program_info, DecodePositionRequest, DecodeProtocolParametersRequest, + InitializeProgramPlanRequest, PositionInfoRequest, ProgramInfoRequest, StablecoinResult, }; #[test] @@ -9,5 +9,7 @@ fn crate_root_reexports_stablecoin_surface() { let _program_info: fn(ProgramInfoRequest) -> StablecoinResult = program_info; let _decode: fn(DecodeProtocolParametersRequest) -> StablecoinResult = decode_protocol_parameters; + let _position_info: fn(PositionInfoRequest) -> StablecoinResult = position_info; + let _decode_position: fn(DecodePositionRequest) -> StablecoinResult = decode_position; let _initialize: fn(InitializeProgramPlanRequest) -> StablecoinResult = initialize_program_plan; } diff --git a/modules/stablecoin/src/stablecoin_module_impl.cpp b/modules/stablecoin/src/stablecoin_module_impl.cpp index 8ed864b3..2b26e97c 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.cpp +++ b/modules/stablecoin/src/stablecoin_module_impl.cpp @@ -125,6 +125,26 @@ bool hasString(const json& object, const char* key) { return field != object.end() && field->is_string(); } +bool hasUnsignedDecimalString(const json& object, const char* key) { + const std::string value = jsonString(object, key); + return !value.empty() + && std::all_of(value.begin(), value.end(), [](unsigned char character) { + return std::isdigit(character) != 0; + }); +} + +std::string canonicalUnsignedDecimal(const std::string& value) { + if (value.empty() + || !std::all_of(value.begin(), value.end(), [](unsigned char character) { + return std::isdigit(character) != 0; + })) { + return {}; + } + const std::size_t first_nonzero = value.find_first_not_of('0'); + return first_nonzero == std::string::npos ? std::string("0") + : value.substr(first_nonzero); +} + } // namespace std::vector StablecoinModuleImpl::loadStablecoinBinary() const { @@ -278,6 +298,100 @@ LogosMap StablecoinModuleImpl::protocolParameters() { }); } +LogosMap StablecoinModuleImpl::positionAccount(const LogosMap& request) { + return guarded([&]() -> LogosMap { + if (!request.is_object() || !hasString(request, "ownerId") + || !hasString(request, "positionNonce")) { + return publicError("bad_request"); + } + + const std::string position_nonce = jsonString(request, "positionNonce"); + const std::string canonical_nonce = canonicalUnsignedDecimal(position_nonce); + + std::string error; + const json info = stablecoinProgramInfo(error); + if (!info.is_object()) return publicError(error.empty() ? "backend_error" : error); + if (canonical_nonce.empty()) return publicError("invalid_numeric_value"); + + const std::string owner_id = normalizeAccountId(jsonString(request, "ownerId")); + if (owner_id.empty()) return publicError("invalid_account_id"); + + const FfiResult derived = callStablecoin(stablecoin_position_info, { + {"stablecoinProgramId", info["programIdHex"]}, + {"ownerId", owner_id}, + {"positionNonce", position_nonce}, + }); + if (!derived.ok) { + return publicError(stablecoin_module::detail::stableFfiError(derived.error)); + } + + static constexpr const char* identity_fields[] = { + "ownerId", + "ownerIdHex", + "positionNonce", + "positionId", + "positionIdHex", + "vaultId", + "vaultIdHex", + }; + if (std::any_of(std::begin(identity_fields), std::end(identity_fields), + [&](const char* key) { + return !hasString(derived.value, key) + || jsonString(derived.value, key).empty(); + }) + || jsonString(derived.value, "ownerIdHex") != owner_id + || jsonString(derived.value, "positionNonce") != canonical_nonce + || !stablecoin_module::detail::isValidAccountIdHex( + jsonString(derived.value, "positionIdHex")) + || !stablecoin_module::detail::isValidAccountIdHex( + jsonString(derived.value, "vaultIdHex")) + || derived.value["positionIdHex"] == derived.value["vaultIdHex"]) { + return publicError("backend_error"); + } + + const json read = readPublicAccount(jsonString(derived.value, "positionIdHex")); + const std::string status = jsonString(read, "status"); + if (status == "not_found") { + LogosMap result = publicError("not_found"); + result["position"] = derived.value; + return result; + } + if (status != "ok") return publicError("account_read_failed"); + + const FfiResult decoded = callStablecoin(stablecoin_decode_position, { + {"stablecoinProgramId", info["programIdHex"]}, + {"ownerId", owner_id}, + {"positionNonce", position_nonce}, + {"position", read}, + }); + if (!decoded.ok) { + return publicError(stablecoin_module::detail::stableFfiError(decoded.error)); + } + + static constexpr const char* numeric_fields[] = { + "positionNonce", + "collateralAmount", + "normalizedDebtAmount", + "openedAt", + }; + if (std::any_of(std::begin(identity_fields), std::end(identity_fields), + [&](const char* key) { + return !hasString(decoded.value, key) + || decoded.value[key] != derived.value[key]; + }) + || std::any_of(std::begin(numeric_fields), std::end(numeric_fields), + [&](const char* key) { + return !hasUnsignedDecimalString(decoded.value, key); + })) { + return publicError("backend_error"); + } + + LogosMap result = publicOk(); + result["position"] = decoded.value; + return result; + }); +} + LogosMap StablecoinModuleImpl::submitPlan(const nlohmann::json& plan) { const auto accounts_field = plan.find("accountIds"); const auto signers_field = plan.find("signingRequirements"); diff --git a/modules/stablecoin/src/stablecoin_module_impl.h b/modules/stablecoin/src/stablecoin_module_impl.h index c87d5ba0..fe6a7a62 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.h +++ b/modules/stablecoin/src/stablecoin_module_impl.h @@ -24,6 +24,11 @@ class StablecoinModuleImpl : public LogosModuleContext { /// Success adds `protocolParameters`; failures use stable error codes. LogosMap protocolParameters(); + /// Resolves and reads one position from `ownerId` plus exact decimal-string + /// `positionNonce`. Success adds `position`. Missing state returns + /// `not_found` with the derived position and vault IDs. + LogosMap positionAccount(const LogosMap& request); + /// Initializes the stablecoin protocol. Request fields are `adminId`, /// `freezeAuthorityId`, `collateralDefinitionId`, `marketPriceOracleId`, /// `initialStabilityFeePerMillisecond`, diff --git a/modules/stablecoin/src/stablecoin_module_support.cpp b/modules/stablecoin/src/stablecoin_module_support.cpp index a8a46e5f..2fac2ee4 100644 --- a/modules/stablecoin/src/stablecoin_module_support.cpp +++ b/modules/stablecoin/src/stablecoin_module_support.cpp @@ -162,11 +162,16 @@ std::string stableFfiError(const std::string& error) { "invalid_collateral_definition", "invalid_market_price_oracle", "invalid_numeric_value", + "invalid_position_data", "invalid_program_binary", "invalid_program_id", "invalid_protocol_parameters_data", "invalid_stablecoin_name", "oracle_asset_mismatch", + "position_nonce_mismatch", + "position_owner_mismatch", + "position_pda_mismatch", + "position_vault_mismatch", "program_id_mismatch", "protocol_parameters_pda_mismatch", "stablecoin_program_mismatch", diff --git a/modules/stablecoin/tests/CMakeLists.txt b/modules/stablecoin/tests/CMakeLists.txt index a54ff781..2582524c 100644 --- a/modules/stablecoin/tests/CMakeLists.txt +++ b/modules/stablecoin/tests/CMakeLists.txt @@ -4,12 +4,20 @@ project(StablecoinModuleTests LANGUAGES CXX) include(LogosTest) logos_test( - NAME stablecoin_module_support_tests + NAME stablecoin_module_tests MODULE_SOURCES + ../src/stablecoin_module_impl.cpp ../src/stablecoin_module_support.cpp TEST_SOURCES main.cpp + stablecoin_module_impl_test.cpp stablecoin_module_support_test.cpp + MOCK_C_SOURCES + mocks/mock_stablecoin_ffi.cpp + GENERATED_SOURCES + ../generated_code/lez_core_api.cpp EXTRA_INCLUDES ../src + ../ffi/include + ../generated_code ) diff --git a/modules/stablecoin/tests/logos_sdk.h b/modules/stablecoin/tests/logos_sdk.h new file mode 100644 index 00000000..45dbb53f --- /dev/null +++ b/modules/stablecoin/tests/logos_sdk.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "lez_core_api.h" + +// Test adapter between the universal module's std-based generated API and the +// Qt test framework's generated dependency client. +class UniversalLezCore { +public: + explicit UniversalLezCore(LogosAPI* api) + : qt_(api) { } + + std::string account_id_from_base58(const std::string& base58) { + return qt_.account_id_from_base58(QString::fromStdString(base58)).toStdString(); + } + + std::string get_account_public(const std::string& account_id, + logos::CallError* error = nullptr) { + return qt_.get_account_public(QString::fromStdString(account_id), error).toStdString(); + } + + std::string send_generic_public_transaction( + const std::vector& account_ids, + const std::vector& signing_requirements, + const std::vector& instruction, + const std::string& program_id, + logos::CallError* error = nullptr) { + QStringList qt_account_ids; + qt_account_ids.reserve(static_cast(account_ids.size())); + for (const auto& account_id : account_ids) { + qt_account_ids.push_back(QString::fromStdString(account_id)); + } + + QVariantList qt_signing_requirements; + qt_signing_requirements.reserve( + static_cast(signing_requirements.size())); + for (const bool required : signing_requirements) { + qt_signing_requirements.push_back(required); + } + + const QByteArray qt_instruction( + reinterpret_cast(instruction.data()), + static_cast(instruction.size())); + return qt_.send_generic_public_transaction( + qt_account_ids, + qt_signing_requirements, + QVariant(qt_instruction), + QString::fromStdString(program_id), + error) + .toStdString(); + } + +private: + LezCore qt_; +}; + +struct LogosModules { + explicit LogosModules(LogosAPI* api) + : lez_core(api) { } + + UniversalLezCore lez_core; +}; diff --git a/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp new file mode 100644 index 00000000..d6b7c307 --- /dev/null +++ b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp @@ -0,0 +1,49 @@ +#include +#include + +#include + +extern "C" { +#include "stablecoin_ffi.h" +} + +namespace { + +char* copyMockResponse(const char* function_name) { + LOGOS_CMOCK_RECORD(function_name); + const char* response = LOGOS_CMOCK_RETURN_STRING(function_name); + if (response == nullptr) return nullptr; + + const std::size_t size = std::strlen(response) + 1; + auto* copy = static_cast(std::malloc(size)); + if (copy == nullptr) return nullptr; + std::memcpy(copy, response, size); + return copy; +} + +} // namespace + +extern "C" char* stablecoin_program_info(const char*) { + return copyMockResponse("stablecoin_program_info"); +} + +extern "C" char* stablecoin_decode_protocol_parameters(const char*) { + return copyMockResponse("stablecoin_decode_protocol_parameters"); +} + +extern "C" char* stablecoin_position_info(const char*) { + return copyMockResponse("stablecoin_position_info"); +} + +extern "C" char* stablecoin_decode_position(const char*) { + return copyMockResponse("stablecoin_decode_position"); +} + +extern "C" char* stablecoin_initialize_program_plan(const char*) { + return copyMockResponse("stablecoin_initialize_program_plan"); +} + +extern "C" void stablecoin_free(char* value) { + LOGOS_CMOCK_RECORD("stablecoin_free"); + std::free(value); +} diff --git a/modules/stablecoin/tests/stablecoin_module_impl_test.cpp b/modules/stablecoin/tests/stablecoin_module_impl_test.cpp new file mode 100644 index 00000000..5ed0754a --- /dev/null +++ b/modules/stablecoin/tests/stablecoin_module_impl_test.cpp @@ -0,0 +1,327 @@ +#include "stablecoin_module_impl.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "logos_sdk.h" + +namespace { + +using json = nlohmann::json; + +const std::string PROGRAM_ID_HEX(64, '1'); +const std::string OWNER_ID_HEX(64, '2'); +const std::string POSITION_ID_HEX(64, '3'); +const std::string VAULT_ID_HEX(64, '4'); +const std::string MAX_U64 = "18446744073709551615"; +const std::string MAX_U128 = "340282366920938463463374607431768211455"; + +class ScopedEnvironment { +public: + ScopedEnvironment(std::string name, const char* value) + : name_(std::move(name)) { + if (const char* previous = std::getenv(name_.c_str()); previous != nullptr) { + previous_ = previous; + } + if (value == nullptr) { + unsetenv(name_.c_str()); + } else { + setenv(name_.c_str(), value, 1); + } + } + + ~ScopedEnvironment() { + if (previous_.has_value()) { + setenv(name_.c_str(), previous_->c_str(), 1); + } else { + unsetenv(name_.c_str()); + } + } + + ScopedEnvironment(const ScopedEnvironment&) = delete; + ScopedEnvironment& operator=(const ScopedEnvironment&) = delete; + +private: + std::string name_; + std::optional previous_; +}; + +json programInfoValue() { + return { + {"programId", "program-id"}, + {"programIdHex", PROGRAM_ID_HEX}, + {"protocolParametersId", "protocol-parameters-id"}, + {"protocolParametersIdHex", std::string(64, '5')}, + {"stabilityFeeAccumulatorId", "stability-fee-accumulator-id"}, + {"stabilityFeeAccumulatorIdHex", std::string(64, '6')}, + {"redemptionPriceStateId", "redemption-price-state-id"}, + {"redemptionPriceStateIdHex", std::string(64, '7')}, + {"stablecoinDefinitionId", "stablecoin-definition-id"}, + {"stablecoinDefinitionIdHex", std::string(64, '8')}, + {"stablecoinMasterHoldingId", "stablecoin-master-holding-id"}, + {"stablecoinMasterHoldingIdHex", std::string(64, '9')}, + {"clockId", "clock-id"}, + {"clockIdHex", std::string(64, 'a')}, + }; +} + +json positionIdentityValue() { + return { + {"ownerId", "owner-id"}, + {"ownerIdHex", OWNER_ID_HEX}, + {"positionNonce", MAX_U64}, + {"positionId", "position-id"}, + {"positionIdHex", POSITION_ID_HEX}, + {"vaultId", "vault-id"}, + {"vaultIdHex", VAULT_ID_HEX}, + }; +} + +json decodedPositionValue() { + json value = positionIdentityValue(); + value["collateralAmount"] = MAX_U128; + value["normalizedDebtAmount"] = MAX_U128; + value["openedAt"] = MAX_U64; + return value; +} + +std::string successEnvelope(const json& value) { + return json{{"ok", true}, {"value", value}}.dump(); +} + +std::string failureEnvelope(const std::string& error) { + return json{{"ok", false}, {"error", error}}.dump(); +} + +const std::string PROGRAM_INFO_RESPONSE = successEnvelope(programInfoValue()); +const std::string POSITION_INFO_RESPONSE = successEnvelope(positionIdentityValue()); +const std::string DECODED_POSITION_RESPONSE = successEnvelope(decodedPositionValue()); +const std::string INVALID_NONCE_RESPONSE = failureEnvelope("invalid_numeric_value"); +const std::string VAULT_MISMATCH_RESPONSE = failureEnvelope("position_vault_mismatch"); + +std::string initializedAccount() { + return json{ + {"program_owner", PROGRAM_ID_HEX}, + {"balance", std::string(32, '0')}, + {"nonce", std::string(32, '0')}, + {"data", "00"}, + }.dump(); +} + +std::string missingAccount() { + return json{ + {"program_owner", std::string(64, '0')}, + {"balance", std::string(32, '0')}, + {"nonce", std::string(32, '0')}, + {"data", ""}, + }.dump(); +} + +void attachModules(StablecoinModuleImpl& module, LogosModules& modules) { + module._logosCoreSetLogosModulesPtr_(&modules); +} + +void configureProgramInfo(LogosTestContext& context) { + context.mockCFunction("stablecoin_program_info").returns(PROGRAM_INFO_RESPONSE); +} + +void assertError(const LogosMap& response, const std::string& error) { + LOGOS_ASSERT_EQ(response["status"].get(), std::string("error")); + LOGOS_ASSERT_EQ(response["error"].get(), error); +} + +} // namespace + +LOGOS_TEST(position_account_reads_once_and_returns_exact_snapshot) { + ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); + ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + + const json identity = positionIdentityValue(); + const json decoded = decodedPositionValue(); + configureProgramInfo(context); + context.mockCFunction("stablecoin_position_info").returns(POSITION_INFO_RESPONSE); + context.mockCFunction("stablecoin_decode_position").returns(DECODED_POSITION_RESPONSE); + context.mockModule("lez_core", "account_id_from_base58").returns(OWNER_ID_HEX); + context.mockModule("lez_core", "get_account_public").returns(initializedAccount()); + + const LogosMap response = module.positionAccount({ + {"ownerId", "owner-base58"}, + {"positionNonce", MAX_U64}, + }); + + LOGOS_ASSERT_EQ(response["status"].get(), std::string("ok")); + LOGOS_ASSERT_EQ(response["error"].get(), std::string()); + LOGOS_ASSERT_EQ(response["position"], decoded); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 1); + LOGOS_ASSERT_TRUE(context.moduleCalledWith( + "lez_core", + "get_account_public", + QVariantList{QVariant(QString::fromStdString(POSITION_ID_HEX))})); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_position_info"), 1); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_decode_position"), 1); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "account_id_from_base58"), 1); +} + +LOGOS_TEST(position_account_returns_derived_ids_when_position_is_absent) { + 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 identity = positionIdentityValue(); + configureProgramInfo(context); + context.mockCFunction("stablecoin_position_info").returns(POSITION_INFO_RESPONSE); + context.mockModule("lez_core", "get_account_public").returns(missingAccount()); + + const LogosMap response = module.positionAccount({ + {"ownerId", OWNER_ID_HEX}, + {"positionNonce", MAX_U64}, + }); + + assertError(response, "not_found"); + LOGOS_ASSERT_EQ(response["position"], identity); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 1); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_decode_position"), 0); +} + +LOGOS_TEST(position_account_rejects_malformed_wallet_response_without_decoding) { + 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); + + configureProgramInfo(context); + context.mockCFunction("stablecoin_position_info").returns(POSITION_INFO_RESPONSE); + context.mockModule("lez_core", "get_account_public").returns("not-json"); + + const LogosMap response = module.positionAccount({ + {"ownerId", OWNER_ID_HEX}, + {"positionNonce", MAX_U64}, + }); + + assertError(response, "account_read_failed"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 1); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_decode_position"), 0); +} + +LOGOS_TEST(position_account_preserves_stable_decoder_errors) { + ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); + ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + + configureProgramInfo(context); + context.mockCFunction("stablecoin_position_info").returns(POSITION_INFO_RESPONSE); + context.mockCFunction("stablecoin_decode_position").returns(VAULT_MISMATCH_RESPONSE); + context.mockModule("lez_core", "get_account_public").returns(initializedAccount()); + + const LogosMap response = module.positionAccount({ + {"ownerId", OWNER_ID_HEX}, + {"positionNonce", MAX_U64}, + }); + + assertError(response, "position_vault_mismatch"); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_decode_position"), 1); +} + +LOGOS_TEST(position_account_rejects_non_string_request_fields_before_io) { + 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 LogosMap response = module.positionAccount({ + {"ownerId", OWNER_ID_HEX}, + {"positionNonce", 1}, + }); + + assertError(response, "bad_request"); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_program_info"), 0); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_position_info"), 0); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 0); +} + +LOGOS_TEST(position_account_rejects_non_decimal_nonce_before_account_io) { + 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); + + configureProgramInfo(context); + + const LogosMap response = module.positionAccount({ + {"ownerId", OWNER_ID_HEX}, + {"positionNonce", "1e3"}, + }); + + assertError(response, "invalid_numeric_value"); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_program_info"), 1); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_position_info"), 0); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 0); +} + +LOGOS_TEST(position_account_propagates_invalid_nonce_without_reading) { + 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); + + configureProgramInfo(context); + context.mockCFunction("stablecoin_position_info").returns(INVALID_NONCE_RESPONSE); + + const LogosMap response = module.positionAccount({ + {"ownerId", OWNER_ID_HEX}, + {"positionNonce", "18446744073709551616"}, + }); + + assertError(response, "invalid_numeric_value"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 0); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_decode_position"), 0); +} + +LOGOS_TEST(position_account_rejects_inconsistent_decoder_identity) { + 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); + + json decoded = decodedPositionValue(); + decoded["vaultIdHex"] = std::string(64, 'b'); + const std::string decoded_response = successEnvelope(decoded); + configureProgramInfo(context); + context.mockCFunction("stablecoin_position_info").returns(POSITION_INFO_RESPONSE); + context.mockCFunction("stablecoin_decode_position").returns(decoded_response); + context.mockModule("lez_core", "get_account_public").returns(initializedAccount()); + + const LogosMap response = module.positionAccount({ + {"ownerId", OWNER_ID_HEX}, + {"positionNonce", MAX_U64}, + }); + + assertError(response, "backend_error"); +} diff --git a/modules/stablecoin/tests/stablecoin_module_support_test.cpp b/modules/stablecoin/tests/stablecoin_module_support_test.cpp index e300039e..68a03cff 100644 --- a/modules/stablecoin/tests/stablecoin_module_support_test.cpp +++ b/modules/stablecoin/tests/stablecoin_module_support_test.cpp @@ -100,6 +100,9 @@ LOGOS_TEST(ffi_error_mapping_preserves_only_public_codes) { LOGOS_ASSERT_EQ( stablecoin_module::detail::stableFfiError("invalid_numeric_value"), std::string("invalid_numeric_value")); + LOGOS_ASSERT_EQ( + stablecoin_module::detail::stableFfiError("position_vault_mismatch"), + std::string("position_vault_mismatch")); LOGOS_ASSERT_EQ( stablecoin_module::detail::stableFfiError("internal parse detail"), std::string("backend_error"));