From d68b2ccfdf68dcc5de3062461853ac45597e8044 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Mon, 7 Sep 2026 12:23:21 -0300 Subject: [PATCH] fix: meter expected program failures with nonzero exits Replace expected rejection panics across all five programs with explicit nonzero guest exits. Preserve native diagnostics and internal invariant panics, and meter SPEL dispatch and validation failures. Bound instruction string and byte-buffer lengths before allocation so malformed input retains the executor session. Add compatibility and executor regressions for retained cycles, empty revert journals, chained failures, and successful output. Refs #356. --- Cargo.lock | 28 ++ Cargo.toml | 4 +- programs/amm/Cargo.toml | 1 + programs/amm/core/Cargo.toml | 1 + programs/amm/core/src/error.rs | 13 + programs/amm/core/src/lib.rs | 67 +++- programs/amm/methods/guest/Cargo.lock | 22 ++ programs/amm/methods/guest/Cargo.toml | 4 +- programs/amm/methods/guest/src/bin/amm.rs | 26 +- programs/amm/src/add.rs | 132 +++++-- .../amm/src/create_oracle_price_account.rs | 39 +- programs/amm/src/create_price_observations.rs | 44 ++- programs/amm/src/initialize.rs | 12 +- programs/amm/src/new_definition.rs | 70 ++-- programs/amm/src/remove.rs | 123 ++++-- programs/amm/src/swap.rs | 198 +++++++--- programs/amm/src/sync.rs | 47 ++- programs/amm/src/update_config.rs | 25 +- programs/ata/Cargo.toml | 1 + programs/ata/core/Cargo.toml | 1 + programs/ata/core/src/error.rs | 13 + programs/ata/core/src/lib.rs | 8 +- programs/ata/methods/guest/Cargo.lock | 22 ++ programs/ata/methods/guest/Cargo.toml | 4 +- programs/ata/methods/guest/src/bin/ata.rs | 3 +- programs/ata/src/burn.rs | 28 +- programs/ata/src/create.rs | 21 +- programs/ata/src/transfer.rs | 33 +- programs/common/revert-macros/Cargo.toml | 15 + programs/common/revert-macros/src/lib.rs | 208 +++++++++++ programs/common/revert/Cargo.toml | 14 + programs/common/revert/src/decode.rs | 264 +++++++++++++ programs/common/revert/src/lib.rs | 108 ++++++ programs/common/revert/tests/behavior.rs | 69 ++++ programs/common/revert/tests/decode.rs | 189 ++++++++++ programs/integration_tests/Cargo.toml | 2 + .../tests/metered_reverts.rs | 351 ++++++++++++++++++ programs/stablecoin/Cargo.toml | 1 + programs/stablecoin/core/Cargo.toml | 1 + programs/stablecoin/core/src/error.rs | 13 + programs/stablecoin/core/src/lib.rs | 30 +- programs/stablecoin/core/src/math.rs | 25 +- programs/stablecoin/methods/guest/Cargo.lock | 22 ++ programs/stablecoin/methods/guest/Cargo.toml | 4 +- .../methods/guest/src/bin/stablecoin.rs | 19 +- .../stablecoin/src/accrue_stability_fee.rs | 45 ++- programs/stablecoin/src/initialize_program.rs | 115 ++++-- programs/stablecoin/src/open_position.rs | 41 +- programs/stablecoin/src/refresh_globals.rs | 11 +- programs/stablecoin/src/repay_debt.rs | 62 +++- .../stablecoin/src/update_redemption_rate.rs | 67 ++-- .../stablecoin/src/withdraw_collateral.rs | 73 ++-- programs/token/Cargo.toml | 1 + programs/token/core/Cargo.toml | 1 + programs/token/core/src/error.rs | 13 + programs/token/core/src/lib.rs | 13 +- programs/token/methods/guest/Cargo.lock | 21 ++ programs/token/methods/guest/Cargo.toml | 4 +- programs/token/methods/guest/src/bin/token.rs | 3 +- programs/token/src/burn.rs | 43 ++- programs/token/src/initialize.rs | 17 +- programs/token/src/mint.rs | 48 ++- programs/token/src/new_definition.rs | 35 +- programs/token/src/print_nft.rs | 24 +- programs/token/src/set_authority.rs | 36 +- programs/token/src/transfer.rs | 55 ++- programs/twap_oracle/Cargo.toml | 1 + programs/twap_oracle/core/src/error.rs | 13 + programs/twap_oracle/core/src/lib.rs | 2 + programs/twap_oracle/methods/guest/Cargo.lock | 20 + programs/twap_oracle/methods/guest/Cargo.toml | 4 +- .../methods/guest/src/bin/twap_oracle.rs | 3 +- .../src/create_current_tick_account.rs | 21 +- .../src/create_oracle_price_account.rs | 31 +- .../src/create_price_observations.rs | 22 +- programs/twap_oracle/src/publish_price.rs | 65 +++- programs/twap_oracle/src/record_tick.rs | 36 +- .../twap_oracle/src/update_current_tick.rs | 26 +- 78 files changed, 2721 insertions(+), 576 deletions(-) create mode 100644 programs/amm/core/src/error.rs create mode 100644 programs/ata/core/src/error.rs create mode 100644 programs/common/revert-macros/Cargo.toml create mode 100644 programs/common/revert-macros/src/lib.rs create mode 100644 programs/common/revert/Cargo.toml create mode 100644 programs/common/revert/src/decode.rs create mode 100644 programs/common/revert/src/lib.rs create mode 100644 programs/common/revert/tests/behavior.rs create mode 100644 programs/common/revert/tests/decode.rs create mode 100644 programs/integration_tests/tests/metered_reverts.rs create mode 100644 programs/stablecoin/core/src/error.rs create mode 100644 programs/token/core/src/error.rs create mode 100644 programs/twap_oracle/core/src/error.rs 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);