diff --git a/Cargo.lock b/Cargo.lock index 8b8b327..4dc8bfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3042,6 +3042,7 @@ version = "0.1.0" dependencies = [ "borsh", "nssa_core", + "risc0-zkvm", "serde", ] @@ -3051,6 +3052,7 @@ version = "0.1.0" dependencies = [ "nssa_core", "stablecoin_core", + "token_core", ] [[package]] diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 27c8a2b..1015da1 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -13,6 +13,51 @@ } ], "args": [] + }, + { + "name": "open_position", + "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": "user_holding", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "token_definition", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "stablecoin_program_id", + "type": "program_id" + }, + { + "name": "collateral_amount", + "type": "u128" + } + ] } ], "instruction_type": "stablecoin_core::Instruction" diff --git a/stablecoin/Cargo.toml b/stablecoin/Cargo.toml index ca3c6a8..83a0155 100644 --- a/stablecoin/Cargo.toml +++ b/stablecoin/Cargo.toml @@ -6,3 +6,4 @@ edition = "2021" [dependencies] nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] } stablecoin_core = { path = "core" } +token_core = { path = "../token/core" } diff --git a/stablecoin/core/Cargo.toml b/stablecoin/core/Cargo.toml index e32a63b..d63f52e 100644 --- a/stablecoin/core/Cargo.toml +++ b/stablecoin/core/Cargo.toml @@ -7,3 +7,4 @@ edition = "2021" nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] } borsh = { version = "1.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } +risc0-zkvm = { version = "=3.0.5", default-features = false } diff --git a/stablecoin/core/src/lib.rs b/stablecoin/core/src/lib.rs index c006cdc..ad96cc7 100644 --- a/stablecoin/core/src/lib.rs +++ b/stablecoin/core/src/lib.rs @@ -1,6 +1,168 @@ +//! Core data structures and utilities for the Stablecoin Program. + +use borsh::{BorshDeserialize, BorshSerialize}; +use nssa_core::{ + account::{AccountId, AccountWithMetadata, Data}, + program::{PdaSeed, ProgramId}, +}; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize)] +// Domain-separation tags for the PDA seeds derived by the Stablecoin Program. +// These bytes are part of the on-chain derivation scheme and must stay unchanged for +// account-address compatibility. +const POSITION_PDA_DOMAIN: [u8; 32] = *b"stablecoin::position::seed::v1\0\0"; +const POSITION_VAULT_PDA_DOMAIN: [u8; 32] = *b"stablecoin::position::vault::v1\0"; + +/// Stablecoin Program Instruction. +#[derive(Debug, Serialize, Deserialize)] pub enum Instruction { + /// Heartbeat / sanity-check entry point that returns the input account unchanged. Noop, + + /// Open a new collateral-only [`Position`] for the calling owner. + /// + /// Required accounts (4): + /// - Owner account (authorized) + /// - Position account (uninitialized, address must match + /// `compute_position_pda(stablecoin_program_id, owner)`) + /// - Position vault token holding account (uninitialized, address must match + /// `compute_position_vault_pda(stablecoin_program_id, position_id)`) + /// - Owner's source token holding for the collateral (authorized, initialized) + /// + /// `token_program_id` is derived from the owner's collateral holding `program_owner`. + OpenPosition { + /// `ProgramId` under which the [`Position`] and vault PDAs are derived. + stablecoin_program_id: ProgramId, + /// Amount of collateral tokens to deposit into the position vault. + collateral_amount: u128, + }, +} + +/// Persistent state held by a Stablecoin [`Position`] account. +/// +/// `debt_amount` is included for forward compatibility with `generate_debt`; until that +/// instruction lands `open_position` always initializes it to `0`. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct Position { + /// Token holding account (vault PDA) that custodies the collateral backing this position. + pub collateral_vault_id: AccountId, + /// Token definition for the collateral held in `collateral_vault_id`. + pub collateral_definition_id: AccountId, + /// Amount of collateral tokens deposited. + pub collateral_amount: u128, + /// Outstanding stablecoin debt against this position. + pub debt_amount: u128, +} + +impl TryFrom<&Data> for Position { + type Error = std::io::Error; + + fn try_from(data: &Data) -> Result { + Self::try_from_slice(data.as_ref()) + } +} + +impl From<&Position> for Data { + fn from(position: &Position) -> Self { + let mut data = Vec::with_capacity(size_of_val(position)); + #[allow( + clippy::expect_used, + reason = "BorshSerialize::serialize is infallible when writing to a Vec" + )] + BorshSerialize::serialize(position, &mut data) + .expect("Serialization to Vec should not fail"); + #[allow( + clippy::expect_used, + reason = "Position encodes to a small, bounded byte length that fits Data" + )] + Self::try_from(data).expect("Position encoded data should fit into Data") + } +} + +/// PDA seed for the [`Position`] account owned by `owner_id`. +/// +/// Derived from the owner's address with a domain-separation tag so the resulting seed +/// cannot collide with other stablecoin-managed PDAs that hash the same owner id. +pub fn compute_position_pda_seed(owner_id: AccountId) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256}; + + let mut bytes = [0u8; 64]; + bytes[0..32].copy_from_slice(&owner_id.to_bytes()); + bytes[32..64].copy_from_slice(&POSITION_PDA_DOMAIN); + + let mut out = [0u8; 32]; + out.copy_from_slice(Impl::hash_bytes(&bytes).as_bytes()); + PdaSeed::new(out) +} + +/// Account id of the [`Position`] PDA owned by `owner_id` under `stablecoin_program_id`. +pub fn compute_position_pda(stablecoin_program_id: ProgramId, owner_id: AccountId) -> AccountId { + AccountId::from((&stablecoin_program_id, &compute_position_pda_seed(owner_id))) +} + +/// PDA seed for the collateral vault token holding bound to a [`Position`]. +/// +/// Derived from the position's address with a distinct domain-separation tag so the vault +/// id cannot collide with the position id even though both PDAs share the same program. +pub fn compute_position_vault_pda_seed(position_id: AccountId) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256}; + + let mut bytes = [0u8; 64]; + bytes[0..32].copy_from_slice(&position_id.to_bytes()); + bytes[32..64].copy_from_slice(&POSITION_VAULT_PDA_DOMAIN); + + let mut out = [0u8; 32]; + out.copy_from_slice(Impl::hash_bytes(&bytes).as_bytes()); + PdaSeed::new(out) +} + +/// Account id of the collateral vault PDA for `position_id` under `stablecoin_program_id`. +pub fn compute_position_vault_pda( + stablecoin_program_id: ProgramId, + position_id: AccountId, +) -> AccountId { + AccountId::from(( + &stablecoin_program_id, + &compute_position_vault_pda_seed(position_id), + )) +} + +/// Verify the position account's address matches `(stablecoin_program_id, owner)` and +/// return the [`PdaSeed`] for use in chained calls. +/// +/// # Panics +/// If `position.account_id` does not match the address derived from `owner` and +/// `stablecoin_program_id`. +pub fn verify_position_and_get_seed( + position: &AccountWithMetadata, + owner: &AccountWithMetadata, + stablecoin_program_id: ProgramId, +) -> PdaSeed { + let seed = compute_position_pda_seed(owner.account_id); + let expected_id = AccountId::from((&stablecoin_program_id, &seed)); + assert_eq!( + position.account_id, expected_id, + "Position account ID does not match expected derivation" + ); + seed +} + +/// Verify the vault account's address matches `(stablecoin_program_id, position)` and +/// return the [`PdaSeed`] for use in chained calls. +/// +/// # Panics +/// If `vault.account_id` does not match the address derived from `position_id` and +/// `stablecoin_program_id`. +pub fn verify_position_vault_and_get_seed( + vault: &AccountWithMetadata, + position_id: AccountId, + stablecoin_program_id: ProgramId, +) -> PdaSeed { + let seed = compute_position_vault_pda_seed(position_id); + let expected_id = AccountId::from((&stablecoin_program_id, &seed)); + assert_eq!( + vault.account_id, expected_id, + "Position vault account ID does not match expected derivation" + ); + seed } diff --git a/stablecoin/methods/build.rs b/stablecoin/methods/build.rs index 08a8a4e..9ffd3b3 100644 --- a/stablecoin/methods/build.rs +++ b/stablecoin/methods/build.rs @@ -1,3 +1,4 @@ +//! Build script that embeds the stablecoin RISC Zero guest ELF as host-side constants. fn main() { risc0_build::embed_methods(); } diff --git a/stablecoin/methods/guest/Cargo.lock b/stablecoin/methods/guest/Cargo.lock index b8d5a21..b04737f 100644 --- a/stablecoin/methods/guest/Cargo.lock +++ b/stablecoin/methods/guest/Cargo.lock @@ -2937,6 +2937,7 @@ dependencies = [ "spel-framework", "stablecoin_core", "stablecoin_program", + "token_core", ] [[package]] @@ -2945,6 +2946,7 @@ version = "0.1.0" dependencies = [ "borsh", "nssa_core", + "risc0-zkvm", "serde", ] @@ -2954,6 +2956,7 @@ version = "0.1.0" dependencies = [ "nssa_core", "stablecoin_core", + "token_core", ] [[package]] @@ -3140,6 +3143,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "token_core" +version = "0.1.0" +dependencies = [ + "borsh", + "nssa_core", + "serde", + "spel-framework-macros", +] + [[package]] name = "tokio" version = "1.52.3" diff --git a/stablecoin/methods/guest/Cargo.toml b/stablecoin/methods/guest/Cargo.toml index 826343e..254a1d1 100644 --- a/stablecoin/methods/guest/Cargo.toml +++ b/stablecoin/methods/guest/Cargo.toml @@ -15,5 +15,6 @@ nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.gi risc0-zkvm = { version = "=3.0.5", default-features = false } stablecoin_core = { path = "../../core" } stablecoin_program = { path = "../..", package = "stablecoin_program" } +token_core = { path = "../../../token/core" } serde = { version = "1.0", features = ["derive"] } borsh = "1.5" diff --git a/stablecoin/methods/guest/src/bin/stablecoin.rs b/stablecoin/methods/guest/src/bin/stablecoin.rs index 6a40c2c..5f46e9c 100644 --- a/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -1,19 +1,76 @@ -#![no_main] +//! RISC Zero guest binary for the Stablecoin Program. +//! +//! Wires the host-side `stablecoin_program` instruction handlers to the LEZ framework via +//! `#[lez_program]` so the entry points can be invoked from a deployed program. -use nssa_core::account::AccountWithMetadata; +#![no_main] +#![allow( + missing_docs, + reason = "lez_program / instruction proc macros emit module, function, and constant items \ + that do not carry doc strings; user-written handlers document inline" +)] + +use nssa_core::{account::AccountWithMetadata, program::ProgramId}; use spel_framework::prelude::*; risc0_zkvm::guest::entry!(main); #[lez_program(instruction = "stablecoin_core::Instruction")] mod stablecoin { - #[allow(unused_imports)] + #[allow( + unused_imports, + reason = "lez_program expansion may or may not reference every super:: import" + )] use super::*; + /// Heartbeat instruction that returns the input account unchanged. + /// + /// # Errors + /// Currently never; reserved for future validation paths. #[instruction] + #[allow( + deprecated, + reason = "SpelOutput::states_only: lez_program macro only rewrites vec![a, b, ...] \ + literals into execute_with_claims; this handler delegates to a host function \ + that returns Vec, so migration requires restructuring the \ + handler shape — workspace-wide follow-up across token/amm/ata" + )] pub fn noop(account: AccountWithMetadata) -> SpelResult { Ok(spel_framework::SpelOutput::execute(stablecoin_program::noop::noop( account, ), vec![])) } + + /// Open a new collateral-only position for the calling owner. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition fails (see + /// [`stablecoin_program::open_position::open_position`] for the full list). + #[instruction] + #[allow( + deprecated, + reason = "SpelOutput::with_chained_calls: same reason as noop above — migration to \ + SpelOutput::execute requires the macro's vec![...] literal shape, which \ + conflicts with delegating to host helpers" + )] + pub fn open_position( + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + user_holding: AccountWithMetadata, + token_definition: AccountWithMetadata, + stablecoin_program_id: ProgramId, + collateral_amount: u128, + ) -> SpelResult { + let (post_states, chained_calls) = stablecoin_program::open_position::open_position( + owner, + position, + vault, + user_holding, + token_definition, + stablecoin_program_id, + collateral_amount, + ); + Ok(SpelOutput::with_chained_calls(post_states, chained_calls)) + } } diff --git a/stablecoin/methods/src/lib.rs b/stablecoin/methods/src/lib.rs index 1bdb308..2806bd1 100644 --- a/stablecoin/methods/src/lib.rs +++ b/stablecoin/methods/src/lib.rs @@ -1 +1,12 @@ +//! Host-side embedding of the stablecoin RISC Zero guest ELF. +//! +//! Re-exports the constants produced by `build.rs` via `risc0_build::embed_methods` — +//! `STABLECOIN_ELF`, `STABLECOIN_PATH`, and `STABLECOIN_ID` — used by host code to +//! load and identify the guest binary. + +#![allow( + missing_docs, + reason = "constants below are generated by risc0_build::embed_methods at build time" +)] + include!(concat!(env!("OUT_DIR"), "/methods.rs")); diff --git a/stablecoin/src/lib.rs b/stablecoin/src/lib.rs index 54cd98d..4790558 100644 --- a/stablecoin/src/lib.rs +++ b/stablecoin/src/lib.rs @@ -2,7 +2,10 @@ pub use stablecoin_core as core; +/// No-op instruction used as a heartbeat / sanity-check entry point. pub mod noop; +/// Open a new collateral-only position for a calling owner. +pub mod open_position; #[cfg(test)] mod tests; diff --git a/stablecoin/src/noop.rs b/stablecoin/src/noop.rs index e1303b0..b405083 100644 --- a/stablecoin/src/noop.rs +++ b/stablecoin/src/noop.rs @@ -1,5 +1,6 @@ use nssa_core::{account::AccountWithMetadata, program::AccountPostState}; +/// Pass `account` through unchanged as a single post-state entry. pub fn noop(account: AccountWithMetadata) -> Vec { vec![AccountPostState::new(account.account)] } diff --git a/stablecoin/src/open_position.rs b/stablecoin/src/open_position.rs new file mode 100644 index 0000000..35b836e --- /dev/null +++ b/stablecoin/src/open_position.rs @@ -0,0 +1,132 @@ +use nssa_core::{ + account::{Account, AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, Claim, ProgramId}, +}; +use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position}; +use token_core::TokenHolding; + +/// Open a new collateral-only position for `owner`. +/// +/// This claims the [`Position`] PDA, issues two chained token-program calls under the +/// stablecoin's PDA authority, and stores `collateral_amount` with `debt_amount = 0`: +/// 1. `InitializeAccount` materializes the vault token holding for the collateral. +/// 2. `Transfer` moves `collateral_amount` collateral tokens from the user's holding into the +/// freshly initialized vault. +/// +/// `debt_amount` is deferred to a future `generate_debt` instruction and is intentionally +/// not parameterized here. +/// +/// # Panics +/// - `owner` or `user_holding` is not authorized. +/// - `position` or `vault` is already initialized. +/// - `position.account_id` / `vault.account_id` do not match their PDA derivations. +/// - `user_holding` cannot be decoded as a [`TokenHolding`]. +/// - `user_holding`'s definition does not match `token_definition`. +/// - `token_definition.program_owner` does not match `user_holding.program_owner`. +#[allow( + clippy::needless_pass_by_value, + reason = "instruction handler shape: spel #[instruction] macro deserializes owned \ + AccountWithMetadata values into these parameters" +)] +pub fn open_position( + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + user_holding: AccountWithMetadata, + token_definition: AccountWithMetadata, + stablecoin_program_id: ProgramId, + collateral_amount: u128, +) -> (Vec, Vec) { + assert!(owner.is_authorized, "Owner authorization is missing"); + assert!( + user_holding.is_authorized, + "User collateral holding authorization is missing" + ); + assert_eq!( + position.account, + Account::default(), + "Position account must be uninitialized" + ); + assert_eq!( + vault.account, + Account::default(), + "Position vault account must be uninitialized" + ); + + let position_seed = verify_position_and_get_seed(&position, &owner, stablecoin_program_id); + let vault_seed = + verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id); + + #[allow( + clippy::expect_used, + reason = "open_position uses the same panic-on-bad-input pattern as the surrounding \ + assert!/assert_eq! invariant checks; runtime must supply a valid TokenHolding" + )] + let user_holding_definition_id = TokenHolding::try_from(&user_holding.account.data) + .expect("User holding must be a valid Token Holding") + .definition_id(); + assert_eq!( + user_holding_definition_id, token_definition.account_id, + "User collateral holding does not match the provided token definition" + ); + let token_program_id = user_holding.account.program_owner; + assert_eq!( + token_definition.account.program_owner, token_program_id, + "Collateral token definition is not owned by the user holding's Token Program" + ); + + 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, + collateral_amount, + debt_amount: 0, + }); + + let post_states = vec![ + AccountPostState::new(owner.account), + AccountPostState::new_claimed(position_post, Claim::Pda(position_seed)), + AccountPostState::new(vault.account.clone()), + AccountPostState::new(user_holding.account.clone()), + AccountPostState::new(token_definition.account.clone()), + ]; + + // Chained Token::InitializeAccount sets the vault up as a zero-balance holding of the + // collateral token. We hand the runtime the vault marked authorized via PDA so it can + // satisfy `InitializeAccount`'s authorization requirement without a user signature. + let mut vault_authorized = vault.clone(); + vault_authorized.is_authorized = true; + let initialize_call = ChainedCall::new( + token_program_id, + vec![token_definition.clone(), vault_authorized], + &token_core::Instruction::InitializeAccount, + ) + .with_pda_seeds(vec![vault_seed]); + + // After InitializeAccount the vault is a zero-balance Fungible holding for the + // collateral definition. Token::Transfer only requires the sender to be authorized; the + // recipient (vault) is already initialized, so no second PDA claim is needed here. + let post_init_vault = AccountWithMetadata { + account: Account { + program_owner: token_program_id, + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: token_definition.account_id, + balance: 0, + }), + nonce: vault.account.nonce, + }, + is_authorized: false, + account_id: vault.account_id, + }; + let transfer_call = ChainedCall::new( + token_program_id, + vec![user_holding, post_init_vault], + &token_core::Instruction::Transfer { + amount_to_transfer: collateral_amount, + }, + ); + + (post_states, vec![initialize_call, transfer_call]) +} diff --git a/stablecoin/src/tests.rs b/stablecoin/src/tests.rs index 2321f7c..03ec652 100644 --- a/stablecoin/src/tests.rs +++ b/stablecoin/src/tests.rs @@ -1,4 +1,100 @@ -use nssa_core::account::{Account, AccountId, AccountWithMetadata}; +#![allow( + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::unwrap_used, + reason = "tests deliberately panic on bad state via assert!/#[should_panic] and index fixed-size vectors" +)] + +use nssa_core::{ + account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, + program::{ChainedCall, Claim, ProgramId}, +}; +use stablecoin_core::{ + compute_position_pda, compute_position_pda_seed, compute_position_vault_pda, + compute_position_vault_pda_seed, Position, +}; +use token_core::{TokenDefinition, TokenHolding}; + +const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [2u32; 8]; + +fn owner_id() -> AccountId { + AccountId::new([0x10u8; 32]) +} + +fn collateral_definition_id() -> AccountId { + AccountId::new([0x20u8; 32]) +} + +fn user_holding_id() -> AccountId { + AccountId::new([0x30u8; 32]) +} + +fn position_id() -> AccountId { + compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id()) +} + +fn vault_id() -> AccountId { + compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position_id()) +} + +fn owner_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: owner_id(), + } +} + +fn collateral_definition_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0, + data: Data::from(&TokenDefinition::Fungible { + name: "SNT".to_owned(), + total_supply: 1_000_000, + metadata_id: None, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: collateral_definition_id(), + } +} + +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(), + } +} + +fn uninit_position_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: position_id(), + } +} + +fn uninit_vault_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: vault_id(), + } +} #[test] fn noop_returns_single_post_state() { @@ -10,3 +106,273 @@ fn noop_returns_single_post_state() { let post_states = crate::noop::noop(account); assert_eq!(post_states.len(), 1); } + +#[test] +fn open_position_claims_pda_and_emits_chained_calls() { + let collateral_amount: u128 = 500; + let (post_states, chained_calls) = crate::open_position::open_position( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + STABLECOIN_PROGRAM_ID, + collateral_amount, + ); + + assert_eq!(post_states.len(), 5); + + // Position is PDA-claimed and carries the encoded Position state. + let position_post = &post_states[1]; + assert_eq!( + position_post.required_claim(), + Some(Claim::Pda(compute_position_pda_seed(owner_id()))) + ); + 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, + debt_amount: 0, + } + ); + assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); + + assert_eq!(chained_calls.len(), 2); + + let mut vault_authorized = uninit_vault_account(); + vault_authorized.is_authorized = true; + let expected_initialize = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![collateral_definition_account(), vault_authorized], + &token_core::Instruction::InitializeAccount, + ) + .with_pda_seeds(vec![compute_position_vault_pda_seed(position_id())]); + assert_eq!(chained_calls[0], expected_initialize); + + let post_init_vault = AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: collateral_definition_id(), + balance: 0, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: vault_id(), + }; + let expected_transfer = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![user_holding_account(1_000), post_init_vault], + &token_core::Instruction::Transfer { + amount_to_transfer: collateral_amount, + }, + ); + assert_eq!(chained_calls[1], expected_transfer); +} + +#[test] +#[should_panic(expected = "Owner authorization is missing")] +fn open_position_requires_owner_authorization() { + let mut owner = owner_account(); + owner.is_authorized = false; + + crate::open_position::open_position( + owner, + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + STABLECOIN_PROGRAM_ID, + 500, + ); +} + +#[test] +#[should_panic(expected = "User collateral holding authorization is missing")] +fn open_position_requires_user_holding_authorization() { + let mut holding = user_holding_account(1_000); + holding.is_authorized = false; + + crate::open_position::open_position( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + holding, + collateral_definition_account(), + STABLECOIN_PROGRAM_ID, + 500, + ); +} + +#[test] +#[should_panic(expected = "Position account must be uninitialized")] +fn open_position_rejects_initialized_position() { + let position = 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: 1, + debt_amount: 0, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: position_id(), + }; + + crate::open_position::open_position( + owner_account(), + position, + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + STABLECOIN_PROGRAM_ID, + 500, + ); +} + +#[test] +#[should_panic(expected = "Position vault account must be uninitialized")] +fn open_position_rejects_initialized_vault() { + let vault = AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: collateral_definition_id(), + balance: 0, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: vault_id(), + }; + + crate::open_position::open_position( + owner_account(), + uninit_position_account(), + vault, + user_holding_account(1_000), + collateral_definition_account(), + STABLECOIN_PROGRAM_ID, + 500, + ); +} + +#[test] +#[should_panic(expected = "Position account ID does not match expected derivation")] +fn open_position_rejects_wrong_position_address() { + let bad_position = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: AccountId::new([0xFFu8; 32]), + }; + + crate::open_position::open_position( + owner_account(), + bad_position, + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + STABLECOIN_PROGRAM_ID, + 500, + ); +} + +#[test] +#[should_panic(expected = "Position vault account ID does not match expected derivation")] +fn open_position_rejects_wrong_vault_address() { + let bad_vault = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: AccountId::new([0xEEu8; 32]), + }; + + crate::open_position::open_position( + owner_account(), + uninit_position_account(), + bad_vault, + user_holding_account(1_000), + collateral_definition_account(), + STABLECOIN_PROGRAM_ID, + 500, + ); +} + +#[test] +#[should_panic(expected = "User collateral holding does not match the provided token definition")] +fn open_position_rejects_mismatched_token_definition() { + let other_definition = AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0, + data: Data::from(&TokenDefinition::Fungible { + name: "OTHER".to_owned(), + total_supply: 1, + metadata_id: None, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: AccountId::new([0x21u8; 32]), + }; + + crate::open_position::open_position( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + other_definition, + STABLECOIN_PROGRAM_ID, + 500, + ); +} + +#[test] +#[should_panic( + expected = "Collateral token definition is not owned by the user holding's Token Program" +)] +fn open_position_rejects_definition_with_wrong_token_program() { + let mut definition = collateral_definition_account(); + definition.account.program_owner = [9u32; 8]; + + crate::open_position::open_position( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + definition, + STABLECOIN_PROGRAM_ID, + 500, + ); +} + +#[test] +fn position_pda_is_deterministic_and_owner_specific() { + let id_a = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id()); + let id_b = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id()); + assert_eq!(id_a, id_b); + + let other_owner = AccountId::new([0x11u8; 32]); + assert_ne!( + compute_position_pda(STABLECOIN_PROGRAM_ID, other_owner), + id_a + ); +} + +#[test] +fn position_pda_and_vault_pda_do_not_collide() { + // Distinct domain tags must keep the position id and its vault id disjoint, even + // though both derivations involve only the owner's address. + let position = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id()); + let vault = compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position); + assert_ne!(position, vault); +}