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:
r4bbit
2026-05-26 14:05:52 +02:00
parent cdb53a4d0c
commit 3622016e6c
109 changed files with 97 additions and 65 deletions
+12
View File
@@ -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" }
+15
View File
@@ -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 }
+312
View File
@@ -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)
}
+17
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
risc0_build::embed_methods();
}
File diff suppressed because it is too large Load Diff
+62
View File
@@ -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"
+206
View File
@@ -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))
}
}
+1
View File
@@ -0,0 +1 @@
include!(concat!(env!("OUT_DIR"), "/methods.rs"));
+200
View File
@@ -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)
}
+11
View File
@@ -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;
+213
View File
@@ -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)
}
+217
View File
@@ -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)
}
+424
View File
@@ -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)
}
+58
View File
@@ -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
+12
View File
@@ -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" }
+13
View File
@@ -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 }
+101
View File
@@ -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
}
+17
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
risc0_build::embed_methods();
}
File diff suppressed because it is too large Load Diff
+62
View File
@@ -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"
+89
View File
@@ -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))
}
}
+1
View File
@@ -0,0 +1 @@
include!(concat!(env!("OUT_DIR"), "/methods.rs"));
+56
View File
@@ -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])
}
+69
View File
@@ -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])
}
+10
View File
@@ -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;
+467
View File
@@ -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,
);
}
+71
View File
@@ -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])
}
+19
View File
@@ -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" }
+1
View File
@@ -0,0 +1 @@
File diff suppressed because it is too large Load Diff
+587
View File
@@ -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")
}
}
}
+906
View File
@@ -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());
}
+9
View File
@@ -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" }
+12
View File
@@ -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" }
+214
View File
@@ -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
}
+14
View File
@@ -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"]
+4
View File
@@ -0,0 +1,4 @@
//! Build script that embeds the stablecoin RISC Zero guest ELF as host-side constants.
fn main() {
risc0_build::embed_methods();
}
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,
))
}
}
+12
View File
@@ -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"));
+15
View File
@@ -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;
+125
View File
@@ -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])
}
+126
View File
@@ -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])
}
+973
View File
@@ -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])
}
+11
View File
@@ -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" }
+13
View File
@@ -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"] }
+247
View File
@@ -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")
}
}
+17
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
risc0_build::embed_methods();
}
File diff suppressed because it is too large Load Diff
+61
View File
@@ -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![]))
}
}
+1
View File
@@ -0,0 +1 @@
include!(concat!(env!("OUT_DIR"), "/methods.rs"));
+104
View File
@@ -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),
]
}
+39
View File
@@ -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),
]
}
+12
View File
@@ -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;
+76
View File
@@ -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),
]
}
+144
View File
@@ -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),
]
}
+60
View File
@@ -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
+110
View File
@@ -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),
]
}
+8
View File
@@ -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" }
+10
View File
@@ -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" }
+53
View File
@@ -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")
}
}
+14
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
risc0_build::embed_methods();
}
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![]))
}
}
+1
View File
@@ -0,0 +1 @@
include!(concat!(env!("OUT_DIR"), "/methods.rs"));
+5
View File
@@ -0,0 +1,5 @@
//! The TWAP Oracle Program implementation.
pub use twap_oracle_core as core;
pub mod noop;
+5
View File
@@ -0,0 +1,5 @@
use nssa_core::program::AccountPostState;
pub fn noop() -> Vec<AccountPostState> {
vec![]
}