diff --git a/Cargo.lock b/Cargo.lock index 551a4564..327650ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,7 @@ dependencies = [ "alloy-primitives", "borsh", "lee_core", + "program-revert", "risc0-zkvm", "ruint", "serde", @@ -119,6 +120,7 @@ dependencies = [ "amm_core", "clock_core", "lee_core", + "program-revert", "token_core", "twap_oracle_core", ] @@ -553,6 +555,7 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "program-revert", "risc0-zkvm", "serde", ] @@ -563,6 +566,7 @@ version = "0.1.0" dependencies = [ "ata_core", "lee_core", + "program-revert", "token_core", ] @@ -2224,6 +2228,8 @@ dependencies = [ "clock_core", "lee", "lee_core", + "risc0-zkvm", + "serde", "stablecoin-methods", "stablecoin_core", "token-methods", @@ -2927,6 +2933,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "program-revert" +version = "0.1.0" +dependencies = [ + "risc0-zkvm", + "serde", +] + +[[package]] +name = "program-revert-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "proptest" version = "1.11.0" @@ -4036,6 +4059,7 @@ dependencies = [ "alloy-primitives", "borsh", "lee_core", + "program-revert", "risc0-zkvm", "ruint", "serde", @@ -4069,6 +4093,7 @@ version = "0.1.0" dependencies = [ "clock_core", "lee_core", + "program-revert", "stablecoin_core", "token_core", "twap_oracle_core", @@ -4295,6 +4320,7 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "program-revert", "serde", "spel-framework-macros", ] @@ -4318,6 +4344,7 @@ name = "token_program" version = "0.1.0" dependencies = [ "lee_core", + "program-revert", "token_core", ] @@ -4551,6 +4578,7 @@ version = "0.1.0" dependencies = [ "clock_core", "lee_core", + "program-revert", "twap_oracle_core", ] diff --git a/Cargo.toml b/Cargo.toml index baf0e02e..9b499796 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,7 @@ [workspace] members = [ + "programs/common/revert", + "programs/common/revert-macros", "modules/amm/ffi", "modules/stablecoin/ffi", "modules/token/ffi", @@ -93,4 +95,4 @@ wildcard_enum_match_arm = "deny" # Too noisy for this codebase unless enforced selectively. module_name_repetitions = "allow" -similar_names = "allow" \ No newline at end of file +similar_names = "allow" diff --git a/programs/amm/Cargo.toml b/programs/amm/Cargo.toml index c7fd9888..cdf626ff 100644 --- a/programs/amm/Cargo.toml +++ b/programs/amm/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +program-revert = { path = "../common/revert" } lee_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", features = ["host"] } clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4" } amm_core = { path = "core" } diff --git a/programs/amm/core/Cargo.toml b/programs/amm/core/Cargo.toml index 2ef3e0c3..5a648cfe 100644 --- a/programs/amm/core/Cargo.toml +++ b/programs/amm/core/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +program-revert = { path = "../../common/revert" } lee_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", features = ["host"] } spel-framework-macros = { git = "https://github.com/logos-co/spel.git", rev = "1ef0500f8fc8ce3ddf95523726ae159db83f744f", package = "spel-framework-macros" } token_core = { path = "../../token/core" } diff --git a/programs/amm/core/src/error.rs b/programs/amm/core/src/error.rs new file mode 100644 index 00000000..1d5e7ba6 --- /dev/null +++ b/programs/amm/core/src/error.rs @@ -0,0 +1,13 @@ +//! Nonzero exit codes for the amm program. +//! +//! Codes are local to this program. Keep existing values stable when adding errors. +//! Diagnostic logs identify the failed check within each category. + +use std::num::NonZeroU8; + +/// Invalid instruction, account, authorization, configuration, or operation state. +pub const INVALID_INPUT: NonZeroU8 = NonZeroU8::MIN; +/// Requested amount exceeds the available balance, debt, or collateral. +pub const INSUFFICIENT_BALANCE: NonZeroU8 = NonZeroU8::new(2).expect("nonzero error code"); +/// Requested operation exceeds the representable arithmetic range. +pub const ARITHMETIC: NonZeroU8 = NonZeroU8::new(3).expect("nonzero error code"); diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index f570cbd8..a44c0846 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -1,5 +1,8 @@ //! This crate contains core data structures and utilities for the AMM Program. +use program_revert::UnwrapOrRevert as _; +pub mod error; + use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ account::{AccountId, AccountWithMetadata, Data}, @@ -276,7 +279,8 @@ pub fn is_supported_fee_tier(fees: u128) -> bool { } pub fn assert_supported_fee_tier(fees: u128) { - assert!( + program_revert::require!( + error::INVALID_INPUT, is_supported_fee_tier(fees), "Fee tier must be one of 1, 5, 30, or 100 basis points" ); @@ -290,13 +294,16 @@ pub fn assert_supported_fee_tier(fees: u128) { /// oracle consumes exactly this representation (it converts the `Q64.64` price to a tick), so the /// AMM owns the reserves → price mapping and the oracle stays agnostic to how the price is formed. /// -/// # Panics -/// Panics if `reserve_base` is zero. +/// # Failures +/// Reverts in the zkVM; panics on native targets. +/// +/// Reverts in the zkVM (panics on native targets) if `reserve_base` is zero. #[must_use] pub fn spot_price_q64_64(reserve_base: u128, reserve_quote: u128) -> u128 { use alloy_primitives::U256; - assert!( + program_revert::require!( + error::INVALID_INPUT, reserve_base != 0, "spot_price_q64_64: reserve_base must be non-zero" ); @@ -314,35 +321,47 @@ pub fn spot_price_q64_64(reserve_base: u128, reserve_quote: u128) -> u128 { /// `floor(a * b / c)` computed in U256 so the `a * b` product can't overflow u128. /// (Storage stays u128; only the intermediate widens.) /// -/// # Panics -/// Panics if `c` is zero, or if the result exceeds u128. +/// # Failures +/// Reverts in the zkVM; panics on native targets. +/// +/// Reverts in the zkVM (panics on native targets) if `c` is zero, or if the result exceeds u128. #[must_use] pub fn mul_div_floor(a: u128, b: u128, c: u128) -> u128 { use alloy_primitives::U256; - assert!(c != 0, "mul_div_floor: divisor must be non-zero"); + program_revert::require!( + error::INVALID_INPUT, + c != 0, + "mul_div_floor: divisor must be non-zero" + ); let product = U256::from(a) .checked_mul(U256::from(b)) .expect("u128 * u128 always fits in U256"); let result = product .checked_div(U256::from(c)) .expect("mul_div_floor: divisor is non-zero after the assertion above"); - u128::try_from(result).expect("mul_div_floor result exceeds u128") + u128::try_from(result).unwrap_or_revert(error::ARITHMETIC, "mul_div_floor result exceeds u128") } /// `ceil(a * b / c)` computed in U256 so the `a * b` product can't overflow u128. /// (Storage stays u128; only the intermediate widens.) /// -/// # Panics -/// Panics if `c` is zero, or if the result exceeds u128. +/// # Failures +/// Reverts in the zkVM; panics on native targets. +/// +/// Reverts in the zkVM (panics on native targets) if `c` is zero, or if the result exceeds u128. #[must_use] pub fn mul_div_ceil(a: u128, b: u128, c: u128) -> u128 { use alloy_primitives::U256; - assert!(c != 0, "mul_div_ceil: divisor must be non-zero"); + program_revert::require!( + error::INVALID_INPUT, + c != 0, + "mul_div_ceil: divisor must be non-zero" + ); let product = U256::from(a) .checked_mul(U256::from(b)) .expect("u128 * u128 always fits in U256"); let result = product.div_ceil(U256::from(c)); - u128::try_from(result).expect("mul_div_ceil result exceeds u128") + u128::try_from(result).unwrap_or_revert(error::ARITHMETIC, "mul_div_ceil result exceeds u128") } /// Adverse price impact of a swap in basis points: how far `amount_out` falls @@ -458,8 +477,10 @@ pub fn swap_exact_out_amounts( /// `floor(sqrt(a * b))` computed in U256 so the `a * b` product can't overflow u128. /// -/// # Panics -/// Panics if the result exceeds u128. +/// # Failures +/// Reverts in the zkVM; panics on native targets. +/// +/// Reverts in the zkVM (panics on native targets) if the result exceeds u128. #[must_use] pub fn isqrt_product(a: u128, b: u128) -> u128 { use alloy_primitives::U256; @@ -572,7 +593,9 @@ pub fn compute_pool_pda_seed( { std::cmp::Ordering::Less => (definition_token_b_id, definition_token_a_id), std::cmp::Ordering::Greater => (definition_token_a_id, definition_token_b_id), - std::cmp::Ordering::Equal => panic!("Definitions match"), + std::cmp::Ordering::Equal => { + program_revert::revert!(error::INVALID_INPUT, "Definitions match") + } }; let mut bytes = [0; 64]; @@ -654,15 +677,23 @@ pub fn compute_lp_lock_holding_pda_seed(pool_id: AccountId) -> PdaSeed { } fn read_fungible_holding(account: &AccountWithMetadata, context: &str) -> (AccountId, u128) { - let token_holding = token_core::TokenHolding::try_from(&account.account.data) - .unwrap_or_else(|_| panic!("{context}: AMM Program expects a valid Token Holding Account")); + let token_holding = + token_core::TokenHolding::try_from(&account.account.data).unwrap_or_else(|_| { + program_revert::revert!( + error::INVALID_INPUT, + "{context}: AMM Program expects a valid Token Holding Account" + ) + }); let token_core::TokenHolding::Fungible { definition_id, balance, } = token_holding else { - panic!("{context}: AMM Program expects a valid Fungible Token Holding Account"); + program_revert::revert!( + error::INVALID_INPUT, + "{context}: AMM Program expects a valid Fungible Token Holding Account" + ); }; (definition_id, balance) diff --git a/programs/amm/methods/guest/Cargo.lock b/programs/amm/methods/guest/Cargo.lock index 31acbcec..2b872013 100644 --- a/programs/amm/methods/guest/Cargo.lock +++ b/programs/amm/methods/guest/Cargo.lock @@ -66,6 +66,8 @@ dependencies = [ "amm_program", "borsh", "lee_core", + "program-revert", + "program-revert-macros", "risc0-zkvm", "serde", "spel-framework", @@ -79,6 +81,7 @@ dependencies = [ "alloy-primitives", "borsh", "lee_core", + "program-revert", "risc0-zkvm", "ruint", "serde", @@ -93,6 +96,7 @@ dependencies = [ "amm_core", "clock_core", "lee_core", + "program-revert", "token_core", "twap_oracle_core", ] @@ -1945,6 +1949,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "program-revert" +version = "0.1.0" +dependencies = [ + "risc0-zkvm", + "serde", +] + +[[package]] +name = "program-revert-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "proptest" version = "1.11.0" @@ -2865,6 +2886,7 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "program-revert", "serde", "spel-framework-macros", ] diff --git a/programs/amm/methods/guest/Cargo.toml b/programs/amm/methods/guest/Cargo.toml index 9d91cc03..133fd36d 100644 --- a/programs/amm/methods/guest/Cargo.toml +++ b/programs/amm/methods/guest/Cargo.toml @@ -56,6 +56,8 @@ name = "amm" path = "src/bin/amm.rs" [dependencies] +program-revert-macros = { path = "../../../common/revert-macros" } +program-revert = { path = "../../../common/revert" } spel-framework = { git = "https://github.com/logos-co/spel.git", rev = "1ef0500f8fc8ce3ddf95523726ae159db83f744f", package = "spel-framework" } nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", package = "lee_core" } risc0-zkvm = { version = "=3.0.5", default-features = false } @@ -63,4 +65,4 @@ amm_core = { path = "../../core" } amm_program = { path = "../..", package = "amm_program" } token_core = { path = "../../../token/core" } serde = { version = "1.0", features = ["derive"] } -borsh = "1.5" \ No newline at end of file +borsh = "1.5" diff --git a/programs/amm/methods/guest/src/bin/amm.rs b/programs/amm/methods/guest/src/bin/amm.rs index 4f1a0fd9..7e33dc7e 100644 --- a/programs/amm/methods/guest/src/bin/amm.rs +++ b/programs/amm/methods/guest/src/bin/amm.rs @@ -6,6 +6,8 @@ use std::num::NonZeroU128; +use program_revert::UnwrapOrRevert as _; + use spel_framework::prelude::*; use spel_framework::context::ProgramContext; use nssa_core::{ @@ -14,8 +16,9 @@ use nssa_core::{ }; #[cfg(not(test))] -risc0_zkvm::guest::entry!(main); +risc0_zkvm::guest::entry!(metered_main); +#[program_revert_macros::metered_entry(amm_core::error::INVALID_INPUT)] #[lez_program(instruction = "amm_core::Instruction")] mod amm { #[expect( @@ -183,8 +186,14 @@ mod amm { user_holding_lp, current_tick_account, clock, - NonZeroU128::new(token_a_amount).expect("token_a_amount must be nonzero"), - NonZeroU128::new(token_b_amount).expect("token_b_amount must be nonzero"), + NonZeroU128::new(token_a_amount).unwrap_or_revert( + amm_core::error::INVALID_INPUT, + "token_a_amount must be nonzero", + ), + NonZeroU128::new(token_b_amount).unwrap_or_revert( + amm_core::error::INVALID_INPUT, + "token_b_amount must be nonzero", + ), fees, ctx.self_program_id, ); @@ -234,7 +243,10 @@ mod amm { user_holding_lp, current_tick_account, clock, - NonZeroU128::new(min_amount_liquidity).expect("min_amount_liquidity must be nonzero"), + NonZeroU128::new(min_amount_liquidity).unwrap_or_revert( + amm_core::error::INVALID_INPUT, + "min_amount_liquidity must be nonzero", + ), max_amount_to_add_token_a, max_amount_to_add_token_b, ctx.self_program_id, @@ -285,8 +297,10 @@ mod amm { user_holding_lp, current_tick_account, clock, - NonZeroU128::new(remove_liquidity_amount) - .expect("remove_liquidity_amount must be nonzero"), + NonZeroU128::new(remove_liquidity_amount).unwrap_or_revert( + amm_core::error::INVALID_INPUT, + "remove_liquidity_amount must be nonzero", + ), min_amount_to_remove_token_a, min_amount_to_remove_token_b, ctx.self_program_id, diff --git a/programs/amm/src/add.rs b/programs/amm/src/add.rs index 17ea849c..b3b1b562 100644 --- a/programs/amm/src/add.rs +++ b/programs/amm/src/add.rs @@ -2,7 +2,7 @@ use std::num::NonZeroU128; use amm_core::{ assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed, - compute_pool_pda_seed, mul_div_floor, read_vault_fungible_balances, spot_price_q64_64, + compute_pool_pda_seed, error, mul_div_floor, read_vault_fungible_balances, spot_price_q64_64, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; @@ -10,6 +10,7 @@ use lee_core::{ account::{AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use twap_oracle_core::compute_current_tick_account_pda; #[expect( @@ -34,65 +35,88 @@ pub fn add_liquidity( ) -> (Vec, Vec) { // The program IDs are taken from the config account, not trusted from a caller-supplied // holding. Validating the config PDA is also the Program's initialization gate. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, config.account_id, compute_config_pda(amm_program_id), "Add liquidity: AMM config Account ID does not match PDA" ); - let config_data = AmmConfig::try_from(&config.account.data) - .expect("Add liquidity: AMM Program must be initialized before use"); + let config_data = AmmConfig::try_from(&config.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Add liquidity: AMM Program must be initialized before use", + ); let token_program_id = config_data.token_program_id; let twap_oracle_program_id = config_data.twap_oracle_program_id; // 1. Fetch Pool state - let pool_def_data = PoolDefinition::try_from(&pool.account.data) - .expect("Add liquidity: AMM Program expects valid Pool Definition Account"); + let pool_def_data = PoolDefinition::try_from(&pool.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Add liquidity: AMM Program expects valid Pool Definition Account", + ); assert_supported_fee_tier(pool_def_data.fees); - assert_eq!( - vault_a.account_id, pool_def_data.vault_a_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_a.account_id, + pool_def_data.vault_a_id, "Vault A was not provided" ); - assert_eq!( - pool_def_data.liquidity_pool_id, pool_definition_lp.account_id, + program_revert::require_eq!( + error::INVALID_INPUT, + pool_def_data.liquidity_pool_id, + pool_definition_lp.account_id, "LP definition mismatch" ); - assert_eq!( - vault_b.account_id, pool_def_data.vault_b_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_b.account_id, + pool_def_data.vault_b_id, "Vault B was not provided" ); - assert_eq!( - vault_a.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_a.account.program_owner, + token_program_id, "Vault A must be owned by the configured Token Program" ); - assert_eq!( - vault_b.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_b.account.program_owner, + token_program_id, "Vault B must be owned by the configured Token Program" ); - assert_eq!( - user_holding_a.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + user_holding_a.account.program_owner, + token_program_id, "User Token A holding must be owned by the configured Token Program" ); - assert_eq!( - user_holding_b.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + user_holding_b.account.program_owner, + token_program_id, "User Token B holding must be owned by the configured Token Program" ); // The current tick is refreshed by a chained call to the oracle; validate its PDA and the // clock here so the add is rejected early with an AMM-level error. - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "Add liquidity: clock account must be the canonical 1-block LEZ clock account" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account_id, compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), "Add liquidity: current tick Account ID does not match PDA" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, max_amount_to_add_token_a != 0 && max_amount_to_add_token_b != 0, "Both max-balances must be nonzero" ); @@ -100,18 +124,28 @@ pub fn add_liquidity( let (vault_a_balance, vault_b_balance) = read_vault_fungible_balances("Add liquidity", &vault_a, &vault_b); - assert!( + program_revert::require!( + error::INVALID_INPUT, vault_a_balance >= pool_def_data.reserve_a, "Vaults' balances must be at least the reserve amounts" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, vault_b_balance >= pool_def_data.reserve_b, "Vaults' balances must be at least the reserve amounts" ); // 2. Determine deposit amount - assert!(pool_def_data.reserve_a != 0, "Reserves must be nonzero"); - assert!(pool_def_data.reserve_b != 0, "Reserves must be nonzero"); + program_revert::require!( + error::INVALID_INPUT, + pool_def_data.reserve_a != 0, + "Reserves must be nonzero" + ); + program_revert::require!( + error::INVALID_INPUT, + pool_def_data.reserve_b != 0, + "Reserves must be nonzero" + ); // floor(reserve * max_amount / reserve), products widened to U256. Reserves are nonzero // (asserted above), so the divisors are valid. @@ -138,17 +172,27 @@ pub fn add_liquidity( }; // 3. Validate amounts - assert!( + program_revert::require!( + error::INVALID_INPUT, max_amount_to_add_token_a >= actual_amount_a, "Actual trade amounts cannot exceed max_amounts" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, max_amount_to_add_token_b >= actual_amount_b, "Actual trade amounts cannot exceed max_amounts" ); - assert!(actual_amount_a != 0, "A trade amount is 0"); - assert!(actual_amount_b != 0, "A trade amount is 0"); + program_revert::require!( + error::INVALID_INPUT, + actual_amount_a != 0, + "A trade amount is 0" + ); + program_revert::require!( + error::INVALID_INPUT, + actual_amount_b != 0, + "A trade amount is 0" + ); // 4. Calculate LP to mint // floor(supply * actual / reserve), products widened to U256. @@ -165,9 +209,14 @@ pub fn add_liquidity( ), ); - assert!(delta_lp != 0, "Payable LP must be nonzero"); + program_revert::require!( + error::INVALID_INPUT, + delta_lp != 0, + "Payable LP must be nonzero" + ); - assert!( + program_revert::require!( + error::INVALID_INPUT, delta_lp >= min_amount_liquidity.get(), "Payable LP is less than provided minimum LP amount" ); @@ -178,15 +227,24 @@ pub fn add_liquidity( liquidity_pool_supply: pool_def_data .liquidity_pool_supply .checked_add(delta_lp) - .expect("liquidity_pool_supply + delta_lp overflows u128"), + .unwrap_or_revert( + error::ARITHMETIC, + "liquidity_pool_supply + delta_lp overflows u128", + ), reserve_a: pool_def_data .reserve_a .checked_add(actual_amount_a) - .expect("reserve_a + actual_amount_a overflows u128"), + .unwrap_or_revert( + error::ARITHMETIC, + "reserve_a + actual_amount_a overflows u128", + ), reserve_b: pool_def_data .reserve_b .checked_add(actual_amount_b) - .expect("reserve_b + actual_amount_b overflows u128"), + .unwrap_or_revert( + error::ARITHMETIC, + "reserve_b + actual_amount_b overflows u128", + ), ..pool_def_data }; diff --git a/programs/amm/src/create_oracle_price_account.rs b/programs/amm/src/create_oracle_price_account.rs index 5d957942..9f6c4ba9 100644 --- a/programs/amm/src/create_oracle_price_account.rs +++ b/programs/amm/src/create_oracle_price_account.rs @@ -1,12 +1,13 @@ use amm_core::{ - compute_config_pda, compute_pool_pda, compute_pool_pda_seed, spot_price_q64_64, AmmConfig, - PoolDefinition, + compute_config_pda, compute_pool_pda, compute_pool_pda_seed, error, spot_price_q64_64, + AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{ account::{Account, AccountWithMetadata}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use twap_oracle_core::{compute_oracle_price_account_pda, OBSERVATIONS_CAPACITY}; /// Creates a TWAP oracle price account for `pool` over a time window, on behalf of the AMM. @@ -26,8 +27,8 @@ use twap_oracle_core::{compute_oracle_price_account_pda, OBSERVATIONS_CAPACITY}; /// both are checked here so the call is rejected early with an AMM-level error, in addition to the /// oracle's own checks. /// -/// # Panics -/// Panics if: +/// # Failures +/// Reverts in the zkVM (panics on native targets) if: /// - `config.account_id` does not match `compute_config_pda(amm_program_id)`, or the config is /// uninitialized (the AMM Program has not been initialized). /// - `clock.account_id` is not [`CLOCK_01_PROGRAM_ACCOUNT_ID`]. @@ -51,18 +52,22 @@ pub fn create_oracle_price_account( amm_program_id: ProgramId, ) -> (Vec, Vec) { // Config gate: validate the config PDA and read the TWAP oracle program ID from it. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, config.account_id, compute_config_pda(amm_program_id), "Create oracle price account: AMM config Account ID does not match PDA" ); let twap_oracle_program_id = AmmConfig::try_from(&config.account.data) - .expect("Create oracle price account: AMM Program must be initialized before use") + .unwrap_or_revert( + error::INVALID_INPUT, + "Create oracle price account: AMM Program must be initialized before use", + ) .twap_oracle_program_id; // The clock must be the canonical 1-block LEZ system clock; otherwise a caller could seed the // price account with a forged base timestamp. - assert_eq!( + program_revert::require_eq!(error::INVALID_INPUT, clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, "Create oracle price account: clock account must be the canonical 1-block LEZ clock account" ); @@ -70,7 +75,7 @@ pub fn create_oracle_price_account( // A window smaller than the observations capacity can never have a matching PriceObservations // account, so PublishPrice could never update the price account. Reject early with an AMM-level // error; the oracle enforces the same bound. - assert!( + program_revert::require!(error::INVALID_INPUT, window_duration >= u64::from(OBSERVATIONS_CAPACITY), "Create oracle price account: window_duration must be >= OBSERVATIONS_CAPACITY so a matching \ PriceObservations account can exist and PublishPrice can update this price account" @@ -79,9 +84,12 @@ pub fn create_oracle_price_account( // The pool is the price source. Verify it is a genuine AMM pool PDA so we only ever authorize a // real pool as the source, and derive the asset pair and initial price from its validated // state. - let pool_def = PoolDefinition::try_from(&pool.account.data) - .expect("Create oracle price account: AMM Program expects a valid Pool Definition Account"); - assert_eq!( + let pool_def = PoolDefinition::try_from(&pool.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Create oracle price account: AMM Program expects a valid Pool Definition Account", + ); + program_revert::require_eq!( + error::INVALID_INPUT, pool.account_id, compute_pool_pda( amm_program_id, @@ -96,7 +104,8 @@ pub fn create_oracle_price_account( // A zero spot price is the sentinel consumers treat as "no valid price", so the account must // never be seeded with it. This happens when `reserve_b` is zero or so small relative to // `reserve_a` that the Q64.64 division floors to zero. The oracle enforces the same bound. - assert!( + program_revert::require!( + error::INVALID_INPUT, initial_price != 0, "Create oracle price account: pool spot price must be non-zero (zero is the no-price \ sentinel; pool reserve_b is zero or negligible relative to reserve_a)" @@ -104,12 +113,14 @@ pub fn create_oracle_price_account( // Verify the price account is the expected TWAP PDA for this (pool, window) pair and reject if // it already exists. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, oracle_price_account.account_id, compute_oracle_price_account_pda(twap_oracle_program_id, pool.account_id, window_duration), "Create oracle price account: oracle price Account ID does not match PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, oracle_price_account.account, Account::default(), "Create oracle price account: oracle price account already exists" diff --git a/programs/amm/src/create_price_observations.rs b/programs/amm/src/create_price_observations.rs index 1a350695..2a227b57 100644 --- a/programs/amm/src/create_price_observations.rs +++ b/programs/amm/src/create_price_observations.rs @@ -1,11 +1,12 @@ use amm_core::{ - compute_config_pda, compute_pool_pda, compute_pool_pda_seed, AmmConfig, PoolDefinition, + compute_config_pda, compute_pool_pda, compute_pool_pda_seed, error, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{ account::{Account, AccountWithMetadata}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use twap_oracle_core::{ compute_current_tick_account_pda, compute_price_observations_pda, CurrentTickAccount, }; @@ -27,8 +28,8 @@ use twap_oracle_core::{ /// exist — both are checked here so the call is rejected early with an AMM-level error, in /// addition to the oracle's own checks. /// -/// # Panics -/// Panics if: +/// # Failures +/// Reverts in the zkVM (panics on native targets) if: /// - `config.account_id` does not match `compute_config_pda(amm_program_id)`, or the config is /// uninitialized (the AMM Program has not been initialized). /// - `clock.account_id` is not [`CLOCK_01_PROGRAM_ACCOUNT_ID`]. @@ -48,27 +49,36 @@ pub fn create_price_observations( amm_program_id: ProgramId, ) -> (Vec, Vec) { // Config gate: validate the config PDA and read the TWAP oracle program ID from it. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, config.account_id, compute_config_pda(amm_program_id), "Create price observations: AMM config Account ID does not match PDA" ); let twap_oracle_program_id = AmmConfig::try_from(&config.account.data) - .expect("Create price observations: AMM Program must be initialized before use") + .unwrap_or_revert( + error::INVALID_INPUT, + "Create price observations: AMM Program must be initialized before use", + ) .twap_oracle_program_id; // The clock must be the canonical 1-block LEZ system clock; otherwise a caller could seed the // feed with a forged base timestamp. - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "Create price observations: clock account must be the canonical 1-block LEZ clock account" ); // The pool is the price source. Verify it is a genuine AMM pool PDA so we only ever authorize // a real pool as the source. - let pool_def = PoolDefinition::try_from(&pool.account.data) - .expect("Create price observations: AMM Program expects a valid Pool Definition Account"); - assert_eq!( + let pool_def = PoolDefinition::try_from(&pool.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Create price observations: AMM Program expects a valid Pool Definition Account", + ); + program_revert::require_eq!( + error::INVALID_INPUT, pool.account_id, compute_pool_pda( amm_program_id, @@ -80,23 +90,29 @@ pub fn create_price_observations( // The initial tick comes from the pool's authoritative CurrentTickAccount, not from the // caller. Verifying its PDA ties it to this exact pool, so the seed tick cannot be forged. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account_id, compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), "Create price observations: current tick Account ID does not match PDA" ); let initial_tick = CurrentTickAccount::try_from(¤t_tick_account.account.data) - .expect("Create price observations: AMM Program expects a valid CurrentTickAccount") + .unwrap_or_revert( + error::INVALID_INPUT, + "Create price observations: AMM Program expects a valid CurrentTickAccount", + ) .tick; // Verify the observations account is the expected TWAP PDA for this (pool, window) pair and // reject if it already exists. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, price_observations.account_id, compute_price_observations_pda(twap_oracle_program_id, pool.account_id, window_duration), "Create price observations: price observations Account ID does not match PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, price_observations.account, Account::default(), "Create price observations: price observations account already exists" diff --git a/programs/amm/src/initialize.rs b/programs/amm/src/initialize.rs index 30799e88..7eaa3dab 100644 --- a/programs/amm/src/initialize.rs +++ b/programs/amm/src/initialize.rs @@ -1,4 +1,4 @@ -use amm_core::{compute_config_pda, compute_config_pda_seed, AmmConfig}; +use amm_core::{compute_config_pda, compute_config_pda_seed, error, AmmConfig}; use lee_core::{ account::{Account, AccountId, AccountWithMetadata, Data}, program::{AccountPostState, Claim, ProgramId}, @@ -13,8 +13,8 @@ use lee_core::{ /// existence is the Program's "initialized" flag: the chained-call instructions read these /// program IDs from it and reject calls until it exists. /// -/// # Panics -/// Panics if: +/// # Failures +/// Reverts in the zkVM (panics on native targets) if: /// - `config.account_id` does not match `compute_config_pda(amm_program_id)`. /// - `config.account` is not the default (the Program is already initialized). pub fn initialize( @@ -24,12 +24,14 @@ pub fn initialize( authority: AccountId, amm_program_id: ProgramId, ) -> Vec { - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, config.account_id, compute_config_pda(amm_program_id), "Initialize: AMM config Account ID does not match PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, config.account, Account::default(), "Initialize: AMM config account must be uninitialized" diff --git a/programs/amm/src/new_definition.rs b/programs/amm/src/new_definition.rs index f0ce8eba..d1d2b49f 100644 --- a/programs/amm/src/new_definition.rs +++ b/programs/amm/src/new_definition.rs @@ -4,7 +4,7 @@ use amm_core::{ assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda, compute_liquidity_token_pda_seed, compute_lp_lock_holding_pda, compute_lp_lock_holding_pda_seed, compute_pool_pda, compute_pool_pda_seed, compute_vault_pda, - compute_vault_pda_seed, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition, + compute_vault_pda_seed, error, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; @@ -12,6 +12,7 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, Claim, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use token_core::TokenDefinition; use twap_oracle_core::compute_current_tick_account_pda; @@ -37,58 +38,77 @@ pub fn new_definition( amm_program_id: ProgramId, ) -> (Vec, Vec) { let definition_token_a_id = token_core::TokenHolding::try_from(&user_holding_a.account.data) - .expect("New definition: AMM Program expects valid Token Holding account for Token A") + .unwrap_or_revert( + error::INVALID_INPUT, + "New definition: AMM Program expects valid Token Holding account for Token A", + ) .definition_id(); let definition_token_b_id = token_core::TokenHolding::try_from(&user_holding_b.account.data) - .expect("New definition: AMM Program expects valid Token Holding account for Token B") + .unwrap_or_revert( + error::INVALID_INPUT, + "New definition: AMM Program expects valid Token Holding account for Token B", + ) .definition_id(); // The Token Program is taken from the config account, not trusted from a caller-supplied // holding. Validating the config PDA is also the Program's initialization gate. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, config.account_id, compute_config_pda(amm_program_id), "New definition: AMM config Account ID does not match PDA" ); - let config_data = AmmConfig::try_from(&config.account.data) - .expect("New definition: AMM Program must be initialized before use"); + let config_data = AmmConfig::try_from(&config.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "New definition: AMM Program must be initialized before use", + ); let token_program_id = config_data.token_program_id; let twap_oracle_program_id = config_data.twap_oracle_program_id; - assert_eq!( - user_holding_a.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + user_holding_a.account.program_owner, + token_program_id, "User Token A holding must be owned by the configured Token Program" ); - assert_eq!( - user_holding_b.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + user_holding_b.account.program_owner, + token_program_id, "User Token B holding must be owned by the configured Token Program" ); // Verify token_a and token_b are different - assert!( + program_revert::require!( + error::INVALID_INPUT, definition_token_a_id != definition_token_b_id, "Cannot set up a swap for a token with itself" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, pool.account_id, compute_pool_pda(amm_program_id, definition_token_a_id, definition_token_b_id), "Pool Definition Account ID does not match PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, vault_a.account_id, compute_vault_pda(amm_program_id, pool.account_id, definition_token_a_id), "Vault ID does not match PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, vault_b.account_id, compute_vault_pda(amm_program_id, pool.account_id, definition_token_b_id), "Vault ID does not match PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, pool_definition_lp.account_id, compute_liquidity_token_pda(amm_program_id, pool.account_id), "Liquidity pool Token Definition Account ID does not match PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, lp_lock_holding.account_id, compute_lp_lock_holding_pda(amm_program_id, pool.account_id), "LP lock holding Account ID does not match PDA" @@ -96,32 +116,38 @@ pub fn new_definition( assert_supported_fee_tier(fees); // Assert that pool is uninitialized (hard precondition) - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, pool.account, Account::default(), "Pool account must be uninitialized" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, user_holding_lp.account != Account::default() || user_holding_lp.is_authorized, "Fresh user LP holding requires user authorization" ); // The pool's TWAP current-tick account is created in the same transaction (a chained call to // the oracle). Validate its PDA and that the clock is the canonical 1-block LEZ clock. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account_id, compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), "New definition: current tick Account ID does not match PDA" ); - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "New definition: clock account must be the canonical 1-block LEZ clock account" ); // LP Token minting calculation. The `token_a * token_b` product is computed in U256 (via // `isqrt_product`) so realistic 18-decimal amounts can't overflow u128 before the sqrt. let initial_lp = isqrt_product(token_a_amount.get(), token_b_amount.get()); - assert!( + program_revert::require!( + error::INVALID_INPUT, initial_lp > MINIMUM_LIQUIDITY, "Initial liquidity must exceed minimum liquidity lock" ); diff --git a/programs/amm/src/remove.rs b/programs/amm/src/remove.rs index 8e3e2bf7..7edbb842 100644 --- a/programs/amm/src/remove.rs +++ b/programs/amm/src/remove.rs @@ -2,14 +2,15 @@ use std::num::NonZeroU128; use amm_core::{ assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed, - compute_pool_pda_seed, compute_vault_pda_seed, mul_div_floor, spot_price_q64_64, AmmConfig, - PoolDefinition, MINIMUM_LIQUIDITY, + compute_pool_pda_seed, compute_vault_pda_seed, error, mul_div_floor, spot_price_q64_64, + AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{ account::{AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use twap_oracle_core::compute_current_tick_account_pda; #[expect( @@ -36,61 +37,84 @@ pub fn remove_liquidity( // The program IDs are taken from the config account, not trusted from a caller-supplied // holding. Validating the config PDA is also the Program's initialization gate. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, config.account_id, compute_config_pda(amm_program_id), "Remove liquidity: AMM config Account ID does not match PDA" ); - let config_data = AmmConfig::try_from(&config.account.data) - .expect("Remove liquidity: AMM Program must be initialized before use"); + let config_data = AmmConfig::try_from(&config.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Remove liquidity: AMM Program must be initialized before use", + ); let token_program_id = config_data.token_program_id; let twap_oracle_program_id = config_data.twap_oracle_program_id; // 1. Fetch Pool state - let pool_def_data = PoolDefinition::try_from(&pool.account.data) - .expect("Remove liquidity: AMM Program expects a valid Pool Definition Account"); + let pool_def_data = PoolDefinition::try_from(&pool.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Remove liquidity: AMM Program expects a valid Pool Definition Account", + ); assert_supported_fee_tier(pool_def_data.fees); - assert!( + program_revert::require!( + error::INVALID_INPUT, pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, "Pool liquidity supply is below minimum liquidity" ); - assert_eq!( - pool_def_data.liquidity_pool_id, pool_definition_lp.account_id, + program_revert::require_eq!( + error::INVALID_INPUT, + pool_def_data.liquidity_pool_id, + pool_definition_lp.account_id, "LP definition mismatch" ); - assert_eq!( - vault_a.account_id, pool_def_data.vault_a_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_a.account_id, + pool_def_data.vault_a_id, "Vault A was not provided" ); - assert_eq!( - vault_b.account_id, pool_def_data.vault_b_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_b.account_id, + pool_def_data.vault_b_id, "Vault B was not provided" ); - assert_eq!( - vault_a.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_a.account.program_owner, + token_program_id, "Vault A must be owned by the configured Token Program" ); - assert_eq!( - vault_b.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_b.account.program_owner, + token_program_id, "Vault B must be owned by the configured Token Program" ); - assert_eq!( - user_holding_a.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + user_holding_a.account.program_owner, + token_program_id, "User Token A holding must be owned by the configured Token Program" ); - assert_eq!( - user_holding_b.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + user_holding_b.account.program_owner, + token_program_id, "User Token B holding must be owned by the configured Token Program" ); // The current tick is refreshed by a chained call to the oracle; validate its PDA and the // clock here so the removal is rejected early with an AMM-level error. - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "Remove liquidity: clock account must be the canonical 1-block LEZ clock account" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account_id, compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), "Remove liquidity: current tick Account ID does not match PDA" @@ -104,44 +128,53 @@ pub fn remove_liquidity( running_vault_a.is_authorized = true; running_vault_b.is_authorized = true; - assert!( + program_revert::require!( + error::INVALID_INPUT, min_amount_to_remove_token_a != 0, "Minimum withdraw amount must be nonzero" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, min_amount_to_remove_token_b != 0, "Minimum withdraw amount must be nonzero" ); // 2. Compute withdrawal amounts let user_holding_lp_data = token_core::TokenHolding::try_from(&user_holding_lp.account.data) - .expect("Remove liquidity: AMM Program expects a valid Token Account for liquidity token"); + .unwrap_or_revert( + error::INVALID_INPUT, + "Remove liquidity: AMM Program expects a valid Token Account for liquidity token", + ); let token_core::TokenHolding::Fungible { definition_id: _, balance: user_lp_balance, } = user_holding_lp_data else { - panic!( + program_revert::revert!(error::INVALID_INPUT, "Remove liquidity: AMM Program expects a valid Fungible Token Holding Account for liquidity token" ); }; - assert!( + program_revert::require!( + error::INVALID_INPUT, user_lp_balance <= pool_def_data.liquidity_pool_supply, "Invalid liquidity account provided" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, user_holding_lp_data.definition_id(), pool_def_data.liquidity_pool_id, "Invalid liquidity account provided" ); // Honest flows should never reach the permanent lock through a valid remove instruction, but // we still reject legacy or corrupted states that are already at the locked floor. - assert!( + program_revert::require!( + error::INVALID_INPUT, pool_def_data.liquidity_pool_supply > MINIMUM_LIQUIDITY, "Pool only contains locked liquidity" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, remove_liquidity_amount <= user_lp_balance, "Remove amount exceeds user LP balance" ); @@ -151,7 +184,8 @@ pub fn remove_liquidity( .expect("liquidity supply must be at least the locked minimum after validation"); // The remove instruction never sees the LP lock account directly, so we must still refuse any // request that would burn through the permanent floor even if ownership is already corrupted. - assert!( + program_revert::require!( + error::INVALID_INPUT, remove_liquidity_amount <= unlocked_liquidity, "Cannot remove locked minimum liquidity" ); @@ -170,11 +204,13 @@ pub fn remove_liquidity( ); // 3. Validate and slippage check - assert!( + program_revert::require!( + error::INVALID_INPUT, withdraw_amount_a >= min_amount_to_remove_token_a, "Insufficient minimal withdraw amount (Token A) provided for liquidity amount" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, withdraw_amount_b >= min_amount_to_remove_token_b, "Insufficient minimal withdraw amount (Token B) provided for liquidity amount" ); @@ -188,15 +224,24 @@ pub fn remove_liquidity( liquidity_pool_supply: pool_def_data .liquidity_pool_supply .checked_sub(delta_lp) - .expect("liquidity_pool_supply - delta_lp underflows"), + .unwrap_or_revert( + error::ARITHMETIC, + "liquidity_pool_supply - delta_lp underflows", + ), reserve_a: pool_def_data .reserve_a .checked_sub(withdraw_amount_a) - .expect("reserve_a - withdraw_amount_a underflows"), + .unwrap_or_revert( + error::ARITHMETIC, + "reserve_a - withdraw_amount_a underflows", + ), reserve_b: pool_def_data .reserve_b .checked_sub(withdraw_amount_b) - .expect("reserve_b - withdraw_amount_b underflows"), + .unwrap_or_revert( + error::ARITHMETIC, + "reserve_b - withdraw_amount_b underflows", + ), ..pool_def_data.clone() }; diff --git a/programs/amm/src/swap.rs b/programs/amm/src/swap.rs index 4de03982..8e8864a8 100644 --- a/programs/amm/src/swap.rs +++ b/programs/amm/src/swap.rs @@ -1,5 +1,5 @@ use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, + assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, error, read_vault_fungible_balances, spot_price_q64_64, swap_exact_in_amounts, swap_exact_out_amounts, AmmConfig, MINIMUM_LIQUIDITY, }; @@ -9,6 +9,7 @@ use lee_core::{ account::{AccountId, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use twap_oracle_core::compute_current_tick_account_pda; /// Validates swap setup: checks pool liquidity is ready, vaults match, and reserves are sufficient. @@ -17,31 +18,40 @@ fn validate_swap_setup( vault_a: &AccountWithMetadata, vault_b: &AccountWithMetadata, ) -> PoolDefinition { - let pool_def_data = PoolDefinition::try_from(&pool.account.data) - .expect("AMM Program expects a valid Pool Definition Account"); + let pool_def_data = PoolDefinition::try_from(&pool.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "AMM Program expects a valid Pool Definition Account", + ); assert_supported_fee_tier(pool_def_data.fees); - assert!( + program_revert::require!( + error::INVALID_INPUT, pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, "Pool liquidity supply is below minimum liquidity" ); - assert_eq!( - vault_a.account_id, pool_def_data.vault_a_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_a.account_id, + pool_def_data.vault_a_id, "Vault A was not provided" ); - assert_eq!( - vault_b.account_id, pool_def_data.vault_b_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_b.account_id, + pool_def_data.vault_b_id, "Vault B was not provided" ); let (vault_a_balance, vault_b_balance) = read_vault_fungible_balances("Validate swap setup", vault_a, vault_b); - assert!( + program_revert::require!( + error::INVALID_INPUT, vault_a_balance >= pool_def_data.reserve_a, "Reserve for Token A exceeds vault balance" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, vault_b_balance >= pool_def_data.reserve_b, "Reserve for Token B exceeds vault balance" ); @@ -81,15 +91,21 @@ fn finalize_swap( reserve_a: pool_def_data .reserve_a .checked_add(deposit_a) - .expect("reserve_a + deposit_a overflows u128") + .unwrap_or_revert(error::ARITHMETIC, "reserve_a + deposit_a overflows u128") .checked_sub(withdraw_a) - .expect("reserve_a + deposit_a - withdraw_a underflows"), + .unwrap_or_revert( + error::ARITHMETIC, + "reserve_a + deposit_a - withdraw_a underflows", + ), reserve_b: pool_def_data .reserve_b .checked_add(deposit_b) - .expect("reserve_b + deposit_b overflows u128") + .unwrap_or_revert(error::ARITHMETIC, "reserve_b + deposit_b overflows u128") .checked_sub(withdraw_b) - .expect("reserve_b + deposit_b - withdraw_b underflows"), + .unwrap_or_revert( + error::ARITHMETIC, + "reserve_b + deposit_b - withdraw_b underflows", + ), ..pool_def_data }; @@ -157,21 +173,28 @@ pub fn swap_exact_input( // The program IDs are taken from the config account, not trusted from a caller-supplied // account. Validating the config PDA is also the Program's initialization gate. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, config.account_id, compute_config_pda(amm_program_id), "Swap exact input: AMM config Account ID does not match PDA" ); - let config_data = AmmConfig::try_from(&config.account.data) - .expect("Swap exact input: AMM Program must be initialized before use"); + let config_data = AmmConfig::try_from(&config.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Swap exact input: AMM Program must be initialized before use", + ); let token_program_id = config_data.token_program_id; let twap_oracle_program_id = config_data.twap_oracle_program_id; - assert_eq!( - vault_a.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_a.account.program_owner, + token_program_id, "Vault A must be owned by the configured Token Program" ); - assert_eq!( - vault_b.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_b.account.program_owner, + token_program_id, "Vault B must be owned by the configured Token Program" ); @@ -179,30 +202,43 @@ pub fn swap_exact_input( // role-based holdings are mapped back to the pool's stored A/B order so the rest of the // routine — reserve bookkeeping and finalize — stays keyed to token A/B. let token_in_id = token_core::TokenHolding::try_from(&user_input_holding.account.data) - .expect("Swap exact input: input holding must be a valid token holding") + .unwrap_or_revert( + error::INVALID_INPUT, + "Swap exact input: input holding must be a valid token holding", + ) .definition_id(); let (user_holding_a, user_holding_b) = if token_in_id == pool_def_data.definition_token_a_id { (user_input_holding, user_output_holding) } else if token_in_id == pool_def_data.definition_token_b_id { (user_output_holding, user_input_holding) } else { - panic!("Swap exact input: input holding token is not part of the pool"); + program_revert::revert!( + error::INVALID_INPUT, + "Swap exact input: input holding token is not part of the pool" + ); }; - assert_eq!( - user_holding_a.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + user_holding_a.account.program_owner, + token_program_id, "User Token A holding must be owned by the configured Token Program" ); - assert_eq!( - user_holding_b.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + user_holding_b.account.program_owner, + token_program_id, "User Token B holding must be owned by the configured Token Program" ); // The current tick is refreshed by a chained call to the oracle; validate its PDA and the // clock here so the swap is rejected early with an AMM-level error. - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "Swap exact input: clock account must be the canonical 1-block LEZ clock account" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account_id, compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), "Swap exact input: current tick Account ID does not match PDA" @@ -240,7 +276,10 @@ pub fn swap_exact_input( (chained_calls, [0, withdraw_a], [deposit_b, 0]) } else { - panic!("AccountId is not a token type for the pool"); + program_revert::revert!( + error::INVALID_INPUT, + "AccountId is not a token type for the pool" + ); }; // Echo the two user holdings in the guest's declared slot order (input, then output) so the @@ -302,17 +341,23 @@ fn swap_logic( reserve_withdraw_vault_amount, fee_bps, ); - assert!( + program_revert::require!( + error::INVALID_INPUT, effective_amount_in != 0, "Effective swap amount should be nonzero" ); // Slippage check - assert!( + program_revert::require!( + error::INVALID_INPUT, min_amount_out <= withdraw_amount, "Withdraw amount is less than minimal amount out" ); - assert!(withdraw_amount != 0, "Withdraw amount should be nonzero"); + program_revert::require!( + error::INVALID_INPUT, + withdraw_amount != 0, + "Withdraw amount should be nonzero" + ); let token_program_id = user_deposit.account.program_owner; @@ -331,7 +376,10 @@ fn swap_logic( let pda_seed = compute_vault_pda_seed( pool_id, token_core::TokenHolding::try_from(&vault_withdraw.account.data) - .expect("Swap Logic: AMM Program expects valid token data") + .unwrap_or_revert( + error::INVALID_INPUT, + "Swap Logic: AMM Program expects valid token data", + ) .definition_id(), ); @@ -371,21 +419,28 @@ pub fn swap_exact_output( // The program IDs are taken from the config account, not trusted from a caller-supplied // account. Validating the config PDA is also the Program's initialization gate. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, config.account_id, compute_config_pda(amm_program_id), "Swap exact output: AMM config Account ID does not match PDA" ); - let config_data = AmmConfig::try_from(&config.account.data) - .expect("Swap exact output: AMM Program must be initialized before use"); + let config_data = AmmConfig::try_from(&config.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Swap exact output: AMM Program must be initialized before use", + ); let token_program_id = config_data.token_program_id; let twap_oracle_program_id = config_data.twap_oracle_program_id; - assert_eq!( - vault_a.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_a.account.program_owner, + token_program_id, "Vault A must be owned by the configured Token Program" ); - assert_eq!( - vault_b.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_b.account.program_owner, + token_program_id, "Vault B must be owned by the configured Token Program" ); @@ -393,30 +448,43 @@ pub fn swap_exact_output( // role-based holdings are mapped back to the pool's stored A/B order so the rest of the // routine — reserve bookkeeping and finalize — stays keyed to token A/B. let token_in_id = token_core::TokenHolding::try_from(&user_input_holding.account.data) - .expect("Swap exact output: input holding must be a valid token holding") + .unwrap_or_revert( + error::INVALID_INPUT, + "Swap exact output: input holding must be a valid token holding", + ) .definition_id(); let (user_holding_a, user_holding_b) = if token_in_id == pool_def_data.definition_token_a_id { (user_input_holding, user_output_holding) } else if token_in_id == pool_def_data.definition_token_b_id { (user_output_holding, user_input_holding) } else { - panic!("Swap exact output: input holding token is not part of the pool"); + program_revert::revert!( + error::INVALID_INPUT, + "Swap exact output: input holding token is not part of the pool" + ); }; - assert_eq!( - user_holding_a.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + user_holding_a.account.program_owner, + token_program_id, "User Token A holding must be owned by the configured Token Program" ); - assert_eq!( - user_holding_b.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + user_holding_b.account.program_owner, + token_program_id, "User Token B holding must be owned by the configured Token Program" ); // The current tick is refreshed by a chained call to the oracle; validate its PDA and the // clock here so the swap is rejected early with an AMM-level error. - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "Swap exact output: clock account must be the canonical 1-block LEZ clock account" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account_id, compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), "Swap exact output: current tick Account ID does not match PDA" @@ -454,7 +522,10 @@ pub fn swap_exact_output( (chained_calls, [0, withdraw_a], [deposit_b, 0]) } else { - panic!("AccountId is not a token type for the pool"); + program_revert::revert!( + error::INVALID_INPUT, + "AccountId is not a token type for the pool" + ); }; // Echo the two user holdings in the guest's declared slot order (input, then output) so the @@ -506,10 +577,16 @@ fn exact_output_swap_logic( pool_id: AccountId, ) -> (Vec, u128, u128) { // Guard: exact_amount_out must be nonzero - assert_ne!(exact_amount_out, 0, "Exact amount out must be nonzero"); + program_revert::require_ne!( + error::INVALID_INPUT, + exact_amount_out, + 0, + "Exact amount out must be nonzero" + ); // Guard: exact_amount_out must be less than reserve_withdraw_vault_amount - assert!( + program_revert::require!( + error::INVALID_INPUT, exact_amount_out < reserve_withdraw_vault_amount, "Exact amount out exceeds reserve" ); @@ -523,10 +600,14 @@ fn exact_output_swap_logic( reserve_withdraw_vault_amount, fee_bps, ) - .expect("swap exact output: reserves and fee must yield a valid input"); + .unwrap_or_revert( + error::INVALID_INPUT, + "swap exact output: reserves and fee must yield a valid input", + ); // Slippage check - assert!( + program_revert::require!( + error::INVALID_INPUT, deposit_amount <= max_amount_in, "Required input exceeds maximum amount in" ); @@ -548,7 +629,10 @@ fn exact_output_swap_logic( let pda_seed = compute_vault_pda_seed( pool_id, token_core::TokenHolding::try_from(&vault_withdraw.account.data) - .expect("Exact Output Swap Logic: AMM Program expects valid token data") + .unwrap_or_revert( + error::INVALID_INPUT, + "Exact Output Swap Logic: AMM Program expects valid token data", + ) .definition_id(), ); diff --git a/programs/amm/src/sync.rs b/programs/amm/src/sync.rs index 91da4e42..494029cc 100644 --- a/programs/amm/src/sync.rs +++ b/programs/amm/src/sync.rs @@ -1,5 +1,5 @@ use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, + assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, error, read_vault_fungible_balances, spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; @@ -7,6 +7,7 @@ use lee_core::{ account::{AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use twap_oracle_core::compute_current_tick_account_pda; pub fn sync_reserves( @@ -18,40 +19,54 @@ pub fn sync_reserves( clock: AccountWithMetadata, amm_program_id: ProgramId, ) -> (Vec, Vec) { - let pool_def_data = PoolDefinition::try_from(&pool.account.data) - .expect("Sync reserves: AMM Program expects a valid Pool Definition Account"); + let pool_def_data = PoolDefinition::try_from(&pool.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Sync reserves: AMM Program expects a valid Pool Definition Account", + ); assert_supported_fee_tier(pool_def_data.fees); // The TWAP oracle program ID is taken from the config account. Validating the config PDA is // also the Program's initialization gate. - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, config.account_id, compute_config_pda(amm_program_id), "Sync reserves: AMM config Account ID does not match PDA" ); let twap_oracle_program_id = AmmConfig::try_from(&config.account.data) - .expect("Sync reserves: AMM Program must be initialized before use") + .unwrap_or_revert( + error::INVALID_INPUT, + "Sync reserves: AMM Program must be initialized before use", + ) .twap_oracle_program_id; - assert!( + program_revert::require!( + error::INVALID_INPUT, pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, "Pool liquidity supply is below minimum liquidity" ); - assert_eq!( - vault_a.account_id, pool_def_data.vault_a_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_a.account_id, + pool_def_data.vault_a_id, "Vault A was not provided" ); - assert_eq!( - vault_b.account_id, pool_def_data.vault_b_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault_b.account_id, + pool_def_data.vault_b_id, "Vault B was not provided" ); // The current tick is refreshed by a chained call to the oracle; validate its PDA and the // clock here so the sync is rejected early with an AMM-level error. - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "Sync reserves: clock account must be the canonical 1-block LEZ clock account" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account_id, compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id), "Sync reserves: current tick Account ID does not match PDA" @@ -59,11 +74,13 @@ pub fn sync_reserves( let (vault_a_balance, vault_b_balance) = read_vault_fungible_balances("Sync reserves", &vault_a, &vault_b); - assert!( + program_revert::require!( + error::INVALID_INPUT, vault_a_balance >= pool_def_data.reserve_a, "Sync reserves: vault A balance is less than its reserve" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, vault_b_balance >= pool_def_data.reserve_b, "Sync reserves: vault B balance is less than its reserve" ); diff --git a/programs/amm/src/update_config.rs b/programs/amm/src/update_config.rs index bf7ca85d..eaf4f67f 100644 --- a/programs/amm/src/update_config.rs +++ b/programs/amm/src/update_config.rs @@ -1,8 +1,9 @@ -use amm_core::{compute_config_pda, AmmConfig}; +use amm_core::{compute_config_pda, error, AmmConfig}; use lee_core::{ account::{AccountId, AccountWithMetadata, Data}, program::{AccountPostState, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; /// Transfers the AMM Program's admin authority to a new account. /// @@ -14,8 +15,8 @@ use lee_core::{ /// cannot change them; it only moves the admin authority. The config account is already owned by /// this Program (created at `initialize`), so its data is updated in place — no claim is required. /// -/// # Panics -/// Panics if: +/// # Failures +/// Reverts in the zkVM (panics on native targets) if: /// - `config.account_id` does not match `compute_config_pda(amm_program_id)`, or the config is /// uninitialized (the Program has not been initialized). /// - `authority.account_id` is not the config's current admin authority. @@ -26,20 +27,26 @@ pub fn update_config( new_authority: AccountId, amm_program_id: ProgramId, ) -> Vec { - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, config.account_id, compute_config_pda(amm_program_id), "Update config: AMM config Account ID does not match PDA" ); - let mut config_data = AmmConfig::try_from(&config.account.data) - .expect("Update config: AMM Program must be initialized before use"); + let mut config_data = AmmConfig::try_from(&config.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Update config: AMM Program must be initialized before use", + ); // Access control: the caller must be the configured admin and must have signed. - assert_eq!( - authority.account_id, config_data.authority, + program_revert::require_eq!( + error::INVALID_INPUT, + authority.account_id, + config_data.authority, "Update config: caller is not the configured admin authority" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, authority.is_authorized, "Update config: admin authority must authorize the update" ); diff --git a/programs/ata/Cargo.toml b/programs/ata/Cargo.toml index f41f4829..0b05b935 100644 --- a/programs/ata/Cargo.toml +++ b/programs/ata/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +program-revert = { path = "../common/revert" } lee_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", features = ["host"] } ata_core = { path = "core" } token_core = { path = "../token/core" } diff --git a/programs/ata/core/Cargo.toml b/programs/ata/core/Cargo.toml index 1c46435d..e155ee4b 100644 --- a/programs/ata/core/Cargo.toml +++ b/programs/ata/core/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +program-revert = { path = "../../common/revert" } lee_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", features = ["host"] } borsh = { version = "1.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } diff --git a/programs/ata/core/src/error.rs b/programs/ata/core/src/error.rs new file mode 100644 index 00000000..efc12a6d --- /dev/null +++ b/programs/ata/core/src/error.rs @@ -0,0 +1,13 @@ +//! Nonzero exit codes for the ata program. +//! +//! Codes are local to this program. Keep existing values stable when adding errors. +//! Diagnostic logs identify the failed check within each category. + +use std::num::NonZeroU8; + +/// Invalid instruction, account, authorization, configuration, or operation state. +pub const INVALID_INPUT: NonZeroU8 = NonZeroU8::MIN; +/// Requested amount exceeds the available balance, debt, or collateral. +pub const INSUFFICIENT_BALANCE: NonZeroU8 = NonZeroU8::new(2).expect("nonzero error code"); +/// Requested operation exceeds the representable arithmetic range. +pub const ARITHMETIC: NonZeroU8 = NonZeroU8::new(3).expect("nonzero error code"); diff --git a/programs/ata/core/src/lib.rs b/programs/ata/core/src/lib.rs index 745472f7..60c3351a 100644 --- a/programs/ata/core/src/lib.rs +++ b/programs/ata/core/src/lib.rs @@ -1,3 +1,5 @@ +pub mod error; + pub use lee_core::program::PdaSeed; use lee_core::{ account::{AccountId, AccountWithMetadata}, @@ -93,8 +95,10 @@ pub fn verify_ata_and_get_seed( ) -> PdaSeed { let seed = compute_ata_seed(token_program_id, owner.account_id, definition_id); let expected_id = get_associated_token_account_id(&ata_program_id, &seed); - assert_eq!( - ata_account.account_id, expected_id, + program_revert::require_eq!( + error::INVALID_INPUT, + ata_account.account_id, + expected_id, "ATA account ID does not match expected derivation" ); seed diff --git a/programs/ata/methods/guest/Cargo.lock b/programs/ata/methods/guest/Cargo.lock index dd213ec6..5d802f33 100644 --- a/programs/ata/methods/guest/Cargo.lock +++ b/programs/ata/methods/guest/Cargo.lock @@ -263,6 +263,8 @@ dependencies = [ "ata_program", "borsh", "lee_core", + "program-revert", + "program-revert-macros", "risc0-zkvm", "ruint", "serde", @@ -276,6 +278,7 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "program-revert", "risc0-zkvm", "serde", ] @@ -286,6 +289,7 @@ version = "0.1.0" dependencies = [ "ata_core", "lee_core", + "program-revert", "token_core", ] @@ -1352,6 +1356,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "program-revert" +version = "0.1.0" +dependencies = [ + "risc0-zkvm", + "serde", +] + +[[package]] +name = "program-revert-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proptest" version = "1.11.0" @@ -2047,6 +2068,7 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "program-revert", "serde", "spel-framework-macros", ] diff --git a/programs/ata/methods/guest/Cargo.toml b/programs/ata/methods/guest/Cargo.toml index 3148ebeb..fec44aca 100644 --- a/programs/ata/methods/guest/Cargo.toml +++ b/programs/ata/methods/guest/Cargo.toml @@ -56,6 +56,8 @@ name = "ata" path = "src/bin/ata.rs" [dependencies] +program-revert-macros = { path = "../../../common/revert-macros" } +program-revert = { path = "../../../common/revert" } spel-framework = { git = "https://github.com/logos-co/spel.git", rev = "1ef0500f8fc8ce3ddf95523726ae159db83f744f", package = "spel-framework" } nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", package = "lee_core" } risc0-zkvm = { version = "=3.0.5", default-features = false } @@ -68,4 +70,4 @@ ata_core = { path = "../../core" } ata_program = { path = "../..", package = "ata_program" } token_core = { path = "../../../token/core" } serde = { version = "1.0", features = ["derive"] } -borsh = "1.5" \ No newline at end of file +borsh = "1.5" diff --git a/programs/ata/methods/guest/src/bin/ata.rs b/programs/ata/methods/guest/src/bin/ata.rs index c24e6786..ff5784ea 100644 --- a/programs/ata/methods/guest/src/bin/ata.rs +++ b/programs/ata/methods/guest/src/bin/ata.rs @@ -5,8 +5,9 @@ use spel_framework::context::ProgramContext; use nssa_core::{account::AccountWithMetadata, program::ProgramId}; #[cfg(not(test))] -risc0_zkvm::guest::entry!(main); +risc0_zkvm::guest::entry!(metered_main); +#[program_revert_macros::metered_entry(ata_core::error::INVALID_INPUT)] #[lez_program(instruction = "ata_core::Instruction")] mod ata { #[expect( diff --git a/programs/ata/src/burn.rs b/programs/ata/src/burn.rs index 0158dbab..181e7da9 100644 --- a/programs/ata/src/burn.rs +++ b/programs/ata/src/burn.rs @@ -1,7 +1,9 @@ +use ata_core::error; use lee_core::{ account::AccountWithMetadata, program::{AccountPostState, ChainedCall, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use token_core::TokenHolding; pub fn burn_from_associated_token_account( @@ -12,20 +14,30 @@ pub fn burn_from_associated_token_account( token_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { - assert!(owner.is_authorized, "Owner authorization is missing"); - assert_eq!( - holder_ata.account.program_owner, token_program_id, + program_revert::require!( + error::INVALID_INPUT, + owner.is_authorized, + "Owner authorization is missing" + ); + program_revert::require_eq!( + error::INVALID_INPUT, + holder_ata.account.program_owner, + token_program_id, "Holder ATA must be owned by expected token program" ); - assert_eq!( - token_definition.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + token_definition.account.program_owner, + token_program_id, "Token definition must be owned by expected token program" ); let definition_id = TokenHolding::try_from(&holder_ata.account.data) - .expect("Holder ATA must hold a valid token") + .unwrap_or_revert(error::INVALID_INPUT, "Holder ATA must hold a valid token") .definition_id(); - assert_eq!( - definition_id, token_definition.account_id, + program_revert::require_eq!( + error::INVALID_INPUT, + definition_id, + token_definition.account_id, "Holder ATA token definition does not match" ); let seed = ata_core::verify_ata_and_get_seed( diff --git a/programs/ata/src/create.rs b/programs/ata/src/create.rs index fca00072..a06d8b2c 100644 --- a/programs/ata/src/create.rs +++ b/programs/ata/src/create.rs @@ -1,7 +1,9 @@ +use ata_core::error; use lee_core::{ account::{Account, AccountWithMetadata}, program::{AccountPostState, ChainedCall, Claim, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use token_core::{TokenDefinition, TokenHolding}; pub fn create_associated_token_account( @@ -15,12 +17,14 @@ pub fn create_associated_token_account( // call itself may proceed without `owner.is_authorized`. If the owner account is still // default, the returned post-state will still carry `Claim::Authorized` so the runtime can // claim that owner account when needed. - assert_eq!( - token_definition.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + token_definition.account.program_owner, + token_program_id, "Token definition must be owned by expected token program" ); let _definition = TokenDefinition::try_from(&token_definition.account.data) - .expect("Token definition must be valid"); + .unwrap_or_revert(error::INVALID_INPUT, "Token definition must be valid"); let seed = ata_core::verify_ata_and_get_seed( &ata_account, &owner, @@ -31,13 +35,16 @@ pub fn create_associated_token_account( // Idempotent: already initialized → no-op if ata_account.account != Account::default() { - assert_eq!( - ata_account.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + ata_account.account.program_owner, + token_program_id, "Existing ATA must be owned by expected token program" ); let holding = TokenHolding::try_from(&ata_account.account.data) - .expect("Existing ATA must hold a valid token"); - assert_eq!( + .unwrap_or_revert(error::INVALID_INPUT, "Existing ATA must hold a valid token"); + program_revert::require_eq!( + error::INVALID_INPUT, holding.definition_id(), token_definition.account_id, "Existing ATA token definition does not match" diff --git a/programs/ata/src/transfer.rs b/programs/ata/src/transfer.rs index 699d6d71..7d03bd7e 100644 --- a/programs/ata/src/transfer.rs +++ b/programs/ata/src/transfer.rs @@ -1,7 +1,9 @@ +use ata_core::error; use lee_core::{ account::{Account, AccountWithMetadata}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use token_core::TokenHolding; pub fn transfer_from_associated_token_account( @@ -12,13 +14,19 @@ pub fn transfer_from_associated_token_account( token_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { - assert!(owner.is_authorized, "Owner authorization is missing"); - assert_eq!( - sender_ata.account.program_owner, token_program_id, + program_revert::require!( + error::INVALID_INPUT, + owner.is_authorized, + "Owner authorization is missing" + ); + program_revert::require_eq!( + error::INVALID_INPUT, + sender_ata.account.program_owner, + token_program_id, "Sender ATA must be owned by expected token program" ); let sender_definition_id = TokenHolding::try_from(&sender_ata.account.data) - .expect("Sender ATA must hold a valid token") + .unwrap_or_revert(error::INVALID_INPUT, "Sender ATA must hold a valid token") .definition_id(); let sender_seed = ata_core::verify_ata_and_get_seed( &sender_ata, @@ -34,20 +42,25 @@ pub fn transfer_from_associated_token_account( // materialized by the downstream token transfer (e.g. via `Claim::Authorized` on a default // recipient), so integrators get an ATA-level failure rather than having to reverse-engineer // token/runtime semantics. - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, recipient.account, Account::default(), "Recipient token holding must be initialized" ); - assert_eq!( - recipient.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + recipient.account.program_owner, + token_program_id, "Recipient must be owned by the same token program as the sender ATA" ); let recipient_definition_id = TokenHolding::try_from(&recipient.account.data) - .expect("Recipient must hold a valid token") + .unwrap_or_revert(error::INVALID_INPUT, "Recipient must hold a valid token") .definition_id(); - assert_eq!( - recipient_definition_id, sender_definition_id, + program_revert::require_eq!( + error::INVALID_INPUT, + recipient_definition_id, + sender_definition_id, "Recipient and sender token definitions do not match" ); diff --git a/programs/common/revert-macros/Cargo.toml b/programs/common/revert-macros/Cargo.toml new file mode 100644 index 00000000..d9d95ac4 --- /dev/null +++ b/programs/common/revert-macros/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "program-revert-macros" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[lints] +workspace = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } diff --git a/programs/common/revert-macros/src/lib.rs b/programs/common/revert-macros/src/lib.rs new file mode 100644 index 00000000..cdbe91e3 --- /dev/null +++ b/programs/common/revert-macros/src/lib.rs @@ -0,0 +1,208 @@ +//! Metered guest entry points for the pinned SPEL framework. +//! +//! SPEL still panics when dispatch or account validation fails. This adapter +//! derives dispatch from the same instruction signatures and calls SPEL's +//! generated validators and handlers (including its automatic claims). Remove +//! the adapter when SPEL supports nonzero exits at those boundaries. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as Tokens; +use quote::{format_ident, quote}; +use syn::{parse_macro_input, FnArg, Item, ItemFn, ItemMod, Pat, Path, Type}; + +/// Add `metered_main` before expanding `#[lez_program]`. +/// +/// The argument names the program-local rejection code. Guest binaries must +/// select `entry!(metered_main)`. Instruction layouts and IDL remain owned by +/// SPEL. Only the fixed-account signatures used by this repository are supported; +/// unsupported constraints fail compilation rather than skipping validation. +#[proc_macro_attribute] +pub fn metered_entry(code: TokenStream, input: TokenStream) -> TokenStream { + let code = parse_macro_input!(code as Path); + let module = parse_macro_input!(input as ItemMod); + match expand(&code, &module) { + Ok(output) => output.into(), + Err(error) => error.into_compile_error().into(), + } +} + +fn expand(code: &Path, module: &ItemMod) -> syn::Result { + let (_, items) = module.content.as_ref().ok_or_else(|| { + syn::Error::new_spanned( + module, + "metered_entry requires an inline lez_program module", + ) + })?; + let mut arms = Vec::new(); + for item in items { + if let Item::Fn(handler) = item { + if handler + .attrs + .iter() + .any(|attr| attr.path().is_ident("instruction")) + { + arms.push(dispatch_arm(code, &module.ident, handler)?); + } + } + } + if arms.is_empty() { + return Err(syn::Error::new_spanned( + module, + "no instructions for metered_entry", + )); + } + + Ok(quote! { + #module + + #[cfg(not(test))] + pub fn metered_main() { + use program_revert::UnwrapOrRevert as _; + // The host owns the input framing; instruction words are caller + // supplied and must be decoded through the expected-error path. + let self_program_id: nssa_core::program::ProgramId = risc0_zkvm::guest::env::read(); + let caller_program_id: Option = risc0_zkvm::guest::env::read(); + let pre_states: Vec = risc0_zkvm::guest::env::read(); + let instruction_words: nssa_core::program::InstructionData = risc0_zkvm::guest::env::read(); + let instruction: Instruction = program_revert::decode_instruction(&instruction_words) + .unwrap_or_revert(#code, "Invalid instruction data"); + let pre_states_clone = pre_states.clone(); + let output = match instruction { #(#arms)* }; + let parts = output + .unwrap_or_revert(#code, "Program error") + .into_parts(); + + // Match SPEL's output contract: omit unclaimed foreign signer + // accounts whose non-default state cannot be returned to LEZ. + let (filtered_pre, filtered_post): (Vec<_>, Vec<_>) = pre_states_clone + .into_iter() + .zip(parts.post_states) + .filter(|(pre, post)| { + pre.account.program_owner != nssa_core::program::DEFAULT_PROGRAM_ID + || pre.account == nssa_core::account::Account::default() + || post.required_claim().is_some() + }) + .unzip(); + nssa_core::program::ProgramOutput::new( + self_program_id, caller_program_id, instruction_words, + filtered_pre, filtered_post, + ) + .with_chained_calls(parts.chained_calls) + .with_block_validity_window(parts.block_validity_window) + .with_timestamp_validity_window(parts.timestamp_validity_window) + .write(); + } + }) +} + +fn dispatch_arm(code: &Path, module: &syn::Ident, handler: &ItemFn) -> syn::Result { + let name = &handler.sig.ident; + let variant = name + .to_string() + .split('_') + .map(|part| { + let mut chars = part.chars(); + chars + .next() + .map(|first| first.to_uppercase().collect::() + chars.as_str()) + .unwrap_or_default() + }) + .collect::(); + let variant = format_ident!("{variant}"); + let mut accounts = Vec::new(); + let mut arguments = Vec::new(); + let mut call_arguments = Vec::new(); + let mut needs_validation = false; + for input in &handler.sig.inputs { + let FnArg::Typed(input) = input else { + return Err(syn::Error::new_spanned( + input, + "instruction cannot take self", + )); + }; + let Pat::Ident(binding) = &*input.pat else { + return Err(syn::Error::new_spanned( + input, + "instruction requires named parameters", + )); + }; + let ident = &binding.ident; + let Type::Path(ty) = &*input.ty else { + return Err(syn::Error::new_spanned( + input, + "unsupported metered parameter type", + )); + }; + let last = ty + .path + .segments + .last() + .ok_or_else(|| syn::Error::new_spanned(ty, "missing parameter type"))?; + if last.ident == "ProgramContext" { + call_arguments.push(quote! { + spel_framework::context::ProgramContext::new( + self_program_id, + caller_program_id.unwrap_or(nssa_core::program::DEFAULT_PROGRAM_ID), + ) + }); + } else { + call_arguments.push(quote! { #ident }); + if last.ident == "AccountWithMetadata" { + accounts.push(ident); + } else { + if quote!(#ty).to_string().contains("AccountWithMetadata") { + return Err(syn::Error::new_spanned( + input, + "metered_entry requires fixed accounts", + )); + } + arguments.push(ident); + } + } + for attr in input + .attrs + .iter() + .filter(|attr| attr.path().is_ident("account")) + { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("signer") || meta.path.is_ident("init") { + needs_validation = true; + } else if meta.path.is_ident("owner") { + let _: syn::Expr = meta.value()?.parse()?; + needs_validation = true; + } else if !meta.path.is_ident("mut") { + return Err( + meta.error("extend metered_entry dispatch for this account constraint") + ); + } + Ok(()) + })?; + } + } + let pattern = if arguments.is_empty() { + quote! { Instruction::#variant } + } else { + quote! { Instruction::#variant { #(#arguments),* } } + }; + let validate = if needs_validation { + let validator = format_ident!("__validate_{name}"); + quote! { + #module::#validator(&pre_states, &self_program_id, &instruction_words) + .unwrap_or_revert(#code, "account validation failed"); + } + } else { + quote! {} + }; + let count = accounts.len(); + Ok(quote! { + #pattern => { + program_revert::require_eq!( + #code, pre_states.len(), #count, "Account count mismatch" + ); + #validate + let [#(#accounts),*] = <[_; #count]>::try_from(pre_states) + .expect("account count was validated"); + #module::#name(#(#call_arguments),*) + }, + }) +} diff --git a/programs/common/revert/Cargo.toml b/programs/common/revert/Cargo.toml new file mode 100644 index 00000000..84959f5e --- /dev/null +++ b/programs/common/revert/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "program-revert" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +risc0-zkvm = { version = "=3.0.5", default-features = false } +serde = "1.0" + +[dev-dependencies] +serde = { version = "1.0", features = ["derive"] } diff --git a/programs/common/revert/src/decode.rs b/programs/common/revert/src/decode.rs new file mode 100644 index 00000000..ab51ca48 --- /dev/null +++ b/programs/common/revert/src/decode.rs @@ -0,0 +1,264 @@ +//! Slice-bounded decoding for the pinned RISC Zero instruction format. +//! +//! RISC Zero 3.0.5 allocates strings/byte buffers from their length prefix before +//! checking for truncated input. Delegate leaf values only after checking their +//! encoded extent. Containers must recurse through this decoder so nested strings +//! receive the same check. The format and acceptance of trailing words are unchanged. + +use risc0_zkvm::serde::{Deserializer as Risc0Deserializer, Error}; +use serde::de::{ + DeserializeOwned, DeserializeSeed, Deserializer, EnumAccess, IntoDeserializer, MapAccess, + SeqAccess, VariantAccess, Visitor, +}; + +/// Decode RISC Zero instruction words without allocating from unchecked byte lengths. +/// +/// Preserves the pinned RISC Zero encoding, including padding and trailing words. +/// Actual input size still determines memory and cycle requirements. +/// +/// # Errors +/// Returns the RISC Zero decoding error for malformed or truncated input, including +/// a string or byte-buffer length that exceeds the available instruction words. +pub fn decode_instruction(words: &[u32]) -> Result { + T::deserialize(&mut Decoder { words }) +} + +struct Decoder<'de> { + words: &'de [u32], +} + +impl<'de> Decoder<'de> { + fn take_words(&mut self, count: usize) -> Result<&'de [u32], Error> { + let (value, rest) = self + .words + .split_at_checked(count) + .ok_or(Error::DeserializeUnexpectedEnd)?; + self.words = rest; + Ok(value) + } + + fn word(&mut self) -> Result { + let (&value, rest) = self + .words + .split_first() + .ok_or(Error::DeserializeUnexpectedEnd)?; + self.words = rest; + Ok(value) + } + + fn length(&mut self) -> Result { + usize::try_from(self.word()?).map_err(|_| Error::DeserializeUnexpectedEnd) + } + + fn take_byte_words(&mut self) -> Result<&'de [u32], Error> { + let &length = self.words.first().ok_or(Error::DeserializeUnexpectedEnd)?; + let length = usize::try_from(length).map_err(|_| Error::DeserializeUnexpectedEnd)?; + let count = length + .div_ceil(size_of::()) + .checked_add(1) + .ok_or(Error::DeserializeUnexpectedEnd)?; + // Include the prefix and word padding. No allocation happens until the + // entire byte payload is known to be present in the instruction frame. + self.take_words(count) + } +} + +macro_rules! delegate_leaf { + ($($method:ident($take:ident $(, $count:expr)?)),* $(,)?) => { + $( + fn $method>(self, visitor: V) -> Result { + let words = self.$take($($count)?)?; + Risc0Deserializer::new(words).$method(visitor) + } + )* + }; +} + +impl<'de> Deserializer<'de> for &mut Decoder<'de> { + type Error = Error; + + delegate_leaf! { + deserialize_bool(take_words, 1), + deserialize_i8(take_words, 1), + deserialize_i16(take_words, 1), + deserialize_i32(take_words, 1), + deserialize_i64(take_words, 2), + deserialize_i128(take_words, 4), + deserialize_u8(take_words, 1), + deserialize_u16(take_words, 1), + deserialize_u32(take_words, 1), + deserialize_u64(take_words, 2), + deserialize_u128(take_words, 4), + deserialize_f32(take_words, 1), + deserialize_f64(take_words, 2), + deserialize_char(take_words, 1), + deserialize_unit(take_words, 0), + deserialize_str(take_byte_words), + deserialize_string(take_byte_words), + deserialize_bytes(take_byte_words), + deserialize_byte_buf(take_byte_words), + } + + serde::forward_to_deserialize_any! { identifier ignored_any } + + fn is_human_readable(&self) -> bool { + false + } + + fn deserialize_any>(self, _visitor: V) -> Result { + Err(Error::NotSupported) + } + + fn deserialize_option>(self, visitor: V) -> Result { + match self.word()? { + 0 => visitor.visit_none(), + 1 => visitor.visit_some(self), + _ => Err(Error::DeserializeBadOption), + } + } + + fn deserialize_unit_struct>( + self, + _name: &'static str, + visitor: V, + ) -> Result { + self.deserialize_unit(visitor) + } + + fn deserialize_newtype_struct>( + self, + _name: &'static str, + visitor: V, + ) -> Result { + visitor.visit_newtype_struct(self) + } + + fn deserialize_seq>(self, visitor: V) -> Result { + let remaining = self.length()?; + self.deserialize_tuple(remaining, visitor) + } + + fn deserialize_tuple>( + self, + remaining: usize, + visitor: V, + ) -> Result { + visitor.visit_seq(Compound { + decoder: self, + remaining, + }) + } + + fn deserialize_tuple_struct>( + self, + _name: &'static str, + len: usize, + visitor: V, + ) -> Result { + self.deserialize_tuple(len, visitor) + } + + fn deserialize_map>(self, visitor: V) -> Result { + let remaining = self.length()?; + visitor.visit_map(Compound { + decoder: self, + remaining, + }) + } + + fn deserialize_struct>( + self, + _name: &'static str, + fields: &'static [&'static str], + visitor: V, + ) -> Result { + self.deserialize_tuple(fields.len(), visitor) + } + + fn deserialize_enum>( + self, + _name: &'static str, + _variants: &'static [&'static str], + visitor: V, + ) -> Result { + visitor.visit_enum(self) + } +} + +struct Compound<'a, 'de> { + decoder: &'a mut Decoder<'de>, + remaining: usize, +} + +impl<'de> SeqAccess<'de> for Compound<'_, 'de> { + type Error = Error; + + fn next_element_seed>( + &mut self, + seed: T, + ) -> Result, Error> { + let Some(remaining) = self.remaining.checked_sub(1) else { + return Ok(None); + }; + self.remaining = remaining; + seed.deserialize(&mut *self.decoder).map(Some) + } + + fn size_hint(&self) -> Option { + Some(self.remaining) + } +} + +impl<'de> MapAccess<'de> for Compound<'_, 'de> { + type Error = Error; + + fn next_key_seed>( + &mut self, + seed: K, + ) -> Result, Error> { + self.next_element_seed(seed) + } + + fn next_value_seed>(&mut self, seed: V) -> Result { + seed.deserialize(&mut *self.decoder) + } + + fn size_hint(&self) -> Option { + Some(self.remaining) + } +} + +impl<'de> EnumAccess<'de> for &mut Decoder<'de> { + type Error = Error; + type Variant = Self; + + fn variant_seed>(self, seed: V) -> Result<(V::Value, Self), Error> { + let tag = self.word()?; + let variant = seed.deserialize(tag.into_deserializer())?; + Ok((variant, self)) + } +} + +impl<'de> VariantAccess<'de> for &mut Decoder<'de> { + type Error = Error; + + fn unit_variant(self) -> Result<(), Error> { + Ok(()) + } + + fn newtype_variant_seed>(self, seed: V) -> Result { + seed.deserialize(self) + } + + fn tuple_variant>(self, len: usize, visitor: V) -> Result { + self.deserialize_tuple(len, visitor) + } + + fn struct_variant>( + self, + fields: &'static [&'static str], + visitor: V, + ) -> Result { + self.deserialize_tuple(fields.len(), visitor) + } +} diff --git a/programs/common/revert/src/lib.rs b/programs/common/revert/src/lib.rs new file mode 100644 index 00000000..0a8bb4a3 --- /dev/null +++ b/programs/common/revert/src/lib.rs @@ -0,0 +1,108 @@ +//! Explicit failures for expected program errors. +//! +//! Guests halt with a nonzero code so the executor retains the session and its +//! metered cycles. Native callers retain panic diagnostics, including existing +//! `should_panic` tests. Ordinary panics are never intercepted: broken internal +//! invariants still fail through the zkVM panic path. +//! +//! Codes belong to each program's core crate; zero is excluded by the type. + +use std::{fmt, num::NonZeroU8}; + +mod decode; + +pub use decode::decode_instruction; + +/// Abort an expected error without committing program output. +#[cold] +#[track_caller] +pub fn revert(code: NonZeroU8, message: fmt::Arguments<'_>) -> ! { + #[cfg(target_os = "zkvm")] + { + risc0_zkvm::guest::env::log(&message.to_string()); + risc0_zkvm::guest::env::exit(code.get()); + } + #[cfg(not(target_os = "zkvm"))] + panic!("Program reverted [{}]: {message}", code.get()); +} + +/// Unwrap an explicitly classified, expected error. +pub trait UnwrapOrRevert { + /// Return the value or abort with the supplied program-local code. + fn unwrap_or_revert(self, code: NonZeroU8, message: &str) -> T; +} + +impl UnwrapOrRevert for Option { + #[track_caller] + fn unwrap_or_revert(self, code: NonZeroU8, message: &str) -> T { + match self { + Some(value) => value, + None => revert(code, format_args!("{message}")), + } + } +} + +impl UnwrapOrRevert for Result { + #[track_caller] + fn unwrap_or_revert(self, code: NonZeroU8, message: &str) -> T { + match self { + Ok(value) => value, + Err(error) => revert(code, format_args!("{message}: {error:?}")), + } + } +} + +/// Abort with a formatted diagnostic and a program-local code. +#[macro_export] +macro_rules! revert { + ($code:expr, $($message:tt)+) => { + $crate::revert($code, format_args!($($message)+)) + }; +} + +/// Require a condition on an expected rejection path. +#[macro_export] +macro_rules! require { + ($code:expr, $condition:expr $(,)?) => { + $crate::require!($code, $condition, "Requirement failed: {}", stringify!($condition)) + }; + ($code:expr, $condition:expr, $($message:tt)+) => { + if !$condition { + $crate::revert!($code, $($message)+); + } + }; +} + +/// Require equality, evaluating and borrowing each operand once. +#[macro_export] +macro_rules! require_eq { + ($code:expr, $left:expr, $right:expr $(,)?) => { + $crate::require_eq!($code, $left, $right, "Values must match") + }; + ($code:expr, $left:expr, $right:expr, $($message:tt)+) => { + match (&$left, &$right) { + (left, right) => { + if *left != *right { + $crate::revert!($code, "{}: left={left:?}, right={right:?}", format_args!($($message)+)); + } + } + } + }; +} + +/// Require inequality, evaluating and borrowing each operand once. +#[macro_export] +macro_rules! require_ne { + ($code:expr, $left:expr, $right:expr $(,)?) => { + $crate::require_ne!($code, $left, $right, "Values must differ") + }; + ($code:expr, $left:expr, $right:expr, $($message:tt)+) => { + match (&$left, &$right) { + (left, right) => { + if *left == *right { + $crate::revert!($code, "{}: left={left:?}, right={right:?}", format_args!($($message)+)); + } + } + } + }; +} diff --git a/programs/common/revert/tests/behavior.rs b/programs/common/revert/tests/behavior.rs new file mode 100644 index 00000000..ec38ee53 --- /dev/null +++ b/programs/common/revert/tests/behavior.rs @@ -0,0 +1,69 @@ +use std::{cell::Cell, num::NonZeroU8}; + +use program_revert::{require, require_eq, require_ne, UnwrapOrRevert as _}; + +const CODE: NonZeroU8 = NonZeroU8::MIN; + +#[test] +fn successful_checks_borrow_operands_once_and_do_not_format_errors() { + let calls = Cell::new(0); + let value = String::from("value"); + require_eq!( + CODE, + { + calls.set(calls.get() + 1); + &value + }, + &value + ); + require_ne!( + CODE, + { + calls.set(calls.get() + 1); + &value + }, + "other" + ); + require!(CODE, !value.is_empty(), "{}", { + calls.set(99); + "error" + }); + assert_eq!(calls.get(), 2); + assert_eq!(value, "value"); +} + +#[test] +fn successful_unwraps_return_owned_values() { + assert_eq!( + Some(String::from("option")).unwrap_or_revert(CODE, "failed"), + "option" + ); + assert_eq!( + Ok::<_, ()>(String::from("result")).unwrap_or_revert(CODE, "failed"), + "result" + ); +} + +#[test] +#[should_panic(expected = "Program reverted [1]: missing balance")] +fn native_option_failure_keeps_code_and_message() { + None::.unwrap_or_revert(CODE, "missing balance"); +} + +#[test] +#[should_panic(expected = "invalid account: \"decode failed\"")] +fn native_result_failure_keeps_cause() { + Err::<(), _>("decode failed").unwrap_or_revert(CODE, "invalid account"); +} + +#[test] +#[should_panic(expected = "must match: left=1, right=2")] +fn equality_failure_keeps_values() { + require_eq!(CODE, 1, 2, "must match"); +} + +#[test] +#[should_panic(expected = "must differ: left=1, right=1")] +fn inequality_failure_keeps_values() { + require_ne!(CODE, 1, 1, "must differ"); +} diff --git a/programs/common/revert/tests/decode.rs b/programs/common/revert/tests/decode.rs new file mode 100644 index 00000000..da331032 --- /dev/null +++ b/programs/common/revert/tests/decode.rs @@ -0,0 +1,189 @@ +use std::{collections::BTreeMap, fmt}; + +use program_revert::decode_instruction; +use risc0_zkvm::serde::{from_slice, to_vec, Error}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct Unit; + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct Newtype(String); + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct Pair(String, String); + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +enum Nested { + Unit, + Newtype(Box), + Tuple(Option, Vec), + Struct { + map: BTreeMap, + pair: Pair, + array: [String; 2], + }, +} + +fn assert_compatible(value: T) { + let words = to_vec(&value).expect("encode fixture"); + let old: T = from_slice(&words).expect("upstream decoder"); + let new: T = decode_instruction(&words).expect("bounded decoder"); + assert_eq!(old, value); + assert_eq!(new, value); +} + +#[test] +fn leaf_values_preserve_the_risc0_format() { + assert_compatible((true, false, 'λ', (), Unit)); + assert_compatible((u8::MAX, u16::MAX, u32::MAX, u64::MAX, u128::MAX)); + assert_compatible((i8::MIN, i16::MIN, i32::MIN, i64::MIN, i128::MIN)); + assert_compatible((i8::MAX, i16::MAX, i32::MAX, i64::MAX, i128::MAX)); + assert_compatible((f32::MIN, f32::MAX, f64::MIN, f64::MAX)); + assert_compatible((None::, Some(u32::MAX), [0u32; 8])); +} + +#[test] +fn strings_preserve_padding_and_trailing_word_acceptance() { + for value in ["", "a", "ab", "abc", "abcd", "abcde", "λ🦀"] { + assert_compatible(value.to_owned()); + let mut words = to_vec(value).expect("encode string"); + words.push(u32::MAX); + assert_eq!(decode_instruction::(&words).expect("string"), value); + assert_eq!(from_slice::(&words).expect("upstream"), value); + } + // Nonzero padding is accepted by the pinned decoder too. + let words = [1, u32::from_ne_bytes([b'a', 0xff, 0xff, 0xff])]; + assert_eq!(decode_instruction::(&words).expect("string"), "a"); + assert_eq!(from_slice::(&words).expect("upstream"), "a"); +} + +#[test] +fn containers_preserve_the_risc0_format() { + assert_compatible(Nested::Unit); + assert_compatible(Nested::Newtype(Box::new(Newtype("name".into())))); + assert_compatible(Nested::Tuple( + Some("optional".into()), + vec!["first".into(), "second".into()], + )); + assert_compatible(Nested::Struct { + map: BTreeMap::from([("key".into(), "value".into())]), + pair: Pair("a".into(), "b".into()), + array: ["c".into(), "d".into()], + }); + assert_compatible(Vec::::new()); + assert_compatible(BTreeMap::::new()); +} + +#[test] +fn truncated_string_lengths_return_errors_before_allocation() { + for length in [1, 4, 5, 0x8000_0000, u32::MAX] { + assert!(matches!( + decode_instruction::(&[length]), + Err(Error::DeserializeUnexpectedEnd) + )); + } + assert!(matches!( + decode_instruction::(&[5, u32::from_ne_bytes(*b"abcd")]), + Err(Error::DeserializeUnexpectedEnd) + )); +} + +#[test] +fn every_container_checks_nested_string_lengths() { + const MARKER: &str = "length-prefix-marker"; + + fn assert_rejected(value: T) { + let mut words = to_vec(&value).expect("encode fixture"); + let marker_len = u32::try_from(MARKER.len()).expect("marker length"); + let length = words + .iter_mut() + .find(|word| **word == marker_len) + .expect("fixture contains the unique marker length"); + *length = u32::MAX; + assert!(matches!( + decode_instruction::(&words), + Err(Error::DeserializeUnexpectedEnd) + )); + } + + assert_rejected(Some(MARKER.to_owned())); + assert_rejected(vec![MARKER.to_owned()]); + assert_rejected([MARKER.to_owned()]); + assert_rejected(Newtype(MARKER.into())); + assert_rejected(Pair("first".into(), MARKER.into())); + assert_rejected(BTreeMap::from([(MARKER.to_owned(), String::new())])); + assert_rejected(BTreeMap::from([(String::new(), MARKER.to_owned())])); + assert_rejected(Nested::Newtype(Box::new(Newtype(MARKER.into())))); + assert_rejected(Nested::Tuple(Some(MARKER.into()), vec![])); + assert_rejected(Nested::Struct { + map: BTreeMap::new(), + pair: Pair("a".into(), "b".into()), + array: ["first".into(), MARKER.into()], + }); +} + +// Exercise Serde's packed-byte representation without adding a test dependency. +#[derive(Debug, PartialEq)] +struct Bytes(Vec); + +impl Serialize for Bytes { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_bytes(&self.0) + } +} + +impl<'de> Deserialize<'de> for Bytes { + fn deserialize>(deserializer: D) -> Result { + struct BytesVisitor; + + impl serde::de::Visitor<'_> for BytesVisitor { + type Value = Bytes; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a packed byte buffer") + } + + fn visit_byte_buf(self, value: Vec) -> Result { + Ok(Bytes(value)) + } + } + + deserializer.deserialize_byte_buf(BytesVisitor) + } +} + +#[test] +fn packed_byte_buffers_preserve_encoding_and_check_lengths() { + for length in 0..=9 { + assert_compatible(Bytes(vec![0xff; length])); + } + for length in [1, 4, 5, 0x8000_0000, u32::MAX] { + assert!(matches!( + decode_instruction::(&[length]), + Err(Error::DeserializeUnexpectedEnd) + )); + } +} + +#[test] +fn other_invalid_values_keep_upstream_errors() { + fn assert_same_error(words: &[u32]) { + let old = from_slice::(words).err().expect("upstream rejects"); + let new = decode_instruction::(words) + .err() + .expect("bounded decoder rejects"); + assert_eq!(new.to_string(), old.to_string()); + } + + assert_same_error::(&[]); + assert_same_error::(&[1, u32::from_ne_bytes([0xff; 4])]); + assert_same_error::(&[2]); + assert_same_error::(&[256]); + assert_same_error::(&[128]); + assert_same_error::(&[0]); + assert_same_error::(&[0xd800]); + assert_same_error::>(&[2]); + assert_same_error::(&[u32::MAX]); + assert_same_error::>(&[2, 0]); +} diff --git a/programs/integration_tests/Cargo.toml b/programs/integration_tests/Cargo.toml index 12eac038..35c4531b 100644 --- a/programs/integration_tests/Cargo.toml +++ b/programs/integration_tests/Cargo.toml @@ -7,6 +7,8 @@ edition = "2021" workspace = true [dependencies] +risc0-zkvm = { workspace = true } +serde = { workspace = true } lee = { workspace = true } lee_core = { workspace = true, features = ["host", "test_utils"] } clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4" } diff --git a/programs/integration_tests/tests/metered_reverts.rs b/programs/integration_tests/tests/metered_reverts.rs new file mode 100644 index 00000000..c477b330 --- /dev/null +++ b/programs/integration_tests/tests/metered_reverts.rs @@ -0,0 +1,351 @@ +//! Exercise the executor directly: the pinned LEZ host predates metered reverts. +//! A panic loses the session; an expected rejection must retain a nonzero halt, +//! consumed cycles, and no committed output (including no chained calls). + +use std::num::NonZeroU8; + +use lee_core::{ + account::{Account, AccountId, AccountWithMetadata, Data}, + program::{ProgramId, ProgramOutput}, +}; +use risc0_zkvm::{default_executor, ExecutorEnv, ExitCode, SessionInfo}; +use serde::Serialize; +use token_core::TokenHolding; + +const CYCLE_LIMIT: u64 = 8 * 1024 * 1024; + +fn account(id: u8, authorized: bool) -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + account_id: AccountId::new([id; 32]), + is_authorized: authorized, + } +} + +fn holding(id: u8, balance: u128, authorized: bool) -> AccountWithMetadata { + let mut value = account(id, authorized); + value.account.program_owner = token_methods::TOKEN_ID; + value.account.data = Data::from(&TokenHolding::Fungible { + definition_id: AccountId::new([9; 32]), + balance, + }); + value +} + +fn environment( + id: ProgramId, + accounts: &[AccountWithMetadata], + instruction: &[u32], +) -> ExecutorEnv<'static> { + ExecutorEnv::builder() + .session_limit(Some(CYCLE_LIMIT)) + .write(&id) + .expect("program id") + .write(&None::) + .expect("caller id") + .write(&accounts) + .expect("accounts") + .write(&instruction) + .expect("instruction words") + .build() + .expect("executor environment") +} + +fn execute( + binary: &[u8], + id: ProgramId, + accounts: &[AccountWithMetadata], + instruction: &impl Serialize, +) -> SessionInfo { + let words = risc0_zkvm::serde::to_vec(instruction).expect("serialize instruction"); + default_executor() + .execute(environment(id, accounts, &words), binary) + .expect("expected rejection must preserve the executor session") +} + +fn assert_revert(session: SessionInfo, code: NonZeroU8) { + assert_eq!(session.exit_code, ExitCode::Halted(u32::from(code.get()))); + assert!(session.cycles() > 0); + assert!(session.cycles() < CYCLE_LIMIT); + assert!( + session.journal.bytes.is_empty(), + "reverts must not commit output" + ); +} + +#[test] +fn token_insufficient_balance_and_overflow_preserve_metered_sessions() { + let instruction = token_core::Instruction::Transfer { + amount_to_transfer: 6, + }; + assert_revert( + execute( + token_methods::TOKEN_ELF, + token_methods::TOKEN_ID, + &[holding(1, 5, true), holding(2, 0, false)], + &instruction, + ), + token_core::error::INSUFFICIENT_BALANCE, + ); + assert_revert( + execute( + token_methods::TOKEN_ELF, + token_methods::TOKEN_ID, + &[holding(1, 6, true), holding(2, u128::MAX, false)], + &instruction, + ), + token_core::error::ARITHMETIC, + ); +} + +#[test] +fn token_transfer_at_balance_boundary_still_commits_success() { + let session = execute( + token_methods::TOKEN_ELF, + token_methods::TOKEN_ID, + &[holding(1, 5, true), holding(2, 0, false)], + &token_core::Instruction::Transfer { + amount_to_transfer: 5, + }, + ); + assert_eq!(session.exit_code, ExitCode::Halted(0)); + let output: ProgramOutput = session.journal.decode().expect("successful output"); + assert_eq!(output.post_states.len(), 2); + for (post, expected) in output.post_states.iter().zip([0, 5]) { + let value = TokenHolding::try_from(&post.account().data).expect("holding"); + assert_eq!( + value, + TokenHolding::Fungible { + definition_id: AccountId::new([9; 32]), + balance: expected, + } + ); + } +} + +#[test] +fn framework_signer_init_and_account_count_failures_are_metered() { + let transfer = token_core::Instruction::Transfer { + amount_to_transfer: 1, + }; + for accounts in [ + vec![holding(1, 5, false), holding(2, 0, false)], + vec![holding(1, 5, true)], + vec![holding(1, 5, true), holding(2, 0, false), account(3, false)], + ] { + assert_revert( + execute( + token_methods::TOKEN_ELF, + token_methods::TOKEN_ID, + &accounts, + &transfer, + ), + token_core::error::INVALID_INPUT, + ); + } + assert_revert( + execute( + token_methods::TOKEN_ELF, + token_methods::TOKEN_ID, + &[holding(1, 5, true), account(2, true)], + &token_core::Instruction::NewFungibleDefinition { + name: "Token".into(), + total_supply: 5, + mint_authority: None, + }, + ), + token_core::error::INVALID_INPUT, + ); +} + +#[test] +fn invalid_instruction_discriminant_is_metered() { + let session = default_executor() + .execute( + environment(token_methods::TOKEN_ID, &[], &[u32::MAX]), + token_methods::TOKEN_ELF, + ) + .expect("invalid instruction must preserve the session"); + assert_revert(session, token_core::error::INVALID_INPUT); +} + +#[test] +fn malformed_token_name_lengths_are_metered() { + // NewFungibleDefinition followed by a truncated name. The high-bit lengths + // used to panic in the 32-bit guest allocator before returning a decode error. + for name_length in [0x8000_0000, u32::MAX, 1, 5] { + let session = default_executor() + .execute( + environment( + token_methods::TOKEN_ID, + &[account(1, true), account(2, true)], + &[1, name_length], + ), + token_methods::TOKEN_ELF, + ) + .expect("malformed string lengths must preserve the executor session"); + assert_revert(session, token_core::error::INVALID_INPUT); + } +} + +#[test] +fn oversized_token_name_is_an_expected_revert() { + // Data holds at most 100 KiB. The name is caller supplied, unlike fixed-size + // serialized program state whose conversion failure remains an invariant bug. + assert_revert( + execute( + token_methods::TOKEN_ELF, + token_methods::TOKEN_ID, + &[account(1, true), account(2, true)], + &token_core::Instruction::NewFungibleDefinition { + name: "x".repeat(100 * 1024), + total_supply: 1, + mint_authority: None, + }, + ), + token_core::error::INVALID_INPUT, + ); +} + +#[test] +fn ata_chained_token_rejection_preserves_callee_cycles() { + let owner = account(3, true); + let seed = ata_core::compute_ata_seed( + token_methods::TOKEN_ID, + owner.account_id, + AccountId::new([9; 32]), + ); + let mut sender = holding(1, 5, false); + sender.account_id = ata_core::get_associated_token_account_id(&ata_methods::ATA_ID, &seed); + let session = execute( + ata_methods::ATA_ELF, + ata_methods::ATA_ID, + &[owner, sender, holding(2, 0, false)], + &ata_core::Instruction::Transfer { + token_program_id: token_methods::TOKEN_ID, + amount: 6, + }, + ); + assert_eq!(session.exit_code, ExitCode::Halted(0)); + let output: ProgramOutput = session.journal.decode().expect("ATA output"); + let [call] = output.chained_calls.as_slice() else { + panic!("ATA must emit exactly one token call"); + }; + assert_eq!(call.program_id, token_methods::TOKEN_ID); + assert_eq!(call.pda_seeds, vec![seed]); + let env = ExecutorEnv::builder() + .session_limit(Some(CYCLE_LIMIT)) + .write(&call.program_id) + .expect("callee id") + .write(&Some(ata_methods::ATA_ID)) + .expect("caller id") + .write(&call.pre_states) + .expect("callee accounts") + .write(&call.instruction_data) + .expect("callee instruction") + .build() + .expect("callee environment"); + let callee = default_executor() + .execute(env, token_methods::TOKEN_ELF) + .expect("callee rejection must retain a session"); + assert_revert(callee, token_core::error::INSUFFICIENT_BALANCE); +} + +#[test] +fn amm_wrong_config_pda_is_metered() { + assert_revert( + execute( + amm_methods::AMM_ELF, + amm_methods::AMM_ID, + &[account(1, false)], + &amm_core::Instruction::Initialize { + token_program_id: token_methods::TOKEN_ID, + twap_oracle_program_id: twap_oracle_methods::TWAP_ORACLE_ID, + authority: AccountId::new([2; 32]), + }, + ), + amm_core::error::INVALID_INPUT, + ); +} + +#[test] +fn ata_wrong_token_owner_is_metered() { + assert_revert( + execute( + ata_methods::ATA_ELF, + ata_methods::ATA_ID, + &[account(1, false), account(2, false), account(3, false)], + &ata_core::Instruction::Create { + token_program_id: token_methods::TOKEN_ID, + }, + ), + ata_core::error::INVALID_INPUT, + ); +} + +#[test] +fn stablecoin_wrong_position_pda_is_metered() { + let mut definition = account(9, false); + definition.account.program_owner = token_methods::TOKEN_ID; + assert_revert( + execute( + stablecoin_methods::STABLECOIN_ELF, + stablecoin_methods::STABLECOIN_ID, + &[ + account(1, true), + account(2, false), + account(3, false), + holding(4, 10, true), + definition, + ], + &stablecoin_core::Instruction::OpenPosition { + position_nonce: 0, + collateral_amount: 1, + }, + ), + stablecoin_core::error::INVALID_INPUT, + ); +} + +#[test] +fn oracle_wrong_tick_pda_is_metered() { + assert_revert( + execute( + twap_oracle_methods::TWAP_ORACLE_ELF, + twap_oracle_methods::TWAP_ORACLE_ID, + &[account(1, false), account(2, true), account(3, false)], + &twap_oracle_core::Instruction::CreateCurrentTickAccount { initial_price: 1 }, + ), + twap_oracle_core::error::INVALID_INPUT, + ); +} + +#[test] +fn corrupted_system_clock_still_panics() { + // Deliberately violate the host's system-account invariant to verify that + // internal panics are not intercepted by the expected-error mechanism. + let source = account(2, true); + let mut tick = account(1, false); + tick.account_id = twap_oracle_core::compute_current_tick_account_pda( + twap_oracle_methods::TWAP_ORACLE_ID, + source.account_id, + ); + let mut clock = account(3, false); + clock.account_id = clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; + let words = + risc0_zkvm::serde::to_vec(&twap_oracle_core::Instruction::CreateCurrentTickAccount { + initial_price: 1, + }) + .expect("instruction"); + let error = default_executor() + .execute( + environment( + twap_oracle_methods::TWAP_ORACLE_ID, + &[tick, source, clock], + &words, + ), + twap_oracle_methods::TWAP_ORACLE_ELF, + ) + .expect_err("corrupted clock must panic without a session"); + assert!(format!("{error:#}").contains("Guest panicked")); +} diff --git a/programs/stablecoin/Cargo.toml b/programs/stablecoin/Cargo.toml index d077ca5f..92b4b673 100644 --- a/programs/stablecoin/Cargo.toml +++ b/programs/stablecoin/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +program-revert = { path = "../common/revert" } lee_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", features = ["host"] } clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4" } stablecoin_core = { path = "core" } diff --git a/programs/stablecoin/core/Cargo.toml b/programs/stablecoin/core/Cargo.toml index 896283f7..e078d60e 100644 --- a/programs/stablecoin/core/Cargo.toml +++ b/programs/stablecoin/core/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +program-revert = { path = "../../common/revert" } lee_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", features = ["host"] } borsh = { version = "1.5", features = ["derive"] } alloy-primitives = { version = "1", default-features = false } diff --git a/programs/stablecoin/core/src/error.rs b/programs/stablecoin/core/src/error.rs new file mode 100644 index 00000000..5e2071eb --- /dev/null +++ b/programs/stablecoin/core/src/error.rs @@ -0,0 +1,13 @@ +//! Nonzero exit codes for the stablecoin program. +//! +//! Codes are local to this program. Keep existing values stable when adding errors. +//! Diagnostic logs identify the failed check within each category. + +use std::num::NonZeroU8; + +/// Invalid instruction, account, authorization, configuration, or operation state. +pub const INVALID_INPUT: NonZeroU8 = NonZeroU8::MIN; +/// Requested amount exceeds the available balance, debt, or collateral. +pub const INSUFFICIENT_BALANCE: NonZeroU8 = NonZeroU8::new(2).expect("nonzero error code"); +/// Requested operation exceeds the representable arithmetic range. +pub const ARITHMETIC: NonZeroU8 = NonZeroU8::new(3).expect("nonzero error code"); diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index e7ccfc28..f9b8bf6d 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -1,5 +1,7 @@ //! Core data structures and utilities for the Stablecoin Program. +pub mod error; + pub mod controller; pub mod math; @@ -122,10 +124,10 @@ pub enum Instruction { /// non-zero; otherwise it SKIPS that half without panicking. Allowed while /// frozen, like the individual pokes. /// - /// Panics ONLY on: `caller` not authorized; any of `protocol_parameters` / - /// `stability_fee_accumulator` / `redemption_price_state` uninitialized, wrong - /// owner, or at the wrong PDA; oracle id mismatch; wrong clock account. It does - /// NOT panic on a not-yet-due interval or a stale / zero oracle. + /// Reverts in the zkVM (panics on native targets) on: `caller` not authorized; any of + /// `protocol_parameters` / `stability_fee_accumulator` / `redemption_price_state` + /// uninitialized, wrong owner, or at the wrong PDA; oracle id mismatch; wrong clock + /// account. It does NOT panic on a not-yet-due interval or a stale / zero oracle. /// /// Required accounts (6), in order — the union of the two standalone pokes: /// 1. `caller` — authorized; not retained anywhere. @@ -315,7 +317,9 @@ pub fn compute_position_vault_pda( /// owner, position_nonce)` and return the [`PdaSeed`] for use in post-state /// claims. /// -/// # Panics +/// # Failures +/// Reverts in the zkVM; panics on native targets. +/// /// If `position.account_id` does not match the derived PDA. pub fn verify_position_and_get_seed( position: &AccountWithMetadata, @@ -325,8 +329,10 @@ pub fn verify_position_and_get_seed( ) -> PdaSeed { let seed = compute_position_pda_seed(owner.account_id, position_nonce); let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed); - assert_eq!( - position.account_id, expected_id, + program_revert::require_eq!( + error::INVALID_INPUT, + position.account_id, + expected_id, "Position account ID does not match expected derivation" ); seed @@ -335,7 +341,9 @@ pub fn verify_position_and_get_seed( /// Verify the vault account's address matches `(stablecoin_program_id, position)` and /// return the [`PdaSeed`] for use in chained calls. /// -/// # Panics +/// # Failures +/// Reverts in the zkVM; panics on native targets. +/// /// If `vault.account_id` does not match the address derived from `position_id` and /// `stablecoin_program_id`. pub fn verify_position_vault_and_get_seed( @@ -345,8 +353,10 @@ pub fn verify_position_vault_and_get_seed( ) -> PdaSeed { let seed = compute_position_vault_pda_seed(position_id); let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed); - assert_eq!( - vault.account_id, expected_id, + program_revert::require_eq!( + error::INVALID_INPUT, + vault.account_id, + expected_id, "Position vault account ID does not match expected derivation" ); seed diff --git a/programs/stablecoin/core/src/math.rs b/programs/stablecoin/core/src/math.rs index cacba28c..65a3f01f 100644 --- a/programs/stablecoin/core/src/math.rs +++ b/programs/stablecoin/core/src/math.rs @@ -5,6 +5,9 @@ //! `10^27`. Multiplications use `U256` intermediates to avoid overflow. use alloy_primitives::U256; +use program_revert::UnwrapOrRevert as _; + +use crate::error; /// The value `1.0` in our 27-decimal fixed-point representation. /// @@ -22,29 +25,39 @@ pub const MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS: u64 = 604_800_000; /// `(a * b) / c` computed via `U256` intermediates and rounded toward zero. /// -/// # Panics +/// # Failures +/// Reverts in the zkVM; panics on native targets. +/// /// - `c == 0` (division by zero). /// - The result exceeds `u128::MAX`. #[must_use] pub fn mul_div(a: u128, b: u128, c: u128) -> u128 { - assert!(c != 0, "mul_div: division by zero"); + program_revert::require!(error::INVALID_INPUT, c != 0, "mul_div: division by zero"); let product = U256::from(a) .checked_mul(U256::from(b)) .expect("mul_div: intermediate product overflows U256"); let quotient = product .checked_div(U256::from(c)) .expect("mul_div: division by zero"); - quotient.try_into().expect("mul_div: result exceeds u128") + quotient + .try_into() + .unwrap_or_revert(error::ARITHMETIC, "mul_div: result exceeds u128") } /// `ceil((a * b) / c)` via `U256` intermediates. /// -/// # Panics +/// # Failures +/// Reverts in the zkVM; panics on native targets. +/// /// - `c == 0`. /// - Result exceeds `u128::MAX`. #[must_use] pub fn mul_div_ceil(a: u128, b: u128, c: u128) -> u128 { - assert!(c != 0, "mul_div_ceil: division by zero"); + program_revert::require!( + error::INVALID_INPUT, + c != 0, + "mul_div_ceil: division by zero" + ); let product = U256::from(a) .checked_mul(U256::from(b)) .expect("mul_div_ceil: intermediate product overflows U256"); @@ -64,7 +77,7 @@ pub fn mul_div_ceil(a: u128, b: u128, c: u128) -> u128 { }; ceiled .try_into() - .expect("mul_div_ceil: result exceeds u128") + .unwrap_or_revert(error::ARITHMETIC, "mul_div_ceil: result exceeds u128") } /// Compute `per_millisecond_rate^milliseconds_elapsed` in fixed-point semantics, where diff --git a/programs/stablecoin/methods/guest/Cargo.lock b/programs/stablecoin/methods/guest/Cargo.lock index f2688927..7adc5c87 100644 --- a/programs/stablecoin/methods/guest/Cargo.lock +++ b/programs/stablecoin/methods/guest/Cargo.lock @@ -1915,6 +1915,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "program-revert" +version = "0.1.0" +dependencies = [ + "risc0-zkvm", + "serde", +] + +[[package]] +name = "program-revert-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proptest" version = "1.11.0" @@ -2691,6 +2708,8 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "program-revert", + "program-revert-macros", "risc0-zkvm", "serde", "spel-framework", @@ -2707,6 +2726,7 @@ dependencies = [ "alloy-primitives", "borsh", "lee_core", + "program-revert", "risc0-zkvm", "ruint", "serde", @@ -2720,6 +2740,7 @@ version = "0.1.0" dependencies = [ "clock_core", "lee_core", + "program-revert", "stablecoin_core", "token_core", "twap_oracle_core", @@ -2876,6 +2897,7 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "program-revert", "serde", "spel-framework-macros", ] diff --git a/programs/stablecoin/methods/guest/Cargo.toml b/programs/stablecoin/methods/guest/Cargo.toml index 6540e38f..84498d9b 100644 --- a/programs/stablecoin/methods/guest/Cargo.toml +++ b/programs/stablecoin/methods/guest/Cargo.toml @@ -14,6 +14,8 @@ name = "stablecoin" path = "src/bin/stablecoin.rs" [dependencies] +program-revert-macros = { path = "../../../common/revert-macros" } +program-revert = { path = "../../../common/revert" } spel-framework = { git = "https://github.com/logos-co/spel.git", rev = "1ef0500f8fc8ce3ddf95523726ae159db83f744f", package = "spel-framework" } nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", package = "lee_core" } risc0-zkvm = { version = "=3.0.5", default-features = false } @@ -22,4 +24,4 @@ stablecoin_core = { path = "../../core" } stablecoin_program = { path = "../..", package = "stablecoin_program" } token_core = { path = "../../../token/core" } serde = { version = "1.0", features = ["derive"] } -borsh = "1.5" \ No newline at end of file +borsh = "1.5" diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index 1164e402..21372ff2 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -5,8 +5,9 @@ use spel_framework::context::ProgramContext; use spel_framework::prelude::*; #[cfg(not(test))] -risc0_zkvm::guest::entry!(main); +risc0_zkvm::guest::entry!(metered_main); +#[program_revert_macros::metered_entry(stablecoin_core::error::INVALID_INPUT)] #[lez_program(instruction = "stablecoin_core::Instruction")] mod stablecoin { #[allow(unused_imports)] @@ -19,7 +20,7 @@ mod stablecoin { /// 9th input — the pinned `ProgramContext` exposes no clock. /// /// # Errors - /// Returns the host program's panic-converted error if any precondition + /// Halts with a nonzero exit code if any precondition /// fails — see the host fn for the full list. #[instruction] #[allow( @@ -90,7 +91,7 @@ mod stablecoin { /// 4th input — the pinned `ProgramContext` exposes no clock. /// /// # Errors - /// Returns the host program's panic-converted error if any precondition + /// Halts with a nonzero exit code if any precondition /// fails — see the host fn for the full list. #[instruction] pub fn accrue_stability_fee( @@ -120,12 +121,12 @@ mod stablecoin { /// re-anchor the redemption price (spec §10.3; host fn /// `stablecoin_program::update_redemption_rate`). /// - /// Strict: panics on a not-yet-due interval or a stale / zero-price oracle. + /// Strict: reverts on a not-yet-due interval or a stale / zero-price oracle. /// Wall-clock time is read from the system `CLOCK_01` account passed as the /// 5th input. /// /// # Errors - /// Returns the host program's panic-converted error if any precondition + /// Halts with a nonzero exit code if any precondition /// fails — see the host fn for the full list. #[instruction] pub fn update_redemption_rate( @@ -163,7 +164,7 @@ mod stablecoin { /// system `CLOCK_01` account passed as the 6th input. /// /// # Errors - /// Returns the host program's panic-converted error if any precondition + /// Halts with a nonzero exit code if any precondition /// fails — see the host fn for the full list. #[instruction] pub fn refresh_globals( @@ -196,7 +197,7 @@ mod stablecoin { /// Open a new collateral-only position for the calling owner. /// /// # Errors - /// Returns the host program's panic-converted error if any precondition fails (see + /// Halts with a nonzero exit code if any precondition fails (see /// [`stablecoin_program::open_position::open_position`] for the full list). #[instruction] #[allow( @@ -237,7 +238,7 @@ mod stablecoin { /// user-controlled holding. /// /// # Errors - /// Returns the host program's panic-converted error if any precondition + /// Halts with a nonzero exit code if any precondition /// fails (see /// [`stablecoin_program::withdraw_collateral::withdraw_collateral`] for the /// full list). @@ -272,7 +273,7 @@ mod stablecoin { /// Repay `amount` of outstanding stablecoin debt against an existing position. /// /// # Errors - /// Returns the host program's panic-converted error if any precondition + /// Halts with a nonzero exit code if any precondition /// fails (see [`stablecoin_program::repay_debt::repay_debt`] for the /// full list). #[instruction] diff --git a/programs/stablecoin/src/accrue_stability_fee.rs b/programs/stablecoin/src/accrue_stability_fee.rs index 431e2aa2..2a1da574 100644 --- a/programs/stablecoin/src/accrue_stability_fee.rs +++ b/programs/stablecoin/src/accrue_stability_fee.rs @@ -9,8 +9,9 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use stablecoin_core::{ - compute_stability_fee_accumulator_pda, math::compute_current_accumulated_rate, + compute_stability_fee_accumulator_pda, error, math::compute_current_accumulated_rate, ProtocolParameters, StabilityFeeAccumulator, }; @@ -21,7 +22,7 @@ use stablecoin_core::{ /// regardless of cadence, so a redundant call is a harmless no-op that just /// re-stamps `last_accrued_at`. Never blocked by the frozen flag. /// -/// See spec §10.2 for the full account contract and panic conditions. +/// See spec §10.2 for the full account contract and rejection conditions. #[allow(clippy::needless_pass_by_value)] pub fn accrue_stability_fee( caller: AccountWithMetadata, @@ -30,7 +31,11 @@ pub fn accrue_stability_fee( clock: AccountWithMetadata, stablecoin_program_id: ProgramId, ) -> (Vec, Vec) { - assert!(caller.is_authorized, "Caller authorization is missing"); + program_revert::require!( + error::INVALID_INPUT, + caller.is_authorized, + "Caller authorization is missing" + ); let (params, accumulator) = decode_fee_accrual_inputs( &protocol_parameters, @@ -61,34 +66,41 @@ pub(crate) fn decode_fee_accrual_inputs( stability_fee_accumulator: &AccountWithMetadata, stablecoin_program_id: ProgramId, ) -> (ProtocolParameters, StabilityFeeAccumulator) { - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, protocol_parameters.account, Account::default(), "ProtocolParameters account must be initialized" ); - assert_eq!( - protocol_parameters.account.program_owner, stablecoin_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + protocol_parameters.account.program_owner, + stablecoin_program_id, "ProtocolParameters not owned by this stablecoin program" ); - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, stability_fee_accumulator.account, Account::default(), "StabilityFeeAccumulator account must be initialized" ); - assert_eq!( - stability_fee_accumulator.account.program_owner, stablecoin_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + stability_fee_accumulator.account.program_owner, + stablecoin_program_id, "StabilityFeeAccumulator not owned by this stablecoin program" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, stability_fee_accumulator.account_id, compute_stability_fee_accumulator_pda(stablecoin_program_id), "StabilityFeeAccumulator account ID does not match expected PDA derivation" ); let params = ProtocolParameters::try_from(&protocol_parameters.account.data) - .expect("ProtocolParameters must decode"); + .unwrap_or_revert(error::INVALID_INPUT, "ProtocolParameters must decode"); let accumulator = StabilityFeeAccumulator::try_from(&stability_fee_accumulator.account.data) - .expect("StabilityFeeAccumulator must decode"); + .unwrap_or_revert(error::INVALID_INPUT, "StabilityFeeAccumulator must decode"); (params, accumulator) } @@ -121,11 +133,14 @@ pub(crate) fn advance_fee_accumulator( /// /// Shared by all three pokes. pub(crate) fn read_clock(clock: &AccountWithMetadata) -> u64 { - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "Clock account must be the system CLOCK_01 account" ); - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, clock.account, Account::default(), "Clock account must be initialized" diff --git a/programs/stablecoin/src/initialize_program.rs b/programs/stablecoin/src/initialize_program.rs index 2d1c8f59..8be17113 100644 --- a/programs/stablecoin/src/initialize_program.rs +++ b/programs/stablecoin/src/initialize_program.rs @@ -10,12 +10,13 @@ use lee_core::{ account::{Account, AccountId, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, Claim, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use stablecoin_core::{ compute_protocol_parameters_pda, compute_protocol_parameters_pda_seed, compute_redemption_price_state_pda, compute_redemption_price_state_pda_seed, compute_stability_fee_accumulator_pda, compute_stability_fee_accumulator_pda_seed, compute_stablecoin_definition_pda, compute_stablecoin_definition_pda_seed, - compute_stablecoin_master_holding_pda, compute_stablecoin_master_holding_pda_seed, + compute_stablecoin_master_holding_pda, compute_stablecoin_master_holding_pda_seed, error, math::FIXED_POINT_ONE, ProtocolParameters, RedemptionPriceState, StabilityFeeAccumulator, }; use token_core::TokenDefinition; @@ -53,7 +54,7 @@ pub struct InitializeProgramParams<'a> { /// Bootstrap a fresh stablecoin protocol instance. /// -/// See spec §10.1 for the full account contract and panic conditions. `now` is +/// See spec §10.1 for the full account contract and rejection conditions. `now` is /// read from the `clock` input (the system `CLOCK_01` account); it anchors the /// stability-fee accumulator and the redemption-price state. #[allow( @@ -74,68 +75,85 @@ pub fn initialize_program( params: InitializeProgramParams<'_>, ) -> (Vec, Vec) { // 1. Authorization - assert!(admin.is_authorized, "Admin authorization is missing"); + program_revert::require!( + error::INVALID_INPUT, + admin.is_authorized, + "Admin authorization is missing" + ); // 2. Target PDAs must be uninitialized - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, protocol_parameters.account, Account::default(), "ProtocolParameters account must be uninitialized" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, stability_fee_accumulator.account, Account::default(), "StabilityFeeAccumulator account must be uninitialized" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, redemption_price_state.account, Account::default(), "RedemptionPriceState account must be uninitialized" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, stablecoin_definition.account, Account::default(), "StablecoinDefinition account must be uninitialized" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, stablecoin_master_holding.account, Account::default(), "StablecoinMasterHolding account must be uninitialized" ); // 3. PDA address checks - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, protocol_parameters.account_id, compute_protocol_parameters_pda(stablecoin_program_id), "ProtocolParameters account ID does not match expected PDA derivation" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, stability_fee_accumulator.account_id, compute_stability_fee_accumulator_pda(stablecoin_program_id), "StabilityFeeAccumulator account ID does not match expected PDA derivation" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, redemption_price_state.account_id, compute_redemption_price_state_pda(stablecoin_program_id), "RedemptionPriceState account ID does not match expected PDA derivation" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, stablecoin_definition.account_id, compute_stablecoin_definition_pda(stablecoin_program_id), "StablecoinDefinition account ID does not match expected PDA derivation" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, stablecoin_master_holding.account_id, compute_stablecoin_master_holding_pda(stablecoin_program_id), "StablecoinMasterHolding account ID does not match expected PDA derivation" ); // 4. Clock account: read the millisecond wall-clock timestamp. - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "Clock account must be the system CLOCK_01 account" ); - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, clock.account, Account::default(), "Clock account must be initialized" @@ -143,58 +161,76 @@ pub fn initialize_program( let now = ClockAccountData::from_bytes(clock.account.data.as_ref()).timestamp; // 5. Collateral definition must be an initialized Fungible TokenDefinition - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, collateral_definition.account, Account::default(), "Collateral definition account must be initialized" ); let collateral_def = TokenDefinition::try_from(&collateral_definition.account.data) - .expect("Collateral definition must be a valid TokenDefinition"); - assert!( + .unwrap_or_revert( + error::INVALID_INPUT, + "Collateral definition must be a valid TokenDefinition", + ); + program_revert::require!( + error::INVALID_INPUT, matches!(collateral_def, TokenDefinition::Fungible { .. }), "Collateral definition must be Fungible" ); // 6. Market price oracle must be initialized + base/quote match - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, market_price_oracle.account, Account::default(), "Market price oracle account must be initialized" ); - let oracle = OraclePriceAccount::try_from(&market_price_oracle.account.data) - .expect("Market price oracle must decode as OraclePriceAccount"); - assert_eq!( - oracle.base_asset, stablecoin_definition.account_id, + let oracle = OraclePriceAccount::try_from(&market_price_oracle.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Market price oracle must decode as OraclePriceAccount", + ); + program_revert::require_eq!( + error::INVALID_INPUT, + oracle.base_asset, + stablecoin_definition.account_id, "Oracle base_asset must equal the stablecoin definition's account_id" ); - assert_eq!( - oracle.quote_asset, collateral_definition.account_id, + program_revert::require_eq!( + error::INVALID_INPUT, + oracle.quote_asset, + collateral_definition.account_id, "Oracle quote_asset must equal the collateral definition's account_id" ); // 7. Numerical param bounds (spec §8) - assert!( + program_revert::require!( + error::INVALID_INPUT, params.initial_stability_fee_per_millisecond >= FIXED_POINT_ONE, "initial_stability_fee_per_millisecond below FIXED_POINT_ONE" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, params.initial_stability_fee_per_millisecond <= MAX_STABILITY_FEE_PER_MILLISECOND, "initial_stability_fee_per_millisecond above sane upper bound" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, params.initial_minimum_collateralization_ratio >= MIN_COLLATERALIZATION_RATIO, "initial_minimum_collateralization_ratio below 1.1x" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, params.initial_minimum_collateralization_ratio <= MAX_COLLATERALIZATION_RATIO, "initial_minimum_collateralization_ratio above 10x" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, params.initial_controller_proportional_gain.unsigned_abs() <= MAX_PROPORTIONAL_GAIN_MAGNITUDE, "controller_proportional_gain out of band" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, params.initial_controller_integral_gain.unsigned_abs() <= MAX_INTEGRAL_GAIN_MAGNITUDE, "controller_integral_gain out of band" ); @@ -208,17 +244,24 @@ pub fn initialize_program( "maximum_oracle_price_age_milliseconds", ), ] { - assert!(milliseconds >= 1, "{label} below minimum 1ms"); - assert!( + program_revert::require!( + error::INVALID_INPUT, + milliseconds >= 1, + "{label} below minimum 1ms" + ); + program_revert::require!( + error::INVALID_INPUT, milliseconds <= MAX_TIMING_MILLISECONDS, "{label} above maximum 86_400_000ms" ); } - assert!( + program_revert::require!( + error::INVALID_INPUT, params.initial_redemption_price > 0, "initial_redemption_price must be positive" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, !params.stablecoin_name.is_empty(), "stablecoin_name must be non-empty" ); diff --git a/programs/stablecoin/src/open_position.rs b/programs/stablecoin/src/open_position.rs index ee830fcd..cb04e4a9 100644 --- a/programs/stablecoin/src/open_position.rs +++ b/programs/stablecoin/src/open_position.rs @@ -2,7 +2,10 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, Claim, ProgramId}, }; -use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position}; +use program_revert::UnwrapOrRevert as _; +use stablecoin_core::{ + error, verify_position_and_get_seed, verify_position_vault_and_get_seed, Position, +}; use token_core::TokenHolding; /// Open a new collateral-only position for `owner`. @@ -16,7 +19,9 @@ use token_core::TokenHolding; /// `debt_amount` is deferred to a future `generate_debt` instruction and is intentionally /// not parameterized here. /// -/// # Panics +/// # Failures +/// Reverts in the zkVM; panics on native targets. +/// /// - `owner` or `user_holding` is not authorized. /// - `position` or `vault` is already initialized. /// - `position.account_id` / `vault.account_id` do not match their PDA derivations. @@ -37,32 +42,46 @@ pub fn open_position( position_nonce: u64, collateral_amount: u128, ) -> (Vec, Vec) { - assert!(owner.is_authorized, "Owner authorization is missing"); - assert!( + program_revert::require!( + error::INVALID_INPUT, + owner.is_authorized, + "Owner authorization is missing" + ); + program_revert::require!( + error::INVALID_INPUT, user_holding.is_authorized, "User collateral holding authorization is missing" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, position.account, Account::default(), "Position account must be uninitialized" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, vault.account, Account::default(), "Position vault account must be uninitialized" ); let user_holding_definition_id = TokenHolding::try_from(&user_holding.account.data) - .expect("User holding must be a valid Token Holding") + .unwrap_or_revert( + error::INVALID_INPUT, + "User holding must be a valid Token Holding", + ) .definition_id(); - assert_eq!( - user_holding_definition_id, token_definition.account_id, + program_revert::require_eq!( + error::INVALID_INPUT, + user_holding_definition_id, + token_definition.account_id, "User collateral holding does not match the provided token definition" ); let token_program_id = user_holding.account.program_owner; - assert_eq!( - token_definition.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + token_definition.account.program_owner, + token_program_id, "Collateral token definition is not owned by the user holding's Token Program" ); diff --git a/programs/stablecoin/src/refresh_globals.rs b/programs/stablecoin/src/refresh_globals.rs index 3f30abc6..7e2b01f0 100644 --- a/programs/stablecoin/src/refresh_globals.rs +++ b/programs/stablecoin/src/refresh_globals.rs @@ -10,6 +10,7 @@ use lee_core::{ account::AccountWithMetadata, program::{AccountPostState, ChainedCall, ProgramId}, }; +use stablecoin_core::error; use crate::{ accrue_stability_fee::{advance_fee_accumulator, decode_fee_accrual_inputs, read_clock}, @@ -21,8 +22,8 @@ use crate::{ /// Every piece of math here is the shared helper the standalone pokes call, so /// the combined path can never drift from them. /// -/// Panics ONLY on caller authorization, an uninitialized / foreign-owned / -/// wrong-PDA global, an oracle id mismatch, or a wrong clock account. A +/// Reverts in the zkVM (panics on native targets) on caller authorization, an uninitialized / +/// foreign-owned / wrong-PDA global, an oracle id mismatch, or a wrong clock account. A /// not-yet-due interval and a stale or zero-price oracle are SOFT — they skip /// the redemption half instead of failing the transaction. Never blocked by the /// frozen flag. @@ -38,7 +39,11 @@ pub fn refresh_globals( clock: AccountWithMetadata, stablecoin_program_id: ProgramId, ) -> (Vec, Vec) { - assert!(caller.is_authorized, "Caller authorization is missing"); + program_revert::require!( + error::INVALID_INPUT, + caller.is_authorized, + "Caller authorization is missing" + ); let (params, accumulator) = decode_fee_accrual_inputs( &protocol_parameters, diff --git a/programs/stablecoin/src/repay_debt.rs b/programs/stablecoin/src/repay_debt.rs index d23efdf7..5c6d6952 100644 --- a/programs/stablecoin/src/repay_debt.rs +++ b/programs/stablecoin/src/repay_debt.rs @@ -2,7 +2,8 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; -use stablecoin_core::{verify_position_and_get_seed, Position}; +use program_revert::UnwrapOrRevert as _; +use stablecoin_core::{error, verify_position_and_get_seed, Position}; use token_core::TokenHolding; /// Repay `amount` of outstanding stablecoin debt against an existing position. @@ -22,7 +23,9 @@ use token_core::TokenHolding; /// `Position`, this instruction cannot validate that `stablecoin_definition` /// is the correct one for the position's debt. The caller is trusted. /// -/// # Panics +/// # Failures +/// Reverts in the zkVM; panics on native targets. +/// /// - `owner` is not authorized. /// - `position` is uninitialized, not owned by `stablecoin_program_id`, holds data that does not /// decode as a [`Position`], or sits at an address that does not match @@ -40,19 +43,28 @@ pub fn repay_debt( stablecoin_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { - assert!(owner.is_authorized, "Owner authorization is missing"); - assert_ne!( + program_revert::require!( + error::INVALID_INPUT, + owner.is_authorized, + "Owner authorization is missing" + ); + program_revert::require_ne!( + error::INVALID_INPUT, position.account, Account::default(), "Position account must be initialized" ); - assert_eq!( - position.account.program_owner, stablecoin_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + position.account.program_owner, + stablecoin_program_id, "Position is not owned by this stablecoin program" ); - let position_data = Position::try_from(&position.account.data) - .expect("Position account must hold valid Position state"); + let position_data = Position::try_from(&position.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Position account must hold valid Position state", + ); // `verify_position_and_get_seed` asserts the position address matches the // (owner, position_nonce) PDA derivation. The returned seed is // dropped — the position is already PDA-claimed. @@ -64,32 +76,43 @@ pub fn repay_debt( ); // The PDA derivation above already binds the owner; this guards the stored // discovery copy against silently drifting out of sync. - assert_eq!( - position_data.owner_account_id, owner.account_id, + program_revert::require_eq!( + error::INVALID_INPUT, + position_data.owner_account_id, + owner.account_id, "Position owner_account_id does not match the owner account" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, user_stablecoin_holding.is_authorized, "User stablecoin holding authorization is missing" ); - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, user_stablecoin_holding.account, Account::default(), "User stablecoin holding must be initialized" ); - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, stablecoin_definition.account, Account::default(), "Stablecoin definition account must be initialized" ); - assert_eq!( - user_stablecoin_holding.account.program_owner, stablecoin_definition.account.program_owner, + program_revert::require_eq!( + error::INVALID_INPUT, + user_stablecoin_holding.account.program_owner, + stablecoin_definition.account.program_owner, "Stablecoin holding and definition must be owned by the same Token Program" ); let user_holding_data = TokenHolding::try_from(&user_stablecoin_holding.account.data) - .expect("User stablecoin holding must hold a valid TokenHolding"); - assert_eq!( + .unwrap_or_revert( + error::INVALID_INPUT, + "User stablecoin holding must hold a valid TokenHolding", + ); + program_revert::require_eq!( + error::INVALID_INPUT, user_holding_data.definition_id(), stablecoin_definition.account_id, "Stablecoin holding does not match the provided stablecoin definition" @@ -102,7 +125,10 @@ pub fn repay_debt( let new_debt = position_data .normalized_debt_amount .checked_sub(amount) - .expect("Repay amount exceeds outstanding debt"); + .unwrap_or_revert( + error::INSUFFICIENT_BALANCE, + "Repay amount exceeds outstanding debt", + ); let updated_position = Position { owner_account_id: position_data.owner_account_id, diff --git a/programs/stablecoin/src/update_redemption_rate.rs b/programs/stablecoin/src/update_redemption_rate.rs index 019712d4..c4175640 100644 --- a/programs/stablecoin/src/update_redemption_rate.rs +++ b/programs/stablecoin/src/update_redemption_rate.rs @@ -7,8 +7,9 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use stablecoin_core::{ - compute_redemption_price_state_pda, controller::run_controller_tick, + compute_redemption_price_state_pda, controller::run_controller_tick, error, math::compute_current_redemption_price, ControllerOutput, ProtocolParameters, RedemptionPriceState, }; @@ -20,12 +21,12 @@ use crate::accrue_stability_fee::read_clock; /// price. /// /// Permissionless but strict: unlike [`crate::refresh_globals`], every gate here -/// is hard. It panics when called before +/// is hard. It reverts when called before /// `minimum_milliseconds_between_rate_updates` has elapsed, and when the oracle /// is stale or reports a zero price — a keeper may want that as an explicit /// failure signal rather than a silent skip. Never blocked by the frozen flag. /// -/// See spec §10.3 for the full account contract and panic conditions. +/// See spec §10.3 for the full account contract and rejection conditions. #[allow(clippy::needless_pass_by_value)] pub fn update_redemption_rate( caller: AccountWithMetadata, @@ -35,7 +36,11 @@ pub fn update_redemption_rate( clock: AccountWithMetadata, stablecoin_program_id: ProgramId, ) -> (Vec, Vec) { - assert!(caller.is_authorized, "Caller authorization is missing"); + program_revert::require!( + error::INVALID_INPUT, + caller.is_authorized, + "Caller authorization is missing" + ); let (params, redemption) = decode_redemption_inputs( &protocol_parameters, @@ -47,12 +52,18 @@ pub fn update_redemption_rate( // Hard gates. `refresh_globals` treats these same three conditions as soft // skips; here they are failures (spec §10.3 vs §10.3a). - assert!( + program_revert::require!( + error::INVALID_INPUT, now.saturating_sub(oracle.timestamp) <= params.maximum_oracle_price_age_milliseconds, "Market price oracle observation is stale" ); - assert!(oracle.price > 0, "Market price oracle reports zero price"); - assert!( + program_revert::require!( + error::INVALID_INPUT, + oracle.price > 0, + "Market price oracle reports zero price" + ); + program_revert::require!( + error::INVALID_INPUT, now.saturating_sub(redemption.last_updated_at) >= params.minimum_milliseconds_between_rate_updates, "update_redemption_rate called too soon since last update" @@ -86,34 +97,41 @@ pub(crate) fn decode_redemption_inputs( redemption_price_state: &AccountWithMetadata, stablecoin_program_id: ProgramId, ) -> (ProtocolParameters, RedemptionPriceState) { - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, protocol_parameters.account, Account::default(), "ProtocolParameters account must be initialized" ); - assert_eq!( - protocol_parameters.account.program_owner, stablecoin_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + protocol_parameters.account.program_owner, + stablecoin_program_id, "ProtocolParameters not owned by this stablecoin program" ); - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, redemption_price_state.account, Account::default(), "RedemptionPriceState account must be initialized" ); - assert_eq!( - redemption_price_state.account.program_owner, stablecoin_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + redemption_price_state.account.program_owner, + stablecoin_program_id, "RedemptionPriceState not owned by this stablecoin program" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, redemption_price_state.account_id, compute_redemption_price_state_pda(stablecoin_program_id), "RedemptionPriceState account ID does not match expected PDA derivation" ); let params = ProtocolParameters::try_from(&protocol_parameters.account.data) - .expect("ProtocolParameters must decode"); + .unwrap_or_revert(error::INVALID_INPUT, "ProtocolParameters must decode"); let redemption = RedemptionPriceState::try_from(&redemption_price_state.account.data) - .expect("RedemptionPriceState must decode"); + .unwrap_or_revert(error::INVALID_INPUT, "RedemptionPriceState must decode"); (params, redemption) } @@ -124,22 +142,27 @@ pub(crate) fn decode_redemption_inputs( /// `ProtocolParameters.market_price_oracle_id`. Its `program_owner` is /// deliberately NOT pinned, so a future aggregator or an alternative producer /// can replace the TWAP oracle without a code change here. Shared with -/// [`crate::refresh_globals`], where the id mismatch is likewise a hard panic. +/// [`crate::refresh_globals`], where the id mismatch is likewise a hard rejection. pub(crate) fn decode_oracle( market_price_oracle: &AccountWithMetadata, params: &ProtocolParameters, ) -> OraclePriceAccount { - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, market_price_oracle.account, Account::default(), "Market price oracle account must be initialized" ); - assert_eq!( - market_price_oracle.account_id, params.market_price_oracle_id, + program_revert::require_eq!( + error::INVALID_INPUT, + market_price_oracle.account_id, + params.market_price_oracle_id, "Market price oracle account_id does not match ProtocolParameters.market_price_oracle_id" ); - OraclePriceAccount::try_from(&market_price_oracle.account.data) - .expect("Market price oracle must decode as OraclePriceAccount") + OraclePriceAccount::try_from(&market_price_oracle.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Market price oracle must decode as OraclePriceAccount", + ) } /// Project the redemption price to `now`, run one controller tick against diff --git a/programs/stablecoin/src/withdraw_collateral.rs b/programs/stablecoin/src/withdraw_collateral.rs index d973e696..17e4b1a2 100644 --- a/programs/stablecoin/src/withdraw_collateral.rs +++ b/programs/stablecoin/src/withdraw_collateral.rs @@ -2,7 +2,10 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; -use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position}; +use program_revert::UnwrapOrRevert as _; +use stablecoin_core::{ + error, verify_position_and_get_seed, verify_position_vault_and_get_seed, Position, +}; use token_core::TokenHolding; /// Withdraw `amount` collateral tokens from `position`'s vault back to `destination`. @@ -18,7 +21,9 @@ use token_core::TokenHolding; /// When that lands, this guard is replaced by real fee accrual + a /// collateralization-ratio check against the post-withdrawal collateral. /// -/// # Panics +/// # Failures +/// Reverts in the zkVM; panics on native targets. +/// /// - `owner` is not authorized. /// - `position` is uninitialized, not owned by `stablecoin_program_id`, holds data that does not /// decode as a [`Position`], or sits at an address that does not match @@ -38,19 +43,28 @@ pub fn withdraw_collateral( stablecoin_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { - assert!(owner.is_authorized, "Owner authorization is missing"); - assert_ne!( + program_revert::require!( + error::INVALID_INPUT, + owner.is_authorized, + "Owner authorization is missing" + ); + program_revert::require_ne!( + error::INVALID_INPUT, position.account, Account::default(), "Position account must be initialized" ); - assert_eq!( - position.account.program_owner, stablecoin_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + position.account.program_owner, + stablecoin_program_id, "Position is not owned by this stablecoin program" ); - let position_data = Position::try_from(&position.account.data) - .expect("Position account must hold valid Position state"); + let position_data = Position::try_from(&position.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Position account must hold valid Position state", + ); // `verify_position_and_get_seed` asserts the position address matches the // (owner, position_nonce) PDA derivation. We do not use the seed // downstream — the position is already PDA-claimed. @@ -62,19 +76,25 @@ pub fn withdraw_collateral( ); // The PDA derivation above already binds the owner; this guards the stored // discovery copy against silently drifting out of sync. - assert_eq!( - position_data.owner_account_id, owner.account_id, + program_revert::require_eq!( + error::INVALID_INPUT, + position_data.owner_account_id, + owner.account_id, "Position owner_account_id does not match the owner account" ); let vault_seed = verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id); - assert_eq!( - position_data.vault_account_id, vault.account_id, + program_revert::require_eq!( + error::INVALID_INPUT, + position_data.vault_account_id, + vault.account_id, "Position vault_account_id does not match the vault account" ); - let vault_holding = TokenHolding::try_from(&vault.account.data) - .expect("Vault account must hold a valid TokenHolding"); + let vault_holding = TokenHolding::try_from(&vault.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Vault account must hold a valid TokenHolding", + ); // The vault PDA is verified to belong to this position, so its holding's // definition is the authoritative collateral definition. #161 dropped the // redundant copy from `Position`; `ProtocolParameters` owns the global @@ -82,31 +102,40 @@ pub fn withdraw_collateral( let collateral_definition_id = vault_holding.definition_id(); let token_program_id = vault.account.program_owner; - assert_ne!( + program_revert::require_ne!( + error::INVALID_INPUT, destination.account, Account::default(), "Destination must be initialized" ); - assert_eq!( - destination.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + destination.account.program_owner, + token_program_id, "Destination must be owned by the same Token Program as the vault" ); - let destination_holding = TokenHolding::try_from(&destination.account.data) - .expect("Destination account must hold a valid TokenHolding"); - assert_eq!( + let destination_holding = TokenHolding::try_from(&destination.account.data).unwrap_or_revert( + error::INVALID_INPUT, + "Destination account must hold a valid TokenHolding", + ); + program_revert::require_eq!( + error::INVALID_INPUT, destination_holding.definition_id(), collateral_definition_id, "Destination token definition does not match the position's collateral definition" ); - assert_eq!( + program_revert::require_eq!(error::INVALID_INPUT, position_data.normalized_debt_amount, 0, "withdraw_collateral with debt is not supported yet — fee accrual + collateralization check land in #173" ); let new_collateral = position_data .collateral_amount .checked_sub(amount) - .expect("Withdrawal amount exceeds position collateral"); + .unwrap_or_revert( + error::INSUFFICIENT_BALANCE, + "Withdrawal amount exceeds position collateral", + ); let updated_position = Position { owner_account_id: position_data.owner_account_id, diff --git a/programs/token/Cargo.toml b/programs/token/Cargo.toml index 2197f580..1990a0d1 100644 --- a/programs/token/Cargo.toml +++ b/programs/token/Cargo.toml @@ -7,5 +7,6 @@ edition = "2021" workspace = true [dependencies] +program-revert = { path = "../common/revert" } lee_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", features = ["host"] } token_core = { path = "core" } diff --git a/programs/token/core/Cargo.toml b/programs/token/core/Cargo.toml index 2fc8ed00..9e02aba2 100644 --- a/programs/token/core/Cargo.toml +++ b/programs/token/core/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +program-revert = { path = "../../common/revert" } lee_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", features = ["host"] } spel-framework-macros = { git = "https://github.com/logos-co/spel.git", rev = "1ef0500f8fc8ce3ddf95523726ae159db83f744f", package = "spel-framework-macros" } borsh = { version = "1.5", features = ["derive"] } diff --git a/programs/token/core/src/error.rs b/programs/token/core/src/error.rs new file mode 100644 index 00000000..a3763f2f --- /dev/null +++ b/programs/token/core/src/error.rs @@ -0,0 +1,13 @@ +//! Nonzero exit codes for the token program. +//! +//! Codes are local to this program. Keep existing values stable when adding errors. +//! Diagnostic logs identify the failed check within each category. + +use std::num::NonZeroU8; + +/// Invalid instruction, account, authorization, configuration, or operation state. +pub const INVALID_INPUT: NonZeroU8 = NonZeroU8::MIN; +/// Requested amount exceeds the available balance, debt, or collateral. +pub const INSUFFICIENT_BALANCE: NonZeroU8 = NonZeroU8::new(2).expect("nonzero error code"); +/// Requested operation exceeds the representable arithmetic range. +pub const ARITHMETIC: NonZeroU8 = NonZeroU8::new(3).expect("nonzero error code"); diff --git a/programs/token/core/src/lib.rs b/programs/token/core/src/lib.rs index 962930d3..d8e471b9 100644 --- a/programs/token/core/src/lib.rs +++ b/programs/token/core/src/lib.rs @@ -1,7 +1,10 @@ //! This crate contains core data structures and utilities for the Token Program. +pub mod error; + use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::account::{AccountId, Data}; +use program_revert::UnwrapOrRevert as _; use serde::{Deserialize, Serialize}; use spel_framework_macros::account_type; @@ -160,7 +163,10 @@ impl From<&TokenDefinition> for Data { BorshSerialize::serialize(definition, &mut data) .expect("Serialization to Vec should not fail"); - Data::try_from(data).expect("Token definition encoded data should fit into Data") + Data::try_from(data).unwrap_or_revert( + error::INVALID_INPUT, + "Token definition encoded data exceeds Data capacity", + ) } } @@ -294,6 +300,9 @@ impl From<&TokenMetadata> for Data { BorshSerialize::serialize(metadata, &mut data) .expect("Serialization to Vec should not fail"); - Data::try_from(data).expect("Token metadata encoded data should fit into Data") + Data::try_from(data).unwrap_or_revert( + error::INVALID_INPUT, + "Token metadata encoded data exceeds Data capacity", + ) } } diff --git a/programs/token/methods/guest/Cargo.lock b/programs/token/methods/guest/Cargo.lock index ef9b0cdb..ccf47360 100644 --- a/programs/token/methods/guest/Cargo.lock +++ b/programs/token/methods/guest/Cargo.lock @@ -1318,6 +1318,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "program-revert" +version = "0.1.0" +dependencies = [ + "risc0-zkvm", + "serde", +] + +[[package]] +name = "program-revert-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proptest" version = "1.11.0" @@ -2013,6 +2030,8 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "program-revert", + "program-revert-macros", "risc0-zkvm", "ruint", "serde", @@ -2027,6 +2046,7 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "program-revert", "serde", "spel-framework-macros", ] @@ -2036,6 +2056,7 @@ name = "token_program" version = "0.1.0" dependencies = [ "lee_core", + "program-revert", "token_core", ] diff --git a/programs/token/methods/guest/Cargo.toml b/programs/token/methods/guest/Cargo.toml index 575266e9..8b4b6e1c 100644 --- a/programs/token/methods/guest/Cargo.toml +++ b/programs/token/methods/guest/Cargo.toml @@ -56,6 +56,8 @@ name = "token" path = "src/bin/token.rs" [dependencies] +program-revert-macros = { path = "../../../common/revert-macros" } +program-revert = { path = "../../../common/revert" } spel-framework = { git = "https://github.com/logos-co/spel.git", rev = "1ef0500f8fc8ce3ddf95523726ae159db83f744f", package = "spel-framework" } nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", package = "lee_core" } risc0-zkvm = { version = "=3.0.5", default-features = false } @@ -67,4 +69,4 @@ ruint = { version = "=1.17.0", default-features = false } token_core = { path = "../../core" } token_program = { path = "../..", package = "token_program" } serde = { version = "1.0", features = ["derive"] } -borsh = "1.5" \ No newline at end of file +borsh = "1.5" diff --git a/programs/token/methods/guest/src/bin/token.rs b/programs/token/methods/guest/src/bin/token.rs index 5024c728..a77ff8e6 100644 --- a/programs/token/methods/guest/src/bin/token.rs +++ b/programs/token/methods/guest/src/bin/token.rs @@ -9,8 +9,9 @@ use spel_framework::context::ProgramContext; use spel_framework::prelude::*; #[cfg(not(test))] -risc0_zkvm::guest::entry!(main); +risc0_zkvm::guest::entry!(metered_main); +#[program_revert_macros::metered_entry(token_core::error::INVALID_INPUT)] #[lez_program(instruction = "token_core::Instruction")] mod token { #[expect( diff --git a/programs/token/src/burn.rs b/programs/token/src/burn.rs index f81f905f..4712e028 100644 --- a/programs/token/src/burn.rs +++ b/programs/token/src/burn.rs @@ -2,24 +2,30 @@ use lee_core::{ account::{AccountWithMetadata, Data}, program::AccountPostState, }; -use token_core::{TokenDefinition, TokenHolding}; +use program_revert::UnwrapOrRevert as _; +use token_core::{error, TokenDefinition, TokenHolding}; pub fn burn( definition_account: AccountWithMetadata, user_holding_account: AccountWithMetadata, amount_to_burn: u128, ) -> Vec { - assert!( + program_revert::require!( + error::INVALID_INPUT, user_holding_account.is_authorized, "Authorization is missing" ); let mut definition = TokenDefinition::try_from(&definition_account.account.data) - .expect("Token Definition account must be valid"); + .unwrap_or_revert( + error::INVALID_INPUT, + "Token Definition account must be valid", + ); let mut holding = TokenHolding::try_from(&user_holding_account.account.data) - .expect("Token Holding account must be valid"); + .unwrap_or_revert(error::INVALID_INPUT, "Token Holding account must be valid"); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, definition_account.account_id, holding.definition_id(), "Mismatch Token Definition and Token Holding" @@ -40,11 +46,11 @@ pub fn burn( ) => { *balance = balance .checked_sub(amount_to_burn) - .expect("Insufficient balance to burn"); + .unwrap_or_revert(error::INSUFFICIENT_BALANCE, "Insufficient balance to burn"); *total_supply = total_supply .checked_sub(amount_to_burn) - .expect("Total supply underflow"); + .unwrap_or_revert(error::ARITHMETIC, "Total supply underflow"); } ( TokenDefinition::NonFungible { @@ -59,11 +65,11 @@ pub fn burn( ) => { *printable_supply = printable_supply .checked_sub(amount_to_burn) - .expect("Printable supply underflow"); + .unwrap_or_revert(error::ARITHMETIC, "Printable supply underflow"); *print_balance = print_balance .checked_sub(amount_to_burn) - .expect("Insufficient balance to burn"); + .unwrap_or_revert(error::INSUFFICIENT_BALANCE, "Insufficient balance to burn"); } ( TokenDefinition::NonFungible { @@ -76,20 +82,29 @@ pub fn burn( owned, }, ) => { - assert_eq!( - amount_to_burn, 1, + program_revert::require_eq!( + error::INVALID_INPUT, + amount_to_burn, + 1, "Invalid balance to burn for NFT Printed Copy" ); - assert!(*owned, "Cannot burn unowned NFT Printed Copy"); + program_revert::require!( + error::INVALID_INPUT, + *owned, + "Cannot burn unowned NFT Printed Copy" + ); *printable_supply = printable_supply .checked_sub(1) - .expect("Printable supply underflow"); + .unwrap_or_revert(error::ARITHMETIC, "Printable supply underflow"); *owned = false; } - _ => panic!("Mismatched Token Definition and Token Holding types"), + _ => program_revert::revert!( + error::INVALID_INPUT, + "Mismatched Token Definition and Token Holding types" + ), } let mut definition_post = definition_account.account; diff --git a/programs/token/src/initialize.rs b/programs/token/src/initialize.rs index 485ae91f..ea0070a2 100644 --- a/programs/token/src/initialize.rs +++ b/programs/token/src/initialize.rs @@ -2,29 +2,34 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, Claim, ProgramId}, }; -use token_core::{TokenDefinition, TokenHolding}; +use program_revert::UnwrapOrRevert as _; +use token_core::{error, TokenDefinition, TokenHolding}; pub fn initialize_account( definition_account: AccountWithMetadata, account_to_initialize: AccountWithMetadata, token_program_id: ProgramId, ) -> Vec { - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, account_to_initialize.account, Account::default(), "Only Uninitialized accounts can be initialized" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, account_to_initialize.is_authorized, "Account to initialize must be authorized" ); - assert_eq!( - definition_account.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + definition_account.account.program_owner, + token_program_id, "Token definition must be owned by token program" ); let definition = TokenDefinition::try_from(&definition_account.account.data) - .expect("Definition account must be valid"); + .unwrap_or_revert(error::INVALID_INPUT, "Definition account must be valid"); let holding = TokenHolding::zeroized_from_definition(definition_account.account_id, &definition); diff --git a/programs/token/src/mint.rs b/programs/token/src/mint.rs index 96d2dd39..837d60e7 100644 --- a/programs/token/src/mint.rs +++ b/programs/token/src/mint.rs @@ -2,7 +2,8 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, Claim, ProgramId}, }; -use token_core::{TokenDefinition, TokenHolding}; +use program_revert::UnwrapOrRevert as _; +use token_core::{error, TokenDefinition, TokenHolding}; /// Mint additional supply under **self/PDA authority**: the definition account /// itself is the current mint authority and proves it by being authorized in @@ -54,13 +55,18 @@ fn mint_inner( amount_to_mint: u128, token_program_id: ProgramId, ) -> Vec { - assert_eq!( - definition_account.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + definition_account.account.program_owner, + token_program_id, "Token definition must be owned by token program" ); let mut definition = TokenDefinition::try_from(&definition_account.account.data) - .expect("Token Definition account must be valid"); + .unwrap_or_revert( + error::INVALID_INPUT, + "Token Definition account must be valid", + ); // Minting is gated on the definition's stored mint authority: the account // that proves authority must be authorized AND its id must match the stored @@ -68,15 +74,20 @@ fn mint_inner( // otherwise the definition account itself (self/PDA authority). if let TokenDefinition::Fungible { authority, .. } = &definition { // `None` means the supply is permanently fixed (renounced) — minting is rejected. - let mint_authority = - authority.expect("Mint authority check failed: authority revoked, supply is fixed"); + let mint_authority = authority.unwrap_or_revert( + error::INVALID_INPUT, + "Mint authority check failed: authority revoked, supply is fixed", + ); let authority_ref = authority_account.as_ref().unwrap_or(&definition_account); - assert!( + program_revert::require!( + error::INVALID_INPUT, authority_ref.is_authorized, "Mint authority must authorize the transaction" ); - assert_eq!( - authority_ref.account_id, mint_authority, + program_revert::require_eq!( + error::INVALID_INPUT, + authority_ref.account_id, + mint_authority, "Mint authority check failed: signer is not the current authority" ); } @@ -85,10 +96,11 @@ fn mint_inner( TokenHolding::zeroized_from_definition(definition_account.account_id, &definition) } else { TokenHolding::try_from(&user_holding_account.account.data) - .expect("Token Holding account must be valid") + .unwrap_or_revert(error::INVALID_INPUT, "Token Holding account must be valid") }; - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, definition_account.account_id, holding.definition_id(), "Mismatch Token Definition and Token Holding" @@ -109,19 +121,25 @@ fn mint_inner( ) => { *balance = balance .checked_add(amount_to_mint) - .expect("Balance overflow on minting"); + .unwrap_or_revert(error::ARITHMETIC, "Balance overflow on minting"); *total_supply = total_supply .checked_add(amount_to_mint) - .expect("Total supply overflow"); + .unwrap_or_revert(error::ARITHMETIC, "Total supply overflow"); } ( TokenDefinition::NonFungible { .. }, TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. }, ) => { - panic!("Cannot mint additional supply for Non-Fungible Tokens"); + program_revert::revert!( + error::INVALID_INPUT, + "Cannot mint additional supply for Non-Fungible Tokens" + ); } - _ => panic!("Mismatched Token Definition and Token Holding types"), + _ => program_revert::revert!( + error::INVALID_INPUT, + "Mismatched Token Definition and Token Holding types" + ), } let mut definition_post = definition_account.account; diff --git a/programs/token/src/new_definition.rs b/programs/token/src/new_definition.rs index c01d2b17..2bfa85de 100644 --- a/programs/token/src/new_definition.rs +++ b/programs/token/src/new_definition.rs @@ -3,7 +3,7 @@ use lee_core::{ program::{AccountPostState, Claim}, }; use token_core::{ - NewTokenDefinition, NewTokenMetadata, TokenDefinition, TokenHolding, TokenMetadata, + error, NewTokenDefinition, NewTokenMetadata, TokenDefinition, TokenHolding, TokenMetadata, }; /// Validate the mint authority for a freshly created fungible definition. @@ -12,7 +12,8 @@ use token_core::{ /// An all-zero authority id is rejected as it cannot be a real signer. fn validate_mint_authority(mint_authority: Option) -> Option { if let Some(id) = &mint_authority { - assert!( + program_revert::require!( + error::INVALID_INPUT, id.value() != &[0u8; 32], "Mint authority must be a valid non-zero account ID" ); @@ -27,22 +28,26 @@ pub fn new_fungible_definition( total_supply: u128, mint_authority: Option, ) -> Vec { - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, definition_target_account.account, Account::default(), "Definition target account must have default values" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, holding_target_account.account, Account::default(), "Holding target account must have default values" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, definition_target_account.is_authorized, "Definition target account must be authorized" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, holding_target_account.is_authorized, "Holding target account must be authorized" ); @@ -77,32 +82,38 @@ pub fn new_definition_with_metadata( new_definition: NewTokenDefinition, metadata: NewTokenMetadata, ) -> Vec { - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, definition_target_account.account, Account::default(), "Definition target account must have default values" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, holding_target_account.account, Account::default(), "Holding target account must have default values" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, metadata_target_account.account, Account::default(), "Metadata target account must have default values" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, definition_target_account.is_authorized, "Definition target account must be authorized" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, holding_target_account.is_authorized, "Holding target account must be authorized" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, metadata_target_account.is_authorized, "Metadata target account must be authorized" ); diff --git a/programs/token/src/print_nft.rs b/programs/token/src/print_nft.rs index 5b2f32b7..dbbfc9b8 100644 --- a/programs/token/src/print_nft.rs +++ b/programs/token/src/print_nft.rs @@ -2,41 +2,49 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, Claim}, }; -use token_core::TokenHolding; +use program_revert::UnwrapOrRevert as _; +use token_core::{error, TokenHolding}; pub fn print_nft( master_account: AccountWithMetadata, printed_account: AccountWithMetadata, ) -> Vec { - assert!( + program_revert::require!( + error::INVALID_INPUT, master_account.is_authorized, "Master NFT Account must be authorized" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, printed_account.account, Account::default(), "Printed Account must be uninitialized" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, printed_account.is_authorized, "Printed Account must be authorized" ); - let mut master_account_data = - TokenHolding::try_from(&master_account.account.data).expect("Invalid Token Holding data"); + let mut master_account_data = TokenHolding::try_from(&master_account.account.data) + .unwrap_or_revert(error::INVALID_INPUT, "Invalid Token Holding data"); let TokenHolding::NftMaster { definition_id, print_balance, } = &mut master_account_data else { - panic!("Invalid Token Holding provided as NFT Master Account"); + program_revert::revert!( + error::INVALID_INPUT, + "Invalid Token Holding provided as NFT Master Account" + ); }; let definition_id = *definition_id; - assert!( + program_revert::require!( + error::INSUFFICIENT_BALANCE, *print_balance > 1, "Insufficient balance to print another NFT copy" ); diff --git a/programs/token/src/set_authority.rs b/programs/token/src/set_authority.rs index 1c8235cf..d2993478 100644 --- a/programs/token/src/set_authority.rs +++ b/programs/token/src/set_authority.rs @@ -2,7 +2,8 @@ use lee_core::{ account::{AccountId, AccountWithMetadata, Data}, program::{AccountPostState, ProgramId}, }; -use token_core::TokenDefinition; +use program_revert::UnwrapOrRevert as _; +use token_core::{error, TokenDefinition}; /// Rotate or revoke the mint authority under **self/PDA authority**: the definition /// account itself is the current authority and proves it by being authorized in this @@ -44,13 +45,18 @@ fn set_authority_inner( new_authority: Option, token_program_id: ProgramId, ) -> Vec { - assert_eq!( - definition_account.account.program_owner, token_program_id, + program_revert::require_eq!( + error::INVALID_INPUT, + definition_account.account.program_owner, + token_program_id, "Token definition must be owned by token program" ); let mut definition = TokenDefinition::try_from(&definition_account.account.data) - .expect("Token Definition account must be valid"); + .unwrap_or_revert( + error::INVALID_INPUT, + "Token Definition account must be valid", + ); match &mut definition { TokenDefinition::Fungible { authority, .. } => { @@ -58,19 +64,26 @@ fn set_authority_inner( // match the stored authority. That account is the explicit external // authority when present, otherwise the definition account itself. // `None` means the authority was renounced and can no longer be set. - let current = authority.expect("SetAuthority failed: authority already revoked"); + let current = authority.unwrap_or_revert( + error::INVALID_INPUT, + "SetAuthority failed: authority already revoked", + ); let authority_ref = authority_account.as_ref().unwrap_or(&definition_account); - assert!( + program_revert::require!( + error::INVALID_INPUT, authority_ref.is_authorized, "Mint authority must authorize the transaction" ); - assert_eq!( - authority_ref.account_id, current, + program_revert::require_eq!( + error::INVALID_INPUT, + authority_ref.account_id, + current, "SetAuthority failed: signer is not the current authority" ); if let Some(new) = &new_authority { - assert!( + program_revert::require!( + error::INVALID_INPUT, new.value() != &[0u8; 32], "New mint authority must be a valid non-zero account ID" ); @@ -79,7 +92,10 @@ fn set_authority_inner( *authority = new_authority; } TokenDefinition::NonFungible { .. } => { - panic!("SetAuthority is not supported for Non-Fungible Tokens"); + program_revert::revert!( + error::INVALID_INPUT, + "SetAuthority is not supported for Non-Fungible Tokens" + ); } } diff --git a/programs/token/src/transfer.rs b/programs/token/src/transfer.rs index 377ca450..e2a1def1 100644 --- a/programs/token/src/transfer.rs +++ b/programs/token/src/transfer.rs @@ -2,25 +2,32 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, Claim}, }; -use token_core::TokenHolding; +use program_revert::UnwrapOrRevert as _; +use token_core::{error, TokenHolding}; pub fn transfer( sender: AccountWithMetadata, recipient: AccountWithMetadata, balance_to_move: u128, ) -> Vec { - assert!(sender.is_authorized, "Sender authorization is missing"); + program_revert::require!( + error::INVALID_INPUT, + sender.is_authorized, + "Sender authorization is missing" + ); - let mut sender_holding = - TokenHolding::try_from(&sender.account.data).expect("Invalid sender data"); + let mut sender_holding = TokenHolding::try_from(&sender.account.data) + .unwrap_or_revert(error::INVALID_INPUT, "Invalid sender data"); let mut recipient_holding = if recipient.account == Account::default() { TokenHolding::zeroized_clone_from(&sender_holding) } else { - TokenHolding::try_from(&recipient.account.data).expect("Invalid recipient data") + TokenHolding::try_from(&recipient.account.data) + .unwrap_or_revert(error::INVALID_INPUT, "Invalid recipient data") }; - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, sender_holding.definition_id(), recipient_holding.definition_id(), "Sender and recipient definition id mismatch" @@ -39,11 +46,11 @@ pub fn transfer( ) => { *sender_balance = sender_balance .checked_sub(balance_to_move) - .expect("Insufficient balance"); + .unwrap_or_revert(error::INSUFFICIENT_BALANCE, "Insufficient balance"); *recipient_balance = recipient_balance .checked_add(balance_to_move) - .expect("Recipient balance overflow"); + .unwrap_or_revert(error::ARITHMETIC, "Recipient balance overflow"); } ( TokenHolding::NftMaster { @@ -55,13 +62,17 @@ pub fn transfer( print_balance: recipient_print_balance, }, ) => { - assert_eq!( - *recipient_print_balance, 0, + program_revert::require_eq!( + error::INVALID_INPUT, + *recipient_print_balance, + 0, "Invalid balance in recipient account for NFT transfer" ); - assert_eq!( - *sender_print_balance, balance_to_move, + program_revert::require_eq!( + error::INVALID_INPUT, + *sender_print_balance, + balance_to_move, "Invalid balance for NFT Master transfer" ); @@ -77,14 +88,21 @@ pub fn transfer( owned: recipient_owned, }, ) => { - assert_eq!( - balance_to_move, 1, + program_revert::require_eq!( + error::INVALID_INPUT, + balance_to_move, + 1, "Invalid balance for NFT Printed Copy transfer" ); - assert!(*sender_owned, "Sender does not own the NFT Printed Copy"); + program_revert::require!( + error::INVALID_INPUT, + *sender_owned, + "Sender does not own the NFT Printed Copy" + ); - assert!( + program_revert::require!( + error::INVALID_INPUT, !*recipient_owned, "Recipient already owns the NFT Printed Copy" ); @@ -93,7 +111,10 @@ pub fn transfer( *recipient_owned = true; } _ => { - panic!("Mismatched token holding types for transfer"); + program_revert::revert!( + error::INVALID_INPUT, + "Mismatched token holding types for transfer" + ); } }; diff --git a/programs/twap_oracle/Cargo.toml b/programs/twap_oracle/Cargo.toml index a12614be..dfecd2d1 100644 --- a/programs/twap_oracle/Cargo.toml +++ b/programs/twap_oracle/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +program-revert = { path = "../common/revert" } lee_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", features = ["host"] } clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4" } twap_oracle_core = { path = "core" } diff --git a/programs/twap_oracle/core/src/error.rs b/programs/twap_oracle/core/src/error.rs new file mode 100644 index 00000000..a9f24722 --- /dev/null +++ b/programs/twap_oracle/core/src/error.rs @@ -0,0 +1,13 @@ +//! Nonzero exit codes for the twap_oracle program. +//! +//! Codes are local to this program. Keep existing values stable when adding errors. +//! Diagnostic logs identify the failed check within each category. + +use std::num::NonZeroU8; + +/// Invalid instruction, account, authorization, configuration, or operation state. +pub const INVALID_INPUT: NonZeroU8 = NonZeroU8::MIN; +/// Requested amount exceeds the available balance, debt, or collateral. +pub const INSUFFICIENT_BALANCE: NonZeroU8 = NonZeroU8::new(2).expect("nonzero error code"); +/// Requested operation exceeds the representable arithmetic range. +pub const ARITHMETIC: NonZeroU8 = NonZeroU8::new(3).expect("nonzero error code"); diff --git a/programs/twap_oracle/core/src/lib.rs b/programs/twap_oracle/core/src/lib.rs index 11f6404f..38a69ec7 100644 --- a/programs/twap_oracle/core/src/lib.rs +++ b/programs/twap_oracle/core/src/lib.rs @@ -1,3 +1,5 @@ +pub mod error; + use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ account::{AccountId, Data}, diff --git a/programs/twap_oracle/methods/guest/Cargo.lock b/programs/twap_oracle/methods/guest/Cargo.lock index faf77a62..9c07c9d1 100644 --- a/programs/twap_oracle/methods/guest/Cargo.lock +++ b/programs/twap_oracle/methods/guest/Cargo.lock @@ -1915,6 +1915,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "program-revert" +version = "0.1.0" +dependencies = [ + "risc0-zkvm", + "serde", +] + +[[package]] +name = "program-revert-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proptest" version = "1.11.0" @@ -2950,6 +2967,8 @@ dependencies = [ "borsh", "clock_core", "lee_core", + "program-revert", + "program-revert-macros", "risc0-zkvm", "serde", "spel-framework", @@ -2977,6 +2996,7 @@ version = "0.1.0" dependencies = [ "clock_core", "lee_core", + "program-revert", "twap_oracle_core", ] diff --git a/programs/twap_oracle/methods/guest/Cargo.toml b/programs/twap_oracle/methods/guest/Cargo.toml index 23f29b99..8cc9cdeb 100644 --- a/programs/twap_oracle/methods/guest/Cargo.toml +++ b/programs/twap_oracle/methods/guest/Cargo.toml @@ -14,6 +14,8 @@ name = "twap_oracle" path = "src/bin/twap_oracle.rs" [dependencies] +program-revert-macros = { path = "../../../common/revert-macros" } +program-revert = { path = "../../../common/revert" } spel-framework = { git = "https://github.com/logos-co/spel.git", rev = "1ef0500f8fc8ce3ddf95523726ae159db83f744f", package = "spel-framework" } nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4", package = "lee_core" } clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.4" } @@ -21,4 +23,4 @@ risc0-zkvm = { version = "=3.0.5", default-features = false } twap_oracle_core = { path = "../../core" } twap_oracle_program = { path = "../..", package = "twap_oracle_program" } serde = { version = "1.0", features = ["derive"] } -borsh = "1.5" \ No newline at end of file +borsh = "1.5" diff --git a/programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs b/programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs index 1cdfdef9..98a9850e 100644 --- a/programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs +++ b/programs/twap_oracle/methods/guest/src/bin/twap_oracle.rs @@ -5,8 +5,9 @@ use spel_framework::context::ProgramContext; use spel_framework::prelude::*; #[cfg(not(test))] -risc0_zkvm::guest::entry!(main); +risc0_zkvm::guest::entry!(metered_main); +#[program_revert_macros::metered_entry(twap_oracle_core::error::INVALID_INPUT)] #[lez_program(instruction = "twap_oracle_core::Instruction")] mod twap_oracle { #[allow(unused_imports)] diff --git a/programs/twap_oracle/src/create_current_tick_account.rs b/programs/twap_oracle/src/create_current_tick_account.rs index 54889b93..9582d110 100644 --- a/programs/twap_oracle/src/create_current_tick_account.rs +++ b/programs/twap_oracle/src/create_current_tick_account.rs @@ -4,7 +4,7 @@ use lee_core::{ program::{AccountPostState, Claim, ProgramId}, }; use twap_oracle_core::{ - compute_current_tick_account_pda, compute_current_tick_account_pda_seed, price_to_tick, + compute_current_tick_account_pda, compute_current_tick_account_pda_seed, error, price_to_tick, CurrentTickAccount, }; @@ -19,8 +19,8 @@ use twap_oracle_core::{ /// The timestamp is taken from `clock`, which must be [`CLOCK_01_PROGRAM_ACCOUNT_ID`]; it is never /// caller-supplied, so it cannot be forged. /// -/// # Panics -/// Panics if: +/// # Failures +/// Reverts in the zkVM (panics on native targets) if: /// - `current_tick_account.account_id` does not match /// `compute_current_tick_account_pda(oracle_program_id, price_source.account_id)`. /// - `current_tick_account.account` is not the default (already initialised). @@ -34,22 +34,27 @@ pub fn create_current_tick_account( oracle_program_id: ProgramId, ) -> Vec { let price_source_id = price_source.account_id; - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account_id, compute_current_tick_account_pda(oracle_program_id, price_source_id), "CreateCurrentTickAccount: current tick account ID does not match expected PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account, Account::default(), "CreateCurrentTickAccount: current tick account must be uninitialized" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, price_source.is_authorized, "CreateCurrentTickAccount: price source account must be authorized" ); - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "CreateCurrentTickAccount: clock account must be the canonical 1-block LEZ clock account" ); diff --git a/programs/twap_oracle/src/create_oracle_price_account.rs b/programs/twap_oracle/src/create_oracle_price_account.rs index d2a37f8b..00b5d4f8 100644 --- a/programs/twap_oracle/src/create_oracle_price_account.rs +++ b/programs/twap_oracle/src/create_oracle_price_account.rs @@ -4,8 +4,8 @@ use lee_core::{ program::{AccountPostState, Claim, ProgramId}, }; use twap_oracle_core::{ - compute_oracle_price_account_pda, compute_oracle_price_account_pda_seed, OraclePriceAccount, - OBSERVATIONS_CAPACITY, + compute_oracle_price_account_pda, compute_oracle_price_account_pda_seed, error, + OraclePriceAccount, OBSERVATIONS_CAPACITY, }; /// Creates and initialises an [`OraclePriceAccount`] for a price source account and time window. @@ -26,8 +26,8 @@ use twap_oracle_core::{ /// `price_source.account_id` and `window_duration`, so whoever controls the price source /// controls this account. /// -/// # Panics -/// Panics if: +/// # Failures +/// Reverts in the zkVM (panics on native targets) if: /// - `oracle_price_account.account_id` does not match /// `compute_oracle_price_account_pda(oracle_program_id, price_source.account_id, /// window_duration)`. @@ -57,25 +57,30 @@ pub fn create_oracle_price_account( oracle_program_id: ProgramId, ) -> Vec { let price_source_id = price_source.account_id; - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, oracle_price_account.account_id, compute_oracle_price_account_pda(oracle_program_id, price_source_id, window_duration), "CreateOraclePriceAccount: oracle price account ID does not match expected PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, oracle_price_account.account, Account::default(), "CreateOraclePriceAccount: oracle price account must be uninitialized" ); - assert!( + program_revert::require!(error::INVALID_INPUT, price_source.is_authorized, "CreateOraclePriceAccount: price source account must be authorized (caller must control it via a PDA)" ); - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "CreateOraclePriceAccount: clock account must be the canonical 1-block LEZ clock account" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, window_duration >= u64::from(OBSERVATIONS_CAPACITY), "CreateOraclePriceAccount: window_duration must be >= OBSERVATIONS_CAPACITY so a matching \ PriceObservations account can exist and PublishPrice can update this price account" @@ -83,11 +88,13 @@ pub fn create_oracle_price_account( let timestamp = ClockAccountData::from_bytes(clock.account.data.as_ref()).timestamp; - assert!( + program_revert::require!( + error::INVALID_INPUT, initial_price != 0, "CreateOraclePriceAccount: initial price must be non-zero" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, timestamp != 0, "CreateOraclePriceAccount: clock timestamp must be non-zero" ); diff --git a/programs/twap_oracle/src/create_price_observations.rs b/programs/twap_oracle/src/create_price_observations.rs index c9c22a80..815350c5 100644 --- a/programs/twap_oracle/src/create_price_observations.rs +++ b/programs/twap_oracle/src/create_price_observations.rs @@ -4,7 +4,7 @@ use lee_core::{ program::{AccountPostState, Claim, ProgramId}, }; use twap_oracle_core::{ - compute_price_observations_pda, compute_price_observations_pda_seed, ObservationEntry, + compute_price_observations_pda, compute_price_observations_pda_seed, error, ObservationEntry, PriceObservations, OBSERVATIONS_CAPACITY, }; @@ -18,8 +18,8 @@ use twap_oracle_core::{ /// LEZ system clock ([`CLOCK_01_PROGRAM_ACCOUNT_ID`]). Enforcing this prevents a caller from /// supplying an account they control to seed the TWAP with a forged base timestamp. /// -/// # Panics -/// Panics if: +/// # Failures +/// Reverts in the zkVM (panics on native targets) if: /// - `price_observations.account_id` does not match /// `compute_price_observations_pda(oracle_program_id, price_source.account_id, window_duration)`. /// - `price_observations.account` is not the default (already initialised). @@ -37,25 +37,29 @@ pub fn create_price_observations( oracle_program_id: ProgramId, ) -> Vec { let price_source_id = price_source.account_id; - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, price_observations.account_id, compute_price_observations_pda(oracle_program_id, price_source_id, window_duration), "CreatePriceObservations: price observations account ID does not match expected PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, price_observations.account, Account::default(), "CreatePriceObservations: price observations account must be uninitialized" ); - assert!( + program_revert::require!(error::INVALID_INPUT, price_source.is_authorized, "CreatePriceObservations: price source account must be authorized (caller must control it via a PDA)" ); - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "CreatePriceObservations: clock account must be the canonical 1-block LEZ clock account" ); - assert!( + program_revert::require!(error::INVALID_INPUT, window_duration >= u64::from(OBSERVATIONS_CAPACITY), "CreatePriceObservations: window_duration must be >= OBSERVATIONS_CAPACITY so the RecordTick \ sampling interval (window_duration / OBSERVATIONS_CAPACITY) is at least one millisecond" diff --git a/programs/twap_oracle/src/publish_price.rs b/programs/twap_oracle/src/publish_price.rs index 655e31bb..4118bb16 100644 --- a/programs/twap_oracle/src/publish_price.rs +++ b/programs/twap_oracle/src/publish_price.rs @@ -3,10 +3,11 @@ use lee_core::{ account::{AccountId, AccountWithMetadata, Data}, program::{AccountPostState, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use twap_oracle_core::{ compute_current_tick_account_pda, compute_oracle_price_account_pda, - compute_price_observations_pda, tick_to_oracle_price, CurrentTickAccount, OraclePriceAccount, - PriceObservations, MAX_TICK_DELTA, OBSERVATIONS_CAPACITY, + compute_price_observations_pda, error, tick_to_oracle_price, CurrentTickAccount, + OraclePriceAccount, PriceObservations, MAX_TICK_DELTA, OBSERVATIONS_CAPACITY, }; /// Computes the TWAP over the span from the oldest stored observation up to `now` and writes the @@ -43,8 +44,8 @@ use twap_oracle_core::{ /// It becomes the consumer-facing freshness signal on the [`OraclePriceAccount`], so a forged or /// caller-controlled clock could make a stale price look current; it must never be caller-supplied. /// -/// # Panics -/// Panics if: +/// # Failures +/// Reverts in the zkVM (panics on native targets) if: /// - `price_observations.account_id` does not match /// `compute_price_observations_pda(oracle_program_id, price_source_id, window_duration)`. /// - `oracle_price_account.account_id` does not match @@ -62,23 +63,28 @@ pub fn publish_price( window_duration: u64, oracle_program_id: ProgramId, ) -> Vec { - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, price_observations.account_id, compute_price_observations_pda(oracle_program_id, price_source_id, window_duration), "PublishPrice: price observations account ID does not match expected PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, oracle_price_account.account_id, compute_oracle_price_account_pda(oracle_program_id, price_source_id, window_duration), "PublishPrice: oracle price account ID does not match expected PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account_id, compute_current_tick_account_pda(oracle_program_id, price_source_id), "PublishPrice: current tick account ID does not match expected PDA" ); - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "PublishPrice: clock account must be the canonical 1-block LEZ clock account" ); @@ -86,11 +92,20 @@ pub fn publish_price( let now = clock_data.timestamp; let observations = PriceObservations::try_from(&price_observations.account.data) - .expect("PublishPrice: price observations account must be initialized"); + .unwrap_or_revert( + error::INVALID_INPUT, + "PublishPrice: price observations account must be initialized", + ); let mut price_account = OraclePriceAccount::try_from(&oracle_price_account.account.data) - .expect("PublishPrice: oracle price account must be initialized"); + .unwrap_or_revert( + error::INVALID_INPUT, + "PublishPrice: oracle price account must be initialized", + ); let current_tick_data = CurrentTickAccount::try_from(¤t_tick_account.account.data) - .expect("PublishPrice: current tick account must be initialized"); + .unwrap_or_revert( + error::INVALID_INPUT, + "PublishPrice: current tick account must be initialized", + ); // No-op: need at least two observations to compute a TWAP. if observations.total_entries < 2 { @@ -162,30 +177,42 @@ pub fn publish_price( let boundary = current_tick_data.last_updated.clamp(t2.timestamp, now); let pre_ms = boundary.saturating_sub(t2.timestamp); let post_ms = now.saturating_sub(boundary); - let pre_ms_i64 = i64::try_from(pre_ms).expect("pre_ms fits in i64"); - let post_ms_i64 = i64::try_from(post_ms).expect("post_ms fits in i64"); + let pre_ms_i64 = + i64::try_from(pre_ms).unwrap_or_revert(error::ARITHMETIC, "pre_ms fits in i64"); + let post_ms_i64 = + i64::try_from(post_ms).unwrap_or_revert(error::ARITHMETIC, "post_ms fits in i64"); // Pre-boundary segment carries `last_recorded_tick` (the tick at t2); post-boundary segment // carries the clamped live tick. let pre_cumulative = i64::from(observations.last_recorded_tick) .checked_mul(pre_ms_i64) - .expect("pre-boundary tail cumulative fits in i64"); + .unwrap_or_revert( + error::ARITHMETIC, + "pre-boundary tail cumulative fits in i64", + ); let post_cumulative = i64::from(clamped_tick) .checked_mul(post_ms_i64) - .expect("post-boundary tail cumulative fits in i64"); + .unwrap_or_revert( + error::ARITHMETIC, + "post-boundary tail cumulative fits in i64", + ); let cum_now = t2 .tick_cumulative .checked_add(pre_cumulative) .and_then(|acc| acc.checked_add(post_cumulative)) - .expect("extrapolated tick_cumulative fits in i64"); + .unwrap_or_revert( + error::ARITHMETIC, + "extrapolated tick_cumulative fits in i64", + ); // Average over [t1, now]. `now > t1.timestamp` because `now >= t2.timestamp > t1.timestamp` // (the sampling guard makes stored timestamps strictly increasing), so the divisor is positive. let elapsed_ms = now.checked_sub(t1.timestamp).expect("now >= t1.timestamp"); - let elapsed_ms_i64 = i64::try_from(elapsed_ms).expect("elapsed_ms fits in i64"); + let elapsed_ms_i64 = + i64::try_from(elapsed_ms).unwrap_or_revert(error::ARITHMETIC, "elapsed_ms fits in i64"); let cumulative_diff = cum_now .checked_sub(t1.tick_cumulative) - .expect("tick_cumulative difference fits in i64"); + .unwrap_or_revert(error::ARITHMETIC, "tick_cumulative difference fits in i64"); // Floor division (round toward −∞), matching Uniswap's `OracleLibrary.consult`. The divisor is // always positive, so `div_euclid` is exactly the floor. Plain truncating division would round // toward zero, biasing negative TWAPs upward by one tick. diff --git a/programs/twap_oracle/src/record_tick.rs b/programs/twap_oracle/src/record_tick.rs index 2ef4e26a..114e0ace 100644 --- a/programs/twap_oracle/src/record_tick.rs +++ b/programs/twap_oracle/src/record_tick.rs @@ -3,8 +3,9 @@ use lee_core::{ account::{AccountId, AccountWithMetadata, Data}, program::{AccountPostState, ProgramId}, }; +use program_revert::UnwrapOrRevert as _; use twap_oracle_core::{ - compute_current_tick_account_pda, compute_price_observations_pda, CurrentTickAccount, + compute_current_tick_account_pda, compute_price_observations_pda, error, CurrentTickAccount, ObservationEntry, PriceObservations, MAX_TICK_DELTA, OBSERVATIONS_CAPACITY, }; @@ -25,8 +26,8 @@ use twap_oracle_core::{ /// advancing the accumulator. `last_recorded_tick` is updated to the raw (untruncated) tick so /// the next delta is computed from the true price position. /// -/// # Panics -/// Panics if: +/// # Failures +/// Reverts in the zkVM (panics on native targets) if: /// - `current_tick_account.account_id` does not match /// `compute_current_tick_account_pda(oracle_program_id, price_source_id)`. /// - `price_observations.account_id` does not match @@ -41,18 +42,22 @@ pub fn record_tick( window_duration: u64, oracle_program_id: ProgramId, ) -> Vec { - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account_id, compute_current_tick_account_pda(oracle_program_id, price_source_id), "RecordTick: current tick account ID does not match expected PDA" ); - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, price_observations.account_id, compute_price_observations_pda(oracle_program_id, price_source_id, window_duration), "RecordTick: price observations account ID does not match expected PDA" ); - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "RecordTick: clock account must be the canonical 1-block LEZ clock account" ); @@ -60,10 +65,16 @@ pub fn record_tick( let now = clock_data.timestamp; let current_tick_data = CurrentTickAccount::try_from(¤t_tick_account.account.data) - .expect("RecordTick: current tick account must be initialized"); + .unwrap_or_revert( + error::INVALID_INPUT, + "RecordTick: current tick account must be initialized", + ); let mut observations = PriceObservations::try_from(&price_observations.account.data) - .expect("RecordTick: price observations account must be initialized"); + .unwrap_or_revert( + error::INVALID_INPUT, + "RecordTick: price observations account must be initialized", + ); let capacity = usize::try_from(OBSERVATIONS_CAPACITY).expect("OBSERVATIONS_CAPACITY fits in usize"); @@ -114,11 +125,12 @@ pub fn record_tick( .saturating_add(clamped_delta); // Advance cumulative (tick × elapsed milliseconds). - let elapsed_ms_i64 = i64::try_from(elapsed_ms).expect("elapsed_ms fits in i64"); + let elapsed_ms_i64 = + i64::try_from(elapsed_ms).unwrap_or_revert(error::ARITHMETIC, "elapsed_ms fits in i64"); let new_cumulative = i64::from(clamped_tick) .checked_mul(elapsed_ms_i64) .and_then(|product| last_cumulative.checked_add(product)) - .expect("tick_cumulative fits in i64"); + .unwrap_or_revert(error::ARITHMETIC, "tick_cumulative fits in i64"); // Write new entry and advance the ring buffer. let write_index = usize::try_from(observations.write_index).expect("write_index fits in usize"); @@ -138,7 +150,7 @@ pub fn record_tick( observations.total_entries = observations .total_entries .checked_add(1) - .expect("total_entries does not overflow"); + .unwrap_or_revert(error::ARITHMETIC, "total_entries does not overflow"); observations.last_recorded_tick = current_tick; let mut price_observations_post = price_observations.account.clone(); diff --git a/programs/twap_oracle/src/update_current_tick.rs b/programs/twap_oracle/src/update_current_tick.rs index c4317cf4..33dcfd1d 100644 --- a/programs/twap_oracle/src/update_current_tick.rs +++ b/programs/twap_oracle/src/update_current_tick.rs @@ -3,7 +3,10 @@ use lee_core::{ account::{AccountWithMetadata, Data}, program::{AccountPostState, ProgramId}, }; -use twap_oracle_core::{compute_current_tick_account_pda, price_to_tick, CurrentTickAccount}; +use program_revert::UnwrapOrRevert as _; +use twap_oracle_core::{ + compute_current_tick_account_pda, error, price_to_tick, CurrentTickAccount, +}; /// Updates the tick stored in an existing [`CurrentTickAccount`] from a new spot price. /// @@ -13,8 +16,8 @@ use twap_oracle_core::{compute_current_tick_account_pda, price_to_tick, CurrentT /// The timestamp is taken from `clock`, which must be [`CLOCK_01_PROGRAM_ACCOUNT_ID`]; it is never /// caller-supplied, so it cannot be forged. /// -/// # Panics -/// Panics if: +/// # Failures +/// Reverts in the zkVM (panics on native targets) if: /// - `current_tick_account.account_id` does not match /// `compute_current_tick_account_pda(oracle_program_id, price_source.account_id)`. /// - `current_tick_account.account` is not a valid, initialised [`CurrentTickAccount`]. @@ -28,22 +31,29 @@ pub fn update_current_tick( oracle_program_id: ProgramId, ) -> Vec { let price_source_id = price_source.account_id; - assert_eq!( + program_revert::require_eq!( + error::INVALID_INPUT, current_tick_account.account_id, compute_current_tick_account_pda(oracle_program_id, price_source_id), "UpdateCurrentTick: current tick account ID does not match expected PDA" ); - assert!( + program_revert::require!( + error::INVALID_INPUT, price_source.is_authorized, "UpdateCurrentTick: price source account must be authorized" ); - assert_eq!( - clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + program_revert::require_eq!( + error::INVALID_INPUT, + clock.account_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, "UpdateCurrentTick: clock account must be the canonical 1-block LEZ clock account" ); let mut stored = CurrentTickAccount::try_from(¤t_tick_account.account.data) - .expect("UpdateCurrentTick: current tick account must be initialized"); + .unwrap_or_revert( + error::INVALID_INPUT, + "UpdateCurrentTick: current tick account must be initialized", + ); let clock_data = ClockAccountData::from_bytes(clock.account.data.as_ref()); stored.tick = price_to_tick(price);