diff --git a/modules/stablecoin/README.md b/modules/stablecoin/README.md index 9ccb5c1f..887c438d 100644 --- a/modules/stablecoin/README.md +++ b/modules/stablecoin/README.md @@ -3,8 +3,8 @@ `stablecoin_module` is a headless Logos `core` module for the LEZ Stablecoin Program. It exposes deployment discovery, protocol-state, position, and position-health reads, protocol initialization, position opening, collateral -deposits, and debt repayment through the same universal API used by -`logoscore` and UI modules. +deposits, debt repayment, and collateral withdrawal 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 @@ -312,6 +312,59 @@ and missing public wallet signers prevent submission. Success returns `{status: "ok", error: "", transactionId: "..."}`. Wallet rejection or transport failure returns `wallet_submission_failed`, without a transaction ID or retry. +### `withdrawCollateral(request)` + +Accepts `ownerId`, `positionNonce` (exact `u64` decimal string), +`userCollateralHoldingId`, and `amount` (exact `u128` decimal string or lossless +JSON integer). Account IDs accept base58 or hexadecimal. Floats, negative +amounts, malformed decimals and values outside the declared types are rejected. + +The module derives Position, Vault and global IDs internally and reads current +Position, Vault, destination holding, Protocol Parameters, Stability Fee +Accumulator, Redemption Price State and canonical `CLOCK_01`. It validates +canonical identities, stablecoin ownership, exact data, stored Position +owner/nonce/vault identity, and collateral definition/Token Program consistency. +The destination must differ from the owner and Vault, preserving the runtime's +distinct-account requirement. Only the Position owner must be a public account +controlled by the active wallet and signs; the destination holding can be +outside that wallet. The program +authorizes the outgoing Token transfer with the Vault PDA seed. + +Account order is owner, Position, Vault, destination collateral holding, +Stability Fee Accumulator, Redemption Price State, Protocol Parameters, +`CLOCK_01`. All `init` flags are false. Recorded Position collateral bounds the +amount; a direct vault donation is unavailable until `depositCollateral` +reconciles it. Preflight also checks live vault availability and destination +balance overflow, including inconsistent observations from separate RPC reads. + +Frozen withdrawals fail, including zero amounts. For nonzero withdrawals with +nonzero debt, preflight checks the remaining collateral using the program's +wide projections, canonical clock and seven-day clamp: + +```text +remainingCollateral * FIXED_POINT_ONE^3 >= normalizedDebt + * currentAccumulator * currentRedemptionPrice * minimumCollateralizationRatio +``` + +Requirement multiplication saturates conservatively in U512. A zero projected +redemption price is rejected for an indebted, nonzero withdrawal. No nominal +debt flooring, narrowing to `u128`, market-oracle read or controller gate is +used. This operation uses the program's conservative wide projection, rather +than the exact-display requirements of `positionHealth`. + +Zero amounts and zero-debt positions skip projections and the health gate, but +still require valid Position, global, clock and token accounts, an unfrozen +protocol and the owner's wallet signature. Every call reads current state; +an earlier health quote does not authorize a later withdrawal. + +Stable errors include `protocol_frozen`, `withdraw_amount_exceeds_collateral`, +`position_undercollateralized`, `redemption_price_zero`, +`insufficient_vault_balance`, `collateral_amount_overflow` and the existing +numeric/account/PDA/ownership/token-binding errors. Failed preflight submits +nothing. Success returns `{status: "ok", error: "", transactionId: "..."}`; +wallet rejection or transport failure returns `wallet_submission_failed` +without a successful transaction ID or automatic retry. + ## Runtime configuration Set either environment variable on the process hosting the module: diff --git a/modules/stablecoin/ffi/include/stablecoin_ffi.h b/modules/stablecoin/ffi/include/stablecoin_ffi.h index 3a05e449..5f8cbac1 100644 --- a/modules/stablecoin/ffi/include/stablecoin_ffi.h +++ b/modules/stablecoin/ffi/include/stablecoin_ffi.h @@ -150,6 +150,14 @@ char *stablecoin_repay_debt_plan(const char *request_json); */ char *stablecoin_position_health(const char *request_json); +/** + * Builds a preflighted owner-signed plan for `WithdrawCollateral`. + * + * # Safety + * `request_json` must be null or point to a live NUL-terminated byte string. + */ +char *stablecoin_withdraw_collateral_plan(const char *request_json); + /** * Releases a string returned by a `stablecoin_*` operation. * diff --git a/modules/stablecoin/ffi/src/api/mod.rs b/modules/stablecoin/ffi/src/api/mod.rs index 256ca869..190df8b5 100644 --- a/modules/stablecoin/ffi/src/api/mod.rs +++ b/modules/stablecoin/ffi/src/api/mod.rs @@ -8,6 +8,7 @@ mod program; mod projection; mod quote; mod request; +mod withdraw; #[cfg(test)] mod tests; @@ -21,6 +22,9 @@ mod repay_tests; #[cfg(test)] mod health_tests; +#[cfg(test)] +mod withdraw_tests; + use std::{error::Error, fmt}; pub use decode::{ @@ -43,9 +47,10 @@ pub use request::{ InitializeProgramPlanRequest, OpenPositionPlanRequest, PositionAddressesRequest, PositionHealthRequest, PositionInfoRequest, ProgramInfoRequest, RedemptionRateUpdateQuoteRequest, RefreshGlobalsPlanRequest, RepayDebtPlanRequest, - UpdateRedemptionRatePlanRequest, + UpdateRedemptionRatePlanRequest, WithdrawCollateralPlanRequest, }; use serde_json::Value; +pub use withdraw::withdraw_collateral_plan; use crate::account::parse_program_id; diff --git a/modules/stablecoin/ffi/src/api/plan.rs b/modules/stablecoin/ffi/src/api/plan.rs index 08a88e81..d40b11a7 100644 --- a/modules/stablecoin/ffi/src/api/plan.rs +++ b/modules/stablecoin/ffi/src/api/plan.rs @@ -436,7 +436,7 @@ fn require_ready_quote(quote: &Value) -> Result<(), StablecoinApiError> { Err(StablecoinApiError::new(code)) } -fn required_account( +pub(super) fn required_account( read: &AccountRead, ) -> Result<(AccountId, lee_core::account::Account), StablecoinApiError> { decode_account(read).map_err(|_| StablecoinApiError::new("account_read_failed")) @@ -470,7 +470,7 @@ fn decimal_text(value: &str) -> Result<&str, StablecoinApiError> { Ok(unquoted) } -fn parse_u128(value: &Value) -> Result { +pub(super) fn parse_u128(value: &Value) -> Result { match value { Value::Number(number) => number .as_u64() @@ -542,7 +542,7 @@ fn parse_i128(value: &Value) -> Result { } } -fn plan_response( +pub(super) fn plan_response( program_id: lee_core::program::ProgramId, account_ids: [AccountId; ACCOUNT_COUNT], signing_requirements: [bool; ACCOUNT_COUNT], diff --git a/modules/stablecoin/ffi/src/api/request.rs b/modules/stablecoin/ffi/src/api/request.rs index 4a02b07b..fd42fac4 100644 --- a/modules/stablecoin/ffi/src/api/request.rs +++ b/modules/stablecoin/ffi/src/api/request.rs @@ -186,3 +186,20 @@ pub struct RepayDebtPlanRequest { pub protocol_parameters: AccountRead, pub clock: AccountRead, } + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WithdrawCollateralPlanRequest { + pub stablecoin_program_id: String, + pub owner_id: String, + pub position_nonce: String, + pub amount: Value, + pub user_collateral_holding_id: String, + pub position: AccountRead, + pub vault: AccountRead, + pub user_collateral_holding: AccountRead, + pub stability_fee_accumulator: AccountRead, + pub redemption_price_state: AccountRead, + pub protocol_parameters: AccountRead, + pub clock: AccountRead, +} diff --git a/modules/stablecoin/ffi/src/api/withdraw.rs b/modules/stablecoin/ffi/src/api/withdraw.rs new file mode 100644 index 00000000..05a57e5e --- /dev/null +++ b/modules/stablecoin/ffi/src/api/withdraw.rs @@ -0,0 +1,142 @@ +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use stablecoin_core::{ + collateralization::collateralization_values, + compute_position_vault_pda, + math::{compute_current_accumulated_rate_wide, compute_current_redemption_price_wide}, + Instruction, +}; +use token_core::TokenHolding; + +use super::{ + decode::{ + validated_protocol_parameters, validated_redemption_price_state, + validated_stability_fee_accumulator, + }, + parse_stablecoin_program_id, + plan::{parse_account_id, parse_decimal_u64, parse_u128, plan_response, required_account}, + position::validated_position, + projection::clock_timestamp, + StablecoinApiError, StablecoinResult, WithdrawCollateralPlanRequest, +}; + +/// Validate and plan withdrawal using the native program's post-withdrawal health gate. +pub fn withdraw_collateral_plan(request: WithdrawCollateralPlanRequest) -> StablecoinResult { + let program_id = parse_stablecoin_program_id(&request.stablecoin_program_id)?; + let owner = parse_account_id(&request.owner_id)?; + let nonce = parse_decimal_u64(&request.position_nonce)?; + let amount = parse_u128(&request.amount)?; + let destination_id = parse_account_id(&request.user_collateral_holding_id)?; + let (parameters_id, parameters) = + validated_protocol_parameters(program_id, &request.protocol_parameters)?; + if parameters.is_frozen { + return Err(StablecoinApiError::new("protocol_frozen")); + } + let (accumulator_id, accumulator) = + validated_stability_fee_accumulator(program_id, &request.stability_fee_accumulator)?; + let (redemption_id, redemption) = + validated_redemption_price_state(program_id, &request.redemption_price_state)?; + let now = clock_timestamp(&request.clock)?; + let (position_id, position) = validated_position(program_id, owner, nonce, &request.position)?; + let expected_vault = compute_position_vault_pda(program_id, position_id); + if position.vault_account_id != expected_vault { + return Err(StablecoinApiError::new("position_vault_mismatch")); + } + let (vault_id, vault_account) = required_account(&request.vault)?; + if vault_id != expected_vault { + return Err(StablecoinApiError::new("vault_pda_mismatch")); + } + // The runtime rejects duplicate public account IDs. These are the two + // destination aliases compatible with the destination's token type. + if destination_id == vault_id || destination_id == owner { + return Err(StablecoinApiError::new("invalid_user_collateral_holding")); + } + let vault_holding = TokenHolding::try_from(&vault_account.data) + .map_err(|_| StablecoinApiError::new("invalid_position_vault"))?; + let vault_balance = match vault_holding { + TokenHolding::Fungible { + definition_id, + balance, + } if definition_id == parameters.collateral_definition_id => balance, + TokenHolding::Fungible { .. } => { + return Err(StablecoinApiError::new("collateral_definition_mismatch")) + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + return Err(StablecoinApiError::new("invalid_position_vault")) + } + }; + let (read_destination_id, destination_account) = + required_account(&request.user_collateral_holding)?; + if read_destination_id != destination_id { + return Err(StablecoinApiError::new("invalid_user_collateral_holding")); + } + if destination_account.program_owner != vault_account.program_owner { + return Err(StablecoinApiError::new("token_program_mismatch")); + } + let destination = TokenHolding::try_from(&destination_account.data) + .map_err(|_| StablecoinApiError::new("invalid_user_collateral_holding"))?; + let destination_balance = match destination { + TokenHolding::Fungible { + definition_id, + balance, + } if definition_id == parameters.collateral_definition_id => balance, + TokenHolding::Fungible { .. } => { + return Err(StablecoinApiError::new("collateral_definition_mismatch")) + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + return Err(StablecoinApiError::new("invalid_user_collateral_holding")) + } + }; + let remaining = position + .collateral_amount + .checked_sub(amount) + .ok_or_else(|| StablecoinApiError::new("withdraw_amount_exceeds_collateral"))?; + if vault_balance < amount { + return Err(StablecoinApiError::new("insufficient_vault_balance")); + } + destination_balance + .checked_add(amount) + .ok_or_else(|| StablecoinApiError::new("collateral_amount_overflow"))?; + if amount != 0 && position.normalized_debt_amount != 0 { + let price = compute_current_redemption_price_wide( + redemption.redemption_price_at_last_update, + redemption.redemption_rate_per_millisecond, + redemption.last_updated_at, + now, + ); + if price.is_zero() { + return Err(StablecoinApiError::new("redemption_price_zero")); + } + let accumulator = compute_current_accumulated_rate_wide( + accumulator.accumulated_rate_at_last_accrual, + parameters.stability_fee_per_millisecond, + accumulator.last_accrued_at, + now, + ); + if !collateralization_values( + remaining, + position.normalized_debt_amount, + accumulator, + price, + parameters.minimum_collateralization_ratio, + ) + .is_collateralized() + { + return Err(StablecoinApiError::new("position_undercollateralized")); + } + } + plan_response( + program_id, + [ + owner, + position_id, + vault_id, + destination_id, + accumulator_id, + redemption_id, + parameters_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + [true, false, false, false, false, false, false, false], + Instruction::WithdrawCollateral { amount }, + ) +} diff --git a/modules/stablecoin/ffi/src/api/withdraw_tests.rs b/modules/stablecoin/ffi/src/api/withdraw_tests.rs new file mode 100644 index 00000000..4dbad990 --- /dev/null +++ b/modules/stablecoin/ffi/src/api/withdraw_tests.rs @@ -0,0 +1,588 @@ +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use lee_core::{ + account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, + program::ProgramId, +}; +use serde_json::{json, Value}; +use stablecoin_core::{ + compute_position_pda, compute_position_vault_pda, compute_position_vault_pda_seed, + compute_protocol_parameters_pda, compute_redemption_price_state_pda, + compute_stability_fee_accumulator_pda, compute_stablecoin_definition_pda, + math::FIXED_POINT_ONE, Instruction, Position, ProtocolParameters, RedemptionPriceState, + StabilityFeeAccumulator, RATE_DELTA_CLAMP, +}; +use token_core::TokenHolding; + +use super::{withdraw_collateral_plan, WithdrawCollateralPlanRequest}; +use crate::{ + account::{account_id_hex, account_read, decode_account, program_id_bytes}, + AccountRead, +}; + +const PROGRAM: ProgramId = [0x11; 8]; +const TOKEN_PROGRAM: ProgramId = [0x22; 8]; +const START: u64 = 1_000; +const NONCE: u64 = u64::MAX; +fn id(seed: u8) -> AccountId { + AccountId::new([seed; 32]) +} +fn read(account_id: AccountId, owner: ProgramId, data: Data) -> AccountRead { + account_read( + account_id, + &Account { + program_owner: owner, + data, + balance: 0, + nonce: Nonce(0), + }, + ) +} + +struct Fixture { + position: Position, + parameters: ProtocolParameters, + accumulator: StabilityFeeAccumulator, + redemption: RedemptionPriceState, + vault_balance: u128, + destination_balance: u128, + now: u64, +} +impl Fixture { + fn new(collateral: u128, debt: u128) -> Self { + let position_id = compute_position_pda(PROGRAM, id(20), NONCE); + Self { + position: Position { + owner_account_id: id(20), + position_nonce: NONCE, + vault_account_id: compute_position_vault_pda(PROGRAM, position_id), + collateral_amount: collateral, + normalized_debt_amount: debt, + opened_at: START, + }, + parameters: ProtocolParameters { + admin_account_id: id(1), + freeze_authority_account_id: id(2), + stablecoin_definition_id: compute_stablecoin_definition_pda(PROGRAM), + collateral_definition_id: id(4), + market_price_oracle_id: id(5), + stability_fee_per_millisecond: FIXED_POINT_ONE, + controller_proportional_gain: 0, + controller_integral_gain: 0, + minimum_collateralization_ratio: FIXED_POINT_ONE * 11 / 10, + minimum_milliseconds_between_rate_updates: 50, + maximum_oracle_price_age_milliseconds: 50, + is_frozen: false, + }, + accumulator: StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: FIXED_POINT_ONE, + last_accrued_at: START, + }, + redemption: RedemptionPriceState { + redemption_price_at_last_update: FIXED_POINT_ONE, + redemption_rate_per_millisecond: FIXED_POINT_ONE, + controller_integral_term: 0, + last_updated_at: START, + }, + vault_balance: collateral, + destination_balance: 0, + now: START, + } + } + + fn request(&self, amount: Value) -> WithdrawCollateralPlanRequest { + WithdrawCollateralPlanRequest { + stablecoin_program_id: hex::encode(program_id_bytes(PROGRAM)), + owner_id: account_id_hex(id(20)), + position_nonce: NONCE.to_string(), + amount, + user_collateral_holding_id: account_id_hex(id(21)), + position: read( + compute_position_pda(PROGRAM, id(20), NONCE), + PROGRAM, + Data::from(&self.position), + ), + vault: read( + self.position.vault_account_id, + TOKEN_PROGRAM, + Data::from(&TokenHolding::Fungible { + definition_id: id(4), + balance: self.vault_balance, + }), + ), + user_collateral_holding: read( + id(21), + TOKEN_PROGRAM, + Data::from(&TokenHolding::Fungible { + definition_id: id(4), + balance: self.destination_balance, + }), + ), + stability_fee_accumulator: read( + compute_stability_fee_accumulator_pda(PROGRAM), + PROGRAM, + Data::from(&self.accumulator), + ), + redemption_price_state: read( + compute_redemption_price_state_pda(PROGRAM), + PROGRAM, + Data::from(&self.redemption), + ), + protocol_parameters: read( + compute_protocol_parameters_pda(PROGRAM), + PROGRAM, + Data::from(&self.parameters), + ), + clock: read( + CLOCK_01_PROGRAM_ACCOUNT_ID, + [0x44; 8], + Data::try_from( + ClockAccountData { + block_id: 1, + timestamp: self.now, + } + .to_bytes(), + ) + .expect("clock fits"), + ), + } + } +} + +fn error(request: WithdrawCollateralPlanRequest, expected: &str) { + assert_eq!( + withdraw_collateral_plan(request) + .expect_err("preflight must reject") + .code(), + expected + ); +} + +fn execute(fixture: &Fixture, amount: u128) { + let request = fixture.request(json!(amount.to_string())); + let plan = withdraw_collateral_plan(request.clone()).expect("accepted plan"); + let reads = [ + &request.position, + &request.vault, + &request.user_collateral_holding, + &request.stability_fee_accumulator, + &request.redemption_price_state, + &request.protocol_parameters, + &request.clock, + ]; + let ids: Vec = serde_json::from_value(plan["accountIds"].clone()).expect("account ids"); + let signers: Vec = + serde_json::from_value(plan["signingRequirements"].clone()).expect("signers"); + let inputs: Vec<_> = ids + .iter() + .zip(signers) + .map(|(account_id, signer)| { + let (account_id, account) = if account_id == &request.owner_id { + (id(20), Account::default()) + } else { + decode_account( + reads + .iter() + .find(|read| &read.id == account_id) + .expect("planned read"), + ) + .expect("account") + }; + AccountWithMetadata::new(account, signer, account_id) + }) + .collect(); + let [owner, position, vault, destination, accumulator, redemption, parameters, clock]: [_; 8] = + inputs.try_into().expect("eight inputs"); + assert!(!vault.is_authorized); + assert!(!destination.is_authorized); + let words: Vec = serde_json::from_value(plan["instruction"].clone()).expect("words"); + let Instruction::WithdrawCollateral { amount } = + risc0_zkvm::serde::from_slice(&words).expect("instruction") + else { + panic!("expected withdrawal"); + }; + let (posts, calls) = stablecoin_program::withdraw_collateral::withdraw_collateral( + owner, + position, + vault, + destination, + accumulator, + redemption, + parameters, + clock, + PROGRAM, + amount, + ); + let actual = + Position::try_from(&posts.get(1).expect("position post").account().data).expect("position"); + let mut expected = fixture.position.clone(); + expected.collateral_amount -= amount; + assert_eq!(actual, expected); + let [call] = calls.as_slice() else { + panic!("one chained transfer"); + }; + assert_eq!(call.program_id, TOKEN_PROGRAM); + assert_eq!( + call.pda_seeds, + vec![compute_position_vault_pda_seed(compute_position_pda( + PROGRAM, + id(20), + NONCE + ))] + ); + let token_core::Instruction::Transfer { amount_to_transfer } = + risc0_zkvm::serde::from_slice(&call.instruction_data).expect("token instruction") + else { + panic!("expected transfer"); + }; + assert_eq!(amount_to_transfer, amount); + let [vault, destination] = call.pre_states.as_slice() else { + panic!("two transfer inputs"); + }; + assert!(vault.is_authorized); + assert!(!destination.is_authorized); + assert_eq!(vault.account_id, fixture.position.vault_account_id); + assert_eq!(destination.account_id, id(21)); + let transferred = + token_program::transfer::transfer(vault.clone(), destination.clone(), amount_to_transfer); + assert_eq!( + TokenHolding::try_from(&transferred.first().expect("vault post").account().data) + .expect("vault"), + TokenHolding::Fungible { + definition_id: id(4), + balance: fixture.vault_balance - amount + } + ); + assert_eq!( + TokenHolding::try_from(&transferred.get(1).expect("destination post").account().data) + .expect("destination"), + TokenHolding::Fungible { + definition_id: id(4), + balance: fixture.destination_balance + amount + } + ); +} + +#[test] +fn withdrawal_pins_serialization_pdas_guest_flags_and_owner_only_signing() { + let fixture = Fixture::new(130, 100); + let request = fixture.request(json!("20")); + let plan = withdraw_collateral_plan(request.clone()).expect("equality is healthy"); + assert_eq!(plan["programId"], request.stablecoin_program_id); + assert_eq!( + plan["accountIds"], + json!([ + request.owner_id, + request.position.id, + request.vault.id, + request.user_collateral_holding.id, + request.stability_fee_accumulator.id, + request.redemption_price_state.id, + request.protocol_parameters.id, + request.clock.id + ]) + ); + assert_eq!( + plan["signingRequirements"], + json!([true, false, false, false, false, false, false, false]) + ); + let words: Vec = serde_json::from_value(plan["instruction"].clone()).expect("words"); + assert!(matches!( + risc0_zkvm::serde::from_slice::(&words).expect("instruction"), + Instruction::WithdrawCollateral { amount: 20 } + )); + let idl: Value = + serde_json::from_str(include_str!("../../../../../artifacts/stablecoin-idl.json")) + .expect("IDL"); + let instruction = idl["instructions"] + .as_array() + .expect("instructions") + .iter() + .find(|entry| entry["name"] == "withdraw_collateral") + .expect("withdraw entry"); + let flags: Vec<_> = instruction["accounts"] + .as_array() + .expect("accounts") + .iter() + .map(|entry| { + ( + entry["name"].clone(), + entry["writable"].clone(), + entry["signer"].clone(), + entry["init"].clone(), + ) + }) + .collect(); + assert_eq!( + flags, + vec![ + (json!("owner"), json!(false), json!(true), json!(false)), + (json!("position"), json!(true), json!(false), json!(false)), + (json!("vault"), json!(true), json!(false), json!(false)), + ( + json!("user_collateral_holding"), + json!(true), + json!(false), + json!(false) + ), + ( + json!("stability_fee_accumulator"), + json!(false), + json!(false), + json!(false) + ), + ( + json!("redemption_price_state"), + json!(false), + json!(false), + json!(false) + ), + ( + json!("protocol_parameters"), + json!(false), + json!(false), + json!(false) + ), + (json!("clock"), json!(false), json!(false), json!(false)), + ] + ); + assert_eq!( + instruction["args"], + json!([{"name":"amount", "type":"u128"}]) + ); +} + +#[test] +fn accepted_plan_executes_pda_transfer_and_keeps_debt_unchanged() { + let mut fixture = Fixture::new(130, 100); + fixture.vault_balance = 140; // Donation is not spendable until Position reconciliation. + fixture.destination_balance = 5; + execute(&fixture, 20); + error(fixture.request(json!("21")), "position_undercollateralized"); + error( + fixture.request(json!("131")), + "withdraw_amount_exceeds_collateral", + ); +} + +#[test] +fn withdrawal_does_not_floor_fractional_debt_or_narrow_wide_projections() { + let mut fractional = Fixture::new(3, 1); + fractional.accumulator.accumulated_rate_at_last_accrual = FIXED_POINT_ONE * 19 / 10; + error( + fractional.request(json!("1")), + "position_undercollateralized", + ); + execute(&fractional, 0); + let mut wide = Fixture::new(1_000_000_000_000, 1); + wide.redemption.redemption_rate_per_millisecond = + FIXED_POINT_ONE + RATE_DELTA_CLAMP.unsigned_abs(); + wide.now = START + 2_700_000; + execute(&wide, 100_000_000_000); + let mut saturated = Fixture::new(u128::MAX, 10); + saturated.redemption.redemption_rate_per_millisecond = + FIXED_POINT_ONE + RATE_DELTA_CLAMP.unsigned_abs(); + saturated.now = START + 18_000_000; + error( + saturated.request(json!("1")), + "position_undercollateralized", + ); +} + +#[test] +fn zero_and_zero_debt_skip_projections_but_not_frozen_or_structural_guards() { + let mut zero = Fixture::new(1, 100); + zero.now = START + 7_000_000; + zero.redemption.redemption_rate_per_millisecond = + FIXED_POINT_ONE - RATE_DELTA_CLAMP.unsigned_abs(); + execute(&zero, 0); + error(zero.request(json!("1")), "redemption_price_zero"); + zero.parameters.is_frozen = true; + error(zero.request(json!("0")), "protocol_frozen"); + let mut settled = Fixture::new(50, 0); + settled.parameters.stability_fee_per_millisecond = FIXED_POINT_ONE * 2; + settled.redemption.redemption_rate_per_millisecond = + FIXED_POINT_ONE + RATE_DELTA_CLAMP.unsigned_abs(); + settled.now = START + 60_000_000; + execute(&settled, 50); + let mut invalid = settled.request(json!("0")); + invalid.clock.id = account_id_hex(id(90)); + error(invalid, "invalid_clock"); +} + +#[test] +fn withdrawal_preserves_maximum_amounts_lossless_json_integers_and_base58_ids() { + let fixture = Fixture::new(u128::MAX, 0); + execute(&fixture, u128::MAX); + let mut request = fixture.request(json!(u64::MAX)); + request.owner_id = id(20).to_string(); + request.user_collateral_holding_id = id(21).to_string(); + let plan = withdraw_collateral_plan(request).expect("lossless integer and base58"); + let words: Vec = serde_json::from_value(plan["instruction"].clone()).expect("words"); + assert!( + matches!(risc0_zkvm::serde::from_slice::(&words).expect("instruction"), + Instruction::WithdrawCollateral { amount } if amount == u128::from(u64::MAX)) + ); + for amount in [ + json!(1.5), + json!(1.0), + json!(-1), + json!("-1"), + json!(""), + json!("1e3"), + json!("340282366920938463463374607431768211456"), + Value::Null, + ] { + error(fixture.request(amount), "invalid_numeric_value"); + } + for nonce in ["", "-1", "1.0", "18446744073709551616"] { + let mut invalid = fixture.request(json!("0")); + invalid.position_nonce = nonce.to_owned(); + error(invalid, "invalid_numeric_value"); + } +} + +#[test] +fn withdrawal_rejects_inconsistent_live_balance_snapshots_before_submission() { + // Each observation can be valid at its read height: read Position/vault + // before another withdrawal, then destination after that withdrawal. These + // cases deliberately violate a single-snapshot balance invariant to model + // the live adapter's independent RPC reads, not an impossible ledger state. + let mut stale_vault = Fixture::new(1, 0); + stale_vault.vault_balance = 0; + stale_vault.destination_balance = 1; + error( + stale_vault.request(json!("1")), + "insufficient_vault_balance", + ); + let mut stale_destination = Fixture::new(1, 0); + stale_destination.destination_balance = u128::MAX; + error( + stale_destination.request(json!("1")), + "collateral_amount_overflow", + ); +} + +#[test] +fn withdrawal_rejects_wrong_position_vault_and_destination_bindings() { + let fixture = Fixture::new(130, 100); + for alias in [id(20), fixture.position.vault_account_id] { + let mut invalid = fixture.request(json!("0")); + invalid.user_collateral_holding_id = account_id_hex(alias); + invalid.user_collateral_holding.id = account_id_hex(alias); + error(invalid, "invalid_user_collateral_holding"); + } + for (state, expected) in [ + ( + Position { + owner_account_id: id(90), + ..fixture.position.clone() + }, + "position_owner_mismatch", + ), + ( + Position { + position_nonce: 1, + ..fixture.position.clone() + }, + "position_nonce_mismatch", + ), + ( + Position { + vault_account_id: id(90), + ..fixture.position.clone() + }, + "position_vault_mismatch", + ), + ] { + let mut invalid = fixture.request(json!("0")); + invalid.position.account.as_mut().expect("account").data = + hex::encode(Data::from(&state).as_ref()); + error(invalid, expected); + } + let mut invalid = fixture.request(json!("0")); + invalid.user_collateral_holding.id = account_id_hex(id(90)); + error(invalid, "invalid_user_collateral_holding"); + let mut invalid = fixture.request(json!("0")); + invalid + .user_collateral_holding + .account + .as_mut() + .expect("account") + .program_owner = hex::encode(program_id_bytes(PROGRAM)); + error(invalid, "token_program_mismatch"); + for vault in [false, true] { + let mut invalid = fixture.request(json!("0")); + let read = if vault { + &mut invalid.vault + } else { + &mut invalid.user_collateral_holding + }; + read.account.as_mut().expect("account").data = hex::encode( + Data::from(&TokenHolding::Fungible { + definition_id: id(90), + balance: 1, + }) + .as_ref(), + ); + error(invalid, "collateral_definition_mismatch"); + } +} + +#[test] +fn withdrawal_validates_all_required_reads_even_without_health_projections() { + let fixture = Fixture::new(130, 0); + let data_errors = [ + "invalid_position_data", + "invalid_position_vault", + "invalid_user_collateral_holding", + "invalid_stability_fee_accumulator_data", + "invalid_redemption_price_state_data", + "invalid_protocol_parameters_data", + "invalid_clock", + ]; + let pda_errors = [ + "position_pda_mismatch", + "vault_pda_mismatch", + "invalid_user_collateral_holding", + "stability_fee_accumulator_pda_mismatch", + "redemption_price_state_pda_mismatch", + "protocol_parameters_pda_mismatch", + "invalid_clock", + ]; + for index in 0..7 { + for mutation in 0..4 { + let mut request = fixture.request(json!("0")); + let reads = [ + &mut request.position, + &mut request.vault, + &mut request.user_collateral_holding, + &mut request.stability_fee_accumulator, + &mut request.redemption_price_state, + &mut request.protocol_parameters, + &mut request.clock, + ]; + let read = reads.into_iter().nth(index).expect("read"); + let expected = match mutation { + 0 => { + read.status = String::from("not_found"); + read.account = None; + "account_read_failed" + } + 1 => { + let data = &mut read.account.as_mut().expect("account").data; + data.truncate(data.len() - 2); + data_errors[index] + } + 2 => { + read.account.as_mut().expect("account").data.push_str("ff"); + data_errors[index] + } + _ => { + read.id = account_id_hex(id(90)); + pda_errors[index] + } + }; + error(request, expected); + } + } +} diff --git a/modules/stablecoin/ffi/src/ffi.rs b/modules/stablecoin/ffi/src/ffi.rs index f08887b0..ef2b6531 100644 --- a/modules/stablecoin/ffi/src/ffi.rs +++ b/modules/stablecoin/ffi/src/ffi.rs @@ -293,6 +293,20 @@ pub unsafe extern "C" fn stablecoin_position_health(request_json: *const c_char) unsafe { call::(request_json, api::position_health) } } +#[unsafe(no_mangle)] +/// Builds a preflighted owner-signed plan for `WithdrawCollateral`. +/// +/// # Safety +/// `request_json` must be null or point to a live NUL-terminated byte string. +pub unsafe extern "C" fn stablecoin_withdraw_collateral_plan( + request_json: *const c_char, +) -> *mut c_char { + // SAFETY: Forwarded from this function's caller contract. + unsafe { + call::(request_json, api::withdraw_collateral_plan) + } +} + /// Releases a string returned by a `stablecoin_*` operation. /// /// # Safety diff --git a/modules/stablecoin/ffi/src/lib.rs b/modules/stablecoin/ffi/src/lib.rs index 6077c1c7..d56b0c71 100644 --- a/modules/stablecoin/ffi/src/lib.rs +++ b/modules/stablecoin/ffi/src/lib.rs @@ -11,11 +11,13 @@ pub use api::{ decode_redemption_price_state, decode_stability_fee_accumulator, deposit_collateral_plan, initialize_program_plan, open_position_plan, position_addresses, position_health, position_info, program_info, redemption_rate_update_quote, refresh_globals_plan, - repay_debt_plan, update_redemption_rate_plan, AccrueStabilityFeePlanRequest, - CurrentGlobalStateRequest, DecodePositionRequest, DecodeProtocolParametersRequest, - DecodeRedemptionPriceStateRequest, DecodeStabilityFeeAccumulatorRequest, - DepositCollateralPlanRequest, InitializeProgramPlanRequest, OpenPositionPlanRequest, - PositionAddressesRequest, PositionHealthRequest, PositionInfoRequest, ProgramInfoRequest, + repay_debt_plan, update_redemption_rate_plan, withdraw_collateral_plan, + AccrueStabilityFeePlanRequest, CurrentGlobalStateRequest, DecodePositionRequest, + DecodeProtocolParametersRequest, DecodeRedemptionPriceStateRequest, + DecodeStabilityFeeAccumulatorRequest, DepositCollateralPlanRequest, + InitializeProgramPlanRequest, OpenPositionPlanRequest, PositionAddressesRequest, + PositionHealthRequest, PositionInfoRequest, ProgramInfoRequest, RedemptionRateUpdateQuoteRequest, RefreshGlobalsPlanRequest, RepayDebtPlanRequest, StablecoinApiError, StablecoinResponse, StablecoinResult, UpdateRedemptionRatePlanRequest, + WithdrawCollateralPlanRequest, }; diff --git a/modules/stablecoin/ffi/tests/public_api.rs b/modules/stablecoin/ffi/tests/public_api.rs index a74e5195..422972ae 100644 --- a/modules/stablecoin/ffi/tests/public_api.rs +++ b/modules/stablecoin/ffi/tests/public_api.rs @@ -14,6 +14,8 @@ use stablecoin_ffi::{ #[test] fn crate_root_reexports_stablecoin_surface() { + let _withdraw: fn(stablecoin_ffi::WithdrawCollateralPlanRequest) -> StablecoinResult = + stablecoin_ffi::withdraw_collateral_plan; let _health: fn(stablecoin_ffi::PositionHealthRequest) -> StablecoinResult = stablecoin_ffi::position_health; let _program_info: fn(ProgramInfoRequest) -> StablecoinResult = program_info; diff --git a/modules/stablecoin/src/stablecoin_module_impl.cpp b/modules/stablecoin/src/stablecoin_module_impl.cpp index e3b59fcb..89f9d0cc 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.cpp +++ b/modules/stablecoin/src/stablecoin_module_impl.cpp @@ -652,6 +652,74 @@ LogosMap StablecoinModuleImpl::redemptionRateUpdateQuote() { }); } +LogosMap StablecoinModuleImpl::withdrawCollateral(const LogosMap& request) { + return guarded([&]() -> LogosMap { + if (!request.is_object() || !hasString(request, "ownerId") + || !hasString(request, "positionNonce") + || !hasString(request, "userCollateralHoldingId") + || request.find("amount") == request.end()) { + return publicError("bad_request"); + } + std::string error; + const json info = stablecoinProgramInfo(error); + if (!info.is_object()) return publicError(error.empty() ? "backend_error" : error); + const std::string owner = normalizeAccountId(jsonString(request, "ownerId")); + const std::string destination_id = normalizeAccountId(jsonString(request, "userCollateralHoldingId")); + if (owner.empty() || destination_id.empty()) return publicError("invalid_account_id"); + if (!requireWalletCaller(owner, error)) return publicError(error); + const FfiResult addresses = callStablecoin(stablecoin_position_addresses, { + {"stablecoinProgramId", info["programIdHex"]}, {"ownerId", owner}, + {"positionNonce", jsonString(request, "positionNonce")}, + }); + if (!addresses.ok) return publicError(stablecoin_module::detail::stableFfiError(addresses.error)); + const std::string position_id = jsonString(addresses.value, "positionIdHex"); + const std::string vault_id = jsonString(addresses.value, "vaultIdHex"); + const std::string parameters_id = jsonString(info, "protocolParametersIdHex"); + const std::string accumulator_id = jsonString(info, "stabilityFeeAccumulatorIdHex"); + const std::string redemption_id = jsonString(info, "redemptionPriceStateIdHex"); + const std::string clock_id = jsonString(info, "clockIdHex"); + for (const auto& account_id : {position_id, vault_id, parameters_id, accumulator_id, redemption_id, clock_id}) { + if (!stablecoin_module::detail::isValidAccountIdHex(account_id)) return publicError("backend_error"); + } + const json parameters = readPublicAccount(parameters_id); + if (jsonString(parameters, "status") == "not_found") return publicError("not_initialized"); + if (jsonString(parameters, "status") != "ok") return publicError("account_read_failed"); + const json position = readPublicAccount(position_id); + const json vault = readPublicAccount(vault_id); + const json destination = readPublicAccount(destination_id); + const json accumulator = readPublicAccount(accumulator_id); + const json redemption = readPublicAccount(redemption_id); + const json clock = readPublicAccount(clock_id); + if (jsonString(position, "status") != "ok" || jsonString(vault, "status") != "ok" + || jsonString(destination, "status") != "ok" || jsonString(accumulator, "status") != "ok" + || jsonString(redemption, "status") != "ok" || jsonString(clock, "status") != "ok") { + return publicError("account_read_failed"); + } + const FfiResult planned = callStablecoin(stablecoin_withdraw_collateral_plan, { + {"stablecoinProgramId", info["programIdHex"]}, {"ownerId", owner}, + {"positionNonce", jsonString(request, "positionNonce")}, {"amount", request["amount"]}, + {"userCollateralHoldingId", destination_id}, {"position", position}, {"vault", vault}, + {"userCollateralHolding", destination}, {"stabilityFeeAccumulator", accumulator}, + {"redemptionPriceState", redemption}, {"protocolParameters", parameters}, {"clock", clock}, + }); + if (!planned.ok) return publicError(stablecoin_module::detail::stableFfiError(planned.error)); + if (jsonString(planned.value, "programId") != jsonString(info, "programIdHex")) return publicError("backend_error"); + const auto account_ids = planned.value.find("accountIds"); + const std::vector expected = { + owner, position_id, vault_id, destination_id, accumulator_id, redemption_id, parameters_id, clock_id, + }; + if (account_ids == planned.value.end() || !account_ids->is_array() || account_ids->size() != expected.size()) { + return publicError("backend_error"); + } + for (std::size_t index = 0; index < expected.size(); ++index) { + if (!(*account_ids)[index].is_string() || (*account_ids)[index].get() != expected[index]) { + return publicError("backend_error"); + } + } + return submitPlan(planned.value, expected.size()); + }); +} + LogosMap StablecoinModuleImpl::repayDebt(const LogosMap& request) { return guarded([&]() -> LogosMap { if (!request.is_object() diff --git a/modules/stablecoin/src/stablecoin_module_impl.h b/modules/stablecoin/src/stablecoin_module_impl.h index 12c48530..c318400e 100644 --- a/modules/stablecoin/src/stablecoin_module_impl.h +++ b/modules/stablecoin/src/stablecoin_module_impl.h @@ -96,6 +96,12 @@ class StablecoinModuleImpl : public LogosModuleContext { /// Repayment remains available while frozen, including zero amounts. LogosMap repayDebt(const LogosMap& request); + /// Withdraws recorded collateral after a native-compatible health preflight. + /// Requires `ownerId`, `positionNonce`, `userCollateralHoldingId`, and + /// `amount`. Only the Position owner must be a public wallet signer; + /// the destination holding need not belong to the wallet. Frozen calls fail. + LogosMap withdrawCollateral(const LogosMap& request); + private: using StablecoinOperation = char* (*)(const char*); diff --git a/modules/stablecoin/src/stablecoin_module_support.cpp b/modules/stablecoin/src/stablecoin_module_support.cpp index 2717def1..2300bc4b 100644 --- a/modules/stablecoin/src/stablecoin_module_support.cpp +++ b/modules/stablecoin/src/stablecoin_module_support.cpp @@ -193,6 +193,10 @@ std::string stableFfiError(const std::string& error) { "position_pda_mismatch", "position_not_found", "health_projection_overflow", + "withdraw_amount_exceeds_collateral", + "insufficient_vault_balance", + "position_undercollateralized", + "redemption_price_zero", "health_arithmetic_overflow", "position_owner_mismatch", "position_nonce_mismatch", diff --git a/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp index d2856a33..4b25f4ef 100644 --- a/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp +++ b/modules/stablecoin/tests/mocks/mock_stablecoin_ffi.cpp @@ -91,6 +91,10 @@ extern "C" char* stablecoin_position_health(const char*) { return copyMockResponse("stablecoin_position_health"); } +extern "C" char* stablecoin_withdraw_collateral_plan(const char*) { + return copyMockResponse("stablecoin_withdraw_collateral_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 index 1be2a4f9..d1df9353 100644 --- a/modules/stablecoin/tests/stablecoin_module_impl_test.cpp +++ b/modules/stablecoin/tests/stablecoin_module_impl_test.cpp @@ -1309,6 +1309,144 @@ LOGOS_TEST(deposit_collateral_rejects_unexpected_plan_accounts_and_wallet_failur } } +namespace { +std::vector withdrawAccounts() { + return {CALLER_ID_HEX, std::string(64, 'c'), std::string(64, 'd'), OTHER_CALLER_ID_HEX, + ACCUMULATOR_ID_HEX, REDEMPTION_STATE_ID_HEX, PROTOCOL_PARAMETERS_ID_HEX, CLOCK_ID_HEX}; +} +LogosMap withdrawRequest() { + return {{"ownerId", CALLER_ID_HEX}, {"positionNonce", "7"}, + {"userCollateralHoldingId", OTHER_CALLER_ID_HEX}, {"amount", "20"}}; +} +struct WithdrawReadMocks { + const std::string info = successEnvelope(programInfoValue()); + const std::string addresses = successEnvelope({{"positionIdHex", std::string(64, 'c')}, {"vaultIdHex", std::string(64, 'd')}}); + explicit WithdrawReadMocks(LogosTestContext& context) { + context.mockCFunction("stablecoin_program_info").returns(info); + context.mockCFunction("stablecoin_position_addresses").returns(addresses); + context.mockModule("lez_core", "list_accounts").returnsVariant(QVariant(walletAccounts(CALLER_ID_HEX))); + context.mockModule("lez_core", "get_account_public").returns(initializedAccount()); + } +}; +} + +LOGOS_TEST(withdraw_collateral_signs_only_owner_and_reads_live_inputs) { + 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 WithdrawReadMocks mocks(context); + const std::string plan = successEnvelope(submissionPlan(withdrawAccounts(), 14)); + context.mockCFunction("stablecoin_withdraw_collateral_plan").returns(plan); + context.mockModule("lez_core", "send_generic_public_transaction").returns(successfulTransaction()); + const LogosMap response = module.withdrawCollateral(withdrawRequest()); + LOGOS_ASSERT_EQ(response["status"].get(), std::string("ok")); + LOGOS_ASSERT_EQ(response["transactionId"].get(), TRANSACTION_ID_HEX); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "list_accounts"), 1); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 7); + LOGOS_ASSERT_TRUE(context.moduleCalledWith("lez_core", "send_generic_public_transaction", submissionArguments(withdrawAccounts(), 14))); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "send_generic_public_transaction"), 1); +} + +LOGOS_TEST(withdraw_collateral_requires_public_owner_even_for_zero) { + ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); + ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); + for (const bool private_owner : {false, true}) { + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + const std::string info = successEnvelope(programInfoValue()); + context.mockCFunction("stablecoin_program_info").returns(info); + QVariantList accounts = walletAccounts(private_owner ? CALLER_ID_HEX : OTHER_CALLER_ID_HEX); + if (private_owner) { + QVariantMap account = accounts.front().toMap(); + account.insert("is_public", false); + accounts[0] = QVariant(account); + } + context.mockModule("lez_core", "list_accounts").returnsVariant(QVariant(accounts)); + LogosMap request = withdrawRequest(); + request["amount"] = "0"; + assertError(module.withdrawCollateral(request), "account_read_failed"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "get_account_public"), 0); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_withdraw_collateral_plan"), 0); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } +} + +LOGOS_TEST(withdraw_collateral_validates_request_reads_and_preflight_errors) { + ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); + ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); + for (const std::string& field : {"ownerId", "positionNonce", "userCollateralHoldingId", "amount"}) { + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + LogosMap request = withdrawRequest(); + request.erase(field); + assertError(module.withdrawCollateral(request), "bad_request"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } + for (const std::string& error : {"protocol_frozen", "withdraw_amount_exceeds_collateral", + "position_undercollateralized", "redemption_price_zero", "insufficient_vault_balance", + "collateral_amount_overflow", "invalid_numeric_value", "invalid_position_vault"}) { + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + const WithdrawReadMocks mocks(context); + const std::string failure = failureEnvelope(error); + context.mockCFunction("stablecoin_withdraw_collateral_plan").returns(failure); + assertError(module.withdrawCollateral(withdrawRequest()), error); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } + for (const std::string& read : {std::string(), std::string("malformed")}) { + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + const WithdrawReadMocks mocks(context); + context.mockModule("lez_core", "get_account_public").returns(read); + assertError(module.withdrawCollateral(withdrawRequest()), read.empty() ? "not_initialized" : "account_read_failed"); + LOGOS_ASSERT_EQ(context.cFunctionCallCount("stablecoin_withdraw_collateral_plan"), 0); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } +} + +LOGOS_TEST(withdraw_collateral_rejects_wrong_plan_and_never_retries_wallet_failure) { + ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); + ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); + for (int mutation = 0; mutation < 3; ++mutation) { + LogosTestContext context("stablecoin_module"); + LogosModules modules(context.api()); + StablecoinModuleImpl module; + attachModules(module, modules); + const WithdrawReadMocks mocks(context); + json plan = submissionPlan(withdrawAccounts(), 14); + if (mutation == 0) plan["accountIds"][7] = ORACLE_ID_HEX; + if (mutation == 1) plan["signingRequirements"][3] = true; + if (mutation == 2) plan["programId"] = ORACLE_ID_HEX; + const std::string response = successEnvelope(plan); + context.mockCFunction("stablecoin_withdraw_collateral_plan").returns(response); + assertError(module.withdrawCollateral(withdrawRequest()), "backend_error"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "send_generic_public_transaction"), 0); + } + 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 WithdrawReadMocks mocks(context); + const std::string plan = successEnvelope(submissionPlan(withdrawAccounts(), 14)); + context.mockCFunction("stablecoin_withdraw_collateral_plan").returns(plan); + context.mockModule("lez_core", "send_generic_public_transaction").returns(wallet_response); + assertError(module.withdrawCollateral(withdrawRequest()), "wallet_submission_failed"); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "send_generic_public_transaction"), 1); + } +} + LOGOS_TEST(position_health_reads_live_state_without_signers_or_submission) { ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr); diff --git a/modules/stablecoin/tests/stablecoin_module_real_ffi_journey_test.cpp b/modules/stablecoin/tests/stablecoin_module_real_ffi_journey_test.cpp index 62c8a5a9..973e899f 100644 --- a/modules/stablecoin/tests/stablecoin_module_real_ffi_journey_test.cpp +++ b/modules/stablecoin/tests/stablecoin_module_real_ffi_journey_test.cpp @@ -536,6 +536,103 @@ LOGOS_TEST(real_ffi_journey_opens_position_with_two_wallet_signers) { LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core", "send_generic_public_transaction"), 1); } +LOGOS_TEST(real_ffi_withdrawal_rechecks_health_and_accepts_external_destination) { + 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 info = module.programInfo(); + assertOk(info); + const std::string protocol_id = info["protocolParametersIdHex"].get(); + const std::string accumulator_id = info["stabilityFeeAccumulatorIdHex"].get(); + const std::string redemption_id = info["redemptionPriceStateIdHex"].get(); + const std::string clock_id = info["clockIdHex"].get(); + const std::string destination_id = idHex(8); + const std::string nonce = "18446744073709551615"; + const std::string payload = json{{"stablecoinProgramId", PROGRAM_ID_HEX}, + {"ownerId", CALLER_ID_HEX}, {"positionNonce", nonce}}.dump(); + std::unique_ptr addresses_result( + stablecoin_position_addresses(payload.c_str()), &stablecoin_free); + LOGOS_ASSERT_TRUE(addresses_result != nullptr); + const json addresses = json::parse(addresses_result.get()).at("value"); + const std::string position_id = addresses["positionIdHex"].get(); + const std::string vault_id = addresses["vaultIdHex"].get(); + std::string parameters = protocolData(false); + parameters.replace(128, 64, info["stablecoinDefinitionIdHex"].get()); + const std::string position = positionData(CALLER_ID_HEX, vault_id, UINT64_MAX, "180", "100"); + const std::string vault = collateralHoldingData("190"); + const std::string destination = collateralHoldingData("10"); + const std::string accumulator = accumulatorData(FIXED_ONE, DUE); + const std::string redemption = redemptionData(FIXED_ONE, FIXED_ONE, "0", DUE); + const std::string clock = clockData(DUE); + expectRead(position_id, PROGRAM_OWNER_HEX, position); + expectRead(vault_id, TOKEN_OWNER_HEX, vault); + expectRead(destination_id, TOKEN_OWNER_HEX, destination); + expectRead(protocol_id, PROGRAM_OWNER_HEX, parameters); + expectRead(accumulator_id, PROGRAM_OWNER_HEX, accumulator); + expectRead(redemption_id, PROGRAM_OWNER_HEX, redemption); + expectRead(clock_id, PROGRAM_OWNER_HEX, clock); + QVariantMap owner; + owner.insert("account_id", QString::fromStdString(CALLER_ID_HEX)); + owner.insert("is_public", true); + context.mockModule("lez_core", "list_accounts").returnsVariant(QVariant(QVariantList{QVariant(owner)})); + context.mockModule("lez_core", "send_generic_public_transaction").returns(successfulTransaction()); + const LogosMap earlier_quote = module.positionHealth({{"ownerId", CALLER_ID_HEX}, {"positionNonce", nonce}}); + assertOk(earlier_quote); + LOGOS_ASSERT_TRUE(earlier_quote["isCollateralized"].get()); + LogosMap request = {{"ownerId", CALLER_ID_HEX}, {"positionNonce", nonce}, + {"userCollateralHoldingId", destination_id}, {"amount", "30"}}; + const LogosMap withdrawn = module.withdrawCollateral(request); + assertOk(withdrawn); + LOGOS_ASSERT_EQ(withdrawn["transactionId"].get(), TRANSACTION_ID_HEX); + const std::string planner_payload = json{ + {"stablecoinProgramId",PROGRAM_ID_HEX},{"ownerId",CALLER_ID_HEX},{"positionNonce",nonce},{"amount","30"}, + {"userCollateralHoldingId",destination_id},{"position",accountReadValue(position_id,PROGRAM_OWNER_HEX,position)}, + {"vault",accountReadValue(vault_id,TOKEN_OWNER_HEX,vault)}, + {"userCollateralHolding",accountReadValue(destination_id,TOKEN_OWNER_HEX,destination)}, + {"stabilityFeeAccumulator",accountReadValue(accumulator_id,PROGRAM_OWNER_HEX,accumulator)}, + {"redemptionPriceState",accountReadValue(redemption_id,PROGRAM_OWNER_HEX,redemption)}, + {"protocolParameters",accountReadValue(protocol_id,PROGRAM_OWNER_HEX,parameters)}, + {"clock",accountReadValue(clock_id,PROGRAM_OWNER_HEX,clock)}, + }.dump(); + std::unique_ptr planned(stablecoin_withdraw_collateral_plan(planner_payload.c_str()),&stablecoin_free); + LOGOS_ASSERT_TRUE(planned != nullptr); + const json plan = json::parse(planned.get()).at("value"); + LOGOS_ASSERT_TRUE(context.moduleCalledWith("lez_core","send_generic_public_transaction", submissionArguments(plan,std::vector{0},PROGRAM_ID_HEX))); + LOGOS_ASSERT_EQ(plan["signingRequirements"], json::array({true,false,false,false,false,false,false,false})); + + // Feed the first withdrawal's state forward, then accrue fees. An earlier + // healthy quote cannot authorize a withdrawal against this new state. + expectRead(position_id,PROGRAM_OWNER_HEX,positionData(CALLER_ID_HEX,vault_id,UINT64_MAX,"150","100")); + expectRead(vault_id,TOKEN_OWNER_HEX,collateralHoldingData("160")); + expectRead(destination_id,TOKEN_OWNER_HEX,collateralHoldingData("40")); + expectRead(accumulator_id,PROGRAM_OWNER_HEX,accumulatorData("2000000000000000000000000000",DUE)); + request["amount"] = "1"; + const LogosMap rejected = module.withdrawCollateral(request); + LOGOS_ASSERT_EQ(rejected["error"].get(), std::string("position_undercollateralized")); + LOGOS_ASSERT_TRUE(rejected.find("transactionId") == rejected.end()); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core","send_generic_public_transaction"),1); + + const std::string maximum = "340282366920938463463374607431768211455"; + expectRead(position_id,PROGRAM_OWNER_HEX,positionData(CALLER_ID_HEX,vault_id,UINT64_MAX,maximum,"0")); + expectRead(vault_id,TOKEN_OWNER_HEX,collateralHoldingData(maximum)); + expectRead(destination_id,TOKEN_OWNER_HEX,collateralHoldingData("0")); + expectRead(clock_id,PROGRAM_OWNER_HEX,clockData(DUE + 60'000'000)); + parameters.replace(320,32,u128Le("2000000000000000000000000000")); + expectRead(protocol_id,PROGRAM_OWNER_HEX,parameters); + request["amount"] = maximum; + assertOk(module.withdrawCollateral(request)); // Zero debt skips even enormous fee projections. + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core","send_generic_public_transaction"),2); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core","get_account_public"),26); + request["amount"] = 1.5; + LOGOS_ASSERT_EQ(module.withdrawCollateral(request)["error"].get(),std::string("invalid_numeric_value")); + request["amount"] = "340282366920938463463374607431768211456"; + LOGOS_ASSERT_EQ(module.withdrawCollateral(request)["error"].get(),std::string("invalid_numeric_value")); + LOGOS_ASSERT_EQ(context.moduleCallCount("lez_core","send_generic_public_transaction"),2); +} + LOGOS_TEST(real_ffi_position_health_rechecks_fractional_debt_while_frozen) { ScopedEnvironment program_id("STABLECOIN_PROGRAM_ID", PROGRAM_ID_HEX.c_str()); ScopedEnvironment program_binary("STABLECOIN_PROGRAM_BIN", nullptr);