From 22a479218220e186c8496cf543a9ec0ddc29bf0a Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 18 Mar 2026 10:49:03 +0000 Subject: [PATCH 1/6] feat(bonding-curve): reset testnet protocol to canonical integer model --- .../src/contracts/bonding_curve/amm_pool.rs | 3 +- .../src/contracts/bonding_curve/canonical.rs | 371 ++---------------- .../src/contracts/bonding_curve/mod.rs | 3 +- .../src/contracts/bonding_curve/token.rs | 12 +- lib-types/src/bonding_curve.rs | 237 +++++------ lib-types/src/lib.rs | 4 +- 6 files changed, 131 insertions(+), 499 deletions(-) diff --git a/lib-blockchain/src/contracts/bonding_curve/amm_pool.rs b/lib-blockchain/src/contracts/bonding_curve/amm_pool.rs index ed3a7248..7c04d544 100644 --- a/lib-blockchain/src/contracts/bonding_curve/amm_pool.rs +++ b/lib-blockchain/src/contracts/bonding_curve/amm_pool.rs @@ -66,8 +66,7 @@ use serde::{Deserialize, Serialize}; /// Minimum liquidity required for AMM pool creation. /// Prevents division by zero attacks and ensures meaningful liquidity. -/// 0.01 SOV = 10^16 atomic units (18-decimal, not 1_000_000 which is 10^-12 SOV). -pub const MINIMUM_AMM_LIQUIDITY: u128 = 10_000_000_000_000_000; // 0.01 SOV at 18 decimals +pub const MINIMUM_AMM_LIQUIDITY: u128 = 1_000_000; // 0.01 SOV or equivalent /// AMM fee in basis points for graduated pools (0.3% = 30 bps). /// Lower than standard 1% to encourage trading post-graduation. diff --git a/lib-blockchain/src/contracts/bonding_curve/canonical.rs b/lib-blockchain/src/contracts/bonding_curve/canonical.rs index 11f13d45..f4db04f9 100644 --- a/lib-blockchain/src/contracts/bonding_curve/canonical.rs +++ b/lib-blockchain/src/contracts/bonding_curve/canonical.rs @@ -2,11 +2,6 @@ pub use lib_types::{ BondingCurveBand as Band, BondingCurveBuyReceipt, BondingCurveBuyTx, BondingCurveSellReceipt, BondingCurveSellTx, CBE_MAX_SUPPLY, TOKEN_SCALE_18, }; -use primitive_types::U256; - -use crate::contracts::utils::{ - integer_sqrt_u256, mul_div_floor_u128, u256_to_u128, MathError, -}; pub const SCALE: u128 = TOKEN_SCALE_18; pub const SLOPE_DEN: u128 = 100_000_000_000_000; @@ -15,11 +10,11 @@ pub const MAX_GROSS_SOV_PER_TX: u128 = 1_000_000_000_000_000_000_000_000; pub const MAX_DELTA_S_PER_TX: u128 = 100_000_000_000 * SCALE; pub const MAX_SUPPLY: u128 = CBE_MAX_SUPPLY; -pub const P_START_0: u128 = 313_345_700_000_000; -pub const P_START_1: u128 = 413_345_700_000_000; -pub const P_START_2: u128 = 813_345_700_000_000; -pub const P_START_3: u128 = 1_713_345_700_000_000; -pub const P_START_4: u128 = 2_713_345_700_000_000; +pub const INTERCEPT_0: i128 = 313_345_700_000_000; +pub const INTERCEPT_1: i128 = 213_345_700_000_000; +pub const INTERCEPT_2: i128 = -86_654_300_000_000; +pub const INTERCEPT_3: i128 = -686_654_300_000_000; +pub const INTERCEPT_4: i128 = -1_536_654_300_000_000; pub const BANDS: [Band; BAND_COUNT] = [ Band { @@ -28,7 +23,7 @@ pub const BANDS: [Band; BAND_COUNT] = [ end_supply: 10_000_000_000u128 * SCALE, slope_num: 1, slope_den: SLOPE_DEN, - p_start: P_START_0, + intercept: INTERCEPT_0, }, Band { index: 1, @@ -36,7 +31,7 @@ pub const BANDS: [Band; BAND_COUNT] = [ end_supply: 30_000_000_000u128 * SCALE, slope_num: 2, slope_den: SLOPE_DEN, - p_start: P_START_1, + intercept: INTERCEPT_1, }, Band { index: 2, @@ -44,7 +39,7 @@ pub const BANDS: [Band; BAND_COUNT] = [ end_supply: 60_000_000_000u128 * SCALE, slope_num: 3, slope_den: SLOPE_DEN, - p_start: P_START_2, + intercept: INTERCEPT_2, }, Band { index: 3, @@ -52,7 +47,7 @@ pub const BANDS: [Band; BAND_COUNT] = [ end_supply: 85_000_000_000u128 * SCALE, slope_num: 4, slope_den: SLOPE_DEN, - p_start: P_START_3, + intercept: INTERCEPT_3, }, Band { index: 4, @@ -60,260 +55,47 @@ pub const BANDS: [Band; BAND_COUNT] = [ end_supply: MAX_SUPPLY, slope_num: 5, slope_den: SLOPE_DEN, - p_start: P_START_4, + intercept: INTERCEPT_4, }, ]; -fn price_at_supply_in_band(supply: u128, band: &Band) -> Result { - if supply < band.start_supply || supply > band.end_supply { - return Err(MathError::Overflow); - } - - let band_local_supply = band_local_supply(supply, band)?; - let slope_component = mul_div_floor_u128(band.slope_num, band_local_supply, band.slope_den)?; - band.p_start - .checked_add(slope_component) - .ok_or(MathError::Overflow) -} - -pub fn band_for_supply(supply: u128) -> Result { - if supply > MAX_SUPPLY { - return Err(MathError::Overflow); - } - +pub fn band_for_supply(supply: u128) -> Band { for band in BANDS { let in_band = if band.index as usize == BAND_COUNT - 1 { supply >= band.start_supply && supply <= band.end_supply } else { supply >= band.start_supply && supply < band.end_supply }; - if in_band { - return Ok(band); - } - } - - Err(MathError::Overflow) -} - -pub fn band_for_redemption_supply(supply: u128) -> Band { - for band in BANDS { - let in_band = if band.index == 0 { - supply >= band.start_supply && supply <= band.end_supply - } else { - supply > band.start_supply && supply <= band.end_supply - }; if in_band { return band; } } - BANDS[0] -} - -pub fn band_local_supply(supply: u128, band: &Band) -> Result { - supply - .checked_sub(band.start_supply) - .ok_or(MathError::Overflow) + BANDS[BAND_COUNT - 1] } pub fn price_at_supply(supply: u128) -> u128 { - let band = band_for_supply(supply).expect("canonical price supply out of range"); - price_at_supply_in_band(supply, &band).expect("canonical price evaluation failed") + let band = band_for_supply(supply); + let slope_component = band + .slope_num + .checked_mul(supply) + .and_then(|v| v.checked_div(band.slope_den)) + .expect("canonical price slope component overflow"); + let price = band.intercept + slope_component as i128; + price.max(0) as u128 } pub fn integer_sqrt(n: u128) -> u128 { - u256_to_u128(integer_sqrt_u256(U256::from(n))).expect("canonical integer_sqrt downcast overflow") -} - -pub fn cost_single_band(s_from: u128, s_to: u128, band: &Band) -> Result { - if s_from > s_to || s_from < band.start_supply || s_to > band.end_supply { - return Err(MathError::Overflow); - } - - let from_local = band_local_supply(s_from, band)?; - let to_local = band_local_supply(s_to, band)?; - let delta_s = s_to.checked_sub(s_from).ok_or(MathError::Overflow)?; - let sum_local = U256::from(to_local) - .checked_add(U256::from(from_local)) - .ok_or(MathError::Overflow)?; - - let numerator = U256::from(band.slope_num) - .checked_mul(sum_local) - .ok_or(MathError::Overflow)? - .checked_mul(U256::from(delta_s)) - .ok_or(MathError::Overflow)?; - let denominator = U256::from(2u8) - .checked_mul(U256::from(band.slope_den)) - .ok_or(MathError::Overflow)? - .checked_mul(U256::from(SCALE)) - .ok_or(MathError::Overflow)?; - let term1 = if numerator.is_zero() { - U256::zero() - } else { - if denominator.is_zero() { - return Err(MathError::DivisionByZero); - } - numerator / denominator - }; - - let term2 = U256::from(band.p_start) - .checked_mul(U256::from(delta_s)) - .ok_or(MathError::Overflow)? - / U256::from(SCALE); - let cost = term1.checked_add(term2).ok_or(MathError::Overflow)?; - - u256_to_u128(cost) -} - -pub fn inverse_mint(reserve_credit: u128, s_c: u128, band: &Band) -> Result { - if s_c < band.start_supply || s_c > band.end_supply { - return Err(MathError::Overflow); - } - - if band.slope_num == 0 { - return mul_div_floor_u128(reserve_credit, SCALE, band.p_start); - } - - let p_local = price_at_supply_in_band(s_c, band)?; - let p_local_u256 = U256::from(p_local); - let p_local_sq = p_local_u256 - .checked_mul(p_local_u256) - .ok_or(MathError::Overflow)?; - let two_m_r = U256::from(2u8) - .checked_mul(U256::from(band.slope_num)) - .ok_or(MathError::Overflow)? - .checked_mul(U256::from(reserve_credit)) - .ok_or(MathError::Overflow)? - .checked_mul(U256::from(SCALE)) - .ok_or(MathError::Overflow)? - / U256::from(band.slope_den); - let discriminant = p_local_sq.checked_add(two_m_r).ok_or(MathError::Overflow)?; - let sqrt_disc = integer_sqrt_u256(discriminant); - let numerator = sqrt_disc - .checked_sub(p_local_u256) - .ok_or(MathError::Overflow)?; - let delta_s = numerator - .checked_mul(U256::from(band.slope_den)) - .ok_or(MathError::Overflow)? - / U256::from(band.slope_num); - - u256_to_u128(delta_s) -} - -pub fn cost_to_mint(delta_s: u128, s_c: u128) -> Result { - let target_supply = s_c.checked_add(delta_s).ok_or(MathError::Overflow)?; - if target_supply > MAX_SUPPLY { - return Err(MathError::Overflow); - } - - let mut total_cost = 0u128; - let mut current_supply = s_c; - - while current_supply < target_supply { - let band = band_for_supply(current_supply)?; - let band_end = band.end_supply.min(target_supply); - let band_cost = cost_single_band(current_supply, band_end, &band)?; - total_cost = total_cost - .checked_add(band_cost) - .ok_or(MathError::Overflow)?; - current_supply = band_end; - } - - Ok(total_cost) -} - -pub fn mint_with_reserve(reserve_credit: u128, s_c: u128) -> Result { - if s_c > MAX_SUPPLY { - return Err(MathError::Overflow); - } - - let mut remaining_reserve = reserve_credit; - let mut minted = 0u128; - let mut current_supply = s_c; - - while remaining_reserve > 0 && current_supply < MAX_SUPPLY { - let band = band_for_supply(current_supply)?; - let band_capacity = band - .end_supply - .checked_sub(current_supply) - .ok_or(MathError::Overflow)?; - if band_capacity == 0 { - break; - } - - let full_band_cost = cost_single_band(current_supply, band.end_supply, &band)?; - if full_band_cost <= remaining_reserve { - minted = minted - .checked_add(band_capacity) - .ok_or(MathError::Overflow)?; - remaining_reserve = remaining_reserve - .checked_sub(full_band_cost) - .ok_or(MathError::Overflow)?; - current_supply = band.end_supply; - continue; - } - - let estimate = inverse_mint(remaining_reserve, current_supply, &band)?.min(band_capacity); - let mut low = 0u128; - let mut high = estimate; - - if cost_single_band(current_supply, current_supply + high, &band)? <= remaining_reserve { - high = band_capacity; - } - - while low < high { - let mid = low - .checked_add(high) - .ok_or(MathError::Overflow)? - .checked_add(1) - .ok_or(MathError::Overflow)? - / 2; - let mid_cost = cost_single_band(current_supply, current_supply + mid, &band)?; - if mid_cost <= remaining_reserve { - low = mid; - } else { - high = mid - 1; - } - } - - let delta_s = low; - - minted = minted.checked_add(delta_s).ok_or(MathError::Overflow)?; - break; + if n < 2 { + return n; } - Ok(minted) -} - -pub fn payout_for_burn(amount_cbe: u128, s_c: u128) -> Result { - if amount_cbe > s_c { - return Err(MathError::Overflow); - } - - let mut remaining_burn = amount_cbe; - let mut current_supply = s_c; - let mut total_payout = 0u128; - - while remaining_burn > 0 { - let band = band_for_redemption_supply(current_supply); - let band_floor = current_supply - .checked_sub(remaining_burn) - .ok_or(MathError::Overflow)? - .max(band.start_supply); - let band_payout = cost_single_band(band_floor, current_supply, &band)?; - total_payout = total_payout - .checked_add(band_payout) - .ok_or(MathError::Overflow)?; - - let burned_here = current_supply - .checked_sub(band_floor) - .ok_or(MathError::Overflow)?; - remaining_burn = remaining_burn - .checked_sub(burned_here) - .ok_or(MathError::Overflow)?; - current_supply = band_floor; + let mut x0 = n; + let mut x1 = (x0 + n / x0) / 2; + while x1 < x0 { + x0 = x1; + x1 = (x0 + n / x0) / 2; } - - Ok(total_payout) + x0 } #[cfg(test)] mod tests { @@ -332,15 +114,15 @@ mod tests { #[test] fn price_is_continuous_at_boundaries() { for pair in BANDS.windows(2) { - let left = price_at_supply_in_band(pair[0].end_supply, &pair[0]).unwrap(); - let right = price_at_supply_in_band(pair[1].start_supply, &pair[1]).unwrap(); + let left = price_at_supply(pair[0].end_supply); + let right = price_at_supply(pair[1].start_supply); assert_eq!(left, right); } } #[test] fn price_matches_known_initial_value() { - assert_eq!(price_at_supply(0), P_START_0); + assert_eq!(price_at_supply(0), INTERCEPT_0 as u128); } #[test] @@ -365,95 +147,4 @@ mod tests { assert!(p3 > p2); assert!(p4 > p3); } - - #[test] - fn band_for_supply_rejects_out_of_range_supply() { - assert_eq!(band_for_supply(MAX_SUPPLY + 1), Err(MathError::Overflow)); - } - - #[test] - fn cost_single_band_zero_width_is_zero() { - let band = BANDS[0]; - assert_eq!( - cost_single_band(band.start_supply, band.start_supply, &band).unwrap(), - 0 - ); - } - - #[test] - fn cost_single_band_is_monotonic_with_range() { - let band = BANDS[0]; - let one_token = SCALE; - let cost_small = - cost_single_band(band.start_supply, band.start_supply + one_token, &band).unwrap(); - let cost_large = - cost_single_band(band.start_supply, band.start_supply + 10 * one_token, &band) - .unwrap(); - assert!(cost_large > cost_small); - } - - #[test] - fn inverse_mint_flat_band_special_case_uses_division() { - let band = Band { - index: 99, - start_supply: 0, - end_supply: 100 * SCALE, - slope_num: 0, - slope_den: 1, - p_start: P_START_0, - }; - let reserve_credit = 10 * SCALE; - let minted = inverse_mint(reserve_credit, 0, &band).unwrap(); - assert_eq!( - minted, - mul_div_floor_u128(reserve_credit, SCALE, band.p_start).unwrap() - ); - } - - #[test] - fn inverse_mint_returns_positive_amount_for_positive_credit() { - let band = BANDS[0]; - let minted = inverse_mint(4 * SCALE, band.start_supply, &band).unwrap(); - assert!(minted > 0); - } - - #[test] - fn cost_to_mint_matches_single_band_cost_when_no_boundary_is_crossed() { - let band = BANDS[0]; - let delta = 50 * SCALE; - assert_eq!( - cost_to_mint(delta, band.start_supply).unwrap(), - cost_single_band(band.start_supply, band.start_supply + delta, &band).unwrap() - ); - } - - #[test] - fn mint_with_reserve_consumes_full_band_when_reserve_matches_boundary_cost() { - let band = BANDS[0]; - let reserve_credit = cost_single_band(band.start_supply, band.end_supply, &band).unwrap(); - assert_eq!( - mint_with_reserve(reserve_credit, band.start_supply).unwrap(), - band.end_supply - band.start_supply - ); - } - - #[test] - fn payout_for_burn_matches_single_band_cost_when_no_boundary_is_crossed() { - let band = BANDS[0]; - let amount = 75 * SCALE; - let current_supply = band.start_supply + amount; - assert_eq!( - payout_for_burn(amount, current_supply).unwrap(), - cost_single_band(band.start_supply, current_supply, &band).unwrap() - ); - } - - #[test] - fn payout_for_burn_crosses_boundary_right_to_left() { - let current_supply = BANDS[1].start_supply + 10 * SCALE; - let amount = 20 * SCALE; - let expected = cost_single_band(BANDS[0].end_supply - 10 * SCALE, BANDS[0].end_supply, &BANDS[0]).unwrap() - + cost_single_band(BANDS[1].start_supply, current_supply, &BANDS[1]).unwrap(); - assert_eq!(payout_for_burn(amount, current_supply).unwrap(), expected); - } } diff --git a/lib-blockchain/src/contracts/bonding_curve/mod.rs b/lib-blockchain/src/contracts/bonding_curve/mod.rs index 2eca112f..ff10de40 100644 --- a/lib-blockchain/src/contracts/bonding_curve/mod.rs +++ b/lib-blockchain/src/contracts/bonding_curve/mod.rs @@ -40,8 +40,7 @@ pub use amm_pool::{ pub use canonical::{ Band as CanonicalBand, BondingCurveBuyReceipt, BondingCurveBuyTx, BondingCurveSellReceipt, BondingCurveSellTx, BAND_COUNT as CANONICAL_BAND_COUNT, BANDS as CANONICAL_BANDS, - MAX_DELTA_S_PER_TX, MAX_GROSS_SOV_PER_TX, MAX_SUPPLY as CANONICAL_MAX_SUPPLY, - P_START_0, + INTERCEPT_0, MAX_DELTA_S_PER_TX, MAX_GROSS_SOV_PER_TX, MAX_SUPPLY as CANONICAL_MAX_SUPPLY, SCALE as CANONICAL_SCALE, integer_sqrt, }; // Issue #1849: Re-export POL pool diff --git a/lib-blockchain/src/contracts/bonding_curve/token.rs b/lib-blockchain/src/contracts/bonding_curve/token.rs index 9cc5020d..bf2b7cc3 100644 --- a/lib-blockchain/src/contracts/bonding_curve/token.rs +++ b/lib-blockchain/src/contracts/bonding_curve/token.rs @@ -262,14 +262,14 @@ impl BondingCurveToken { let token_amount = self.calculate_buy(stable_amount)?; // Issue #1844: Split purchase 40% reserve / 60% treasury. - // Compute treasury as floor(60%) and assign remainder to reserve so that - // reserve + treasury == stable_amount exactly (no atomic units destroyed). - let to_treasury = stable_amount - .checked_mul(3) + // Use u128 intermediate to prevent u64 overflow on large stable_amount values; + // use try_into() to explicitly guard the final cast back to u64. + let to_reserve = stable_amount + .checked_mul(RESERVE_SPLIT_NUMERATOR) .ok_or(CurveError::Overflow)? - .checked_div(5) + .checked_div(RESERVE_SPLIT_DENOMINATOR) .ok_or(CurveError::Overflow)?; - let to_reserve = stable_amount - to_treasury; + let to_treasury = stable_amount - to_reserve; // Update state self.reserve_balance = self diff --git a/lib-types/src/bonding_curve.rs b/lib-types/src/bonding_curve.rs index fd04b9c7..6cb23b92 100644 --- a/lib-types/src/bonding_curve.rs +++ b/lib-types/src/bonding_curve.rs @@ -1,95 +1,40 @@ -use serde::{Deserialize, Serialize}; +use serde::de::{Error, SeqAccess, Visitor}; +use serde::ser::SerializeTuple; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct BondingCurveBand { - pub index: u64, + pub index: u8, pub start_supply: u128, pub end_supply: u128, pub slope_num: u128, pub slope_den: u128, - pub p_start: u128, -} - -/// 48-bit nonce packed as six big-endian bytes. -#[repr(transparent)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct Nonce48(pub [u8; 6]); - -impl Nonce48 { - pub const MAX: u64 = (1u64 << 48) - 1; - - pub const fn zero() -> Self { - Self([0; 6]) - } - - pub fn from_u64(value: u64) -> Option { - if value > Self::MAX { - return None; - } - - Some(Self([ - ((value >> 40) & 0xff) as u8, - ((value >> 32) & 0xff) as u8, - ((value >> 24) & 0xff) as u8, - ((value >> 16) & 0xff) as u8, - ((value >> 8) & 0xff) as u8, - (value & 0xff) as u8, - ])) - } - - pub const fn to_u64(self) -> u64 { - ((self.0[0] as u64) << 40) - | ((self.0[1] as u64) << 32) - | ((self.0[2] as u64) << 24) - | ((self.0[3] as u64) << 16) - | ((self.0[4] as u64) << 8) - | (self.0[5] as u64) - } - - pub const fn to_be_bytes(self) -> [u8; 6] { - self.0 - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct BondingCurveEconomicState { - pub s_c: u128, - pub reserve_balance: u128, - pub treasury_balance: u128, - pub graduated: bool, - pub sell_enabled: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct BondingCurveAccountState { - pub key_id: [u8; 32], - pub balance_cbe: u128, - pub balance_sov: u128, - pub next_nonce: Nonce48, + pub intercept: i128, } #[repr(C)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct BondingCurveBuyTx { - pub action: u8, - pub chain_id: u8, - pub nonce: Nonce48, pub sender: [u8; 32], - pub amount_in: u128, - pub max_price: u128, - pub expected_s_c: u128, + pub gross_sov: u128, + pub min_cbe: u128, + pub nonce: u64, + pub deadline: u64, + #[serde(with = "signature_serde")] + pub signature: [u8; 64], } #[repr(C)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct BondingCurveSellTx { - pub action: u8, - pub chain_id: u8, - pub nonce: Nonce48, pub sender: [u8; 32], - pub amount_cbe: u128, - pub min_payout: u128, - pub expected_s_c: u128, + pub delta_s: u128, + pub min_sov: u128, + pub nonce: u64, + pub deadline: u64, + #[serde(with = "signature_serde")] + pub signature: [u8; 64], } #[repr(C)] @@ -111,104 +56,102 @@ pub struct BondingCurveSellReceipt { pub price_post: u128, } -#[cfg(test)] -mod tests { +mod signature_serde { use super::*; - const MAX_AMOUNT_PER_TX: u128 = 100_000_000_000u128 * 1_000_000_000_000_000_000u128; - - #[test] - fn nonce48_round_trip_through_u64() { - let nonce = Nonce48::from_u64(42).unwrap(); - assert_eq!(nonce.to_u64(), 42); - assert_eq!(nonce.to_be_bytes(), [0, 0, 0, 0, 0, 42]); + pub fn serialize(data: &[u8; 64], serializer: S) -> Result + where + S: Serializer, + { + let mut tuple = serializer.serialize_tuple(64)?; + for byte in data { + tuple.serialize_element(byte)?; + } + tuple.end() } - #[test] - fn nonce48_rejects_values_above_max() { - assert!(Nonce48::from_u64(Nonce48::MAX).is_some()); - assert!(Nonce48::from_u64(Nonce48::MAX + 1).is_none()); + pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error> + where + D: Deserializer<'de>, + { + struct SignatureVisitor; + + impl<'de> Visitor<'de> for SignatureVisitor { + type Value = [u8; 64]; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a 64-byte signature") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut out = [0u8; 64]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = seq + .next_element()? + .ok_or_else(|| A::Error::invalid_length(i, &self))?; + } + Ok(out) + } + } + + deserializer.deserialize_tuple(64, SignatureVisitor) } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAX_GROSS_SOV_PER_TX: u128 = 1_000_000_000_000_000_000_000_000; + const MAX_DELTA_S_PER_TX: u128 = 100_000_000_000u128 * 1_000_000_000_000_000_000u128; #[test] - fn buy_tx_bincode_size_is_fixed_to_payload_width() { + fn buy_tx_bincode_size_is_fixed() { let a = BondingCurveBuyTx { - action: 0x01, - chain_id: 0x02, - nonce: Nonce48::from_u64(3).unwrap(), sender: [1u8; 32], - amount_in: 1, - max_price: 2, - expected_s_c: 4, + gross_sov: 1, + min_cbe: 2, + nonce: 3, + deadline: 4, + signature: [5u8; 64], }; let b = BondingCurveBuyTx { - action: 0x01, - chain_id: 0xff, - nonce: Nonce48::from_u64(Nonce48::MAX).unwrap(), sender: [9u8; 32], - amount_in: MAX_AMOUNT_PER_TX, - max_price: MAX_AMOUNT_PER_TX - 1, - expected_s_c: MAX_AMOUNT_PER_TX - 2, + gross_sov: MAX_GROSS_SOV_PER_TX, + min_cbe: MAX_DELTA_S_PER_TX, + nonce: u64::MAX, + deadline: u64::MAX - 1, + signature: [7u8; 64], }; - assert_eq!(bincode::serialize(&a).unwrap().len(), 88); - assert_eq!(bincode::serialize(&b).unwrap().len(), 88); + assert_eq!(bincode::serialize(&a).unwrap().len(), 144); + assert_eq!(bincode::serialize(&b).unwrap().len(), 144); } #[test] - fn sell_tx_bincode_size_is_fixed_to_payload_width() { + fn sell_tx_bincode_size_is_fixed() { let a = BondingCurveSellTx { - action: 0x02, - chain_id: 0x03, - nonce: Nonce48::from_u64(3).unwrap(), sender: [1u8; 32], - amount_cbe: 1, - min_payout: 2, - expected_s_c: 4, + delta_s: 1, + min_sov: 2, + nonce: 3, + deadline: 4, + signature: [5u8; 64], }; let b = BondingCurveSellTx { - action: 0x02, - chain_id: 0xfe, - nonce: Nonce48::from_u64(Nonce48::MAX).unwrap(), sender: [9u8; 32], - amount_cbe: MAX_AMOUNT_PER_TX, - min_payout: MAX_AMOUNT_PER_TX - 1, - expected_s_c: MAX_AMOUNT_PER_TX - 2, - }; - - assert_eq!(bincode::serialize(&a).unwrap().len(), 88); - assert_eq!(bincode::serialize(&b).unwrap().len(), 88); - } - - #[test] - fn economic_state_round_trips_through_bincode() { - let state = BondingCurveEconomicState { - s_c: 1, - reserve_balance: 2, - treasury_balance: 3, - graduated: true, - sell_enabled: false, + delta_s: MAX_DELTA_S_PER_TX, + min_sov: MAX_GROSS_SOV_PER_TX, + nonce: u64::MAX, + deadline: u64::MAX - 1, + signature: [7u8; 64], }; - let encoded = bincode::serialize(&state).unwrap(); - let decoded: BondingCurveEconomicState = bincode::deserialize(&encoded).unwrap(); - - assert_eq!(decoded, state); - } - - #[test] - fn account_state_round_trips_through_bincode() { - let account = BondingCurveAccountState { - key_id: [7u8; 32], - balance_cbe: 8, - balance_sov: 9, - next_nonce: Nonce48::from_u64(10).unwrap(), - }; - - let encoded = bincode::serialize(&account).unwrap(); - let decoded: BondingCurveAccountState = bincode::deserialize(&encoded).unwrap(); - - assert_eq!(decoded, account); + assert_eq!(bincode::serialize(&a).unwrap().len(), 144); + assert_eq!(bincode::serialize(&b).unwrap().len(), 144); } #[test] diff --git a/lib-types/src/lib.rs b/lib-types/src/lib.rs index 24b91618..62416e69 100644 --- a/lib-types/src/lib.rs +++ b/lib-types/src/lib.rs @@ -22,8 +22,8 @@ pub use primitives::{Address, Amount, BlockHash, BlockHeight, Bps, TokenId, TxHa pub use chunk::*; pub use bonding_curve::{ - BondingCurveAccountState, BondingCurveBand, BondingCurveBuyReceipt, BondingCurveBuyTx, - BondingCurveEconomicState, BondingCurveSellReceipt, BondingCurveSellTx, Nonce48, + BondingCurveBand, BondingCurveBuyReceipt, BondingCurveBuyTx, BondingCurveSellReceipt, + BondingCurveSellTx, }; pub use consensus::{ ConsensusConfig, ConsensusStep, ConsensusType, FeeDistributionResult, SlashType, From 4e1624fb26f0dc7cae6be58e9fcb42b1a0c56213 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 18 Mar 2026 12:32:56 +0000 Subject: [PATCH 2/6] fix(zhtp): align bonding curve handlers with u128 core --- zhtp/src/api/handlers/bonding_curve/api_v1.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/zhtp/src/api/handlers/bonding_curve/api_v1.rs b/zhtp/src/api/handlers/bonding_curve/api_v1.rs index 9ee2c951..c13f65cc 100644 --- a/zhtp/src/api/handlers/bonding_curve/api_v1.rs +++ b/zhtp/src/api/handlers/bonding_curve/api_v1.rs @@ -648,10 +648,7 @@ impl BondingCurveApiHandler { /// Get current supply band for CBE fn get_current_band(&self, supply: u128) -> u32 { - band_for_supply(supply) - .ok() - .and_then(|b| u32::try_from(b.index + 1).ok()) - .unwrap_or(u32::MAX) + u32::try_from(band_for_supply(supply).index + 1).unwrap_or(u32::MAX) } /// Get requester public key from authenticated request From c8b8a80dd61bd957eca5f594a554c1d20fa7c1ca Mon Sep 17 00:00:00 2001 From: Hugo Perez Date: Thu, 19 Mar 2026 13:39:04 +0000 Subject: [PATCH 3/6] Implement canonical bonding curve math foundation --- .../src/contracts/bonding_curve/canonical.rs | 371 ++++++++++++++++-- .../src/contracts/bonding_curve/mod.rs | 3 +- lib-types/src/bonding_curve.rs | 237 ++++++----- lib-types/src/lib.rs | 4 +- 4 files changed, 491 insertions(+), 124 deletions(-) diff --git a/lib-blockchain/src/contracts/bonding_curve/canonical.rs b/lib-blockchain/src/contracts/bonding_curve/canonical.rs index f4db04f9..11f13d45 100644 --- a/lib-blockchain/src/contracts/bonding_curve/canonical.rs +++ b/lib-blockchain/src/contracts/bonding_curve/canonical.rs @@ -2,6 +2,11 @@ pub use lib_types::{ BondingCurveBand as Band, BondingCurveBuyReceipt, BondingCurveBuyTx, BondingCurveSellReceipt, BondingCurveSellTx, CBE_MAX_SUPPLY, TOKEN_SCALE_18, }; +use primitive_types::U256; + +use crate::contracts::utils::{ + integer_sqrt_u256, mul_div_floor_u128, u256_to_u128, MathError, +}; pub const SCALE: u128 = TOKEN_SCALE_18; pub const SLOPE_DEN: u128 = 100_000_000_000_000; @@ -10,11 +15,11 @@ pub const MAX_GROSS_SOV_PER_TX: u128 = 1_000_000_000_000_000_000_000_000; pub const MAX_DELTA_S_PER_TX: u128 = 100_000_000_000 * SCALE; pub const MAX_SUPPLY: u128 = CBE_MAX_SUPPLY; -pub const INTERCEPT_0: i128 = 313_345_700_000_000; -pub const INTERCEPT_1: i128 = 213_345_700_000_000; -pub const INTERCEPT_2: i128 = -86_654_300_000_000; -pub const INTERCEPT_3: i128 = -686_654_300_000_000; -pub const INTERCEPT_4: i128 = -1_536_654_300_000_000; +pub const P_START_0: u128 = 313_345_700_000_000; +pub const P_START_1: u128 = 413_345_700_000_000; +pub const P_START_2: u128 = 813_345_700_000_000; +pub const P_START_3: u128 = 1_713_345_700_000_000; +pub const P_START_4: u128 = 2_713_345_700_000_000; pub const BANDS: [Band; BAND_COUNT] = [ Band { @@ -23,7 +28,7 @@ pub const BANDS: [Band; BAND_COUNT] = [ end_supply: 10_000_000_000u128 * SCALE, slope_num: 1, slope_den: SLOPE_DEN, - intercept: INTERCEPT_0, + p_start: P_START_0, }, Band { index: 1, @@ -31,7 +36,7 @@ pub const BANDS: [Band; BAND_COUNT] = [ end_supply: 30_000_000_000u128 * SCALE, slope_num: 2, slope_den: SLOPE_DEN, - intercept: INTERCEPT_1, + p_start: P_START_1, }, Band { index: 2, @@ -39,7 +44,7 @@ pub const BANDS: [Band; BAND_COUNT] = [ end_supply: 60_000_000_000u128 * SCALE, slope_num: 3, slope_den: SLOPE_DEN, - intercept: INTERCEPT_2, + p_start: P_START_2, }, Band { index: 3, @@ -47,7 +52,7 @@ pub const BANDS: [Band; BAND_COUNT] = [ end_supply: 85_000_000_000u128 * SCALE, slope_num: 4, slope_den: SLOPE_DEN, - intercept: INTERCEPT_3, + p_start: P_START_3, }, Band { index: 4, @@ -55,47 +60,260 @@ pub const BANDS: [Band; BAND_COUNT] = [ end_supply: MAX_SUPPLY, slope_num: 5, slope_den: SLOPE_DEN, - intercept: INTERCEPT_4, + p_start: P_START_4, }, ]; -pub fn band_for_supply(supply: u128) -> Band { +fn price_at_supply_in_band(supply: u128, band: &Band) -> Result { + if supply < band.start_supply || supply > band.end_supply { + return Err(MathError::Overflow); + } + + let band_local_supply = band_local_supply(supply, band)?; + let slope_component = mul_div_floor_u128(band.slope_num, band_local_supply, band.slope_den)?; + band.p_start + .checked_add(slope_component) + .ok_or(MathError::Overflow) +} + +pub fn band_for_supply(supply: u128) -> Result { + if supply > MAX_SUPPLY { + return Err(MathError::Overflow); + } + for band in BANDS { let in_band = if band.index as usize == BAND_COUNT - 1 { supply >= band.start_supply && supply <= band.end_supply } else { supply >= band.start_supply && supply < band.end_supply }; + if in_band { + return Ok(band); + } + } + + Err(MathError::Overflow) +} + +pub fn band_for_redemption_supply(supply: u128) -> Band { + for band in BANDS { + let in_band = if band.index == 0 { + supply >= band.start_supply && supply <= band.end_supply + } else { + supply > band.start_supply && supply <= band.end_supply + }; if in_band { return band; } } - BANDS[BAND_COUNT - 1] + BANDS[0] +} + +pub fn band_local_supply(supply: u128, band: &Band) -> Result { + supply + .checked_sub(band.start_supply) + .ok_or(MathError::Overflow) } pub fn price_at_supply(supply: u128) -> u128 { - let band = band_for_supply(supply); - let slope_component = band - .slope_num - .checked_mul(supply) - .and_then(|v| v.checked_div(band.slope_den)) - .expect("canonical price slope component overflow"); - let price = band.intercept + slope_component as i128; - price.max(0) as u128 + let band = band_for_supply(supply).expect("canonical price supply out of range"); + price_at_supply_in_band(supply, &band).expect("canonical price evaluation failed") } pub fn integer_sqrt(n: u128) -> u128 { - if n < 2 { - return n; + u256_to_u128(integer_sqrt_u256(U256::from(n))).expect("canonical integer_sqrt downcast overflow") +} + +pub fn cost_single_band(s_from: u128, s_to: u128, band: &Band) -> Result { + if s_from > s_to || s_from < band.start_supply || s_to > band.end_supply { + return Err(MathError::Overflow); + } + + let from_local = band_local_supply(s_from, band)?; + let to_local = band_local_supply(s_to, band)?; + let delta_s = s_to.checked_sub(s_from).ok_or(MathError::Overflow)?; + let sum_local = U256::from(to_local) + .checked_add(U256::from(from_local)) + .ok_or(MathError::Overflow)?; + + let numerator = U256::from(band.slope_num) + .checked_mul(sum_local) + .ok_or(MathError::Overflow)? + .checked_mul(U256::from(delta_s)) + .ok_or(MathError::Overflow)?; + let denominator = U256::from(2u8) + .checked_mul(U256::from(band.slope_den)) + .ok_or(MathError::Overflow)? + .checked_mul(U256::from(SCALE)) + .ok_or(MathError::Overflow)?; + let term1 = if numerator.is_zero() { + U256::zero() + } else { + if denominator.is_zero() { + return Err(MathError::DivisionByZero); + } + numerator / denominator + }; + + let term2 = U256::from(band.p_start) + .checked_mul(U256::from(delta_s)) + .ok_or(MathError::Overflow)? + / U256::from(SCALE); + let cost = term1.checked_add(term2).ok_or(MathError::Overflow)?; + + u256_to_u128(cost) +} + +pub fn inverse_mint(reserve_credit: u128, s_c: u128, band: &Band) -> Result { + if s_c < band.start_supply || s_c > band.end_supply { + return Err(MathError::Overflow); + } + + if band.slope_num == 0 { + return mul_div_floor_u128(reserve_credit, SCALE, band.p_start); + } + + let p_local = price_at_supply_in_band(s_c, band)?; + let p_local_u256 = U256::from(p_local); + let p_local_sq = p_local_u256 + .checked_mul(p_local_u256) + .ok_or(MathError::Overflow)?; + let two_m_r = U256::from(2u8) + .checked_mul(U256::from(band.slope_num)) + .ok_or(MathError::Overflow)? + .checked_mul(U256::from(reserve_credit)) + .ok_or(MathError::Overflow)? + .checked_mul(U256::from(SCALE)) + .ok_or(MathError::Overflow)? + / U256::from(band.slope_den); + let discriminant = p_local_sq.checked_add(two_m_r).ok_or(MathError::Overflow)?; + let sqrt_disc = integer_sqrt_u256(discriminant); + let numerator = sqrt_disc + .checked_sub(p_local_u256) + .ok_or(MathError::Overflow)?; + let delta_s = numerator + .checked_mul(U256::from(band.slope_den)) + .ok_or(MathError::Overflow)? + / U256::from(band.slope_num); + + u256_to_u128(delta_s) +} + +pub fn cost_to_mint(delta_s: u128, s_c: u128) -> Result { + let target_supply = s_c.checked_add(delta_s).ok_or(MathError::Overflow)?; + if target_supply > MAX_SUPPLY { + return Err(MathError::Overflow); + } + + let mut total_cost = 0u128; + let mut current_supply = s_c; + + while current_supply < target_supply { + let band = band_for_supply(current_supply)?; + let band_end = band.end_supply.min(target_supply); + let band_cost = cost_single_band(current_supply, band_end, &band)?; + total_cost = total_cost + .checked_add(band_cost) + .ok_or(MathError::Overflow)?; + current_supply = band_end; + } + + Ok(total_cost) +} + +pub fn mint_with_reserve(reserve_credit: u128, s_c: u128) -> Result { + if s_c > MAX_SUPPLY { + return Err(MathError::Overflow); + } + + let mut remaining_reserve = reserve_credit; + let mut minted = 0u128; + let mut current_supply = s_c; + + while remaining_reserve > 0 && current_supply < MAX_SUPPLY { + let band = band_for_supply(current_supply)?; + let band_capacity = band + .end_supply + .checked_sub(current_supply) + .ok_or(MathError::Overflow)?; + if band_capacity == 0 { + break; + } + + let full_band_cost = cost_single_band(current_supply, band.end_supply, &band)?; + if full_band_cost <= remaining_reserve { + minted = minted + .checked_add(band_capacity) + .ok_or(MathError::Overflow)?; + remaining_reserve = remaining_reserve + .checked_sub(full_band_cost) + .ok_or(MathError::Overflow)?; + current_supply = band.end_supply; + continue; + } + + let estimate = inverse_mint(remaining_reserve, current_supply, &band)?.min(band_capacity); + let mut low = 0u128; + let mut high = estimate; + + if cost_single_band(current_supply, current_supply + high, &band)? <= remaining_reserve { + high = band_capacity; + } + + while low < high { + let mid = low + .checked_add(high) + .ok_or(MathError::Overflow)? + .checked_add(1) + .ok_or(MathError::Overflow)? + / 2; + let mid_cost = cost_single_band(current_supply, current_supply + mid, &band)?; + if mid_cost <= remaining_reserve { + low = mid; + } else { + high = mid - 1; + } + } + + let delta_s = low; + + minted = minted.checked_add(delta_s).ok_or(MathError::Overflow)?; + break; } - let mut x0 = n; - let mut x1 = (x0 + n / x0) / 2; - while x1 < x0 { - x0 = x1; - x1 = (x0 + n / x0) / 2; + Ok(minted) +} + +pub fn payout_for_burn(amount_cbe: u128, s_c: u128) -> Result { + if amount_cbe > s_c { + return Err(MathError::Overflow); + } + + let mut remaining_burn = amount_cbe; + let mut current_supply = s_c; + let mut total_payout = 0u128; + + while remaining_burn > 0 { + let band = band_for_redemption_supply(current_supply); + let band_floor = current_supply + .checked_sub(remaining_burn) + .ok_or(MathError::Overflow)? + .max(band.start_supply); + let band_payout = cost_single_band(band_floor, current_supply, &band)?; + total_payout = total_payout + .checked_add(band_payout) + .ok_or(MathError::Overflow)?; + + let burned_here = current_supply + .checked_sub(band_floor) + .ok_or(MathError::Overflow)?; + remaining_burn = remaining_burn + .checked_sub(burned_here) + .ok_or(MathError::Overflow)?; + current_supply = band_floor; } - x0 + + Ok(total_payout) } #[cfg(test)] mod tests { @@ -114,15 +332,15 @@ mod tests { #[test] fn price_is_continuous_at_boundaries() { for pair in BANDS.windows(2) { - let left = price_at_supply(pair[0].end_supply); - let right = price_at_supply(pair[1].start_supply); + let left = price_at_supply_in_band(pair[0].end_supply, &pair[0]).unwrap(); + let right = price_at_supply_in_band(pair[1].start_supply, &pair[1]).unwrap(); assert_eq!(left, right); } } #[test] fn price_matches_known_initial_value() { - assert_eq!(price_at_supply(0), INTERCEPT_0 as u128); + assert_eq!(price_at_supply(0), P_START_0); } #[test] @@ -147,4 +365,95 @@ mod tests { assert!(p3 > p2); assert!(p4 > p3); } + + #[test] + fn band_for_supply_rejects_out_of_range_supply() { + assert_eq!(band_for_supply(MAX_SUPPLY + 1), Err(MathError::Overflow)); + } + + #[test] + fn cost_single_band_zero_width_is_zero() { + let band = BANDS[0]; + assert_eq!( + cost_single_band(band.start_supply, band.start_supply, &band).unwrap(), + 0 + ); + } + + #[test] + fn cost_single_band_is_monotonic_with_range() { + let band = BANDS[0]; + let one_token = SCALE; + let cost_small = + cost_single_band(band.start_supply, band.start_supply + one_token, &band).unwrap(); + let cost_large = + cost_single_band(band.start_supply, band.start_supply + 10 * one_token, &band) + .unwrap(); + assert!(cost_large > cost_small); + } + + #[test] + fn inverse_mint_flat_band_special_case_uses_division() { + let band = Band { + index: 99, + start_supply: 0, + end_supply: 100 * SCALE, + slope_num: 0, + slope_den: 1, + p_start: P_START_0, + }; + let reserve_credit = 10 * SCALE; + let minted = inverse_mint(reserve_credit, 0, &band).unwrap(); + assert_eq!( + minted, + mul_div_floor_u128(reserve_credit, SCALE, band.p_start).unwrap() + ); + } + + #[test] + fn inverse_mint_returns_positive_amount_for_positive_credit() { + let band = BANDS[0]; + let minted = inverse_mint(4 * SCALE, band.start_supply, &band).unwrap(); + assert!(minted > 0); + } + + #[test] + fn cost_to_mint_matches_single_band_cost_when_no_boundary_is_crossed() { + let band = BANDS[0]; + let delta = 50 * SCALE; + assert_eq!( + cost_to_mint(delta, band.start_supply).unwrap(), + cost_single_band(band.start_supply, band.start_supply + delta, &band).unwrap() + ); + } + + #[test] + fn mint_with_reserve_consumes_full_band_when_reserve_matches_boundary_cost() { + let band = BANDS[0]; + let reserve_credit = cost_single_band(band.start_supply, band.end_supply, &band).unwrap(); + assert_eq!( + mint_with_reserve(reserve_credit, band.start_supply).unwrap(), + band.end_supply - band.start_supply + ); + } + + #[test] + fn payout_for_burn_matches_single_band_cost_when_no_boundary_is_crossed() { + let band = BANDS[0]; + let amount = 75 * SCALE; + let current_supply = band.start_supply + amount; + assert_eq!( + payout_for_burn(amount, current_supply).unwrap(), + cost_single_band(band.start_supply, current_supply, &band).unwrap() + ); + } + + #[test] + fn payout_for_burn_crosses_boundary_right_to_left() { + let current_supply = BANDS[1].start_supply + 10 * SCALE; + let amount = 20 * SCALE; + let expected = cost_single_band(BANDS[0].end_supply - 10 * SCALE, BANDS[0].end_supply, &BANDS[0]).unwrap() + + cost_single_band(BANDS[1].start_supply, current_supply, &BANDS[1]).unwrap(); + assert_eq!(payout_for_burn(amount, current_supply).unwrap(), expected); + } } diff --git a/lib-blockchain/src/contracts/bonding_curve/mod.rs b/lib-blockchain/src/contracts/bonding_curve/mod.rs index ff10de40..2eca112f 100644 --- a/lib-blockchain/src/contracts/bonding_curve/mod.rs +++ b/lib-blockchain/src/contracts/bonding_curve/mod.rs @@ -40,7 +40,8 @@ pub use amm_pool::{ pub use canonical::{ Band as CanonicalBand, BondingCurveBuyReceipt, BondingCurveBuyTx, BondingCurveSellReceipt, BondingCurveSellTx, BAND_COUNT as CANONICAL_BAND_COUNT, BANDS as CANONICAL_BANDS, - INTERCEPT_0, MAX_DELTA_S_PER_TX, MAX_GROSS_SOV_PER_TX, MAX_SUPPLY as CANONICAL_MAX_SUPPLY, + MAX_DELTA_S_PER_TX, MAX_GROSS_SOV_PER_TX, MAX_SUPPLY as CANONICAL_MAX_SUPPLY, + P_START_0, SCALE as CANONICAL_SCALE, integer_sqrt, }; // Issue #1849: Re-export POL pool diff --git a/lib-types/src/bonding_curve.rs b/lib-types/src/bonding_curve.rs index 6cb23b92..fd04b9c7 100644 --- a/lib-types/src/bonding_curve.rs +++ b/lib-types/src/bonding_curve.rs @@ -1,40 +1,95 @@ -use serde::de::{Error, SeqAccess, Visitor}; -use serde::ser::SerializeTuple; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use std::fmt; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct BondingCurveBand { - pub index: u8, + pub index: u64, pub start_supply: u128, pub end_supply: u128, pub slope_num: u128, pub slope_den: u128, - pub intercept: i128, + pub p_start: u128, +} + +/// 48-bit nonce packed as six big-endian bytes. +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Nonce48(pub [u8; 6]); + +impl Nonce48 { + pub const MAX: u64 = (1u64 << 48) - 1; + + pub const fn zero() -> Self { + Self([0; 6]) + } + + pub fn from_u64(value: u64) -> Option { + if value > Self::MAX { + return None; + } + + Some(Self([ + ((value >> 40) & 0xff) as u8, + ((value >> 32) & 0xff) as u8, + ((value >> 24) & 0xff) as u8, + ((value >> 16) & 0xff) as u8, + ((value >> 8) & 0xff) as u8, + (value & 0xff) as u8, + ])) + } + + pub const fn to_u64(self) -> u64 { + ((self.0[0] as u64) << 40) + | ((self.0[1] as u64) << 32) + | ((self.0[2] as u64) << 24) + | ((self.0[3] as u64) << 16) + | ((self.0[4] as u64) << 8) + | (self.0[5] as u64) + } + + pub const fn to_be_bytes(self) -> [u8; 6] { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct BondingCurveEconomicState { + pub s_c: u128, + pub reserve_balance: u128, + pub treasury_balance: u128, + pub graduated: bool, + pub sell_enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct BondingCurveAccountState { + pub key_id: [u8; 32], + pub balance_cbe: u128, + pub balance_sov: u128, + pub next_nonce: Nonce48, } #[repr(C)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct BondingCurveBuyTx { + pub action: u8, + pub chain_id: u8, + pub nonce: Nonce48, pub sender: [u8; 32], - pub gross_sov: u128, - pub min_cbe: u128, - pub nonce: u64, - pub deadline: u64, - #[serde(with = "signature_serde")] - pub signature: [u8; 64], + pub amount_in: u128, + pub max_price: u128, + pub expected_s_c: u128, } #[repr(C)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct BondingCurveSellTx { + pub action: u8, + pub chain_id: u8, + pub nonce: Nonce48, pub sender: [u8; 32], - pub delta_s: u128, - pub min_sov: u128, - pub nonce: u64, - pub deadline: u64, - #[serde(with = "signature_serde")] - pub signature: [u8; 64], + pub amount_cbe: u128, + pub min_payout: u128, + pub expected_s_c: u128, } #[repr(C)] @@ -56,102 +111,104 @@ pub struct BondingCurveSellReceipt { pub price_post: u128, } -mod signature_serde { +#[cfg(test)] +mod tests { use super::*; - pub fn serialize(data: &[u8; 64], serializer: S) -> Result - where - S: Serializer, - { - let mut tuple = serializer.serialize_tuple(64)?; - for byte in data { - tuple.serialize_element(byte)?; - } - tuple.end() - } + const MAX_AMOUNT_PER_TX: u128 = 100_000_000_000u128 * 1_000_000_000_000_000_000u128; - pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error> - where - D: Deserializer<'de>, - { - struct SignatureVisitor; - - impl<'de> Visitor<'de> for SignatureVisitor { - type Value = [u8; 64]; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a 64-byte signature") - } - - fn visit_seq(self, mut seq: A) -> Result - where - A: SeqAccess<'de>, - { - let mut out = [0u8; 64]; - for (i, byte) in out.iter_mut().enumerate() { - *byte = seq - .next_element()? - .ok_or_else(|| A::Error::invalid_length(i, &self))?; - } - Ok(out) - } - } - - deserializer.deserialize_tuple(64, SignatureVisitor) + #[test] + fn nonce48_round_trip_through_u64() { + let nonce = Nonce48::from_u64(42).unwrap(); + assert_eq!(nonce.to_u64(), 42); + assert_eq!(nonce.to_be_bytes(), [0, 0, 0, 0, 0, 42]); } -} -#[cfg(test)] -mod tests { - use super::*; - - const MAX_GROSS_SOV_PER_TX: u128 = 1_000_000_000_000_000_000_000_000; - const MAX_DELTA_S_PER_TX: u128 = 100_000_000_000u128 * 1_000_000_000_000_000_000u128; + #[test] + fn nonce48_rejects_values_above_max() { + assert!(Nonce48::from_u64(Nonce48::MAX).is_some()); + assert!(Nonce48::from_u64(Nonce48::MAX + 1).is_none()); + } #[test] - fn buy_tx_bincode_size_is_fixed() { + fn buy_tx_bincode_size_is_fixed_to_payload_width() { let a = BondingCurveBuyTx { + action: 0x01, + chain_id: 0x02, + nonce: Nonce48::from_u64(3).unwrap(), sender: [1u8; 32], - gross_sov: 1, - min_cbe: 2, - nonce: 3, - deadline: 4, - signature: [5u8; 64], + amount_in: 1, + max_price: 2, + expected_s_c: 4, }; let b = BondingCurveBuyTx { + action: 0x01, + chain_id: 0xff, + nonce: Nonce48::from_u64(Nonce48::MAX).unwrap(), sender: [9u8; 32], - gross_sov: MAX_GROSS_SOV_PER_TX, - min_cbe: MAX_DELTA_S_PER_TX, - nonce: u64::MAX, - deadline: u64::MAX - 1, - signature: [7u8; 64], + amount_in: MAX_AMOUNT_PER_TX, + max_price: MAX_AMOUNT_PER_TX - 1, + expected_s_c: MAX_AMOUNT_PER_TX - 2, }; - assert_eq!(bincode::serialize(&a).unwrap().len(), 144); - assert_eq!(bincode::serialize(&b).unwrap().len(), 144); + assert_eq!(bincode::serialize(&a).unwrap().len(), 88); + assert_eq!(bincode::serialize(&b).unwrap().len(), 88); } #[test] - fn sell_tx_bincode_size_is_fixed() { + fn sell_tx_bincode_size_is_fixed_to_payload_width() { let a = BondingCurveSellTx { + action: 0x02, + chain_id: 0x03, + nonce: Nonce48::from_u64(3).unwrap(), sender: [1u8; 32], - delta_s: 1, - min_sov: 2, - nonce: 3, - deadline: 4, - signature: [5u8; 64], + amount_cbe: 1, + min_payout: 2, + expected_s_c: 4, }; let b = BondingCurveSellTx { + action: 0x02, + chain_id: 0xfe, + nonce: Nonce48::from_u64(Nonce48::MAX).unwrap(), sender: [9u8; 32], - delta_s: MAX_DELTA_S_PER_TX, - min_sov: MAX_GROSS_SOV_PER_TX, - nonce: u64::MAX, - deadline: u64::MAX - 1, - signature: [7u8; 64], + amount_cbe: MAX_AMOUNT_PER_TX, + min_payout: MAX_AMOUNT_PER_TX - 1, + expected_s_c: MAX_AMOUNT_PER_TX - 2, + }; + + assert_eq!(bincode::serialize(&a).unwrap().len(), 88); + assert_eq!(bincode::serialize(&b).unwrap().len(), 88); + } + + #[test] + fn economic_state_round_trips_through_bincode() { + let state = BondingCurveEconomicState { + s_c: 1, + reserve_balance: 2, + treasury_balance: 3, + graduated: true, + sell_enabled: false, }; - assert_eq!(bincode::serialize(&a).unwrap().len(), 144); - assert_eq!(bincode::serialize(&b).unwrap().len(), 144); + let encoded = bincode::serialize(&state).unwrap(); + let decoded: BondingCurveEconomicState = bincode::deserialize(&encoded).unwrap(); + + assert_eq!(decoded, state); + } + + #[test] + fn account_state_round_trips_through_bincode() { + let account = BondingCurveAccountState { + key_id: [7u8; 32], + balance_cbe: 8, + balance_sov: 9, + next_nonce: Nonce48::from_u64(10).unwrap(), + }; + + let encoded = bincode::serialize(&account).unwrap(); + let decoded: BondingCurveAccountState = bincode::deserialize(&encoded).unwrap(); + + assert_eq!(decoded, account); } #[test] diff --git a/lib-types/src/lib.rs b/lib-types/src/lib.rs index 62416e69..24b91618 100644 --- a/lib-types/src/lib.rs +++ b/lib-types/src/lib.rs @@ -22,8 +22,8 @@ pub use primitives::{Address, Amount, BlockHash, BlockHeight, Bps, TokenId, TxHa pub use chunk::*; pub use bonding_curve::{ - BondingCurveBand, BondingCurveBuyReceipt, BondingCurveBuyTx, BondingCurveSellReceipt, - BondingCurveSellTx, + BondingCurveAccountState, BondingCurveBand, BondingCurveBuyReceipt, BondingCurveBuyTx, + BondingCurveEconomicState, BondingCurveSellReceipt, BondingCurveSellTx, Nonce48, }; pub use consensus::{ ConsensusConfig, ConsensusStep, ConsensusType, FeeDistributionResult, SlashType, From 2f8d1ca133e68eb851f8b87c2753969db7f028c2 Mon Sep 17 00:00:00 2001 From: Hugo Perez Date: Fri, 20 Mar 2026 00:06:47 +0000 Subject: [PATCH 4/6] Add canonical bonding curve tx codec lane --- AGENT_TASKS.md | 25 ++ lib-blockchain/src/execution/executor.rs | 144 +++++++- .../src/transaction/bonding_curve_codec.rs | 322 ++++++++++++++++++ lib-blockchain/src/transaction/mod.rs | 10 + 4 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 lib-blockchain/src/transaction/bonding_curve_codec.rs diff --git a/AGENT_TASKS.md b/AGENT_TASKS.md index e7332527..c29c730b 100644 --- a/AGENT_TASKS.md +++ b/AGENT_TASKS.md @@ -1,5 +1,30 @@ # Agent Task List - TokenCreation Fee Cleanup +## Active Deferred Boundaries + +### 2026-03-19: Canonical Bonding Curve Envelope Signature Boundary + +- Scope: + Fixed-width canonical curve transaction work on branch `codex/1928-fixed-width-curve-tx`. +- Implemented: + [`/Users/supertramp/Dev/SOVN-workspace/SOVN/The-Sovereign-Network/lib-blockchain/src/transaction/bonding_curve_codec.rs`](/Users/supertramp/Dev/SOVN-workspace/SOVN/The-Sovereign-Network/lib-blockchain/src/transaction/bonding_curve_codec.rs) now defines: + - canonical `88`-byte `BUY_CBE` / `SELL_CBE` payloads + - canonical envelope wrapper + - sender/signature-key ownership matching +- Explicitly deferred: + full executor-side cryptographic signature verification of the canonical curve envelope against the signed payload bytes via `lib_crypto`. +- Reason: + this stacked slice is locking the fixed-width wire format and executor entry boundary first; full cryptographic verification belongs in the next executor integration step so the verification contract is introduced together with the canonical execution lane, not as an isolated helper. +- Required follow-up: + when wiring canonical curve execution into state mutation, replace sender/signature-key ownership matching with real signature verification over the canonical signed region and keep the sender equality check as a second invariant, not the only one. +- Rule for future slices: + whenever work intentionally stops at a boundary like this, record: + - what was implemented + - what was explicitly deferred + - why it was deferred + - what exact follow-up is required + in this file before continuing. + **Last Updated:** 2026-03-09 UTC by Codex **Primary Agent:** Agent 3 - Token Consensus Agent **Secondary Reviewers Required:** Agent 2 - Storage and Atomicity, Agent 4 - Runtime/API Contract, Agent 8 - Security and Replay Assurance, Agent 10 - QA and Release Readiness diff --git a/lib-blockchain/src/execution/executor.rs b/lib-blockchain/src/execution/executor.rs index 6d2f2ec2..7b256d9d 100644 --- a/lib-blockchain/src/execution/executor.rs +++ b/lib-blockchain/src/execution/executor.rs @@ -42,7 +42,9 @@ use crate::storage::{ }; use crate::transaction::{ contract_deployment::ContractDeploymentPayloadV1, - contract_execution::DecodedContractExecutionMemo, hash_transaction, + contract_execution::DecodedContractExecutionMemo, decode_canonical_bonding_curve_tx, + envelope_signer_matches_sender, hash_transaction, CanonicalBondingCurveEnvelope, + CanonicalBondingCurveTx, token_creation::TokenCreationPayloadV1, DEFAULT_TOKEN_CREATION_FEE, }; use crate::types::TransactionType; @@ -1779,6 +1781,44 @@ impl BlockExecutor { }) } + fn apply_canonical_bonding_curve_tx( + &self, + _mutator: &StateMutator<'_>, + payload: &[u8], + ) -> Result { + match decode_canonical_bonding_curve_tx(payload) + .map_err(|e| TxApplyError::InvalidType(format!("Invalid canonical curve payload: {e}")))? + { + CanonicalBondingCurveTx::Buy(tx) => Err(TxApplyError::InvalidType(format!( + "Canonical BUY_CBE lane parsed but is not wired to state execution yet (sender={}, nonce={})", + hex::encode(tx.sender), + tx.nonce.to_u64() + ))), + CanonicalBondingCurveTx::Sell(tx) => Err(TxApplyError::InvalidType(format!( + "Canonical SELL_CBE lane parsed but is not wired to state execution yet (sender={}, nonce={})", + hex::encode(tx.sender), + tx.nonce.to_u64() + ))), + } + } + + fn apply_canonical_bonding_curve_envelope( + &self, + mutator: &StateMutator<'_>, + envelope: &CanonicalBondingCurveEnvelope, + ) -> Result { + let signer_matches = envelope_signer_matches_sender(envelope).map_err(|e| { + TxApplyError::InvalidType(format!("Invalid canonical curve envelope: {e}")) + })?; + if !signer_matches { + return Err(TxApplyError::InvalidType( + "Canonical curve signer does not match payload sender".to_string(), + )); + } + + self.apply_canonical_bonding_curve_tx(mutator, &envelope.payload) + } + fn apply_bonding_curve_sell( &self, mutator: &StateMutator<'_>, @@ -2319,6 +2359,13 @@ pub struct BondingCurveGraduateOutcome { pub pool_id: [u8; 32], } +/// Outcome of parsing a canonical fixed-width bonding curve transaction. +#[derive(Debug, Clone)] +pub enum CanonicalBondingCurveOutcome { + Buy([u8; 32]), + Sell([u8; 32]), +} + /// Outcome of an oracle attestation transaction (ORACLE-R3: Canonical Path) #[derive(Debug, Clone)] pub struct OracleAttestationOutcome { @@ -3748,6 +3795,101 @@ mod tests { ); } + #[test] + fn test_canonical_bonding_curve_lane_parses_buy_payload_and_rejects_as_not_wired() { + use crate::execution::tx_apply::StateMutator; + use crate::transaction::{ + encode_canonical_bonding_curve_tx, CanonicalBondingCurveTx, BONDING_CURVE_BUY_ACTION, + }; + use lib_types::{BondingCurveBuyTx, Nonce48}; + + let store = create_test_store(); + store.begin_block(0).unwrap(); + let mutator = StateMutator::new(store.as_ref()); + let executor = BlockExecutor::with_store(store.clone()); + + let payload = encode_canonical_bonding_curve_tx(&CanonicalBondingCurveTx::Buy( + BondingCurveBuyTx { + action: BONDING_CURVE_BUY_ACTION, + chain_id: 0x03, + nonce: Nonce48::from_u64(42).unwrap(), + sender: [0x11; 32], + amount_in: 1000, + max_price: 2000, + expected_s_c: 3000, + }, + )); + + let err = executor + .apply_canonical_bonding_curve_tx(&mutator, &payload) + .expect_err("canonical lane should reject until state execution is wired"); + + let msg = err.to_string(); + assert!(msg.contains("Canonical BUY_CBE lane parsed")); + assert!(msg.contains("nonce=42")); + } + + #[test] + fn test_canonical_bonding_curve_lane_rejects_unknown_action() { + use crate::execution::tx_apply::StateMutator; + + let store = create_test_store(); + store.begin_block(0).unwrap(); + let mutator = StateMutator::new(store.as_ref()); + let executor = BlockExecutor::with_store(store.clone()); + + let mut payload = [0u8; 88]; + payload[0] = 0xff; + + let err = executor + .apply_canonical_bonding_curve_tx(&mutator, &payload) + .expect_err("unknown canonical action must be rejected"); + + assert!(err.to_string().contains("Invalid canonical curve payload")); + } + + #[test] + fn test_canonical_bonding_curve_envelope_rejects_signer_sender_mismatch() { + use crate::execution::tx_apply::StateMutator; + use crate::transaction::{ + encode_canonical_bonding_curve_tx, CanonicalBondingCurveEnvelope, + CanonicalBondingCurveTx, BONDING_CURVE_BUY_ACTION, + }; + use lib_crypto::KeyPair; + use lib_types::{BondingCurveBuyTx, Nonce48}; + + let store = create_test_store(); + store.begin_block(0).unwrap(); + let mutator = StateMutator::new(store.as_ref()); + let executor = BlockExecutor::with_store(store.clone()); + let signer = KeyPair::generate().unwrap(); + let other = KeyPair::generate().unwrap(); + + let payload = encode_canonical_bonding_curve_tx(&CanonicalBondingCurveTx::Buy( + BondingCurveBuyTx { + action: BONDING_CURVE_BUY_ACTION, + chain_id: 0x03, + nonce: Nonce48::from_u64(43).unwrap(), + sender: other.public_key.key_id, + amount_in: 1000, + max_price: 2000, + expected_s_c: 3000, + }, + )); + let envelope = CanonicalBondingCurveEnvelope { + payload, + signature: signer.sign(&payload).unwrap(), + }; + + let err = executor + .apply_canonical_bonding_curve_envelope(&mutator, &envelope) + .expect_err("mismatched envelope signer must be rejected"); + assert!( + err.to_string() + .contains("Canonical curve signer does not match payload sender") + ); + } + #[test] fn test_bonding_curve_deploy_stores_token_and_symbol_index() { use crate::contracts::bonding_curve::{ diff --git a/lib-blockchain/src/transaction/bonding_curve_codec.rs b/lib-blockchain/src/transaction/bonding_curve_codec.rs new file mode 100644 index 00000000..f2ee9424 --- /dev/null +++ b/lib-blockchain/src/transaction/bonding_curve_codec.rs @@ -0,0 +1,322 @@ +use crate::integration::crypto_integration::Signature; +use lib_types::{BondingCurveBuyTx, BondingCurveSellTx, Nonce48}; +use thiserror::Error; + +pub const BONDING_CURVE_TX_PAYLOAD_LEN: usize = 88; +pub const BONDING_CURVE_TX_SIGNED_REGION_END: usize = 88; +pub const BONDING_CURVE_BUY_ACTION: u8 = 0x01; +pub const BONDING_CURVE_SELL_ACTION: u8 = 0x02; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CanonicalBondingCurveTx { + Buy(BondingCurveBuyTx), + Sell(BondingCurveSellTx), +} + +#[derive(Debug, Clone)] +pub struct CanonicalBondingCurveEnvelope { + pub payload: [u8; BONDING_CURVE_TX_PAYLOAD_LEN], + pub signature: Signature, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum BondingCurveCodecError { + #[error("invalid payload length: expected 88 bytes, got {0}")] + InvalidLength(usize), + #[error("invalid buy action byte: {0:#04x}")] + InvalidBuyAction(u8), + #[error("invalid sell action byte: {0:#04x}")] + InvalidSellAction(u8), + #[error("unknown action byte: {0:#04x}")] + UnknownAction(u8), +} + +pub fn bonding_curve_signed_region(payload: &[u8]) -> Result<&[u8], BondingCurveCodecError> { + if payload.len() != BONDING_CURVE_TX_PAYLOAD_LEN { + return Err(BondingCurveCodecError::InvalidLength(payload.len())); + } + Ok(&payload[..BONDING_CURVE_TX_SIGNED_REGION_END]) +} + +pub fn encode_bonding_curve_buy(tx: &BondingCurveBuyTx) -> [u8; BONDING_CURVE_TX_PAYLOAD_LEN] { + let mut payload = [0u8; BONDING_CURVE_TX_PAYLOAD_LEN]; + payload[0] = tx.action; + payload[1] = tx.chain_id; + payload[2..8].copy_from_slice(&tx.nonce.to_be_bytes()); + payload[8..40].copy_from_slice(&tx.sender); + payload[40..56].copy_from_slice(&tx.amount_in.to_be_bytes()); + payload[56..72].copy_from_slice(&tx.max_price.to_be_bytes()); + payload[72..88].copy_from_slice(&tx.expected_s_c.to_be_bytes()); + payload +} + +pub fn decode_bonding_curve_buy( + payload: &[u8], +) -> Result { + if payload.len() != BONDING_CURVE_TX_PAYLOAD_LEN { + return Err(BondingCurveCodecError::InvalidLength(payload.len())); + } + if payload[0] != BONDING_CURVE_BUY_ACTION { + return Err(BondingCurveCodecError::InvalidBuyAction(payload[0])); + } + + let mut sender = [0u8; 32]; + sender.copy_from_slice(&payload[8..40]); + + Ok(BondingCurveBuyTx { + action: payload[0], + chain_id: payload[1], + nonce: Nonce48(payload[2..8].try_into().expect("nonce slice length is fixed")), + sender, + amount_in: u128::from_be_bytes(payload[40..56].try_into().expect("amount slice length")), + max_price: u128::from_be_bytes(payload[56..72].try_into().expect("price slice length")), + expected_s_c: u128::from_be_bytes( + payload[72..88].try_into().expect("supply slice length"), + ), + }) +} + +pub fn encode_bonding_curve_sell(tx: &BondingCurveSellTx) -> [u8; BONDING_CURVE_TX_PAYLOAD_LEN] { + let mut payload = [0u8; BONDING_CURVE_TX_PAYLOAD_LEN]; + payload[0] = tx.action; + payload[1] = tx.chain_id; + payload[2..8].copy_from_slice(&tx.nonce.to_be_bytes()); + payload[8..40].copy_from_slice(&tx.sender); + payload[40..56].copy_from_slice(&tx.amount_cbe.to_be_bytes()); + payload[56..72].copy_from_slice(&tx.min_payout.to_be_bytes()); + payload[72..88].copy_from_slice(&tx.expected_s_c.to_be_bytes()); + payload +} + +pub fn decode_bonding_curve_sell( + payload: &[u8], +) -> Result { + if payload.len() != BONDING_CURVE_TX_PAYLOAD_LEN { + return Err(BondingCurveCodecError::InvalidLength(payload.len())); + } + if payload[0] != BONDING_CURVE_SELL_ACTION { + return Err(BondingCurveCodecError::InvalidSellAction(payload[0])); + } + + let mut sender = [0u8; 32]; + sender.copy_from_slice(&payload[8..40]); + + Ok(BondingCurveSellTx { + action: payload[0], + chain_id: payload[1], + nonce: Nonce48(payload[2..8].try_into().expect("nonce slice length is fixed")), + sender, + amount_cbe: u128::from_be_bytes( + payload[40..56].try_into().expect("amount slice length"), + ), + min_payout: u128::from_be_bytes( + payload[56..72].try_into().expect("payout slice length"), + ), + expected_s_c: u128::from_be_bytes( + payload[72..88].try_into().expect("supply slice length"), + ), + }) +} + +pub fn encode_canonical_bonding_curve_tx( + tx: &CanonicalBondingCurveTx, +) -> [u8; BONDING_CURVE_TX_PAYLOAD_LEN] { + match tx { + CanonicalBondingCurveTx::Buy(tx) => encode_bonding_curve_buy(tx), + CanonicalBondingCurveTx::Sell(tx) => encode_bonding_curve_sell(tx), + } +} + +pub fn canonical_curve_sender(tx: &CanonicalBondingCurveTx) -> [u8; 32] { + match tx { + CanonicalBondingCurveTx::Buy(tx) => tx.sender, + CanonicalBondingCurveTx::Sell(tx) => tx.sender, + } +} + +pub fn decode_canonical_bonding_curve_tx( + payload: &[u8], +) -> Result { + if payload.len() != BONDING_CURVE_TX_PAYLOAD_LEN { + return Err(BondingCurveCodecError::InvalidLength(payload.len())); + } + + match payload[0] { + BONDING_CURVE_BUY_ACTION => { + decode_bonding_curve_buy(payload).map(CanonicalBondingCurveTx::Buy) + } + BONDING_CURVE_SELL_ACTION => { + decode_bonding_curve_sell(payload).map(CanonicalBondingCurveTx::Sell) + } + action => Err(BondingCurveCodecError::UnknownAction(action)), + } +} + +pub fn envelope_signer_matches_sender( + envelope: &CanonicalBondingCurveEnvelope, +) -> Result { + let tx = decode_canonical_bonding_curve_tx(&envelope.payload)?; + Ok(envelope.signature.public_key.key_id == canonical_curve_sender(&tx)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn buy_payload_round_trips_with_fixed_offsets() { + let tx = BondingCurveBuyTx { + action: BONDING_CURVE_BUY_ACTION, + chain_id: 0x03, + nonce: Nonce48::from_u64(0x0102_0304_0506).unwrap(), + sender: [0x11; 32], + amount_in: 7, + max_price: 8, + expected_s_c: 9, + }; + + let encoded = encode_bonding_curve_buy(&tx); + assert_eq!(encoded.len(), BONDING_CURVE_TX_PAYLOAD_LEN); + assert_eq!(&encoded[2..8], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]); + assert_eq!(decode_bonding_curve_buy(&encoded).unwrap(), tx); + } + + #[test] + fn sell_payload_round_trips_with_fixed_offsets() { + let tx = BondingCurveSellTx { + action: BONDING_CURVE_SELL_ACTION, + chain_id: 0x03, + nonce: Nonce48::from_u64(0x0a0b_0c0d_0e0f).unwrap(), + sender: [0x22; 32], + amount_cbe: 17, + min_payout: 18, + expected_s_c: 19, + }; + + let encoded = encode_bonding_curve_sell(&tx); + assert_eq!(encoded.len(), BONDING_CURVE_TX_PAYLOAD_LEN); + assert_eq!(&encoded[2..8], &[0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f]); + assert_eq!(decode_bonding_curve_sell(&encoded).unwrap(), tx); + } + + #[test] + fn decode_rejects_wrong_payload_length() { + assert_eq!( + decode_bonding_curve_buy(&[0u8; 87]), + Err(BondingCurveCodecError::InvalidLength(87)) + ); + assert_eq!( + decode_bonding_curve_sell(&[0u8; 89]), + Err(BondingCurveCodecError::InvalidLength(89)) + ); + } + + #[test] + fn decode_rejects_wrong_action_byte() { + let mut buy_payload = [0u8; BONDING_CURVE_TX_PAYLOAD_LEN]; + buy_payload[0] = BONDING_CURVE_SELL_ACTION; + assert_eq!( + decode_bonding_curve_buy(&buy_payload), + Err(BondingCurveCodecError::InvalidBuyAction( + BONDING_CURVE_SELL_ACTION + )) + ); + + let mut sell_payload = [0u8; BONDING_CURVE_TX_PAYLOAD_LEN]; + sell_payload[0] = BONDING_CURVE_BUY_ACTION; + assert_eq!( + decode_bonding_curve_sell(&sell_payload), + Err(BondingCurveCodecError::InvalidSellAction( + BONDING_CURVE_BUY_ACTION + )) + ); + } + + #[test] + fn signed_region_is_the_full_payload_prefix() { + let payload = [0x55u8; BONDING_CURVE_TX_PAYLOAD_LEN]; + let signed = bonding_curve_signed_region(&payload).unwrap(); + assert_eq!(signed.len(), BONDING_CURVE_TX_SIGNED_REGION_END); + assert_eq!(signed, &payload[..]); + } + + #[test] + fn canonical_decoder_dispatches_buy_and_sell_by_action_byte() { + let buy = CanonicalBondingCurveTx::Buy(BondingCurveBuyTx { + action: BONDING_CURVE_BUY_ACTION, + chain_id: 0x03, + nonce: Nonce48::from_u64(1).unwrap(), + sender: [0x33; 32], + amount_in: 21, + max_price: 22, + expected_s_c: 23, + }); + let sell = CanonicalBondingCurveTx::Sell(BondingCurveSellTx { + action: BONDING_CURVE_SELL_ACTION, + chain_id: 0x03, + nonce: Nonce48::from_u64(2).unwrap(), + sender: [0x44; 32], + amount_cbe: 31, + min_payout: 32, + expected_s_c: 33, + }); + + assert_eq!( + decode_canonical_bonding_curve_tx(&encode_canonical_bonding_curve_tx(&buy)).unwrap(), + buy + ); + assert_eq!( + decode_canonical_bonding_curve_tx(&encode_canonical_bonding_curve_tx(&sell)).unwrap(), + sell + ); + } + + #[test] + fn canonical_decoder_rejects_unknown_action_byte() { + let mut payload = [0u8; BONDING_CURVE_TX_PAYLOAD_LEN]; + payload[0] = 0xff; + assert_eq!( + decode_canonical_bonding_curve_tx(&payload), + Err(BondingCurveCodecError::UnknownAction(0xff)) + ); + } + + #[test] + fn envelope_signer_must_match_payload_sender() { + let keypair = lib_crypto::KeyPair::generate().unwrap(); + let tx = CanonicalBondingCurveTx::Buy(BondingCurveBuyTx { + action: BONDING_CURVE_BUY_ACTION, + chain_id: 0x03, + nonce: Nonce48::from_u64(7).unwrap(), + sender: keypair.public_key.key_id, + amount_in: 55, + max_price: 66, + expected_s_c: 77, + }); + let payload = encode_canonical_bonding_curve_tx(&tx); + let signature = keypair.sign(&payload).unwrap(); + let envelope = CanonicalBondingCurveEnvelope { payload, signature }; + + assert!(envelope_signer_matches_sender(&envelope).unwrap()); + } + + #[test] + fn envelope_signer_mismatch_is_rejected() { + let signer = lib_crypto::KeyPair::generate().unwrap(); + let other = lib_crypto::KeyPair::generate().unwrap(); + let tx = CanonicalBondingCurveTx::Sell(BondingCurveSellTx { + action: BONDING_CURVE_SELL_ACTION, + chain_id: 0x03, + nonce: Nonce48::from_u64(8).unwrap(), + sender: other.public_key.key_id, + amount_cbe: 88, + min_payout: 99, + expected_s_c: 111, + }); + let payload = encode_canonical_bonding_curve_tx(&tx); + let signature = signer.sign(&payload).unwrap(); + let envelope = CanonicalBondingCurveEnvelope { payload, signature }; + + assert!(!envelope_signer_matches_sender(&envelope).unwrap()); + } +} diff --git a/lib-blockchain/src/transaction/mod.rs b/lib-blockchain/src/transaction/mod.rs index e00fa25d..64212af8 100644 --- a/lib-blockchain/src/transaction/mod.rs +++ b/lib-blockchain/src/transaction/mod.rs @@ -3,6 +3,7 @@ //! Handles transaction structures, creation, validation, hashing, and signing. //! Identity transactions delegate processing to lib-identity package. +pub mod bonding_curve_codec; pub mod contract_deployment; pub mod contract_execution; pub mod core; @@ -24,6 +25,15 @@ pub use core::{ TX_VERSION_V7, }; +pub use bonding_curve_codec::{ + bonding_curve_signed_region, decode_bonding_curve_buy, decode_bonding_curve_sell, + decode_canonical_bonding_curve_tx, encode_bonding_curve_buy, encode_bonding_curve_sell, + encode_canonical_bonding_curve_tx, envelope_signer_matches_sender, + BondingCurveCodecError, CanonicalBondingCurveEnvelope, CanonicalBondingCurveTx, + BONDING_CURVE_BUY_ACTION, BONDING_CURVE_SELL_ACTION, BONDING_CURVE_TX_PAYLOAD_LEN, + BONDING_CURVE_TX_SIGNED_REGION_END, +}; + // Re-exports from oracle_governance module pub use oracle_governance::{ CancelOracleUpdateData, OracleAttestationData, OracleCommitteeUpdateData, From 59a49b958c035113b54e7d79335193a8db5a1493 Mon Sep 17 00:00:00 2001 From: Hugo Perez Date: Fri, 20 Mar 2026 12:16:22 +0000 Subject: [PATCH 5/6] fix(bonding_curve): propagate Result from band_for_supply in get_current_band --- zhtp/src/api/handlers/bonding_curve/api_v1.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/zhtp/src/api/handlers/bonding_curve/api_v1.rs b/zhtp/src/api/handlers/bonding_curve/api_v1.rs index c13f65cc..9ee2c951 100644 --- a/zhtp/src/api/handlers/bonding_curve/api_v1.rs +++ b/zhtp/src/api/handlers/bonding_curve/api_v1.rs @@ -648,7 +648,10 @@ impl BondingCurveApiHandler { /// Get current supply band for CBE fn get_current_band(&self, supply: u128) -> u32 { - u32::try_from(band_for_supply(supply).index + 1).unwrap_or(u32::MAX) + band_for_supply(supply) + .ok() + .and_then(|b| u32::try_from(b.index + 1).ok()) + .unwrap_or(u32::MAX) } /// Get requester public key from authenticated request From 7d1eb1e5943a21c5512fe34060f3f6fa18dc1b12 Mon Sep 17 00:00:00 2001 From: Hugo Perez Date: Fri, 20 Mar 2026 13:06:46 +0000 Subject: [PATCH 6/6] chore(branch): remove unrelated drift from curve tx lane --- genesis.toml | 4 + lib-blockchain/src/blockchain.rs | 347 +++--------------- lib-blockchain/src/genesis/mod.rs | 6 + lib-blockchain/src/resources.rs | 12 +- lib-blockchain/src/storage/mod.rs | 14 - lib-blockchain/src/storage/sled_store.rs | 60 --- .../engines/consensus_engine/state_machine.rs | 23 +- lib-network/src/protocols/quic_mesh.rs | 8 +- zhtp-cli/src/argument_parsing.rs | 10 +- zhtp-cli/src/commands/genesis.rs | 47 +-- zhtp/src/api/handlers/blockchain/mod.rs | 30 +- zhtp/src/api/handlers/wallet/mod.rs | 31 +- 12 files changed, 96 insertions(+), 496 deletions(-) diff --git a/genesis.toml b/genesis.toml index bef8c868..00f566b7 100644 --- a/genesis.toml +++ b/genesis.toml @@ -10,6 +10,10 @@ name = "Sovereign Network Testnet" # genesis_time is parsed by GenesisConfig::genesis_timestamp(); Unix value = 1761955200 genesis_time = "2025-11-01T00:00:00Z" +[sov] +# SOV is minted exclusively via UBI — no initial allocation at genesis +initial_supply = 0 + [cbe_token] total_supply = 100_000_000_000 # 100 billion CBE # Dilithium5 public keys (hex-encoded) for each pool. diff --git a/lib-blockchain/src/blockchain.rs b/lib-blockchain/src/blockchain.rs index 2d66d874..daf58211 100644 --- a/lib-blockchain/src/blockchain.rs +++ b/lib-blockchain/src/blockchain.rs @@ -1363,33 +1363,6 @@ impl BlockchainStorageV7 { } } -/// Stable storage format V8 for blockchain serialization. -/// -/// V8 extends V7 with persisted `bonding_curve_registry` so the CBE bonding curve -/// price survives node restarts. Without this, `get_cbe_curve_price_atomic()` always -/// returns `None` after restart, causing the oracle to emit `cbe_usd_price: None`. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct BlockchainStorageV8 { - pub v7: BlockchainStorageV7, - #[serde(default)] - pub bonding_curve_registry: crate::contracts::bonding_curve::BondingCurveRegistry, -} - -impl BlockchainStorageV8 { - fn from_blockchain(bc: &Blockchain) -> Self { - Self { - v7: BlockchainStorageV7::from_blockchain(bc), - bonding_curve_registry: bc.bonding_curve_registry.clone(), - } - } - - fn to_blockchain(self) -> Blockchain { - let mut blockchain = self.v7.to_blockchain(); - blockchain.bonding_curve_registry = self.bonding_curve_registry; - blockchain - } -} - /// Blockchain import structure for deserializing received chains #[derive(Serialize, Deserialize)] pub struct BlockchainImport { @@ -2180,12 +2153,6 @@ impl Blockchain { ); } - // Evict any Phase-2-invalid pending transactions that were persisted before - // the fee=0 rule was enforced at intake. Must run before the node starts - // proposing blocks, otherwise BlockExecutor rejects every proposed block. - blockchain.evict_phase2_invalid_transactions("load_from_store"); - blockchain.evict_invalid_signature_transactions("load_from_store"); - // NOTE: Do not mint SOV in-memory here. SledStore requires writes inside // an active block transaction. Missing or underfunded balances are // repaired via TokenMint backfill after startup. @@ -2205,18 +2172,6 @@ impl Blockchain { blockchain.initialize_cbe_token_genesis(); } - // Restore CBE bonding curve registry entry if missing (not persisted pre-V8). - // bonding_curve_registry is loaded from blockchain.dat (V8+). For older files or - // sled-only nodes that predate V8, re-register via initialize_cbe_genesis() so that - // get_cbe_curve_price_atomic() returns a valid price and the oracle emits CBE/USD. - { - let cbe_token_id = Self::derive_cbe_token_id(); - if !blockchain.bonding_curve_registry.contains(&cbe_token_id) { - blockchain.initialize_cbe_genesis(); - info!("Restored CBE bonding curve registry entry (pre-V8 compatibility)"); - } - } - // Sync SOV balances from the authoritative token_balances Sled tree into in-memory // token_contracts.balances. The BlockExecutor updates token_balances on every // TokenMint/TokenTransfer block, but put_token_contract (which updates the blob) is @@ -2976,68 +2931,18 @@ impl Blockchain { self.remove_pending_transactions(&block.transactions); // Begin sled transaction for remaining processing - let sled_began = if let Some(ref store) = self.store { + if let Some(ref store) = self.store { store .begin_block(block.header.height) .map_err(|e| anyhow::anyhow!("Failed to begin Sled transaction: {}", e))?; - true - } else { - false - }; - - // Helper: roll back the open sled transaction on early-exit so that - // tx_active is reset to false and the next block-commit attempt can - // call begin_block() without hitting TransactionAlreadyActive. - let rollback_sled = |store: &dyn crate::storage::BlockchainStore| { - if let Err(rb_err) = store.rollback_block() { - error!("Failed to rollback Sled transaction after block processing error: {}", rb_err); - } - }; - - // Macro to run a fallible expression, rolling back sled on error. - // We use a closure pattern instead of a macro to keep this readable. + } // Process identity transactions - if let Err(e) = self.process_identity_transactions(&block) { - if sled_began { - if let Some(ref store) = self.store { - rollback_sled(store.as_ref()); - } - } - return Err(e); - } - if let Err(e) = self.process_wallet_transactions(&block) { - if sled_began { - if let Some(ref store) = self.store { - rollback_sled(store.as_ref()); - } - } - return Err(e); - } - if let Err(e) = self.process_entity_registry_transactions(&block) { - if sled_began { - if let Some(ref store) = self.store { - rollback_sled(store.as_ref()); - } - } - return Err(e); - } - if let Err(e) = self.process_contract_transactions(&block) { - if sled_began { - if let Some(ref store) = self.store { - rollback_sled(store.as_ref()); - } - } - return Err(e); - } - if let Err(e) = self.process_token_transactions(&block) { - if sled_began { - if let Some(ref store) = self.store { - rollback_sled(store.as_ref()); - } - } - return Err(e); - } + self.process_identity_transactions(&block)?; + self.process_wallet_transactions(&block)?; + self.process_entity_registry_transactions(&block)?; + self.process_contract_transactions(&block)?; + self.process_token_transactions(&block)?; self.process_validator_registration_transactions(&block); for tx in &block.transactions { self.index_dao_registry_entry_from_tx(tx, block.header.height); @@ -3099,20 +3004,16 @@ impl Blockchain { } } - // Persist block to SledStore (also calls commit_block, clearing tx_active) - if sled_began { - if let Some(ref store) = self.store { - if let Err(e) = self.persist_to_sled_store(&block, store.clone()) { - error!( - "Failed to persist block {} to SledStore: {}", - block.height(), - e - ); - // Rollback so tx_active is cleared; the block is already in memory. - rollback_sled(store.as_ref()); - } else { - debug!("Block {} persisted to SledStore", block.height()); - } + // Persist block to SledStore + if let Some(ref store) = self.store { + if let Err(e) = self.persist_to_sled_store(&block, store.clone()) { + error!( + "Failed to persist block {} to SledStore: {}", + block.height(), + e + ); + } else { + debug!("Block {} persisted to SledStore", block.height()); } } @@ -3152,80 +3053,27 @@ impl Blockchain { self.remove_pending_transactions(&block.transactions); // When the BlockExecutor is active it has already called begin_block/commit_block - // inside apply_block(). We cannot call begin_block() for the same height again - // (it would fail with InvalidBlockHeight). Instead, open a supplementary write - // batch that allows identity/wallet side-data to be written without touching - // LATEST_HEIGHT (which the executor already updated). + // inside apply_block(). Starting a second begin_block() for the same height would + // fail with an InvalidBlockHeight error. Only open a new SledStore transaction on + // the legacy path (no executor). let using_executor = self.executor.is_some(); - let sled_began = if let Some(ref store) = self.store { - if !using_executor { + if !using_executor { + if let Some(ref store) = self.store { store .begin_block(block.header.height) .map_err(|e| anyhow::anyhow!("Failed to begin Sled transaction: {}", e))?; - } else { - store - .begin_supplementary_writes() - .map_err(|e| anyhow::anyhow!("Failed to begin supplementary Sled writes: {}", e))?; } - true - } else { - false - }; - - let rollback_sled = |store: &dyn crate::storage::BlockchainStore, supplementary: bool| { - if supplementary { - if let Err(rb_err) = store.rollback_supplementary_writes() { - error!("Failed to rollback supplementary Sled writes: {}", rb_err); - } - } else if let Err(rb_err) = store.rollback_block() { - error!("Failed to rollback Sled transaction after block processing error: {}", rb_err); - } - }; + } // Process identity transactions - if let Err(e) = self.process_identity_transactions(&block) { - if sled_began { - if let Some(ref store) = self.store { - rollback_sled(store.as_ref(), using_executor); - } - } - return Err(e); - } - if let Err(e) = self.process_wallet_transactions(&block) { - if sled_began { - if let Some(ref store) = self.store { - rollback_sled(store.as_ref(), using_executor); - } - } - return Err(e); - } - if let Err(e) = self.process_entity_registry_transactions(&block) { - if sled_began { - if let Some(ref store) = self.store { - rollback_sled(store.as_ref(), using_executor); - } - } - return Err(e); - } + self.process_identity_transactions(&block)?; + self.process_wallet_transactions(&block)?; + self.process_entity_registry_transactions(&block)?; // Skip token/contract processing when using BlockExecutor - it handles these if !self.has_executor() { - if let Err(e) = self.process_contract_transactions(&block) { - if sled_began { - if let Some(ref store) = self.store { - rollback_sled(store.as_ref(), using_executor); - } - } - return Err(e); - } - if let Err(e) = self.process_token_transactions(&block) { - if sled_began { - if let Some(ref store) = self.store { - rollback_sled(store.as_ref(), using_executor); - } - } - return Err(e); - } + self.process_contract_transactions(&block)?; + self.process_token_transactions(&block)?; } else { debug!("Skipping legacy token/contract processing - BlockExecutor is single source of truth"); } @@ -3290,34 +3138,27 @@ impl Blockchain { } } - // Persist block to SledStore. - // Legacy path: persist_to_sled_store calls append_block + commit_block. - // Executor path: block was already committed by the executor; only commit - // the supplementary writes (identity/wallet) opened above. - if sled_began { + // Persist block to SledStore — skip when using the BlockExecutor because + // apply_block() already committed the block (begin_block → append_block → + // commit_block). Calling persist_to_sled_store() again would open a second + // store transaction for the same block height, causing an InvalidBlockHeight error. + if !using_executor { if let Some(ref store) = self.store { - if using_executor { - if let Err(e) = store.commit_supplementary_writes() { - error!( - "Failed to commit supplementary Sled writes for block {}: {}", - block.height(), - e - ); - let _ = store.rollback_supplementary_writes(); - } else { - debug!("Block {} supplementary data (identity/wallet) persisted to SledStore", block.height()); - } - } else if let Err(e) = self.persist_to_sled_store(&block, store.clone()) { + if let Err(e) = self.persist_to_sled_store(&block, store.clone()) { error!( "Failed to persist block {} to SledStore: {}", block.height(), e ); - rollback_sled(store.as_ref(), false); } else { debug!("Block {} persisted to SledStore", block.height()); } } + } else { + debug!( + "Block {} already persisted by BlockExecutor", + block.height() + ); } self.blocks_since_last_persist += 1; @@ -4185,23 +4026,6 @@ impl Blockchain { transaction.inputs.len(), transaction.outputs.len() ); - - // Phase 2 invariant: TokenMint and TokenTransfer must have fee=0. - // Reject at intake so these never enter the mempool and poison proposed blocks. - use crate::types::transaction_type::TransactionType; - if transaction.fee != 0 - && matches!( - transaction.transaction_type, - TransactionType::TokenMint | TransactionType::TokenTransfer - ) - { - return Err(anyhow::anyhow!( - "Phase 2: {:?} transaction must have fee=0, got fee={}", - transaction.transaction_type, - transaction.fee - )); - } - if !self.verify_transaction(&transaction)? { return Err(anyhow::anyhow!("Transaction verification failed")); } @@ -5144,15 +4968,12 @@ impl Blockchain { if fee_amount == 0 { return Ok(()); } - // At this point, token.transfer(net_amount) has already debited net_amount from - // the sender. The sender's remaining balance is exactly fee_amount (assuming the - // pre-check `from_bal >= amount` passed). Check against fee_amount, not amount. let sender_bal = token.balance_of(sender); - if sender_bal < fee_amount { + if sender_bal < amount { return Err(anyhow::anyhow!( - "TokenTransfer fee deduction failed: have {}, need fee {}", + "TokenTransfer insufficient balance: have {}, need {}", sender_bal, - fee_amount + amount )); } let sender_bal_post = token.balance_of(sender); @@ -5200,55 +5021,6 @@ impl Blockchain { } evicted } - /// Evict pending transactions that carry a non-empty but invalid signature. - /// - /// System transactions (e.g. IdentityRegistration) historically bypassed signature - /// validation at mempool intake. Clients that accidentally attached a malformed - /// Dilithium5 signature (wrong byte length) would therefore slip into the mempool - /// and stay there indefinitely, because: - /// - They always pass g1's intake check (signature skipped for system txs). - /// - They always fail block verification on other validators (sig parsing fails). - /// - g1's proposed blocks are rejected via NIL votes → only the invalid txs' own - /// proposer is ever blocked, but the txs never get committed or removed. - /// - /// This function must be called at startup (load_from_file / load_from_store) to - /// drain the backlog accumulated before the fix was deployed. - pub fn evict_invalid_signature_transactions(&mut self, context: &str) -> usize { - use crate::transaction::validation::TransactionValidator; - let validator = TransactionValidator::new(); - let before = self.pending_transactions.len(); - self.pending_transactions.retain(|tx| { - // Only inspect transactions that carry a non-empty signature. - if tx.signature.signature.is_empty() { - return true; - } - // Reuse the shared validator; treat every such transaction as non-system - // so that validate_signature is always invoked. - match validator.validate_transaction_with_system_flag(tx, false) { - Ok(_) => true, - Err(e) => { - warn!( - "{}: evicting invalid-signature pending tx hash={} type={:?} sig_len={} err={:?}", - context, - hex::encode(&tx.hash().as_bytes()[..8]), - tx.transaction_type, - tx.signature.signature.len(), - e, - ); - false - } - } - }); - let evicted = before - self.pending_transactions.len(); - if evicted > 0 { - warn!( - "{}: evicted {} invalid-signature pending transaction(s)", - context, evicted - ); - } - evicted - } - fn resolve_credit_pubkey_from_parts( &self, public_key: Vec, @@ -11939,7 +11711,7 @@ impl Blockchain { /// File format magic bytes - "ZHTP" const FILE_MAGIC: [u8; 4] = [0x5A, 0x48, 0x54, 0x50]; /// Current file format version - const FILE_VERSION: u16 = 8; + const FILE_VERSION: u16 = 7; #[deprecated( since = "0.2.0", @@ -11964,8 +11736,8 @@ impl Blockchain { std::fs::create_dir_all(parent)?; } - // Convert to stable storage format (V8) - let storage = BlockchainStorageV8::from_blockchain(self); + // Convert to stable storage format (V7) + let storage = BlockchainStorageV7::from_blockchain(self); // Serialize to bincode let serialized = bincode::serialize(&storage) @@ -12040,25 +11812,10 @@ impl Blockchain { info!("📂 Detected versioned format v{}", version); match version { - 8 => match bincode::deserialize::(data) { - Ok(storage) => { - info!("📂 Loaded blockchain storage v8 (bonding-curve-registry persistence format)"); - storage.to_blockchain() - } - Err(storage_err) => { - error!("❌ Failed to deserialize v8 blockchain: {}", storage_err); - return Err(anyhow::anyhow!( - "Failed to deserialize v8 blockchain: {}", - storage_err - )); - } - }, 7 => match bincode::deserialize::(data) { Ok(storage) => { - info!("📂 Loaded blockchain storage v7 (migrating to v8)"); + info!("📂 Loaded blockchain storage v7 (cbe-token persistence format)"); storage.to_blockchain() - // bonding_curve_registry will be empty; the post-load - // backfill below will re-register the CBE genesis entry. } Err(storage_err) => { error!("❌ Failed to deserialize v7 blockchain: {}", storage_err); @@ -12217,17 +11974,6 @@ impl Blockchain { } blockchain.rebuild_dao_registry_index(); - // V7→V8 migration: re-register CBE bonding curve entry if registry is empty. - // Pre-V8 files did not persist bonding_curve_registry, so get_cbe_curve_price_atomic() - // returned None after every restart, causing the oracle to emit cbe_usd_price: None. - { - let cbe_token_id = Self::derive_cbe_token_id(); - if !blockchain.bonding_curve_registry.contains(&cbe_token_id) { - blockchain.initialize_cbe_genesis(); - info!("📂 Restored CBE bonding curve registry entry (V7→V8 migration)"); - } - } - let elapsed = start.elapsed(); // Migrate legacy initial_balance values from human SOV to atomic units. @@ -12372,7 +12118,6 @@ impl Blockchain { // disk after the next successful block save, even if no other valid transaction // arrives in the same session. blockchain.evict_phase2_invalid_transactions("load_from_file"); - blockchain.evict_invalid_signature_transactions("load_from_file"); info!("📂 Blockchain loaded successfully (height: {}, identities: {}, wallets: {}, tokens: {}, UTXOs: {}, {:?})", blockchain.height, blockchain.identity_registry.len(), diff --git a/lib-blockchain/src/genesis/mod.rs b/lib-blockchain/src/genesis/mod.rs index 9314ef18..4c22d321 100644 --- a/lib-blockchain/src/genesis/mod.rs +++ b/lib-blockchain/src/genesis/mod.rs @@ -42,6 +42,7 @@ const EMBEDDED_GENESIS_TOML: &[u8] = include_bytes!("../../../genesis.toml"); #[derive(Debug, Clone, Deserialize)] pub struct GenesisConfig { pub chain: ChainConfig, + pub sov: SovConfig, pub cbe_token: CbeTokenConfig, pub entity_registry: EntityRegistryConfig, pub bootstrap_council: BootstrapCouncilConfig, @@ -57,6 +58,11 @@ pub struct ChainConfig { pub genesis_time: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct SovConfig { + pub initial_supply: u64, +} + #[derive(Debug, Clone, Deserialize)] pub struct CbeTokenConfig { pub total_supply: u64, diff --git a/lib-blockchain/src/resources.rs b/lib-blockchain/src/resources.rs index 25193167..068d69f7 100644 --- a/lib-blockchain/src/resources.rs +++ b/lib-blockchain/src/resources.rs @@ -23,10 +23,10 @@ pub struct BlockLimits { impl Default for BlockLimits { fn default() -> Self { Self { - max_payload_bytes: 4_194_304, // 4 MB - max_witness_bytes: 2_097_152, // 2 MB (matches fee params block_max_witness_bytes) + max_payload_bytes: 1_048_576, // 1 MB + max_witness_bytes: 524_288, // 512 KB max_verify_units: 1_000_000, // 1M verify units - max_state_write_bytes: 4_194_304, // 4 MB + max_state_write_bytes: 2_097_152, // 2 MB max_tx_count: 10_000, // 10k txs } } @@ -516,10 +516,10 @@ mod tests { fn golden_default_limits() { let limits = BlockLimits::default(); - assert_eq!(limits.max_payload_bytes, 4_194_304, "4 MB"); - assert_eq!(limits.max_witness_bytes, 2_097_152, "2 MB"); + assert_eq!(limits.max_payload_bytes, 1_048_576, "1 MB"); + assert_eq!(limits.max_witness_bytes, 524_288, "512 KB"); assert_eq!(limits.max_verify_units, 1_000_000, "1M units"); - assert_eq!(limits.max_state_write_bytes, 4_194_304, "4 MB"); + assert_eq!(limits.max_state_write_bytes, 2_097_152, "2 MB"); assert_eq!(limits.max_tx_count, 10_000, "10k txs"); } } diff --git a/lib-blockchain/src/storage/mod.rs b/lib-blockchain/src/storage/mod.rs index 074be40f..cba7acf6 100644 --- a/lib-blockchain/src/storage/mod.rs +++ b/lib-blockchain/src/storage/mod.rs @@ -1151,20 +1151,6 @@ pub trait BlockchainStore: Send + Sync + fmt::Debug { /// - MUST have an active transaction from begin_block fn rollback_block(&self) -> StorageResult<()>; - /// Begin a supplementary write batch for side-data (identity, wallet) that - /// must be written after the executor has already committed the block. - /// - /// Unlike begin_block, this does NOT validate block height and does NOT - /// update LATEST_HEIGHT on commit. Safe to call after executor commit_block. - fn begin_supplementary_writes(&self) -> StorageResult<()>; - - /// Commit the supplementary write batch opened by begin_supplementary_writes. - /// Writes identity/wallet data without touching block height metadata. - fn commit_supplementary_writes(&self) -> StorageResult<()>; - - /// Discard the supplementary write batch. - fn rollback_supplementary_writes(&self) -> StorageResult<()>; - // ========================================================================= // Account State (Legacy - Migrating to typed sub-stores) // ========================================================================= diff --git a/lib-blockchain/src/storage/sled_store.rs b/lib-blockchain/src/storage/sled_store.rs index 17962751..9277b31a 100644 --- a/lib-blockchain/src/storage/sled_store.rs +++ b/lib-blockchain/src/storage/sled_store.rs @@ -1157,66 +1157,6 @@ impl BlockchainStore for SledStore { Ok(()) } - fn begin_supplementary_writes(&self) -> StorageResult<()> { - // No transaction must be active - if self.tx_active.swap(true, Ordering::SeqCst) { - return Err(StorageError::TransactionAlreadyActive); - } - let mut batch_guard = self.tx_batch.lock().unwrap(); - *batch_guard = Some(PendingBatch::new()); - Ok(()) - } - - fn commit_supplementary_writes(&self) -> StorageResult<()> { - self.require_transaction()?; - - let batch = { - let mut batch_guard = self.tx_batch.lock().unwrap(); - batch_guard - .take() - .ok_or(StorageError::NoActiveTransaction)? - }; - - // Apply identity, wallet, and token side-data only. - // Do NOT update LATEST_HEIGHT — the executor already did that. - // NOTE: token_contracts must be included because process_wallet_transactions - // calls put_token_contract() (e.g. for initial SOV registration). - self.identities - .apply_batch(batch.identities) - .map_err(|e| StorageError::Database(e.to_string()))?; - - self.identity_metadata - .apply_batch(batch.identity_metadata) - .map_err(|e| StorageError::Database(e.to_string()))?; - - self.identity_by_owner - .apply_batch(batch.identity_by_owner) - .map_err(|e| StorageError::Database(e.to_string()))?; - - self.accounts - .apply_batch(batch.accounts) - .map_err(|e| StorageError::Database(e.to_string()))?; - - self.token_contracts - .apply_batch(batch.token_contracts) - .map_err(|e| StorageError::Database(e.to_string()))?; - - self.db - .flush() - .map_err(|e| StorageError::Database(e.to_string()))?; - - self.tx_active.store(false, Ordering::SeqCst); - Ok(()) - } - - fn rollback_supplementary_writes(&self) -> StorageResult<()> { - self.require_transaction()?; - let mut batch_guard = self.tx_batch.lock().unwrap(); - *batch_guard = None; - self.tx_active.store(false, Ordering::SeqCst); - Ok(()) - } - // ========================================================================= // Bonding Curve Operations // ========================================================================= diff --git a/lib-consensus/src/engines/consensus_engine/state_machine.rs b/lib-consensus/src/engines/consensus_engine/state_machine.rs index 63254da6..476bca37 100644 --- a/lib-consensus/src/engines/consensus_engine/state_machine.rs +++ b/lib-consensus/src/engines/consensus_engine/state_machine.rs @@ -886,14 +886,12 @@ impl ConsensusEngine { /// /// This ensures Byzantine fault tolerance - no single node can inject blocks. #[allow(deprecated)] - async fn process_committed_block(&mut self, round: u32, proposal_id: &Hash) -> ConsensusResult<()> { + async fn process_committed_block(&mut self, proposal_id: &Hash) -> ConsensusResult<()> { // SAFETY: Verify commit quorum before processing (Issue #939) - // This is a defense-in-depth check - callers must already verify commit votes. - // Use `round` (not self.current_round.round) because this may be called for a past - // round when late commit votes arrive after the node advanced to the next round. + // This is a defense-in-depth check - callers must already verify commit votes let commit_count = self.count_commits_for( self.current_round.height, - round, + self.current_round.round, proposal_id, ); let total_validators = self.validator_manager.get_active_validators().len() as u64; @@ -2134,21 +2132,10 @@ impl ConsensusEngine { // Process the committed block (finalization) directly // Note: This is safe even if we've already finalized once, // process_committed_block is idempotent. - self.process_committed_block(round, proposal_id).await?; - } else if self.current_round.height == height { - // Late-finalization: commit votes for a past round arrived after we advanced - // to a new round at the same height. The quorum is valid — apply the block. - // The commit_finalized_block callback is idempotent (skips if already applied). - tracing::info!( - "Late-finalization: commit quorum for H={} R={} reached while at R={}, applying block", - height, - round, - self.current_round.round - ); - self.process_committed_block(round, proposal_id).await?; + self.process_committed_block(proposal_id).await?; } else { tracing::debug!( - "Commit quorum observed for past height (H={} R={}) while at H={} R={}", + "Commit quorum observed for past round (H={} R={}) while at H={} R={}", height, round, self.current_round.height, diff --git a/lib-network/src/protocols/quic_mesh.rs b/lib-network/src/protocols/quic_mesh.rs index 567f954d..aa0e3529 100644 --- a/lib-network/src/protocols/quic_mesh.rs +++ b/lib-network/src/protocols/quic_mesh.rs @@ -1001,8 +1001,8 @@ impl QuicMeshProtocol { loop { match conn.accept_uni().await { Ok(mut stream) => { - match stream.read_to_end(4 * 1024 * 1024).await { - // 4MB max (matches max_payload_bytes in BlockLimits) + match stream.read_to_end(1024 * 1024).await { + // 1MB max Ok(encrypted) => { match decrypt_data(&encrypted, &session_key) { Ok(decrypted) => { @@ -1248,7 +1248,7 @@ impl QuicMeshProtocol { loop { match quic_conn_clone.accept_uni().await { Ok(mut stream) => { - match stream.read_to_end(4 * 1024 * 1024).await { + match stream.read_to_end(1024 * 1024).await { Ok(encrypted) => { match decrypt_data( &encrypted, @@ -1792,7 +1792,7 @@ impl PqcQuicConnection { // Receive from QUIC (TLS 1.3 decryption automatic) let mut stream = self.quic_conn.accept_uni().await?; - let encrypted = stream.read_to_end(4 * 1024 * 1024).await?; // 4MB max (matches max_payload_bytes in BlockLimits) + let encrypted = stream.read_to_end(1024 * 1024).await?; // 1MB max message size // Decrypt using master key (nonce is embedded in encrypted data by lib-crypto) let decrypted = decrypt_data(&encrypted, &session_key)?; diff --git a/zhtp-cli/src/argument_parsing.rs b/zhtp-cli/src/argument_parsing.rs index e6b4fb0a..c2acae49 100644 --- a/zhtp-cli/src/argument_parsing.rs +++ b/zhtp-cli/src/argument_parsing.rs @@ -1439,15 +1439,9 @@ pub enum GenesisCommand { #[arg(short, long)] output: Option, }, - /// Export the full blockchain state to a JSON snapshot. - /// Supports both SledStore directories (live nodes) and legacy blockchain.dat files. + /// Export the full blockchain state from a .dat file to a JSON snapshot ExportState { - /// Path to the Sled data directory (e.g. /opt/zhtp/data/testnet/sled). - /// Takes priority over --dat-file when both are supplied. - #[arg(long)] - sled_dir: Option, - /// Path to blockchain.dat (legacy; used only when --sled-dir is absent). - /// Defaults to ~/.zhtp/data/testnet/blockchain.dat. + /// Path to blockchain.dat (defaults to ~/.zhtp/data/testnet/blockchain.dat) #[arg(short, long)] dat_file: Option, /// Output JSON snapshot file diff --git a/zhtp-cli/src/commands/genesis.rs b/zhtp-cli/src/commands/genesis.rs index b4003639..fdfac6e8 100644 --- a/zhtp-cli/src/commands/genesis.rs +++ b/zhtp-cli/src/commands/genesis.rs @@ -8,9 +8,7 @@ use crate::argument_parsing::{GenesisArgs, GenesisCommand, ZhtpCli}; pub async fn handle_genesis_command(args: GenesisArgs, _cli: &ZhtpCli) -> Result<()> { match args.command { GenesisCommand::Build { config, output } => cmd_build(config, output), - GenesisCommand::ExportState { sled_dir, dat_file, output } => { - cmd_export_state(sled_dir, dat_file, output) - } + GenesisCommand::ExportState { dat_file, output } => cmd_export_state(dat_file, output), GenesisCommand::MigrateState { snapshot, config, @@ -48,39 +46,16 @@ fn cmd_build(config: Option, output: Option) -> Result<()> { Ok(()) } -/// Export the full blockchain state to a JSON snapshot. -/// -/// Prefers SledStore (live-node data directory) when `sled_dir` is supplied. -/// Falls back to legacy `blockchain.dat` when only `dat_file` is given (or the -/// default ~/.zhtp path). -fn cmd_export_state( - sled_dir: Option, - dat_file: Option, - output: PathBuf, -) -> Result<()> { - let bc = if let Some(ref sled_path) = sled_dir { - println!("Loading blockchain from SledStore: {}", sled_path.display()); - println!( - "NOTE: SledStore does not support concurrent access. \ - Ensure the node is stopped (or this is a copy of the sled directory) \ - before running export-state." - ); - let store = std::sync::Arc::new( - lib_blockchain::storage::SledStore::open(sled_path) - .with_context(|| format!("Failed to open SledStore at {}", sled_path.display()))?, - ); - lib_blockchain::Blockchain::load_from_store(store) - .with_context(|| format!("Failed to load blockchain from SledStore at {}", sled_path.display()))? - .with_context(|| format!("SledStore at {} appears empty — no blocks found", sled_path.display()))? - } else { - let dat_path = dat_file.unwrap_or_else(|| { - let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); - PathBuf::from(home).join(".zhtp/data/testnet/blockchain.dat") - }); - println!("Loading blockchain from: {}", dat_path.display()); - lib_blockchain::Blockchain::load_from_file(&dat_path) - .with_context(|| format!("Failed to load {}", dat_path.display()))? - }; +/// Export the full blockchain state from a blockchain.dat file to a JSON snapshot. +fn cmd_export_state(dat_file: Option, output: PathBuf) -> Result<()> { + let dat_path = dat_file.unwrap_or_else(|| { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".zhtp/data/testnet/blockchain.dat") + }); + + println!("Loading blockchain from: {}", dat_path.display()); + let bc = lib_blockchain::Blockchain::load_from_file(&dat_path) + .with_context(|| format!("Failed to load {}", dat_path.display()))?; println!( "Loaded blockchain: height={}, wallets={}, identities={}, web4={}", diff --git a/zhtp/src/api/handlers/blockchain/mod.rs b/zhtp/src/api/handlers/blockchain/mod.rs index a10d95b5..c22deec2 100644 --- a/zhtp/src/api/handlers/blockchain/mod.rs +++ b/zhtp/src/api/handlers/blockchain/mod.rs @@ -98,27 +98,19 @@ impl BlockchainHandler { } fn tx_to_info(tx: &lib_blockchain::transaction::Transaction) -> TransactionInfo { - // Derive from/to/amount from the typed transaction data fields - let (from, to, amount) = if let Some(ref d) = tx.token_transfer_data { - (hex::encode(d.from), hex::encode(d.to), d.amount.min(u64::MAX as u128) as u64) - } else if let Some(ref d) = tx.token_mint_data { - ("system".to_string(), hex::encode(d.to), d.amount.min(u64::MAX as u128) as u64) - } else if let Some(ref d) = tx.wallet_data { - let owner = d.owner_identity_id - .map(|id| hex::encode(id.as_bytes())) - .unwrap_or_else(|| "unknown".to_string()); - (owner, hex::encode(d.wallet_id.as_bytes()), 0) - } else if let Some(ref d) = tx.identity_data { - ("system".to_string(), d.did.clone(), 0) - } else { - ("unknown".to_string(), "unknown".to_string(), 0) - }; - TransactionInfo { hash: tx.hash().to_string(), - from, - to, - amount, + from: tx + .inputs + .first() + .map(|i| i.previous_output.to_string()) + .unwrap_or_else(|| "genesis".to_string()), + to: tx + .outputs + .first() + .map(|o| format!("{:02x?}", &o.recipient.key_id[..8])) + .unwrap_or_else(|| "unknown".to_string()), + amount: 0, // Amount is hidden in commitment for privacy fee: tx.fee, transaction_type: format!("{:?}", tx.transaction_type), timestamp: tx.signature.timestamp, diff --git a/zhtp/src/api/handlers/wallet/mod.rs b/zhtp/src/api/handlers/wallet/mod.rs index 04cd06d1..05bb50c7 100644 --- a/zhtp/src/api/handlers/wallet/mod.rs +++ b/zhtp/src/api/handlers/wallet/mod.rs @@ -205,10 +205,7 @@ struct TransactionHistoryResponse { struct TransactionRecord { tx_hash: String, tx_type: String, - /// Raw atomic amount (1 SOV = 100_000_000 atomic units). Use `amount_human` for display. amount: u64, - /// Human-readable amount in SOV (atomic / 100_000_000). Use this for display. - amount_human: f64, fee: u64, from_wallet: Option, to_address: Option, @@ -1094,7 +1091,6 @@ impl WalletHandler { status: &str, timestamp: u64, block_height: Option, - token_decimals: u8, ) -> TransactionRecord { let tx_hash = tx.hash(); let amount = Self::infer_transaction_amount(tx); @@ -1105,14 +1101,10 @@ impl WalletHandler { .map(|d| hex::encode(d.to)) .or_else(|| tx.token_mint_data.as_ref().map(|d| hex::encode(d.to))); - let divisor = 10u64.pow(token_decimals as u32) as f64; - let amount_human = amount as f64 / divisor; - TransactionRecord { tx_hash: hex::encode(tx_hash.as_bytes()), tx_type: format!("{:?}", tx.transaction_type), amount, - amount_human, fee: tx.fee, from_wallet, to_address, @@ -1191,24 +1183,6 @@ impl WalletHandler { // Use a map keyed by hash to avoid duplicate records. let mut tx_by_hash: HashMap = HashMap::new(); - // Helper: look up decimals for a transaction's token (defaults to 18 if unknown). - let token_decimals_for_tx = - |tx: &lib_blockchain::transaction::Transaction| -> u8 { - let token_id = tx - .token_transfer_data - .as_ref() - .map(|d| d.token_id) - .or_else(|| tx.token_mint_data.as_ref().map(|d| d.token_id)); - match token_id { - Some(tid) => blockchain - .token_contracts - .get(&tid) - .map(|c| c.decimals) - .unwrap_or(18), - None => 18, - } - }; - // Search through all blocks for transactions for block in &blockchain.blocks { for tx in &block.transactions { @@ -1219,13 +1193,11 @@ impl WalletHandler { identity_id, &identity_did, ) { - let decimals = token_decimals_for_tx(tx); let record = Self::tx_to_record( tx, "confirmed", block.timestamp(), Some(block.height()), - decimals, ); tx_by_hash.insert(record.tx_hash.clone(), record); } @@ -1250,8 +1222,7 @@ impl WalletHandler { } else { now }; - let decimals = token_decimals_for_tx(tx); - let record = Self::tx_to_record(tx, "pending", ts, None, decimals); + let record = Self::tx_to_record(tx, "pending", ts, None); tx_by_hash.entry(record.tx_hash.clone()).or_insert(record); } }