mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
refactor: move programs into programs and UIs into apps
This refactors the repository structure as it has grown over time.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "amm_program"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
|
||||
amm_core = { path = "core" }
|
||||
token_core = { path = "../token/core" }
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "amm_core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
|
||||
spel-framework-macros = { git = "https://github.com/logos-co/spel.git", tag = "v0.3.0", package = "spel-framework-macros" }
|
||||
token_core = { path = "../../token/core" }
|
||||
borsh = { version = "1.5", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
risc0-zkvm = { version = "=3.0.5", default-features = false }
|
||||
@@ -0,0 +1,312 @@
|
||||
//! This crate contains core data structures and utilities for the AMM Program.
|
||||
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use nssa_core::{
|
||||
account::{AccountId, AccountWithMetadata, Data},
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use spel_framework_macros::account_type;
|
||||
|
||||
// These stable seed bytes are part of the PDA derivation scheme and must stay unchanged for
|
||||
// compatibility.
|
||||
const LIQUIDITY_TOKEN_PDA_SEED: [u8; 32] = [0; 32];
|
||||
const LP_LOCK_HOLDING_PDA_SEED: [u8; 32] = [1; 32];
|
||||
|
||||
/// AMM Program Instruction.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Initializes a new Pool (or re-initializes an existing zero-supply Pool).
|
||||
///
|
||||
/// On initialization, `MINIMUM_LIQUIDITY` LP tokens are permanently locked
|
||||
/// in the LP-lock holding PDA; the caller receives `initial_lp - MINIMUM_LIQUIDITY`.
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - AMM Pool
|
||||
/// - Vault Holding Account for Token A
|
||||
/// - Vault Holding Account for Token B
|
||||
/// - Pool Liquidity Token Definition
|
||||
/// - LP Lock Holding Account, derived as `compute_lp_lock_holding_pda(self_program_id,
|
||||
/// pool.account_id)`
|
||||
/// - User Holding Account for Token A (authorized)
|
||||
/// - User Holding Account for Token B (authorized)
|
||||
/// - User Holding Account for Pool Liquidity (authorized when uninitialized)
|
||||
NewDefinition {
|
||||
token_a_amount: u128,
|
||||
token_b_amount: u128,
|
||||
fees: u128,
|
||||
/// Unix timestamp (milliseconds) after which this transaction is invalid.
|
||||
deadline: u64,
|
||||
},
|
||||
|
||||
/// Adds liquidity to the Pool
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - AMM Pool (initialized)
|
||||
/// - Vault Holding Account for Token A (initialized)
|
||||
/// - Vault Holding Account for Token B (initialized)
|
||||
/// - Pool Liquidity Token Definition (initialized)
|
||||
/// - User Holding Account for Token A (authorized)
|
||||
/// - User Holding Account for Token B (authorized)
|
||||
/// - User Holding Account for Pool Liquidity
|
||||
AddLiquidity {
|
||||
min_amount_liquidity: u128,
|
||||
max_amount_to_add_token_a: u128,
|
||||
max_amount_to_add_token_b: u128,
|
||||
/// Unix timestamp (milliseconds) after which this transaction is invalid.
|
||||
deadline: u64,
|
||||
},
|
||||
|
||||
/// Removes liquidity from the Pool
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - AMM Pool (initialized)
|
||||
/// - Vault Holding Account for Token A (initialized)
|
||||
/// - Vault Holding Account for Token B (initialized)
|
||||
/// - Pool Liquidity Token Definition (initialized)
|
||||
/// - User Holding Account for Token A (initialized)
|
||||
/// - User Holding Account for Token B (initialized)
|
||||
/// - User Holding Account for Pool Liquidity (authorized)
|
||||
RemoveLiquidity {
|
||||
remove_liquidity_amount: u128,
|
||||
min_amount_to_remove_token_a: u128,
|
||||
min_amount_to_remove_token_b: u128,
|
||||
/// Unix timestamp (milliseconds) after which this transaction is invalid.
|
||||
deadline: u64,
|
||||
},
|
||||
|
||||
/// Swap some quantity of Tokens (either Token A or Token B)
|
||||
/// while maintaining the Pool constant product.
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - AMM Pool (initialized)
|
||||
/// - Vault Holding Account for Token A (initialized)
|
||||
/// - Vault Holding Account for Token B (initialized)
|
||||
/// - User Holding Account for Token A
|
||||
/// - User Holding Account for Token B; either is authorized.
|
||||
SwapExactInput {
|
||||
swap_amount_in: u128,
|
||||
min_amount_out: u128,
|
||||
token_definition_id_in: AccountId,
|
||||
/// Unix timestamp (milliseconds) after which this transaction is invalid.
|
||||
deadline: u64,
|
||||
},
|
||||
|
||||
/// Swap tokens specifying the exact desired output amount,
|
||||
/// while maintaining the Pool constant product.
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - AMM Pool (initialized)
|
||||
/// - Vault Holding Account for Token A (initialized)
|
||||
/// - Vault Holding Account for Token B (initialized)
|
||||
/// - User Holding Account for Token A
|
||||
/// - User Holding Account for Token B; either is authorized.
|
||||
SwapExactOutput {
|
||||
exact_amount_out: u128,
|
||||
max_amount_in: u128,
|
||||
token_definition_id_in: AccountId,
|
||||
/// Unix timestamp (milliseconds) after which this transaction is invalid.
|
||||
deadline: u64,
|
||||
},
|
||||
|
||||
/// Sync pool reserves with current vault balances.
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - AMM Pool (initialized, with LP supply at or above minimum liquidity)
|
||||
/// - Vault Holding Account for Token A (initialized)
|
||||
/// - Vault Holding Account for Token B (initialized)
|
||||
SyncReserves,
|
||||
}
|
||||
|
||||
pub const MINIMUM_LIQUIDITY: u128 = 1_000;
|
||||
|
||||
#[account_type]
|
||||
#[derive(Clone, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
|
||||
pub struct PoolDefinition {
|
||||
pub definition_token_a_id: AccountId,
|
||||
pub definition_token_b_id: AccountId,
|
||||
pub vault_a_id: AccountId,
|
||||
pub vault_b_id: AccountId,
|
||||
pub liquidity_pool_id: AccountId,
|
||||
/// Total LP supply tracked by the pool. After initialization it includes the permanently
|
||||
/// locked `MINIMUM_LIQUIDITY`; a zero supply means the pool is uninitialized
|
||||
pub liquidity_pool_supply: u128,
|
||||
pub reserve_a: u128,
|
||||
pub reserve_b: u128,
|
||||
/// Fee tier in basis points.
|
||||
pub fees: u128,
|
||||
}
|
||||
|
||||
pub const FEE_BPS_DENOMINATOR: u128 = 10_000;
|
||||
pub const FEE_TIER_BPS_1: u128 = 1;
|
||||
pub const FEE_TIER_BPS_5: u128 = 5;
|
||||
pub const FEE_TIER_BPS_30: u128 = 30;
|
||||
pub const FEE_TIER_BPS_100: u128 = 100;
|
||||
|
||||
pub fn is_supported_fee_tier(fees: u128) -> bool {
|
||||
matches!(
|
||||
fees,
|
||||
FEE_TIER_BPS_1 | FEE_TIER_BPS_5 | FEE_TIER_BPS_30 | FEE_TIER_BPS_100
|
||||
)
|
||||
}
|
||||
|
||||
pub fn assert_supported_fee_tier(fees: u128) {
|
||||
assert!(
|
||||
is_supported_fee_tier(fees),
|
||||
"Fee tier must be one of 1, 5, 30, or 100 basis points"
|
||||
);
|
||||
}
|
||||
|
||||
impl TryFrom<&Data> for PoolDefinition {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from(data: &Data) -> Result<Self, Self::Error> {
|
||||
PoolDefinition::try_from_slice(data.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PoolDefinition> for Data {
|
||||
fn from(definition: &PoolDefinition) -> Self {
|
||||
// Using size_of_val as size hint for Vec allocation
|
||||
let mut data = Vec::with_capacity(std::mem::size_of_val(definition));
|
||||
|
||||
BorshSerialize::serialize(definition, &mut data)
|
||||
.expect("Serialization to Vec should not fail");
|
||||
|
||||
Data::try_from(data).expect("Token definition encoded data should fit into Data")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_pool_pda(
|
||||
amm_program_id: ProgramId,
|
||||
definition_token_a_id: AccountId,
|
||||
definition_token_b_id: AccountId,
|
||||
) -> AccountId {
|
||||
AccountId::for_public_pda(
|
||||
&amm_program_id,
|
||||
&compute_pool_pda_seed(definition_token_a_id, definition_token_b_id),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compute_pool_pda_seed(
|
||||
definition_token_a_id: AccountId,
|
||||
definition_token_b_id: AccountId,
|
||||
) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256};
|
||||
|
||||
let (token_1, token_2) = match definition_token_a_id
|
||||
.value()
|
||||
.cmp(definition_token_b_id.value())
|
||||
{
|
||||
std::cmp::Ordering::Less => (definition_token_b_id, definition_token_a_id),
|
||||
std::cmp::Ordering::Greater => (definition_token_a_id, definition_token_b_id),
|
||||
std::cmp::Ordering::Equal => panic!("Definitions match"),
|
||||
};
|
||||
|
||||
let mut bytes = [0; 64];
|
||||
let (token_1_bytes, token_2_bytes) = bytes.split_at_mut(32);
|
||||
token_1_bytes.copy_from_slice(&token_1.to_bytes());
|
||||
token_2_bytes.copy_from_slice(&token_2.to_bytes());
|
||||
|
||||
PdaSeed::new(
|
||||
Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.expect("Hash output must be exactly 32 bytes long"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compute_vault_pda(
|
||||
amm_program_id: ProgramId,
|
||||
pool_id: AccountId,
|
||||
definition_token_id: AccountId,
|
||||
) -> AccountId {
|
||||
AccountId::for_public_pda(
|
||||
&amm_program_id,
|
||||
&compute_vault_pda_seed(pool_id, definition_token_id),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compute_vault_pda_seed(pool_id: AccountId, definition_token_id: AccountId) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256};
|
||||
|
||||
let mut bytes = [0; 64];
|
||||
let (pool_bytes, definition_bytes) = bytes.split_at_mut(32);
|
||||
pool_bytes.copy_from_slice(&pool_id.to_bytes());
|
||||
definition_bytes.copy_from_slice(&definition_token_id.to_bytes());
|
||||
|
||||
PdaSeed::new(
|
||||
Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.expect("Hash output must be exactly 32 bytes long"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compute_liquidity_token_pda(amm_program_id: ProgramId, pool_id: AccountId) -> AccountId {
|
||||
AccountId::for_public_pda(&amm_program_id, &compute_liquidity_token_pda_seed(pool_id))
|
||||
}
|
||||
|
||||
pub fn compute_liquidity_token_pda_seed(pool_id: AccountId) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256};
|
||||
|
||||
let mut bytes = [0; 64];
|
||||
let (pool_bytes, seed_bytes) = bytes.split_at_mut(32);
|
||||
pool_bytes.copy_from_slice(&pool_id.to_bytes());
|
||||
seed_bytes.copy_from_slice(&LIQUIDITY_TOKEN_PDA_SEED);
|
||||
|
||||
PdaSeed::new(
|
||||
Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.expect("Hash output must be exactly 32 bytes long"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compute_lp_lock_holding_pda(amm_program_id: ProgramId, pool_id: AccountId) -> AccountId {
|
||||
AccountId::for_public_pda(&amm_program_id, &compute_lp_lock_holding_pda_seed(pool_id))
|
||||
}
|
||||
|
||||
pub fn compute_lp_lock_holding_pda_seed(pool_id: AccountId) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256};
|
||||
|
||||
let mut bytes = [0; 64];
|
||||
let (pool_bytes, seed_bytes) = bytes.split_at_mut(32);
|
||||
pool_bytes.copy_from_slice(&pool_id.to_bytes());
|
||||
seed_bytes.copy_from_slice(&LP_LOCK_HOLDING_PDA_SEED);
|
||||
|
||||
PdaSeed::new(
|
||||
Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.expect("Hash output must be exactly 32 bytes long"),
|
||||
)
|
||||
}
|
||||
|
||||
fn read_fungible_holding(account: &AccountWithMetadata, context: &str) -> (AccountId, u128) {
|
||||
let token_holding = token_core::TokenHolding::try_from(&account.account.data)
|
||||
.unwrap_or_else(|_| panic!("{context}: AMM Program expects a valid Token Holding Account"));
|
||||
|
||||
let token_core::TokenHolding::Fungible {
|
||||
definition_id,
|
||||
balance,
|
||||
} = token_holding
|
||||
else {
|
||||
panic!("{context}: AMM Program expects a valid Fungible Token Holding Account");
|
||||
};
|
||||
|
||||
(definition_id, balance)
|
||||
}
|
||||
|
||||
pub fn read_vault_fungible_balances(
|
||||
context: &str,
|
||||
vault_a: &AccountWithMetadata,
|
||||
vault_b: &AccountWithMetadata,
|
||||
) -> (u128, u128) {
|
||||
let vault_a_context = format!("{context}: Vault A");
|
||||
let vault_b_context = format!("{context}: Vault B");
|
||||
let (_, vault_a_balance) = read_fungible_holding(vault_a, &vault_a_context);
|
||||
let (_, vault_b_balance) = read_fungible_holding(vault_b, &vault_b_context);
|
||||
|
||||
(vault_a_balance, vault_b_balance)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "amm-methods"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
risc0-build = "=3.0.5"
|
||||
|
||||
[dependencies]
|
||||
risc0-zkvm = { version = "=3.0.5", features = ["std"] }
|
||||
amm_core = { path = "../core" }
|
||||
|
||||
[package.metadata.risc0]
|
||||
methods = ["guest"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
risc0_build::embed_methods();
|
||||
}
|
||||
Generated
+4048
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
[package]
|
||||
name = "amm-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[workspace]
|
||||
|
||||
[lints.rust]
|
||||
rust_2018_idioms = { level = "deny", priority = -1 }
|
||||
# deny (not forbid) so a targeted per-item #[allow] remains possible if ever needed
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
# Deny only the groups where a new lint should always be a hard error.
|
||||
# style/pedantic lints default to warn so toolchain upgrades don't break the
|
||||
# build unexpectedly — they can be evaluated and addressed at our own pace.
|
||||
correctness = { level = "deny", priority = -1 }
|
||||
suspicious = { level = "deny", priority = -1 }
|
||||
perf = { level = "deny", priority = -1 }
|
||||
style = { level = "warn", priority = -1 }
|
||||
|
||||
# Generated-code / placeholder blockers.
|
||||
dbg_macro = "deny"
|
||||
todo = "deny"
|
||||
unimplemented = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
# Lint suppression hygiene.
|
||||
allow_attributes = "warn"
|
||||
allow_attributes_without_reason = "deny"
|
||||
|
||||
# Determinism, panic-safety, and arithmetic correctness.
|
||||
arithmetic_side_effects = "deny"
|
||||
indexing_slicing = "deny"
|
||||
|
||||
# Cast discipline.
|
||||
as_conversions = "deny"
|
||||
cast_possible_truncation = "deny"
|
||||
cast_possible_wrap = "deny"
|
||||
cast_sign_loss = "deny"
|
||||
|
||||
# API and enum evolution.
|
||||
large_enum_variant = "deny"
|
||||
wildcard_enum_match_arm = "deny"
|
||||
|
||||
# Too noisy for this codebase unless enforced selectively.
|
||||
module_name_repetitions = "allow"
|
||||
similar_names = "allow"
|
||||
|
||||
[[bin]]
|
||||
name = "amm"
|
||||
path = "src/bin/amm.rs"
|
||||
|
||||
[dependencies]
|
||||
spel-framework = { git = "https://github.com/logos-co/spel.git", tag = "v0.3.0", package = "spel-framework" }
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" }
|
||||
risc0-zkvm = { version = "=3.0.5", default-features = false }
|
||||
amm_core = { path = "../../core" }
|
||||
amm_program = { path = "../..", package = "amm_program" }
|
||||
token_core = { path = "../../../token/core" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
borsh = "1.5"
|
||||
@@ -0,0 +1,206 @@
|
||||
#![cfg_attr(not(test), no_main)]
|
||||
|
||||
use std::num::NonZeroU128;
|
||||
|
||||
use spel_framework::prelude::*;
|
||||
use spel_framework::context::ProgramContext;
|
||||
use nssa_core::{
|
||||
account::{AccountId, AccountWithMetadata},
|
||||
};
|
||||
|
||||
#[cfg(not(test))]
|
||||
risc0_zkvm::guest::entry!(main);
|
||||
|
||||
#[lez_program(instruction = "amm_core::Instruction")]
|
||||
mod amm {
|
||||
#[expect(
|
||||
unused_imports,
|
||||
reason = "SPEL instruction macro requires importing parent-scope handler types"
|
||||
)]
|
||||
use super::*;
|
||||
|
||||
/// Initializes a new Pool (or re-initializes an existing zero-supply Pool).
|
||||
/// A fresh user LP holding must be explicitly authorized by the caller.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "instruction interface requires explicit pool, vault, mint, lock, and user accounts"
|
||||
)]
|
||||
#[instruction]
|
||||
pub fn new_definition(
|
||||
ctx: ProgramContext,
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
pool_definition_lp: AccountWithMetadata,
|
||||
lp_lock_holding: AccountWithMetadata,
|
||||
user_holding_a: AccountWithMetadata,
|
||||
user_holding_b: AccountWithMetadata,
|
||||
user_holding_lp: AccountWithMetadata,
|
||||
token_a_amount: u128,
|
||||
token_b_amount: u128,
|
||||
fees: u128,
|
||||
deadline: u64,
|
||||
) -> SpelResult {
|
||||
let (post_states, chained_calls) = amm_program::new_definition::new_definition(
|
||||
pool,
|
||||
vault_a,
|
||||
vault_b,
|
||||
pool_definition_lp,
|
||||
lp_lock_holding,
|
||||
user_holding_a,
|
||||
user_holding_b,
|
||||
user_holding_lp,
|
||||
NonZeroU128::new(token_a_amount).expect("token_a_amount must be nonzero"),
|
||||
NonZeroU128::new(token_b_amount).expect("token_b_amount must be nonzero"),
|
||||
fees,
|
||||
ctx.self_program_id,
|
||||
);
|
||||
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls)
|
||||
.with_timestamp_validity_window(..deadline))
|
||||
}
|
||||
|
||||
/// Adds liquidity to the Pool.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "instruction interface requires explicit pool, vault, and user accounts"
|
||||
)]
|
||||
#[instruction]
|
||||
pub fn add_liquidity(
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
pool_definition_lp: AccountWithMetadata,
|
||||
user_holding_a: AccountWithMetadata,
|
||||
user_holding_b: AccountWithMetadata,
|
||||
user_holding_lp: AccountWithMetadata,
|
||||
min_amount_liquidity: u128,
|
||||
max_amount_to_add_token_a: u128,
|
||||
max_amount_to_add_token_b: u128,
|
||||
deadline: u64,
|
||||
) -> SpelResult {
|
||||
let (post_states, chained_calls) = amm_program::add::add_liquidity(
|
||||
pool,
|
||||
vault_a,
|
||||
vault_b,
|
||||
pool_definition_lp,
|
||||
user_holding_a,
|
||||
user_holding_b,
|
||||
user_holding_lp,
|
||||
NonZeroU128::new(min_amount_liquidity).expect("min_amount_liquidity must be nonzero"),
|
||||
max_amount_to_add_token_a,
|
||||
max_amount_to_add_token_b,
|
||||
);
|
||||
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls)
|
||||
.with_timestamp_validity_window(..deadline))
|
||||
}
|
||||
|
||||
/// Removes liquidity from the Pool.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "instruction interface requires explicit pool, vault, and user accounts"
|
||||
)]
|
||||
#[instruction]
|
||||
pub fn remove_liquidity(
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
pool_definition_lp: AccountWithMetadata,
|
||||
user_holding_a: AccountWithMetadata,
|
||||
user_holding_b: AccountWithMetadata,
|
||||
user_holding_lp: AccountWithMetadata,
|
||||
remove_liquidity_amount: u128,
|
||||
min_amount_to_remove_token_a: u128,
|
||||
min_amount_to_remove_token_b: u128,
|
||||
deadline: u64,
|
||||
) -> SpelResult {
|
||||
let (post_states, chained_calls) = amm_program::remove::remove_liquidity(
|
||||
pool,
|
||||
vault_a,
|
||||
vault_b,
|
||||
pool_definition_lp,
|
||||
user_holding_a,
|
||||
user_holding_b,
|
||||
user_holding_lp,
|
||||
NonZeroU128::new(remove_liquidity_amount)
|
||||
.expect("remove_liquidity_amount must be nonzero"),
|
||||
min_amount_to_remove_token_a,
|
||||
min_amount_to_remove_token_b,
|
||||
);
|
||||
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls)
|
||||
.with_timestamp_validity_window(..deadline))
|
||||
}
|
||||
|
||||
/// Swap some quantity of tokens while maintaining the pool constant product.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "instruction interface requires explicit pool, vault, user accounts, and bounds"
|
||||
)]
|
||||
#[instruction]
|
||||
pub fn swap_exact_input(
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
user_holding_a: AccountWithMetadata,
|
||||
user_holding_b: AccountWithMetadata,
|
||||
swap_amount_in: u128,
|
||||
min_amount_out: u128,
|
||||
token_definition_id_in: AccountId,
|
||||
deadline: u64,
|
||||
) -> SpelResult {
|
||||
let (post_states, chained_calls) = amm_program::swap::swap_exact_input(
|
||||
pool,
|
||||
vault_a,
|
||||
vault_b,
|
||||
user_holding_a,
|
||||
user_holding_b,
|
||||
swap_amount_in,
|
||||
min_amount_out,
|
||||
token_definition_id_in,
|
||||
);
|
||||
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls)
|
||||
.with_timestamp_validity_window(..deadline))
|
||||
}
|
||||
|
||||
/// Swap tokens specifying the exact desired output amount.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "instruction interface requires explicit pool, vault, user accounts, and bounds"
|
||||
)]
|
||||
#[instruction]
|
||||
pub fn swap_exact_output(
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
user_holding_a: AccountWithMetadata,
|
||||
user_holding_b: AccountWithMetadata,
|
||||
exact_amount_out: u128,
|
||||
max_amount_in: u128,
|
||||
token_definition_id_in: AccountId,
|
||||
deadline: u64,
|
||||
) -> SpelResult {
|
||||
let (post_states, chained_calls) = amm_program::swap::swap_exact_output(
|
||||
pool,
|
||||
vault_a,
|
||||
vault_b,
|
||||
user_holding_a,
|
||||
user_holding_b,
|
||||
exact_amount_out,
|
||||
max_amount_in,
|
||||
token_definition_id_in,
|
||||
);
|
||||
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls)
|
||||
.with_timestamp_validity_window(..deadline))
|
||||
}
|
||||
|
||||
/// Sync pool reserves with current vault balances.
|
||||
#[instruction]
|
||||
pub fn sync_reserves(
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
) -> SpelResult {
|
||||
let (post_states, chained_calls) =
|
||||
amm_program::sync::sync_reserves(pool, vault_a, vault_b);
|
||||
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
include!(concat!(env!("OUT_DIR"), "/methods.rs"));
|
||||
@@ -0,0 +1,200 @@
|
||||
use std::num::NonZeroU128;
|
||||
|
||||
use amm_core::{
|
||||
assert_supported_fee_tier, compute_liquidity_token_pda_seed, read_vault_fungible_balances,
|
||||
PoolDefinition,
|
||||
};
|
||||
use nssa_core::{
|
||||
account::{AccountWithMetadata, Data},
|
||||
program::{AccountPostState, ChainedCall},
|
||||
};
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "instruction surface passes explicit pool, vault, and user accounts"
|
||||
)]
|
||||
pub fn add_liquidity(
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
pool_definition_lp: AccountWithMetadata,
|
||||
user_holding_a: AccountWithMetadata,
|
||||
user_holding_b: AccountWithMetadata,
|
||||
user_holding_lp: AccountWithMetadata,
|
||||
min_amount_liquidity: NonZeroU128,
|
||||
max_amount_to_add_token_a: u128,
|
||||
max_amount_to_add_token_b: u128,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
// 1. Fetch Pool state
|
||||
let pool_def_data = PoolDefinition::try_from(&pool.account.data)
|
||||
.expect("Add liquidity: AMM Program expects valid Pool Definition Account");
|
||||
assert_supported_fee_tier(pool_def_data.fees);
|
||||
|
||||
assert_eq!(
|
||||
vault_a.account_id, pool_def_data.vault_a_id,
|
||||
"Vault A was not provided"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
pool_def_data.liquidity_pool_id, pool_definition_lp.account_id,
|
||||
"LP definition mismatch"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
vault_b.account_id, pool_def_data.vault_b_id,
|
||||
"Vault B was not provided"
|
||||
);
|
||||
|
||||
let token_program_id = vault_a.account.program_owner;
|
||||
assert_eq!(
|
||||
user_holding_a.account.program_owner, token_program_id,
|
||||
"User Token A holding must be owned by the vault's Token Program"
|
||||
);
|
||||
assert_eq!(
|
||||
user_holding_b.account.program_owner, token_program_id,
|
||||
"User Token B holding must be owned by the vault's Token Program"
|
||||
);
|
||||
|
||||
assert!(
|
||||
max_amount_to_add_token_a != 0 && max_amount_to_add_token_b != 0,
|
||||
"Both max-balances must be nonzero"
|
||||
);
|
||||
|
||||
let (vault_a_balance, vault_b_balance) =
|
||||
read_vault_fungible_balances("Add liquidity", &vault_a, &vault_b);
|
||||
|
||||
assert!(
|
||||
vault_a_balance >= pool_def_data.reserve_a,
|
||||
"Vaults' balances must be at least the reserve amounts"
|
||||
);
|
||||
assert!(
|
||||
vault_b_balance >= pool_def_data.reserve_b,
|
||||
"Vaults' balances must be at least the reserve amounts"
|
||||
);
|
||||
|
||||
// 2. Determine deposit amount
|
||||
assert!(pool_def_data.reserve_a != 0, "Reserves must be nonzero");
|
||||
assert!(pool_def_data.reserve_b != 0, "Reserves must be nonzero");
|
||||
|
||||
let ideal_a: u128 = pool_def_data
|
||||
.reserve_a
|
||||
.checked_mul(max_amount_to_add_token_b)
|
||||
.expect("reserve_a * max_amount_b overflows u128")
|
||||
.checked_div(pool_def_data.reserve_b)
|
||||
.expect("reserve_b must be nonzero after validation");
|
||||
let ideal_b: u128 = pool_def_data
|
||||
.reserve_b
|
||||
.checked_mul(max_amount_to_add_token_a)
|
||||
.expect("reserve_b * max_amount_a overflows u128")
|
||||
.checked_div(pool_def_data.reserve_a)
|
||||
.expect("reserve_a must be nonzero after validation");
|
||||
|
||||
let actual_amount_a = if ideal_a > max_amount_to_add_token_a {
|
||||
max_amount_to_add_token_a
|
||||
} else {
|
||||
ideal_a
|
||||
};
|
||||
let actual_amount_b = if ideal_b > max_amount_to_add_token_b {
|
||||
max_amount_to_add_token_b
|
||||
} else {
|
||||
ideal_b
|
||||
};
|
||||
|
||||
// 3. Validate amounts
|
||||
assert!(
|
||||
max_amount_to_add_token_a >= actual_amount_a,
|
||||
"Actual trade amounts cannot exceed max_amounts"
|
||||
);
|
||||
assert!(
|
||||
max_amount_to_add_token_b >= actual_amount_b,
|
||||
"Actual trade amounts cannot exceed max_amounts"
|
||||
);
|
||||
|
||||
assert!(actual_amount_a != 0, "A trade amount is 0");
|
||||
assert!(actual_amount_b != 0, "A trade amount is 0");
|
||||
|
||||
// 4. Calculate LP to mint
|
||||
let delta_lp = std::cmp::min(
|
||||
pool_def_data
|
||||
.liquidity_pool_supply
|
||||
.checked_mul(actual_amount_a)
|
||||
.expect("liquidity_pool_supply * actual_amount_a overflows u128")
|
||||
.checked_div(pool_def_data.reserve_a)
|
||||
.expect("reserve_a must be nonzero after validation"),
|
||||
pool_def_data
|
||||
.liquidity_pool_supply
|
||||
.checked_mul(actual_amount_b)
|
||||
.expect("liquidity_pool_supply * actual_amount_b overflows u128")
|
||||
.checked_div(pool_def_data.reserve_b)
|
||||
.expect("reserve_b must be nonzero after validation"),
|
||||
);
|
||||
|
||||
assert!(delta_lp != 0, "Payable LP must be nonzero");
|
||||
|
||||
assert!(
|
||||
delta_lp >= min_amount_liquidity.get(),
|
||||
"Payable LP is less than provided minimum LP amount"
|
||||
);
|
||||
|
||||
// 5. Update pool account
|
||||
let mut pool_post = pool.account.clone();
|
||||
let pool_post_definition = PoolDefinition {
|
||||
liquidity_pool_supply: pool_def_data
|
||||
.liquidity_pool_supply
|
||||
.checked_add(delta_lp)
|
||||
.expect("liquidity_pool_supply + delta_lp overflows u128"),
|
||||
reserve_a: pool_def_data
|
||||
.reserve_a
|
||||
.checked_add(actual_amount_a)
|
||||
.expect("reserve_a + actual_amount_a overflows u128"),
|
||||
reserve_b: pool_def_data
|
||||
.reserve_b
|
||||
.checked_add(actual_amount_b)
|
||||
.expect("reserve_b + actual_amount_b overflows u128"),
|
||||
..pool_def_data
|
||||
};
|
||||
|
||||
pool_post.data = Data::from(&pool_post_definition);
|
||||
|
||||
// Chain call for Token A (UserHoldingA -> Vault_A)
|
||||
let call_token_a = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![user_holding_a.clone(), vault_a.clone()],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: actual_amount_a,
|
||||
},
|
||||
);
|
||||
// Chain call for Token B (UserHoldingB -> Vault_B)
|
||||
let call_token_b = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![user_holding_b.clone(), vault_b.clone()],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: actual_amount_b,
|
||||
},
|
||||
);
|
||||
// Chain call for LP (mint new tokens for user_holding_lp)
|
||||
let mut pool_definition_lp_auth = pool_definition_lp.clone();
|
||||
pool_definition_lp_auth.is_authorized = true;
|
||||
let call_token_lp = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![pool_definition_lp_auth.clone(), user_holding_lp.clone()],
|
||||
&token_core::Instruction::Mint {
|
||||
amount_to_mint: delta_lp,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]);
|
||||
|
||||
let chained_calls = vec![call_token_lp, call_token_b, call_token_a];
|
||||
|
||||
let post_states = vec![
|
||||
AccountPostState::new(pool_post),
|
||||
AccountPostState::new(vault_a.account.clone()),
|
||||
AccountPostState::new(vault_b.account.clone()),
|
||||
AccountPostState::new(pool_definition_lp.account.clone()),
|
||||
AccountPostState::new(user_holding_a.account.clone()),
|
||||
AccountPostState::new(user_holding_b.account.clone()),
|
||||
AccountPostState::new(user_holding_lp.account.clone()),
|
||||
];
|
||||
|
||||
(post_states, chained_calls)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! The AMM Program implementation.
|
||||
|
||||
pub use amm_core as core;
|
||||
|
||||
pub mod add;
|
||||
pub mod new_definition;
|
||||
pub mod remove;
|
||||
pub mod swap;
|
||||
pub mod sync;
|
||||
|
||||
mod tests;
|
||||
@@ -0,0 +1,213 @@
|
||||
use std::num::NonZeroU128;
|
||||
|
||||
use amm_core::{
|
||||
assert_supported_fee_tier, compute_liquidity_token_pda, compute_liquidity_token_pda_seed,
|
||||
compute_lp_lock_holding_pda, compute_lp_lock_holding_pda_seed, compute_pool_pda,
|
||||
compute_pool_pda_seed, compute_vault_pda, compute_vault_pda_seed, PoolDefinition,
|
||||
MINIMUM_LIQUIDITY,
|
||||
};
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata, Data},
|
||||
program::{AccountPostState, ChainedCall, Claim, ProgramId},
|
||||
};
|
||||
use token_core::TokenDefinition;
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "instruction surface passes explicit pool, vault, mint, lock, and user accounts"
|
||||
)]
|
||||
pub fn new_definition(
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
pool_definition_lp: AccountWithMetadata,
|
||||
lp_lock_holding: AccountWithMetadata,
|
||||
user_holding_a: AccountWithMetadata,
|
||||
user_holding_b: AccountWithMetadata,
|
||||
user_holding_lp: AccountWithMetadata,
|
||||
token_a_amount: NonZeroU128,
|
||||
token_b_amount: NonZeroU128,
|
||||
fees: u128,
|
||||
amm_program_id: ProgramId,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
let definition_token_a_id = token_core::TokenHolding::try_from(&user_holding_a.account.data)
|
||||
.expect("New definition: AMM Program expects valid Token Holding account for Token A")
|
||||
.definition_id();
|
||||
let definition_token_b_id = token_core::TokenHolding::try_from(&user_holding_b.account.data)
|
||||
.expect("New definition: AMM Program expects valid Token Holding account for Token B")
|
||||
.definition_id();
|
||||
|
||||
let token_program = user_holding_a.account.program_owner;
|
||||
|
||||
// both instances of the same token program
|
||||
assert_eq!(
|
||||
user_holding_b.account.program_owner, token_program,
|
||||
"User Token holdings must use the same Token Program"
|
||||
);
|
||||
// Verify token_a and token_b are different
|
||||
assert!(
|
||||
definition_token_a_id != definition_token_b_id,
|
||||
"Cannot set up a swap for a token with itself"
|
||||
);
|
||||
assert_eq!(
|
||||
pool.account_id,
|
||||
compute_pool_pda(amm_program_id, definition_token_a_id, definition_token_b_id),
|
||||
"Pool Definition Account ID does not match PDA"
|
||||
);
|
||||
assert_eq!(
|
||||
vault_a.account_id,
|
||||
compute_vault_pda(amm_program_id, pool.account_id, definition_token_a_id),
|
||||
"Vault ID does not match PDA"
|
||||
);
|
||||
assert_eq!(
|
||||
vault_b.account_id,
|
||||
compute_vault_pda(amm_program_id, pool.account_id, definition_token_b_id),
|
||||
"Vault ID does not match PDA"
|
||||
);
|
||||
assert_eq!(
|
||||
pool_definition_lp.account_id,
|
||||
compute_liquidity_token_pda(amm_program_id, pool.account_id),
|
||||
"Liquidity pool Token Definition Account ID does not match PDA"
|
||||
);
|
||||
assert_eq!(
|
||||
lp_lock_holding.account_id,
|
||||
compute_lp_lock_holding_pda(amm_program_id, pool.account_id),
|
||||
"LP lock holding Account ID does not match PDA"
|
||||
);
|
||||
assert_supported_fee_tier(fees);
|
||||
|
||||
// Assert that pool is uninitialized (hard precondition)
|
||||
assert_eq!(
|
||||
pool.account,
|
||||
Account::default(),
|
||||
"Pool account must be uninitialized"
|
||||
);
|
||||
assert!(
|
||||
user_holding_lp.account != Account::default() || user_holding_lp.is_authorized,
|
||||
"Fresh user LP holding requires user authorization"
|
||||
);
|
||||
|
||||
// LP Token minting calculation
|
||||
let initial_lp = token_a_amount
|
||||
.get()
|
||||
.checked_mul(token_b_amount.get())
|
||||
.expect("token_a * token_b overflows u128")
|
||||
.isqrt();
|
||||
assert!(
|
||||
initial_lp > MINIMUM_LIQUIDITY,
|
||||
"Initial liquidity must exceed minimum liquidity lock"
|
||||
);
|
||||
let user_lp = initial_lp
|
||||
.checked_sub(MINIMUM_LIQUIDITY)
|
||||
.expect("initial liquidity must exceed minimum liquidity after validation");
|
||||
|
||||
// Update pool account
|
||||
let mut pool_post = pool.account.clone();
|
||||
let pool_post_definition = PoolDefinition {
|
||||
definition_token_a_id,
|
||||
definition_token_b_id,
|
||||
vault_a_id: vault_a.account_id,
|
||||
vault_b_id: vault_b.account_id,
|
||||
liquidity_pool_id: pool_definition_lp.account_id,
|
||||
liquidity_pool_supply: initial_lp,
|
||||
reserve_a: token_a_amount.into(),
|
||||
reserve_b: token_b_amount.into(),
|
||||
fees,
|
||||
};
|
||||
|
||||
pool_post.data = Data::from(&pool_post_definition);
|
||||
let pool_post: AccountPostState = AccountPostState::new_claimed(
|
||||
pool_post.clone(),
|
||||
Claim::Pda(compute_pool_pda_seed(
|
||||
definition_token_a_id,
|
||||
definition_token_b_id,
|
||||
)),
|
||||
);
|
||||
|
||||
let token_program_id = user_holding_a.account.program_owner;
|
||||
|
||||
// Chain call for Token A (user_holding_a -> Vault_A)
|
||||
let mut vault_a_authorized = vault_a.clone();
|
||||
vault_a_authorized.is_authorized = true;
|
||||
let call_token_a = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![user_holding_a.clone(), vault_a_authorized],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: token_a_amount.into(),
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![compute_vault_pda_seed(
|
||||
pool.account_id,
|
||||
definition_token_a_id,
|
||||
)]);
|
||||
// Chain call for Token B (user_holding_b -> Vault_B)
|
||||
let mut vault_b_authorized = vault_b.clone();
|
||||
vault_b_authorized.is_authorized = true;
|
||||
let call_token_b = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![user_holding_b.clone(), vault_b_authorized],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: token_b_amount.into(),
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![compute_vault_pda_seed(
|
||||
pool.account_id,
|
||||
definition_token_b_id,
|
||||
)]);
|
||||
|
||||
// Chain call for liquidity token lock holding
|
||||
let mut pool_lp_auth = pool_definition_lp.clone();
|
||||
pool_lp_auth.is_authorized = true;
|
||||
let mut lp_lock_holding_auth = lp_lock_holding.clone();
|
||||
lp_lock_holding_auth.is_authorized = true;
|
||||
|
||||
let call_token_lp_lock = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![pool_lp_auth.clone(), lp_lock_holding_auth],
|
||||
&token_core::Instruction::NewFungibleDefinition {
|
||||
name: String::from("LP Token"),
|
||||
total_supply: MINIMUM_LIQUIDITY,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![
|
||||
compute_liquidity_token_pda_seed(pool.account_id),
|
||||
compute_lp_lock_holding_pda_seed(pool.account_id),
|
||||
]);
|
||||
|
||||
let mut pool_lp_after_lock = pool_lp_auth.clone();
|
||||
pool_lp_after_lock.account.program_owner = token_program_id;
|
||||
pool_lp_after_lock.account.data = Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("LP Token"),
|
||||
total_supply: MINIMUM_LIQUIDITY,
|
||||
metadata_id: None,
|
||||
});
|
||||
|
||||
let call_token_lp_user = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![pool_lp_after_lock, user_holding_lp.clone()],
|
||||
&token_core::Instruction::Mint {
|
||||
amount_to_mint: user_lp,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]);
|
||||
|
||||
let chained_calls = vec![
|
||||
call_token_lp_lock,
|
||||
call_token_lp_user,
|
||||
call_token_b,
|
||||
call_token_a,
|
||||
];
|
||||
|
||||
let post_states = vec![
|
||||
pool_post.clone(),
|
||||
AccountPostState::new(vault_a.account.clone()),
|
||||
AccountPostState::new(vault_b.account.clone()),
|
||||
AccountPostState::new(pool_definition_lp.account.clone()),
|
||||
AccountPostState::new(lp_lock_holding.account.clone()),
|
||||
AccountPostState::new(user_holding_a.account.clone()),
|
||||
AccountPostState::new(user_holding_b.account.clone()),
|
||||
AccountPostState::new(user_holding_lp.account.clone()),
|
||||
];
|
||||
|
||||
(post_states, chained_calls)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use std::num::NonZeroU128;
|
||||
|
||||
use amm_core::{
|
||||
assert_supported_fee_tier, compute_liquidity_token_pda_seed, compute_vault_pda_seed,
|
||||
PoolDefinition, MINIMUM_LIQUIDITY,
|
||||
};
|
||||
use nssa_core::{
|
||||
account::{AccountWithMetadata, Data},
|
||||
program::{AccountPostState, ChainedCall},
|
||||
};
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "instruction surface passes explicit pool, vault, and user accounts"
|
||||
)]
|
||||
pub fn remove_liquidity(
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
pool_definition_lp: AccountWithMetadata,
|
||||
user_holding_a: AccountWithMetadata,
|
||||
user_holding_b: AccountWithMetadata,
|
||||
user_holding_lp: AccountWithMetadata,
|
||||
remove_liquidity_amount: NonZeroU128,
|
||||
min_amount_to_remove_token_a: u128,
|
||||
min_amount_to_remove_token_b: u128,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
let remove_liquidity_amount: u128 = remove_liquidity_amount.into();
|
||||
|
||||
// 1. Fetch Pool state
|
||||
let pool_def_data = PoolDefinition::try_from(&pool.account.data)
|
||||
.expect("Remove liquidity: AMM Program expects a valid Pool Definition Account");
|
||||
assert_supported_fee_tier(pool_def_data.fees);
|
||||
|
||||
assert!(
|
||||
pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY,
|
||||
"Pool liquidity supply is below minimum liquidity"
|
||||
);
|
||||
assert_eq!(
|
||||
pool_def_data.liquidity_pool_id, pool_definition_lp.account_id,
|
||||
"LP definition mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
vault_a.account_id, pool_def_data.vault_a_id,
|
||||
"Vault A was not provided"
|
||||
);
|
||||
assert_eq!(
|
||||
vault_b.account_id, pool_def_data.vault_b_id,
|
||||
"Vault B was not provided"
|
||||
);
|
||||
|
||||
let token_program_id = vault_a.account.program_owner;
|
||||
assert_eq!(
|
||||
user_holding_a.account.program_owner, token_program_id,
|
||||
"User Token A holding must be owned by the vault's Token Program"
|
||||
);
|
||||
assert_eq!(
|
||||
user_holding_b.account.program_owner, token_program_id,
|
||||
"User Token B holding must be owned by the vault's Token Program"
|
||||
);
|
||||
|
||||
// Vault addresses do not need to be checked with PDA
|
||||
// calculation for setting authorization since stored
|
||||
// in the Pool Definition.
|
||||
let mut running_vault_a = vault_a.clone();
|
||||
let mut running_vault_b = vault_b.clone();
|
||||
running_vault_a.is_authorized = true;
|
||||
running_vault_b.is_authorized = true;
|
||||
|
||||
assert!(
|
||||
min_amount_to_remove_token_a != 0,
|
||||
"Minimum withdraw amount must be nonzero"
|
||||
);
|
||||
assert!(
|
||||
min_amount_to_remove_token_b != 0,
|
||||
"Minimum withdraw amount must be nonzero"
|
||||
);
|
||||
|
||||
// 2. Compute withdrawal amounts
|
||||
let user_holding_lp_data = token_core::TokenHolding::try_from(&user_holding_lp.account.data)
|
||||
.expect("Remove liquidity: AMM Program expects a valid Token Account for liquidity token");
|
||||
let token_core::TokenHolding::Fungible {
|
||||
definition_id: _,
|
||||
balance: user_lp_balance,
|
||||
} = user_holding_lp_data
|
||||
else {
|
||||
panic!(
|
||||
"Remove liquidity: AMM Program expects a valid Fungible Token Holding Account for liquidity token"
|
||||
);
|
||||
};
|
||||
|
||||
assert!(
|
||||
user_lp_balance <= pool_def_data.liquidity_pool_supply,
|
||||
"Invalid liquidity account provided"
|
||||
);
|
||||
assert_eq!(
|
||||
user_holding_lp_data.definition_id(),
|
||||
pool_def_data.liquidity_pool_id,
|
||||
"Invalid liquidity account provided"
|
||||
);
|
||||
// Honest flows should never reach the permanent lock through a valid remove instruction, but
|
||||
// we still reject legacy or corrupted states that are already at the locked floor.
|
||||
assert!(
|
||||
pool_def_data.liquidity_pool_supply > MINIMUM_LIQUIDITY,
|
||||
"Pool only contains locked liquidity"
|
||||
);
|
||||
assert!(
|
||||
remove_liquidity_amount <= user_lp_balance,
|
||||
"Remove amount exceeds user LP balance"
|
||||
);
|
||||
let unlocked_liquidity = pool_def_data
|
||||
.liquidity_pool_supply
|
||||
.checked_sub(MINIMUM_LIQUIDITY)
|
||||
.expect("liquidity supply must be at least the locked minimum after validation");
|
||||
// The remove instruction never sees the LP lock account directly, so we must still refuse any
|
||||
// request that would burn through the permanent floor even if ownership is already corrupted.
|
||||
assert!(
|
||||
remove_liquidity_amount <= unlocked_liquidity,
|
||||
"Cannot remove locked minimum liquidity"
|
||||
);
|
||||
|
||||
let withdraw_amount_a = pool_def_data
|
||||
.reserve_a
|
||||
.checked_mul(remove_liquidity_amount)
|
||||
.expect("reserve_a * remove_liquidity_amount overflows u128")
|
||||
.checked_div(pool_def_data.liquidity_pool_supply)
|
||||
.expect("liquidity supply must be nonzero after validation");
|
||||
let withdraw_amount_b = pool_def_data
|
||||
.reserve_b
|
||||
.checked_mul(remove_liquidity_amount)
|
||||
.expect("reserve_b * remove_liquidity_amount overflows u128")
|
||||
.checked_div(pool_def_data.liquidity_pool_supply)
|
||||
.expect("liquidity supply must be nonzero after validation");
|
||||
|
||||
// 3. Validate and slippage check
|
||||
assert!(
|
||||
withdraw_amount_a >= min_amount_to_remove_token_a,
|
||||
"Insufficient minimal withdraw amount (Token A) provided for liquidity amount"
|
||||
);
|
||||
assert!(
|
||||
withdraw_amount_b >= min_amount_to_remove_token_b,
|
||||
"Insufficient minimal withdraw amount (Token B) provided for liquidity amount"
|
||||
);
|
||||
|
||||
// 4. Calculate LP to reduce cap by
|
||||
let delta_lp: u128 = remove_liquidity_amount;
|
||||
|
||||
// 5. Update pool account
|
||||
let mut pool_post = pool.account.clone();
|
||||
let pool_post_definition = PoolDefinition {
|
||||
liquidity_pool_supply: pool_def_data
|
||||
.liquidity_pool_supply
|
||||
.checked_sub(delta_lp)
|
||||
.expect("liquidity_pool_supply - delta_lp underflows"),
|
||||
reserve_a: pool_def_data
|
||||
.reserve_a
|
||||
.checked_sub(withdraw_amount_a)
|
||||
.expect("reserve_a - withdraw_amount_a underflows"),
|
||||
reserve_b: pool_def_data
|
||||
.reserve_b
|
||||
.checked_sub(withdraw_amount_b)
|
||||
.expect("reserve_b - withdraw_amount_b underflows"),
|
||||
..pool_def_data.clone()
|
||||
};
|
||||
|
||||
pool_post.data = Data::from(&pool_post_definition);
|
||||
|
||||
// Chaincall for Token A withdraw
|
||||
let call_token_a = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![running_vault_a, user_holding_a.clone()],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: withdraw_amount_a,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![compute_vault_pda_seed(
|
||||
pool.account_id,
|
||||
pool_def_data.definition_token_a_id,
|
||||
)]);
|
||||
// Chaincall for Token B withdraw
|
||||
let call_token_b = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![running_vault_b, user_holding_b.clone()],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: withdraw_amount_b,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![compute_vault_pda_seed(
|
||||
pool.account_id,
|
||||
pool_def_data.definition_token_b_id,
|
||||
)]);
|
||||
// Chaincall for LP adjustment
|
||||
let mut pool_definition_lp_auth = pool_definition_lp.clone();
|
||||
pool_definition_lp_auth.is_authorized = true;
|
||||
let call_token_lp = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![pool_definition_lp_auth, user_holding_lp.clone()],
|
||||
&token_core::Instruction::Burn {
|
||||
amount_to_burn: delta_lp,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![compute_liquidity_token_pda_seed(pool.account_id)]);
|
||||
|
||||
let chained_calls = vec![call_token_lp, call_token_b, call_token_a];
|
||||
|
||||
let post_states = vec![
|
||||
AccountPostState::new(pool_post.clone()),
|
||||
AccountPostState::new(vault_a.account.clone()),
|
||||
AccountPostState::new(vault_b.account.clone()),
|
||||
AccountPostState::new(pool_definition_lp.account.clone()),
|
||||
AccountPostState::new(user_holding_a.account.clone()),
|
||||
AccountPostState::new(user_holding_b.account.clone()),
|
||||
AccountPostState::new(user_holding_lp.account.clone()),
|
||||
];
|
||||
|
||||
(post_states, chained_calls)
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
use amm_core::{
|
||||
assert_supported_fee_tier, read_vault_fungible_balances, FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY,
|
||||
};
|
||||
pub use amm_core::{compute_liquidity_token_pda_seed, compute_vault_pda_seed, PoolDefinition};
|
||||
use nssa_core::{
|
||||
account::{AccountId, AccountWithMetadata, Data},
|
||||
program::{AccountPostState, ChainedCall},
|
||||
};
|
||||
|
||||
/// Validates swap setup: checks pool liquidity is ready, vaults match, and reserves are sufficient.
|
||||
fn validate_swap_setup(
|
||||
pool: &AccountWithMetadata,
|
||||
vault_a: &AccountWithMetadata,
|
||||
vault_b: &AccountWithMetadata,
|
||||
) -> PoolDefinition {
|
||||
let pool_def_data = PoolDefinition::try_from(&pool.account.data)
|
||||
.expect("AMM Program expects a valid Pool Definition Account");
|
||||
assert_supported_fee_tier(pool_def_data.fees);
|
||||
|
||||
assert!(
|
||||
pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY,
|
||||
"Pool liquidity supply is below minimum liquidity"
|
||||
);
|
||||
assert_eq!(
|
||||
vault_a.account_id, pool_def_data.vault_a_id,
|
||||
"Vault A was not provided"
|
||||
);
|
||||
assert_eq!(
|
||||
vault_b.account_id, pool_def_data.vault_b_id,
|
||||
"Vault B was not provided"
|
||||
);
|
||||
|
||||
let (vault_a_balance, vault_b_balance) =
|
||||
read_vault_fungible_balances("Validate swap setup", vault_a, vault_b);
|
||||
|
||||
assert!(
|
||||
vault_a_balance >= pool_def_data.reserve_a,
|
||||
"Reserve for Token A exceeds vault balance"
|
||||
);
|
||||
assert!(
|
||||
vault_b_balance >= pool_def_data.reserve_b,
|
||||
"Reserve for Token B exceeds vault balance"
|
||||
);
|
||||
|
||||
pool_def_data
|
||||
}
|
||||
|
||||
/// Creates post-state and returns reserves after swap.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "post-state assembly keeps pool, vault, user account, and delta state explicit"
|
||||
)]
|
||||
#[expect(
|
||||
clippy::needless_pass_by_value,
|
||||
reason = "consistent with codebase style"
|
||||
)]
|
||||
fn create_swap_post_states(
|
||||
pool: AccountWithMetadata,
|
||||
pool_def_data: PoolDefinition,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
user_holding_a: AccountWithMetadata,
|
||||
user_holding_b: AccountWithMetadata,
|
||||
deposit_a: u128,
|
||||
withdraw_a: u128,
|
||||
deposit_b: u128,
|
||||
withdraw_b: u128,
|
||||
) -> Vec<AccountPostState> {
|
||||
let mut pool_post = pool.account;
|
||||
let pool_post_definition = PoolDefinition {
|
||||
reserve_a: pool_def_data
|
||||
.reserve_a
|
||||
.checked_add(deposit_a)
|
||||
.expect("reserve_a + deposit_a overflows u128")
|
||||
.checked_sub(withdraw_a)
|
||||
.expect("reserve_a + deposit_a - withdraw_a underflows"),
|
||||
reserve_b: pool_def_data
|
||||
.reserve_b
|
||||
.checked_add(deposit_b)
|
||||
.expect("reserve_b + deposit_b overflows u128")
|
||||
.checked_sub(withdraw_b)
|
||||
.expect("reserve_b + deposit_b - withdraw_b underflows"),
|
||||
..pool_def_data
|
||||
};
|
||||
|
||||
pool_post.data = Data::from(&pool_post_definition);
|
||||
|
||||
vec![
|
||||
AccountPostState::new(pool_post),
|
||||
AccountPostState::new(vault_a.account),
|
||||
AccountPostState::new(vault_b.account),
|
||||
AccountPostState::new(user_holding_a.account),
|
||||
AccountPostState::new(user_holding_b.account),
|
||||
]
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "instruction surface passes explicit pool, vault, and user accounts"
|
||||
)]
|
||||
#[must_use]
|
||||
pub fn swap_exact_input(
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
user_holding_a: AccountWithMetadata,
|
||||
user_holding_b: AccountWithMetadata,
|
||||
swap_amount_in: u128,
|
||||
min_amount_out: u128,
|
||||
token_in_id: AccountId,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b);
|
||||
|
||||
let token_program_id = vault_a.account.program_owner;
|
||||
assert_eq!(
|
||||
user_holding_a.account.program_owner, token_program_id,
|
||||
"User Token A holding must be owned by the vault's Token Program"
|
||||
);
|
||||
assert_eq!(
|
||||
user_holding_b.account.program_owner, token_program_id,
|
||||
"User Token B holding must be owned by the vault's Token Program"
|
||||
);
|
||||
|
||||
let (chained_calls, [deposit_a, withdraw_a], [deposit_b, withdraw_b]) =
|
||||
if token_in_id == pool_def_data.definition_token_a_id {
|
||||
let (chained_calls, deposit_a, withdraw_b) = swap_logic(
|
||||
user_holding_a.clone(),
|
||||
vault_a.clone(),
|
||||
vault_b.clone(),
|
||||
user_holding_b.clone(),
|
||||
swap_amount_in,
|
||||
min_amount_out,
|
||||
pool_def_data.fees,
|
||||
pool_def_data.reserve_a,
|
||||
pool_def_data.reserve_b,
|
||||
pool.account_id,
|
||||
);
|
||||
|
||||
(chained_calls, [deposit_a, 0], [0, withdraw_b])
|
||||
} else if token_in_id == pool_def_data.definition_token_b_id {
|
||||
let (chained_calls, deposit_b, withdraw_a) = swap_logic(
|
||||
user_holding_b.clone(),
|
||||
vault_b.clone(),
|
||||
vault_a.clone(),
|
||||
user_holding_a.clone(),
|
||||
swap_amount_in,
|
||||
min_amount_out,
|
||||
pool_def_data.fees,
|
||||
pool_def_data.reserve_b,
|
||||
pool_def_data.reserve_a,
|
||||
pool.account_id,
|
||||
);
|
||||
|
||||
(chained_calls, [0, withdraw_a], [deposit_b, 0])
|
||||
} else {
|
||||
panic!("AccountId is not a token type for the pool");
|
||||
};
|
||||
|
||||
let post_states = create_swap_post_states(
|
||||
pool,
|
||||
pool_def_data,
|
||||
vault_a,
|
||||
vault_b,
|
||||
user_holding_a,
|
||||
user_holding_b,
|
||||
deposit_a,
|
||||
withdraw_a,
|
||||
deposit_b,
|
||||
withdraw_b,
|
||||
);
|
||||
|
||||
(post_states, chained_calls)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "swap calculation keeps account context and pricing parameters explicit"
|
||||
)]
|
||||
fn swap_logic(
|
||||
user_deposit: AccountWithMetadata,
|
||||
vault_deposit: AccountWithMetadata,
|
||||
vault_withdraw: AccountWithMetadata,
|
||||
user_withdraw: AccountWithMetadata,
|
||||
swap_amount_in: u128,
|
||||
min_amount_out: u128,
|
||||
fee_bps: u128,
|
||||
reserve_deposit_vault_amount: u128,
|
||||
reserve_withdraw_vault_amount: u128,
|
||||
pool_id: AccountId,
|
||||
) -> (Vec<ChainedCall>, u128, u128) {
|
||||
let fee_multiplier = FEE_BPS_DENOMINATOR
|
||||
.checked_sub(fee_bps)
|
||||
.expect("fee_bps exceeds fee denominator");
|
||||
let effective_amount_in = swap_amount_in
|
||||
.checked_mul(fee_multiplier)
|
||||
.expect("swap_amount_in * (FEE_BPS_DENOMINATOR - fee_bps) overflows u128")
|
||||
.checked_div(FEE_BPS_DENOMINATOR)
|
||||
.expect("fee denominator must be nonzero");
|
||||
assert!(
|
||||
effective_amount_in != 0,
|
||||
"Effective swap amount should be nonzero"
|
||||
);
|
||||
// Compute the withdraw amount using the fee-adjusted input for pricing.
|
||||
// The recorded pool reserves are updated later with the full
|
||||
// `swap_amount_in`, so LP fees accrue inside `reserve_*` via invariant
|
||||
// growth rather than as a separate vault balance surplus over `reserve_*`.
|
||||
let withdraw_amount = reserve_withdraw_vault_amount
|
||||
.checked_mul(effective_amount_in)
|
||||
.expect("reserve * effective_amount_in overflows u128")
|
||||
.checked_div(
|
||||
reserve_deposit_vault_amount
|
||||
.checked_add(effective_amount_in)
|
||||
.expect("reserve + effective_amount_in overflows u128"),
|
||||
)
|
||||
.expect("reserve plus effective input must be nonzero");
|
||||
|
||||
// Slippage check
|
||||
assert!(
|
||||
min_amount_out <= withdraw_amount,
|
||||
"Withdraw amount is less than minimal amount out"
|
||||
);
|
||||
assert!(withdraw_amount != 0, "Withdraw amount should be nonzero");
|
||||
|
||||
let token_program_id = user_deposit.account.program_owner;
|
||||
|
||||
let mut chained_calls = Vec::new();
|
||||
chained_calls.push(ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![user_deposit, vault_deposit],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: swap_amount_in,
|
||||
},
|
||||
));
|
||||
|
||||
let mut vault_withdraw = vault_withdraw.clone();
|
||||
vault_withdraw.is_authorized = true;
|
||||
|
||||
let pda_seed = compute_vault_pda_seed(
|
||||
pool_id,
|
||||
token_core::TokenHolding::try_from(&vault_withdraw.account.data)
|
||||
.expect("Swap Logic: AMM Program expects valid token data")
|
||||
.definition_id(),
|
||||
);
|
||||
|
||||
chained_calls.push(
|
||||
ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![vault_withdraw, user_withdraw],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: withdraw_amount,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![pda_seed]),
|
||||
);
|
||||
|
||||
(chained_calls, swap_amount_in, withdraw_amount)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "instruction surface passes explicit pool, vault, and user accounts"
|
||||
)]
|
||||
#[must_use]
|
||||
pub fn swap_exact_output(
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
user_holding_a: AccountWithMetadata,
|
||||
user_holding_b: AccountWithMetadata,
|
||||
exact_amount_out: u128,
|
||||
max_amount_in: u128,
|
||||
token_in_id: AccountId,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b);
|
||||
|
||||
let token_program_id = vault_a.account.program_owner;
|
||||
assert_eq!(
|
||||
user_holding_a.account.program_owner, token_program_id,
|
||||
"User Token A holding must be owned by the vault's Token Program"
|
||||
);
|
||||
assert_eq!(
|
||||
user_holding_b.account.program_owner, token_program_id,
|
||||
"User Token B holding must be owned by the vault's Token Program"
|
||||
);
|
||||
|
||||
let (chained_calls, [deposit_a, withdraw_a], [deposit_b, withdraw_b]) =
|
||||
if token_in_id == pool_def_data.definition_token_a_id {
|
||||
let (chained_calls, deposit_a, withdraw_b) = exact_output_swap_logic(
|
||||
user_holding_a.clone(),
|
||||
vault_a.clone(),
|
||||
vault_b.clone(),
|
||||
user_holding_b.clone(),
|
||||
exact_amount_out,
|
||||
max_amount_in,
|
||||
pool_def_data.reserve_a,
|
||||
pool_def_data.reserve_b,
|
||||
pool_def_data.fees,
|
||||
pool.account_id,
|
||||
);
|
||||
|
||||
(chained_calls, [deposit_a, 0], [0, withdraw_b])
|
||||
} else if token_in_id == pool_def_data.definition_token_b_id {
|
||||
let (chained_calls, deposit_b, withdraw_a) = exact_output_swap_logic(
|
||||
user_holding_b.clone(),
|
||||
vault_b.clone(),
|
||||
vault_a.clone(),
|
||||
user_holding_a.clone(),
|
||||
exact_amount_out,
|
||||
max_amount_in,
|
||||
pool_def_data.reserve_b,
|
||||
pool_def_data.reserve_a,
|
||||
pool_def_data.fees,
|
||||
pool.account_id,
|
||||
);
|
||||
|
||||
(chained_calls, [0, withdraw_a], [deposit_b, 0])
|
||||
} else {
|
||||
panic!("AccountId is not a token type for the pool");
|
||||
};
|
||||
|
||||
let post_states = create_swap_post_states(
|
||||
pool,
|
||||
pool_def_data,
|
||||
vault_a,
|
||||
vault_b,
|
||||
user_holding_a,
|
||||
user_holding_b,
|
||||
deposit_a,
|
||||
withdraw_a,
|
||||
deposit_b,
|
||||
withdraw_b,
|
||||
);
|
||||
|
||||
(post_states, chained_calls)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "swap calculation keeps account context and pricing parameters explicit"
|
||||
)]
|
||||
fn exact_output_swap_logic(
|
||||
user_deposit: AccountWithMetadata,
|
||||
vault_deposit: AccountWithMetadata,
|
||||
vault_withdraw: AccountWithMetadata,
|
||||
user_withdraw: AccountWithMetadata,
|
||||
exact_amount_out: u128,
|
||||
max_amount_in: u128,
|
||||
reserve_deposit_vault_amount: u128,
|
||||
reserve_withdraw_vault_amount: u128,
|
||||
fee_bps: u128,
|
||||
pool_id: AccountId,
|
||||
) -> (Vec<ChainedCall>, u128, u128) {
|
||||
// Guard: exact_amount_out must be nonzero
|
||||
assert_ne!(exact_amount_out, 0, "Exact amount out must be nonzero");
|
||||
|
||||
// Guard: exact_amount_out must be less than reserve_withdraw_vault_amount
|
||||
assert!(
|
||||
exact_amount_out < reserve_withdraw_vault_amount,
|
||||
"Exact amount out exceeds reserve"
|
||||
);
|
||||
|
||||
// Compute the minimum effective input required to achieve exact_amount_out
|
||||
// using the same floor-rounded fee application as swap_exact_input.
|
||||
//
|
||||
// Solve constant product for effective_in (fee already removed):
|
||||
// effective_in >= ceil(reserve_in * amount_out / (reserve_out - amount_out))
|
||||
let effective_in_numerator = reserve_deposit_vault_amount
|
||||
.checked_mul(exact_amount_out)
|
||||
.expect("reserve * amount_out overflows u128");
|
||||
let effective_in_denominator = reserve_withdraw_vault_amount
|
||||
.checked_sub(exact_amount_out)
|
||||
.expect("reserve_out - amount_out underflows");
|
||||
let effective_in_min = effective_in_numerator.div_ceil(effective_in_denominator);
|
||||
|
||||
// Lift back to gross input so that
|
||||
// floor(gross_in * (FEE_DENOM - fee) / FEE_DENOM) >= effective_in_min
|
||||
let fee_multiplier = FEE_BPS_DENOMINATOR
|
||||
.checked_sub(fee_bps)
|
||||
.expect("fee_bps exceeds fee denominator");
|
||||
let deposit_amount = effective_in_min
|
||||
.checked_mul(FEE_BPS_DENOMINATOR)
|
||||
.expect("effective_in * FEE_DENOM overflows u128")
|
||||
.div_ceil(fee_multiplier);
|
||||
|
||||
// Slippage check
|
||||
assert!(
|
||||
deposit_amount <= max_amount_in,
|
||||
"Required input exceeds maximum amount in"
|
||||
);
|
||||
|
||||
let token_program_id = user_deposit.account.program_owner;
|
||||
|
||||
let mut chained_calls = Vec::new();
|
||||
chained_calls.push(ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![user_deposit, vault_deposit],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: deposit_amount,
|
||||
},
|
||||
));
|
||||
|
||||
let mut vault_withdraw = vault_withdraw;
|
||||
vault_withdraw.is_authorized = true;
|
||||
|
||||
let pda_seed = compute_vault_pda_seed(
|
||||
pool_id,
|
||||
token_core::TokenHolding::try_from(&vault_withdraw.account.data)
|
||||
.expect("Exact Output Swap Logic: AMM Program expects valid token data")
|
||||
.definition_id(),
|
||||
);
|
||||
|
||||
chained_calls.push(
|
||||
ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![vault_withdraw, user_withdraw],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: exact_amount_out,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![pda_seed]),
|
||||
);
|
||||
|
||||
(chained_calls, deposit_amount, exact_amount_out)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use amm_core::{
|
||||
assert_supported_fee_tier, read_vault_fungible_balances, PoolDefinition, MINIMUM_LIQUIDITY,
|
||||
};
|
||||
use nssa_core::{
|
||||
account::{AccountWithMetadata, Data},
|
||||
program::{AccountPostState, ChainedCall},
|
||||
};
|
||||
|
||||
pub fn sync_reserves(
|
||||
pool: AccountWithMetadata,
|
||||
vault_a: AccountWithMetadata,
|
||||
vault_b: AccountWithMetadata,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
let pool_def_data = PoolDefinition::try_from(&pool.account.data)
|
||||
.expect("Sync reserves: AMM Program expects a valid Pool Definition Account");
|
||||
assert_supported_fee_tier(pool_def_data.fees);
|
||||
|
||||
assert!(
|
||||
pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY,
|
||||
"Pool liquidity supply is below minimum liquidity"
|
||||
);
|
||||
assert_eq!(
|
||||
vault_a.account_id, pool_def_data.vault_a_id,
|
||||
"Vault A was not provided"
|
||||
);
|
||||
assert_eq!(
|
||||
vault_b.account_id, pool_def_data.vault_b_id,
|
||||
"Vault B was not provided"
|
||||
);
|
||||
|
||||
let (vault_a_balance, vault_b_balance) =
|
||||
read_vault_fungible_balances("Sync reserves", &vault_a, &vault_b);
|
||||
assert!(
|
||||
vault_a_balance >= pool_def_data.reserve_a,
|
||||
"Sync reserves: vault A balance is less than its reserve"
|
||||
);
|
||||
assert!(
|
||||
vault_b_balance >= pool_def_data.reserve_b,
|
||||
"Sync reserves: vault B balance is less than its reserve"
|
||||
);
|
||||
|
||||
let mut pool_post = pool.account.clone();
|
||||
let pool_post_definition = PoolDefinition {
|
||||
reserve_a: vault_a_balance,
|
||||
reserve_b: vault_b_balance,
|
||||
..pool_def_data
|
||||
};
|
||||
pool_post.data = Data::from(&pool_post_definition);
|
||||
|
||||
(
|
||||
vec![
|
||||
AccountPostState::new(pool_post),
|
||||
AccountPostState::new(vault_a.account.clone()),
|
||||
AccountPostState::new(vault_b.account.clone()),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "ata_program"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
|
||||
ata_core = { path = "core" }
|
||||
token_core = { path = "../token/core" }
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "ata_core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
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 }
|
||||
@@ -0,0 +1,101 @@
|
||||
pub use nssa_core::program::PdaSeed;
|
||||
use nssa_core::{
|
||||
account::{AccountId, AccountWithMetadata},
|
||||
program::ProgramId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Create the Associated Token Account for (token program, owner, definition).
|
||||
/// Idempotent: no-op if the account already exists.
|
||||
///
|
||||
/// Required accounts (3):
|
||||
/// - Owner account
|
||||
/// - Token definition account
|
||||
/// - Associated token account (default/uninitialized, or already initialized)
|
||||
///
|
||||
/// `token_program_id` is explicit so callers can support multiple token programs without
|
||||
/// letting account metadata choose downstream code.
|
||||
Create { token_program_id: ProgramId },
|
||||
|
||||
/// Transfer tokens FROM owner's ATA to a recipient token holding account.
|
||||
/// Uses ATA PDA seeds to authorize the chained Token::Transfer call.
|
||||
///
|
||||
/// Required accounts (3):
|
||||
/// - Owner account (authorized)
|
||||
/// - Sender ATA (owner's token holding)
|
||||
/// - Recipient token holding. Must be:
|
||||
/// - already initialized (not a default account),
|
||||
/// - owned by the same token program as the sender ATA,
|
||||
/// - and point at the same token definition as the sender.
|
||||
///
|
||||
/// `token_program_id` is explicit so callers can support multiple token programs without
|
||||
/// letting account metadata choose downstream code.
|
||||
Transfer {
|
||||
token_program_id: ProgramId,
|
||||
amount: u128,
|
||||
},
|
||||
|
||||
/// Burn tokens FROM owner's ATA.
|
||||
/// Uses PDA seeds to authorize the ATA in the chained Token::Burn call.
|
||||
///
|
||||
/// Required accounts (3):
|
||||
/// - Owner account (authorized)
|
||||
/// - Owner's ATA (the holding to burn from)
|
||||
/// - Token definition account
|
||||
///
|
||||
/// `token_program_id` is explicit so callers can support multiple token programs without
|
||||
/// letting account metadata choose downstream code.
|
||||
Burn {
|
||||
token_program_id: ProgramId,
|
||||
amount: u128,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn compute_ata_seed(
|
||||
token_program_id: ProgramId,
|
||||
owner_id: AccountId,
|
||||
definition_id: AccountId,
|
||||
) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256};
|
||||
let mut bytes = [0u8; 96];
|
||||
let (program_id_bytes, rest) = bytes.split_at_mut(32);
|
||||
let (owner_bytes, definition_bytes) = rest.split_at_mut(32);
|
||||
for (chunk, word) in program_id_bytes
|
||||
.chunks_exact_mut(4)
|
||||
.zip(token_program_id.iter())
|
||||
{
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
owner_bytes.copy_from_slice(&owner_id.to_bytes());
|
||||
definition_bytes.copy_from_slice(&definition_id.to_bytes());
|
||||
PdaSeed::new(
|
||||
Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.expect("Hash output must be exactly 32 bytes long"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_associated_token_account_id(ata_program_id: &ProgramId, seed: &PdaSeed) -> AccountId {
|
||||
AccountId::for_public_pda(ata_program_id, seed)
|
||||
}
|
||||
|
||||
/// Verify the ATA's address matches `(ata_program_id, token_program_id, owner, definition)` and
|
||||
/// return the [`PdaSeed`] for use in chained calls.
|
||||
pub fn verify_ata_and_get_seed(
|
||||
ata_account: &AccountWithMetadata,
|
||||
owner: &AccountWithMetadata,
|
||||
token_program_id: ProgramId,
|
||||
definition_id: AccountId,
|
||||
ata_program_id: ProgramId,
|
||||
) -> PdaSeed {
|
||||
let seed = compute_ata_seed(token_program_id, owner.account_id, definition_id);
|
||||
let expected_id = get_associated_token_account_id(&ata_program_id, &seed);
|
||||
assert_eq!(
|
||||
ata_account.account_id, expected_id,
|
||||
"ATA account ID does not match expected derivation"
|
||||
);
|
||||
seed
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "ata-methods"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
risc0-build = "=3.0.5"
|
||||
|
||||
[dependencies]
|
||||
risc0-zkvm = { version = "=3.0.5", features = ["std"] }
|
||||
ata_core = { path = "../core" }
|
||||
|
||||
[package.metadata.risc0]
|
||||
methods = ["guest"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
risc0_build::embed_methods();
|
||||
}
|
||||
Generated
+4046
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
[package]
|
||||
name = "ata-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[workspace]
|
||||
|
||||
[lints.rust]
|
||||
rust_2018_idioms = { level = "deny", priority = -1 }
|
||||
# deny (not forbid) so a targeted per-item #[allow] remains possible if ever needed
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
# Deny only the groups where a new lint should always be a hard error.
|
||||
# style/pedantic lints default to warn so toolchain upgrades don't break the
|
||||
# build unexpectedly — they can be evaluated and addressed at our own pace.
|
||||
correctness = { level = "deny", priority = -1 }
|
||||
suspicious = { level = "deny", priority = -1 }
|
||||
perf = { level = "deny", priority = -1 }
|
||||
style = { level = "warn", priority = -1 }
|
||||
|
||||
# Generated-code / placeholder blockers.
|
||||
dbg_macro = "deny"
|
||||
todo = "deny"
|
||||
unimplemented = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
# Lint suppression hygiene.
|
||||
allow_attributes = "warn"
|
||||
allow_attributes_without_reason = "deny"
|
||||
|
||||
# Determinism, panic-safety, and arithmetic correctness.
|
||||
arithmetic_side_effects = "deny"
|
||||
indexing_slicing = "deny"
|
||||
|
||||
# Cast discipline.
|
||||
as_conversions = "deny"
|
||||
cast_possible_truncation = "deny"
|
||||
cast_possible_wrap = "deny"
|
||||
cast_sign_loss = "deny"
|
||||
|
||||
# API and enum evolution.
|
||||
large_enum_variant = "deny"
|
||||
wildcard_enum_match_arm = "deny"
|
||||
|
||||
# Too noisy for this codebase unless enforced selectively.
|
||||
module_name_repetitions = "allow"
|
||||
similar_names = "allow"
|
||||
|
||||
[[bin]]
|
||||
name = "ata"
|
||||
path = "src/bin/ata.rs"
|
||||
|
||||
[dependencies]
|
||||
spel-framework = { git = "https://github.com/logos-co/spel.git", tag = "v0.3.0", package = "spel-framework" }
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" }
|
||||
risc0-zkvm = { version = "=3.0.5", default-features = false }
|
||||
ata_core = { path = "../../core" }
|
||||
ata_program = { path = "../..", package = "ata_program" }
|
||||
token_core = { path = "../../../token/core" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
borsh = "1.5"
|
||||
@@ -0,0 +1,89 @@
|
||||
#![cfg_attr(not(test), no_main)]
|
||||
|
||||
use spel_framework::prelude::*;
|
||||
use spel_framework::context::ProgramContext;
|
||||
use nssa_core::{account::AccountWithMetadata, program::ProgramId};
|
||||
|
||||
#[cfg(not(test))]
|
||||
risc0_zkvm::guest::entry!(main);
|
||||
|
||||
#[lez_program(instruction = "ata_core::Instruction")]
|
||||
mod ata {
|
||||
#[expect(
|
||||
unused_imports,
|
||||
reason = "SPEL instruction macro requires importing parent-scope handler types"
|
||||
)]
|
||||
use super::*;
|
||||
|
||||
/// Create the Associated Token Account for (token program, owner, definition).
|
||||
/// Idempotent: no-op if the account already exists.
|
||||
/// The token program is selected explicitly by `token_program_id`; the token definition and
|
||||
/// any existing ATA occupant must be owned by that program.
|
||||
#[instruction]
|
||||
pub fn create(
|
||||
ctx: ProgramContext,
|
||||
owner: AccountWithMetadata,
|
||||
token_definition: AccountWithMetadata,
|
||||
ata_account: AccountWithMetadata,
|
||||
token_program_id: ProgramId,
|
||||
) -> SpelResult {
|
||||
let (post_states, chained_calls) = ata_program::create::create_associated_token_account(
|
||||
owner,
|
||||
token_definition,
|
||||
ata_account,
|
||||
ctx.self_program_id,
|
||||
token_program_id,
|
||||
);
|
||||
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
|
||||
}
|
||||
|
||||
/// Transfer tokens FROM owner's ATA to a recipient token holding account.
|
||||
/// The token program is selected explicitly by `token_program_id`; the sender ATA and recipient
|
||||
/// holding must be owned by that program.
|
||||
/// The recipient holding must already be initialized, be owned by the same token program
|
||||
/// as the sender ATA, and point at the same token definition as the sender.
|
||||
#[instruction]
|
||||
pub fn transfer(
|
||||
ctx: ProgramContext,
|
||||
owner: AccountWithMetadata,
|
||||
sender_ata: AccountWithMetadata,
|
||||
recipient: AccountWithMetadata,
|
||||
token_program_id: ProgramId,
|
||||
amount: u128,
|
||||
) -> SpelResult {
|
||||
let (post_states, chained_calls) =
|
||||
ata_program::transfer::transfer_from_associated_token_account(
|
||||
owner,
|
||||
sender_ata,
|
||||
recipient,
|
||||
ctx.self_program_id,
|
||||
token_program_id,
|
||||
amount,
|
||||
);
|
||||
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
|
||||
}
|
||||
|
||||
/// Burn tokens FROM owner's ATA.
|
||||
/// The token program is selected explicitly by `token_program_id`; the holder ATA and token
|
||||
/// definition must be owned by that program.
|
||||
#[instruction]
|
||||
pub fn burn(
|
||||
ctx: ProgramContext,
|
||||
owner: AccountWithMetadata,
|
||||
holder_ata: AccountWithMetadata,
|
||||
token_definition: AccountWithMetadata,
|
||||
token_program_id: ProgramId,
|
||||
amount: u128,
|
||||
) -> SpelResult {
|
||||
let (post_states, chained_calls) =
|
||||
ata_program::burn::burn_from_associated_token_account(
|
||||
owner,
|
||||
holder_ata,
|
||||
token_definition,
|
||||
ctx.self_program_id,
|
||||
token_program_id,
|
||||
amount,
|
||||
);
|
||||
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
include!(concat!(env!("OUT_DIR"), "/methods.rs"));
|
||||
@@ -0,0 +1,56 @@
|
||||
use nssa_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, ChainedCall, ProgramId},
|
||||
};
|
||||
use token_core::TokenHolding;
|
||||
|
||||
pub fn burn_from_associated_token_account(
|
||||
owner: AccountWithMetadata,
|
||||
holder_ata: AccountWithMetadata,
|
||||
token_definition: AccountWithMetadata,
|
||||
ata_program_id: ProgramId,
|
||||
token_program_id: ProgramId,
|
||||
amount: u128,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
assert!(owner.is_authorized, "Owner authorization is missing");
|
||||
assert_eq!(
|
||||
holder_ata.account.program_owner, token_program_id,
|
||||
"Holder ATA must be owned by expected token program"
|
||||
);
|
||||
assert_eq!(
|
||||
token_definition.account.program_owner, token_program_id,
|
||||
"Token definition must be owned by expected token program"
|
||||
);
|
||||
let definition_id = TokenHolding::try_from(&holder_ata.account.data)
|
||||
.expect("Holder ATA must hold a valid token")
|
||||
.definition_id();
|
||||
assert_eq!(
|
||||
definition_id, token_definition.account_id,
|
||||
"Holder ATA token definition does not match"
|
||||
);
|
||||
let seed = ata_core::verify_ata_and_get_seed(
|
||||
&holder_ata,
|
||||
&owner,
|
||||
token_program_id,
|
||||
definition_id,
|
||||
ata_program_id,
|
||||
);
|
||||
|
||||
let post_states = vec![
|
||||
AccountPostState::new(owner.account.clone()),
|
||||
AccountPostState::new(holder_ata.account.clone()),
|
||||
AccountPostState::new(token_definition.account.clone()),
|
||||
];
|
||||
let mut holder_ata_auth = holder_ata.clone();
|
||||
holder_ata_auth.is_authorized = true;
|
||||
|
||||
let chained_call = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![token_definition.clone(), holder_ata_auth],
|
||||
&token_core::Instruction::Burn {
|
||||
amount_to_burn: amount,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![seed]);
|
||||
(post_states, vec![chained_call])
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{AccountPostState, ChainedCall, Claim, ProgramId},
|
||||
};
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
pub fn create_associated_token_account(
|
||||
owner: AccountWithMetadata,
|
||||
token_definition: AccountWithMetadata,
|
||||
ata_account: AccountWithMetadata,
|
||||
ata_program_id: ProgramId,
|
||||
token_program_id: ProgramId,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
// No explicit owner authorization check is needed here: ATA creation is idempotent, so the
|
||||
// call itself may proceed without `owner.is_authorized`. If the owner account is still
|
||||
// default, the returned post-state will still carry `Claim::Authorized` so the runtime can
|
||||
// claim that owner account when needed.
|
||||
assert_eq!(
|
||||
token_definition.account.program_owner, token_program_id,
|
||||
"Token definition must be owned by expected token program"
|
||||
);
|
||||
let _definition = TokenDefinition::try_from(&token_definition.account.data)
|
||||
.expect("Token definition must be valid");
|
||||
let seed = ata_core::verify_ata_and_get_seed(
|
||||
&ata_account,
|
||||
&owner,
|
||||
token_program_id,
|
||||
token_definition.account_id,
|
||||
ata_program_id,
|
||||
);
|
||||
|
||||
// Idempotent: already initialized → no-op
|
||||
if ata_account.account != Account::default() {
|
||||
assert_eq!(
|
||||
ata_account.account.program_owner, token_program_id,
|
||||
"Existing ATA must be owned by expected token program"
|
||||
);
|
||||
let holding = TokenHolding::try_from(&ata_account.account.data)
|
||||
.expect("Existing ATA must hold a valid token");
|
||||
assert_eq!(
|
||||
holding.definition_id(),
|
||||
token_definition.account_id,
|
||||
"Existing ATA token definition does not match"
|
||||
);
|
||||
return (
|
||||
vec![
|
||||
AccountPostState::new_claimed_if_default(owner.account.clone(), Claim::Authorized),
|
||||
AccountPostState::new(token_definition.account.clone()),
|
||||
AccountPostState::new(ata_account.account.clone()),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
}
|
||||
|
||||
let post_states = vec![
|
||||
AccountPostState::new_claimed_if_default(owner.account.clone(), Claim::Authorized),
|
||||
AccountPostState::new(token_definition.account.clone()),
|
||||
AccountPostState::new(ata_account.account.clone()),
|
||||
];
|
||||
let mut ata_account_auth = ata_account.clone();
|
||||
ata_account_auth.is_authorized = true;
|
||||
let chained_call = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![token_definition.clone(), ata_account_auth],
|
||||
&token_core::Instruction::InitializeAccount,
|
||||
)
|
||||
.with_pda_seeds(vec![seed]);
|
||||
(post_states, vec![chained_call])
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! The Associated Token Account Program implementation.
|
||||
|
||||
pub use ata_core as core;
|
||||
|
||||
pub mod burn;
|
||||
pub mod create;
|
||||
pub mod transfer;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,467 @@
|
||||
use ata_core::{compute_ata_seed, get_associated_token_account_id};
|
||||
use nssa_core::{
|
||||
account::{Account, AccountId, AccountWithMetadata, Data},
|
||||
program::{ChainedCall, Claim},
|
||||
};
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
const ATA_PROGRAM_ID: nssa_core::program::ProgramId = [1u32; 8];
|
||||
const TOKEN_PROGRAM_ID: nssa_core::program::ProgramId = [2u32; 8];
|
||||
const OTHER_TOKEN_PROGRAM_ID: nssa_core::program::ProgramId = [3u32; 8];
|
||||
|
||||
fn owner_id() -> AccountId {
|
||||
AccountId::new([0x01u8; 32])
|
||||
}
|
||||
|
||||
fn definition_id() -> AccountId {
|
||||
AccountId::new([0x02u8; 32])
|
||||
}
|
||||
|
||||
fn ata_id() -> AccountId {
|
||||
get_associated_token_account_id(
|
||||
&ATA_PROGRAM_ID,
|
||||
&compute_ata_seed(TOKEN_PROGRAM_ID, owner_id(), definition_id()),
|
||||
)
|
||||
}
|
||||
|
||||
fn owner_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: true,
|
||||
account_id: owner_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn definition_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: TOKEN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: "TEST".to_string(),
|
||||
total_supply: 1000,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: nssa_core::account::Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: definition_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn uninitialized_ata_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: false,
|
||||
account_id: ata_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn initialized_ata_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: TOKEN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: definition_id(),
|
||||
balance: 100,
|
||||
}),
|
||||
nonce: nssa_core::account::Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: ata_id(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_emits_chained_call_for_uninitialized_ata() {
|
||||
let (post_states, chained_calls) = crate::create::create_associated_token_account(
|
||||
owner_account(),
|
||||
definition_account(),
|
||||
uninitialized_ata_account(),
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
);
|
||||
|
||||
assert_eq!(post_states.len(), 3);
|
||||
assert_eq!(post_states[0].required_claim(), Some(Claim::Authorized));
|
||||
|
||||
let mut authorized_ata = uninitialized_ata_account();
|
||||
authorized_ata.is_authorized = true;
|
||||
let expected_call = ChainedCall::new(
|
||||
TOKEN_PROGRAM_ID,
|
||||
vec![definition_account(), authorized_ata],
|
||||
&token_core::Instruction::InitializeAccount,
|
||||
)
|
||||
.with_pda_seeds(vec![compute_ata_seed(
|
||||
TOKEN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
definition_id(),
|
||||
)]);
|
||||
|
||||
assert_eq!(chained_calls, vec![expected_call]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_is_idempotent_for_initialized_ata() {
|
||||
let (post_states, chained_calls) = crate::create::create_associated_token_account(
|
||||
owner_account(),
|
||||
definition_account(),
|
||||
initialized_ata_account(),
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
);
|
||||
|
||||
assert_eq!(post_states.len(), 3);
|
||||
assert!(
|
||||
chained_calls.is_empty(),
|
||||
"Should emit no chained call for already-initialized ATA"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "ATA account ID does not match expected derivation")]
|
||||
fn create_panics_on_wrong_ata_address() {
|
||||
let wrong_ata = AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: false,
|
||||
account_id: AccountId::new([0xFFu8; 32]),
|
||||
};
|
||||
|
||||
crate::create::create_associated_token_account(
|
||||
owner_account(),
|
||||
definition_account(),
|
||||
wrong_ata,
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_associated_token_account_id_is_deterministic() {
|
||||
let seed = compute_ata_seed(TOKEN_PROGRAM_ID, owner_id(), definition_id());
|
||||
let id1 = get_associated_token_account_id(&ATA_PROGRAM_ID, &seed);
|
||||
let id2 = get_associated_token_account_id(&ATA_PROGRAM_ID, &seed);
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_associated_token_account_id_differs_by_token_program() {
|
||||
let id1 = get_associated_token_account_id(
|
||||
&ATA_PROGRAM_ID,
|
||||
&compute_ata_seed(TOKEN_PROGRAM_ID, owner_id(), definition_id()),
|
||||
);
|
||||
let id2 = get_associated_token_account_id(
|
||||
&ATA_PROGRAM_ID,
|
||||
&compute_ata_seed(OTHER_TOKEN_PROGRAM_ID, owner_id(), definition_id()),
|
||||
);
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_associated_token_account_id_differs_by_owner() {
|
||||
let other_owner = AccountId::new([0x99u8; 32]);
|
||||
let id1 = get_associated_token_account_id(
|
||||
&ATA_PROGRAM_ID,
|
||||
&compute_ata_seed(TOKEN_PROGRAM_ID, owner_id(), definition_id()),
|
||||
);
|
||||
let id2 = get_associated_token_account_id(
|
||||
&ATA_PROGRAM_ID,
|
||||
&compute_ata_seed(TOKEN_PROGRAM_ID, other_owner, definition_id()),
|
||||
);
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_associated_token_account_id_differs_by_definition() {
|
||||
let other_def = AccountId::new([0x99u8; 32]);
|
||||
let id1 = get_associated_token_account_id(
|
||||
&ATA_PROGRAM_ID,
|
||||
&compute_ata_seed(TOKEN_PROGRAM_ID, owner_id(), definition_id()),
|
||||
);
|
||||
let id2 = get_associated_token_account_id(
|
||||
&ATA_PROGRAM_ID,
|
||||
&compute_ata_seed(TOKEN_PROGRAM_ID, owner_id(), other_def),
|
||||
);
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Token definition must be owned by expected token program")]
|
||||
fn create_panics_when_definition_is_owned_by_unexpected_token_program() {
|
||||
let mut definition = definition_account();
|
||||
definition.account.program_owner = OTHER_TOKEN_PROGRAM_ID;
|
||||
|
||||
crate::create::create_associated_token_account(
|
||||
owner_account(),
|
||||
definition,
|
||||
uninitialized_ata_account(),
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Existing ATA must be owned by expected token program")]
|
||||
fn create_panics_when_existing_ata_is_owned_by_unexpected_token_program() {
|
||||
let mut ata = initialized_ata_account();
|
||||
ata.account.program_owner = OTHER_TOKEN_PROGRAM_ID;
|
||||
|
||||
crate::create::create_associated_token_account(
|
||||
owner_account(),
|
||||
definition_account(),
|
||||
ata,
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Existing ATA token definition does not match")]
|
||||
fn create_panics_when_existing_ata_definition_mismatches_requested_definition() {
|
||||
let mut ata = initialized_ata_account();
|
||||
ata.account.data = Data::from(&TokenHolding::Fungible {
|
||||
definition_id: AccountId::new([0xAAu8; 32]),
|
||||
balance: 100,
|
||||
});
|
||||
|
||||
crate::create::create_associated_token_account(
|
||||
owner_account(),
|
||||
definition_account(),
|
||||
ata,
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
fn recipient_id() -> AccountId {
|
||||
AccountId::new([0x03u8; 32])
|
||||
}
|
||||
|
||||
fn initialized_recipient_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: TOKEN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: definition_id(),
|
||||
balance: 0,
|
||||
}),
|
||||
nonce: nssa_core::account::Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: recipient_id(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_emits_chained_call_for_initialized_recipient() {
|
||||
let (post_states, chained_calls) = crate::transfer::transfer_from_associated_token_account(
|
||||
owner_account(),
|
||||
initialized_ata_account(),
|
||||
initialized_recipient_account(),
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
25,
|
||||
);
|
||||
|
||||
assert_eq!(post_states.len(), 3);
|
||||
assert_eq!(chained_calls.len(), 1);
|
||||
|
||||
let mut sender_auth = initialized_ata_account();
|
||||
sender_auth.is_authorized = true;
|
||||
let expected_call = ChainedCall::new(
|
||||
TOKEN_PROGRAM_ID,
|
||||
vec![sender_auth, initialized_recipient_account()],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: 25,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![compute_ata_seed(
|
||||
TOKEN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
definition_id(),
|
||||
)]);
|
||||
|
||||
assert_eq!(chained_calls, vec![expected_call]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Owner authorization is missing")]
|
||||
fn transfer_panics_when_owner_not_authorized() {
|
||||
let mut unauthorized_owner = owner_account();
|
||||
unauthorized_owner.is_authorized = false;
|
||||
|
||||
crate::transfer::transfer_from_associated_token_account(
|
||||
unauthorized_owner,
|
||||
initialized_ata_account(),
|
||||
initialized_recipient_account(),
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Recipient token holding must be initialized")]
|
||||
fn transfer_panics_when_recipient_is_default() {
|
||||
let default_recipient = AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: false,
|
||||
account_id: recipient_id(),
|
||||
};
|
||||
|
||||
crate::transfer::transfer_from_associated_token_account(
|
||||
owner_account(),
|
||||
initialized_ata_account(),
|
||||
default_recipient,
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Sender ATA must be owned by expected token program")]
|
||||
fn transfer_panics_when_sender_ata_is_owned_by_unexpected_token_program() {
|
||||
let mut sender = initialized_ata_account();
|
||||
sender.account.program_owner = OTHER_TOKEN_PROGRAM_ID;
|
||||
|
||||
crate::transfer::transfer_from_associated_token_account(
|
||||
owner_account(),
|
||||
sender,
|
||||
initialized_recipient_account(),
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Recipient must be owned by the same token program as the sender ATA")]
|
||||
fn transfer_panics_when_recipient_is_foreign_owned() {
|
||||
let mut foreign_recipient = initialized_recipient_account();
|
||||
foreign_recipient.account.program_owner = [9u32; 8];
|
||||
|
||||
crate::transfer::transfer_from_associated_token_account(
|
||||
owner_account(),
|
||||
initialized_ata_account(),
|
||||
foreign_recipient,
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Recipient must hold a valid token")]
|
||||
fn transfer_panics_when_recipient_data_is_malformed() {
|
||||
let mut malformed_recipient = initialized_recipient_account();
|
||||
malformed_recipient.account.data = Data::try_from(vec![0xFFu8, 0xFE, 0xFD]).unwrap();
|
||||
|
||||
crate::transfer::transfer_from_associated_token_account(
|
||||
owner_account(),
|
||||
initialized_ata_account(),
|
||||
malformed_recipient,
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Recipient and sender token definitions do not match")]
|
||||
fn transfer_panics_when_recipient_definition_mismatches_sender() {
|
||||
let mut mismatched_recipient = initialized_recipient_account();
|
||||
mismatched_recipient.account.data = Data::from(&TokenHolding::Fungible {
|
||||
definition_id: AccountId::new([0xAAu8; 32]),
|
||||
balance: 0,
|
||||
});
|
||||
|
||||
crate::transfer::transfer_from_associated_token_account(
|
||||
owner_account(),
|
||||
initialized_ata_account(),
|
||||
mismatched_recipient,
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burn_emits_chained_call_for_initialized_ata() {
|
||||
let (post_states, chained_calls) = crate::burn::burn_from_associated_token_account(
|
||||
owner_account(),
|
||||
initialized_ata_account(),
|
||||
definition_account(),
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
25,
|
||||
);
|
||||
|
||||
assert_eq!(post_states.len(), 3);
|
||||
assert_eq!(chained_calls.len(), 1);
|
||||
|
||||
let mut holder_auth = initialized_ata_account();
|
||||
holder_auth.is_authorized = true;
|
||||
let expected_call = ChainedCall::new(
|
||||
TOKEN_PROGRAM_ID,
|
||||
vec![definition_account(), holder_auth],
|
||||
&token_core::Instruction::Burn { amount_to_burn: 25 },
|
||||
)
|
||||
.with_pda_seeds(vec![compute_ata_seed(
|
||||
TOKEN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
definition_id(),
|
||||
)]);
|
||||
|
||||
assert_eq!(chained_calls, vec![expected_call]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Holder ATA must be owned by expected token program")]
|
||||
fn burn_panics_when_holder_ata_is_owned_by_unexpected_token_program() {
|
||||
let mut holder = initialized_ata_account();
|
||||
holder.account.program_owner = OTHER_TOKEN_PROGRAM_ID;
|
||||
|
||||
crate::burn::burn_from_associated_token_account(
|
||||
owner_account(),
|
||||
holder,
|
||||
definition_account(),
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Token definition must be owned by expected token program")]
|
||||
fn burn_panics_when_definition_is_owned_by_unexpected_token_program() {
|
||||
let mut definition = definition_account();
|
||||
definition.account.program_owner = OTHER_TOKEN_PROGRAM_ID;
|
||||
|
||||
crate::burn::burn_from_associated_token_account(
|
||||
owner_account(),
|
||||
initialized_ata_account(),
|
||||
definition,
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Holder ATA token definition does not match")]
|
||||
fn burn_panics_when_holder_definition_mismatches_supplied_definition() {
|
||||
let mut definition = definition_account();
|
||||
definition.account_id = AccountId::new([0xBBu8; 32]);
|
||||
|
||||
crate::burn::burn_from_associated_token_account(
|
||||
owner_account(),
|
||||
initialized_ata_account(),
|
||||
definition,
|
||||
ATA_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID,
|
||||
1,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{AccountPostState, ChainedCall, ProgramId},
|
||||
};
|
||||
use token_core::TokenHolding;
|
||||
|
||||
pub fn transfer_from_associated_token_account(
|
||||
owner: AccountWithMetadata,
|
||||
sender_ata: AccountWithMetadata,
|
||||
recipient: AccountWithMetadata,
|
||||
ata_program_id: ProgramId,
|
||||
token_program_id: ProgramId,
|
||||
amount: u128,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
assert!(owner.is_authorized, "Owner authorization is missing");
|
||||
assert_eq!(
|
||||
sender_ata.account.program_owner, token_program_id,
|
||||
"Sender ATA must be owned by expected token program"
|
||||
);
|
||||
let sender_definition_id = TokenHolding::try_from(&sender_ata.account.data)
|
||||
.expect("Sender ATA must hold a valid token")
|
||||
.definition_id();
|
||||
let sender_seed = ata_core::verify_ata_and_get_seed(
|
||||
&sender_ata,
|
||||
&owner,
|
||||
token_program_id,
|
||||
sender_definition_id,
|
||||
ata_program_id,
|
||||
);
|
||||
|
||||
// The recipient contract: ATA::Transfer requires a recipient token holding that is already
|
||||
// initialized, owned by the same token program as the sender ATA, and that points at the same
|
||||
// token definition as the sender. Anything else fails here rather than being silently
|
||||
// materialized by the downstream token transfer (e.g. via `Claim::Authorized` on a default
|
||||
// recipient), so integrators get an ATA-level failure rather than having to reverse-engineer
|
||||
// token/runtime semantics.
|
||||
assert_ne!(
|
||||
recipient.account,
|
||||
Account::default(),
|
||||
"Recipient token holding must be initialized"
|
||||
);
|
||||
assert_eq!(
|
||||
recipient.account.program_owner, token_program_id,
|
||||
"Recipient must be owned by the same token program as the sender ATA"
|
||||
);
|
||||
let recipient_definition_id = TokenHolding::try_from(&recipient.account.data)
|
||||
.expect("Recipient must hold a valid token")
|
||||
.definition_id();
|
||||
assert_eq!(
|
||||
recipient_definition_id, sender_definition_id,
|
||||
"Recipient and sender token definitions do not match"
|
||||
);
|
||||
|
||||
let post_states = vec![
|
||||
AccountPostState::new(owner.account.clone()),
|
||||
AccountPostState::new(sender_ata.account.clone()),
|
||||
AccountPostState::new(recipient.account.clone()),
|
||||
];
|
||||
let mut sender_ata_auth = sender_ata.clone();
|
||||
sender_ata_auth.is_authorized = true;
|
||||
|
||||
let chained_call = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![sender_ata_auth, recipient],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: amount,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![sender_seed]);
|
||||
(post_states, vec![chained_call])
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "integration_tests"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nssa = { workspace = true }
|
||||
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" }
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,587 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ata_core::{compute_ata_seed, get_associated_token_account_id};
|
||||
use nssa::{
|
||||
execute_and_prove,
|
||||
privacy_preserving_transaction::{
|
||||
circuit::ProgramWithDependencies, Message, PrivacyPreservingTransaction, WitnessSet,
|
||||
},
|
||||
program::Program,
|
||||
program_deployment_transaction::{self, ProgramDeploymentTransaction},
|
||||
public_transaction, EphemeralPublicKey, PrivateKey, PublicKey, PublicTransaction,
|
||||
SharedSecretKey, V03State,
|
||||
};
|
||||
use nssa_core::{
|
||||
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
|
||||
encryption::{Scalar, ViewingPublicKey},
|
||||
NullifierPublicKey, NullifierSecretKey,
|
||||
};
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
struct Keys;
|
||||
struct Ids;
|
||||
struct Accounts;
|
||||
|
||||
impl Keys {
|
||||
fn def_key() -> PrivateKey {
|
||||
PrivateKey::try_new([10; 32]).expect("valid private key")
|
||||
}
|
||||
|
||||
fn owner_key() -> PrivateKey {
|
||||
PrivateKey::try_new([11; 32]).expect("valid private key")
|
||||
}
|
||||
|
||||
fn recipient_key() -> PrivateKey {
|
||||
PrivateKey::try_new([12; 32]).expect("valid private key")
|
||||
}
|
||||
}
|
||||
|
||||
impl Ids {
|
||||
fn token_program() -> nssa_core::program::ProgramId {
|
||||
token_methods::TOKEN_ID
|
||||
}
|
||||
|
||||
fn ata_program() -> nssa_core::program::ProgramId {
|
||||
ata_methods::ATA_ID
|
||||
}
|
||||
|
||||
fn token_definition() -> AccountId {
|
||||
AccountId::from(&PublicKey::new_from_private_key(&Keys::def_key()))
|
||||
}
|
||||
|
||||
fn owner() -> AccountId {
|
||||
AccountId::from(&PublicKey::new_from_private_key(&Keys::owner_key()))
|
||||
}
|
||||
|
||||
fn recipient() -> AccountId {
|
||||
AccountId::from(&PublicKey::new_from_private_key(&Keys::recipient_key()))
|
||||
}
|
||||
|
||||
fn owner_ata() -> AccountId {
|
||||
let seed = compute_ata_seed(
|
||||
Self::token_program(),
|
||||
Self::owner(),
|
||||
Self::token_definition(),
|
||||
);
|
||||
get_associated_token_account_id(&Self::ata_program(), &seed)
|
||||
}
|
||||
|
||||
fn recipient_ata() -> AccountId {
|
||||
let seed = compute_ata_seed(
|
||||
Self::token_program(),
|
||||
Self::recipient(),
|
||||
Self::token_definition(),
|
||||
);
|
||||
get_associated_token_account_id(&Self::ata_program(), &seed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Accounts {
|
||||
fn token_definition_init() -> Account {
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_000_000_u128,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_ata_init() -> Account {
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_000_000_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn recipient_ata_init() -> Account {
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 0_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn foreign_owned_token_definition() -> Account {
|
||||
Account {
|
||||
program_owner: [99; 8],
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Foreign Gold"),
|
||||
total_supply: 1_000_000_u128,
|
||||
metadata_id: None,
|
||||
}),
|
||||
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 ata_message = program_deployment_transaction::Message::new(ata_methods::ATA_ELF.to_vec());
|
||||
state
|
||||
.transition_from_program_deployment_transaction(&ProgramDeploymentTransaction::new(
|
||||
ata_message,
|
||||
))
|
||||
.expect("ata program deployment must succeed");
|
||||
}
|
||||
|
||||
fn state_for_ata_tests() -> V03State {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
deploy_programs(&mut state);
|
||||
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
||||
state.force_insert_account(Ids::owner_ata(), Accounts::owner_ata_init());
|
||||
state
|
||||
}
|
||||
|
||||
fn state_for_ata_tests_with_precreated_recipient_ata() -> V03State {
|
||||
let mut state = state_for_ata_tests();
|
||||
state.force_insert_account(Ids::recipient_ata(), Accounts::recipient_ata_init());
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ata_create() {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
deploy_programs(&mut state);
|
||||
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
||||
|
||||
let instruction = ata_core::Instruction::Create {
|
||||
token_program_id: Ids::token_program(),
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::ata_program(),
|
||||
vec![Ids::owner(), Ids::token_definition(), Ids::owner_ata()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::owner_ata()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 0_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ata_create_is_idempotent() {
|
||||
let mut state = state_for_ata_tests();
|
||||
|
||||
let instruction = ata_core::Instruction::Create {
|
||||
token_program_id: Ids::token_program(),
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::ata_program(),
|
||||
vec![Ids::owner(), Ids::token_definition(), Ids::owner_ata()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
|
||||
// Already initialized — should remain unchanged
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::owner_ata()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_000_000_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ata_create_rejects_definition_owned_by_unexpected_token_program() {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
deploy_programs(&mut state);
|
||||
state.force_insert_account(
|
||||
Ids::token_definition(),
|
||||
Accounts::foreign_owned_token_definition(),
|
||||
);
|
||||
|
||||
let instruction = ata_core::Instruction::Create {
|
||||
token_program_id: Ids::token_program(),
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::ata_program(),
|
||||
vec![Ids::owner(), Ids::token_definition(), Ids::owner_ata()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::owner_ata()),
|
||||
Account::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ata_create_rejects_existing_ata_owned_by_unexpected_token_program() {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
deploy_programs(&mut state);
|
||||
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
||||
|
||||
let mut foreign_ata = Accounts::owner_ata_init();
|
||||
foreign_ata.program_owner = [99; 8];
|
||||
state.force_insert_account(Ids::owner_ata(), foreign_ata.clone());
|
||||
|
||||
let instruction = ata_core::Instruction::Create {
|
||||
token_program_id: Ids::token_program(),
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::ata_program(),
|
||||
vec![Ids::owner(), Ids::token_definition(), Ids::owner_ata()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
||||
assert_eq!(state.get_account_by_id(Ids::owner_ata()), foreign_ata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ata_create_rejects_existing_ata_with_mismatched_definition() {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
deploy_programs(&mut state);
|
||||
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
||||
|
||||
let mut mismatched_ata = Accounts::owner_ata_init();
|
||||
mismatched_ata.data = Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::recipient(),
|
||||
balance: 1_000_000_u128,
|
||||
});
|
||||
state.force_insert_account(Ids::owner_ata(), mismatched_ata.clone());
|
||||
|
||||
let instruction = ata_core::Instruction::Create {
|
||||
token_program_id: Ids::token_program(),
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::ata_program(),
|
||||
vec![Ids::owner(), Ids::token_definition(), Ids::owner_ata()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
||||
assert_eq!(state.get_account_by_id(Ids::owner_ata()), mismatched_ata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ata_transfer() {
|
||||
let mut state = state_for_ata_tests_with_precreated_recipient_ata();
|
||||
|
||||
let instruction = ata_core::Instruction::Transfer {
|
||||
token_program_id: Ids::token_program(),
|
||||
amount: 400_000_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::ata_program(),
|
||||
vec![Ids::owner(), Ids::owner_ata(), Ids::recipient_ata()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::owner_ata()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 600_000_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::recipient_ata()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 400_000_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ata_transfer_rejects_default_recipient() {
|
||||
let mut state = state_for_ata_tests();
|
||||
|
||||
let instruction = ata_core::Instruction::Transfer {
|
||||
token_program_id: Ids::token_program(),
|
||||
amount: 1_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::ata_program(),
|
||||
vec![Ids::owner(), Ids::owner_ata(), Ids::recipient_ata()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::owner_ata()),
|
||||
Accounts::owner_ata_init()
|
||||
);
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::recipient_ata()),
|
||||
Account::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ata_transfer_rejects_mismatched_definition_recipient() {
|
||||
let mut state = state_for_ata_tests_with_precreated_recipient_ata();
|
||||
|
||||
// Replace the recipient ATA with a token holding pointing at a different definition.
|
||||
let foreign_definition_id = AccountId::from(&PublicKey::new_from_private_key(
|
||||
&PrivateKey::try_new([42; 32]).expect("valid private key"),
|
||||
));
|
||||
let mismatched_recipient = Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: foreign_definition_id,
|
||||
balance: 0_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
};
|
||||
state.force_insert_account(Ids::recipient_ata(), mismatched_recipient.clone());
|
||||
|
||||
let instruction = ata_core::Instruction::Transfer {
|
||||
token_program_id: Ids::token_program(),
|
||||
amount: 1_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::ata_program(),
|
||||
vec![Ids::owner(), Ids::owner_ata(), Ids::recipient_ata()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::owner_ata()),
|
||||
Accounts::owner_ata_init()
|
||||
);
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::recipient_ata()),
|
||||
mismatched_recipient
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ata_burn() {
|
||||
let mut state = state_for_ata_tests();
|
||||
|
||||
let instruction = ata_core::Instruction::Burn {
|
||||
token_program_id: Ids::token_program(),
|
||||
amount: 300_000_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::ata_program(),
|
||||
vec![Ids::owner(), Ids::owner_ata(), Ids::token_definition()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::owner_ata()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 700_000_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 700_000_u128,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ata_create_from_private_owner() {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
deploy_programs(&mut state);
|
||||
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
||||
|
||||
// Private owner key material
|
||||
let owner_nsk: NullifierSecretKey = [13u8; 32];
|
||||
let owner_npk = NullifierPublicKey::from(&owner_nsk);
|
||||
let owner_vsk: Scalar = [31u8; 32];
|
||||
let owner_vpk = ViewingPublicKey::from_scalar(owner_vsk);
|
||||
let owner_id = AccountId::from(&owner_npk);
|
||||
|
||||
// ATA derived from the private owner
|
||||
let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition());
|
||||
let owner_ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed);
|
||||
|
||||
// Pre-states: private uninitialized owner (mask=2), public token definition (mask=0), public
|
||||
// uninitialized ATA (mask=0)
|
||||
let owner_pre = AccountWithMetadata::new(Account::default(), false, owner_id);
|
||||
let def_pre = AccountWithMetadata::new(
|
||||
Accounts::token_definition_init(),
|
||||
false,
|
||||
Ids::token_definition(),
|
||||
);
|
||||
let ata_pre = AccountWithMetadata::new(Account::default(), false, owner_ata_id);
|
||||
|
||||
let instruction = ata_core::Instruction::Create {
|
||||
token_program_id: Ids::token_program(),
|
||||
};
|
||||
let instruction_data = Program::serialize_instruction(instruction).unwrap();
|
||||
|
||||
// Ephemeral key for encrypting the private owner's post-state
|
||||
let esk: Scalar = [3u8; 32];
|
||||
let shared_secret = SharedSecretKey::new(&esk, &owner_vpk);
|
||||
let epk = EphemeralPublicKey::from_scalar(esk);
|
||||
|
||||
let ata_program = Program::new(ata_methods::ATA_ELF.to_vec()).unwrap();
|
||||
let token_program = Program::new(token_methods::TOKEN_ELF.to_vec()).unwrap();
|
||||
let program_with_deps = ProgramWithDependencies::new(
|
||||
ata_program,
|
||||
HashMap::from([(Ids::token_program(), token_program)]),
|
||||
);
|
||||
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![owner_pre, def_pre, ata_pre],
|
||||
instruction_data,
|
||||
// owner=new private (2), token_definition=public (0), ata=public (0)
|
||||
vec![2, 0, 0],
|
||||
vec![(owner_npk, shared_secret)],
|
||||
vec![], // no NSKs: new private accounts don't require one
|
||||
vec![None], // no membership proof: owner is being created, not spending
|
||||
&program_with_deps,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let message = Message::try_from_circuit_output(
|
||||
vec![Ids::token_definition(), owner_ata_id],
|
||||
vec![],
|
||||
vec![(owner_npk, owner_vpk, epk)],
|
||||
output,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[]);
|
||||
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(&tx, 0, 0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(owner_ata_id),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 0_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
use nssa::{
|
||||
execute_and_prove,
|
||||
privacy_preserving_transaction::{Message, WitnessSet},
|
||||
program::Program,
|
||||
program_deployment_transaction::{self, ProgramDeploymentTransaction},
|
||||
public_transaction, PrivacyPreservingTransaction, PrivateKey, PublicKey, PublicTransaction,
|
||||
SharedSecretKey, V03State,
|
||||
};
|
||||
use nssa_core::{
|
||||
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
|
||||
encryption::{EphemeralPublicKey, ViewingPublicKey},
|
||||
Commitment, NullifierPublicKey, NullifierSecretKey,
|
||||
};
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
struct Keys;
|
||||
struct Ids;
|
||||
struct Accounts;
|
||||
|
||||
impl Keys {
|
||||
fn def_key() -> PrivateKey {
|
||||
PrivateKey::try_new([10; 32]).expect("valid private key")
|
||||
}
|
||||
|
||||
fn holder_key() -> PrivateKey {
|
||||
PrivateKey::try_new([11; 32]).expect("valid private key")
|
||||
}
|
||||
|
||||
fn recipient_key() -> PrivateKey {
|
||||
PrivateKey::try_new([12; 32]).expect("valid private key")
|
||||
}
|
||||
}
|
||||
|
||||
impl Ids {
|
||||
fn token_program() -> nssa_core::program::ProgramId {
|
||||
token_methods::TOKEN_ID
|
||||
}
|
||||
|
||||
fn foreign_token_program() -> nssa_core::program::ProgramId {
|
||||
[0xfeed_u32; 8]
|
||||
}
|
||||
|
||||
fn token_definition() -> AccountId {
|
||||
AccountId::from(&PublicKey::new_from_private_key(&Keys::def_key()))
|
||||
}
|
||||
|
||||
fn holder() -> AccountId {
|
||||
AccountId::from(&PublicKey::new_from_private_key(&Keys::holder_key()))
|
||||
}
|
||||
|
||||
fn recipient() -> AccountId {
|
||||
AccountId::from(&PublicKey::new_from_private_key(&Keys::recipient_key()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Accounts {
|
||||
fn token_definition_init() -> Account {
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_000_000_u128,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn token_definition_foreign_owner() -> Account {
|
||||
Account {
|
||||
program_owner: Ids::foreign_token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_000_000_u128,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn holder_init() -> Account {
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_000_000_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn recipient_init() -> Account {
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 0_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deploy_token(state: &mut V03State) {
|
||||
let message = program_deployment_transaction::Message::new(token_methods::TOKEN_ELF.to_vec());
|
||||
let tx = ProgramDeploymentTransaction::new(message);
|
||||
state
|
||||
.transition_from_program_deployment_transaction(&tx)
|
||||
.expect("token program deployment must succeed");
|
||||
}
|
||||
|
||||
fn state_for_token_tests() -> V03State {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
deploy_token(&mut state);
|
||||
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
||||
state.force_insert_account(Ids::holder(), Accounts::holder_init());
|
||||
state.force_insert_account(Ids::recipient(), Accounts::recipient_init());
|
||||
state
|
||||
}
|
||||
|
||||
fn state_for_token_tests_without_recipient() -> V03State {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
deploy_token(&mut state);
|
||||
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
||||
state.force_insert_account(Ids::holder(), Accounts::holder_init());
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_new_fungible_definition() {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
deploy_token(&mut state);
|
||||
|
||||
let instruction = token_core::Instruction::NewFungibleDefinition {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_000_000_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::token_program(),
|
||||
vec![Ids::token_definition(), Ids::holder()],
|
||||
vec![Nonce(0), Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(
|
||||
&message,
|
||||
&[&Keys::def_key(), &Keys::holder_key()],
|
||||
);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_000_000_u128,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::holder()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_000_000_u128,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_initialize_account_succeeds_for_canonical_definition() {
|
||||
let mut state = state_for_token_tests_without_recipient();
|
||||
|
||||
let instruction = token_core::Instruction::InitializeAccount;
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::token_program(),
|
||||
vec![Ids::token_definition(), Ids::recipient()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set =
|
||||
public_transaction::WitnessSet::for_message(&message, &[&Keys::recipient_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Accounts::token_definition_init()
|
||||
);
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::recipient()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 0_u128,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_initialize_account_rejects_foreign_owned_definition() {
|
||||
let mut state = state_for_token_tests_without_recipient();
|
||||
state.force_insert_account(
|
||||
Ids::token_definition(),
|
||||
Accounts::token_definition_foreign_owner(),
|
||||
);
|
||||
|
||||
let instruction = token_core::Instruction::InitializeAccount;
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::token_program(),
|
||||
vec![Ids::token_definition(), Ids::recipient()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set =
|
||||
public_transaction::WitnessSet::for_message(&message, &[&Keys::recipient_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Accounts::token_definition_foreign_owner()
|
||||
);
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::recipient()),
|
||||
Account::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_transfer() {
|
||||
let mut state = state_for_token_tests();
|
||||
|
||||
let instruction = token_core::Instruction::Transfer {
|
||||
amount_to_transfer: 500_000_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::token_program(),
|
||||
vec![Ids::holder(), Ids::recipient()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::holder_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::holder()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 500_000_u128,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::recipient()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 500_000_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_transfer_fresh_public_recipient_requires_authorization() {
|
||||
let mut state = state_for_token_tests_without_recipient();
|
||||
|
||||
let instruction = token_core::Instruction::Transfer {
|
||||
amount_to_transfer: 500_000_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::token_program(),
|
||||
vec![Ids::holder(), Ids::recipient()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::holder_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::holder()),
|
||||
Accounts::holder_init()
|
||||
);
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::recipient()),
|
||||
Account::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_transfer_fresh_authorized_public_recipient() {
|
||||
let mut state = state_for_token_tests_without_recipient();
|
||||
|
||||
let instruction = token_core::Instruction::Transfer {
|
||||
amount_to_transfer: 500_000_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::token_program(),
|
||||
vec![Ids::holder(), Ids::recipient()],
|
||||
vec![Nonce(0), Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(
|
||||
&message,
|
||||
&[&Keys::holder_key(), &Keys::recipient_key()],
|
||||
);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::holder()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 500_000_u128,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::recipient()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 500_000_u128,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_burn() {
|
||||
let mut state = state_for_token_tests();
|
||||
|
||||
let instruction = token_core::Instruction::Burn {
|
||||
amount_to_burn: 200_000_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::token_program(),
|
||||
vec![Ids::token_definition(), Ids::holder()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::holder_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 800_000_u128,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::holder()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 800_000_u128,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_mint() {
|
||||
let mut state = state_for_token_tests();
|
||||
|
||||
let instruction = token_core::Instruction::Mint {
|
||||
amount_to_mint: 500_000_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::token_program(),
|
||||
vec![Ids::token_definition(), Ids::holder()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::def_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_500_000_u128,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::holder()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_500_000_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_mint_rejects_foreign_owned_definition() {
|
||||
let mut state = state_for_token_tests_without_recipient();
|
||||
state.force_insert_account(
|
||||
Ids::token_definition(),
|
||||
Accounts::token_definition_foreign_owner(),
|
||||
);
|
||||
|
||||
let instruction = token_core::Instruction::Mint {
|
||||
amount_to_mint: 500_000_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::token_program(),
|
||||
vec![Ids::token_definition(), Ids::recipient()],
|
||||
vec![Nonce(0), Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(
|
||||
&message,
|
||||
&[&Keys::def_key(), &Keys::recipient_key()],
|
||||
);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Accounts::token_definition_foreign_owner()
|
||||
);
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::recipient()),
|
||||
Account::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_mint_fresh_public_recipient_requires_authorization() {
|
||||
let mut state = state_for_token_tests_without_recipient();
|
||||
|
||||
let instruction = token_core::Instruction::Mint {
|
||||
amount_to_mint: 500_000_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::token_program(),
|
||||
vec![Ids::token_definition(), Ids::recipient()],
|
||||
vec![Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::def_key()]);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Accounts::token_definition_init()
|
||||
);
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::recipient()),
|
||||
Account::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_mint_fresh_authorized_public_recipient() {
|
||||
let mut state = state_for_token_tests_without_recipient();
|
||||
|
||||
let instruction = token_core::Instruction::Mint {
|
||||
amount_to_mint: 500_000_u128,
|
||||
};
|
||||
|
||||
let message = public_transaction::Message::try_new(
|
||||
Ids::token_program(),
|
||||
vec![Ids::token_definition(), Ids::recipient()],
|
||||
vec![Nonce(0), Nonce(0)],
|
||||
instruction,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = public_transaction::WitnessSet::for_message(
|
||||
&message,
|
||||
&[&Keys::def_key(), &Keys::recipient_key()],
|
||||
);
|
||||
|
||||
let tx = PublicTransaction::new(message, witness_set);
|
||||
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_500_000_u128,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::recipient()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 500_000_u128,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
struct PrivateKeys;
|
||||
|
||||
impl PrivateKeys {
|
||||
fn holder_nsk() -> NullifierSecretKey {
|
||||
[42; 32]
|
||||
}
|
||||
|
||||
fn holder_npk() -> NullifierPublicKey {
|
||||
NullifierPublicKey::from(&Self::holder_nsk())
|
||||
}
|
||||
|
||||
fn holder_vsk() -> [u8; 32] {
|
||||
[73; 32]
|
||||
}
|
||||
|
||||
fn holder_vpk() -> ViewingPublicKey {
|
||||
ViewingPublicKey::from_scalar(Self::holder_vsk())
|
||||
}
|
||||
|
||||
fn recipient_nsk() -> NullifierSecretKey {
|
||||
[84; 32]
|
||||
}
|
||||
|
||||
fn recipient_npk() -> NullifierPublicKey {
|
||||
NullifierPublicKey::from(&Self::recipient_nsk())
|
||||
}
|
||||
|
||||
fn recipient_vsk() -> [u8; 32] {
|
||||
[48; 32]
|
||||
}
|
||||
|
||||
fn recipient_vpk() -> ViewingPublicKey {
|
||||
ViewingPublicKey::from_scalar(Self::recipient_vsk())
|
||||
}
|
||||
}
|
||||
|
||||
fn token_program() -> Program {
|
||||
Program::new(token_methods::TOKEN_ELF.to_vec()).expect("valid token ELF")
|
||||
}
|
||||
|
||||
/// Performs a shielded transfer (public → private) of `amount` tokens from
|
||||
/// `Ids::holder()` to a new private account keyed by `PrivateKeys::recipient_*`.
|
||||
/// Returns the resulting private recipient account.
|
||||
#[cfg(test)]
|
||||
fn shielded_token_transfer(amount: u128, state: &mut V03State) -> Account {
|
||||
let sender_id = Ids::holder();
|
||||
let sender_account = state.get_account_by_id(sender_id);
|
||||
let sender_nonce = sender_account.nonce;
|
||||
|
||||
let sender = AccountWithMetadata::new(sender_account, true, sender_id);
|
||||
let recipient =
|
||||
AccountWithMetadata::new(Account::default(), false, &PrivateKeys::recipient_npk());
|
||||
|
||||
let esk = [99u8; 32];
|
||||
let shared_secret = SharedSecretKey::new(&esk, &PrivateKeys::recipient_vpk());
|
||||
let epk = EphemeralPublicKey::from_scalar(esk);
|
||||
|
||||
let instruction = token_core::Instruction::Transfer {
|
||||
amount_to_transfer: amount,
|
||||
};
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![sender, recipient],
|
||||
Program::serialize_instruction(instruction).unwrap(),
|
||||
vec![0, 2],
|
||||
vec![(PrivateKeys::recipient_npk(), shared_secret)],
|
||||
vec![],
|
||||
vec![None],
|
||||
&token_program().into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let message = Message::try_from_circuit_output(
|
||||
vec![sender_id],
|
||||
vec![sender_nonce],
|
||||
vec![(
|
||||
PrivateKeys::recipient_npk(),
|
||||
PrivateKeys::recipient_vpk(),
|
||||
epk,
|
||||
)],
|
||||
output,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::holder_key()]);
|
||||
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(&tx, 0, 0)
|
||||
.unwrap();
|
||||
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: amount,
|
||||
}),
|
||||
nonce: Nonce::private_account_nonce_init(&PrivateKeys::recipient_npk()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_shielded_transfer() {
|
||||
let mut state = state_for_token_tests();
|
||||
let amount = 500_000_u128;
|
||||
|
||||
let recipient_account = shielded_token_transfer(amount, &mut state);
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::holder()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_000_000 - amount,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
|
||||
let recipient_commitment = Commitment::new(&PrivateKeys::recipient_npk(), &recipient_account);
|
||||
assert!(state
|
||||
.get_proof_for_commitment(&recipient_commitment)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_private_transfer() {
|
||||
let mut state = state_for_token_tests();
|
||||
let shielded_amount = 500_000_u128;
|
||||
let transfer_amount = 200_000_u128;
|
||||
|
||||
// Shield tokens into a private account (becomes the sender for the private transfer).
|
||||
let sender_account = shielded_token_transfer(shielded_amount, &mut state);
|
||||
let sender_npk = PrivateKeys::recipient_npk();
|
||||
let sender_nsk = PrivateKeys::recipient_nsk();
|
||||
let sender_vpk = PrivateKeys::recipient_vpk();
|
||||
|
||||
let new_recipient_npk = PrivateKeys::holder_npk();
|
||||
let new_recipient_vpk = PrivateKeys::holder_vpk();
|
||||
|
||||
let sender_commitment = Commitment::new(&sender_npk, &sender_account);
|
||||
|
||||
let esk_1 = [11u8; 32];
|
||||
let shared_secret_1 = SharedSecretKey::new(&esk_1, &sender_vpk);
|
||||
let epk_1 = EphemeralPublicKey::from_scalar(esk_1);
|
||||
|
||||
let esk_2 = [22u8; 32];
|
||||
let shared_secret_2 = SharedSecretKey::new(&esk_2, &new_recipient_vpk);
|
||||
let epk_2 = EphemeralPublicKey::from_scalar(esk_2);
|
||||
|
||||
let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, &sender_npk);
|
||||
let new_recipient_pre = AccountWithMetadata::new(Account::default(), false, &new_recipient_npk);
|
||||
|
||||
let instruction = token_core::Instruction::Transfer {
|
||||
amount_to_transfer: transfer_amount,
|
||||
};
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![sender_pre, new_recipient_pre],
|
||||
Program::serialize_instruction(instruction).unwrap(),
|
||||
vec![1, 2],
|
||||
vec![
|
||||
(sender_npk, shared_secret_1),
|
||||
(new_recipient_npk, shared_secret_2),
|
||||
],
|
||||
vec![sender_nsk],
|
||||
vec![state.get_proof_for_commitment(&sender_commitment), None],
|
||||
&token_program().into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let message = Message::try_from_circuit_output(
|
||||
vec![],
|
||||
vec![],
|
||||
vec![
|
||||
(sender_npk, sender_vpk, epk_1),
|
||||
(new_recipient_npk, new_recipient_vpk, epk_2),
|
||||
],
|
||||
output,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[]);
|
||||
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(&tx, 0, 0)
|
||||
.unwrap();
|
||||
|
||||
let sender_nonce_after =
|
||||
Nonce::private_account_nonce_init(&sender_npk).private_account_nonce_increment(&sender_nsk);
|
||||
let new_sender_account = Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: shielded_amount - transfer_amount,
|
||||
}),
|
||||
nonce: sender_nonce_after,
|
||||
};
|
||||
assert!(state
|
||||
.get_proof_for_commitment(&Commitment::new(&sender_npk, &new_sender_account))
|
||||
.is_some());
|
||||
|
||||
let new_recipient_account = Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: transfer_amount,
|
||||
}),
|
||||
nonce: Nonce::private_account_nonce_init(&new_recipient_npk),
|
||||
};
|
||||
assert!(state
|
||||
.get_proof_for_commitment(&Commitment::new(&new_recipient_npk, &new_recipient_account))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_deshielded_transfer() {
|
||||
let mut state = state_for_token_tests();
|
||||
let shielded_amount = 500_000_u128;
|
||||
let deshield_amount = 300_000_u128;
|
||||
|
||||
// Shield tokens into a private account, then deshield some back to a public account.
|
||||
let sender_account = shielded_token_transfer(shielded_amount, &mut state);
|
||||
let sender_npk = PrivateKeys::recipient_npk();
|
||||
let sender_nsk = PrivateKeys::recipient_nsk();
|
||||
let sender_vpk = PrivateKeys::recipient_vpk();
|
||||
|
||||
let public_recipient_id = Ids::recipient();
|
||||
let sender_commitment = Commitment::new(&sender_npk, &sender_account);
|
||||
|
||||
let esk = [55u8; 32];
|
||||
let shared_secret = SharedSecretKey::new(&esk, &sender_vpk);
|
||||
let epk = EphemeralPublicKey::from_scalar(esk);
|
||||
|
||||
let public_recipient_pre = AccountWithMetadata::new(
|
||||
state.get_account_by_id(public_recipient_id),
|
||||
false,
|
||||
public_recipient_id,
|
||||
);
|
||||
let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, &sender_npk);
|
||||
|
||||
let instruction = token_core::Instruction::Transfer {
|
||||
amount_to_transfer: deshield_amount,
|
||||
};
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![sender_pre, public_recipient_pre],
|
||||
Program::serialize_instruction(instruction).unwrap(),
|
||||
vec![1, 0],
|
||||
vec![(sender_npk, shared_secret)],
|
||||
vec![sender_nsk],
|
||||
vec![state.get_proof_for_commitment(&sender_commitment)],
|
||||
&token_program().into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let message = Message::try_from_circuit_output(
|
||||
vec![public_recipient_id],
|
||||
vec![],
|
||||
vec![(sender_npk, sender_vpk, epk)],
|
||||
output,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[]);
|
||||
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(&tx, 0, 0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(public_recipient_id),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: deshield_amount,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
|
||||
let sender_nonce_after =
|
||||
Nonce::private_account_nonce_init(&sender_npk).private_account_nonce_increment(&sender_nsk);
|
||||
let new_sender_account = Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: shielded_amount - deshield_amount,
|
||||
}),
|
||||
nonce: sender_nonce_after,
|
||||
};
|
||||
assert!(state
|
||||
.get_proof_for_commitment(&Commitment::new(&sender_npk, &new_sender_account))
|
||||
.is_some());
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "stablecoin_program"
|
||||
version = "0.1.0"
|
||||
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" }
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "stablecoin_core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
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"] }
|
||||
twap_oracle_core = { path = "../../twap_oracle/core" }
|
||||
risc0-zkvm = { version = "=3.0.5", default-features = false }
|
||||
spel-framework-macros = { git = "https://github.com/logos-co/spel.git", tag = "v0.3.0", package = "spel-framework-macros" }
|
||||
@@ -0,0 +1,214 @@
|
||||
//! 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};
|
||||
use spel_framework_macros::account_type;
|
||||
|
||||
const POSITION_PDA_DOMAIN: [u8; 32] = [0; 32];
|
||||
const POSITION_VAULT_PDA_DOMAIN: [u8; 32] = [1; 32];
|
||||
|
||||
/// Stablecoin Program Instruction.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Open a new collateral-only [`Position`] for the calling owner.
|
||||
///
|
||||
/// Required accounts (5):
|
||||
/// - Owner account (authorized)
|
||||
/// - Position account (uninitialized, address must match
|
||||
/// `compute_position_pda(self_program_id, owner, token_definition)`)
|
||||
/// - Position vault token holding account (uninitialized, address must match
|
||||
/// `compute_position_vault_pda(self_program_id, position_id)`)
|
||||
/// - Owner's source token holding for the collateral (authorized, initialized)
|
||||
/// - Token definition account for the collateral (matches the user holding's `definition_id`;
|
||||
/// its `program_owner` determines the Token Program used by the chained `InitializeAccount`
|
||||
/// / `Transfer` calls)
|
||||
OpenPosition {
|
||||
/// 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.
|
||||
///
|
||||
/// `debt_amount` is included for forward compatibility with `generate_debt`; until that
|
||||
/// instruction lands `open_position` always initializes it to `0`.
|
||||
#[account_type]
|
||||
#[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(std::mem::size_of_val(position));
|
||||
BorshSerialize::serialize(position, &mut data)
|
||||
.expect("Serialization to Vec should not fail");
|
||||
Self::try_from(data).expect("Position encoded data should fit into Data")
|
||||
}
|
||||
}
|
||||
|
||||
/// PDA seed for the [`Position`] account owned by `owner_id` for `collateral_definition_id`.
|
||||
///
|
||||
/// Derived from the owner and collateral definition addresses with a domain-separation tag
|
||||
/// so one owner can hold separate positions for separate collateral definitions.
|
||||
pub fn compute_position_pda_seed(
|
||||
owner_id: AccountId,
|
||||
collateral_definition_id: AccountId,
|
||||
) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let mut bytes = [0u8; 96];
|
||||
bytes[0..32].copy_from_slice(&owner_id.to_bytes());
|
||||
bytes[32..64].copy_from_slice(&collateral_definition_id.to_bytes());
|
||||
bytes[64..96].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,
|
||||
collateral_definition_id: AccountId,
|
||||
) -> AccountId {
|
||||
AccountId::for_public_pda(
|
||||
&stablecoin_program_id,
|
||||
&compute_position_pda_seed(owner_id, collateral_definition_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 as _};
|
||||
|
||||
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::for_public_pda(
|
||||
&stablecoin_program_id,
|
||||
&compute_position_vault_pda_seed(position_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// Verify the position account's address matches
|
||||
/// `(stablecoin_program_id, owner, collateral_definition_id)` and return the [`PdaSeed`] for
|
||||
/// use in post-state claims.
|
||||
///
|
||||
/// # Panics
|
||||
/// If `position.account_id` does not match the address derived from `owner`,
|
||||
/// `collateral_definition_id`, and `stablecoin_program_id`.
|
||||
pub fn verify_position_and_get_seed(
|
||||
position: &AccountWithMetadata,
|
||||
owner: &AccountWithMetadata,
|
||||
collateral_definition_id: AccountId,
|
||||
stablecoin_program_id: ProgramId,
|
||||
) -> PdaSeed {
|
||||
let seed = compute_position_pda_seed(owner.account_id, collateral_definition_id);
|
||||
let expected_id = AccountId::for_public_pda(&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::for_public_pda(&stablecoin_program_id, &seed);
|
||||
assert_eq!(
|
||||
vault.account_id, expected_id,
|
||||
"Position vault account ID does not match expected derivation"
|
||||
);
|
||||
seed
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "stablecoin-methods"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
risc0-build = "=3.0.5"
|
||||
|
||||
[dependencies]
|
||||
risc0-zkvm = { version = "=3.0.5", features = ["std"] }
|
||||
stablecoin_core = { path = "../core" }
|
||||
|
||||
[package.metadata.risc0]
|
||||
methods = ["guest"]
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Build script that embeds the stablecoin RISC Zero guest ELF as host-side constants.
|
||||
fn main() {
|
||||
risc0_build::embed_methods();
|
||||
}
|
||||
+4059
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "stablecoin-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[workspace]
|
||||
|
||||
[[bin]]
|
||||
name = "stablecoin"
|
||||
path = "src/bin/stablecoin.rs"
|
||||
|
||||
[dependencies]
|
||||
spel-framework = { git = "https://github.com/logos-co/spel.git", tag = "v0.3.0", package = "spel-framework" }
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" }
|
||||
risc0-zkvm = { version = "=3.0.5", default-features = false }
|
||||
twap_oracle_core = { path = "../../../twap_oracle/core" }
|
||||
stablecoin_core = { path = "../../core" }
|
||||
stablecoin_program = { path = "../..", package = "stablecoin_program" }
|
||||
token_core = { path = "../../../token/core" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
borsh = "1.5"
|
||||
@@ -0,0 +1,105 @@
|
||||
#![cfg_attr(not(test), no_main)]
|
||||
|
||||
use nssa_core::account::AccountWithMetadata;
|
||||
use spel_framework::context::ProgramContext;
|
||||
use spel_framework::prelude::*;
|
||||
|
||||
#[cfg(not(test))]
|
||||
risc0_zkvm::guest::entry!(main);
|
||||
|
||||
#[lez_program(instruction = "stablecoin_core::Instruction")]
|
||||
mod stablecoin {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
|
||||
/// 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]
|
||||
pub fn open_position(
|
||||
ctx: ProgramContext,
|
||||
owner: AccountWithMetadata,
|
||||
position: AccountWithMetadata,
|
||||
vault: AccountWithMetadata,
|
||||
user_holding: AccountWithMetadata,
|
||||
token_definition: AccountWithMetadata,
|
||||
collateral_amount: u128,
|
||||
) -> SpelResult {
|
||||
let (post_states, chained_calls) = stablecoin_program::open_position::open_position(
|
||||
owner,
|
||||
position,
|
||||
vault,
|
||||
user_holding,
|
||||
token_definition,
|
||||
ctx.self_program_id,
|
||||
collateral_amount,
|
||||
);
|
||||
Ok(spel_framework::SpelOutput::execute(
|
||||
post_states,
|
||||
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,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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"));
|
||||
@@ -0,0 +1,15 @@
|
||||
//! The Stablecoin Program implementation.
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,125 @@
|
||||
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`.
|
||||
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 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 position_seed = verify_position_and_get_seed(
|
||||
&position,
|
||||
&owner,
|
||||
token_definition.account_id,
|
||||
stablecoin_program_id,
|
||||
);
|
||||
let vault_seed =
|
||||
verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id);
|
||||
|
||||
let mut position_post = position.account;
|
||||
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 owns the vault as a Token holding. The Stablecoin
|
||||
// program only authorizes that claim by passing the vault PDA seed to the chained call.
|
||||
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])
|
||||
}
|
||||
@@ -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<AccountPostState>, Vec<ChainedCall>) {
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
#![allow(
|
||||
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 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,
|
||||
owner_id(),
|
||||
collateral_definition_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 {
|
||||
let mut account = token_holding_account(user_holding_id(), collateral_definition_id(), balance);
|
||||
account.is_authorized = true;
|
||||
account
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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(),
|
||||
collateral_definition_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,
|
||||
}
|
||||
);
|
||||
// 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);
|
||||
|
||||
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_and_collateral_specific() {
|
||||
let id_a = compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
collateral_definition_id(),
|
||||
);
|
||||
let id_b = compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
collateral_definition_id(),
|
||||
);
|
||||
assert_eq!(id_a, id_b);
|
||||
|
||||
let other_owner = AccountId::new([0x11u8; 32]);
|
||||
assert_ne!(
|
||||
compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
other_owner,
|
||||
collateral_definition_id()
|
||||
),
|
||||
id_a
|
||||
);
|
||||
|
||||
let other_definition = AccountId::new([0x21u8; 32]);
|
||||
assert_ne!(
|
||||
compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), other_definition),
|
||||
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.
|
||||
let position = compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
collateral_definition_id(),
|
||||
);
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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<AccountPostState>, Vec<ChainedCall>) {
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "token_program"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
|
||||
token_core = { path = "core" }
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "token_core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
|
||||
spel-framework-macros = { git = "https://github.com/logos-co/spel.git", tag = "v0.3.0", package = "spel-framework-macros" }
|
||||
borsh = { version = "1.5", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -0,0 +1,247 @@
|
||||
//! This crate contains core data structures and utilities for the Token Program.
|
||||
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use nssa_core::account::{AccountId, Data};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use spel_framework_macros::account_type;
|
||||
|
||||
/// Token Program Instruction.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Transfer tokens from sender to recipient.
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - Sender's Token Holding account (initialized, authorized),
|
||||
/// - Recipient's Token Holding account (initialized, or uninitialized with recipient
|
||||
/// authorization in the same transaction).
|
||||
Transfer { amount_to_transfer: u128 },
|
||||
|
||||
/// Create a new fungible token definition without metadata.
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - Token Definition account (uninitialized, authorized),
|
||||
/// - Token Holding account (uninitialized, authorized).
|
||||
NewFungibleDefinition { name: String, total_supply: u128 },
|
||||
|
||||
/// Create a new fungible or non-fungible token definition with metadata.
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - Token Definition account (uninitialized, authorized),
|
||||
/// - Token Holding account (uninitialized, authorized),
|
||||
/// - Token Metadata account (uninitialized, authorized).
|
||||
NewDefinitionWithMetadata {
|
||||
new_definition: NewTokenDefinition,
|
||||
/// Boxed to avoid large enum variant size
|
||||
metadata: Box<NewTokenMetadata>,
|
||||
},
|
||||
|
||||
/// Initialize a token holding account for a given token definition.
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - Token Definition account (initialized),
|
||||
/// - Token Holding account (uninitialized, authorized),
|
||||
InitializeAccount,
|
||||
|
||||
/// Burn tokens from the holder's account.
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - Token Definition account (initialized),
|
||||
/// - Token Holding account (authorized).
|
||||
Burn { amount_to_burn: u128 },
|
||||
|
||||
/// Mint new tokens to the holder's account.
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - Token Definition account (initialized, authorized),
|
||||
/// - Token Holding account (initialized, or uninitialized with holder authorization in the
|
||||
/// same transaction).
|
||||
Mint { amount_to_mint: u128 },
|
||||
|
||||
/// Print a new NFT from the master copy.
|
||||
///
|
||||
/// Required accounts:
|
||||
/// - NFT Master Token Holding account (authorized),
|
||||
/// - NFT Printed Copy Token Holding account (uninitialized, authorized).
|
||||
PrintNft,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum NewTokenDefinition {
|
||||
Fungible {
|
||||
name: String,
|
||||
total_supply: u128,
|
||||
},
|
||||
NonFungible {
|
||||
name: String,
|
||||
printable_supply: u128,
|
||||
},
|
||||
}
|
||||
|
||||
#[account_type]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
|
||||
pub enum TokenDefinition {
|
||||
Fungible {
|
||||
name: String,
|
||||
total_supply: u128,
|
||||
metadata_id: Option<AccountId>,
|
||||
},
|
||||
NonFungible {
|
||||
name: String,
|
||||
printable_supply: u128,
|
||||
metadata_id: AccountId,
|
||||
},
|
||||
}
|
||||
|
||||
impl TryFrom<&Data> for TokenDefinition {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from(data: &Data) -> Result<Self, Self::Error> {
|
||||
TokenDefinition::try_from_slice(data.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&TokenDefinition> for Data {
|
||||
fn from(definition: &TokenDefinition) -> Self {
|
||||
// Using size_of_val as size hint for Vec allocation
|
||||
let mut data = Vec::with_capacity(std::mem::size_of_val(definition));
|
||||
|
||||
BorshSerialize::serialize(definition, &mut data)
|
||||
.expect("Serialization to Vec should not fail");
|
||||
|
||||
Data::try_from(data).expect("Token definition encoded data should fit into Data")
|
||||
}
|
||||
}
|
||||
|
||||
#[account_type]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
|
||||
pub enum TokenHolding {
|
||||
Fungible {
|
||||
definition_id: AccountId,
|
||||
balance: u128,
|
||||
},
|
||||
NftMaster {
|
||||
definition_id: AccountId,
|
||||
/// The amount of printed copies left - 1 (1 reserved for master copy itself).
|
||||
print_balance: u128,
|
||||
},
|
||||
NftPrintedCopy {
|
||||
definition_id: AccountId,
|
||||
/// Whether nft is owned by the holder.
|
||||
owned: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl TokenHolding {
|
||||
pub fn zeroized_clone_from(other: &Self) -> Self {
|
||||
match other {
|
||||
TokenHolding::Fungible { definition_id, .. } => TokenHolding::Fungible {
|
||||
definition_id: *definition_id,
|
||||
balance: 0,
|
||||
},
|
||||
TokenHolding::NftMaster { definition_id, .. } => TokenHolding::NftMaster {
|
||||
definition_id: *definition_id,
|
||||
print_balance: 0,
|
||||
},
|
||||
TokenHolding::NftPrintedCopy { definition_id, .. } => TokenHolding::NftPrintedCopy {
|
||||
definition_id: *definition_id,
|
||||
owned: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn zeroized_from_definition(
|
||||
definition_id: AccountId,
|
||||
definition: &TokenDefinition,
|
||||
) -> Self {
|
||||
match definition {
|
||||
TokenDefinition::Fungible { .. } => TokenHolding::Fungible {
|
||||
definition_id,
|
||||
balance: 0,
|
||||
},
|
||||
TokenDefinition::NonFungible { .. } => TokenHolding::NftPrintedCopy {
|
||||
definition_id,
|
||||
owned: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn definition_id(&self) -> AccountId {
|
||||
match self {
|
||||
TokenHolding::Fungible { definition_id, .. } => *definition_id,
|
||||
TokenHolding::NftMaster { definition_id, .. } => *definition_id,
|
||||
TokenHolding::NftPrintedCopy { definition_id, .. } => *definition_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&Data> for TokenHolding {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from(data: &Data) -> Result<Self, Self::Error> {
|
||||
TokenHolding::try_from_slice(data.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&TokenHolding> for Data {
|
||||
fn from(holding: &TokenHolding) -> Self {
|
||||
// Using size_of_val as size hint for Vec allocation
|
||||
let mut data = Vec::with_capacity(std::mem::size_of_val(holding));
|
||||
|
||||
BorshSerialize::serialize(holding, &mut data)
|
||||
.expect("Serialization to Vec should not fail");
|
||||
|
||||
Data::try_from(data).expect("Token holding encoded data should fit into Data")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct NewTokenMetadata {
|
||||
/// Metadata standard.
|
||||
pub standard: MetadataStandard,
|
||||
/// Pointer to off-chain metadata
|
||||
pub uri: String,
|
||||
/// Creators of the token.
|
||||
pub creators: String,
|
||||
}
|
||||
|
||||
#[account_type]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
|
||||
pub struct TokenMetadata {
|
||||
/// Token Definition account id.
|
||||
pub definition_id: AccountId,
|
||||
/// Metadata standard .
|
||||
pub standard: MetadataStandard,
|
||||
/// Pointer to off-chain metadata.
|
||||
pub uri: String,
|
||||
/// Creators of the token.
|
||||
pub creators: String,
|
||||
/// Block id of primary sale.
|
||||
pub primary_sale_date: u64,
|
||||
}
|
||||
|
||||
/// Metadata standard defining the expected format of JSON located off-chain.
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
|
||||
pub enum MetadataStandard {
|
||||
Simple,
|
||||
Expanded,
|
||||
}
|
||||
|
||||
impl TryFrom<&Data> for TokenMetadata {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from(data: &Data) -> Result<Self, Self::Error> {
|
||||
TokenMetadata::try_from_slice(data.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&TokenMetadata> for Data {
|
||||
fn from(metadata: &TokenMetadata) -> Self {
|
||||
// Using size_of_val as size hint for Vec allocation
|
||||
let mut data = Vec::with_capacity(std::mem::size_of_val(metadata));
|
||||
|
||||
BorshSerialize::serialize(metadata, &mut data)
|
||||
.expect("Serialization to Vec should not fail");
|
||||
|
||||
Data::try_from(data).expect("Token metadata encoded data should fit into Data")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "token-methods"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
risc0-build = "=3.0.5"
|
||||
|
||||
[dependencies]
|
||||
risc0-zkvm = { version = "=3.0.5", features = ["std"] }
|
||||
token_core = { path = "../core" }
|
||||
|
||||
[package.metadata.risc0]
|
||||
methods = ["guest"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
risc0_build::embed_methods();
|
||||
}
|
||||
Generated
+4034
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
[package]
|
||||
name = "token-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[workspace]
|
||||
|
||||
[lints.rust]
|
||||
rust_2018_idioms = { level = "deny", priority = -1 }
|
||||
# deny (not forbid) so a targeted per-item #[allow] remains possible if ever needed
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
# Deny only the groups where a new lint should always be a hard error.
|
||||
# style/pedantic lints default to warn so toolchain upgrades don't break the
|
||||
# build unexpectedly — they can be evaluated and addressed at our own pace.
|
||||
correctness = { level = "deny", priority = -1 }
|
||||
suspicious = { level = "deny", priority = -1 }
|
||||
perf = { level = "deny", priority = -1 }
|
||||
style = { level = "warn", priority = -1 }
|
||||
|
||||
# Generated-code / placeholder blockers.
|
||||
dbg_macro = "deny"
|
||||
todo = "deny"
|
||||
unimplemented = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
# Lint suppression hygiene.
|
||||
allow_attributes = "warn"
|
||||
allow_attributes_without_reason = "deny"
|
||||
|
||||
# Determinism, panic-safety, and arithmetic correctness.
|
||||
arithmetic_side_effects = "deny"
|
||||
indexing_slicing = "deny"
|
||||
|
||||
# Cast discipline.
|
||||
as_conversions = "deny"
|
||||
cast_possible_truncation = "deny"
|
||||
cast_possible_wrap = "deny"
|
||||
cast_sign_loss = "deny"
|
||||
|
||||
# API and enum evolution.
|
||||
large_enum_variant = "deny"
|
||||
wildcard_enum_match_arm = "deny"
|
||||
|
||||
# Too noisy for this codebase unless enforced selectively.
|
||||
module_name_repetitions = "allow"
|
||||
similar_names = "allow"
|
||||
|
||||
[[bin]]
|
||||
name = "token"
|
||||
path = "src/bin/token.rs"
|
||||
|
||||
[dependencies]
|
||||
spel-framework = { git = "https://github.com/logos-co/spel.git", tag = "v0.3.0", package = "spel-framework" }
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" }
|
||||
risc0-zkvm = { version = "=3.0.5", default-features = false }
|
||||
token_core = { path = "../../core" }
|
||||
token_program = { path = "../..", package = "token_program" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
borsh = "1.5"
|
||||
@@ -0,0 +1,140 @@
|
||||
#![cfg_attr(not(test), no_main)]
|
||||
|
||||
use spel_framework::prelude::*;
|
||||
use spel_framework::context::ProgramContext;
|
||||
use nssa_core::account::AccountWithMetadata;
|
||||
|
||||
#[cfg(not(test))]
|
||||
risc0_zkvm::guest::entry!(main);
|
||||
|
||||
#[lez_program(instruction = "token_core::Instruction")]
|
||||
mod token {
|
||||
#[expect(
|
||||
unused_imports,
|
||||
reason = "SPEL instruction macro requires importing parent-scope handler types"
|
||||
)]
|
||||
use super::*;
|
||||
|
||||
/// Transfer tokens from sender to recipient.
|
||||
/// Fresh public recipients must be explicitly authorized in the same transaction.
|
||||
#[instruction]
|
||||
pub fn transfer(
|
||||
sender: AccountWithMetadata,
|
||||
recipient: AccountWithMetadata,
|
||||
amount_to_transfer: u128,
|
||||
) -> SpelResult {
|
||||
Ok(spel_framework::SpelOutput::execute(token_program::transfer::transfer(
|
||||
sender,
|
||||
recipient,
|
||||
amount_to_transfer,
|
||||
), vec![]))
|
||||
}
|
||||
|
||||
/// Create a new fungible token definition without metadata.
|
||||
/// Definition and holding targets must be uninitialized and authorized.
|
||||
#[instruction]
|
||||
pub fn new_fungible_definition(
|
||||
definition_target_account: AccountWithMetadata,
|
||||
holding_target_account: AccountWithMetadata,
|
||||
name: String,
|
||||
total_supply: u128,
|
||||
) -> SpelResult {
|
||||
Ok(spel_framework::SpelOutput::execute(
|
||||
token_program::new_definition::new_fungible_definition(
|
||||
definition_target_account,
|
||||
holding_target_account,
|
||||
name,
|
||||
total_supply,
|
||||
),
|
||||
vec![],
|
||||
))
|
||||
}
|
||||
|
||||
/// Create a new fungible or non-fungible token definition with metadata.
|
||||
/// Definition, holding, and metadata targets must be uninitialized and authorized.
|
||||
#[expect(
|
||||
clippy::boxed_local,
|
||||
reason = "boxed metadata keeps the instruction argument size bounded on the stack"
|
||||
)]
|
||||
#[instruction]
|
||||
pub fn new_definition_with_metadata(
|
||||
definition_target_account: AccountWithMetadata,
|
||||
holding_target_account: AccountWithMetadata,
|
||||
metadata_target_account: AccountWithMetadata,
|
||||
new_definition: token_core::NewTokenDefinition,
|
||||
metadata: Box<token_core::NewTokenMetadata>,
|
||||
) -> SpelResult {
|
||||
Ok(spel_framework::SpelOutput::execute(
|
||||
token_program::new_definition::new_definition_with_metadata(
|
||||
definition_target_account,
|
||||
holding_target_account,
|
||||
metadata_target_account,
|
||||
new_definition,
|
||||
*metadata,
|
||||
),
|
||||
vec![],
|
||||
))
|
||||
}
|
||||
|
||||
/// Initialize a token holding account for a given token definition.
|
||||
/// The holding target must be uninitialized and authorized.
|
||||
#[instruction]
|
||||
pub fn initialize_account(
|
||||
ctx: ProgramContext,
|
||||
definition_account: AccountWithMetadata,
|
||||
account_to_initialize: AccountWithMetadata,
|
||||
) -> SpelResult {
|
||||
Ok(spel_framework::SpelOutput::execute(
|
||||
token_program::initialize::initialize_account(
|
||||
definition_account,
|
||||
account_to_initialize,
|
||||
ctx.self_program_id,
|
||||
),
|
||||
vec![],
|
||||
))
|
||||
}
|
||||
|
||||
/// Burn tokens from the holder's account.
|
||||
#[instruction]
|
||||
pub fn burn(
|
||||
definition_account: AccountWithMetadata,
|
||||
user_holding_account: AccountWithMetadata,
|
||||
amount_to_burn: u128,
|
||||
) -> SpelResult {
|
||||
Ok(spel_framework::SpelOutput::execute(token_program::burn::burn(
|
||||
definition_account,
|
||||
user_holding_account,
|
||||
amount_to_burn,
|
||||
), vec![]))
|
||||
}
|
||||
|
||||
/// Mint new tokens to the holder's account.
|
||||
/// Fresh public holders must be explicitly authorized in the same transaction.
|
||||
#[instruction]
|
||||
pub fn mint(
|
||||
ctx: ProgramContext,
|
||||
definition_account: AccountWithMetadata,
|
||||
user_holding_account: AccountWithMetadata,
|
||||
amount_to_mint: u128,
|
||||
) -> SpelResult {
|
||||
Ok(spel_framework::SpelOutput::execute(token_program::mint::mint(
|
||||
definition_account,
|
||||
user_holding_account,
|
||||
amount_to_mint,
|
||||
ctx.self_program_id,
|
||||
), vec![]))
|
||||
}
|
||||
|
||||
/// Print a new NFT from the master copy.
|
||||
/// The printed copy target must be uninitialized and authorized.
|
||||
#[instruction]
|
||||
pub fn print_nft(
|
||||
master_account: AccountWithMetadata,
|
||||
printed_account: AccountWithMetadata,
|
||||
) -> SpelResult {
|
||||
Ok(spel_framework::SpelOutput::execute(token_program::print_nft::print_nft(
|
||||
master_account,
|
||||
printed_account,
|
||||
), vec![]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
include!(concat!(env!("OUT_DIR"), "/methods.rs"));
|
||||
@@ -0,0 +1,104 @@
|
||||
use nssa_core::{
|
||||
account::{AccountWithMetadata, Data},
|
||||
program::AccountPostState,
|
||||
};
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
pub fn burn(
|
||||
definition_account: AccountWithMetadata,
|
||||
user_holding_account: AccountWithMetadata,
|
||||
amount_to_burn: u128,
|
||||
) -> Vec<AccountPostState> {
|
||||
assert!(
|
||||
user_holding_account.is_authorized,
|
||||
"Authorization is missing"
|
||||
);
|
||||
|
||||
let mut definition = TokenDefinition::try_from(&definition_account.account.data)
|
||||
.expect("Token Definition account must be valid");
|
||||
let mut holding = TokenHolding::try_from(&user_holding_account.account.data)
|
||||
.expect("Token Holding account must be valid");
|
||||
|
||||
assert_eq!(
|
||||
definition_account.account_id,
|
||||
holding.definition_id(),
|
||||
"Mismatch Token Definition and Token Holding"
|
||||
);
|
||||
|
||||
match (&mut definition, &mut holding) {
|
||||
(
|
||||
TokenDefinition::Fungible {
|
||||
name: _,
|
||||
metadata_id: _,
|
||||
total_supply,
|
||||
},
|
||||
TokenHolding::Fungible {
|
||||
definition_id: _,
|
||||
balance,
|
||||
},
|
||||
) => {
|
||||
*balance = balance
|
||||
.checked_sub(amount_to_burn)
|
||||
.expect("Insufficient balance to burn");
|
||||
|
||||
*total_supply = total_supply
|
||||
.checked_sub(amount_to_burn)
|
||||
.expect("Total supply underflow");
|
||||
}
|
||||
(
|
||||
TokenDefinition::NonFungible {
|
||||
name: _,
|
||||
printable_supply,
|
||||
metadata_id: _,
|
||||
},
|
||||
TokenHolding::NftMaster {
|
||||
definition_id: _,
|
||||
print_balance,
|
||||
},
|
||||
) => {
|
||||
*printable_supply = printable_supply
|
||||
.checked_sub(amount_to_burn)
|
||||
.expect("Printable supply underflow");
|
||||
|
||||
*print_balance = print_balance
|
||||
.checked_sub(amount_to_burn)
|
||||
.expect("Insufficient balance to burn");
|
||||
}
|
||||
(
|
||||
TokenDefinition::NonFungible {
|
||||
name: _,
|
||||
printable_supply,
|
||||
metadata_id: _,
|
||||
},
|
||||
TokenHolding::NftPrintedCopy {
|
||||
definition_id: _,
|
||||
owned,
|
||||
},
|
||||
) => {
|
||||
assert_eq!(
|
||||
amount_to_burn, 1,
|
||||
"Invalid balance to burn for NFT Printed Copy"
|
||||
);
|
||||
|
||||
assert!(*owned, "Cannot burn unowned NFT Printed Copy");
|
||||
|
||||
*printable_supply = printable_supply
|
||||
.checked_sub(1)
|
||||
.expect("Printable supply underflow");
|
||||
|
||||
*owned = false;
|
||||
}
|
||||
_ => panic!("Mismatched Token Definition and Token Holding types"),
|
||||
}
|
||||
|
||||
let mut definition_post = definition_account.account;
|
||||
definition_post.data = Data::from(&definition);
|
||||
|
||||
let mut holding_post = user_holding_account.account;
|
||||
holding_post.data = Data::from(&holding);
|
||||
|
||||
vec![
|
||||
AccountPostState::new(definition_post),
|
||||
AccountPostState::new(holding_post),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata, Data},
|
||||
program::{AccountPostState, Claim, ProgramId},
|
||||
};
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
pub fn initialize_account(
|
||||
definition_account: AccountWithMetadata,
|
||||
account_to_initialize: AccountWithMetadata,
|
||||
token_program_id: ProgramId,
|
||||
) -> Vec<AccountPostState> {
|
||||
assert_eq!(
|
||||
account_to_initialize.account,
|
||||
Account::default(),
|
||||
"Only Uninitialized accounts can be initialized"
|
||||
);
|
||||
assert!(
|
||||
account_to_initialize.is_authorized,
|
||||
"Account to initialize must be authorized"
|
||||
);
|
||||
assert_eq!(
|
||||
definition_account.account.program_owner, token_program_id,
|
||||
"Token definition must be owned by token program"
|
||||
);
|
||||
|
||||
let definition = TokenDefinition::try_from(&definition_account.account.data)
|
||||
.expect("Definition account must be valid");
|
||||
let holding =
|
||||
TokenHolding::zeroized_from_definition(definition_account.account_id, &definition);
|
||||
|
||||
let definition_post = definition_account.account;
|
||||
let mut account_to_initialize = account_to_initialize.account;
|
||||
account_to_initialize.data = Data::from(&holding);
|
||||
|
||||
vec![
|
||||
AccountPostState::new(definition_post),
|
||||
AccountPostState::new_claimed(account_to_initialize, Claim::Authorized),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! The Token Program implementation.
|
||||
|
||||
pub use token_core as core;
|
||||
|
||||
pub mod burn;
|
||||
pub mod initialize;
|
||||
pub mod mint;
|
||||
pub mod new_definition;
|
||||
pub mod print_nft;
|
||||
pub mod transfer;
|
||||
|
||||
mod tests;
|
||||
@@ -0,0 +1,76 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata, Data},
|
||||
program::{AccountPostState, Claim, ProgramId},
|
||||
};
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
pub fn mint(
|
||||
definition_account: AccountWithMetadata,
|
||||
user_holding_account: AccountWithMetadata,
|
||||
amount_to_mint: u128,
|
||||
token_program_id: ProgramId,
|
||||
) -> Vec<AccountPostState> {
|
||||
assert!(
|
||||
definition_account.is_authorized,
|
||||
"Definition authorization is missing"
|
||||
);
|
||||
assert_eq!(
|
||||
definition_account.account.program_owner, token_program_id,
|
||||
"Token definition must be owned by token program"
|
||||
);
|
||||
|
||||
let mut definition = TokenDefinition::try_from(&definition_account.account.data)
|
||||
.expect("Token Definition account must be valid");
|
||||
let mut holding = if user_holding_account.account == Account::default() {
|
||||
TokenHolding::zeroized_from_definition(definition_account.account_id, &definition)
|
||||
} else {
|
||||
TokenHolding::try_from(&user_holding_account.account.data)
|
||||
.expect("Token Holding account must be valid")
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
definition_account.account_id,
|
||||
holding.definition_id(),
|
||||
"Mismatch Token Definition and Token Holding"
|
||||
);
|
||||
|
||||
match (&mut definition, &mut holding) {
|
||||
(
|
||||
TokenDefinition::Fungible {
|
||||
name: _,
|
||||
metadata_id: _,
|
||||
total_supply,
|
||||
},
|
||||
TokenHolding::Fungible {
|
||||
definition_id: _,
|
||||
balance,
|
||||
},
|
||||
) => {
|
||||
*balance = balance
|
||||
.checked_add(amount_to_mint)
|
||||
.expect("Balance overflow on minting");
|
||||
|
||||
*total_supply = total_supply
|
||||
.checked_add(amount_to_mint)
|
||||
.expect("Total supply overflow");
|
||||
}
|
||||
(
|
||||
TokenDefinition::NonFungible { .. },
|
||||
TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. },
|
||||
) => {
|
||||
panic!("Cannot mint additional supply for Non-Fungible Tokens");
|
||||
}
|
||||
_ => panic!("Mismatched Token Definition and Token Holding types"),
|
||||
}
|
||||
|
||||
let mut definition_post = definition_account.account;
|
||||
definition_post.data = Data::from(&definition);
|
||||
|
||||
let mut holding_post = user_holding_account.account;
|
||||
holding_post.data = Data::from(&holding);
|
||||
|
||||
vec![
|
||||
AccountPostState::new(definition_post),
|
||||
AccountPostState::new_claimed_if_default(holding_post, Claim::Authorized),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata, Data},
|
||||
program::{AccountPostState, Claim},
|
||||
};
|
||||
use token_core::{
|
||||
NewTokenDefinition, NewTokenMetadata, TokenDefinition, TokenHolding, TokenMetadata,
|
||||
};
|
||||
|
||||
pub fn new_fungible_definition(
|
||||
definition_target_account: AccountWithMetadata,
|
||||
holding_target_account: AccountWithMetadata,
|
||||
name: String,
|
||||
total_supply: u128,
|
||||
) -> Vec<AccountPostState> {
|
||||
assert_eq!(
|
||||
definition_target_account.account,
|
||||
Account::default(),
|
||||
"Definition target account must have default values"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
holding_target_account.account,
|
||||
Account::default(),
|
||||
"Holding target account must have default values"
|
||||
);
|
||||
assert!(
|
||||
definition_target_account.is_authorized,
|
||||
"Definition target account must be authorized"
|
||||
);
|
||||
assert!(
|
||||
holding_target_account.is_authorized,
|
||||
"Holding target account must be authorized"
|
||||
);
|
||||
|
||||
let token_definition = TokenDefinition::Fungible {
|
||||
name,
|
||||
total_supply,
|
||||
metadata_id: None,
|
||||
};
|
||||
let token_holding = TokenHolding::Fungible {
|
||||
definition_id: definition_target_account.account_id,
|
||||
balance: total_supply,
|
||||
};
|
||||
|
||||
let mut definition_target_account_post = definition_target_account.account;
|
||||
definition_target_account_post.data = Data::from(&token_definition);
|
||||
|
||||
let mut holding_target_account_post = holding_target_account.account;
|
||||
holding_target_account_post.data = Data::from(&token_holding);
|
||||
|
||||
vec![
|
||||
AccountPostState::new_claimed(definition_target_account_post, Claim::Authorized),
|
||||
AccountPostState::new_claimed(holding_target_account_post, Claim::Authorized),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn new_definition_with_metadata(
|
||||
definition_target_account: AccountWithMetadata,
|
||||
holding_target_account: AccountWithMetadata,
|
||||
metadata_target_account: AccountWithMetadata,
|
||||
new_definition: NewTokenDefinition,
|
||||
metadata: NewTokenMetadata,
|
||||
) -> Vec<AccountPostState> {
|
||||
assert_eq!(
|
||||
definition_target_account.account,
|
||||
Account::default(),
|
||||
"Definition target account must have default values"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
holding_target_account.account,
|
||||
Account::default(),
|
||||
"Holding target account must have default values"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
metadata_target_account.account,
|
||||
Account::default(),
|
||||
"Metadata target account must have default values"
|
||||
);
|
||||
assert!(
|
||||
definition_target_account.is_authorized,
|
||||
"Definition target account must be authorized"
|
||||
);
|
||||
assert!(
|
||||
holding_target_account.is_authorized,
|
||||
"Holding target account must be authorized"
|
||||
);
|
||||
assert!(
|
||||
metadata_target_account.is_authorized,
|
||||
"Metadata target account must be authorized"
|
||||
);
|
||||
|
||||
let (token_definition, token_holding) = match new_definition {
|
||||
NewTokenDefinition::Fungible { name, total_supply } => (
|
||||
TokenDefinition::Fungible {
|
||||
name,
|
||||
total_supply,
|
||||
metadata_id: Some(metadata_target_account.account_id),
|
||||
},
|
||||
TokenHolding::Fungible {
|
||||
definition_id: definition_target_account.account_id,
|
||||
balance: total_supply,
|
||||
},
|
||||
),
|
||||
NewTokenDefinition::NonFungible {
|
||||
name,
|
||||
printable_supply,
|
||||
} => (
|
||||
TokenDefinition::NonFungible {
|
||||
name,
|
||||
printable_supply,
|
||||
metadata_id: metadata_target_account.account_id,
|
||||
},
|
||||
TokenHolding::NftMaster {
|
||||
definition_id: definition_target_account.account_id,
|
||||
print_balance: printable_supply,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
let token_metadata = TokenMetadata {
|
||||
definition_id: definition_target_account.account_id,
|
||||
standard: metadata.standard,
|
||||
uri: metadata.uri,
|
||||
creators: metadata.creators,
|
||||
primary_sale_date: 0u64, // TODO #261: future works to implement this
|
||||
};
|
||||
|
||||
let mut definition_target_account_post = definition_target_account.account.clone();
|
||||
definition_target_account_post.data = Data::from(&token_definition);
|
||||
|
||||
let mut holding_target_account_post = holding_target_account.account.clone();
|
||||
holding_target_account_post.data = Data::from(&token_holding);
|
||||
|
||||
let mut metadata_target_account_post = metadata_target_account.account.clone();
|
||||
metadata_target_account_post.data = Data::from(&token_metadata);
|
||||
|
||||
vec![
|
||||
AccountPostState::new_claimed(definition_target_account_post, Claim::Authorized),
|
||||
AccountPostState::new_claimed(holding_target_account_post, Claim::Authorized),
|
||||
AccountPostState::new_claimed(metadata_target_account_post, Claim::Authorized),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata, Data},
|
||||
program::{AccountPostState, Claim},
|
||||
};
|
||||
use token_core::TokenHolding;
|
||||
|
||||
pub fn print_nft(
|
||||
master_account: AccountWithMetadata,
|
||||
printed_account: AccountWithMetadata,
|
||||
) -> Vec<AccountPostState> {
|
||||
assert!(
|
||||
master_account.is_authorized,
|
||||
"Master NFT Account must be authorized"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
printed_account.account,
|
||||
Account::default(),
|
||||
"Printed Account must be uninitialized"
|
||||
);
|
||||
assert!(
|
||||
printed_account.is_authorized,
|
||||
"Printed Account must be authorized"
|
||||
);
|
||||
|
||||
let mut master_account_data =
|
||||
TokenHolding::try_from(&master_account.account.data).expect("Invalid Token Holding data");
|
||||
|
||||
let TokenHolding::NftMaster {
|
||||
definition_id,
|
||||
print_balance,
|
||||
} = &mut master_account_data
|
||||
else {
|
||||
panic!("Invalid Token Holding provided as NFT Master Account");
|
||||
};
|
||||
|
||||
let definition_id = *definition_id;
|
||||
|
||||
assert!(
|
||||
*print_balance > 1,
|
||||
"Insufficient balance to print another NFT copy"
|
||||
);
|
||||
*print_balance = print_balance
|
||||
.checked_sub(1)
|
||||
.expect("print balance must be greater than one after validation");
|
||||
|
||||
let mut master_account_post = master_account.account;
|
||||
master_account_post.data = Data::from(&master_account_data);
|
||||
|
||||
let mut printed_account_post = printed_account.account;
|
||||
printed_account_post.data = Data::from(&TokenHolding::NftPrintedCopy {
|
||||
definition_id,
|
||||
owned: true,
|
||||
});
|
||||
|
||||
vec![
|
||||
AccountPostState::new(master_account_post),
|
||||
AccountPostState::new_claimed(printed_account_post, Claim::Authorized),
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata, Data},
|
||||
program::{AccountPostState, Claim},
|
||||
};
|
||||
use token_core::TokenHolding;
|
||||
|
||||
pub fn transfer(
|
||||
sender: AccountWithMetadata,
|
||||
recipient: AccountWithMetadata,
|
||||
balance_to_move: u128,
|
||||
) -> Vec<AccountPostState> {
|
||||
assert!(sender.is_authorized, "Sender authorization is missing");
|
||||
|
||||
let mut sender_holding =
|
||||
TokenHolding::try_from(&sender.account.data).expect("Invalid sender data");
|
||||
|
||||
let mut recipient_holding = if recipient.account == Account::default() {
|
||||
TokenHolding::zeroized_clone_from(&sender_holding)
|
||||
} else {
|
||||
TokenHolding::try_from(&recipient.account.data).expect("Invalid recipient data")
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
sender_holding.definition_id(),
|
||||
recipient_holding.definition_id(),
|
||||
"Sender and recipient definition id mismatch"
|
||||
);
|
||||
|
||||
match (&mut sender_holding, &mut recipient_holding) {
|
||||
(
|
||||
TokenHolding::Fungible {
|
||||
definition_id: _,
|
||||
balance: sender_balance,
|
||||
},
|
||||
TokenHolding::Fungible {
|
||||
definition_id: _,
|
||||
balance: recipient_balance,
|
||||
},
|
||||
) => {
|
||||
*sender_balance = sender_balance
|
||||
.checked_sub(balance_to_move)
|
||||
.expect("Insufficient balance");
|
||||
|
||||
*recipient_balance = recipient_balance
|
||||
.checked_add(balance_to_move)
|
||||
.expect("Recipient balance overflow");
|
||||
}
|
||||
(
|
||||
TokenHolding::NftMaster {
|
||||
definition_id: _,
|
||||
print_balance: sender_print_balance,
|
||||
},
|
||||
TokenHolding::NftMaster {
|
||||
definition_id: _,
|
||||
print_balance: recipient_print_balance,
|
||||
},
|
||||
) => {
|
||||
assert_eq!(
|
||||
*recipient_print_balance, 0,
|
||||
"Invalid balance in recipient account for NFT transfer"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
*sender_print_balance, balance_to_move,
|
||||
"Invalid balance for NFT Master transfer"
|
||||
);
|
||||
|
||||
std::mem::swap(sender_print_balance, recipient_print_balance);
|
||||
}
|
||||
(
|
||||
TokenHolding::NftPrintedCopy {
|
||||
definition_id: _,
|
||||
owned: sender_owned,
|
||||
},
|
||||
TokenHolding::NftPrintedCopy {
|
||||
definition_id: _,
|
||||
owned: recipient_owned,
|
||||
},
|
||||
) => {
|
||||
assert_eq!(
|
||||
balance_to_move, 1,
|
||||
"Invalid balance for NFT Printed Copy transfer"
|
||||
);
|
||||
|
||||
assert!(*sender_owned, "Sender does not own the NFT Printed Copy");
|
||||
|
||||
assert!(
|
||||
!*recipient_owned,
|
||||
"Recipient already owns the NFT Printed Copy"
|
||||
);
|
||||
|
||||
*sender_owned = false;
|
||||
*recipient_owned = true;
|
||||
}
|
||||
_ => {
|
||||
panic!("Mismatched token holding types for transfer");
|
||||
}
|
||||
};
|
||||
|
||||
let mut sender_post = sender.account;
|
||||
sender_post.data = Data::from(&sender_holding);
|
||||
|
||||
let mut recipient_post = recipient.account;
|
||||
recipient_post.data = Data::from(&recipient_holding);
|
||||
|
||||
vec![
|
||||
AccountPostState::new(sender_post),
|
||||
AccountPostState::new_claimed_if_default(recipient_post, Claim::Authorized),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "twap_oracle_program"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] }
|
||||
twap_oracle_core = { path = "core" }
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "twap_oracle_core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
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"] }
|
||||
spel-framework-macros = { git = "https://github.com/logos-co/spel.git", tag = "v0.3.0", package = "spel-framework-macros" }
|
||||
@@ -0,0 +1,53 @@
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use nssa_core::account::{AccountId, Data};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use spel_framework_macros::account_type;
|
||||
|
||||
/// TWAP Oracle Program Instruction.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// No-op instruction. Does nothing and returns no state changes.
|
||||
Noop,
|
||||
}
|
||||
|
||||
/// Canonical oracle price account consumed by LEZ programs.
|
||||
///
|
||||
/// Oracle producers own how this account is written; consumers only read and validate it.
|
||||
#[account_type]
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
|
||||
pub struct OraclePriceAccount {
|
||||
/// Canonical identifier for the priced asset.
|
||||
pub base_asset: AccountId,
|
||||
/// Canonical identifier for the quote asset that denominates `price`.
|
||||
pub quote_asset: AccountId,
|
||||
/// Amount of `quote_asset` one unit of `base_asset` is worth.
|
||||
///
|
||||
/// `u128` keeps the consumer-side interface non-negative; zero is rejected on read.
|
||||
pub price: u128,
|
||||
/// Price observation timestamp. Consumers choose the time unit by matching this with
|
||||
/// `max_age`.
|
||||
pub timestamp: u64,
|
||||
/// Identifier of the source that populated this account, such as a TWAP or external adaptor.
|
||||
pub source_id: String,
|
||||
/// Source-provided confidence interval, or zero when the source does not provide one.
|
||||
pub confidence_interval: u128,
|
||||
}
|
||||
|
||||
impl TryFrom<&Data> for OraclePriceAccount {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from(data: &Data) -> Result<Self, Self::Error> {
|
||||
Self::try_from_slice(data.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&OraclePriceAccount> for Data {
|
||||
fn from(price_account: &OraclePriceAccount) -> Self {
|
||||
let serialized_len =
|
||||
borsh::object_length(price_account).expect("Oracle price account length must be known");
|
||||
let mut data = Vec::with_capacity(serialized_len);
|
||||
BorshSerialize::serialize(price_account, &mut data)
|
||||
.expect("Serialization to Vec should not fail");
|
||||
Self::try_from(data).expect("Oracle price account encoded data should fit into Data")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "twap-oracle-methods"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
risc0-build = "=3.0.5"
|
||||
|
||||
[dependencies]
|
||||
risc0-zkvm = { version = "=3.0.5", features = ["std"] }
|
||||
twap_oracle_core = { path = "../core" }
|
||||
|
||||
[package.metadata.risc0]
|
||||
methods = ["guest"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
risc0_build::embed_methods();
|
||||
}
|
||||
+4034
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "twap-oracle-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[workspace]
|
||||
|
||||
[[bin]]
|
||||
name = "twap_oracle"
|
||||
path = "src/bin/twap_oracle.rs"
|
||||
|
||||
[dependencies]
|
||||
spel-framework = { git = "https://github.com/logos-co/spel.git", tag = "v0.3.0", package = "spel-framework" }
|
||||
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3" }
|
||||
risc0-zkvm = { version = "=3.0.5", default-features = false }
|
||||
twap_oracle_core = { path = "../../core" }
|
||||
twap_oracle_program = { path = "../..", package = "twap_oracle_program" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
borsh = "1.5"
|
||||
@@ -0,0 +1,18 @@
|
||||
#![cfg_attr(not(test), no_main)]
|
||||
|
||||
use spel_framework::prelude::*;
|
||||
|
||||
#[cfg(not(test))]
|
||||
risc0_zkvm::guest::entry!(main);
|
||||
|
||||
#[lez_program(instruction = "twap_oracle_core::Instruction")]
|
||||
mod twap_oracle {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
|
||||
/// No-op instruction. Does nothing and returns no state changes.
|
||||
#[instruction]
|
||||
pub fn noop() -> SpelResult {
|
||||
Ok(spel_framework::SpelOutput::execute(twap_oracle_program::noop::noop(), vec![]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
include!(concat!(env!("OUT_DIR"), "/methods.rs"));
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The TWAP Oracle Program implementation.
|
||||
|
||||
pub use twap_oracle_core as core;
|
||||
|
||||
pub mod noop;
|
||||
@@ -0,0 +1,5 @@
|
||||
use nssa_core::program::AccountPostState;
|
||||
|
||||
pub fn noop() -> Vec<AccountPostState> {
|
||||
vec![]
|
||||
}
|
||||
Reference in New Issue
Block a user