From 57c5b220ae4908ff510e7e5bc4a4d539e81a8033 Mon Sep 17 00:00:00 2001 From: Kachinsky <22611640+kb1ns@users.noreply.github.com> Date: Tue, 1 Jul 2025 15:39:38 +0800 Subject: [PATCH 01/12] add lp_locks --- Cargo.lock | 39 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/canister.rs | 31 ++++++++++++++++++++++++++++++- src/lib.rs | 16 +++++++++++++--- src/pool.rs | 14 +++++++++++++- swap.did | 5 +++++ 6 files changed, 101 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51e7ed4..8d52aa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bip322" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05fd969833f0181470a254c9aed7389537f3fe6068bc8d7bcd9afef1cc7a049" +dependencies = [ + "base64 0.22.1", + "bitcoin", + "snafu", +] + [[package]] name = "bitcoin" version = "0.32.5" @@ -560,6 +571,12 @@ version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -994,6 +1011,7 @@ name = "rich-swap" version = "0.1.0" dependencies = [ "anyhow", + "bip322", "bitcoin-canister", "candid", "cfg-if", @@ -1220,6 +1238,27 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "snafu" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320b01e011bf8d5d7a4a4a4be966d9160968935849c83b918827f6a435e7f627" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1961e2ef424c1424204d3a5d6975f934f56b6d50ff5732382d84ebf460e147f7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "stacker" version = "0.1.19" diff --git a/Cargo.toml b/Cargo.toml index 1072283..809268f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ serde_json = "1.0" serde_bytes = "0.11.17" ordinals = "0.0.14" rust_decimal = "1.37.1" +bip322 = "0.0.9" [features] testnet = [] diff --git a/src/canister.rs b/src/canister.rs index a68b9f6..908d3ee 100644 --- a/src/canister.rs +++ b/src/canister.rs @@ -79,6 +79,34 @@ pub fn recover() { }); } +#[update] +pub fn lock_lp(addr: String, message: String, sig: String) -> Result<(), ExchangeError> { + crate::ensure_online()?; + bip322::verify_simple_encoded(&addr, &message, &sig) + .map_err(|_| ExchangeError::InvalidSignature)?; + let lock: Vec<&str> = message.split(':').collect(); + let pool = lock.get(0).ok_or(ExchangeError::InvalidLockMessage)?; + let lock_until = lock + .get(1) + .and_then(|s| s.parse::().ok()) + .ok_or(ExchangeError::InvalidLockMessage)?; + crate::with_pool_mut(pool.to_string(), |p| { + let mut pool = p.ok_or(ExchangeError::InvalidPool)?; + let state = pool.states.last_mut().ok_or(ExchangeError::EmptyPool)?; + state + .lp_locks + .entry(addr.clone()) + .and_modify(|t| { + if *t < lock_until { + *t = lock_until; + } + }) + .or_insert(lock_until); + Ok(Some(pool)) + })?; + Ok(()) +} + #[query] pub fn get_pool_state_chain( addr: String, @@ -355,7 +383,8 @@ pub fn pre_withdraw_liquidity( crate::ensure_online()?; crate::with_pool(&pool_addr, |p| { let pool = p.as_ref().ok_or(ExchangeError::InvalidPool)?; - let (btc, rune_output, _) = pool.available_to_withdraw(&user_addr, share)?; + let (btc, rune_output, _) = + pool.available_to_withdraw(&user_addr, share, crate::ic_timestamp())?; let state = pool.states.last().expect("already checked"); Ok(WithdrawalOffer { input: state.utxo.clone().expect("already checked"), diff --git a/src/lib.rs b/src/lib.rs index 37be6f0..c297b6a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,4 @@ mod canister; -mod migrate; mod pool; mod psbt; mod reorg; @@ -109,6 +108,12 @@ pub enum ExchangeError { PriceImpactLimitExceeded, #[error("Funds limit exceeded")] FundsLimitExceeded, + #[error("Liquidity locked")] + LiquidityLocked, + #[error("invalid bip-322 signature")] + InvalidSignature, + #[error("invalid lock message")] + InvalidLockMessage, } type Memory = VirtualMemory; @@ -122,6 +127,7 @@ const _POOLS_MEMORY_ID_V2: MemoryId = MemoryId::new(5); // the v3 is token -> addr const POOL_TOKENS_MEMORY_ID: MemoryId = MemoryId::new(7); // the v3 is addr -> pool, notice: 6 is deprecated in the testnet +#[allow(unused)] const POOLS_MEMORY_ID_V4: MemoryId = MemoryId::new(10); const BLOCKS_ID: MemoryId = MemoryId::new(8); @@ -141,8 +147,8 @@ thread_local! { // static POOLS_V2: RefCell> = // RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(POOLS_MEMORY_ID_V2)))); - pub(crate) static POOLS_V4: RefCell> = - RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(POOLS_MEMORY_ID_V4)))); + // pub(crate) static POOLS_V4: RefCell> = + // RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(POOLS_MEMORY_ID_V4)))); pub(crate) static POOLS: RefCell> = RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(POOLS_MEMORY_ID)))); @@ -681,3 +687,7 @@ impl Drop for ExecuteTxGuard { }); } } + +pub(crate) fn ic_timestamp() -> u64 { + ic_cdk::api::time() / 1_000_000_000 +} diff --git a/src/pool.rs b/src/pool.rs index e862404..a16354e 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -74,6 +74,8 @@ pub struct PoolState { pub lp_earnings: BTreeMap, pub total_btc_donation: u64, pub total_rune_donation: u128, + #[serde(default)] + pub lp_locks: BTreeMap, } impl PoolState { @@ -455,8 +457,17 @@ impl LiquidityPool { &self, pubkey_hash: impl AsRef, share: u128, + now: u64, ) -> Result<(u64, CoinBalance, u128), ExchangeError> { let recent_state = self.states.last().ok_or(ExchangeError::EmptyPool)?; + let lock_until = recent_state + .lp_locks + .get(pubkey_hash.as_ref()) + .map(|v| *v) + .unwrap_or(0); + (lock_until < now) + .then(|| ()) + .ok_or(ExchangeError::LiquidityLocked)?; let user_total_share = recent_state.lp(pubkey_hash.as_ref()); (share <= user_total_share) .then(|| ()) @@ -560,7 +571,7 @@ impl LiquidityPool { .map_err(|_| ExchangeError::Overflow)?; let (btc_expecting, rune_expecting, new_share) = - self.available_to_withdraw(&initiator, share)?; + self.available_to_withdraw(&initiator, share, crate::ic_timestamp())?; (rune_expecting == rune_output && btc_expecting == btc_output_sats) .then(|| ()) .ok_or(ExchangeError::InvalidSignPsbtArgs( @@ -598,6 +609,7 @@ impl LiquidityPool { }; state.utxo = new_utxo; state.k -= share; + state.lp_locks.remove(&initiator); if state.utxo.is_none() { state.incomes = 0; state.lp.clear(); diff --git a/swap.did b/swap.did index b47c68b..d4763dc 100644 --- a/swap.did +++ b/swap.did @@ -18,7 +18,9 @@ type ExchangeError = variant { PriceImpactLimitExceeded; RuneIndexerError : text; PoolStateExpired : nat64; + InvalidSignature; TooSmallFunds; + LiquidityLocked; InvalidRuneId; InvalidPool; InvalidPsbt : text; @@ -26,6 +28,7 @@ type ExchangeError = variant { InvalidTxid; InvalidLiquidity; EmptyPool; + InvalidLockMessage; FetchBitcoinCanisterError; LpNotFound; NoConfirmedUtxos; @@ -107,6 +110,7 @@ type PoolState = record { incomes : nat64; total_btc_donation : nat64; nonce : nat64; + lp_locks : vec record { text; nat64 }; }; type Result = variant { Ok : record { nat64; nat64 }; Err : text }; type Result_1 = variant { Ok : text; Err : ExchangeError }; @@ -168,6 +172,7 @@ service : { get_pool_state_chain : (text, text) -> (Result_6) query; get_tx_affected : (text) -> (opt TxRecord) query; list_pools : (opt text, nat64) -> (vec PoolInfo) query; + lock_lp : (text, text, text) -> (Result_2); new_block : (NewBlockInfo) -> (Result_7); pause : () -> (); pre_add_liquidity : (text, CoinBalance) -> (Result_8) query; From 515417259cd727c83dca4f823d3c7fab099d91fc Mon Sep 17 00:00:00 2001 From: Kachinsky <22611640+kb1ns@users.noreply.github.com> Date: Sun, 21 Sep 2025 14:21:48 +0800 Subject: [PATCH 02/12] lock lp --- src/canister.rs | 20 +++++++++++++++++--- src/lib.rs | 6 ++---- src/pool.rs | 26 ++++++++++++++++++++++---- swap.did | 3 ++- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/canister.rs b/src/canister.rs index 908d3ee..9eba278 100644 --- a/src/canister.rs +++ b/src/canister.rs @@ -79,6 +79,7 @@ pub fn recover() { }); } +/// message -> {pool}:{lock_time_in_blocks} #[update] pub fn lock_lp(addr: String, message: String, sig: String) -> Result<(), ExchangeError> { crate::ensure_online()?; @@ -86,10 +87,15 @@ pub fn lock_lp(addr: String, message: String, sig: String) -> Result<(), Exchang .map_err(|_| ExchangeError::InvalidSignature)?; let lock: Vec<&str> = message.split(':').collect(); let pool = lock.get(0).ok_or(ExchangeError::InvalidLockMessage)?; - let lock_until = lock + let lock_time = lock .get(1) - .and_then(|s| s.parse::().ok()) + .and_then(|s| s.parse::().ok()) .ok_or(ExchangeError::InvalidLockMessage)?; + let max_block = crate::get_max_block().ok_or(ExchangeError::InvalidLockMessage)?; + let lock_until = max_block + .block_height + .checked_add(lock_time) + .unwrap_or(u32::MAX); crate::with_pool_mut(pool.to_string(), |p| { let mut pool = p.ok_or(ExchangeError::InvalidPool)?; let state = pool.states.last_mut().ok_or(ExchangeError::EmptyPool)?; @@ -383,8 +389,9 @@ pub fn pre_withdraw_liquidity( crate::ensure_online()?; crate::with_pool(&pool_addr, |p| { let pool = p.as_ref().ok_or(ExchangeError::InvalidPool)?; + let max_block = crate::get_max_block().ok_or(ExchangeError::BlockSyncing)?; let (btc, rune_output, _) = - pool.available_to_withdraw(&user_addr, share, crate::ic_timestamp())?; + pool.available_to_withdraw(&user_addr, share, max_block.block_height)?; let state = pool.states.last().expect("already checked"); Ok(WithdrawalOffer { input: state.utxo.clone().expect("already checked"), @@ -659,10 +666,17 @@ pub async fn execute_tx(args: ExecuteTxArgs) -> ExecuteTxResponse { .ok_or(ExchangeError::InvalidPool.to_string())?; match intention.action.as_ref() { "add_liquidity" => { + let lock_time = match action_params.is_empty() { + true => 0, + false => action_params + .parse() + .map_err(|_| "action params \"lock_time\" required")?, + }; let (new_state, consumed) = pool .validate_adding_liquidity( txid, nonce, + lock_time, pool_utxo_spent, pool_utxo_received, input_coins, diff --git a/src/lib.rs b/src/lib.rs index c297b6a..2a6c36e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -114,6 +114,8 @@ pub enum ExchangeError { InvalidSignature, #[error("invalid lock message")] InvalidLockMessage, + #[error("operation is forbidden during synching blocks")] + BlockSyncing, } type Memory = VirtualMemory; @@ -687,7 +689,3 @@ impl Drop for ExecuteTxGuard { }); } } - -pub(crate) fn ic_timestamp() -> u64 { - ic_cdk::api::time() / 1_000_000_000 -} diff --git a/src/pool.rs b/src/pool.rs index a16354e..9f4aeef 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -75,7 +75,7 @@ pub struct PoolState { pub total_btc_donation: u64, pub total_rune_donation: u128, #[serde(default)] - pub lp_locks: BTreeMap, + pub lp_locks: BTreeMap, } impl PoolState { @@ -246,6 +246,7 @@ impl LiquidityPool { &self, txid: Txid, nonce: u64, + lock_time: u32, pool_utxo_spend: Vec, pool_utxo_receive: Vec, input_coins: Vec, @@ -344,7 +345,23 @@ impl LiquidityPool { .ok_or(ExchangeError::InvalidSignPsbtArgs( "pool_utxo_receive mismatch with pre_add_liquidity".to_string(), ))?; - state.utxo = Some(pool_output); + if lock_time > 0 { + let max_block = crate::get_max_block().ok_or(ExchangeError::BlockSyncing)?; + let lock_until = max_block + .block_height + .checked_add(lock_time) + .unwrap_or(u32::MAX); + state.utxo = Some(pool_output); + state + .lp_locks + .entry(initiator.clone()) + .and_modify(|t| { + if *t < lock_until { + *t = lock_until; + } + }) + .or_insert(lock_until); + } state .lp .entry(initiator) @@ -457,7 +474,7 @@ impl LiquidityPool { &self, pubkey_hash: impl AsRef, share: u128, - now: u64, + now: u32, ) -> Result<(u64, CoinBalance, u128), ExchangeError> { let recent_state = self.states.last().ok_or(ExchangeError::EmptyPool)?; let lock_until = recent_state @@ -570,8 +587,9 @@ impl LiquidityPool { .try_into() .map_err(|_| ExchangeError::Overflow)?; + let max_block = crate::get_max_block().ok_or(ExchangeError::BlockSyncing)?; let (btc_expecting, rune_expecting, new_share) = - self.available_to_withdraw(&initiator, share, crate::ic_timestamp())?; + self.available_to_withdraw(&initiator, share, max_block.block_height)?; (rune_expecting == rune_output && btc_expecting == btc_output_sats) .then(|| ()) .ok_or(ExchangeError::InvalidSignPsbtArgs( diff --git a/swap.did b/swap.did index d4763dc..54dcbf5 100644 --- a/swap.did +++ b/swap.did @@ -27,6 +27,7 @@ type ExchangeError = variant { PoolAlreadyExists; InvalidTxid; InvalidLiquidity; + BlockSyncing; EmptyPool; InvalidLockMessage; FetchBitcoinCanisterError; @@ -110,7 +111,7 @@ type PoolState = record { incomes : nat64; total_btc_donation : nat64; nonce : nat64; - lp_locks : vec record { text; nat64 }; + lp_locks : vec record { text; nat32 }; }; type Result = variant { Ok : record { nat64; nat64 }; Err : text }; type Result_1 = variant { Ok : text; Err : ExchangeError }; From f9121d60645b1fc50bddc409ceed6aa7cec982bb Mon Sep 17 00:00:00 2001 From: Kachinsky <22611640+kb1ns@users.noreply.github.com> Date: Sun, 21 Sep 2025 14:25:14 +0800 Subject: [PATCH 03/12] update utxo --- src/pool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pool.rs b/src/pool.rs index 9f4aeef..504e54b 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -351,7 +351,6 @@ impl LiquidityPool { .block_height .checked_add(lock_time) .unwrap_or(u32::MAX); - state.utxo = Some(pool_output); state .lp_locks .entry(initiator.clone()) @@ -362,6 +361,7 @@ impl LiquidityPool { }) .or_insert(lock_until); } + state.utxo = Some(pool_output); state .lp .entry(initiator) From ca32861a555aaedcf485cad4c0bfd85b0b31a062 Mon Sep 17 00:00:00 2001 From: Kachinsky <22611640+kb1ns@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:50:30 +0800 Subject: [PATCH 04/12] add lp lock info --- src/canister.rs | 25 ++++++++----------------- src/pool.rs | 31 ++++++++++++++++++++++--------- swap.did | 6 +++++- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/src/canister.rs b/src/canister.rs index 9eba278..28c806d 100644 --- a/src/canister.rs +++ b/src/canister.rs @@ -1,5 +1,5 @@ use crate::{ - pool::{self, CoinMeta, PoolState}, + pool::{self, CoinMeta, Liquidity, PoolState}, ExchangeError, }; use candid::{CandidType, Deserialize, Principal}; @@ -294,37 +294,28 @@ pub fn pre_extract_fee(addr: String) -> Result { }) } -#[derive(Clone, CandidType, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct Liquidity { - pub user_incomes: u64, - pub user_share: u128, - pub total_share: u128, -} - #[query] pub fn get_lp(addr: String, user_addr: String) -> Result { crate::with_pool(&addr, |p| { let pool = p.as_ref().ok_or(ExchangeError::InvalidPool)?; pool.states .last() - .and_then(|s| { - Some(Liquidity { - user_share: s.lp(&user_addr), - user_incomes: s.earning(&user_addr), - total_share: s.k, - }) - }) + .and_then(|s| Some(s.lp(&user_addr))) .ok_or(ExchangeError::EmptyPool) }) } #[query] -pub fn get_all_lp(addr: String) -> Result, ExchangeError> { +pub fn get_all_lp(addr: String) -> Result, ExchangeError> { crate::with_pool(&addr, |p| { let pool = p.as_ref().ok_or(ExchangeError::InvalidPool)?; pool.states .last() - .map(|s| s.lp.clone()) + .map(|s| { + s.lp.iter() + .map(|(addr, _)| (addr.clone(), s.lp(addr))) + .collect() + }) .ok_or(ExchangeError::EmptyPool) }) } diff --git a/src/pool.rs b/src/pool.rs index 504e54b..a243e10 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -78,6 +78,14 @@ pub struct PoolState { pub lp_locks: BTreeMap, } +#[derive(Clone, CandidType, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Liquidity { + pub user_incomes: u64, + pub user_share: u128, + pub total_share: u128, + pub lock_until: u32, +} + impl PoolState { pub fn satoshis(&self) -> u64 { self.utxo.as_ref().map(|utxo| utxo.sats).unwrap_or_default() @@ -97,12 +105,13 @@ impl PoolState { .unwrap_or_default() } - pub fn lp(&self, key: &str) -> u128 { - self.lp.get(key).copied().unwrap_or_default() - } - - pub fn earning(&self, key: &str) -> u64 { - self.lp_earnings.get(key).copied().unwrap_or_default() + pub fn lp(&self, key: &str) -> Liquidity { + Liquidity { + user_incomes: self.lp_earnings.get(key).copied().unwrap_or_default(), + user_share: self.lp.get(key).copied().unwrap_or_default(), + total_share: self.k, + lock_until: self.lp_locks.get(key).copied().unwrap_or_default(), + } } } @@ -480,12 +489,16 @@ impl LiquidityPool { let lock_until = recent_state .lp_locks .get(pubkey_hash.as_ref()) - .map(|v| *v) - .unwrap_or(0); + .copied() + .unwrap_or_default(); (lock_until < now) .then(|| ()) .ok_or(ExchangeError::LiquidityLocked)?; - let user_total_share = recent_state.lp(pubkey_hash.as_ref()); + let user_total_share = recent_state + .lp + .get(pubkey_hash.as_ref()) + .copied() + .unwrap_or_default(); (share <= user_total_share) .then(|| ()) .ok_or(ExchangeError::InsufficientFunds)?; diff --git a/swap.did b/swap.did index 54dcbf5..aa454a5 100644 --- a/swap.did +++ b/swap.did @@ -76,6 +76,7 @@ type Liquidity = record { total_share : nat; user_share : nat; user_incomes : nat64; + lock_until : nat32; }; type LiquidityOffer = record { output : CoinBalance; @@ -123,7 +124,10 @@ type Result_14 = variant { Ok : PoolInfo; Err : ExchangeError }; type Result_15 = variant { Ok : vec TxRecordInfo; Err : text }; type Result_2 = variant { Ok; Err : ExchangeError }; type Result_3 = variant { Ok : text; Err : text }; -type Result_4 = variant { Ok : vec record { text; nat }; Err : ExchangeError }; +type Result_4 = variant { + Ok : vec record { text; Liquidity }; + Err : ExchangeError; +}; type Result_5 = variant { Ok : Liquidity; Err : ExchangeError }; type Result_6 = variant { Ok : opt record { opt PoolState; PoolState }; From ff1d5b1311a3490631534b25833c09f53a73f7c3 Mon Sep 17 00:00:00 2001 From: Kachinsky <22611640+kb1ns@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:11:31 +0800 Subject: [PATCH 05/12] distribute locked_lp_revenue --- src/canister.rs | 2 +- src/lib.rs | 51 ++++++++++++-------------------- src/pool.rs | 78 +++++++++++++++++++++++++++++++++++++------------ swap.did | 2 ++ 4 files changed, 81 insertions(+), 52 deletions(-) diff --git a/src/canister.rs b/src/canister.rs index 28c806d..1c23de6 100644 --- a/src/canister.rs +++ b/src/canister.rs @@ -437,7 +437,7 @@ pub fn pre_swap(id: String, input: CoinBalance) -> Result; const _POOL_TOKENS_MEMORY_ID_V2: MemoryId = MemoryId::new(1); -const FEE_COLLECTOR_MEMORY_ID: MemoryId = MemoryId::new(2); -const ORCHESTRATOR_MEMORY_ID: MemoryId = MemoryId::new(3); -// deprecated const _POOL_ADDR_MEMORY_ID: MemoryId = MemoryId::new(4); const _POOLS_MEMORY_ID_V2: MemoryId = MemoryId::new(5); -// the v3 is token -> addr -const POOL_TOKENS_MEMORY_ID: MemoryId = MemoryId::new(7); -// the v3 is addr -> pool, notice: 6 is deprecated in the testnet -#[allow(unused)] -const POOLS_MEMORY_ID_V4: MemoryId = MemoryId::new(10); +const _POOLS_MEMORY_ID_V4: MemoryId = MemoryId::new(10); +const FEE_COLLECTOR_MEMORY_ID: MemoryId = MemoryId::new(2); +const ORCHESTRATOR_MEMORY_ID: MemoryId = MemoryId::new(3); +const POOL_TOKENS_MEMORY_ID: MemoryId = MemoryId::new(7); const BLOCKS_ID: MemoryId = MemoryId::new(8); const TX_RECORDS_ID: MemoryId = MemoryId::new(9); #[allow(unused)] const WHITELIST_ID: MemoryId = MemoryId::new(11); const PAUSED_ID: MemoryId = MemoryId::new(12); - const POOLS_MEMORY_ID: MemoryId = MemoryId::new(13); thread_local! { @@ -146,24 +141,12 @@ thread_local! { static MEMORY_MANAGER: RefCell>> = RefCell::new(Some(MemoryManager::init(MEMORY.with(|m| m.borrow().clone().unwrap())))); - // static POOLS_V2: RefCell> = - // RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(POOLS_MEMORY_ID_V2)))); - - // pub(crate) static POOLS_V4: RefCell> = - // RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(POOLS_MEMORY_ID_V4)))); - - pub(crate) static POOLS: RefCell> = + static POOLS: RefCell> = RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(POOLS_MEMORY_ID)))); - static _POOL_TOKENS_V2: RefCell> = - RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(_POOL_TOKENS_MEMORY_ID_V2)))); - - pub(crate) static POOL_TOKENS: RefCell> = + static POOL_TOKENS: RefCell> = RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(POOL_TOKENS_MEMORY_ID)))); - static _POOL_ADDR_DEPRECATED: RefCell> = - RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(_POOL_ADDR_MEMORY_ID)))); - static FEE_COLLECTOR: RefCell> = RefCell::new(Cell::init(with_memory_manager(|m| m.get(FEE_COLLECTOR_MEMORY_ID)), DEFAULT_FEE_COLLECTOR.to_string()) .expect("fail to init a StableCell")); @@ -172,19 +155,19 @@ thread_local! { RefCell::new(Cell::init(with_memory_manager(|m| m.get(ORCHESTRATOR_MEMORY_ID)), Principal::from_str(ORCHESTRATOR_CANISTER).expect("invalid principal: orchestrator")) .expect("fail to init a StableCell")); - pub(crate) static BLOCKS: RefCell> = + static BLOCKS: RefCell> = RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(BLOCKS_ID)))); - pub(crate) static TX_RECORDS: RefCell> = + static TX_RECORDS: RefCell> = RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(TX_RECORDS_ID)))); - pub(crate) static WHITELIST: RefCell> = + static WHITELIST: RefCell> = RefCell::new(StableBTreeMap::init(with_memory_manager(|m| m.get(WHITELIST_ID)))); - pub(crate) static PAUSED: RefCell> = + static PAUSED: RefCell> = RefCell::new(Cell::init(with_memory_manager(|m| m.get(PAUSED_ID)), false).expect("fail to init a StableCell")); - pub(crate) static GUARDS: RefCell> = RefCell::new(HashSet::new()); + static GUARDS: RefCell> = RefCell::new(HashSet::new()); } fn with_memory_manager(f: impl FnOnce(&MemoryManager) -> R) -> R { @@ -307,9 +290,13 @@ pub(crate) fn create_empty_pool( return Err(ExchangeError::PoolAlreadyExists); } let id = meta.id; - let pool = - LiquidityPool::new_empty(meta, DEFAULT_FEE_RATE, DEFAULT_BURN_RATE, untweaked.clone()) - .expect("didn't set fee rate"); + let pool = LiquidityPool::new_empty( + meta, + DEFAULT_LP_FEE_RATE, + DEFAULT_PROTOCOL_FEE_RATE, + untweaked.clone(), + ) + .expect("didn't set fee rate"); let addr = pool.addr.clone(); POOL_TOKENS.with_borrow_mut(|l| { l.insert(id, addr.clone()); diff --git a/src/pool.rs b/src/pool.rs index a243e10..b618fe4 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -9,10 +9,12 @@ use serde::Serialize; use std::collections::BTreeMap; use std::str::FromStr; -/// represents 0.7/100 = 7/1_000 = 7000/1_000_000 -pub const DEFAULT_FEE_RATE: u64 = 7000; -/// represents 0.2/100 = 2/1_000 = 2000/1_000_000 -pub const DEFAULT_BURN_RATE: u64 = 2000; +/// represents 0.007 +pub const DEFAULT_LP_FEE_RATE: u64 = 7000; +// represents 0.0035 +// pub const DEFAULT_LOCKED_LP_FEE_RATE: u64 = 3500; +/// represents 0.002 +pub const DEFAULT_PROTOCOL_FEE_RATE: u64 = 2000; /// each tx's satoshis should be >= 10000 pub const MIN_BTC_VALUE: u64 = 10000; /// each tx's staoshis should be <= 10000000; @@ -76,12 +78,15 @@ pub struct PoolState { pub total_rune_donation: u128, #[serde(default)] pub lp_locks: BTreeMap, + #[serde(default)] + pub locked_lp_revenue: BTreeMap, } #[derive(Clone, CandidType, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Liquidity { pub user_incomes: u64, pub user_share: u128, + pub locked_revenue: u64, pub total_share: u128, pub lock_until: u32, } @@ -106,11 +111,16 @@ impl PoolState { } pub fn lp(&self, key: &str) -> Liquidity { + let lock_until = self.lp_locks.get(key).copied().unwrap_or_default(); + let height = crate::get_max_block().map(|b| b.block_height).unwrap_or(0); + // if the lock_until is in the past, set it to 0 + let lock_until = if lock_until < height { 0 } else { lock_until }; Liquidity { user_incomes: self.lp_earnings.get(key).copied().unwrap_or_default(), user_share: self.lp.get(key).copied().unwrap_or_default(), + locked_revenue: self.locked_lp_revenue.get(key).copied().unwrap_or_default(), total_share: self.k, - lock_until: self.lp_locks.get(key).copied().unwrap_or_default(), + lock_until, } } } @@ -180,10 +190,11 @@ impl LiquidityPool { self.meta.id } - pub(crate) fn charge_fee(btc: u64, fee_: u64, burn_: u64) -> (u64, u64, u64) { + /// FIXME for some reasons, we don't save the lp_fee_rate and locked_fee_rate independently + pub(crate) fn charge_fee(btc: u64, fee_: u64, burn_: u64) -> (u64, u64, u64, u64) { let fee = btc * fee_ / 1_000_000u64; let burn = btc * burn_ / 1_000_000u64; - (btc - fee - burn, fee, burn) + (btc - fee - burn, fee / 2, fee / 2, burn) } pub(crate) fn liquidity_should_add( @@ -907,7 +918,7 @@ impl LiquidityPool { pub(crate) fn available_to_swap( &self, taker: CoinBalance, - ) -> Result<(CoinBalance, u64, u64, u32), ExchangeError> { + ) -> Result<(CoinBalance, u64, u64, u64, u32), ExchangeError> { let btc_meta = CoinMeta::btc(); (taker.id == self.meta.id || taker.id == CoinId::btc()) .then(|| ()) @@ -925,7 +936,7 @@ impl LiquidityPool { (input_btc <= MAX_BTC_VALUE as u64) .then(|| ()) .ok_or(ExchangeError::FundsLimitExceeded)?; - let (input_amount, fee, burn) = + let (input_amount, lp_fee, locked_lp_fee, protocol_fee) = Self::charge_fee(input_btc, self.fee_rate, self.burn_rate); let rune_remains = btc_supply .checked_add(input_amount) @@ -937,7 +948,7 @@ impl LiquidityPool { let price_impact = Self::ensure_price_limit( btc_supply, rune_supply, - btc_supply + input_btc, + btc_supply + input_amount, rune_remains, )?; let offer = rune_supply - rune_remains; @@ -946,8 +957,9 @@ impl LiquidityPool { value: offer, id: self.meta.id, }, - fee, - burn, + lp_fee, + locked_lp_fee, + protocol_fee, price_impact, )) } else { @@ -959,9 +971,10 @@ impl LiquidityPool { let min_hold = CoinMeta::btc().min_amount as u64; let pool_btc_remains: u64 = pool_btc_remains.try_into().expect("BTC amount overflow"); let pre_charge = btc_supply - pool_btc_remains; - let (offer, fee, burn) = Self::charge_fee(pre_charge, self.fee_rate, self.burn_rate); + let (offer, lp_fee, locked_lp_fee, protocol_fee) = + Self::charge_fee(pre_charge, self.fee_rate, self.burn_rate); // this is the actual remains - let pool_btc_remains = btc_supply - offer - burn; + let pool_btc_remains = btc_supply - offer - protocol_fee - locked_lp_fee; // plus this to ensure the pool remains >= 546 let round_to_keep = if pool_btc_remains < min_hold { min_hold - pool_btc_remains @@ -983,8 +996,9 @@ impl LiquidityPool { id: btc_meta.id, value: out_sats as u128, }, - fee + round_to_keep, - burn, + lp_fee + round_to_keep, + locked_lp_fee, + protocol_fee, price_impact, )) } @@ -1032,7 +1046,7 @@ impl LiquidityPool { ExchangeError::InvalidSignPsbtArgs("pool_utxo_spend/pool state mismatch".to_string()), )?; // check minimal sats - let (offer, fee, burn, _) = self.available_to_swap(input.coin)?; + let (offer, lp_fee, locked_lp_fee, protocol_fee, _) = self.available_to_swap(input.coin)?; let (btc_output, rune_output) = if input.coin.id == CoinId::btc() { let input_btc: u64 = input .coin @@ -1086,8 +1100,34 @@ impl LiquidityPool { "pool_utxo_receive mismatch".to_string(), ))?; state.utxo = Some(pool_output); + // only update + let max_height = crate::get_max_block() + .map(|b| b.block_height) + .unwrap_or_default(); + // locked LPs have extra revenue + for (k, _) in state.lp_locks.iter().filter(|(_, u)| **u > max_height) { + if let Some(fee) = state + .lp + .get(k) + .and_then(|share| share.checked_mul(locked_lp_fee as u128)) + .and_then(|mul| mul.checked_div(state.k)) + { + let fee_in_sats = fee as u64; + state + .locked_lp_revenue + .entry(k.clone()) + .and_modify(|e| *e += fee_in_sats) + .or_insert(fee_in_sats); + state + .lp_earnings + .entry(k.clone()) + .and_modify(|e| *e += fee_in_sats) + .or_insert(fee_in_sats); + } + } + // all LPs share the rest for (k, v) in state.lp.iter() { - if let Some(incr) = (fee as u128) + if let Some(incr) = (lp_fee as u128) .checked_mul(*v) .and_then(|mul| mul.checked_div(state.k)) { @@ -1099,7 +1139,7 @@ impl LiquidityPool { } } state.nonce += 1; - state.incomes += burn; + state.incomes += protocol_fee; state.id = Some(txid); Ok((state, prev_utxo)) } diff --git a/swap.did b/swap.did index aa454a5..7a82ef6 100644 --- a/swap.did +++ b/swap.did @@ -75,6 +75,7 @@ type IntentionSet = record { type Liquidity = record { total_share : nat; user_share : nat; + locked_revenue : nat64; user_incomes : nat64; lock_until : nat32; }; @@ -110,6 +111,7 @@ type PoolState = record { utxo : opt Utxo; total_rune_donation : nat; incomes : nat64; + locked_lp_revenue : vec record { text; nat64 }; total_btc_donation : nat64; nonce : nat64; lp_locks : vec record { text; nat32 }; From 64f5f62ffe9ce0d5848218579635baba68e8525f Mon Sep 17 00:00:00 2001 From: Kachinsky <22611640+kb1ns@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:50:41 +0800 Subject: [PATCH 06/12] add claim_revenue --- src/canister.rs | 45 ++++++++++++++++++++++++++ src/pool.rs | 84 +++++++++++++++++++++++++++++++++++++++++++++++++ swap.did | 37 +++++++++++++--------- 3 files changed, 151 insertions(+), 15 deletions(-) diff --git a/src/canister.rs b/src/canister.rs index 1c23de6..0311778 100644 --- a/src/canister.rs +++ b/src/canister.rs @@ -398,6 +398,31 @@ pub fn pre_withdraw_liquidity( }) } +#[derive(Clone, CandidType, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct PreClaimOutput { + pub input: Utxo, + pub claim_sats: u64, + pub nonce: u64, +} + +#[query] +pub fn pre_claim_revenue( + pool_addr: String, + user_addr: String, +) -> Result { + crate::ensure_online()?; + crate::with_pool(&pool_addr, |p| { + let pool = p.as_ref().ok_or(ExchangeError::InvalidPool)?; + let sats = pool.available_to_claim(&user_addr)?; + let state = pool.states.last().expect("already checked"); + Ok(PreClaimOutput { + input: state.utxo.clone().expect("already checked"), + claim_sats: sats, + nonce: state.nonce, + }) + }) +} + #[derive(Eq, PartialEq, CandidType, Clone, Debug, Deserialize, Serialize)] pub struct LiquidityOffer { pub inputs: Option, @@ -712,6 +737,26 @@ pub async fn execute_tx(args: ExecuteTxArgs) -> ExecuteTxResponse { }) .map_err(|e| e.to_string())?; } + "claim_revenue" => { + let (new_state, consumed) = pool + .validate_claiming_revenue( + txid, + nonce, + pool_utxo_spent, + pool_utxo_received, + initiator, + ) + .map_err(|e| e.to_string())?; + crate::psbt::sign(&mut psbt, &consumed, pool.base_id().to_bytes()) + .await + .map_err(|e| e.to_string())?; + crate::with_pool_mut(pool_address, |p| { + let mut pool = p.expect("already checked in available_to_withdraw;qed"); + pool.commit(new_state); + Ok(Some(pool)) + }) + .map_err(|e| e.to_string())?; + } "extract_protocol_fee" => { // enforce combination (intention_index == 0) diff --git a/src/pool.rs b/src/pool.rs index b618fe4..1342fa6 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -490,6 +490,90 @@ impl LiquidityPool { Ok((state, prev_utxo)) } + pub(crate) fn available_to_claim( + &self, + pubkey_hash: impl AsRef, + ) -> Result { + let recent_state = self.states.last().ok_or(ExchangeError::EmptyPool)?; + let user_revenue = recent_state + .locked_lp_revenue + .get(pubkey_hash.as_ref()) + .copied() + .unwrap_or_default(); + (user_revenue >= MIN_BTC_VALUE) + .then(|| ()) + .ok_or(ExchangeError::TooSmallFunds)?; + (user_revenue <= MAX_BTC_VALUE) + .then(|| ()) + .ok_or(ExchangeError::FundsLimitExceeded)?; + let btc_remains = recent_state + .satoshis() + .checked_sub(user_revenue) + .ok_or(ExchangeError::EmptyPool)?; + (btc_remains >= CoinMeta::btc().min_amount as u64) + .then(|| ()) + .ok_or(ExchangeError::EmptyPool)?; + Ok(user_revenue) + } + + pub(crate) fn validate_claiming_revenue( + &self, + txid: Txid, + nonce: u64, + pool_utxo_spend: Vec, + pool_utxo_receive: Vec, + initiator: String, + ) -> Result<(PoolState, Utxo), ExchangeError> { + (pool_utxo_receive.len() == 1) + .then(|| ()) + .ok_or(ExchangeError::InvalidSignPsbtArgs( + "pool_utxo_receive not found".to_string(), + ))?; + let pool_prev_outpoint = + pool_utxo_spend + .last() + .map(|s| s.clone()) + .ok_or(ExchangeError::InvalidSignPsbtArgs( + "pool_utxo_spend not found".to_string(), + ))?; + let mut state = self.states.last().ok_or(ExchangeError::EmptyPool)?.clone(); + // check nonce + (state.nonce == nonce) + .then(|| ()) + .ok_or(ExchangeError::PoolStateExpired(state.nonce))?; + // check prev state equals utxo_spend + let prev_utxo = state.utxo.clone().ok_or(ExchangeError::EmptyPool)?; + (prev_utxo.outpoint() == pool_prev_outpoint) + .then(|| ()) + .ok_or(ExchangeError::InvalidSignPsbtArgs( + "pool_utxo_spend/pool_state don't match".to_string(), + ))?; + + let claim_sats = self.available_to_claim(&initiator)?; + let (pool_btc_output, pool_rune_output) = ( + prev_utxo + .sats + .checked_sub(claim_sats) + .ok_or(ExchangeError::Overflow)?, + prev_utxo.coins.value_of(&self.meta.id), + ); + let pool_output = pool_utxo_receive.last().map(|s| s.clone()).ok_or( + ExchangeError::InvalidSignPsbtArgs("pool_utxo_receive not found".to_string()), + )?; + (pool_output.sats == pool_btc_output + && pool_output.coins.value_of(&self.meta.id) == pool_rune_output) + .then(|| ()) + .ok_or(ExchangeError::InvalidSignPsbtArgs( + "pool_utxo_receive mismatch with pre_claim_revenue".to_string(), + ))?; + + state.utxo = Some(pool_output); + state.locked_lp_revenue.remove(&initiator); + state.nonce += 1; + state.id = Some(txid); + Ok((state, prev_utxo)) + } + pub(crate) fn available_to_withdraw( &self, pubkey_hash: impl AsRef, diff --git a/swap.did b/swap.did index 7a82ef6..242df20 100644 --- a/swap.did +++ b/swap.did @@ -116,14 +116,20 @@ type PoolState = record { nonce : nat64; lp_locks : vec record { text; nat32 }; }; +type PreClaimOutput = record { + nonce : nat64; + claim_sats : nat64; + input : Utxo; +}; type Result = variant { Ok : record { nat64; nat64 }; Err : text }; type Result_1 = variant { Ok : text; Err : ExchangeError }; -type Result_10 = variant { Ok : ExtractFeeOffer; Err : ExchangeError }; -type Result_11 = variant { Ok : SwapOffer; Err : ExchangeError }; -type Result_12 = variant { Ok : WithdrawalOffer; Err : ExchangeError }; -type Result_13 = variant { Ok : vec BlockInfo; Err : text }; -type Result_14 = variant { Ok : PoolInfo; Err : ExchangeError }; -type Result_15 = variant { Ok : vec TxRecordInfo; Err : text }; +type Result_10 = variant { Ok : DonateIntention; Err : ExchangeError }; +type Result_11 = variant { Ok : ExtractFeeOffer; Err : ExchangeError }; +type Result_12 = variant { Ok : SwapOffer; Err : ExchangeError }; +type Result_13 = variant { Ok : WithdrawalOffer; Err : ExchangeError }; +type Result_14 = variant { Ok : vec BlockInfo; Err : text }; +type Result_15 = variant { Ok : PoolInfo; Err : ExchangeError }; +type Result_16 = variant { Ok : vec TxRecordInfo; Err : text }; type Result_2 = variant { Ok; Err : ExchangeError }; type Result_3 = variant { Ok : text; Err : text }; type Result_4 = variant { @@ -137,7 +143,7 @@ type Result_6 = variant { }; type Result_7 = variant { Ok; Err : text }; type Result_8 = variant { Ok : LiquidityOffer; Err : ExchangeError }; -type Result_9 = variant { Ok : DonateIntention; Err : ExchangeError }; +type Result_9 = variant { Ok : PreClaimOutput; Err : ExchangeError }; type RollbackTxArgs = record { txid : text }; type SwapOffer = record { output : CoinBalance; @@ -183,14 +189,15 @@ service : { new_block : (NewBlockInfo) -> (Result_7); pause : () -> (); pre_add_liquidity : (text, CoinBalance) -> (Result_8) query; - pre_donate : (text, nat64) -> (Result_9) query; - pre_extract_fee : (text) -> (Result_10) query; - pre_self_donate : () -> (Result_9) query; - pre_swap : (text, CoinBalance) -> (Result_11) query; - pre_withdraw_liquidity : (text, text, nat) -> (Result_12) query; - query_blocks : () -> (Result_13) query; - query_pool : (text) -> (Result_14) query; - query_tx_records : () -> (Result_15) query; + pre_claim_revenue : (text, text) -> (Result_9) query; + pre_donate : (text, nat64) -> (Result_10) query; + pre_extract_fee : (text) -> (Result_11) query; + pre_self_donate : () -> (Result_10) query; + pre_swap : (text, CoinBalance) -> (Result_12) query; + pre_withdraw_liquidity : (text, text, nat) -> (Result_13) query; + query_blocks : () -> (Result_14) query; + query_pool : (text) -> (Result_15) query; + query_tx_records : () -> (Result_16) query; recover : () -> (); rollback_tx : (RollbackTxArgs) -> (Result_7); set_donation_amount : (text, nat64, nat) -> (Result_2); From 76ec4779cb5f3dd139629fb11483e4183a5fd627 Mon Sep 17 00:00:00 2001 From: Kachinsky <22611640+kb1ns@users.noreply.github.com> Date: Tue, 14 Oct 2025 12:01:10 +0800 Subject: [PATCH 07/12] update min lock time --- src/canister.rs | 6 ++++++ src/lib.rs | 10 ++++++++++ src/pool.rs | 27 +++++++++++++++++++++++---- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/canister.rs b/src/canister.rs index 0311778..2e0ddd6 100644 --- a/src/canister.rs +++ b/src/canister.rs @@ -91,6 +91,9 @@ pub fn lock_lp(addr: String, message: String, sig: String) -> Result<(), Exchang .get(1) .and_then(|s| s.parse::().ok()) .ok_or(ExchangeError::InvalidLockMessage)?; + (lock_time >= crate::min_lock_time()) + .then_some(()) + .ok_or(ExchangeError::InvalidLockMessage)?; let max_block = crate::get_max_block().ok_or(ExchangeError::InvalidLockMessage)?; let lock_until = max_block .block_height @@ -744,6 +747,9 @@ pub async fn execute_tx(args: ExecuteTxArgs) -> ExecuteTxResponse { nonce, pool_utxo_spent, pool_utxo_received, + action_params, + input_coins, + output_coins, initiator, ) .map_err(|e| e.to_string())?; diff --git a/src/lib.rs b/src/lib.rs index 84546bf..0fa73e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -654,6 +654,16 @@ pub(crate) fn min_sats() -> u64 { } } +pub(crate) fn min_lock_time() -> u32 { + cfg_if::cfg_if! { + if #[cfg(feature = "testnet")] { + 1 + } else { + 144 * 7 + } + } +} + #[must_use] pub struct ExecuteTxGuard(String); diff --git a/src/pool.rs b/src/pool.rs index 1342fa6..9d96375 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -366,6 +366,9 @@ impl LiquidityPool { "pool_utxo_receive mismatch with pre_add_liquidity".to_string(), ))?; if lock_time > 0 { + (lock_time >= crate::min_lock_time()) + .then_some(()) + .ok_or(ExchangeError::InvalidLockMessage)?; let max_block = crate::get_max_block().ok_or(ExchangeError::BlockSyncing)?; let lock_until = max_block .block_height @@ -500,7 +503,7 @@ impl LiquidityPool { .get(pubkey_hash.as_ref()) .copied() .unwrap_or_default(); - (user_revenue >= MIN_BTC_VALUE) + (user_revenue >= crate::min_sats()) .then(|| ()) .ok_or(ExchangeError::TooSmallFunds)?; (user_revenue <= MAX_BTC_VALUE) @@ -522,8 +525,16 @@ impl LiquidityPool { nonce: u64, pool_utxo_spend: Vec, pool_utxo_receive: Vec, - initiator: String, + beneficiary: String, + input_coins: Vec, + output_coins: Vec, + _initiator: String, ) -> Result<(PoolState, Utxo), ExchangeError> { + (input_coins.is_empty() && output_coins.len() == 1) + .then(|| ()) + .ok_or(ExchangeError::InvalidSignPsbtArgs( + "invalid input/output coins, extract fee requires 0 input and 1 output".to_string(), + ))?; (pool_utxo_receive.len() == 1) .then(|| ()) .ok_or(ExchangeError::InvalidSignPsbtArgs( @@ -536,6 +547,14 @@ impl LiquidityPool { .ok_or(ExchangeError::InvalidSignPsbtArgs( "pool_utxo_spend not found".to_string(), ))?; + let output = output_coins.first().clone().expect("checked;qed"); + (output.coin.id == CoinMeta::btc().id && output.to == beneficiary) + .then(|| ()) + .ok_or(ExchangeError::InvalidSignPsbtArgs(format!( + "invalid output coin, extract fee requires 1 output of BTC to {}", + beneficiary + )))?; + let mut state = self.states.last().ok_or(ExchangeError::EmptyPool)?.clone(); // check nonce (state.nonce == nonce) @@ -549,7 +568,7 @@ impl LiquidityPool { "pool_utxo_spend/pool_state don't match".to_string(), ))?; - let claim_sats = self.available_to_claim(&initiator)?; + let claim_sats = self.available_to_claim(&beneficiary)?; let (pool_btc_output, pool_rune_output) = ( prev_utxo .sats @@ -568,7 +587,7 @@ impl LiquidityPool { ))?; state.utxo = Some(pool_output); - state.locked_lp_revenue.remove(&initiator); + state.locked_lp_revenue.remove(&beneficiary); state.nonce += 1; state.id = Some(txid); Ok((state, prev_utxo)) From 3db56ff845727ff28b9105674d20f28fd3cd8b9e Mon Sep 17 00:00:00 2001 From: Kachinsky <22611640+kb1ns@users.noreply.github.com> Date: Tue, 14 Oct 2025 18:56:11 +0800 Subject: [PATCH 08/12] add pool_template support --- src/canister.rs | 26 ++++++++++++++------ src/lib.rs | 27 ++++++++++++++++++--- src/pool.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++---- swap.did | 3 +++ 4 files changed, 105 insertions(+), 14 deletions(-) diff --git a/src/canister.rs b/src/canister.rs index 2e0ddd6..a621a4a 100644 --- a/src/canister.rs +++ b/src/canister.rs @@ -1,5 +1,5 @@ use crate::{ - pool::{self, CoinMeta, Liquidity, PoolState}, + pool::{self, CoinMeta, Liquidity, PoolState, PoolTemplate}, ExchangeError, }; use candid::{CandidType, Deserialize, Principal}; @@ -154,8 +154,7 @@ pub fn get_tx_affected(txid: Txid) -> Option { crate::get_tx_affected(txid) } -#[update] -pub async fn create(rune_id: CoinId) -> Result { +pub async fn create_pool(rune_id: CoinId, template: PoolTemplate) -> Result { crate::ensure_online()?; match crate::with_pool_name(&rune_id) { Some(addr) => crate::with_pool(&addr, |pool| { @@ -187,11 +186,25 @@ pub async fn create(rune_id: CoinId) -> Result { symbol: name, min_amount: 1, }; - crate::create_empty_pool(meta, untweaked_pubkey.clone()) + crate::create_empty_pool(meta, template, untweaked_pubkey.clone()) } } } +#[update] +pub async fn create_with_template( + rune_id: CoinId, + template: PoolTemplate, +) -> Result { + // TODO whitelist + create_pool(rune_id, template).await +} + +#[update] +pub async fn create(rune_id: CoinId) -> Result { + create_pool(rune_id, PoolTemplate::Standard).await +} + #[query] pub fn query_pool(rune_id: CoinId) -> Result { crate::ensure_online()?; @@ -681,7 +694,7 @@ pub async fn execute_tx(args: ExecuteTxArgs) -> ExecuteTxResponse { let _guard = crate::ExecuteTxGuard::new(pool_addr.clone()) .ok_or(format!("Pool {0} Executing", pool_addr).to_string())?; - let pool = crate::with_pool(&pool_address, |p| p.clone()) + let mut pool = crate::with_pool(&pool_address, |p| p.clone()) .ok_or(ExchangeError::InvalidPool.to_string())?; match intention.action.as_ref() { "add_liquidity" => { @@ -708,8 +721,7 @@ pub async fn execute_tx(args: ExecuteTxArgs) -> ExecuteTxResponse { .await .map_err(|e| e.to_string())?; } - crate::with_pool_mut(pool_address, |p| { - let mut pool = p.expect("already checked in pre_add_liquidity;qed"); + crate::with_pool_mut(pool_address, |_| { pool.commit(new_state); Ok(Some(pool)) }) diff --git a/src/lib.rs b/src/lib.rs index 0fa73e8..a4132a1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,10 @@ mod pool; mod psbt; mod reorg; -use crate::pool::{CoinMeta, LiquidityPool, DEFAULT_LP_FEE_RATE, DEFAULT_PROTOCOL_FEE_RATE}; +use crate::pool::{ + CoinMeta, FeeAdjustMechanism, LiquidityPool, PoolTemplate, DEFAULT_LP_FEE_RATE, + DEFAULT_PROTOCOL_FEE_RATE, +}; use candid::{CandidType, Deserialize, Principal}; use ic_cdk::api::management_canister::schnorr::{ self, SchnorrAlgorithm, SchnorrKeyId, SchnorrPublicKeyArgument, @@ -116,6 +119,8 @@ pub enum ExchangeError { InvalidLockMessage, #[error("operation is forbidden during synching blocks")] BlockSyncing, + #[error("Couldn't add liquidity into onetime pools and it must be permanently locked")] + OnetimePool, } type Memory = VirtualMemory; @@ -284,16 +289,32 @@ pub(crate) async fn sign_prehash_with_schnorr( pub(crate) fn create_empty_pool( meta: CoinMeta, + template: PoolTemplate, untweaked: Pubkey, ) -> Result { if has_pool(&meta.id) { return Err(ExchangeError::PoolAlreadyExists); } let id = meta.id; + let fee_adjust_mechanism = match template { + PoolTemplate::Standard => None, + PoolTemplate::Onetime => Some(FeeAdjustMechanism { + start_at: 0, + decr_interval_ms: 10 * 60 * 1000, + rate_decr_step: 10_000, + min_rate: 100_000, + }), + }; + let (lp_fee, protocol_fee) = if fee_adjust_mechanism.is_some() { + (990_000, 10_000) + } else { + (DEFAULT_LP_FEE_RATE, DEFAULT_PROTOCOL_FEE_RATE) + }; let pool = LiquidityPool::new_empty( meta, - DEFAULT_LP_FEE_RATE, - DEFAULT_PROTOCOL_FEE_RATE, + fee_adjust_mechanism, + lp_fee, + protocol_fee, untweaked.clone(), ) .expect("didn't set fee rate"); diff --git a/src/pool.rs b/src/pool.rs index 9d96375..021abe0 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -37,6 +37,33 @@ impl CoinMeta { } } +/// The `PoolTemplate::Onetime` rule: +/// - only allow add liquidity once +/// - lock_time must be u32::MAX +/// - dynamic fee rate? +/// - only created by governance +#[derive(Clone, Copy, CandidType, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum PoolTemplate { + #[serde(rename = "standard")] + Standard, + #[serde(rename = "onetime")] + Onetime, +} + +impl Default for PoolTemplate { + fn default() -> Self { + PoolTemplate::Standard + } +} + +#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct FeeAdjustMechanism { + pub start_at: u64, + pub decr_interval_ms: u64, + pub rate_decr_step: u64, + pub min_rate: u64, +} + #[derive(CandidType, Clone, Debug, Deserialize, Serialize)] pub struct LiquidityPool { pub states: Vec, @@ -46,6 +73,8 @@ pub struct LiquidityPool { pub pubkey: Pubkey, pub tweaked: Pubkey, pub addr: String, + #[serde(default)] + pub fee_adjust_mechanism: Option, } impl LiquidityPool { @@ -53,13 +82,14 @@ impl LiquidityPool { let attr = serde_json::json!({ "tweaked_key": self.tweaked.to_string(), "key_derive_path": vec![self.base_id().to_bytes()], - "lp_fee_rate": self.fee_rate, + "lp_fee_rate": self.get_lp_fee(), "protocol_fee_rate": self.burn_rate, "lp_revenue": self.states.last().map(|state| state.lp_earnings.values().map(|v| *v).sum::()).unwrap_or_default(), "protocol_revenue": self.states.last().map(|state| state.incomes).unwrap_or_default(), "sqrt_k": self.states.last().map(|state| state.k).unwrap_or_default(), "total_btc_donation": self.states.last().map(|state| state.total_btc_donation).unwrap_or_default(), "total_rune_donation": self.states.last().map(|state| state.total_rune_donation).unwrap_or_default(), + "template": self.fee_adjust_mechanism.map(|_| PoolTemplate::Onetime).unwrap_or(PoolTemplate::Standard), }); serde_json::to_string(&attr).expect("failed to serialize") } @@ -158,6 +188,7 @@ impl Storable for LiquidityPool { impl LiquidityPool { pub fn new_empty( meta: CoinMeta, + mechanism: Option, fee_rate: u64, burn_rate: u64, untweaked: Pubkey, @@ -183,6 +214,7 @@ impl LiquidityPool { pubkey: untweaked, tweaked, addr: addr.to_string(), + fee_adjust_mechanism: mechanism, }) } @@ -190,6 +222,18 @@ impl LiquidityPool { self.meta.id } + pub fn get_lp_fee(&self) -> u64 { + match self.fee_adjust_mechanism { + Some(machanism) => { + let current_ms = ic_cdk::api::time() / 1_000_000; + let decr = (current_ms - machanism.start_at) / machanism.decr_interval_ms + * machanism.rate_decr_step; + u64::max(self.fee_rate - decr, machanism.min_rate) + } + None => self.fee_rate, + } + } + /// FIXME for some reasons, we don't save the lp_fee_rate and locked_fee_rate independently pub(crate) fn charge_fee(btc: u64, fee_: u64, burn_: u64) -> (u64, u64, u64, u64) { let fee = btc * fee_ / 1_000_000u64; @@ -263,7 +307,7 @@ impl LiquidityPool { } pub(crate) fn validate_adding_liquidity( - &self, + &mut self, txid: Txid, nonce: u64, lock_time: u32, @@ -286,6 +330,17 @@ impl LiquidityPool { ))?; let x = input_coins[0].coin.clone(); let y = input_coins[1].coin.clone(); + // check if `onetime` pool + if let Some(mut mechanism) = self.fee_adjust_mechanism { + (self.states.is_empty()) + .then(|| ()) + .ok_or(ExchangeError::OnetimePool)?; + (lock_time == u32::MAX) + .then(|| ()) + .ok_or(ExchangeError::OnetimePool)?; + mechanism.start_at = ic_cdk::api::time() / 1_000_000; + } + let mut state = self.states.last().cloned().unwrap_or_default(); // check nonce matches (state.nonce == nonce) @@ -1040,7 +1095,7 @@ impl LiquidityPool { .then(|| ()) .ok_or(ExchangeError::FundsLimitExceeded)?; let (input_amount, lp_fee, locked_lp_fee, protocol_fee) = - Self::charge_fee(input_btc, self.fee_rate, self.burn_rate); + Self::charge_fee(input_btc, self.get_lp_fee(), self.burn_rate); let rune_remains = btc_supply .checked_add(input_amount) .and_then(|sum| k.checked_div(sum as u128)) @@ -1075,7 +1130,7 @@ impl LiquidityPool { let pool_btc_remains: u64 = pool_btc_remains.try_into().expect("BTC amount overflow"); let pre_charge = btc_supply - pool_btc_remains; let (offer, lp_fee, locked_lp_fee, protocol_fee) = - Self::charge_fee(pre_charge, self.fee_rate, self.burn_rate); + Self::charge_fee(pre_charge, self.get_lp_fee(), self.burn_rate); // this is the actual remains let pool_btc_remains = btc_supply - offer - protocol_fee - locked_lp_fee; // plus this to ensure the pool remains >= 546 diff --git a/swap.did b/swap.did index 242df20..3f5512a 100644 --- a/swap.did +++ b/swap.did @@ -21,6 +21,7 @@ type ExchangeError = variant { InvalidSignature; TooSmallFunds; LiquidityLocked; + OnetimePool; InvalidRuneId; InvalidPool; InvalidPsbt : text; @@ -116,6 +117,7 @@ type PoolState = record { nonce : nat64; lp_locks : vec record { text; nat32 }; }; +type PoolTemplate = variant { onetime; standard }; type PreClaimOutput = record { nonce : nat64; claim_sats : nat64; @@ -171,6 +173,7 @@ type WithdrawalOffer = record { service : { blocks_tx_records_count : () -> (Result) query; create : (text) -> (Result_1); + create_with_template : (text, PoolTemplate) -> (Result_1); donate_rich_protocol_revenue : () -> (Result_2); escape_hatch : (text, text, nat64) -> (Result_3); execute_tx : (ExecuteTxArgs) -> (Result_3); From 2d08f5035f11bcb0f9858a4f56a28e2ea1fe3762 Mon Sep 17 00:00:00 2001 From: kvvi Date: Fri, 17 Oct 2025 11:37:08 +0800 Subject: [PATCH 09/12] bidirectional donation --- src/canister.rs | 39 +++++++++++++++ src/pool.rs | 126 +++++++++++++++++++++++++++++++++++++++++++++++- swap.did | 11 +++-- 3 files changed, 169 insertions(+), 7 deletions(-) diff --git a/src/canister.rs b/src/canister.rs index a621a4a..efe3840 100644 --- a/src/canister.rs +++ b/src/canister.rs @@ -268,6 +268,24 @@ pub async fn pre_donate(pool: String, input_sats: u64) -> Result Result { + crate::ensure_online()?; + let pool = crate::with_pool(&pool, |p| p.clone()).ok_or(ExchangeError::InvalidPool)?; + let state = pool.states.last().ok_or(ExchangeError::EmptyPool)?; + let (out_rune, out_sats) = pool.wish_to_bi_donate(input_sats, input_rune)?; + Ok(DonateIntention { + input: state.utxo.clone().ok_or(ExchangeError::EmptyPool)?, + nonce: state.nonce, + out_rune, + out_sats, + }) +} + #[query] pub fn pre_self_donate() -> Result { crate::ensure_online()?; @@ -863,6 +881,27 @@ pub async fn execute_tx(args: ExecuteTxArgs) -> ExecuteTxResponse { }) .map_err(|e| e.to_string())?; } + "bi_donate" => { + let (new_state, consumed) = pool + .validate_bi_donate( + txid, + nonce, + pool_utxo_spent, + pool_utxo_received, + input_coins, + output_coins, + ) + .map_err(|e| e.to_string())?; + crate::psbt::sign(&mut psbt, &consumed, pool.base_id().to_bytes()) + .await + .map_err(|e| e.to_string())?; + crate::with_pool_mut(pool_address, |p| { + let mut pool = p.expect("already checked in pre_swap;qed"); + pool.commit(new_state); + Ok(Some(pool)) + }) + .map_err(|e| e.to_string())?; + } "self_donate" => { let (new_state, consumed) = pool .validate_self_donate(txid, nonce, pool_utxo_spent, pool_utxo_received) diff --git a/src/pool.rs b/src/pool.rs index 021abe0..7f97405 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -142,9 +142,7 @@ impl PoolState { pub fn lp(&self, key: &str) -> Liquidity { let lock_until = self.lp_locks.get(key).copied().unwrap_or_default(); - let height = crate::get_max_block().map(|b| b.block_height).unwrap_or(0); // if the lock_until is in the past, set it to 0 - let lock_until = if lock_until < height { 0 } else { lock_until }; Liquidity { user_incomes: self.lp_earnings.get(key).copied().unwrap_or_default(), user_share: self.lp.get(key).copied().unwrap_or_default(), @@ -826,6 +824,130 @@ impl LiquidityPool { Ok((state, prev_utxo)) } + pub(crate) fn wish_to_bi_donate( + &self, + input_sats: u64, + input_rune: CoinBalance, + ) -> Result<(CoinBalance, u64), ExchangeError> { + (input_sats >= crate::min_sats()) + .then(|| ()) + .ok_or(ExchangeError::TooSmallFunds)?; + if input_rune.id != self.meta.id { + return Err(ExchangeError::InvalidPool); + } + let recent_state = self.states.last().ok_or(ExchangeError::EmptyPool)?; + let total_sats = recent_state + .utxo + .as_ref() + .map(|u| u.sats) + .ok_or(ExchangeError::EmptyPool)?; + let rune_supply = recent_state.rune_supply(&self.base_id()); + (total_sats != 0 && rune_supply != 0) + .then(|| ()) + .ok_or(ExchangeError::EmptyPool)?; + Ok(( + CoinBalance { + value: rune_supply + input_rune.value, + id: self.meta.id, + }, + total_sats + input_sats, + )) + } + + pub(crate) fn validate_bi_donate( + &self, + txid: Txid, + nonce: u64, + pool_utxo_spend: Vec, + pool_utxo_receive: Vec, + input_coins: Vec, + output_coins: Vec, + ) -> Result<(PoolState, Utxo), ExchangeError> { + (input_coins.len() == 2 && output_coins.is_empty()) + .then(|| ()) + .ok_or(ExchangeError::InvalidSignPsbtArgs( + "invalid input/output coins, donate requires 2 inputs and 0 output".to_string(), + ))?; + (pool_utxo_receive.len() == 1) + .then(|| ()) + .ok_or(ExchangeError::InvalidSignPsbtArgs( + "pool_utxo_receive not found".to_string(), + ))?; + let x = input_coins[0].coin.clone(); + let y = input_coins[1].coin.clone(); + let mut state = self + .states + .last() + .cloned() + .ok_or(ExchangeError::EmptyPool)?; + // check nonce + (state.nonce == nonce) + .then(|| ()) + .ok_or(ExchangeError::PoolStateExpired(state.nonce))?; + let prev_outpoint = + pool_utxo_spend + .last() + .map(|s| s.clone()) + .ok_or(ExchangeError::InvalidSignPsbtArgs( + "pool_utxo_spend not found".to_string(), + ))?; + let prev_utxo = state.utxo.clone().ok_or(ExchangeError::EmptyPool)?; + (prev_outpoint == prev_utxo.outpoint()).then(|| ()).ok_or( + ExchangeError::InvalidSignPsbtArgs("pool_utxo_spend/pool state mismatch".to_string()), + )?; + (pool_utxo_receive.len() == 1) + .then(|| ()) + .ok_or(ExchangeError::InvalidSignPsbtArgs( + "pool_utxo_receive not found".to_string(), + ))?; + let (btc_input, rune_input) = if x.id == CoinId::btc() && y.id != CoinId::btc() { + Ok((x, y)) + } else if x.id != CoinId::btc() && y.id == CoinId::btc() { + Ok((y, x)) + } else { + Err(ExchangeError::InvalidSignPsbtArgs( + "Invalid inputs: requires 2 different input coins".to_string(), + )) + }?; + + let (out_rune, out_sats) = self.wish_to_bi_donate(btc_input.value as u64, rune_input)?; + let pool_output = pool_utxo_receive.last().map(|s| s.clone()).ok_or( + ExchangeError::InvalidSignPsbtArgs("pool_utxo_receive not found".to_string()), + )?; + ic_cdk::println!( + "pool_output: {:?}, out_sats: {}, out_rune({}): {}", + pool_output, + out_sats, + out_rune.id, + out_rune.value + ); + (pool_output.sats == out_sats + && pool_output.coins.value_of(&self.meta.id) == out_rune.value) + .then(|| ()) + .ok_or(ExchangeError::InvalidSignPsbtArgs( + "pool_utxo_receive mismatch with pre_donate".to_string(), + ))?; + let new_k = crate::sqrt(out_rune.value * (out_sats - state.incomes) as u128); + let mut new_lp = BTreeMap::new(); + for (lp, share) in state.lp.iter() { + new_lp.insert( + lp.clone(), + share + .checked_mul(new_k) + .and_then(|mul| mul.checked_div(state.k)) + .ok_or(ExchangeError::Overflow)?, + ); + } + let k_adjust = new_lp.values().sum(); + state.id = Some(txid); + state.nonce += 1; + state.k = k_adjust; + state.lp = new_lp; + state.total_btc_donation += btc_input.value as u64; + state.utxo = Some(pool_output); + Ok((state, prev_utxo)) + } + pub(crate) fn wish_to_donate( &self, input_sats: u64, diff --git a/swap.did b/swap.did index 3f5512a..167e88c 100644 --- a/swap.did +++ b/swap.did @@ -125,7 +125,7 @@ type PreClaimOutput = record { }; type Result = variant { Ok : record { nat64; nat64 }; Err : text }; type Result_1 = variant { Ok : text; Err : ExchangeError }; -type Result_10 = variant { Ok : DonateIntention; Err : ExchangeError }; +type Result_10 = variant { Ok : PreClaimOutput; Err : ExchangeError }; type Result_11 = variant { Ok : ExtractFeeOffer; Err : ExchangeError }; type Result_12 = variant { Ok : SwapOffer; Err : ExchangeError }; type Result_13 = variant { Ok : WithdrawalOffer; Err : ExchangeError }; @@ -145,7 +145,7 @@ type Result_6 = variant { }; type Result_7 = variant { Ok; Err : text }; type Result_8 = variant { Ok : LiquidityOffer; Err : ExchangeError }; -type Result_9 = variant { Ok : PreClaimOutput; Err : ExchangeError }; +type Result_9 = variant { Ok : DonateIntention; Err : ExchangeError }; type RollbackTxArgs = record { txid : text }; type SwapOffer = record { output : CoinBalance; @@ -192,10 +192,11 @@ service : { new_block : (NewBlockInfo) -> (Result_7); pause : () -> (); pre_add_liquidity : (text, CoinBalance) -> (Result_8) query; - pre_claim_revenue : (text, text) -> (Result_9) query; - pre_donate : (text, nat64) -> (Result_10) query; + pre_bi_donate : (text, nat64, CoinBalance) -> (Result_9) query; + pre_claim_revenue : (text, text) -> (Result_10) query; + pre_donate : (text, nat64) -> (Result_9) query; pre_extract_fee : (text) -> (Result_11) query; - pre_self_donate : () -> (Result_10) query; + pre_self_donate : () -> (Result_9) query; pre_swap : (text, CoinBalance) -> (Result_12) query; pre_withdraw_liquidity : (text, text, nat) -> (Result_13) query; query_blocks : () -> (Result_14) query; From de386dc87729f897ca703a7a71b1a133b33d7630 Mon Sep 17 00:00:00 2001 From: kvvi Date: Fri, 24 Oct 2025 13:36:42 +0800 Subject: [PATCH 10/12] remove small funds limit on donation --- src/pool.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/pool.rs b/src/pool.rs index 7f97405..7a85431 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -829,9 +829,6 @@ impl LiquidityPool { input_sats: u64, input_rune: CoinBalance, ) -> Result<(CoinBalance, u64), ExchangeError> { - (input_sats >= crate::min_sats()) - .then(|| ()) - .ok_or(ExchangeError::TooSmallFunds)?; if input_rune.id != self.meta.id { return Err(ExchangeError::InvalidPool); } @@ -952,9 +949,6 @@ impl LiquidityPool { &self, input_sats: u64, ) -> Result<(CoinBalance, u64), ExchangeError> { - (input_sats >= crate::min_sats()) - .then(|| ()) - .ok_or(ExchangeError::TooSmallFunds)?; let recent_state = self.states.last().ok_or(ExchangeError::EmptyPool)?; let total_sats = recent_state .utxo From de44c4ee700d489a423e3af7e7456925917fc626 Mon Sep 17 00:00:00 2001 From: kvvi Date: Sat, 25 Oct 2025 13:11:03 +0800 Subject: [PATCH 11/12] fix bug: start_at --- src/lib.rs | 2 +- src/pool.rs | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a4132a1..cb2814a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -299,7 +299,7 @@ pub(crate) fn create_empty_pool( let fee_adjust_mechanism = match template { PoolTemplate::Standard => None, PoolTemplate::Onetime => Some(FeeAdjustMechanism { - start_at: 0, + start_at: ic_cdk::api::time() / 1_000_000, decr_interval_ms: 10 * 60 * 1000, rate_decr_step: 10_000, min_rate: 100_000, diff --git a/src/pool.rs b/src/pool.rs index 7a85431..5248daa 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -308,7 +308,7 @@ impl LiquidityPool { &mut self, txid: Txid, nonce: u64, - lock_time: u32, + mut lock_time: u32, pool_utxo_spend: Vec, pool_utxo_receive: Vec, input_coins: Vec, @@ -333,9 +333,7 @@ impl LiquidityPool { (self.states.is_empty()) .then(|| ()) .ok_or(ExchangeError::OnetimePool)?; - (lock_time == u32::MAX) - .then(|| ()) - .ok_or(ExchangeError::OnetimePool)?; + lock_time = u32::MAX; mechanism.start_at = ic_cdk::api::time() / 1_000_000; } From fa620ea3b0c8e74a9d7a6d22e529b8f61cd395b6 Mon Sep 17 00:00:00 2001 From: kvvi Date: Sun, 26 Oct 2025 14:18:42 +0800 Subject: [PATCH 12/12] fix timestamp overflow --- src/lib.rs | 13 +++++++++---- src/pool.rs | 29 +++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cb2814a..e852cae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,6 +51,11 @@ pub const TESTNET_GUARDIAN_PRINCIPAL: &'static str = pub const GUARDIAN_PRINCIPAL: &'static str = "v5md3-vs7qy-se4kd-gzd2u-mi225-76rva-rt2ci-ibb2p-petro-2y7aj-hae"; +pub const ONETIME_INIT_FEE_RATE: u64 = 990_000; // 99% +pub const ONETIME_MAX_DECR: u64 = 890_000; // 89% +pub const ONETIME_DECR_INTERVAL_MS: u64 = 600_000; // 10 min +pub const ONETIME_DECR_STEP: u64 = 10_000; // 1% + #[derive(Eq, PartialEq, Clone, CandidType, Debug, Deserialize, Serialize)] pub struct Output { pub balance: CoinBalance, @@ -300,13 +305,13 @@ pub(crate) fn create_empty_pool( PoolTemplate::Standard => None, PoolTemplate::Onetime => Some(FeeAdjustMechanism { start_at: ic_cdk::api::time() / 1_000_000, - decr_interval_ms: 10 * 60 * 1000, - rate_decr_step: 10_000, - min_rate: 100_000, + decr_interval_ms: ONETIME_DECR_INTERVAL_MS, + rate_decr_step: ONETIME_DECR_STEP, + min_rate: ONETIME_INIT_FEE_RATE - ONETIME_MAX_DECR, }), }; let (lp_fee, protocol_fee) = if fee_adjust_mechanism.is_some() { - (990_000, 10_000) + (ONETIME_INIT_FEE_RATE, 10_000) } else { (DEFAULT_LP_FEE_RATE, DEFAULT_PROTOCOL_FEE_RATE) }; diff --git a/src/pool.rs b/src/pool.rs index 5248daa..8434f62 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -90,6 +90,7 @@ impl LiquidityPool { "total_btc_donation": self.states.last().map(|state| state.total_btc_donation).unwrap_or_default(), "total_rune_donation": self.states.last().map(|state| state.total_rune_donation).unwrap_or_default(), "template": self.fee_adjust_mechanism.map(|_| PoolTemplate::Onetime).unwrap_or(PoolTemplate::Standard), + "fee_adjust_mechanism": self.fee_adjust_mechanism, }); serde_json::to_string(&attr).expect("failed to serialize") } @@ -222,11 +223,12 @@ impl LiquidityPool { pub fn get_lp_fee(&self) -> u64 { match self.fee_adjust_mechanism { - Some(machanism) => { + Some(mechanism) => { let current_ms = ic_cdk::api::time() / 1_000_000; - let decr = (current_ms - machanism.start_at) / machanism.decr_interval_ms - * machanism.rate_decr_step; - u64::max(self.fee_rate - decr, machanism.min_rate) + let decr = (current_ms - mechanism.start_at) / mechanism.decr_interval_ms + * mechanism.rate_decr_step; + let decr = u64::min(decr, self.fee_rate - mechanism.min_rate); + self.fee_rate - decr } None => self.fee_rate, } @@ -1529,3 +1531,22 @@ pub fn test_price_limit() { assert!(delta.is_ok()); assert_eq!(delta.unwrap(), 909); } + +#[test] +pub fn test_fee_adjust() { + let mechanism = FeeAdjustMechanism { + start_at: 1761368803654, + decr_interval_ms: 10 * 60 * 1000, + rate_decr_step: 10_000, + min_rate: 100_000, + }; + let current_ms = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let decr = + (current_ms - mechanism.start_at) / mechanism.decr_interval_ms * mechanism.rate_decr_step; + let decr = u64::min(decr, 990_000 - mechanism.min_rate); + let fee_rate = 990_000 - decr; + assert_eq!(fee_rate, 100_000); +}