feat(amm): add SyncReserves instruction

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.
This commit is contained in:
Ricardo Guilherme Schmidt 2026-04-08 10:57:47 -03:00 committed by r4bbit
parent fddd6e15bd
commit e61cd594b5
9 changed files with 241 additions and 1 deletions

1
Cargo.lock generated
View File

@ -46,6 +46,7 @@ dependencies = [
"nssa_core",
"risc0-zkvm",
"serde",
"token_core",
]
[[package]]

View File

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

View File

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

View File

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

View File

@ -51,6 +51,7 @@ dependencies = [
"nssa_core",
"risc0-zkvm",
"serde",
"token_core",
]
[[package]]

View File

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

View File

@ -6,5 +6,6 @@ pub mod add;
pub mod new_definition;
pub mod remove;
pub mod swap;
pub mod sync;
mod tests;

52
amm/src/sync.rs Normal file
View File

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

View File

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