feat(stablecoin): implement open_position

Adds the `open_position` instruction to the Stablecoin Program. The instruction
claims a per-owner `Position` PDA, initializes a collateral vault token holding
via a chained `Token::InitializeAccount` under the vault's PDA authority, and
moves `collateral_amount` from the user's holding into the vault with a chained
`Token::Transfer`. `Position` is persisted with `collateral_amount` and
`debt_amount = 0`; the debt path is deferred to `generate_debt`.

- Add `Position` struct, `OpenPosition` instruction variant, and
  `compute_position_pda{,_seed}` / `compute_position_vault_pda{,_seed}` helpers
  in `stablecoin_core` with domain-separated PDA seeds.
- Implement `open_position::open_position` mirroring the ATA `create` and AMM
  `new_definition` patterns: authorization and uninitialized-state asserts, PDA
  verification, and same-transaction chained `InitializeAccount` + `Transfer`.
- Wire the new instruction through the SPEL guest and regenerate the stablecoin
  IDL artifact.
- Cover the happy path, all assertion paths, and PDA determinism /
  non-collision in 11 new unit tests.
This commit is contained in:
Ricardo Guilherme Schmidt 2026-05-11 17:14:27 -03:00 committed by r4bbit
parent 22b41bdb3d
commit f4f7b45bd4
14 changed files with 801 additions and 5 deletions

2
Cargo.lock generated
View File

@ -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]]

View File

@ -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"

View File

@ -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" }

View File

@ -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 }

View File

@ -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, Self::Error> {
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
}

View File

@ -1,3 +1,4 @@
//! Build script that embeds the stablecoin RISC Zero guest ELF as host-side constants.
fn main() {
risc0_build::embed_methods();
}

View File

@ -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"

View File

@ -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"

View File

@ -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<AccountPostState>, 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))
}
}

View File

@ -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"));

View File

@ -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;

View File

@ -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<AccountPostState> {
vec![AccountPostState::new(account.account)]
}

View File

@ -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<AccountPostState>, Vec<ChainedCall>) {
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])
}

View File

@ -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);
}