mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-05-18 15:09:51 +00:00
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:
parent
22b41bdb3d
commit
f4f7b45bd4
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -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]]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
//! Build script that embeds the stablecoin RISC Zero guest ELF as host-side constants.
|
||||
fn main() {
|
||||
risc0_build::embed_methods();
|
||||
}
|
||||
|
||||
13
stablecoin/methods/guest/Cargo.lock
generated
13
stablecoin/methods/guest/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)]
|
||||
}
|
||||
|
||||
132
stablecoin/src/open_position.rs
Normal file
132
stablecoin/src/open_position.rs
Normal 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])
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user