diff --git a/Cargo.lock b/Cargo.lock index eda78aa..9372e45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,6 +1606,8 @@ dependencies = [ "ata_core", "nssa", "nssa_core", + "stablecoin-methods", + "stablecoin_core", "token-methods", "token_core", ] diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index ce5dfa7..5c601e8 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -42,6 +42,76 @@ "type": "u128" } ] + }, + { + "name": "withdraw_collateral", + "accounts": [ + { + "name": "owner", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "position", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "vault", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "destination", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "amount", + "type": "u128" + } + ] + }, + { + "name": "repay_debt", + "accounts": [ + { + "name": "owner", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "position", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "stablecoin_definition", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "user_stablecoin_holding", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "amount", + "type": "u128" + } + ] } ], "accounts": [ diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 5f85b31..f2374fd 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -12,6 +12,8 @@ nssa_core = { workspace = true, features = ["host"] } amm_core = { workspace = true } token_core = { workspace = true } ata_core = { workspace = true } +stablecoin_core = { workspace = true } token-methods = { path = "../token/methods" } amm-methods = { path = "../amm/methods" } ata-methods = { path = "../ata/methods" } +stablecoin-methods = { path = "../stablecoin/methods" } diff --git a/integration_tests/tests/stablecoin.rs b/integration_tests/tests/stablecoin.rs new file mode 100644 index 0000000..4044d84 --- /dev/null +++ b/integration_tests/tests/stablecoin.rs @@ -0,0 +1,398 @@ +use nssa::{ + program_deployment_transaction::{self, ProgramDeploymentTransaction}, + public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, +}; +use nssa_core::account::{Account, AccountId, Data, Nonce}; +use stablecoin_core::{compute_position_pda, compute_position_vault_pda, Position}; +use token_core::{TokenDefinition, TokenHolding}; + +struct Keys; +struct Ids; +struct Balances; +struct Accounts; + +impl Keys { + fn owner() -> PrivateKey { + PrivateKey::try_new([41; 32]).expect("valid private key") + } + + fn user_holding() -> PrivateKey { + PrivateKey::try_new([42; 32]).expect("valid private key") + } + + fn user_stablecoin_holding() -> PrivateKey { + PrivateKey::try_new([43; 32]).expect("valid private key") + } +} + +impl Ids { + fn token_program() -> nssa_core::program::ProgramId { + token_methods::TOKEN_ID + } + + fn stablecoin_program() -> nssa_core::program::ProgramId { + stablecoin_methods::STABLECOIN_ID + } + + fn collateral_definition() -> AccountId { + AccountId::new([5; 32]) + } + + fn owner() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&Keys::owner())) + } + + fn user_holding() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&Keys::user_holding())) + } + + fn stablecoin_definition() -> AccountId { + AccountId::new([6; 32]) + } + + fn user_stablecoin_holding() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key( + &Keys::user_stablecoin_holding(), + )) + } + + fn position() -> AccountId { + compute_position_pda( + Self::stablecoin_program(), + Self::owner(), + Self::collateral_definition(), + ) + } + + fn vault() -> AccountId { + compute_position_vault_pda(Self::stablecoin_program(), Self::position()) + } +} + +impl Balances { + fn user_holding_init() -> u128 { + 1_000_000 + } + + fn collateral_deposit() -> u128 { + 500_000 + } + + fn collateral_withdraw() -> u128 { + 200_000 + } + + fn stablecoin_supply_init() -> u128 { + 1_000 + } + + fn user_stablecoin_holding_init() -> u128 { + 1_000 + } + + fn initial_debt() -> u128 { + 300 + } + + fn debt_repay_amount() -> u128 { + 100 + } +} + +impl Accounts { + fn collateral_definition_init() -> Account { + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("Gold"), + total_supply: Balances::user_holding_init(), + metadata_id: None, + }), + nonce: Nonce(0), + } + } + + fn user_holding_init() -> Account { + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: Balances::user_holding_init(), + }), + nonce: Nonce(0), + } + } + + fn stablecoin_definition_init() -> Account { + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("DAI"), + total_supply: Balances::stablecoin_supply_init(), + metadata_id: None, + }), + nonce: Nonce(0), + } + } + + fn user_stablecoin_holding_init() -> Account { + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::stablecoin_definition(), + balance: Balances::user_stablecoin_holding_init(), + }), + nonce: Nonce(0), + } + } + + fn position_with_debt_init() -> Account { + Account { + program_owner: stablecoin_methods::STABLECOIN_ID, + balance: 0_u128, + data: Data::from(&Position { + collateral_vault_id: Ids::vault(), + collateral_definition_id: Ids::collateral_definition(), + collateral_amount: Balances::collateral_deposit(), + debt_amount: Balances::initial_debt(), + }), + nonce: Nonce(0), + } + } +} + +fn deploy_programs(state: &mut V03State) { + let token_message = + program_deployment_transaction::Message::new(token_methods::TOKEN_ELF.to_vec()); + state + .transition_from_program_deployment_transaction(&ProgramDeploymentTransaction::new( + token_message, + )) + .expect("token program deployment must succeed"); + + let stablecoin_message = + program_deployment_transaction::Message::new(stablecoin_methods::STABLECOIN_ELF.to_vec()); + state + .transition_from_program_deployment_transaction(&ProgramDeploymentTransaction::new( + stablecoin_message, + )) + .expect("stablecoin program deployment must succeed"); +} + +fn state_for_stablecoin_tests() -> V03State { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init()); + state +} + +fn current_nonce(state: &V03State, account_id: AccountId) -> Nonce { + state.get_account_by_id(account_id).nonce +} + +fn state_for_stablecoin_repay_tests() -> V03State { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + state.force_insert_account( + Ids::stablecoin_definition(), + Accounts::stablecoin_definition_init(), + ); + state.force_insert_account(Ids::position(), Accounts::position_with_debt_init()); + state.force_insert_account( + Ids::user_stablecoin_holding(), + Accounts::user_stablecoin_holding_init(), + ); + state +} + +fn assert_position(state: &V03State, expected_collateral: u128) { + let position = + Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position"); + assert_eq!(position.collateral_amount, expected_collateral); + assert_eq!(position.debt_amount, 0); + assert_eq!(position.collateral_vault_id, Ids::vault()); + assert_eq!( + position.collateral_definition_id, + Ids::collateral_definition() + ); +} + +fn assert_fungible_balance(state: &V03State, account_id: AccountId, expected_balance: u128) { + let holding = TokenHolding::try_from(&state.get_account_by_id(account_id).data) + .expect("valid TokenHolding"); + match holding { + TokenHolding::Fungible { + definition_id, + balance, + } => { + assert_eq!(definition_id, Ids::collateral_definition()); + assert_eq!(balance, expected_balance); + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("expected Fungible holding") + } + } +} + +#[test] +fn stablecoin_open_position_then_withdraw_collateral() { + let mut state = state_for_stablecoin_tests(); + + // Open the position: deposit collateral from the user's holding into a fresh vault. + let open = stablecoin_core::Instruction::OpenPosition { + collateral_amount: Balances::collateral_deposit(), + }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::owner(), + Ids::position(), + Ids::vault(), + Ids::user_holding(), + Ids::collateral_definition(), + ], + vec![ + current_nonce(&state, Ids::owner()), + current_nonce(&state, Ids::user_holding()), + ], + open, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::owner(), &Keys::user_holding()], + ); + let tx = PublicTransaction::new(message, witness_set); + state + .transition_from_public_transaction(&tx, 0, 0) + .expect("open_position must succeed"); + + assert_position(&state, Balances::collateral_deposit()); + assert_fungible_balance(&state, Ids::vault(), Balances::collateral_deposit()); + assert_fungible_balance( + &state, + Ids::user_holding(), + Balances::user_holding_init() - Balances::collateral_deposit(), + ); + + // Withdraw part of the collateral back to the same user holding. + let withdraw = stablecoin_core::Instruction::WithdrawCollateral { + amount: Balances::collateral_withdraw(), + }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::owner(), + Ids::position(), + Ids::vault(), + Ids::user_holding(), + ], + vec![current_nonce(&state, Ids::owner())], + withdraw, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]); + let tx = PublicTransaction::new(message, witness_set); + state + .transition_from_public_transaction(&tx, 0, 0) + .expect("withdraw_collateral must succeed"); + + assert_position( + &state, + Balances::collateral_deposit() - Balances::collateral_withdraw(), + ); + assert_fungible_balance( + &state, + Ids::vault(), + Balances::collateral_deposit() - Balances::collateral_withdraw(), + ); + assert_fungible_balance( + &state, + Ids::user_holding(), + Balances::user_holding_init() - Balances::collateral_deposit() + + Balances::collateral_withdraw(), + ); +} + +#[test] +fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { + let mut state = state_for_stablecoin_repay_tests(); + + let repay = stablecoin_core::Instruction::RepayDebt { + amount: Balances::debt_repay_amount(), + }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::owner(), + Ids::position(), + Ids::stablecoin_definition(), + Ids::user_stablecoin_holding(), + ], + vec![ + current_nonce(&state, Ids::owner()), + current_nonce(&state, Ids::user_stablecoin_holding()), + ], + repay, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::owner(), &Keys::user_stablecoin_holding()], + ); + let tx = PublicTransaction::new(message, witness_set); + state + .transition_from_public_transaction(&tx, 0, 0) + .expect("repay_debt must succeed"); + + // Position debt decreased; collateral untouched. + let position = + Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position"); + assert_eq!( + position.debt_amount, + Balances::initial_debt() - Balances::debt_repay_amount() + ); + assert_eq!(position.collateral_amount, Balances::collateral_deposit()); + + // Stablecoin total supply decreased by the burn amount. + let definition = + TokenDefinition::try_from(&state.get_account_by_id(Ids::stablecoin_definition()).data) + .expect("valid TokenDefinition"); + match definition { + TokenDefinition::Fungible { total_supply, .. } => { + assert_eq!( + total_supply, + Balances::stablecoin_supply_init() - Balances::debt_repay_amount() + ); + } + TokenDefinition::NonFungible { .. } => panic!("expected Fungible definition"), + } + + // User stablecoin holding decreased by the burn amount. + let holding = + TokenHolding::try_from(&state.get_account_by_id(Ids::user_stablecoin_holding()).data) + .expect("valid TokenHolding"); + match holding { + TokenHolding::Fungible { balance, .. } => { + assert_eq!( + balance, + Balances::user_stablecoin_holding_init() - Balances::debt_repay_amount() + ); + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("expected Fungible holding") + } + } +} diff --git a/stablecoin/core/src/lib.rs b/stablecoin/core/src/lib.rs index 11527c7..b5f51ac 100644 --- a/stablecoin/core/src/lib.rs +++ b/stablecoin/core/src/lib.rs @@ -30,6 +30,52 @@ pub enum Instruction { /// Amount of collateral tokens to deposit into the position vault. collateral_amount: u128, }, + /// Withdraw `amount` collateral tokens from a position back to a user-controlled holding. + /// + /// Required accounts (4): + /// - Owner account (authorized) + /// - Position account (initialized, owned by `self_program_id`) + /// - Position vault token holding (address must match + /// `compute_position_vault_pda(self_program_id, position_id)`) + /// - Destination user collateral holding (initialized, owned by the vault's Token Program, + /// `TokenHolding.definition_id == Position.collateral_definition_id`) + /// + /// `token_program_id` is derived from `vault.account.program_owner`; + /// `collateral_definition_id` is read from the decoded [`Position`]. + /// + /// **Note:** until issues #97/#96/#95 land, this instruction hard-asserts + /// `Position.debt_amount == 0` instead of accruing fees and checking the + /// collateralization ratio. + WithdrawCollateral { + /// Amount of collateral tokens to move from the vault back to `destination`. + amount: u128, + }, + /// Repay `amount` of outstanding stablecoin debt against an existing position. + /// + /// Required accounts (4): + /// - Owner account (authorized; binds caller-as-owner via position PDA re-derivation) + /// - Position account (initialized, owned by `self_program_id`) + /// - Stablecoin token definition account (the definition of the stablecoin being repaid) + /// - User's stablecoin holding (authorized, initialized, owned by the same Token Program as + /// the definition, with `TokenHolding.definition_id == stablecoin_definition.account_id`) + /// + /// `token_program_id` is derived from `user_stablecoin_holding.account.program_owner`. + /// `collateral_definition_id` (for position PDA verification) is read from the + /// decoded [`Position`]. + /// + /// **Note:** until issue #97 (stability fee accrual) lands, this instruction does + /// not accrue fees before reducing debt. A `// TODO(#97)` comment in the host + /// function marks where the accrual code will plug in. Today every position has + /// `debt_amount = 0` (no `generate_debt` yet), so the precondition is vacuously met. + /// + /// **Note:** until issue #91 (`generate_debt`) records the stablecoin definition + /// into `Position`, this instruction cannot validate that the passed + /// `stablecoin_token_definition` is the one this position's debt is denominated + /// in. The caller is trusted for that until then. + RepayDebt { + /// Amount of stablecoin debt to repay (also the amount burned from the user's holding). + amount: u128, + }, } /// Persistent state held by a Stablecoin [`Position`] account. diff --git a/stablecoin/methods/guest/src/bin/stablecoin.rs b/stablecoin/methods/guest/src/bin/stablecoin.rs index 57cd3fa..f677ca3 100644 --- a/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -41,4 +41,65 @@ mod stablecoin { chained_calls, )) } + + /// Withdraw `amount` collateral tokens from an existing position back to a + /// user-controlled holding. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition + /// fails (see + /// [`stablecoin_program::withdraw_collateral::withdraw_collateral`] for the + /// full list). + #[instruction] + pub fn withdraw_collateral( + ctx: ProgramContext, + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + destination: AccountWithMetadata, + amount: u128, + ) -> SpelResult { + let (post_states, chained_calls) = + stablecoin_program::withdraw_collateral::withdraw_collateral( + owner, + position, + vault, + destination, + ctx.self_program_id, + amount, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } + + /// Repay `amount` of outstanding stablecoin debt against an existing position. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition + /// fails (see [`stablecoin_program::repay_debt::repay_debt`] for the + /// full list). + #[instruction] + pub fn repay_debt( + ctx: ProgramContext, + owner: AccountWithMetadata, + position: AccountWithMetadata, + stablecoin_definition: AccountWithMetadata, + user_stablecoin_holding: AccountWithMetadata, + amount: u128, + ) -> SpelResult { + let (post_states, chained_calls) = stablecoin_program::repay_debt::repay_debt( + owner, + position, + stablecoin_definition, + user_stablecoin_holding, + ctx.self_program_id, + amount, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } } diff --git a/stablecoin/src/lib.rs b/stablecoin/src/lib.rs index 7f5c47e..690024e 100644 --- a/stablecoin/src/lib.rs +++ b/stablecoin/src/lib.rs @@ -5,5 +5,11 @@ pub use stablecoin_core as core; /// Open a new collateral-only position for a calling owner. pub mod open_position; +/// Repay outstanding stablecoin debt against an existing position. +pub mod repay_debt; + +/// Withdraw collateral from an existing position back to a user-controlled holding. +pub mod withdraw_collateral; + #[cfg(test)] mod tests; diff --git a/stablecoin/src/open_position.rs b/stablecoin/src/open_position.rs index d3fb71f..8c33ae8 100644 --- a/stablecoin/src/open_position.rs +++ b/stablecoin/src/open_position.rs @@ -71,7 +71,6 @@ pub fn open_position( verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id); let mut position_post = position.account; - position_post.program_owner = stablecoin_program_id; position_post.data = Data::from(&Position { collateral_vault_id: vault.account_id, collateral_definition_id: token_definition.account_id, diff --git a/stablecoin/src/repay_debt.rs b/stablecoin/src/repay_debt.rs new file mode 100644 index 0000000..dfe72bd --- /dev/null +++ b/stablecoin/src/repay_debt.rs @@ -0,0 +1,126 @@ +use nssa_core::{ + account::{Account, AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, ProgramId}, +}; +use stablecoin_core::{verify_position_and_get_seed, Position}; +use token_core::TokenHolding; + +/// Repay `amount` of outstanding stablecoin debt against an existing position. +/// +/// Burns `amount` stablecoins from `user_stablecoin_holding` via a chained +/// `Token::Burn` and decreases `Position.debt_amount` by the same amount. The +/// position post-state uses plain [`AccountPostState::new`] — the PDA was +/// already claimed at `open_position` time. +/// +/// Until issue #97 (stability fee accrual) lands, the fee-accrual step is a +/// no-op (every position structurally has `debt_amount = 0` today because +/// `generate_debt` is unimplemented; "fees-accrued" is therefore vacuously +/// true). A `// TODO(#97)` comment marks where the accrual code will plug in +/// — right before the `checked_sub` below. +/// +/// Until issue #91 (`generate_debt`) records the stablecoin definition into +/// `Position`, this instruction cannot validate that `stablecoin_definition` +/// is the correct one for the position's debt. The caller is trusted. +/// +/// # Panics +/// - `owner` is not authorized. +/// - `position` is uninitialized, not owned by `stablecoin_program_id`, holds data that does not +/// decode as a [`Position`], or sits at an address that does not match +/// `compute_position_pda(stablecoin_program_id, owner, Position.collateral_definition_id)`. +/// - `user_stablecoin_holding` is not authorized, is uninitialized, is owned by a different Token +/// Program than `stablecoin_definition`, or holds a [`TokenHolding`] whose `definition_id` does +/// not match `stablecoin_definition.account_id`. +/// - `stablecoin_definition` is uninitialized. +/// - `amount > Position.debt_amount`. +pub fn repay_debt( + owner: AccountWithMetadata, + position: AccountWithMetadata, + stablecoin_definition: AccountWithMetadata, + user_stablecoin_holding: AccountWithMetadata, + stablecoin_program_id: ProgramId, + amount: u128, +) -> (Vec, Vec) { + assert!(owner.is_authorized, "Owner authorization is missing"); + assert_ne!( + position.account, + Account::default(), + "Position account must be initialized" + ); + assert_eq!( + position.account.program_owner, stablecoin_program_id, + "Position is not owned by this stablecoin program" + ); + + let position_data = Position::try_from(&position.account.data) + .expect("Position account must hold valid Position state"); + // `verify_position_and_get_seed` asserts the position address matches the + // (owner, collateral_definition) PDA derivation. The returned seed is + // dropped — the position is already PDA-claimed. + let _position_seed = verify_position_and_get_seed( + &position, + &owner, + position_data.collateral_definition_id, + stablecoin_program_id, + ); + + assert!( + user_stablecoin_holding.is_authorized, + "User stablecoin holding authorization is missing" + ); + assert_ne!( + user_stablecoin_holding.account, + Account::default(), + "User stablecoin holding must be initialized" + ); + assert_ne!( + stablecoin_definition.account, + Account::default(), + "Stablecoin definition account must be initialized" + ); + assert_eq!( + user_stablecoin_holding.account.program_owner, stablecoin_definition.account.program_owner, + "Stablecoin holding and definition must be owned by the same Token Program" + ); + let user_holding_data = TokenHolding::try_from(&user_stablecoin_holding.account.data) + .expect("User stablecoin holding must hold a valid TokenHolding"); + assert_eq!( + user_holding_data.definition_id(), + stablecoin_definition.account_id, + "Stablecoin holding does not match the provided stablecoin definition" + ); + + // TODO(#97): accrue stability fees onto position_data.debt_amount here, before + // the checked_sub below. Today every position has debt_amount = 0 (no + // generate_debt yet), so the precondition is trivially met. + let new_debt = position_data + .debt_amount + .checked_sub(amount) + .expect("Repay amount exceeds outstanding debt"); + + let updated_position = Position { + collateral_vault_id: position_data.collateral_vault_id, + collateral_definition_id: position_data.collateral_definition_id, + collateral_amount: position_data.collateral_amount, + debt_amount: new_debt, + }; + let mut position_post = position.account.clone(); + position_post.data = Data::from(&updated_position); + + let post_states = vec![ + AccountPostState::new(owner.account), + AccountPostState::new(position_post), + AccountPostState::new(stablecoin_definition.account.clone()), + AccountPostState::new(user_stablecoin_holding.account.clone()), + ]; + + let token_program_id = user_stablecoin_holding.account.program_owner; + let burn_call = ChainedCall::new( + token_program_id, + vec![stablecoin_definition, user_stablecoin_holding], + &token_core::Instruction::Burn { + amount_to_burn: amount, + }, + ); + + (post_states, vec![burn_call]) +} diff --git a/stablecoin/src/tests.rs b/stablecoin/src/tests.rs index b7fe339..41e0154 100644 --- a/stablecoin/src/tests.rs +++ b/stablecoin/src/tests.rs @@ -30,6 +30,26 @@ fn user_holding_id() -> AccountId { AccountId::new([0x30u8; 32]) } +fn token_holding_account( + account_id: AccountId, + definition_id: AccountId, + balance: u128, +) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id, + } +} + fn position_id() -> AccountId { compute_position_pda( STABLECOIN_PROGRAM_ID, @@ -68,19 +88,9 @@ fn collateral_definition_account() -> AccountWithMetadata { } fn user_holding_account(balance: u128) -> AccountWithMetadata { - AccountWithMetadata { - account: Account { - program_owner: TOKEN_PROGRAM_ID, - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: collateral_definition_id(), - balance, - }), - nonce: Nonce(0), - }, - is_authorized: true, - account_id: user_holding_id(), - } + let mut account = token_holding_account(user_holding_id(), collateral_definition_id(), balance); + account.is_authorized = true; + account } fn uninit_position_account() -> AccountWithMetadata { @@ -99,6 +109,71 @@ fn uninit_vault_account() -> AccountWithMetadata { } } +fn destination_holding_id() -> AccountId { + AccountId::new([0x40u8; 32]) +} + +fn init_position_account(collateral_amount: u128, debt_amount: u128) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: STABLECOIN_PROGRAM_ID, + balance: 0, + data: Data::from(&Position { + collateral_vault_id: vault_id(), + collateral_definition_id: collateral_definition_id(), + collateral_amount, + debt_amount, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: position_id(), + } +} + +fn init_vault_account() -> AccountWithMetadata { + token_holding_account(vault_id(), collateral_definition_id(), 0) +} + +fn destination_holding_account() -> AccountWithMetadata { + token_holding_account(destination_holding_id(), collateral_definition_id(), 0) +} + +fn stablecoin_definition_id() -> AccountId { + AccountId::new([0x50u8; 32]) +} + +fn user_stablecoin_holding_id() -> AccountId { + AccountId::new([0x60u8; 32]) +} + +fn stablecoin_definition_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0, + data: Data::from(&TokenDefinition::Fungible { + name: "DAI".to_owned(), + total_supply: 1_000_000, + metadata_id: None, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: stablecoin_definition_id(), + } +} + +fn user_stablecoin_holding_account(balance: u128) -> AccountWithMetadata { + let mut account = token_holding_account( + user_stablecoin_holding_id(), + stablecoin_definition_id(), + balance, + ); + account.is_authorized = true; + account +} + #[test] fn open_position_claims_pda_and_emits_chained_calls() { let collateral_amount: u128 = 500; @@ -133,7 +208,8 @@ fn open_position_claims_pda_and_emits_chained_calls() { debt_amount: 0, } ); - assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); + // The runtime sets the program_owner on the claimed account after validating Claim::Pda. + assert_eq!(position_post.account().program_owner, ProgramId::default()); assert_eq!(chained_calls.len(), 2); @@ -392,3 +468,506 @@ fn position_pda_and_vault_pda_do_not_collide() { let vault = compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position); assert_ne!(position, vault); } + +#[test] +fn withdraw_collateral_updates_position_and_emits_transfer() { + let initial_collateral: u128 = 500; + let amount: u128 = 200; + let (post_states, chained_calls) = crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(initial_collateral, 0), + init_vault_account(), + destination_holding_account(), + STABLECOIN_PROGRAM_ID, + amount, + ); + + assert_eq!(post_states.len(), 4); + + // Position post-state: plain `new`, holds the decremented Position. + let position_post = &post_states[1]; + assert_eq!(position_post.required_claim(), None); + let position = Position::try_from(&position_post.account().data).expect("valid Position"); + assert_eq!( + position, + Position { + collateral_vault_id: vault_id(), + collateral_definition_id: collateral_definition_id(), + collateral_amount: initial_collateral - amount, + debt_amount: 0, + } + ); + assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); + + // Vault and destination post-states are pre-transfer (mutation comes via chained call). + assert_eq!(post_states[2].account(), &init_vault_account().account); + assert_eq!( + post_states[3].account(), + &destination_holding_account().account + ); + + // Single chained Token::Transfer with vault PDA seed. + assert_eq!(chained_calls.len(), 1); + let mut vault_authorized = init_vault_account(); + vault_authorized.is_authorized = true; + let expected_transfer = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![vault_authorized, destination_holding_account()], + &token_core::Instruction::Transfer { + amount_to_transfer: amount, + }, + ) + .with_pda_seeds(vec![compute_position_vault_pda_seed(position_id())]); + assert_eq!(chained_calls[0], expected_transfer); +} + +#[test] +fn withdraw_collateral_allows_full_drain() { + let amount: u128 = 500; + let (post_states, _chained_calls) = crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(amount, 0), + init_vault_account(), + destination_holding_account(), + STABLECOIN_PROGRAM_ID, + amount, + ); + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.collateral_amount, 0); + assert_eq!(position.debt_amount, 0); +} + +#[test] +fn withdraw_collateral_allows_zero_amount() { + let initial: u128 = 500; + let (post_states, chained_calls) = crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(initial, 0), + init_vault_account(), + destination_holding_account(), + STABLECOIN_PROGRAM_ID, + 0, + ); + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.collateral_amount, initial); + + let mut vault_authorized = init_vault_account(); + vault_authorized.is_authorized = true; + let expected_transfer = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![vault_authorized, destination_holding_account()], + &token_core::Instruction::Transfer { + amount_to_transfer: 0, + }, + ) + .with_pda_seeds(vec![compute_position_vault_pda_seed(position_id())]); + assert_eq!(chained_calls, vec![expected_transfer]); +} + +#[test] +#[should_panic(expected = "Owner authorization is missing")] +fn withdraw_collateral_requires_owner_authorization() { + let mut owner = owner_account(); + owner.is_authorized = false; + crate::withdraw_collateral::withdraw_collateral( + owner, + init_position_account(500, 0), + init_vault_account(), + destination_holding_account(), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Position account must be initialized")] +fn withdraw_collateral_rejects_uninitialized_position() { + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + uninit_position_account(), + init_vault_account(), + destination_holding_account(), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Position is not owned by this stablecoin program")] +fn withdraw_collateral_rejects_position_owned_by_other_program() { + let mut position = init_position_account(500, 0); + position.account.program_owner = [9u32; 8]; + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + position, + init_vault_account(), + destination_holding_account(), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Position account ID does not match expected derivation")] +fn withdraw_collateral_rejects_wrong_position_address() { + let mut position = init_position_account(500, 0); + position.account_id = AccountId::new([0xFFu8; 32]); + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + position, + init_vault_account(), + destination_holding_account(), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Position vault account ID does not match expected derivation")] +fn withdraw_collateral_rejects_wrong_vault_address() { + let mut vault = init_vault_account(); + vault.account_id = AccountId::new([0xEEu8; 32]); + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(500, 0), + vault, + destination_holding_account(), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Vault token holding is not for the position's collateral definition")] +fn withdraw_collateral_rejects_vault_for_other_definition() { + let mut vault = init_vault_account(); + vault.account.data = Data::from(&TokenHolding::Fungible { + definition_id: AccountId::new([0x21u8; 32]), + balance: 0, + }); + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(500, 0), + vault, + destination_holding_account(), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Destination must be initialized")] +fn withdraw_collateral_rejects_uninitialized_destination() { + let destination = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: destination_holding_id(), + }; + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + destination, + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Destination must be owned by the same Token Program as the vault")] +fn withdraw_collateral_rejects_destination_with_wrong_token_program() { + let mut destination = destination_holding_account(); + destination.account.program_owner = [9u32; 8]; + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + destination, + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic( + expected = "Destination token definition does not match the position's collateral definition" +)] +fn withdraw_collateral_rejects_destination_for_other_definition() { + let mut destination = destination_holding_account(); + destination.account.data = Data::from(&TokenHolding::Fungible { + definition_id: AccountId::new([0x21u8; 32]), + balance: 0, + }); + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + destination, + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "withdraw_collateral with debt is not supported yet")] +fn withdraw_collateral_rejects_withdrawal_with_outstanding_debt() { + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(500, 1), + init_vault_account(), + destination_holding_account(), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Withdrawal amount exceeds position collateral")] +fn withdraw_collateral_rejects_overdraw() { + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(100, 0), + init_vault_account(), + destination_holding_account(), + STABLECOIN_PROGRAM_ID, + 200, + ); +} + +#[test] +fn repay_debt_decreases_debt_and_emits_burn() { + let initial_collateral: u128 = 500; + let initial_debt: u128 = 300; + let amount: u128 = 100; + let holding_balance: u128 = 1_000; + + let (post_states, chained_calls) = crate::repay_debt::repay_debt( + owner_account(), + init_position_account(initial_collateral, initial_debt), + stablecoin_definition_account(), + user_stablecoin_holding_account(holding_balance), + STABLECOIN_PROGRAM_ID, + amount, + ); + + assert_eq!(post_states.len(), 4); + + // Position post-state: plain `new`, holds the decremented Position. + let position_post = &post_states[1]; + assert_eq!(position_post.required_claim(), None); + let position = Position::try_from(&position_post.account().data).expect("valid Position"); + assert_eq!( + position, + Position { + collateral_vault_id: vault_id(), + collateral_definition_id: collateral_definition_id(), + collateral_amount: initial_collateral, + debt_amount: initial_debt - amount, + } + ); + assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); + + // Stablecoin definition and user holding post-states are pre-burn. + assert_eq!( + post_states[2].account(), + &stablecoin_definition_account().account + ); + assert_eq!( + post_states[3].account(), + &user_stablecoin_holding_account(holding_balance).account + ); + + // Single chained Token::Burn, no PDA seeds (user-authorized burn source). + assert_eq!(chained_calls.len(), 1); + let expected_burn = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![ + stablecoin_definition_account(), + user_stablecoin_holding_account(holding_balance), + ], + &token_core::Instruction::Burn { + amount_to_burn: amount, + }, + ); + assert_eq!(chained_calls[0], expected_burn); +} + +#[test] +fn repay_debt_allows_full_repayment() { + let debt: u128 = 300; + let (post_states, _chained_calls) = crate::repay_debt::repay_debt( + owner_account(), + init_position_account(500, debt), + stablecoin_definition_account(), + user_stablecoin_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + debt, + ); + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.debt_amount, 0); + assert_eq!(position.collateral_amount, 500); +} + +#[test] +fn repay_debt_allows_zero_amount() { + let initial_debt: u128 = 300; + let (post_states, chained_calls) = crate::repay_debt::repay_debt( + owner_account(), + init_position_account(500, initial_debt), + stablecoin_definition_account(), + user_stablecoin_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 0, + ); + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.debt_amount, initial_debt); + + let expected_burn = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![ + stablecoin_definition_account(), + user_stablecoin_holding_account(1_000), + ], + &token_core::Instruction::Burn { amount_to_burn: 0 }, + ); + assert_eq!(chained_calls, vec![expected_burn]); +} + +#[test] +#[should_panic(expected = "Owner authorization is missing")] +fn repay_debt_requires_owner_authorization() { + let mut owner = owner_account(); + owner.is_authorized = false; + crate::repay_debt::repay_debt( + owner, + init_position_account(500, 300), + stablecoin_definition_account(), + user_stablecoin_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Position account must be initialized")] +fn repay_debt_rejects_uninitialized_position() { + crate::repay_debt::repay_debt( + owner_account(), + uninit_position_account(), + stablecoin_definition_account(), + user_stablecoin_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Position is not owned by this stablecoin program")] +fn repay_debt_rejects_position_owned_by_other_program() { + let mut position = init_position_account(500, 300); + position.account.program_owner = [9u32; 8]; + crate::repay_debt::repay_debt( + owner_account(), + position, + stablecoin_definition_account(), + user_stablecoin_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Position account ID does not match expected derivation")] +fn repay_debt_rejects_wrong_position_address() { + let mut position = init_position_account(500, 300); + position.account_id = AccountId::new([0xFFu8; 32]); + crate::repay_debt::repay_debt( + owner_account(), + position, + stablecoin_definition_account(), + user_stablecoin_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "User stablecoin holding authorization is missing")] +fn repay_debt_requires_user_holding_authorization() { + let mut holding = user_stablecoin_holding_account(1_000); + holding.is_authorized = false; + crate::repay_debt::repay_debt( + owner_account(), + init_position_account(500, 300), + stablecoin_definition_account(), + holding, + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "User stablecoin holding must be initialized")] +fn repay_debt_rejects_uninitialized_user_holding() { + let holding = AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: user_stablecoin_holding_id(), + }; + crate::repay_debt::repay_debt( + owner_account(), + init_position_account(500, 300), + stablecoin_definition_account(), + holding, + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic( + expected = "Stablecoin holding and definition must be owned by the same Token Program" +)] +fn repay_debt_rejects_holding_with_different_token_program() { + let mut holding = user_stablecoin_holding_account(1_000); + holding.account.program_owner = [9u32; 8]; + crate::repay_debt::repay_debt( + owner_account(), + init_position_account(500, 300), + stablecoin_definition_account(), + holding, + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Stablecoin holding does not match the provided stablecoin definition")] +fn repay_debt_rejects_holding_for_other_definition() { + let mut holding = user_stablecoin_holding_account(1_000); + holding.account.data = Data::from(&TokenHolding::Fungible { + definition_id: AccountId::new([0x21u8; 32]), + balance: 1_000, + }); + crate::repay_debt::repay_debt( + owner_account(), + init_position_account(500, 300), + stablecoin_definition_account(), + holding, + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Repay amount exceeds outstanding debt")] +fn repay_debt_rejects_overrepay() { + crate::repay_debt::repay_debt( + owner_account(), + init_position_account(500, 100), + stablecoin_definition_account(), + user_stablecoin_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 200, + ); +} diff --git a/stablecoin/src/withdraw_collateral.rs b/stablecoin/src/withdraw_collateral.rs new file mode 100644 index 0000000..259c663 --- /dev/null +++ b/stablecoin/src/withdraw_collateral.rs @@ -0,0 +1,129 @@ +use nssa_core::{ + account::{Account, AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, ProgramId}, +}; +use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position}; +use token_core::TokenHolding; + +/// Withdraw `amount` collateral tokens from `position`'s vault back to `destination`. +/// +/// Decreases `Position.collateral_amount` by `amount` and emits a single chained +/// `Token::Transfer` from the vault to `destination`, authorized by the vault +/// PDA seed. The position post-state uses plain [`AccountPostState::new`] — +/// the initial PDA claim already happened in +/// [`crate::open_position::open_position`]. +/// +/// Until issues #95 / #96 / #97 land (redemption price, price feed, stability +/// fee accrual), this instruction hard-asserts `Position.debt_amount == 0`. +/// When those land, this guard is replaced by real fee accrual + a +/// collateralization-ratio check against the post-withdrawal collateral. +/// +/// # Panics +/// - `owner` is not authorized. +/// - `position` is uninitialized, not owned by `stablecoin_program_id`, holds data that does not +/// decode as a [`Position`], or sits at an address that does not match +/// `compute_position_pda(stablecoin_program_id, owner, Position.collateral_definition_id)`. +/// - `vault` sits at an address that does not match +/// `compute_position_vault_pda(stablecoin_program_id, position_id)`, or holds a [`TokenHolding`] +/// whose `definition_id` does not match the position's collateral definition. +/// - `destination` is uninitialized, owned by a different Token Program than the vault, or holds a +/// [`TokenHolding`] whose `definition_id` does not match the position's collateral definition. +/// - `Position.debt_amount` is non-zero. +/// - `amount > Position.collateral_amount`. +pub fn withdraw_collateral( + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + destination: AccountWithMetadata, + stablecoin_program_id: ProgramId, + amount: u128, +) -> (Vec, Vec) { + assert!(owner.is_authorized, "Owner authorization is missing"); + assert_ne!( + position.account, + Account::default(), + "Position account must be initialized" + ); + assert_eq!( + position.account.program_owner, stablecoin_program_id, + "Position is not owned by this stablecoin program" + ); + + let position_data = Position::try_from(&position.account.data) + .expect("Position account must hold valid Position state"); + // `verify_position_and_get_seed` asserts the position address matches the + // (owner, collateral_definition) PDA derivation. We do not use the seed + // downstream — the position is already PDA-claimed. + let _position_seed = verify_position_and_get_seed( + &position, + &owner, + position_data.collateral_definition_id, + stablecoin_program_id, + ); + let vault_seed = + verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id); + + let vault_holding = TokenHolding::try_from(&vault.account.data) + .expect("Vault account must hold a valid TokenHolding"); + assert_eq!( + vault_holding.definition_id(), + position_data.collateral_definition_id, + "Vault token holding is not for the position's collateral definition" + ); + + let token_program_id = vault.account.program_owner; + assert_ne!( + destination.account, + Account::default(), + "Destination must be initialized" + ); + assert_eq!( + destination.account.program_owner, token_program_id, + "Destination must be owned by the same Token Program as the vault" + ); + let destination_holding = TokenHolding::try_from(&destination.account.data) + .expect("Destination account must hold a valid TokenHolding"); + assert_eq!( + destination_holding.definition_id(), + position_data.collateral_definition_id, + "Destination token definition does not match the position's collateral definition" + ); + + assert_eq!( + position_data.debt_amount, 0, + "withdraw_collateral with debt is not supported yet — stability fee accrual and collateralization check land with #97/#96" + ); + let new_collateral = position_data + .collateral_amount + .checked_sub(amount) + .expect("Withdrawal amount exceeds position collateral"); + + let updated_position = Position { + collateral_vault_id: position_data.collateral_vault_id, + collateral_definition_id: position_data.collateral_definition_id, + collateral_amount: new_collateral, + debt_amount: position_data.debt_amount, + }; + let mut position_post = position.account.clone(); + position_post.data = Data::from(&updated_position); + + let post_states = vec![ + AccountPostState::new(owner.account), + AccountPostState::new(position_post), + AccountPostState::new(vault.account.clone()), + AccountPostState::new(destination.account.clone()), + ]; + + let mut vault_authorized = vault.clone(); + vault_authorized.is_authorized = true; + let transfer_call = ChainedCall::new( + token_program_id, + vec![vault_authorized, destination], + &token_core::Instruction::Transfer { + amount_to_transfer: amount, + }, + ) + .with_pda_seeds(vec![vault_seed]); + + (post_states, vec![transfer_call]) +}