From e61cd594b532312a8a43d2b4c185d45b9682600e Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 8 Apr 2026 10:57:47 -0300 Subject: [PATCH] feat(amm): add SyncReserves instruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `SyncReserves` instruction that updates a pool's recorded reserves to match the actual vault balances. This allows the pool to absorb donations (direct token transfers to vaults) without breaking the invariant — only upward adjustments are permitted; vaults may not be under-collateralized relative to reserves. Vault reading helpers (`read_fungible_holding`, `read_vault_fungible_balances`) are implemented in `amm_core` so they can be shared across instructions without crossing crate boundaries. --- Cargo.lock | 1 + amm/amm-idl.json | 24 +++++++ amm/core/Cargo.toml | 1 + amm/core/src/lib.rs | 38 ++++++++++- amm/methods/guest/Cargo.lock | 1 + amm/methods/guest/src/bin/amm.rs | 12 ++++ amm/src/lib.rs | 1 + amm/src/sync.rs | 52 ++++++++++++++ amm/src/tests.rs | 112 +++++++++++++++++++++++++++++++ 9 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 amm/src/sync.rs diff --git a/Cargo.lock b/Cargo.lock index ebc7c05..c5cf3d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,7 @@ dependencies = [ "nssa_core", "risc0-zkvm", "serde", + "token_core", ] [[package]] diff --git a/amm/amm-idl.json b/amm/amm-idl.json index 64c9a06..e3b1747 100644 --- a/amm/amm-idl.json +++ b/amm/amm-idl.json @@ -239,6 +239,30 @@ "type": "account_id" } ] + }, + { + "name": "sync_reserves", + "accounts": [ + { + "name": "pool", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "vault_a", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "vault_b", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [] } ], "instruction_type": "amm_core::Instruction" diff --git a/amm/core/Cargo.toml b/amm/core/Cargo.toml index 5852d91..9e33d34 100644 --- a/amm/core/Cargo.toml +++ b/amm/core/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", rev = "ffcbc15972adbf557939bf3e2852af276422631b", features = ["host"] } +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 } diff --git a/amm/core/src/lib.rs b/amm/core/src/lib.rs index c47e234..84977ea 100644 --- a/amm/core/src/lib.rs +++ b/amm/core/src/lib.rs @@ -2,7 +2,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use nssa_core::{ - account::{AccountId, Data}, + account::{AccountId, AccountWithMetadata, Data}, program::{PdaSeed, ProgramId}, }; use serde::{Deserialize, Serialize}; @@ -83,6 +83,14 @@ pub enum Instruction { min_amount_out: u128, token_definition_id_in: AccountId, }, + + /// Sync pool reserves with current vault balances. + /// + /// Required accounts: + /// - AMM Pool (initialized, active) + /// - Vault Holding Account for Token A (initialized) + /// - Vault Holding Account for Token B (initialized) + SyncReserves, } pub const MINIMUM_LIQUIDITY: u128 = 1_000; @@ -230,3 +238,31 @@ pub fn compute_lp_lock_holding_pda_seed(pool_id: AccountId) -> PdaSeed { .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) +} diff --git a/amm/methods/guest/Cargo.lock b/amm/methods/guest/Cargo.lock index e2225c4..3324889 100644 --- a/amm/methods/guest/Cargo.lock +++ b/amm/methods/guest/Cargo.lock @@ -51,6 +51,7 @@ dependencies = [ "nssa_core", "risc0-zkvm", "serde", + "token_core", ] [[package]] diff --git a/amm/methods/guest/src/bin/amm.rs b/amm/methods/guest/src/bin/amm.rs index 52ff58e..5ea0cbb 100644 --- a/amm/methods/guest/src/bin/amm.rs +++ b/amm/methods/guest/src/bin/amm.rs @@ -129,4 +129,16 @@ mod amm { ); Ok(SpelOutput::with_chained_calls(post_states, chained_calls)) } + + /// 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(SpelOutput::with_chained_calls(post_states, chained_calls)) + } } diff --git a/amm/src/lib.rs b/amm/src/lib.rs index e50c738..77a59dd 100644 --- a/amm/src/lib.rs +++ b/amm/src/lib.rs @@ -6,5 +6,6 @@ pub mod add; pub mod new_definition; pub mod remove; pub mod swap; +pub mod sync; mod tests; diff --git a/amm/src/sync.rs b/amm/src/sync.rs new file mode 100644 index 0000000..1a6e00a --- /dev/null +++ b/amm/src/sync.rs @@ -0,0 +1,52 @@ +use amm_core::{read_vault_fungible_balances, PoolDefinition}; +use nssa_core::{ + account::{AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall}, +}; + +pub fn sync_reserves( + pool: AccountWithMetadata, + vault_a: AccountWithMetadata, + vault_b: AccountWithMetadata, +) -> (Vec, Vec) { + let pool_def_data = PoolDefinition::try_from(&pool.account.data) + .expect("Sync reserves: AMM Program expects a valid Pool Definition Account"); + + assert!(pool_def_data.active, "Pool is inactive"); + 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(), + ) +} diff --git a/amm/src/tests.rs b/amm/src/tests.rs index f3f6e8c..341dc4f 100644 --- a/amm/src/tests.rs +++ b/amm/src/tests.rs @@ -14,6 +14,7 @@ use token_core::{TokenDefinition, TokenHolding}; use crate::{ add::add_liquidity, new_definition::new_definition, remove::remove_liquidity, swap::swap, + sync::sync_reserves, }; const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; @@ -2236,3 +2237,114 @@ fn test_minimum_liquidity_lock_and_remove_all_user_lp() { assert!(pool_after_remove.reserve_b > 0); assert!(pool_after_remove.active); } + +#[test] +fn test_sync_reserves_with_donation() { + let pool = AccountWithMetadataForTests::pool_definition_init(); + let donation_a = 111u128; + + let mut donated_vault_a = AccountWithMetadataForTests::vault_a_init(); + donated_vault_a.account.data = Data::from(&TokenHolding::Fungible { + definition_id: IdForTests::token_a_definition_id(), + balance: BalanceForTests::vault_a_reserve_init() + donation_a, + }); + + let pool_pre = PoolDefinition::try_from(&pool.account.data).unwrap(); + assert_eq!(pool_pre.reserve_a, BalanceForTests::vault_a_reserve_init()); + + let (post_states, chained_calls) = sync_reserves( + pool, + donated_vault_a, + AccountWithMetadataForTests::vault_b_init(), + ); + assert!(chained_calls.is_empty()); + + let pool_post = PoolDefinition::try_from(&post_states[0].account().data).unwrap(); + assert_eq!( + pool_post.reserve_a, + BalanceForTests::vault_a_reserve_init() + donation_a + ); + assert_eq!(pool_post.reserve_b, BalanceForTests::vault_b_reserve_init()); +} + +#[should_panic(expected = "Sync reserves: vault A balance is less than its reserve")] +#[test] +fn test_sync_reserves_panics_when_vault_a_under_collateralized() { + let _ = sync_reserves( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init_low(), + AccountWithMetadataForTests::vault_b_init(), + ); +} + +#[should_panic(expected = "Sync reserves: vault B balance is less than its reserve")] +#[test] +fn test_sync_reserves_panics_when_vault_b_under_collateralized() { + let _ = sync_reserves( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init_low(), + ); +} + +#[test] +fn test_donation_then_add_liquidity_sync_mitigates_mispricing() { + let donation_a = 100u128; + + let mut donated_vault_a = AccountWithMetadataForTests::vault_a_init(); + donated_vault_a.account.data = Data::from(&TokenHolding::Fungible { + definition_id: IdForTests::token_a_definition_id(), + balance: BalanceForTests::vault_a_reserve_init() + donation_a, + }); + let donated_vault_b = AccountWithMetadataForTests::vault_b_init(); + + let (post_unsynced, _) = add_liquidity( + AccountWithMetadataForTests::pool_definition_init(), + donated_vault_a.clone(), + donated_vault_b.clone(), + AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::user_holding_lp_init(), + NonZero::new(1).unwrap(), + 100, + 50, + ); + let unsynced_pool_post = PoolDefinition::try_from(&post_unsynced[0].account().data).unwrap(); + let unsynced_delta_lp = + unsynced_pool_post.liquidity_pool_supply - BalanceForTests::lp_supply_init(); + + let donated_vault_a_for_synced_add = donated_vault_a.clone(); + let donated_vault_b_for_synced_add = donated_vault_b.clone(); + + let (sync_post, _) = sync_reserves( + AccountWithMetadataForTests::pool_definition_init(), + donated_vault_a, + donated_vault_b, + ); + let synced_pool = AccountWithMetadata { + account: sync_post[0].account().clone(), + is_authorized: true, + account_id: IdForTests::pool_definition_id(), + }; + + let (post_synced, _) = add_liquidity( + synced_pool, + donated_vault_a_for_synced_add, + donated_vault_b_for_synced_add, + AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::user_holding_lp_init(), + NonZero::new(1).unwrap(), + 100, + 50, + ); + let synced_pool_post = PoolDefinition::try_from(&post_synced[0].account().data).unwrap(); + let synced_delta_lp = synced_pool_post.liquidity_pool_supply + - PoolDefinition::try_from(&sync_post[0].account().data) + .unwrap() + .liquidity_pool_supply; + + assert!(synced_delta_lp < unsynced_delta_lp); +}