Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions modules/stablecoin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions modules/stablecoin/ffi/include/stablecoin_ffi.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
7 changes: 6 additions & 1 deletion modules/stablecoin/ffi/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod program;
mod projection;
mod quote;
mod request;
mod withdraw;

#[cfg(test)]
mod tests;
Expand All @@ -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::{
Expand All @@ -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;

Expand Down
6 changes: 3 additions & 3 deletions modules/stablecoin/ffi/src/api/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -470,7 +470,7 @@ fn decimal_text(value: &str) -> Result<&str, StablecoinApiError> {
Ok(unquoted)
}

fn parse_u128(value: &Value) -> Result<u128, StablecoinApiError> {
pub(super) fn parse_u128(value: &Value) -> Result<u128, StablecoinApiError> {
match value {
Value::Number(number) => number
.as_u64()
Expand Down Expand Up @@ -542,7 +542,7 @@ fn parse_i128(value: &Value) -> Result<i128, StablecoinApiError> {
}
}

fn plan_response<const ACCOUNT_COUNT: usize>(
pub(super) fn plan_response<const ACCOUNT_COUNT: usize>(
program_id: lee_core::program::ProgramId,
account_ids: [AccountId; ACCOUNT_COUNT],
signing_requirements: [bool; ACCOUNT_COUNT],
Expand Down
17 changes: 17 additions & 0 deletions modules/stablecoin/ffi/src/api/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
142 changes: 142 additions & 0 deletions modules/stablecoin/ffi/src/api/withdraw.rs
Original file line number Diff line number Diff line change
@@ -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 },
)
}
Loading
Loading