From d70248f5a7bc1c6ff62ad5c19e1dfda070abda17 Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Wed, 5 Nov 2025 10:15:06 -0800 Subject: [PATCH 01/68] Initial sanity checks --- certora/.gitignore | 6 ++++++ certora/spec/Move.toml | 11 ++++++++++ certora/spec/Spec.conf | 9 +++++++++ .../spec/sources/liquid_staking_sanity.move | 20 +++++++++++++++++++ 4 files changed, 46 insertions(+) create mode 100644 certora/.gitignore create mode 100644 certora/spec/Move.toml create mode 100644 certora/spec/Spec.conf create mode 100644 certora/spec/sources/liquid_staking_sanity.move diff --git a/certora/.gitignore b/certora/.gitignore new file mode 100644 index 0000000..c0e52d9 --- /dev/null +++ b/certora/.gitignore @@ -0,0 +1,6 @@ +# certora +.certora_internal +.venv/ +emv-*/ +build/ +Move.lock diff --git a/certora/spec/Move.toml b/certora/spec/Move.toml new file mode 100644 index 0000000..ae0f337 --- /dev/null +++ b/certora/spec/Move.toml @@ -0,0 +1,11 @@ +[package] +name = "spec" +edition = "2024.beta" + +[dependencies] +liquid_staking = { local = "../../contracts" } +cvlm = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "cvlm", rev = "main" } +certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "main" } + +[addresses] +spec = "0x0" \ No newline at end of file diff --git a/certora/spec/Spec.conf b/certora/spec/Spec.conf new file mode 100644 index 0000000..63816ff --- /dev/null +++ b/certora/spec/Spec.conf @@ -0,0 +1,9 @@ +{ + "optimistic_loop": true, + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-tacDumpsWithInternalFunctions true" + ] +} \ No newline at end of file diff --git a/certora/spec/sources/liquid_staking_sanity.move b/certora/spec/sources/liquid_staking_sanity.move new file mode 100644 index 0000000..2eca880 --- /dev/null +++ b/certora/spec/sources/liquid_staking_sanity.move @@ -0,0 +1,20 @@ +module spec::liquid_staking_sanity; + +use cvlm::manifest::{ target, target_sanity }; + +public fun cvlm_manifest() { + target(@liquid_staking, b"liquid_staking", b"create_lst"); + target(@liquid_staking, b"liquid_staking", b"create_lst_with_stake"); + target(@liquid_staking, b"liquid_staking", b"mint"); + target(@liquid_staking, b"liquid_staking", b"redeem"); + target(@liquid_staking, b"liquid_staking", b"custom_redeem_request"); + target(@liquid_staking, b"liquid_staking", b"custom_redeem"); + target(@liquid_staking, b"liquid_staking", b"change_validator_priority"); + target(@liquid_staking, b"liquid_staking", b"increase_validator_stake"); + target(@liquid_staking, b"liquid_staking", b"decrease_validator_stake"); + target(@liquid_staking, b"liquid_staking", b"collect_fees"); + target(@liquid_staking, b"liquid_staking", b"update_fees"); + target(@liquid_staking, b"liquid_staking", b"refresh"); + target(@liquid_staking, b"liquid_staking", b"update_metadata"); + target_sanity(); +} \ No newline at end of file From 213e7101a424ae6863d629432973014cacc6628a Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Fri, 7 Nov 2025 15:59:15 -0800 Subject: [PATCH 02/68] WIP --- certora/spec/Spec.conf | 6 +- .../sources/liquid_staking_summaries.move | 11 +++ .../spec/sources/sui_system_summaries.move | 73 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 certora/spec/sources/liquid_staking_summaries.move create mode 100644 certora/spec/sources/sui_system_summaries.move diff --git a/certora/spec/Spec.conf b/certora/spec/Spec.conf index 63816ff..07c05bc 100644 --- a/certora/spec/Spec.conf +++ b/certora/spec/Spec.conf @@ -4,6 +4,10 @@ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", "-maxMergedBranchSize 1000000", - "-tacDumpsWithInternalFunctions true" + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true", ] } \ No newline at end of file diff --git a/certora/spec/sources/liquid_staking_summaries.move b/certora/spec/sources/liquid_staking_summaries.move new file mode 100644 index 0000000..9cba4db --- /dev/null +++ b/certora/spec/sources/liquid_staking_summaries.move @@ -0,0 +1,11 @@ +module spec::liquid_staking_summaries; + +use cvlm::manifest::{ summary, ghost }; +use sui_system::staking_pool::PoolTokenExchangeRate; + +public fun cvlm_manifest() { + summary(b"get_sui_amount", @liquid_staking, b"storage", b"get_sui_amount"); + ghost(b"get_sui_amount"); +} + +public native fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64; diff --git a/certora/spec/sources/sui_system_summaries.move b/certora/spec/sources/sui_system_summaries.move new file mode 100644 index 0000000..0b9ea54 --- /dev/null +++ b/certora/spec/sources/sui_system_summaries.move @@ -0,0 +1,73 @@ +module spec::sui_system_summaries; + +use cvlm::manifest::{ summary, ghost }; +use sui_system::sui_system::SuiSystemState; +use sui_system::sui_system_state_inner::SuiSystemStateInnerV2; +use sui_system::staking_pool::PoolTokenExchangeRate; + +public fun cvlm_manifest() { + summary(b"load_inner_maybe_upgrade", @sui_system, b"sui_system", b"load_inner_maybe_upgrade"); + ghost(b"the_system_state_inner_v2"); + + summary(b"get_sui_amount", @sui_system, b"staking_pool", b"get_sui_amount"); + ghost(b"get_sui_amount"); + + summary(b"get_token_amount", @sui_system, b"staking_pool", b"get_token_amount"); + ghost(b"get_token_amount"); + + summary( + b"calculate_fungible_staked_sui_withdraw_amount", + @sui_system, + b"staking_pool", + b"calculate_fungible_staked_sui_withdraw_amount" + ); + ghost(b"calculate_fungible_staked_sui_withdraw_amount_principal"); + ghost(b"calculate_fungible_staked_sui_withdraw_amount_rewards"); +} + +native fun the_system_state_inner_v2(): &mut SuiSystemStateInnerV2; + +public fun load_inner_maybe_upgrade(_self: &mut SuiSystemState): &mut SuiSystemStateInnerV2 { + // SuiSystemState is a singleton, so we can just always return the same SuiSystemStateInnerV2 instance + the_system_state_inner_v2() +} + +public native fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64; +public native fun get_token_amount(exchange_rate: &PoolTokenExchangeRate, sui_amount: u64): u64; + + +public fun calculate_fungible_staked_sui_withdraw_amount( + latest_exchange_rate: PoolTokenExchangeRate, + fungible_staked_sui_value: u64, + fungible_staked_sui_data_principal_amount: u64, + fungible_staked_sui_data_total_supply: u64, +): (u64, u64) { + ( + calculate_fungible_staked_sui_withdraw_amount_principal( + latest_exchange_rate, + fungible_staked_sui_value, + fungible_staked_sui_data_principal_amount, + fungible_staked_sui_data_total_supply, + ), + calculate_fungible_staked_sui_withdraw_amount_rewards( + latest_exchange_rate, + fungible_staked_sui_value, + fungible_staked_sui_data_principal_amount, + fungible_staked_sui_data_total_supply, + ), + ) +} + +native fun calculate_fungible_staked_sui_withdraw_amount_principal( + latest_exchange_rate: PoolTokenExchangeRate, + fungible_staked_sui_value: u64, + fungible_staked_sui_data_principal_amount: u64, + fungible_staked_sui_data_total_supply: u64, +): u64; + +native fun calculate_fungible_staked_sui_withdraw_amount_rewards( + latest_exchange_rate: PoolTokenExchangeRate, + fungible_staked_sui_value: u64, + fungible_staked_sui_data_principal_amount: u64, + fungible_staked_sui_data_total_supply: u64, +): u64; \ No newline at end of file From 3ec9bd534c201f2dd2f024a0b20ef530a9597766 Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Tue, 11 Nov 2025 11:48:15 -0800 Subject: [PATCH 03/68] get_sui_amount equlivalence --- certora/assumptions/Assumptions.conf | 13 ++++++++ certora/assumptions/Move.toml | 11 +++++++ certora/assumptions/sources/assumptions.move | 32 +++++++++++++++++++ .../sources/liquid_staking_summaries.move | 7 ++-- 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 certora/assumptions/Assumptions.conf create mode 100644 certora/assumptions/Move.toml create mode 100644 certora/assumptions/sources/assumptions.move diff --git a/certora/assumptions/Assumptions.conf b/certora/assumptions/Assumptions.conf new file mode 100644 index 0000000..07c05bc --- /dev/null +++ b/certora/assumptions/Assumptions.conf @@ -0,0 +1,13 @@ +{ + "optimistic_loop": true, + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true", + ] +} \ No newline at end of file diff --git a/certora/assumptions/Move.toml b/certora/assumptions/Move.toml new file mode 100644 index 0000000..6435323 --- /dev/null +++ b/certora/assumptions/Move.toml @@ -0,0 +1,11 @@ +[package] +name = "assumptions" +edition = "2024.beta" + +[dependencies] +liquid_staking = { local = "../../contracts" } +cvlm = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "cvlm", rev = "eric/functionAccess" } +certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "eric/functionAccess" } + +[addresses] +assumptions = "0x0" \ No newline at end of file diff --git a/certora/assumptions/sources/assumptions.move b/certora/assumptions/sources/assumptions.move new file mode 100644 index 0000000..0b7414b --- /dev/null +++ b/certora/assumptions/sources/assumptions.move @@ -0,0 +1,32 @@ +module assumptions::assumptions; + +/* + Here we validate the assumptions we make in the main spec's summaries + */ + +use cvlm::manifest::{rule, function_access}; +use cvlm::asserts::cvlm_assert; +use sui_system::staking_pool::PoolTokenExchangeRate; + +public fun cvlm_manifest() { + rule(b"get_sui_amount_equivalence"); + + function_access(b"ls_get_sui_amount", @liquid_staking, b"storage", b"get_sui_amount"); + function_access(b"ss_get_sui_amount", @sui_system, b"staking_pool", b"get_sui_amount"); +} + +// private function accessors +native fun ls_get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64; +native fun ss_get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64; + +// The main spec's summaries assume that liquid_staking::storage::get_sui_amount is equivalent to +// sui_system::staking_pool::get_sui_amount. We validate that assumption here. +public fun get_sui_amount_equivalence( + exchange_rate: &PoolTokenExchangeRate, + token_amount: u64 +) { + let ls_amount = ls_get_sui_amount(exchange_rate, token_amount); + let ss_amount = ss_get_sui_amount(exchange_rate, token_amount); + cvlm_assert(ls_amount == ss_amount); +} + diff --git a/certora/spec/sources/liquid_staking_summaries.move b/certora/spec/sources/liquid_staking_summaries.move index 9cba4db..ff0677f 100644 --- a/certora/spec/sources/liquid_staking_summaries.move +++ b/certora/spec/sources/liquid_staking_summaries.move @@ -5,7 +5,10 @@ use sui_system::staking_pool::PoolTokenExchangeRate; public fun cvlm_manifest() { summary(b"get_sui_amount", @liquid_staking, b"storage", b"get_sui_amount"); - ghost(b"get_sui_amount"); } -public native fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64; +// We assume that liquid_staking::storage::get_sui_amount is equivalent to sui_system::staking_pool::get_sui_amount +// This is validated in the "assumptions" spec +public native fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64 { + spec::sui_system_summaries::get_sui_amount(exchange_rate, token_amount) +} From d6bc353060a648a753181ef702dedc8d30c579ee Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Mon, 24 Nov 2025 06:14:28 -0800 Subject: [PATCH 04/68] WIP --- certora/assumptions/Move.toml | 4 ++-- certora/assumptions/sources/assumptions.move | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/certora/assumptions/Move.toml b/certora/assumptions/Move.toml index 6435323..28f2d82 100644 --- a/certora/assumptions/Move.toml +++ b/certora/assumptions/Move.toml @@ -4,8 +4,8 @@ edition = "2024.beta" [dependencies] liquid_staking = { local = "../../contracts" } -cvlm = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "cvlm", rev = "eric/functionAccess" } -certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "eric/functionAccess" } +cvlm = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "cvlm", rev = "main" } +certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "main" } [addresses] assumptions = "0x0" \ No newline at end of file diff --git a/certora/assumptions/sources/assumptions.move b/certora/assumptions/sources/assumptions.move index 0b7414b..80b9933 100644 --- a/certora/assumptions/sources/assumptions.move +++ b/certora/assumptions/sources/assumptions.move @@ -19,7 +19,7 @@ public fun cvlm_manifest() { native fun ls_get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64; native fun ss_get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64; -// The main spec's summaries assume that liquid_staking::storage::get_sui_amount is equivalent to +// The main suilend spec's summaries assume that liquid_staking::storage::get_sui_amount is equivalent to // sui_system::staking_pool::get_sui_amount. We validate that assumption here. public fun get_sui_amount_equivalence( exchange_rate: &PoolTokenExchangeRate, From eddfdf4b757aa763e7fd2b6002486206408a7764 Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Mon, 24 Nov 2025 06:27:23 -0800 Subject: [PATCH 05/68] WIP --- certora/spec/sources/liquid_staking_summaries.move | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/certora/spec/sources/liquid_staking_summaries.move b/certora/spec/sources/liquid_staking_summaries.move index ff0677f..0a90377 100644 --- a/certora/spec/sources/liquid_staking_summaries.move +++ b/certora/spec/sources/liquid_staking_summaries.move @@ -1,6 +1,6 @@ module spec::liquid_staking_summaries; -use cvlm::manifest::{ summary, ghost }; +use cvlm::manifest::summary; use sui_system::staking_pool::PoolTokenExchangeRate; public fun cvlm_manifest() { @@ -9,6 +9,6 @@ public fun cvlm_manifest() { // We assume that liquid_staking::storage::get_sui_amount is equivalent to sui_system::staking_pool::get_sui_amount // This is validated in the "assumptions" spec -public native fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64 { +public fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64 { spec::sui_system_summaries::get_sui_amount(exchange_rate, token_amount) } From 81146917b3832dc6f5c91a226a39cef58d58407a Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Fri, 9 Jan 2026 10:34:46 +0100 Subject: [PATCH 06/68] fix: duplicate summary for system state --- certora/spec/sources/sui_system_summaries.move | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/certora/spec/sources/sui_system_summaries.move b/certora/spec/sources/sui_system_summaries.move index 0b9ea54..4e7bdf6 100644 --- a/certora/spec/sources/sui_system_summaries.move +++ b/certora/spec/sources/sui_system_summaries.move @@ -6,9 +6,6 @@ use sui_system::sui_system_state_inner::SuiSystemStateInnerV2; use sui_system::staking_pool::PoolTokenExchangeRate; public fun cvlm_manifest() { - summary(b"load_inner_maybe_upgrade", @sui_system, b"sui_system", b"load_inner_maybe_upgrade"); - ghost(b"the_system_state_inner_v2"); - summary(b"get_sui_amount", @sui_system, b"staking_pool", b"get_sui_amount"); ghost(b"get_sui_amount"); @@ -25,13 +22,6 @@ public fun cvlm_manifest() { ghost(b"calculate_fungible_staked_sui_withdraw_amount_rewards"); } -native fun the_system_state_inner_v2(): &mut SuiSystemStateInnerV2; - -public fun load_inner_maybe_upgrade(_self: &mut SuiSystemState): &mut SuiSystemStateInnerV2 { - // SuiSystemState is a singleton, so we can just always return the same SuiSystemStateInnerV2 instance - the_system_state_inner_v2() -} - public native fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64; public native fun get_token_amount(exchange_rate: &PoolTokenExchangeRate, sui_amount: u64): u64; From 5ccf65ac0056525852f7c199659fd08b45eff0db Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Fri, 9 Jan 2026 10:35:26 +0100 Subject: [PATCH 07/68] Create dummy implementation for parametric rules --- certora/spec/sources/dummy.move | 143 ++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 certora/spec/sources/dummy.move diff --git a/certora/spec/sources/dummy.move b/certora/spec/sources/dummy.move new file mode 100644 index 0000000..dacff3a --- /dev/null +++ b/certora/spec/sources/dummy.move @@ -0,0 +1,143 @@ +module spec::dummy; + + +use liquid_staking::fees::FeeConfig; +use liquid_staking::liquid_staking::{LiquidStakingInfo, AdminCap, CustomRedeemRequest}; +use std::ascii; +use std::string::String; +use sui::coin::{Coin, CoinMetadata}; +use sui::sui::SUI; +use sui_system::sui_system::SuiSystemState; + +public struct DummyToken has drop {} + +public fun mint( + self: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + sui: Coin, + ctx: &mut TxContext, +): Coin { + liquid_staking::liquid_staking::mint(self, system_state, sui, ctx) +} + +public fun redeem( + self: &mut LiquidStakingInfo, + lst: Coin, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +): Coin { + liquid_staking::liquid_staking::redeem(self, lst, system_state, ctx) +} + +public fun custom_redeem_request( + self: &mut LiquidStakingInfo, + lst: Coin, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +): CustomRedeemRequest { + liquid_staking::liquid_staking::custom_redeem_request(self, lst, system_state, ctx) +} + +public fun custom_redeem( + self: &mut LiquidStakingInfo, + request: CustomRedeemRequest, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +): Coin { + liquid_staking::liquid_staking::custom_redeem(self, request, system_state, ctx) +} + +public fun change_validator_priority( + self: &mut LiquidStakingInfo, + cap: &AdminCap, + validator_index: u64, + new_validator_index: u64, +) { + liquid_staking::liquid_staking::change_validator_priority( + self, + cap, + validator_index, + new_validator_index, + ) +} + +public fun increase_validator_stake( + self: &mut LiquidStakingInfo, + cap: &AdminCap, + system_state: &mut SuiSystemState, + validator_address: address, + sui_amount: u64, + ctx: &mut TxContext, +): u64 { + liquid_staking::liquid_staking::increase_validator_stake( + self, + cap, + system_state, + validator_address, + sui_amount, + ctx, + ) +} + +public fun decrease_validator_stake( + self: &mut LiquidStakingInfo, + cap: &AdminCap, + system_state: &mut SuiSystemState, + validator_address: address, + target_unstake_sui_amount: u64, + ctx: &mut TxContext, +): u64 { + liquid_staking::liquid_staking::decrease_validator_stake( + self, + cap, + system_state, + validator_address, + target_unstake_sui_amount, + ctx, + ) +} + +public fun collect_fees( + self: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + _admin_cap: &AdminCap, + ctx: &mut TxContext, +): Coin { + liquid_staking::liquid_staking::collect_fees(self, system_state, _admin_cap, ctx) +} + +public fun update_fees( + self: &mut LiquidStakingInfo, + _admin_cap: &AdminCap, + fee_config: FeeConfig, +) { + liquid_staking::liquid_staking::update_fees(self, _admin_cap, fee_config) +} + +public fun refresh( + self: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +): bool { + liquid_staking::liquid_staking::refresh(self, system_state, ctx) +} + +public fun update_metadata( + self: &mut LiquidStakingInfo, + cap: &AdminCap, + metadata: &mut CoinMetadata, + name: Option, + symbol: Option, + description: Option, + icon_url: Option, +) { + liquid_staking::liquid_staking::update_metadata( + self, + cap, + metadata, + name, + symbol, + description, + icon_url, + ) +} From 3a78f53850e1395ff248a8c4ed0b704a393ec008 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Fri, 9 Jan 2026 13:14:40 +0100 Subject: [PATCH 08/68] wip: solvency --- certora/munges/fees.patch | 13 ++ certora/munges/liquid_staking.patch | 0 certora/munges/munge.sh | 3 + certora/munges/record_patches.sh | 3 + certora/munges/storage.patch | 45 +++++ certora/munges/unmunge.sh | 3 + certora/spec/confs/solvency.conf | 19 ++ .../sources/liquid_staking_summaries.move | 1 + certora/spec/sources/solvency.move | 169 ++++++++++++++++++ 9 files changed, 256 insertions(+) create mode 100644 certora/munges/fees.patch create mode 100644 certora/munges/liquid_staking.patch create mode 100755 certora/munges/munge.sh create mode 100755 certora/munges/record_patches.sh create mode 100644 certora/munges/storage.patch create mode 100755 certora/munges/unmunge.sh create mode 100644 certora/spec/confs/solvency.conf create mode 100644 certora/spec/sources/solvency.move diff --git a/certora/munges/fees.patch b/certora/munges/fees.patch new file mode 100644 index 0000000..ca83af3 --- /dev/null +++ b/certora/munges/fees.patch @@ -0,0 +1,13 @@ +diff --git a/contracts/sources/fees.move b/contracts/sources/fees.move +index ae1cdd8..dd650c1 100644 +--- a/contracts/sources/fees.move ++++ b/contracts/sources/fees.move +@@ -130,7 +130,7 @@ module liquid_staking::fees { + // Note that while it's technically exploitable, we allow lsts to be created with 0 mint/redeem fees. + // This is because having a 0 fee LST is useful in certain cases where mint/redemption can only be done by + // a single party. It is up to the pool creator to ensure that the fees are set correctly. +- fun validate_fees(fees: &FeeConfig) { ++ public fun validate_fees(fees: &FeeConfig) { + assert!(fees.sui_mint_fee_bps <= MAX_BPS, EInvalidFeeConfig); + assert!(fees.staked_sui_mint_fee_bps <= MAX_BPS, EInvalidFeeConfig); + assert!(fees.redeem_fee_bps <= MAX_REDEEM_FEE_BPS, EInvalidFeeConfig); diff --git a/certora/munges/liquid_staking.patch b/certora/munges/liquid_staking.patch new file mode 100644 index 0000000..e69de29 diff --git a/certora/munges/munge.sh b/certora/munges/munge.sh new file mode 100755 index 0000000..8987d18 --- /dev/null +++ b/certora/munges/munge.sh @@ -0,0 +1,3 @@ +git apply certora/munges/liquid_staking.patch +git apply certora/munges/fees.patch +git apply certora/munges/storage.patch \ No newline at end of file diff --git a/certora/munges/record_patches.sh b/certora/munges/record_patches.sh new file mode 100755 index 0000000..df50dd3 --- /dev/null +++ b/certora/munges/record_patches.sh @@ -0,0 +1,3 @@ +git diff HEAD:contracts/sources/liquid_staking.move contracts/sources/liquid_staking.move > certora/munges/liquid_staking.patch; +git diff HEAD:contracts/sources/fees.move contracts/sources/fees.move > certora/munges/fees.patch; +git diff HEAD:contracts/sources/storage.move contracts/sources/storage.move > certora/munges/storage.patch; \ No newline at end of file diff --git a/certora/munges/storage.patch b/certora/munges/storage.patch new file mode 100644 index 0000000..f6a5e4f --- /dev/null +++ b/certora/munges/storage.patch @@ -0,0 +1,45 @@ +diff --git a/contracts/sources/storage.move b/contracts/sources/storage.move +index d3de10c..3b499ba 100644 +--- a/contracts/sources/storage.move ++++ b/contracts/sources/storage.move +@@ -70,11 +70,11 @@ module liquid_staking::storage { + &self.validator_infos + } + +- public(package) fun total_sui_supply(self: &Storage): u64 { ++ public fun total_sui_supply(self: &Storage): u64 { + self.total_sui_supply + } + +- public(package) fun last_refresh_epoch(self: &Storage): u64 { ++ public fun last_refresh_epoch(self: &Storage): u64 { + self.last_refresh_epoch + } + +@@ -437,6 +437,8 @@ module liquid_staking::storage { + unstaked_sui_amount + } + ++ ++ fun log(_: &T) {} + // This function approximately unstakes n SUI from validators, then returns up to n SUI. + public(package) fun split_n_sui( + self: &mut Storage, +@@ -459,6 +461,8 @@ module liquid_staking::storage { + }; + }; + ++ log(self); ++ + { + let mut i = self.validators().length(); + while (i > 0 && self.sui_pool.value() < max_sui_amount_out) { +@@ -477,6 +481,8 @@ module liquid_staking::storage { + }; + }; + ++ log(self); ++ + assert!(self.sui_pool.value() >= max_sui_amount_out, ENotEnoughSuiInSuiPool); + self.split_from_sui_pool(max_sui_amount_out) + } diff --git a/certora/munges/unmunge.sh b/certora/munges/unmunge.sh new file mode 100755 index 0000000..a51feb2 --- /dev/null +++ b/certora/munges/unmunge.sh @@ -0,0 +1,3 @@ +git apply -R certora/munges/liquid_staking.patch +git apply -R certora/munges/fees.patch +git apply -R certora/munges/storage.patch \ No newline at end of file diff --git a/certora/spec/confs/solvency.conf b/certora/spec/confs/solvency.conf new file mode 100644 index 0000000..0e4f7eb --- /dev/null +++ b/certora/spec/confs/solvency.conf @@ -0,0 +1,19 @@ +{ + "optimistic_loop": true, + "server": "prover", + "prover_version": "master", + "rule": [ + "*solvency*" + ], + "msg": "Reserve solvency", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true", + ] +} \ No newline at end of file diff --git a/certora/spec/sources/liquid_staking_summaries.move b/certora/spec/sources/liquid_staking_summaries.move index 0a90377..76ad7b0 100644 --- a/certora/spec/sources/liquid_staking_summaries.move +++ b/certora/spec/sources/liquid_staking_summaries.move @@ -11,4 +11,5 @@ public fun cvlm_manifest() { // This is validated in the "assumptions" spec public fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64 { spec::sui_system_summaries::get_sui_amount(exchange_rate, token_amount) + // (exchange_rate.sui_amount() * token_amount) / exchange_rate.pool_token_amount() } diff --git a/certora/spec/sources/solvency.move b/certora/spec/sources/solvency.move new file mode 100644 index 0000000..ca9919f --- /dev/null +++ b/certora/spec/sources/solvency.move @@ -0,0 +1,169 @@ +module spec::solvency; + +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg, cvlm_assert_msg}; +use cvlm::function::Function; +use cvlm::ghost::ghost_destroy; +use cvlm::manifest::{target, invoker, rule}; +use cvlm::nondet::nondet; +use liquid_staking::liquid_staking::{Self, LiquidStakingInfo, AdminCap, CustomRedeemRequest}; +use spec::dummy::DummyToken; +use sui_system::sui_system::SuiSystemState; + +public fun cvlm_manifest() { + // Public mut functions + + target(@spec, b"dummy", b"mint"); + target(@spec, b"dummy", b"redeem"); + target(@spec, b"dummy", b"custom_redeem_request"); + target(@spec, b"dummy", b"custom_redeem"); + target(@spec, b"dummy", b"change_validator_priority"); + target(@spec, b"dummy", b"increase_validator_stake"); + target(@spec, b"dummy", b"decrease_validator_stake"); + target(@spec, b"dummy", b"collect_fees"); + target(@spec, b"dummy", b"update_fees"); + target(@spec, b"dummy", b"refresh"); + target(@spec, b"dummy", b"update_metadata"); + + invoker(b"invoke"); + + rule(b"solvency_base"); + rule(b"solvency_base_staker"); + rule(b"solvency_step"); + + rule(b"monotonicity"); + rule(b"rate_changes_only_if"); +} + +native fun invoke( + target: Function, + lis: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +); + +/// lsi.total_sui_supply()/lsi.total_lst_supply() >= 1 +/// <==> lsi.total_sui_supply() >= lsi.total_lst_supply() +fun is_solvent(lsi: &LiquidStakingInfo): bool { + let sui_supply = lsi.total_sui_supply(); + let lst_supply = lsi.total_lst_supply(); + + sui_supply >= lst_supply +} + +/// The base case for the induction. +public fun solvency_base() { + let fee_config = nondet(); + let lst_treasury_cap = nondet(); + let mut ctx = nondet(); + let (cap, lsi) = liquid_staking::create_lst

(fee_config, lst_treasury_cap, &mut ctx); + + cvlm_assert(is_solvent(&lsi)); + + ghost_destroy(cap); + ghost_destroy(lsi); +} + +/// The base case for the induction. +public fun solvency_base_staker() { + let fee_config = nondet(); + let mut system_state = nondet(); + let lst_treasury_cap = nondet(); + let mut ctx = nondet(); + let fungible_staked_suis = nondet(); + let sui = nondet(); + let (cap, lsi) = liquid_staking::create_lst_with_stake

( + &mut system_state, + fee_config, + lst_treasury_cap, + fungible_staked_suis, + sui, + &mut ctx, + ); + + cvlm_assert(is_solvent(&lsi)); + + ghost_destroy(cap); + ghost_destroy(lsi); + ghost_destroy(system_state); +} + +/// The induction steps for the solvency invariant. +public fun solvency_step( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Refresh"); + lsi.refresh(system_state, ctx); + //lsi.fee_config().validate_fees(); + cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); + + let mut ctx2: TxContext = nondet(); + cvlm_assume_msg(ctx.epoch() <= ctx2.epoch(), b"Time"); + + invoke(target, lsi, system_state, &mut ctx2); + + //lsi.refresh(system_state, ctx); + cvlm_assert(is_solvent(lsi)); +} + +public fun monotonicity( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + + cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Refresh"); + lsi.refresh(system_state, ctx); + //lsi.fee_config().validate_fees(); + cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); + + let lst_pre = lsi.total_lst_supply(); + let sui_pre = lsi.total_sui_supply(); + + + let mut ctx2: TxContext = nondet(); + cvlm_assume_msg(ctx.epoch() <= ctx2.epoch(), b"Time"); + + invoke(target, lsi, system_state, &mut ctx2); + + let lst_post = lsi.total_lst_supply(); + let sui_post = lsi.total_sui_supply(); + + // sui_pre/lst_pre <= sui_post/lst_post + // <==> sui_pre*lst_post <= sui_post*lst_pre + + cvlm_assert(sui_pre*lst_post <= sui_post*lst_pre); +} + +public fun rate_changes_only_if( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + let lst_pre = lsi.total_lst_supply(); + let sui_pre = lsi.total_sui_supply(); + + let epoch_pre = lsi.storage().last_refresh_epoch(); + cvlm_assume_msg(ctx.epoch() >= epoch_pre, b"Don't perform actions in the past"); + + invoke(target, lsi, system_state, ctx); + + let lst_post = lsi.total_lst_supply(); + let sui_post = lsi.total_sui_supply(); + + // sui_pre/lst_pre != sui_post/lst_post + // <==> sui_pre*lst_post != sui_post*lst_pre + let changed = sui_pre*lst_post != sui_post*lst_pre; + let is_refresh = target.name() == b"refresh"; + let new_epoch = epoch_pre < lsi.storage().last_refresh_epoch(); + + if (changed) { + cvlm_assert(new_epoch || is_refresh) // must be &&? + } else { + cvlm_assert(true) // please the prover + } +} From eed1d5443c45a21389a19b035c105fdb93575f38 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Fri, 9 Jan 2026 16:59:17 +0100 Subject: [PATCH 09/68] Disable system summaries --- .../sources/liquid_staking_summaries.move | 15 ----- .../spec/sources/sui_system_summaries.move | 63 ------------------- 2 files changed, 78 deletions(-) delete mode 100644 certora/spec/sources/liquid_staking_summaries.move delete mode 100644 certora/spec/sources/sui_system_summaries.move diff --git a/certora/spec/sources/liquid_staking_summaries.move b/certora/spec/sources/liquid_staking_summaries.move deleted file mode 100644 index 76ad7b0..0000000 --- a/certora/spec/sources/liquid_staking_summaries.move +++ /dev/null @@ -1,15 +0,0 @@ -module spec::liquid_staking_summaries; - -use cvlm::manifest::summary; -use sui_system::staking_pool::PoolTokenExchangeRate; - -public fun cvlm_manifest() { - summary(b"get_sui_amount", @liquid_staking, b"storage", b"get_sui_amount"); -} - -// We assume that liquid_staking::storage::get_sui_amount is equivalent to sui_system::staking_pool::get_sui_amount -// This is validated in the "assumptions" spec -public fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64 { - spec::sui_system_summaries::get_sui_amount(exchange_rate, token_amount) - // (exchange_rate.sui_amount() * token_amount) / exchange_rate.pool_token_amount() -} diff --git a/certora/spec/sources/sui_system_summaries.move b/certora/spec/sources/sui_system_summaries.move deleted file mode 100644 index 4e7bdf6..0000000 --- a/certora/spec/sources/sui_system_summaries.move +++ /dev/null @@ -1,63 +0,0 @@ -module spec::sui_system_summaries; - -use cvlm::manifest::{ summary, ghost }; -use sui_system::sui_system::SuiSystemState; -use sui_system::sui_system_state_inner::SuiSystemStateInnerV2; -use sui_system::staking_pool::PoolTokenExchangeRate; - -public fun cvlm_manifest() { - summary(b"get_sui_amount", @sui_system, b"staking_pool", b"get_sui_amount"); - ghost(b"get_sui_amount"); - - summary(b"get_token_amount", @sui_system, b"staking_pool", b"get_token_amount"); - ghost(b"get_token_amount"); - - summary( - b"calculate_fungible_staked_sui_withdraw_amount", - @sui_system, - b"staking_pool", - b"calculate_fungible_staked_sui_withdraw_amount" - ); - ghost(b"calculate_fungible_staked_sui_withdraw_amount_principal"); - ghost(b"calculate_fungible_staked_sui_withdraw_amount_rewards"); -} - -public native fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64; -public native fun get_token_amount(exchange_rate: &PoolTokenExchangeRate, sui_amount: u64): u64; - - -public fun calculate_fungible_staked_sui_withdraw_amount( - latest_exchange_rate: PoolTokenExchangeRate, - fungible_staked_sui_value: u64, - fungible_staked_sui_data_principal_amount: u64, - fungible_staked_sui_data_total_supply: u64, -): (u64, u64) { - ( - calculate_fungible_staked_sui_withdraw_amount_principal( - latest_exchange_rate, - fungible_staked_sui_value, - fungible_staked_sui_data_principal_amount, - fungible_staked_sui_data_total_supply, - ), - calculate_fungible_staked_sui_withdraw_amount_rewards( - latest_exchange_rate, - fungible_staked_sui_value, - fungible_staked_sui_data_principal_amount, - fungible_staked_sui_data_total_supply, - ), - ) -} - -native fun calculate_fungible_staked_sui_withdraw_amount_principal( - latest_exchange_rate: PoolTokenExchangeRate, - fungible_staked_sui_value: u64, - fungible_staked_sui_data_principal_amount: u64, - fungible_staked_sui_data_total_supply: u64, -): u64; - -native fun calculate_fungible_staked_sui_withdraw_amount_rewards( - latest_exchange_rate: PoolTokenExchangeRate, - fungible_staked_sui_value: u64, - fungible_staked_sui_data_principal_amount: u64, - fungible_staked_sui_data_total_supply: u64, -): u64; \ No newline at end of file From 7bcba800831b0c866ecba29b237faec5fe9c1a4c Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Fri, 9 Jan 2026 17:00:55 +0100 Subject: [PATCH 10/68] Solvency: Assume only one validator --- certora/spec/sources/solvency.move | 2 ++ 1 file changed, 2 insertions(+) diff --git a/certora/spec/sources/solvency.move b/certora/spec/sources/solvency.move index ca9919f..3859473 100644 --- a/certora/spec/sources/solvency.move +++ b/certora/spec/sources/solvency.move @@ -94,6 +94,8 @@ public fun solvency_step( system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { + cvlm_assume_msg(lsi.storage().validators().length() == 1, b"Only one validator"); + cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Refresh"); lsi.refresh(system_state, ctx); //lsi.fee_config().validate_fees(); From c6b9bebd78fecec7ad70f5136ce4f160d67e4e88 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Fri, 9 Jan 2026 17:01:12 +0100 Subject: [PATCH 11/68] Summarize exchange rate --- certora/spec/sources/summaries.move | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 certora/spec/sources/summaries.move diff --git a/certora/spec/sources/summaries.move b/certora/spec/sources/summaries.move new file mode 100644 index 0000000..c01c39f --- /dev/null +++ b/certora/spec/sources/summaries.move @@ -0,0 +1,70 @@ +module spec::summaries; + +use cvlm::manifest::summary; +use liquid_staking::storage::Storage; +use sui_system::staking_pool::PoolTokenExchangeRate; +use sui_system::sui_system::SuiSystemState; +use cvlm::nondet::nondet; +use std::option::some; +use cvlm::asserts::cvlm_assume_msg; +use cvlm::manifest::ghost; +use sui_system::validator::staking_pool_id; +use sui_system::staking_pool::StakedSui; +use sui::balance::Balance; +use sui::sui::SUI; +use sui::kiosk::withdraw; +use cvlm::ghost::ghost_destroy; +use cvlm::asserts::cvlm_assert_msg; +use sui::token::amount; +use liquid_staking::storage; +use liquid_staking::storage::get_sui_amount; +use sui_system::staking_pool::FungibleStakedSui; + +public fun cvlm_manifest() { + + ghost(b"exchange_rate"); + summary(b"get_latest_exchange_rate", @liquid_staking, b"storage", b"get_latest_exchange_rate"); + + summary(b"active_validator_addresses", @sui_system, b"sui_system", b"active_validator_addresses"); + summary(b"request_withdraw_stake_non_entry", @sui_system, b"sui_system", b"request_withdraw_stake_non_entry"); +} + +native fun exchange_rate(epoch: u64, staking_pool_id: &ID): PoolTokenExchangeRate; + +fun get_exr(epoch: u64, staking_pool_id: &ID): PoolTokenExchangeRate { + let er: PoolTokenExchangeRate = exchange_rate(epoch, staking_pool_id); + cvlm_assume_msg(er.sui_amount() > er.pool_token_amount(), b"solvent"); + er +} + + + +fun get_latest_exchange_rate( + _self: &Storage, + staking_pool_id: &ID, + _system_state: &mut SuiSystemState, + ctx: &TxContext, +): Option { + + some(get_exr(ctx.epoch(), staking_pool_id)) +} + + +public fun active_validator_addresses(_wrapper: &mut SuiSystemState): vector

{ + nondet() +} + +public fun request_withdraw_stake_non_entry( + _wrapper: &mut SuiSystemState, + staked_sui: StakedSui, + ctx: &mut TxContext, +): Balance { + + let exr = get_exr(ctx.epoch(), &staked_sui.pool_id()); + let am = storage::get_sui_amount(&exr, staked_sui.amount()); + + let w: Balance = nondet(); + cvlm_assume_msg(w.value() == am, b"Exchange"); + ghost_destroy(staked_sui); + w +} From d2ce162f79a5d3c4f0b694991e4cc23c39d9cd32 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Fri, 9 Jan 2026 17:02:52 +0100 Subject: [PATCH 12/68] Update patches --- certora/munges/storage.patch | 43 +++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/certora/munges/storage.patch b/certora/munges/storage.patch index f6a5e4f..2a7300e 100644 --- a/certora/munges/storage.patch +++ b/certora/munges/storage.patch @@ -1,8 +1,20 @@ diff --git a/contracts/sources/storage.move b/contracts/sources/storage.move -index d3de10c..3b499ba 100644 +index d3de10c..8a3456f 100644 --- a/contracts/sources/storage.move +++ b/contracts/sources/storage.move -@@ -70,11 +70,11 @@ module liquid_staking::storage { +@@ -61,20 +61,22 @@ module liquid_staking::storage { + } + } + ++ fun log(_: &T) {} ++ + /* Public View Functions */ + public(package) fun sui_pool(self: &Storage): &Balance { + &self.sui_pool + } + +- public(package) fun validators(self: &Storage): &vector { ++ public fun validators(self: &Storage): &vector { &self.validator_infos } @@ -16,15 +28,15 @@ index d3de10c..3b499ba 100644 self.last_refresh_epoch } -@@ -437,6 +437,8 @@ module liquid_staking::storage { - unstaked_sui_amount - } - -+ -+ fun log(_: &T) {} - // This function approximately unstakes n SUI from validators, then returns up to n SUI. - public(package) fun split_n_sui( - self: &mut Storage, +@@ -214,7 +216,7 @@ module liquid_staking::storage { + fun refresh_validator_info(self: &mut Storage, i: u64) { + let validator_info = &mut self.validator_infos[i]; + self.total_sui_supply = self.total_sui_supply - validator_info.total_sui_amount; +- ++ log(validator_info); + let mut total_sui_amount = 0; + if (validator_info.active_stake.is_some()) { + let active_stake = validator_info.active_stake.borrow(); @@ -459,6 +461,8 @@ module liquid_staking::storage { }; }; @@ -43,3 +55,12 @@ index d3de10c..3b499ba 100644 assert!(self.sui_pool.value() >= max_sui_amount_out, ENotEnoughSuiInSuiPool); self.split_from_sui_pool(max_sui_amount_out) } +@@ -593,7 +599,7 @@ module liquid_staking::storage { + } + + /// copied directly from staking_pool.move +- fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64 { ++ public fun get_sui_amount(exchange_rate: &PoolTokenExchangeRate, token_amount: u64): u64 { + // When either amount is 0, that means we have no stakes with this pool. + // The other amount might be non-zero when there's dust left in the pool. + if (exchange_rate.sui_amount() == 0 || exchange_rate.pool_token_amount() == 0) { From ebfca3dde985f3f170ac1c68cdd4371e7084a615 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Fri, 9 Jan 2026 18:15:47 +0100 Subject: [PATCH 13/68] Verify correct accounting of total sui supply --- certora/munges/storage.patch | 33 +++++++- certora/spec/confs/accounting.conf | 19 +++++ certora/spec/sources/accounting.move | 113 +++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 certora/spec/confs/accounting.conf create mode 100644 certora/spec/sources/accounting.move diff --git a/certora/munges/storage.patch b/certora/munges/storage.patch index 2a7300e..5822659 100644 --- a/certora/munges/storage.patch +++ b/certora/munges/storage.patch @@ -1,5 +1,5 @@ diff --git a/contracts/sources/storage.move b/contracts/sources/storage.move -index d3de10c..8a3456f 100644 +index d3de10c..2102eca 100644 --- a/contracts/sources/storage.move +++ b/contracts/sources/storage.move @@ -61,20 +61,22 @@ module liquid_staking::storage { @@ -9,7 +9,8 @@ index d3de10c..8a3456f 100644 + fun log(_: &T) {} + /* Public View Functions */ - public(package) fun sui_pool(self: &Storage): &Balance { +- public(package) fun sui_pool(self: &Storage): &Balance { ++ public fun sui_pool(self: &Storage): &Balance { &self.sui_pool } @@ -28,6 +29,34 @@ index d3de10c..8a3456f 100644 self.last_refresh_epoch } +@@ -86,15 +88,15 @@ module liquid_staking::storage { + self.validator_address + } + +- public(package) fun active_stake(self: &ValidatorInfo): &Option { ++ public fun active_stake(self: &ValidatorInfo): &Option { + &self.active_stake + } + +- public(package) fun inactive_stake(self: &ValidatorInfo): &Option { ++ public fun inactive_stake(self: &ValidatorInfo): &Option { + &self.inactive_stake + } + +- public(package) fun exchange_rate(self: &ValidatorInfo): &PoolTokenExchangeRate { ++ public fun exchange_rate(self: &ValidatorInfo): &PoolTokenExchangeRate { + &self.exchange_rate + } + +@@ -126,7 +128,7 @@ module liquid_staking::storage { + /// - Moves any inactive stake that can be converted to active stake. + /// - Removes validators that have no stake. + /// Returns true if the storage was updated. +- public(package) fun refresh( ++ public fun refresh( + self: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext @@ -214,7 +216,7 @@ module liquid_staking::storage { fun refresh_validator_info(self: &mut Storage, i: u64) { let validator_info = &mut self.validator_infos[i]; diff --git a/certora/spec/confs/accounting.conf b/certora/spec/confs/accounting.conf new file mode 100644 index 0000000..72098c9 --- /dev/null +++ b/certora/spec/confs/accounting.conf @@ -0,0 +1,19 @@ +{ + "optimistic_loop": true, + "server": "prover", + "prover_version": "master", + "rule": [ + "*accounting*" + ], + "msg": "Total supply accounting consistency", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true", + ] +} \ No newline at end of file diff --git a/certora/spec/sources/accounting.move b/certora/spec/sources/accounting.move new file mode 100644 index 0000000..530c578 --- /dev/null +++ b/certora/spec/sources/accounting.move @@ -0,0 +1,113 @@ +module spec::accounting; + +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; +use cvlm::function::Function; +use cvlm::manifest::{target, invoker, rule}; +use liquid_staking::storage::{Storage, get_sui_amount, active_stake}; +use sui_system::sui_system::SuiSystemState; + +public fun cvlm_manifest() { + // Public mut functions + + target(@liquid_staking, b"storage", b"refresh"); + target(@liquid_staking, b"storage", b"change_validator_priority"); + target(@liquid_staking, b"storage", b"join_to_sui_pool"); + target(@liquid_staking, b"storage", b"join_stake"); + target(@liquid_staking, b"storage", b"join_fungible_stake"); + target(@liquid_staking, b"storage", b"join_inactive_stake_to_validator"); + target(@liquid_staking, b"storage", b"join_fungible_staked_sui_to_validator"); + target(@liquid_staking, b"storage", b"split_up_to_n_sui_from_sui_pool"); + target(@liquid_staking, b"storage", b"split_from_sui_pool"); + target(@liquid_staking, b"storage", b"unstake_approx_n_sui_from_validator"); + target(@liquid_staking, b"storage", b"unstake_approx_n_sui_from_active_stake"); + target(@liquid_staking, b"storage", b"unstake_approx_n_sui_from_inactive_stake"); + target(@liquid_staking, b"storage", b"split_n_sui"); + + invoker(b"invoke"); + + rule(b"total_sui_supply_correct"); + rule(b"total_sui_supply_correct_sanity"); +} + +native fun invoke( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +); + +fun staked_active(strg: &Storage, i: u64): u64 { + let validator_info = &strg.validators()[i]; + if (validator_info.active_stake().is_some()) { + let active_stake = validator_info.active_stake().borrow(); + get_sui_amount( + validator_info.exchange_rate(), + active_stake.value(), + ) + } else { + 0 + } +} + +fun staked_inactive(strg: &Storage, i: u64): u64 { + let validator_info = &strg.validators()[i]; + if (validator_info.inactive_stake().is_some()) { + let inactive_stake = validator_info.inactive_stake().borrow(); + inactive_stake.staked_sui_amount() + } else { + 0 + } +} + +fun validator_sui_supply(strg: &Storage, i: u64): u64 { + let active_stake = staked_active(strg, i); + let inactive_stake = staked_inactive(strg, i); + + + active_stake + inactive_stake +} + +fun current_supply(strg: &Storage): u64 { + let mut i = 0; + let mut v = strg.sui_pool().value(); + + while (i < strg.validators().length()) { + v = v + validator_sui_supply(strg, i); + i = i+1; + }; + v +} + +public fun total_sui_supply_correct( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + cvlm_assume_msg(strg.validators().length() <= 1, b"Only one validator"); + + cvlm_assume_msg(ctx.epoch() > strg.last_refresh_epoch(), b"Refresh"); + strg.refresh(system_state, ctx); + //lsi.fee_config().validate_fees(); + + let supply_pre = current_supply(strg); + let supply_expected_pre = strg.total_sui_supply(); + cvlm_assume_msg(supply_pre == supply_expected_pre, b"Assume in pre state"); + + invoke(target, strg, system_state, ctx); + + strg.refresh(system_state, ctx); // No necessary but to be extra sure everything is up to date + let supply_post = current_supply(strg); + let supply_expected_post = strg.total_sui_supply(); + cvlm_assert(supply_post == supply_expected_post); +} + +public fun total_sui_supply_correct_sanity( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + total_sui_supply_correct(target, strg, system_state, ctx); + cvlm_assert(false); +} From 20c69bc8a1c48c5bf54837948beec2975ea0232f Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 12 Jan 2026 08:51:26 +0100 Subject: [PATCH 14/68] Remove invalid rule declaration --- certora/spec/sources/accounting.move | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/certora/spec/sources/accounting.move b/certora/spec/sources/accounting.move index 530c578..74a81bf 100644 --- a/certora/spec/sources/accounting.move +++ b/certora/spec/sources/accounting.move @@ -26,7 +26,6 @@ public fun cvlm_manifest() { invoker(b"invoke"); rule(b"total_sui_supply_correct"); - rule(b"total_sui_supply_correct_sanity"); } native fun invoke( @@ -86,13 +85,12 @@ public fun total_sui_supply_correct( ) { cvlm_assume_msg(strg.validators().length() <= 1, b"Only one validator"); - cvlm_assume_msg(ctx.epoch() > strg.last_refresh_epoch(), b"Refresh"); + cvlm_assume_msg(ctx.epoch() > strg.last_refresh_epoch(), b"Assume fresh state"); strg.refresh(system_state, ctx); - //lsi.fee_config().validate_fees(); let supply_pre = current_supply(strg); let supply_expected_pre = strg.total_sui_supply(); - cvlm_assume_msg(supply_pre == supply_expected_pre, b"Assume in pre state"); + cvlm_assume_msg(supply_pre == supply_expected_pre, b"Assume invariant holds in pre state"); invoke(target, strg, system_state, ctx); @@ -101,13 +99,3 @@ public fun total_sui_supply_correct( let supply_expected_post = strg.total_sui_supply(); cvlm_assert(supply_post == supply_expected_post); } - -public fun total_sui_supply_correct_sanity( - target: Function, - strg: &mut Storage, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - total_sui_supply_correct(target, strg, system_state, ctx); - cvlm_assert(false); -} From 41576438cfe0815441cdf8b432eec7d7b6cbd9cd Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 12 Jan 2026 17:00:30 +0100 Subject: [PATCH 15/68] Fix solvency rules --- certora/munges/storage.patch | 40 ++--- certora/spec/sources/solvency.move | 164 ++++++++++++++---- certora/spec/sources/summaries.move | 260 +++++++++++++++++++++++++--- 3 files changed, 375 insertions(+), 89 deletions(-) diff --git a/certora/munges/storage.patch b/certora/munges/storage.patch index 5822659..ce92ef6 100644 --- a/certora/munges/storage.patch +++ b/certora/munges/storage.patch @@ -1,13 +1,10 @@ diff --git a/contracts/sources/storage.move b/contracts/sources/storage.move -index d3de10c..2102eca 100644 +index d3de10c..70d75ed 100644 --- a/contracts/sources/storage.move +++ b/contracts/sources/storage.move -@@ -61,20 +61,22 @@ module liquid_staking::storage { - } +@@ -62,39 +62,39 @@ module liquid_staking::storage { } -+ fun log(_: &T) {} -+ /* Public View Functions */ - public(package) fun sui_pool(self: &Storage): &Balance { + public fun sui_pool(self: &Storage): &Balance { @@ -29,7 +26,13 @@ index d3de10c..2102eca 100644 self.last_refresh_epoch } -@@ -86,15 +88,15 @@ module liquid_staking::storage { +- public(package) fun staking_pool_id(self: &ValidatorInfo): ID { ++ public fun staking_pool_id(self: &ValidatorInfo): ID { + self.staking_pool_id + } + +- public(package) fun validator_address(self: &ValidatorInfo): address { ++ public fun validator_address(self: &ValidatorInfo): address { self.validator_address } @@ -48,7 +51,7 @@ index d3de10c..2102eca 100644 &self.exchange_rate } -@@ -126,7 +128,7 @@ module liquid_staking::storage { +@@ -126,7 +126,7 @@ module liquid_staking::storage { /// - Moves any inactive stake that can be converted to active stake. /// - Removes validators that have no stake. /// Returns true if the storage was updated. @@ -57,34 +60,15 @@ index d3de10c..2102eca 100644 self: &mut Storage, system_state: &mut SuiSystemState, ctx: &mut TxContext -@@ -214,7 +216,7 @@ module liquid_staking::storage { +@@ -214,7 +214,6 @@ module liquid_staking::storage { fun refresh_validator_info(self: &mut Storage, i: u64) { let validator_info = &mut self.validator_infos[i]; self.total_sui_supply = self.total_sui_supply - validator_info.total_sui_amount; - -+ log(validator_info); let mut total_sui_amount = 0; if (validator_info.active_stake.is_some()) { let active_stake = validator_info.active_stake.borrow(); -@@ -459,6 +461,8 @@ module liquid_staking::storage { - }; - }; - -+ log(self); -+ - { - let mut i = self.validators().length(); - while (i > 0 && self.sui_pool.value() < max_sui_amount_out) { -@@ -477,6 +481,8 @@ module liquid_staking::storage { - }; - }; - -+ log(self); -+ - assert!(self.sui_pool.value() >= max_sui_amount_out, ENotEnoughSuiInSuiPool); - self.split_from_sui_pool(max_sui_amount_out) - } -@@ -593,7 +599,7 @@ module liquid_staking::storage { +@@ -593,7 +592,7 @@ module liquid_staking::storage { } /// copied directly from staking_pool.move diff --git a/certora/spec/sources/solvency.move b/certora/spec/sources/solvency.move index 3859473..4ffa9bf 100644 --- a/certora/spec/sources/solvency.move +++ b/certora/spec/sources/solvency.move @@ -1,11 +1,12 @@ module spec::solvency; -use cvlm::asserts::{cvlm_assert, cvlm_assume_msg, cvlm_assert_msg}; +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::function::Function; use cvlm::ghost::ghost_destroy; use cvlm::manifest::{target, invoker, rule}; use cvlm::nondet::nondet; -use liquid_staking::liquid_staking::{Self, LiquidStakingInfo, AdminCap, CustomRedeemRequest}; +use liquid_staking::fees::validate_fees; +use liquid_staking::liquid_staking::{Self, LiquidStakingInfo}; use spec::dummy::DummyToken; use sui_system::sui_system::SuiSystemState; @@ -29,11 +30,15 @@ public fun cvlm_manifest() { rule(b"solvency_base"); rule(b"solvency_base_staker"); rule(b"solvency_step"); + rule(b"insolvency_bound"); rule(b"monotonicity"); - rule(b"rate_changes_only_if"); + rule(b"no_lst_no_sui"); + rule(b"no_sui_no_lst"); } +const MAX_VALIDATORS: u64 = 1; + native fun invoke( target: Function, lis: &mut LiquidStakingInfo, @@ -41,6 +46,32 @@ native fun invoke( ctx: &mut TxContext, ); +fun setup_fresh( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Refresh"); + + let mut i = 0; + while (i < lsi.storage().validators().length()) { + let validator = &lsi.storage().validators()[i]; + let pool_id = validator.staking_pool_id(); + let active = validator.active_stake(); + let inactive = lsi.storage().validators()[i].inactive_stake(); + if (active.is_some()) { + cvlm_assume_msg(active.borrow().pool_id() == pool_id, b"Matching pool ids"); + }; + if (inactive.is_some()) { + cvlm_assume_msg(inactive.borrow().pool_id() == pool_id, b"Matching pool ids"); + }; + + i = i+1; + }; + + lsi.refresh(system_state, ctx); +} + /// lsi.total_sui_supply()/lsi.total_lst_supply() >= 1 /// <==> lsi.total_sui_supply() >= lsi.total_lst_supply() fun is_solvent(lsi: &LiquidStakingInfo): bool { @@ -94,42 +125,71 @@ public fun solvency_step( system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { - cvlm_assume_msg(lsi.storage().validators().length() == 1, b"Only one validator"); + cvlm_assume_msg( + lsi.storage().validators().length() <= MAX_VALIDATORS, + b"Restrict number of validators", + ); + setup_fresh(lsi, system_state, ctx); - cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Refresh"); - lsi.refresh(system_state, ctx); - //lsi.fee_config().validate_fees(); + // cvlm_assume_msg(lsi.accrued_spread_fees() == 0, b"No fees"); + // cvlm_assume_msg(lsi.total_lst_supply() <= 10000 && lsi.total_lst_supply() <= 10000, b"Reasonable values for CEX"); cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); - let mut ctx2: TxContext = nondet(); - cvlm_assume_msg(ctx.epoch() <= ctx2.epoch(), b"Time"); + validate_fees(lsi.fee_config()); - invoke(target, lsi, system_state, &mut ctx2); + invoke(target, lsi, system_state, ctx); - //lsi.refresh(system_state, ctx); cvlm_assert(is_solvent(lsi)); } -public fun monotonicity( +public fun insolvency_bound( target: Function, lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { + cvlm_assume_msg( + lsi.storage().validators().length() <= MAX_VALIDATORS, + b"Restrict number of validators", + ); + setup_fresh(lsi, system_state, ctx); + + // cvlm_assume_msg(lsi.accrued_spread_fees() == 0, b"No fees"); + // cvlm_assume_msg(lsi.total_lst_supply() <= 10000 && lsi.total_lst_supply() <= 10000, b"Reasonable values for CEX"); + cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); + + validate_fees(lsi.fee_config()); + + invoke(target, lsi, system_state, ctx); + + cvlm_assert(lsi.total_lst_supply() +1 >= lsi.total_lst_supply()); +} + +public fun no_lst_no_sui( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + cvlm_assume_msg( + lsi.storage().validators().length() <= MAX_VALIDATORS, + b"Restrict number of validators", + ); + setup_fresh(lsi, system_state, ctx); - cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Refresh"); - lsi.refresh(system_state, ctx); - //lsi.fee_config().validate_fees(); cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); let lst_pre = lsi.total_lst_supply(); let sui_pre = lsi.total_sui_supply(); + // lst=0 -> sui = 0 + // <==> lst != 0 || sui = 0 + cvlm_assume_msg(lst_pre != 0 || sui_pre == 0, b"Assume in pre-state"); - let mut ctx2: TxContext = nondet(); - cvlm_assume_msg(ctx.epoch() <= ctx2.epoch(), b"Time"); + //let mut ctx2: TxContext = nondet(); + //cvlm_assume_msg(ctx.epoch() <= ctx2.epoch(), b"Time"); - invoke(target, lsi, system_state, &mut ctx2); + invoke(target, lsi, system_state, ctx); let lst_post = lsi.total_lst_supply(); let sui_post = lsi.total_sui_supply(); @@ -137,35 +197,73 @@ public fun monotonicity( // sui_pre/lst_pre <= sui_post/lst_post // <==> sui_pre*lst_post <= sui_post*lst_pre - cvlm_assert(sui_pre*lst_post <= sui_post*lst_pre); + cvlm_assert(lst_post != 0 || sui_post == 0); } -public fun rate_changes_only_if( +public fun no_sui_no_lst( target: Function, lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { + cvlm_assume_msg( + lsi.storage().validators().length() <= MAX_VALIDATORS, + b"Restrict number of validators", + ); + setup_fresh(lsi, system_state, ctx); + + cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); + let lst_pre = lsi.total_lst_supply(); let sui_pre = lsi.total_sui_supply(); - let epoch_pre = lsi.storage().last_refresh_epoch(); - cvlm_assume_msg(ctx.epoch() >= epoch_pre, b"Don't perform actions in the past"); + // sui=0 -> lst=0 + // <==> sui != 0 || lst = 0 + cvlm_assume_msg(sui_pre != 0 || lst_pre == 0, b"Assume in pre-state"); + + //let mut ctx2: TxContext = nondet(); + //cvlm_assume_msg(ctx.epoch() <= ctx2.epoch(), b"Time"); invoke(target, lsi, system_state, ctx); let lst_post = lsi.total_lst_supply(); let sui_post = lsi.total_sui_supply(); - // sui_pre/lst_pre != sui_post/lst_post - // <==> sui_pre*lst_post != sui_post*lst_pre - let changed = sui_pre*lst_post != sui_post*lst_pre; - let is_refresh = target.name() == b"refresh"; - let new_epoch = epoch_pre < lsi.storage().last_refresh_epoch(); - - if (changed) { - cvlm_assert(new_epoch || is_refresh) // must be &&? - } else { - cvlm_assert(true) // please the prover - } + // sui_pre/lst_pre <= sui_post/lst_post + // <==> sui_pre*lst_post <= sui_post*lst_pre + + cvlm_assert(sui_post != 0 || lst_post == 0); +} + +public fun monotonicity( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + cvlm_assume_msg( + lsi.storage().validators().length() <= MAX_VALIDATORS, + b"Restrict number of validators", + ); + setup_fresh(lsi, system_state, ctx); + + //cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); + + let lst_pre = lsi.total_lst_supply(); + let sui_pre = lsi.total_sui_supply(); + + cvlm_assume_msg(lst_pre > 0 && sui_pre > 0, b"Non-empty reserve"); + + //let mut ctx2: TxContext = nondet(); + //cvlm_assume_msg(ctx.epoch() <= ctx2.epoch(), b"Time"); + + invoke(target, lsi, system_state, ctx); + + let lst_post = lsi.total_lst_supply(); + let sui_post = lsi.total_sui_supply(); + + // sui_pre/lst_pre <= sui_post/lst_post + // <==> sui_pre*lst_post <= sui_post*lst_pre + + cvlm_assert(sui_pre*lst_post <= sui_post*lst_pre); } diff --git a/certora/spec/sources/summaries.move b/certora/spec/sources/summaries.move index c01c39f..437cccb 100644 --- a/certora/spec/sources/summaries.move +++ b/certora/spec/sources/summaries.move @@ -1,55 +1,253 @@ module spec::summaries; -use cvlm::manifest::summary; -use liquid_staking::storage::Storage; -use sui_system::staking_pool::PoolTokenExchangeRate; -use sui_system::sui_system::SuiSystemState; +use cvlm::asserts::{cvlm_assume_msg}; +use cvlm::ghost::ghost_destroy; +use cvlm::manifest::{summary, ghost}; use cvlm::nondet::nondet; +use liquid_staking::storage::{Self, Storage}; use std::option::some; -use cvlm::asserts::cvlm_assume_msg; -use cvlm::manifest::ghost; -use sui_system::validator::staking_pool_id; -use sui_system::staking_pool::StakedSui; use sui::balance::Balance; +use sui::object::id; use sui::sui::SUI; -use sui::kiosk::withdraw; -use cvlm::ghost::ghost_destroy; -use cvlm::asserts::cvlm_assert_msg; -use sui::token::amount; -use liquid_staking::storage; -use liquid_staking::storage::get_sui_amount; -use sui_system::staking_pool::FungibleStakedSui; +use sui::tx_context::epoch; +use sui_system::staking_pool::{PoolTokenExchangeRate, StakedSui, StakingPool, FungibleStakedSui}; +use sui_system::sui_system::SuiSystemState; +use cvlm::asserts::cvlm_assert; +use sui::coin::TreasuryCap; +use sui::coin::Coin; public fun cvlm_manifest() { - ghost(b"exchange_rate"); summary(b"get_latest_exchange_rate", @liquid_staking, b"storage", b"get_latest_exchange_rate"); + ghost(b"validator_index"); + summary( + b"find_validator_index_by_address", + @liquid_staking, + b"storage", + b"find_validator_index_by_address", + ); + + summary( + b"pool_token_exchange_rate_at_epoch", + @sui_system, + b"staking_pool", + b"pool_token_exchange_rate_at_epoch", + ); + + summary( + b"burn", + @sui, + b"coin", + b"burn", + ); + + ghost(b"fungible_total_supply"); + ghost(b"fungible_total_principal"); - summary(b"active_validator_addresses", @sui_system, b"sui_system", b"active_validator_addresses"); - summary(b"request_withdraw_stake_non_entry", @sui_system, b"sui_system", b"request_withdraw_stake_non_entry"); + summary( + b"convert_to_fungible_staked_sui", + @sui_system, + b"sui_system", + b"convert_to_fungible_staked_sui", + ); + summary( + b"redeem_fungible_staked_sui", + @sui_system, + b"sui_system", + b"redeem_fungible_staked_sui", + ); + summary( + b"active_validator_addresses", + @sui_system, + b"sui_system", + b"active_validator_addresses", + ); + summary( + b"request_withdraw_stake_non_entry", + @sui_system, + b"sui_system", + b"request_withdraw_stake_non_entry", + ); +} + +native fun validator_index(validator_address: address): u64; +public(package) fun find_validator_index_by_address( + self: &Storage, + validator_address: address, +): u64 { + let i = validator_index(validator_address); + cvlm_assume_msg(i < self.validators().length(), b"Validator exists"); + cvlm_assume_msg( + self.validators()[i].validator_address() == validator_address, + b"Address is consistent", + ); + return i } native fun exchange_rate(epoch: u64, staking_pool_id: &ID): PoolTokenExchangeRate; fun get_exr(epoch: u64, staking_pool_id: &ID): PoolTokenExchangeRate { - let er: PoolTokenExchangeRate = exchange_rate(epoch, staking_pool_id); - cvlm_assume_msg(er.sui_amount() > er.pool_token_amount(), b"solvent"); - er + let er: PoolTokenExchangeRate = exchange_rate(epoch, staking_pool_id); + cvlm_assume_msg(er.sui_amount() >= er.pool_token_amount(), b"solvent"); + er } -fun get_latest_exchange_rate( +native fun fungible_total_supply(pool: ID): &mut u64; +native fun fungible_total_principal(pool: ID): &mut u64; + +public fun convert_to_fungible_staked_sui( + _: &mut SuiSystemState, + staked_sui: StakedSui, + ctx: &mut TxContext, +): FungibleStakedSui { + let id = staked_sui.pool_id(); + let epoch = ctx.epoch(); + + let principal = staked_sui.staked_sui_amount(); + + let fss: FungibleStakedSui = nondet(); + + let er = get_exr(epoch, &id); + let pool_token_amount = get_token_amount(&er, principal); + + cvlm_assume_msg(fss.pool_id() == id, b"Correct id"); + cvlm_assume_msg(fss.value() == pool_token_amount, b"Correct value"); + + // Update fungible token data + let f_total = fungible_total_supply(id); + let f_principal = fungible_total_supply(id); + *f_total = *f_total + pool_token_amount; + *f_principal = *f_principal + principal; + + + ghost_destroy(staked_sui); + + fss +} + + +public fun redeem_fungible_staked_sui( + _wrapper: &mut SuiSystemState, + fungible_staked_sui: FungibleStakedSui, + ctx: &TxContext, +): Balance { + + let id = fungible_staked_sui.pool_id(); + let epoch = ctx.epoch(); + let value = fungible_staked_sui.value(); + + let principal = *fungible_total_principal(id); + cvlm_assume_msg(principal >= value, b""); + + + let total_supply = *fungible_total_supply(id); + cvlm_assume_msg(total_supply >= principal, b""); + + + let er = get_exr(epoch, &id); + + + // let ( + // principal_amount, + // rewards_amount, + // ) = calculate_fungible_staked_sui_withdraw_amount( + // er, + // value, + // principal, + // total_supply, + // ); + //let total_withdraw = principal_amount+rewards_amount; + + let total_withdraw = get_sui_amount(er, value); + + //fungible_staked_sui_data.total_supply = fungible_staked_sui_data.total_supply - value; + + let sui_out: Balance = nondet(); + cvlm_assume_msg(sui_out.value() == total_withdraw, b""); + // = fungible_staked_sui_data.principal.split(principal_amount); + // sui_out.join(pool.rewards_pool.split(rewards_amount)); + + // pool.pending_total_sui_withdraw = pool.pending_total_sui_withdraw + sui_out.value(); + // pool.pending_pool_token_withdraw = pool.pending_pool_token_withdraw + value; + ghost_destroy(fungible_staked_sui); + sui_out +} + +fun calculate_fungible_staked_sui_withdraw_amount( + latest_exchange_rate: PoolTokenExchangeRate, + fungible_staked_sui_value: u64, + fungible_staked_sui_data_principal_amount: u64, // fungible_staked_sui_data.principal.value() + fungible_staked_sui_data_total_supply: u64, // fungible_staked_sui_data.total_supply +): (u64, u64) { + // 1. if the entire FungibleStakedSuiData supply is redeemed, how much sui should we receive? + let total_sui_amount = get_sui_amount( + latest_exchange_rate, + fungible_staked_sui_data_total_supply, + ); // == fungible_staked_sui_data_principal_amount + rewards + + // min with total_sui_amount to prevent underflow + // let fungible_staked_sui_data_principal_amount = fungible_staked_sui_data_principal_amount.min( + // total_sui_amount, + // ); + + cvlm_assume_msg(fungible_staked_sui_data_principal_amount <= total_sui_amount, b"Principal amount is less than total sui amount"); + + // 2. how much do we need to withdraw from the rewards pool? + let total_rewards = total_sui_amount - fungible_staked_sui_data_principal_amount; + + // 3. proportionally withdraw from both wrt the fungible_staked_sui_value. + let principal_withdraw_amount = + (fungible_staked_sui_value*fungible_staked_sui_data_principal_amount)/fungible_staked_sui_data_total_supply; + + let rewards_withdraw_amount = + (fungible_staked_sui_value*total_rewards)/fungible_staked_sui_data_total_supply; + + // invariant check, just in case + let expected_sui_amount = get_sui_amount(latest_exchange_rate, fungible_staked_sui_value); + cvlm_assert( + principal_withdraw_amount + rewards_withdraw_amount <= expected_sui_amount, + ); + + (principal_withdraw_amount, rewards_withdraw_amount) +} + +fun get_token_amount(exchange_rate: &PoolTokenExchangeRate, sui_amount: u64): u64 { + // When either amount is 0, that means we have no stakes with this pool. + // The other amount might be non-zero when there's dust left in the pool. + if (exchange_rate.sui_amount() == 0 || exchange_rate.pool_token_amount() == 0) { + return sui_amount + }; + + (exchange_rate.pool_token_amount()* sui_amount) / exchange_rate.sui_amount() +} + +fun get_sui_amount(exchange_rate: PoolTokenExchangeRate, token_amount: u64): u64 { + // When either amount is 0, that means we have no stakes with this pool. + // The other amount might be non-zero when there's dust left in the pool. + if (exchange_rate.sui_amount() == 0 || exchange_rate.pool_token_amount() == 0) { + return token_amount + }; + + (exchange_rate.sui_amount()* token_amount) / exchange_rate.pool_token_amount() +} + +public(package) fun get_latest_exchange_rate( _self: &Storage, staking_pool_id: &ID, _system_state: &mut SuiSystemState, ctx: &TxContext, -): Option { +): Option { some(get_exr(ctx.epoch(), staking_pool_id)) } - some(get_exr(ctx.epoch(), staking_pool_id)) +public fun pool_token_exchange_rate_at_epoch( + pool: &StakingPool, + epoch: u64, +): PoolTokenExchangeRate { + let id = id(pool); + some(get_exr(epoch, &id)).destroy_some() } - public fun active_validator_addresses(_wrapper: &mut SuiSystemState): vector
{ nondet() } @@ -59,12 +257,18 @@ public fun request_withdraw_stake_non_entry( staked_sui: StakedSui, ctx: &mut TxContext, ): Balance { - - let exr = get_exr(ctx.epoch(), &staked_sui.pool_id()); + let exr = get_exr(ctx.epoch(), &staked_sui.pool_id()); let am = storage::get_sui_amount(&exr, staked_sui.amount()); - let w: Balance = nondet(); + let w: Balance = nondet(); cvlm_assume_msg(w.value() == am, b"Exchange"); ghost_destroy(staked_sui); w } + + +fun burn(cap: &mut TreasuryCap, c: Coin): u64 { + let b = c.value(); + ghost_destroy(c); + b +} \ No newline at end of file From 40fc6edd08842b2b82c06f0d2fbd0bd5c62719ba Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 12 Jan 2026 17:59:41 +0100 Subject: [PATCH 16/68] Add basic integrity rules --- certora/munges/storage.patch | 20 +++++++++- .../{accounting.conf => consistency.conf} | 4 +- .../{accounting.move => consistency.move} | 37 ++++++++++++------- 3 files changed, 44 insertions(+), 17 deletions(-) rename certora/spec/confs/{accounting.conf => consistency.conf} (86%) rename certora/spec/sources/{accounting.move => consistency.move} (80%) diff --git a/certora/munges/storage.patch b/certora/munges/storage.patch index ce92ef6..6995d8f 100644 --- a/certora/munges/storage.patch +++ b/certora/munges/storage.patch @@ -1,7 +1,16 @@ diff --git a/contracts/sources/storage.move b/contracts/sources/storage.move -index d3de10c..70d75ed 100644 +index d3de10c..a5fce28 100644 --- a/contracts/sources/storage.move +++ b/contracts/sources/storage.move +@@ -51,7 +51,7 @@ module liquid_staking::storage { + extra_fields: Bag + } + +- public(package) fun new(ctx: &mut TxContext): Storage { ++ public fun new(ctx: &mut TxContext): Storage { + Storage { + sui_pool: balance::zero(), + validator_infos: vector::empty(), @@ -62,39 +62,39 @@ module liquid_staking::storage { } @@ -68,6 +77,15 @@ index d3de10c..70d75ed 100644 let mut total_sui_amount = 0; if (validator_info.active_stake.is_some()) { let active_stake = validator_info.active_stake.borrow(); +@@ -543,7 +542,7 @@ module liquid_staking::storage { + } + + /* Private functions */ +- fun get_or_add_validator_index_by_staking_pool_id_mut( ++ public fun get_or_add_validator_index_by_staking_pool_id_mut( + self: &mut Storage, + system_state: &mut SuiSystemState, + staking_pool_id: ID, @@ -593,7 +592,7 @@ module liquid_staking::storage { } diff --git a/certora/spec/confs/accounting.conf b/certora/spec/confs/consistency.conf similarity index 86% rename from certora/spec/confs/accounting.conf rename to certora/spec/confs/consistency.conf index 72098c9..d784638 100644 --- a/certora/spec/confs/accounting.conf +++ b/certora/spec/confs/consistency.conf @@ -3,9 +3,9 @@ "server": "prover", "prover_version": "master", "rule": [ - "*accounting*" + "*consistency*" ], - "msg": "Total supply accounting consistency", + "msg": "Accounting consistency", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/sources/accounting.move b/certora/spec/sources/consistency.move similarity index 80% rename from certora/spec/sources/accounting.move rename to certora/spec/sources/consistency.move index 74a81bf..545ad9e 100644 --- a/certora/spec/sources/accounting.move +++ b/certora/spec/sources/consistency.move @@ -1,10 +1,11 @@ -module spec::accounting; +module spec::consistency; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::function::Function; use cvlm::manifest::{target, invoker, rule}; -use liquid_staking::storage::{Storage, get_sui_amount, active_stake}; +use liquid_staking::storage::{Self, Storage, get_sui_amount, active_stake, new}; use sui_system::sui_system::SuiSystemState; +use cvlm::ghost::ghost_destroy; public fun cvlm_manifest() { // Public mut functions @@ -25,7 +26,8 @@ public fun cvlm_manifest() { invoker(b"invoke"); - rule(b"total_sui_supply_correct"); + rule(b"total_sui_supply_correct_base"); + rule(b"total_sui_supply_correct_step"); } native fun invoke( @@ -62,22 +64,29 @@ fun validator_sui_supply(strg: &Storage, i: u64): u64 { let active_stake = staked_active(strg, i); let inactive_stake = staked_inactive(strg, i); - active_stake + inactive_stake } fun current_supply(strg: &Storage): u64 { - let mut i = 0; - let mut v = strg.sui_pool().value(); - - while (i < strg.validators().length()) { - v = v + validator_sui_supply(strg, i); - i = i+1; - }; - v + let mut i = 0; + let mut v = strg.sui_pool().value(); + + while (i < strg.validators().length()) { + v = v + validator_sui_supply(strg, i); + i = i+1; + }; + v +} + +public fun total_sui_supply_correct_base(ctx: &mut TxContext) { + let strg = storage::new(ctx); + let supply = current_supply(&strg); + let supply_expected = strg.total_sui_supply(); + cvlm_assert(supply == supply_expected); + ghost_destroy(strg); } -public fun total_sui_supply_correct( +public fun total_sui_supply_correct_step( target: Function, strg: &mut Storage, system_state: &mut SuiSystemState, @@ -98,4 +107,4 @@ public fun total_sui_supply_correct( let supply_post = current_supply(strg); let supply_expected_post = strg.total_sui_supply(); cvlm_assert(supply_post == supply_expected_post); -} +} \ No newline at end of file From c9b8ef51050d714ed72ee922aad0a65b0cb72ec5 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 12 Jan 2026 18:22:37 +0100 Subject: [PATCH 17/68] Add integrity rules --- certora/spec/confs/integrity.conf | 19 ++++ certora/spec/sources/common.move | 32 +++++++ certora/spec/sources/integrity.move | 136 ++++++++++++++++++++++++++++ certora/spec/sources/solvency.move | 30 +----- 4 files changed, 191 insertions(+), 26 deletions(-) create mode 100644 certora/spec/confs/integrity.conf create mode 100644 certora/spec/sources/common.move create mode 100644 certora/spec/sources/integrity.move diff --git a/certora/spec/confs/integrity.conf b/certora/spec/confs/integrity.conf new file mode 100644 index 0000000..b883064 --- /dev/null +++ b/certora/spec/confs/integrity.conf @@ -0,0 +1,19 @@ +{ + "optimistic_loop": true, + "server": "prover", + "prover_version": "master", + "rule": [ + "*integrity*" + ], + "msg": "Integrity rules", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true" + ] +} \ No newline at end of file diff --git a/certora/spec/sources/common.move b/certora/spec/sources/common.move new file mode 100644 index 0000000..897881c --- /dev/null +++ b/certora/spec/sources/common.move @@ -0,0 +1,32 @@ +module spec::common; + +use liquid_staking::liquid_staking::LiquidStakingInfo; +use sui_system::sui_system::SuiSystemState; +use cvlm::asserts::cvlm_assume_msg; + + +public fun setup_fresh( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Refresh"); + + let mut i = 0; + while (i < lsi.storage().validators().length()) { + let validator = &lsi.storage().validators()[i]; + let pool_id = validator.staking_pool_id(); + let active = validator.active_stake(); + let inactive = lsi.storage().validators()[i].inactive_stake(); + if (active.is_some()) { + cvlm_assume_msg(active.borrow().pool_id() == pool_id, b"Matching pool ids"); + }; + if (inactive.is_some()) { + cvlm_assume_msg(inactive.borrow().pool_id() == pool_id, b"Matching pool ids"); + }; + + i = i+1; + }; + + lsi.refresh(system_state, ctx); +} \ No newline at end of file diff --git a/certora/spec/sources/integrity.move b/certora/spec/sources/integrity.move new file mode 100644 index 0000000..5c293d7 --- /dev/null +++ b/certora/spec/sources/integrity.move @@ -0,0 +1,136 @@ +module spec::integrity; + +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; +use cvlm::ghost::ghost_destroy; +use cvlm::manifest::rule; +use cvlm::nondet::nondet; +use liquid_staking::fees::validate_fees; +use liquid_staking::liquid_staking::{LiquidStakingInfo}; +use spec::dummy::DummyToken; +use sui_system::sui_system::SuiSystemState; +use spec::common::setup_fresh; +use sui::coin::Coin; + +use sui::sui::SUI; + + +public fun cvlm_manifest() { + rule(b"no_lost_funds_on_redeem"); + rule(b"no_lost_funds_on_mint"); + rule(b"fees_dont_eat_deposit"); + rule(b"fees_dont_eat_redemption"); + rule(b"no_arbitrage_opportunity"); +} + +public fun no_lost_funds_on_redeem( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + + // Solvency is sufficient to make the rule pass. + // However, since the system is not solvent at all times, it is not safe to assume it here. + //cvlm_assume_msg(is_solvent(lsi), b"Solvency"); + + let coin: Coin = nondet(); + + let fees_pre = lsi.fees(); + + cvlm_assume_msg(coin.value() > 0, b"Non-zero value"); + let sui = lsi.redeem(coin, system_state, ctx); + + let fees = lsi.fees() - fees_pre; + + cvlm_assert(sui.value() + fees > 0); + ghost_destroy(sui); +} + +public fun fees_dont_eat_redemption( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + validate_fees(lsi.fee_config()); + + + let coin: Coin = nondet(); + + let fees_pre = lsi.fees(); + + cvlm_assume_msg(coin.value() > 0, b"Non-zero value"); + let sui = lsi.redeem(coin, system_state, ctx); + + let fees = lsi.fees() - fees_pre; + + cvlm_assume_msg(sui.value() + fees > 0, b"No lost funds"); + cvlm_assert(sui.value() > 0); + ghost_destroy(sui); +} + + +public fun no_lost_funds_on_mint( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + + let coin: Coin = nondet(); + + let fees_pre = lsi.fees(); + + cvlm_assume_msg(coin.value() > 0, b"Non-zero value"); + let lst = lsi.mint(system_state, coin, ctx); + let fees = lsi.fees() - fees_pre; + + cvlm_assert(lst.value()+fees > 0); + ghost_destroy(lst); +} + + +public fun fees_dont_eat_deposit( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + validate_fees(lsi.fee_config()); + + + let coin: Coin = nondet(); + + let fees_pre = lsi.fees(); + + cvlm_assume_msg(coin.value() > 0, b"Non-zero value"); + let lst = lsi.mint(system_state, coin, ctx); + let fees = lsi.fees() - fees_pre; + + cvlm_assume_msg(lst.value()+fees > 0, b"No lost funds"); + cvlm_assert(lst.value() > 0); + ghost_destroy(lst); +} + + +public fun no_arbitrage_opportunity( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + validate_fees(lsi.fee_config()); + + // The rule fails if we have 0 LST but non-zero SUI supply + // This state, however, should not be possible to reach (check rule `no_lst_no_sui` in `solvency.move`) + cvlm_assume_msg(lsi.total_lst_supply() != 0 || lsi.total_sui_supply() == 0, b"No LST means no SUI supply"); + + let sui_in: Coin = nondet(); + let sui_in_value = sui_in.value(); + + let lst = lsi.mint(system_state, sui_in, ctx); + let sui_out = lsi.redeem(lst, system_state, ctx); + + cvlm_assert(sui_out.value() <= sui_in_value); + ghost_destroy(sui_out); +} diff --git a/certora/spec/sources/solvency.move b/certora/spec/sources/solvency.move index 4ffa9bf..99fb8bd 100644 --- a/certora/spec/sources/solvency.move +++ b/certora/spec/sources/solvency.move @@ -9,6 +9,7 @@ use liquid_staking::fees::validate_fees; use liquid_staking::liquid_staking::{Self, LiquidStakingInfo}; use spec::dummy::DummyToken; use sui_system::sui_system::SuiSystemState; +use spec::common::setup_fresh; public fun cvlm_manifest() { // Public mut functions @@ -46,35 +47,11 @@ native fun invoke( ctx: &mut TxContext, ); -fun setup_fresh( - lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Refresh"); - - let mut i = 0; - while (i < lsi.storage().validators().length()) { - let validator = &lsi.storage().validators()[i]; - let pool_id = validator.staking_pool_id(); - let active = validator.active_stake(); - let inactive = lsi.storage().validators()[i].inactive_stake(); - if (active.is_some()) { - cvlm_assume_msg(active.borrow().pool_id() == pool_id, b"Matching pool ids"); - }; - if (inactive.is_some()) { - cvlm_assume_msg(inactive.borrow().pool_id() == pool_id, b"Matching pool ids"); - }; - - i = i+1; - }; - - lsi.refresh(system_state, ctx); -} + /// lsi.total_sui_supply()/lsi.total_lst_supply() >= 1 /// <==> lsi.total_sui_supply() >= lsi.total_lst_supply() -fun is_solvent(lsi: &LiquidStakingInfo): bool { +public fun is_solvent(lsi: &LiquidStakingInfo): bool { let sui_supply = lsi.total_sui_supply(); let lst_supply = lsi.total_lst_supply(); @@ -118,6 +95,7 @@ public fun solvency_base_staker() { ghost_destroy(system_state); } + /// The induction steps for the solvency invariant. public fun solvency_step( target: Function, From f97717b55e3fe8657ecbeb4ca78f74176815e12b Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 10:16:35 +0100 Subject: [PATCH 18/68] Add rules for validator info consistency --- certora/spec/sources/common.move | 4 +- certora/spec/sources/consistency.move | 127 +++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/certora/spec/sources/common.move b/certora/spec/sources/common.move index 897881c..e1aa25d 100644 --- a/certora/spec/sources/common.move +++ b/certora/spec/sources/common.move @@ -29,4 +29,6 @@ public fun setup_fresh( }; lsi.refresh(system_state, ctx); -} \ No newline at end of file +} + +public fun log(_: &T) {} \ No newline at end of file diff --git a/certora/spec/sources/consistency.move b/certora/spec/sources/consistency.move index 545ad9e..49f8a24 100644 --- a/certora/spec/sources/consistency.move +++ b/certora/spec/sources/consistency.move @@ -2,10 +2,13 @@ module spec::consistency; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::function::Function; +use cvlm::ghost::ghost_destroy; use cvlm::manifest::{target, invoker, rule}; -use liquid_staking::storage::{Self, Storage, get_sui_amount, active_stake, new}; +use liquid_staking::storage::{Self, Storage, get_sui_amount, active_stake}; use sui_system::sui_system::SuiSystemState; -use cvlm::ghost::ghost_destroy; +use spec::common::log; +use cvlm::asserts::cvlm_assert_msg; + public fun cvlm_manifest() { // Public mut functions @@ -23,11 +26,17 @@ public fun cvlm_manifest() { target(@liquid_staking, b"storage", b"unstake_approx_n_sui_from_active_stake"); target(@liquid_staking, b"storage", b"unstake_approx_n_sui_from_inactive_stake"); target(@liquid_staking, b"storage", b"split_n_sui"); + target(@liquid_staking, b"storage", b"get_or_add_validator_index_by_staking_pool_id_mut"); invoker(b"invoke"); rule(b"total_sui_supply_correct_base"); rule(b"total_sui_supply_correct_step"); + + rule(b"no_duplicate_validators"); + rule(b"can_add_correct"); + rule(b"can_remove_correct"); + rule(b"add_at_most_one"); } native fun invoke( @@ -107,4 +116,116 @@ public fun total_sui_supply_correct_step( let supply_post = current_supply(strg); let supply_expected_post = strg.total_sui_supply(); cvlm_assert(supply_post == supply_expected_post); -} \ No newline at end of file +} + + + +fun can_add_validator(target: Function): bool { + target.name() == b"get_or_add_validator_index_by_staking_pool_id_mut" + || target.name() == b"join_stake" + || target.name() == b"join_fungible_stake" +} + +fun can_remove_validator(target: Function): bool { + target.name() == b"refresh" +} + +public fun can_add_correct( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + let validators_pre = strg.validators().length(); + invoke(target, strg, system_state, ctx); + let validators_post = strg.validators().length(); + + let appended = validators_post > validators_pre; + let allowed = can_add_validator(target); + + cvlm_assert(!appended || allowed); + +} + +public fun can_remove_correct( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + let validators_pre = strg.validators().length(); + invoke(target, strg, system_state, ctx); + let validators_post = strg.validators().length(); + + let removed = validators_post < validators_pre; + let allowed = can_remove_validator(target); + + cvlm_assert(!removed || allowed); +} + + +public fun no_duplicate_validators( + strg: &mut Storage, + staking_pool_id: ID, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + + let validator_address = system_state.validator_address_by_pool_id(&staking_pool_id); + + let mut id_exists = false; + let mut address_exists = false; + + let infos_pre = strg.validators().length(); + + let mut i = 0; + while (i < infos_pre) { + let v = &strg.validators()[i]; + id_exists = id_exists || v.staking_pool_id() == staking_pool_id; + address_exists = address_exists || v.validator_address() == validator_address; + i = i + 1; + }; + + let index = strg.get_or_add_validator_index_by_staking_pool_id_mut(system_state, staking_pool_id, ctx); + let appended = index == infos_pre; + + log(&id_exists); + log(&address_exists); + log(&appended); + + // appended -> !id_exists && !address_exists + cvlm_assert(!appended || (!id_exists && !address_exists )); +} + +public fun add_at_most_one( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + let validators_pre = strg.validators().length(); + invoke(target, strg, system_state, ctx); + let validators_post = strg.validators().length(); + + cvlm_assert(validators_post <= validators_pre + 1); +} + +// Same as in storage.move +const MAX_VALIDATORS: u64 = 50; + +public fun validators_upper_bound_base(ctx: &mut TxContext) { + let strg = storage::new(ctx); + cvlm_assert(strg.validators().length() <= MAX_VALIDATORS); + ghost_destroy(strg); +} + +public fun validators_upper_bound_step( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + cvlm_assert_msg(strg.validators().length() <= MAX_VALIDATORS, b"Assume in pre state"); + invoke(target, strg, system_state, ctx); + cvlm_assert(strg.validators().length() <= MAX_VALIDATORS); +} From cd1c3f573635e74404ae421c2c0dc91b4ac092a9 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 12:03:24 +0100 Subject: [PATCH 19/68] Initial CI setup --- .github/workflows/certora.yml | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/certora.yml diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml new file mode 100644 index 0000000..4103aa2 --- /dev/null +++ b/.github/workflows/certora.yml @@ -0,0 +1,43 @@ +name: Certora Prover + +on: + push: + branches: + - kel/specs + - certora + pull_request: + workflow_dispatch: + +jobs: + certora_run_health: + runs-on: ubuntu-latest + permissions: + contents: read + statuses: write + pull-requests: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install sui + run: | + curl -sSfL https://raw.githubusercontent.com/Mystenlabs/suiup/main/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + suiup install -y sui@testnet + - name: Apply patch + run: ./certora/munges/munge.sh + - name: Submit Jobs to Certora Prover + uses: Certora/certora-run-action@v2 + with: + ecosystem: sui + working-directory: certora/spec/ + configurations: |- + confs/consistency.conf + confs/integrity.conf + confs/solvency.conf + job-name: "Verify Obligation Health Rules" + certora-key: ${{ secrets.CERTORAKEY }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From d76386ce34b5ac8c66de083b20873c1f6b171420 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 12:31:14 +0100 Subject: [PATCH 20/68] Restructure rules --- ...ncy.conf => accounting_no_lst_no_sui.conf} | 6 +- .../confs/accounting_total_sui_supply.conf | 19 +++ certora/spec/confs/integrity.conf | 2 +- certora/spec/confs/solvency.conf | 2 +- .../spec/confs/validators_consistency.conf | 19 +++ .../sources/accounting_no_lst_no_sui.move | 107 +++++++++++++++++ .../sources/accounting_total_sui_supply.move | 113 ++++++++++++++++++ certora/spec/sources/common.move | 2 +- certora/spec/sources/solvency.move | 76 ------------ ...tency.move => validators_consistency.move} | 2 +- 10 files changed, 265 insertions(+), 83 deletions(-) rename certora/spec/confs/{consistency.conf => accounting_no_lst_no_sui.conf} (78%) create mode 100644 certora/spec/confs/accounting_total_sui_supply.conf create mode 100644 certora/spec/confs/validators_consistency.conf create mode 100644 certora/spec/sources/accounting_no_lst_no_sui.move create mode 100644 certora/spec/sources/accounting_total_sui_supply.move rename certora/spec/sources/{consistency.move => validators_consistency.move} (99%) diff --git a/certora/spec/confs/consistency.conf b/certora/spec/confs/accounting_no_lst_no_sui.conf similarity index 78% rename from certora/spec/confs/consistency.conf rename to certora/spec/confs/accounting_no_lst_no_sui.conf index d784638..e4d3bee 100644 --- a/certora/spec/confs/consistency.conf +++ b/certora/spec/confs/accounting_no_lst_no_sui.conf @@ -3,9 +3,9 @@ "server": "prover", "prover_version": "master", "rule": [ - "*consistency*" + "*accounting_no_lst_no_sui*" ], - "msg": "Accounting consistency", + "msg": "Accounting: No LST iff No SUI", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", @@ -14,6 +14,6 @@ "-maxBlockCount 1000000", "-tacDumpsWithInternalFunctions true", "-callTraceVecElemCount 0", - "-dumpCodeSizeAnalysis true", + "-dumpCodeSizeAnalysis true" ] } \ No newline at end of file diff --git a/certora/spec/confs/accounting_total_sui_supply.conf b/certora/spec/confs/accounting_total_sui_supply.conf new file mode 100644 index 0000000..adc7597 --- /dev/null +++ b/certora/spec/confs/accounting_total_sui_supply.conf @@ -0,0 +1,19 @@ +{ + "optimistic_loop": true, + "server": "prover", + "prover_version": "master", + "rule": [ + "*accounting_total_sui_supply*" + ], + "msg": "Correct accounting of total sui supply", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true" + ] +} \ No newline at end of file diff --git a/certora/spec/confs/integrity.conf b/certora/spec/confs/integrity.conf index b883064..437b300 100644 --- a/certora/spec/confs/integrity.conf +++ b/certora/spec/confs/integrity.conf @@ -5,7 +5,7 @@ "rule": [ "*integrity*" ], - "msg": "Integrity rules", + "msg": "Various external flow integrity rules", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/confs/solvency.conf b/certora/spec/confs/solvency.conf index 0e4f7eb..808a9a8 100644 --- a/certora/spec/confs/solvency.conf +++ b/certora/spec/confs/solvency.conf @@ -14,6 +14,6 @@ "-maxBlockCount 1000000", "-tacDumpsWithInternalFunctions true", "-callTraceVecElemCount 0", - "-dumpCodeSizeAnalysis true", + "-dumpCodeSizeAnalysis true" ] } \ No newline at end of file diff --git a/certora/spec/confs/validators_consistency.conf b/certora/spec/confs/validators_consistency.conf new file mode 100644 index 0000000..33bf05d --- /dev/null +++ b/certora/spec/confs/validators_consistency.conf @@ -0,0 +1,19 @@ +{ + "optimistic_loop": true, + "server": "prover", + "prover_version": "master", + "rule": [ + "*validators_consistency*" + ], + "msg": "Consistency of the validators list", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true" + ] +} \ No newline at end of file diff --git a/certora/spec/sources/accounting_no_lst_no_sui.move b/certora/spec/sources/accounting_no_lst_no_sui.move new file mode 100644 index 0000000..fb36019 --- /dev/null +++ b/certora/spec/sources/accounting_no_lst_no_sui.move @@ -0,0 +1,107 @@ +module spec::accounting_no_lst_no_sui; + +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; +use cvlm::function::Function; +use cvlm::manifest::{target, invoker, rule}; +use liquid_staking::liquid_staking::{LiquidStakingInfo}; +use spec::dummy::DummyToken; +use sui_system::sui_system::SuiSystemState; +use spec::common::setup_fresh; +use spec::solvency::is_solvent; + +public fun cvlm_manifest() { + // Public mut functions + target(@spec, b"dummy", b"mint"); + target(@spec, b"dummy", b"redeem"); + target(@spec, b"dummy", b"custom_redeem_request"); + target(@spec, b"dummy", b"custom_redeem"); + target(@spec, b"dummy", b"change_validator_priority"); + target(@spec, b"dummy", b"increase_validator_stake"); + target(@spec, b"dummy", b"decrease_validator_stake"); + target(@spec, b"dummy", b"collect_fees"); + target(@spec, b"dummy", b"update_fees"); + target(@spec, b"dummy", b"refresh"); + target(@spec, b"dummy", b"update_metadata"); + + invoker(b"invoke"); + + rule(b"no_lst_no_sui"); + rule(b"no_sui_no_lst"); +} + +const MAX_VALIDATORS: u64 = 1; + +native fun invoke( + target: Function, + lis: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +); + + + + +public fun no_lst_no_sui( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + cvlm_assume_msg( + lsi.storage().validators().length() <= MAX_VALIDATORS, + b"Restrict number of validators", + ); + setup_fresh(lsi, system_state, ctx); + + cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); + + let lst_pre = lsi.total_lst_supply(); + let sui_pre = lsi.total_sui_supply(); + + // lst=0 -> sui = 0 + // <==> lst != 0 || sui = 0 + cvlm_assume_msg(lst_pre != 0 || sui_pre == 0, b"Assume in pre-state"); + + + invoke(target, lsi, system_state, ctx); + + let lst_post = lsi.total_lst_supply(); + let sui_post = lsi.total_sui_supply(); + + // sui_pre/lst_pre <= sui_post/lst_post + // <==> sui_pre*lst_post <= sui_post*lst_pre + + cvlm_assert(lst_post != 0 || sui_post == 0); +} + +public fun no_sui_no_lst( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + cvlm_assume_msg( + lsi.storage().validators().length() <= MAX_VALIDATORS, + b"Restrict number of validators", + ); + setup_fresh(lsi, system_state, ctx); + + cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); + + let lst_pre = lsi.total_lst_supply(); + let sui_pre = lsi.total_sui_supply(); + + // sui=0 -> lst=0 + // <==> sui != 0 || lst = 0 + cvlm_assume_msg(sui_pre != 0 || lst_pre == 0, b"Assume in pre-state"); + + invoke(target, lsi, system_state, ctx); + + let lst_post = lsi.total_lst_supply(); + let sui_post = lsi.total_sui_supply(); + + // sui_pre/lst_pre <= sui_post/lst_post + // <==> sui_pre*lst_post <= sui_post*lst_pre + + cvlm_assert(sui_post != 0 || lst_post == 0); +} \ No newline at end of file diff --git a/certora/spec/sources/accounting_total_sui_supply.move b/certora/spec/sources/accounting_total_sui_supply.move new file mode 100644 index 0000000..2d32d5f --- /dev/null +++ b/certora/spec/sources/accounting_total_sui_supply.move @@ -0,0 +1,113 @@ +module spec::accounting_total_sui_supply; + +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; +use cvlm::function::Function; +use cvlm::ghost::ghost_destroy; +use cvlm::manifest::{target, invoker, rule}; +use liquid_staking::storage::{Self, Storage, get_sui_amount, active_stake}; +use sui_system::sui_system::SuiSystemState; + +public fun cvlm_manifest() { + // Public mut functions + + target(@liquid_staking, b"storage", b"refresh"); + target(@liquid_staking, b"storage", b"change_validator_priority"); + target(@liquid_staking, b"storage", b"join_to_sui_pool"); + target(@liquid_staking, b"storage", b"join_stake"); + target(@liquid_staking, b"storage", b"join_fungible_stake"); + target(@liquid_staking, b"storage", b"join_inactive_stake_to_validator"); + target(@liquid_staking, b"storage", b"join_fungible_staked_sui_to_validator"); + target(@liquid_staking, b"storage", b"split_up_to_n_sui_from_sui_pool"); + target(@liquid_staking, b"storage", b"split_from_sui_pool"); + target(@liquid_staking, b"storage", b"unstake_approx_n_sui_from_validator"); + target(@liquid_staking, b"storage", b"unstake_approx_n_sui_from_active_stake"); + target(@liquid_staking, b"storage", b"unstake_approx_n_sui_from_inactive_stake"); + target(@liquid_staking, b"storage", b"split_n_sui"); + target(@liquid_staking, b"storage", b"get_or_add_validator_index_by_staking_pool_id_mut"); + + invoker(b"invoke"); + + rule(b"total_sui_supply_correct_base"); + rule(b"total_sui_supply_correct_step"); +} + +native fun invoke( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +); + +fun staked_active(strg: &Storage, i: u64): u64 { + let validator_info = &strg.validators()[i]; + if (validator_info.active_stake().is_some()) { + let active_stake = validator_info.active_stake().borrow(); + get_sui_amount( + validator_info.exchange_rate(), + active_stake.value(), + ) + } else { + 0 + } +} + +fun staked_inactive(strg: &Storage, i: u64): u64 { + let validator_info = &strg.validators()[i]; + if (validator_info.inactive_stake().is_some()) { + let inactive_stake = validator_info.inactive_stake().borrow(); + inactive_stake.staked_sui_amount() + } else { + 0 + } +} + +fun validator_sui_supply(strg: &Storage, i: u64): u64 { + let active_stake = staked_active(strg, i); + let inactive_stake = staked_inactive(strg, i); + + active_stake + inactive_stake +} + +fun current_supply(strg: &Storage): u64 { + let mut i = 0; + let mut v = strg.sui_pool().value(); + + while (i < strg.validators().length()) { + v = v + validator_sui_supply(strg, i); + i = i+1; + }; + v +} + +public fun total_sui_supply_correct_base(ctx: &mut TxContext) { + let strg = storage::new(ctx); + let supply = current_supply(&strg); + let supply_expected = strg.total_sui_supply(); + cvlm_assert(supply == supply_expected); + ghost_destroy(strg); +} + +public fun total_sui_supply_correct_step( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + cvlm_assume_msg(strg.validators().length() <= 1, b"Only one validator"); + + cvlm_assume_msg(ctx.epoch() > strg.last_refresh_epoch(), b"Assume fresh state"); + strg.refresh(system_state, ctx); + + let supply_pre = current_supply(strg); + let supply_expected_pre = strg.total_sui_supply(); + cvlm_assume_msg(supply_pre == supply_expected_pre, b"Assume invariant holds in pre state"); + + invoke(target, strg, system_state, ctx); + + strg.refresh(system_state, ctx); // No necessary but to be extra sure everything is up to date + let supply_post = current_supply(strg); + let supply_expected_post = strg.total_sui_supply(); + cvlm_assert(supply_post == supply_expected_post); +} + + diff --git a/certora/spec/sources/common.move b/certora/spec/sources/common.move index e1aa25d..b1e57be 100644 --- a/certora/spec/sources/common.move +++ b/certora/spec/sources/common.move @@ -10,7 +10,7 @@ public fun setup_fresh( system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { - cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Refresh"); + cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Force refresh"); let mut i = 0; while (i < lsi.storage().validators().length()) { diff --git a/certora/spec/sources/solvency.move b/certora/spec/sources/solvency.move index 99fb8bd..f863741 100644 --- a/certora/spec/sources/solvency.move +++ b/certora/spec/sources/solvency.move @@ -34,8 +34,6 @@ public fun cvlm_manifest() { rule(b"insolvency_bound"); rule(b"monotonicity"); - rule(b"no_lst_no_sui"); - rule(b"no_sui_no_lst"); } const MAX_VALIDATORS: u64 = 1; @@ -132,8 +130,6 @@ public fun insolvency_bound( ); setup_fresh(lsi, system_state, ctx); - // cvlm_assume_msg(lsi.accrued_spread_fees() == 0, b"No fees"); - // cvlm_assume_msg(lsi.total_lst_supply() <= 10000 && lsi.total_lst_supply() <= 10000, b"Reasonable values for CEX"); cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); validate_fees(lsi.fee_config()); @@ -143,75 +139,6 @@ public fun insolvency_bound( cvlm_assert(lsi.total_lst_supply() +1 >= lsi.total_lst_supply()); } -public fun no_lst_no_sui( - target: Function, - lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - cvlm_assume_msg( - lsi.storage().validators().length() <= MAX_VALIDATORS, - b"Restrict number of validators", - ); - setup_fresh(lsi, system_state, ctx); - - cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); - - let lst_pre = lsi.total_lst_supply(); - let sui_pre = lsi.total_sui_supply(); - - // lst=0 -> sui = 0 - // <==> lst != 0 || sui = 0 - cvlm_assume_msg(lst_pre != 0 || sui_pre == 0, b"Assume in pre-state"); - - //let mut ctx2: TxContext = nondet(); - //cvlm_assume_msg(ctx.epoch() <= ctx2.epoch(), b"Time"); - - invoke(target, lsi, system_state, ctx); - - let lst_post = lsi.total_lst_supply(); - let sui_post = lsi.total_sui_supply(); - - // sui_pre/lst_pre <= sui_post/lst_post - // <==> sui_pre*lst_post <= sui_post*lst_pre - - cvlm_assert(lst_post != 0 || sui_post == 0); -} - -public fun no_sui_no_lst( - target: Function, - lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - cvlm_assume_msg( - lsi.storage().validators().length() <= MAX_VALIDATORS, - b"Restrict number of validators", - ); - setup_fresh(lsi, system_state, ctx); - - cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); - - let lst_pre = lsi.total_lst_supply(); - let sui_pre = lsi.total_sui_supply(); - - // sui=0 -> lst=0 - // <==> sui != 0 || lst = 0 - cvlm_assume_msg(sui_pre != 0 || lst_pre == 0, b"Assume in pre-state"); - - //let mut ctx2: TxContext = nondet(); - //cvlm_assume_msg(ctx.epoch() <= ctx2.epoch(), b"Time"); - - invoke(target, lsi, system_state, ctx); - - let lst_post = lsi.total_lst_supply(); - let sui_post = lsi.total_sui_supply(); - - // sui_pre/lst_pre <= sui_post/lst_post - // <==> sui_pre*lst_post <= sui_post*lst_pre - - cvlm_assert(sui_post != 0 || lst_post == 0); -} public fun monotonicity( target: Function, @@ -232,9 +159,6 @@ public fun monotonicity( cvlm_assume_msg(lst_pre > 0 && sui_pre > 0, b"Non-empty reserve"); - //let mut ctx2: TxContext = nondet(); - //cvlm_assume_msg(ctx.epoch() <= ctx2.epoch(), b"Time"); - invoke(target, lsi, system_state, ctx); let lst_post = lsi.total_lst_supply(); diff --git a/certora/spec/sources/consistency.move b/certora/spec/sources/validators_consistency.move similarity index 99% rename from certora/spec/sources/consistency.move rename to certora/spec/sources/validators_consistency.move index 49f8a24..4b5ccae 100644 --- a/certora/spec/sources/consistency.move +++ b/certora/spec/sources/validators_consistency.move @@ -1,4 +1,4 @@ -module spec::consistency; +module spec::validators_consistency; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::function::Function; From ab0b36b8d5f1c5471328a940938e43cfd0e28110 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 13:00:52 +0100 Subject: [PATCH 21/68] Remove invalid summary --- certora/spec/sources/summaries.move | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/certora/spec/sources/summaries.move b/certora/spec/sources/summaries.move index 437cccb..ff56bf9 100644 --- a/certora/spec/sources/summaries.move +++ b/certora/spec/sources/summaries.move @@ -34,12 +34,6 @@ public fun cvlm_manifest() { b"pool_token_exchange_rate_at_epoch", ); - summary( - b"burn", - @sui, - b"coin", - b"burn", - ); ghost(b"fungible_total_supply"); ghost(b"fungible_total_principal"); @@ -265,10 +259,3 @@ public fun request_withdraw_stake_non_entry( ghost_destroy(staked_sui); w } - - -fun burn(cap: &mut TreasuryCap, c: Coin): u64 { - let b = c.value(); - ghost_destroy(c); - b -} \ No newline at end of file From 37c29e63373a8151a4b30fef711c00cf9ef5c9b8 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 13:01:57 +0100 Subject: [PATCH 22/68] Fix no sui iff no lst rule --- .../sources/accounting_no_lst_no_sui.move | 107 ++++++++++++++---- .../sources/accounting_total_sui_supply.move | 19 ++-- 2 files changed, 92 insertions(+), 34 deletions(-) diff --git a/certora/spec/sources/accounting_no_lst_no_sui.move b/certora/spec/sources/accounting_no_lst_no_sui.move index fb36019..dd0e5c4 100644 --- a/certora/spec/sources/accounting_no_lst_no_sui.move +++ b/certora/spec/sources/accounting_no_lst_no_sui.move @@ -8,6 +8,11 @@ use spec::dummy::DummyToken; use sui_system::sui_system::SuiSystemState; use spec::common::setup_fresh; use spec::solvency::is_solvent; +use spec::accounting_total_sui_supply::total_supply_correct; +use cvlm::ghost::ghost_destroy; +use liquid_staking::liquid_staking::create_lst; +use cvlm::nondet::nondet; +use liquid_staking::liquid_staking::create_lst_with_stake; public fun cvlm_manifest() { // Public mut functions @@ -25,8 +30,11 @@ public fun cvlm_manifest() { invoker(b"invoke"); - rule(b"no_lst_no_sui"); - rule(b"no_sui_no_lst"); + rule(b"no_lst_no_sui_step"); + rule(b"no_lst_no_sui_base"); + + rule(b"no_sui_no_lst_base"); + rule(b"no_sui_no_lst_step"); } const MAX_VALIDATORS: u64 = 1; @@ -39,9 +47,46 @@ native fun invoke( ); +public fun no_lst_no_sui

(lsi: &LiquidStakingInfo

): bool { + let lst = lsi.total_lst_supply(); + let sui = lsi.total_sui_supply(); + // lst == 0 -> sui == 0 <==> lst != 0 || sui == 0 + lst != 0 || sui == 0 +} + +public fun no_sui_no_lst

(lsi: &LiquidStakingInfo

): bool { + let lst = lsi.total_lst_supply(); + let sui = lsi.total_sui_supply(); + // sui == 0 -> lst == 0 <==> sui != 0 || lst == 0 + sui != 0 || lst == 0 +} +public fun no_lst_no_sui_base( + ctx: &mut TxContext, +) { + let fee_config = nondet(); + let lst_treasury_cap = nondet(); + let (_cap, lsi) = create_lst(fee_config, lst_treasury_cap, ctx); + cvlm_assert(no_lst_no_sui(&lsi)); + + ghost_destroy(lsi); + ghost_destroy(_cap); + + let fee_config = nondet(); + let lst_treasury_cap = nondet(); + let mut system_state = nondet(); + let fungible_staked_suis = nondet(); + let sui = nondet(); + let (_cap, lsi) = create_lst_with_stake(&mut system_state, fee_config, lst_treasury_cap, fungible_staked_suis, sui, ctx); + cvlm_assert(no_lst_no_sui(&lsi)); + + ghost_destroy(lsi); + ghost_destroy(_cap); + ghost_destroy(system_state); + +} -public fun no_lst_no_sui( +public fun no_lst_no_sui_step( target: Function, lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, @@ -54,27 +99,47 @@ public fun no_lst_no_sui( setup_fresh(lsi, system_state, ctx); cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); + + cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Correct accounting"); - let lst_pre = lsi.total_lst_supply(); - let sui_pre = lsi.total_sui_supply(); - // lst=0 -> sui = 0 - // <==> lst != 0 || sui = 0 - cvlm_assume_msg(lst_pre != 0 || sui_pre == 0, b"Assume in pre-state"); + cvlm_assume_msg(no_lst_no_sui(lsi), b"Assume in pre-state"); invoke(target, lsi, system_state, ctx); - let lst_post = lsi.total_lst_supply(); - let sui_post = lsi.total_sui_supply(); - // sui_pre/lst_pre <= sui_post/lst_post - // <==> sui_pre*lst_post <= sui_post*lst_pre - cvlm_assert(lst_post != 0 || sui_post == 0); + cvlm_assert(no_lst_no_sui(lsi)); } -public fun no_sui_no_lst( + +public fun no_sui_no_lst_base( + ctx: &mut TxContext, +) { + let fee_config = nondet(); + let lst_treasury_cap = nondet(); + let (_cap, lsi) = create_lst(fee_config, lst_treasury_cap, ctx); + cvlm_assert(no_sui_no_lst(&lsi)); + + ghost_destroy(lsi); + ghost_destroy(_cap); + + let fee_config = nondet(); + let lst_treasury_cap = nondet(); + let mut system_state = nondet(); + let fungible_staked_suis = nondet(); + let sui = nondet(); + let (_cap, lsi) = create_lst_with_stake(&mut system_state, fee_config, lst_treasury_cap, fungible_staked_suis, sui, ctx); + cvlm_assert(no_sui_no_lst(&lsi)); + + ghost_destroy(lsi); + ghost_destroy(_cap); + ghost_destroy(system_state); + +} + +public fun no_sui_no_lst_step( target: Function, lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, @@ -87,21 +152,13 @@ public fun no_sui_no_lst( setup_fresh(lsi, system_state, ctx); cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); + cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Correct accounting"); - let lst_pre = lsi.total_lst_supply(); - let sui_pre = lsi.total_sui_supply(); - // sui=0 -> lst=0 - // <==> sui != 0 || lst = 0 - cvlm_assume_msg(sui_pre != 0 || lst_pre == 0, b"Assume in pre-state"); + cvlm_assume_msg(no_sui_no_lst(lsi), b"Assume in pre-state"); invoke(target, lsi, system_state, ctx); - let lst_post = lsi.total_lst_supply(); - let sui_post = lsi.total_sui_supply(); - - // sui_pre/lst_pre <= sui_post/lst_post - // <==> sui_pre*lst_post <= sui_post*lst_pre - cvlm_assert(sui_post != 0 || lst_post == 0); + cvlm_assert(no_sui_no_lst(lsi)); } \ No newline at end of file diff --git a/certora/spec/sources/accounting_total_sui_supply.move b/certora/spec/sources/accounting_total_sui_supply.move index 2d32d5f..78342af 100644 --- a/certora/spec/sources/accounting_total_sui_supply.move +++ b/certora/spec/sources/accounting_total_sui_supply.move @@ -79,11 +79,15 @@ fun current_supply(strg: &Storage): u64 { v } +public fun total_supply_correct(strg: &Storage): bool { + let expected = current_supply(strg); + let actual = strg.total_sui_supply(); + expected == actual +} + public fun total_sui_supply_correct_base(ctx: &mut TxContext) { let strg = storage::new(ctx); - let supply = current_supply(&strg); - let supply_expected = strg.total_sui_supply(); - cvlm_assert(supply == supply_expected); + cvlm_assert(total_supply_correct(&strg)); ghost_destroy(strg); } @@ -98,16 +102,13 @@ public fun total_sui_supply_correct_step( cvlm_assume_msg(ctx.epoch() > strg.last_refresh_epoch(), b"Assume fresh state"); strg.refresh(system_state, ctx); - let supply_pre = current_supply(strg); - let supply_expected_pre = strg.total_sui_supply(); - cvlm_assume_msg(supply_pre == supply_expected_pre, b"Assume invariant holds in pre state"); + + cvlm_assume_msg(total_supply_correct(strg), b"Assume invariant holds in pre state"); invoke(target, strg, system_state, ctx); strg.refresh(system_state, ctx); // No necessary but to be extra sure everything is up to date - let supply_post = current_supply(strg); - let supply_expected_post = strg.total_sui_supply(); - cvlm_assert(supply_post == supply_expected_post); + cvlm_assert(total_supply_correct(strg)); } From bf6b8c2d6c9ce0faf57a94dc450fc4f105fc40ac Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 13:39:34 +0100 Subject: [PATCH 23/68] Fix spurious cex in solvency rules --- certora/spec/sources/solvency.move | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/certora/spec/sources/solvency.move b/certora/spec/sources/solvency.move index f863741..1f803c6 100644 --- a/certora/spec/sources/solvency.move +++ b/certora/spec/sources/solvency.move @@ -10,6 +10,7 @@ use liquid_staking::liquid_staking::{Self, LiquidStakingInfo}; use spec::dummy::DummyToken; use sui_system::sui_system::SuiSystemState; use spec::common::setup_fresh; +use spec::accounting_total_sui_supply::total_supply_correct; public fun cvlm_manifest() { // Public mut functions @@ -31,7 +32,9 @@ public fun cvlm_manifest() { rule(b"solvency_base"); rule(b"solvency_base_staker"); rule(b"solvency_step"); - rule(b"insolvency_bound"); + + // This rule verifies an upper bound of 1 for insolvency per operation + // rule(b"insolvency_bound"); rule(b"monotonicity"); } @@ -110,6 +113,7 @@ public fun solvency_step( // cvlm_assume_msg(lsi.accrued_spread_fees() == 0, b"No fees"); // cvlm_assume_msg(lsi.total_lst_supply() <= 10000 && lsi.total_lst_supply() <= 10000, b"Reasonable values for CEX"); cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); + cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Correct accounting"); validate_fees(lsi.fee_config()); @@ -151,6 +155,7 @@ public fun monotonicity( b"Restrict number of validators", ); setup_fresh(lsi, system_state, ctx); + cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Correct accounting"); //cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); From d075725c7cccf11ea6386c5d7edd896a9207e3de Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 13:59:43 +0100 Subject: [PATCH 24/68] Value conservation rules --- .../confs/accounting_value_conservation.conf | 20 +++ .../accounting_value_conservation.move | 148 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 certora/spec/confs/accounting_value_conservation.conf create mode 100644 certora/spec/sources/accounting_value_conservation.move diff --git a/certora/spec/confs/accounting_value_conservation.conf b/certora/spec/confs/accounting_value_conservation.conf new file mode 100644 index 0000000..92ba159 --- /dev/null +++ b/certora/spec/confs/accounting_value_conservation.conf @@ -0,0 +1,20 @@ +{ + "optimistic_loop": true, + "server": "prover", + "prover_version": "master", + "rule": [ + "*accounting_value_conservation*" + ], + "msg": "SUI value conservation", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true", + "-depth 20" + ] +} \ No newline at end of file diff --git a/certora/spec/sources/accounting_value_conservation.move b/certora/spec/sources/accounting_value_conservation.move new file mode 100644 index 0000000..759f9b8 --- /dev/null +++ b/certora/spec/sources/accounting_value_conservation.move @@ -0,0 +1,148 @@ +module spec::accounting_value_conservation; + +use cvlm::asserts::{cvlm_assert}; +use cvlm::function::Function; +use cvlm::manifest::{target, invoker}; +use liquid_staking::liquid_staking::{LiquidStakingInfo}; +use spec::dummy::DummyToken; +use sui_system::sui_system::SuiSystemState; +use cvlm::manifest::rule; +use cvlm::asserts::cvlm_assume_msg; +use spec::common::setup_fresh; +use spec::accounting_total_sui_supply::total_supply_correct; +use cvlm::nondet::nondet; +use cvlm::ghost::ghost_destroy; +use sui::coin::Coin; +use sui::sui::SUI; +use spec::common::log; + +public fun cvlm_manifest() { + // Public mut functions + target(@spec, b"dummy", b"mint"); + target(@spec, b"dummy", b"redeem"); + target(@spec, b"dummy", b"custom_redeem_request"); + target(@spec, b"dummy", b"custom_redeem"); + target(@spec, b"dummy", b"change_validator_priority"); + target(@spec, b"dummy", b"increase_validator_stake"); + target(@spec, b"dummy", b"decrease_validator_stake"); + target(@spec, b"dummy", b"collect_fees"); + target(@spec, b"dummy", b"update_fees"); + target(@spec, b"dummy", b"refresh"); + target(@spec, b"dummy", b"update_metadata"); + + invoker(b"invoke"); + + rule(b"sui_value_conservation"); + rule(b"lst_value_conservation"); + + rule(b"deposit_value_conservation"); + rule(b"redeem_value_conservation"); + +} + +native fun invoke( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +); + + +fun can_decrease_supply(f: Function): bool { + f.name() == b"redeem" || f.name() == b"custom_redeem" +} + +public fun sui_value_conservation( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + + setup_fresh(lsi, system_state, ctx); + let sui_pre = lsi.total_sui_supply(); + + cvlm_assume_msg(!can_decrease_supply(target), b"Cannot decrease sui supply"); + cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Sound state"); + invoke(target, lsi, system_state, ctx); + + let sui_post = lsi.total_sui_supply(); + + cvlm_assert(sui_post >= sui_pre); +} + + +public fun lst_value_conservation( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + let lst_pre = lsi.total_lst_supply(); + cvlm_assume_msg(!can_decrease_supply(target), b"Cannot decrease sui supply"); + invoke(target, lsi, system_state, ctx); + + let lst_post = lsi.total_lst_supply(); + + cvlm_assert(lst_post >= lst_pre); +} + + +public fun deposit_value_conservation( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Sound state"); + + let sui: Coin = nondet(); + let sui_value = sui.value(); + let fees_pre = lsi.fees(); + let sui_pre = lsi.total_sui_supply(); + let lst = lsi.mint(system_state, sui, ctx); + + cvlm_assert(lsi.total_sui_supply() >= sui_pre); + cvlm_assert(lsi.fees() >= fees_pre); + let sui_increase = lsi.total_sui_supply() - sui_pre; + let fee_increase = lsi.fees() - fees_pre; + + + cvlm_assert(sui_increase + fee_increase == sui_value); + + + ghost_destroy(lst); +} + + +public fun redeem_value_conservation( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Sound state"); + + let lst: Coin = nondet(); + + let fees_pre = lsi.fees(); + let sui_pre = lsi.total_sui_supply(); + + let sui = lsi.redeem(lst, system_state, ctx); + + cvlm_assert(lsi.total_sui_supply() <= sui_pre); + cvlm_assert(lsi.fees() >= fees_pre); + + let sui_decrease = sui_pre - lsi.total_sui_supply(); + let fee_increase = lsi.fees() - fees_pre; + + log(&fee_increase); + log(&sui.value()); + log(&(fee_increase + sui.value())); + log(&sui_decrease); + cvlm_assert(fee_increase + sui.value() == sui_decrease); + + + ghost_destroy(sui); +} \ No newline at end of file From 42962819e20adc6edb8b1b51edd074a1d7ca5d3b Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 14:01:41 +0100 Subject: [PATCH 25/68] ci: Update conf files --- .github/workflows/certora.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 4103aa2..b117d07 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -34,9 +34,12 @@ jobs: ecosystem: sui working-directory: certora/spec/ configurations: |- - confs/consistency.conf + confs/accounting_no_lst_no_sui.conf + confs/accounting_total_sui_supply.conf + confs/accounting_value_conservation.conf confs/integrity.conf confs/solvency.conf + confs/validators_consistency.conf job-name: "Verify Obligation Health Rules" certora-key: ${{ secrets.CERTORAKEY }} env: From 9e44d51bf17c062e7cbf271c396f745539da5e29 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 14:02:39 +0100 Subject: [PATCH 26/68] ci: print sui version --- .github/workflows/certora.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index b117d07..4574709 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -26,6 +26,10 @@ jobs: curl -sSfL https://raw.githubusercontent.com/Mystenlabs/suiup/main/install.sh | sh export PATH="$HOME/.local/bin:$PATH" suiup install -y sui@testnet + - name: Sui version + run: | + sui -V + sui move -V - name: Apply patch run: ./certora/munges/munge.sh - name: Submit Jobs to Certora Prover From a7b49e3297ab19fc95bd58b2bacb65e4aad6d8cb Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 14:03:32 +0100 Subject: [PATCH 27/68] ci: Fix sui version 1.53.2 --- .github/workflows/certora.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 4574709..672c673 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -25,7 +25,7 @@ jobs: run: | curl -sSfL https://raw.githubusercontent.com/Mystenlabs/suiup/main/install.sh | sh export PATH="$HOME/.local/bin:$PATH" - suiup install -y sui@testnet + suiup install -y sui@1.53.2 - name: Sui version run: | sui -V From 73b8cd935cc8c31ed5dee13fab761487337af5c0 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 14:12:04 +0100 Subject: [PATCH 28/68] Fix timeouts --- certora/spec/confs/accounting_no_lst_no_sui.conf | 7 ++++++- certora/spec/confs/accounting_value_conservation.conf | 6 +++++- certora/spec/confs/solvency.conf | 7 ++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/certora/spec/confs/accounting_no_lst_no_sui.conf b/certora/spec/confs/accounting_no_lst_no_sui.conf index e4d3bee..a4b753f 100644 --- a/certora/spec/confs/accounting_no_lst_no_sui.conf +++ b/certora/spec/confs/accounting_no_lst_no_sui.conf @@ -14,6 +14,11 @@ "-maxBlockCount 1000000", "-tacDumpsWithInternalFunctions true", "-callTraceVecElemCount 0", - "-dumpCodeSizeAnalysis true" + "-dumpCodeSizeAnalysis true", + "-depth 20", + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" ] } \ No newline at end of file diff --git a/certora/spec/confs/accounting_value_conservation.conf b/certora/spec/confs/accounting_value_conservation.conf index 92ba159..12de91f 100644 --- a/certora/spec/confs/accounting_value_conservation.conf +++ b/certora/spec/confs/accounting_value_conservation.conf @@ -15,6 +15,10 @@ "-tacDumpsWithInternalFunctions true", "-callTraceVecElemCount 0", "-dumpCodeSizeAnalysis true", - "-depth 20" + "-depth 20", + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" ] } \ No newline at end of file diff --git a/certora/spec/confs/solvency.conf b/certora/spec/confs/solvency.conf index 808a9a8..c0713b0 100644 --- a/certora/spec/confs/solvency.conf +++ b/certora/spec/confs/solvency.conf @@ -14,6 +14,11 @@ "-maxBlockCount 1000000", "-tacDumpsWithInternalFunctions true", "-callTraceVecElemCount 0", - "-dumpCodeSizeAnalysis true" + "-dumpCodeSizeAnalysis true", + "-depth 20", + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" ] } \ No newline at end of file From 7b4bb9b8957ea2466d5429828924d3087a9f1941 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 15:34:40 +0100 Subject: [PATCH 29/68] Add accounting integrity rules --- .github/workflows/certora.yml | 1 + certora/spec/confs/accounting_integrity.conf | 24 +++++ .../spec/sources/accounting_integrity.move | 91 +++++++++++++++++++ .../accounting_value_conservation.move | 36 ++------ 4 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 certora/spec/confs/accounting_integrity.conf create mode 100644 certora/spec/sources/accounting_integrity.move diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 672c673..862fbe3 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -38,6 +38,7 @@ jobs: ecosystem: sui working-directory: certora/spec/ configurations: |- + confs/accounting_integrity.conf confs/accounting_no_lst_no_sui.conf confs/accounting_total_sui_supply.conf confs/accounting_value_conservation.conf diff --git a/certora/spec/confs/accounting_integrity.conf b/certora/spec/confs/accounting_integrity.conf new file mode 100644 index 0000000..cb75399 --- /dev/null +++ b/certora/spec/confs/accounting_integrity.conf @@ -0,0 +1,24 @@ +{ + "optimistic_loop": true, + "server": "prover", + "prover_version": "master", + "rule": [ + "*accounting_integrity*" + ], + "msg": "Various accounting integrity rules", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true", + "-depth 20", + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" + ] +} \ No newline at end of file diff --git a/certora/spec/sources/accounting_integrity.move b/certora/spec/sources/accounting_integrity.move new file mode 100644 index 0000000..1db29ed --- /dev/null +++ b/certora/spec/sources/accounting_integrity.move @@ -0,0 +1,91 @@ +module spec::accounting_integrity; + +use cvlm::asserts::{cvlm_assert}; +use cvlm::function::Function; +use cvlm::manifest::{target, invoker}; +use liquid_staking::liquid_staking::{LiquidStakingInfo}; +use spec::dummy::DummyToken; +use sui_system::sui_system::SuiSystemState; +use cvlm::manifest::rule; +use cvlm::asserts::cvlm_assume_msg; +use spec::common::setup_fresh; +use spec::accounting_total_sui_supply::total_supply_correct; +use spec::common::can_decrease_supply; +use spec::common::can_increase_supply; + +public fun cvlm_manifest() { + // Public mut functions + target(@spec, b"dummy", b"mint"); + target(@spec, b"dummy", b"redeem"); + target(@spec, b"dummy", b"custom_redeem_request"); + target(@spec, b"dummy", b"custom_redeem"); + target(@spec, b"dummy", b"change_validator_priority"); + target(@spec, b"dummy", b"increase_validator_stake"); + target(@spec, b"dummy", b"decrease_validator_stake"); + target(@spec, b"dummy", b"collect_fees"); + target(@spec, b"dummy", b"update_fees"); + target(@spec, b"dummy", b"refresh"); + target(@spec, b"dummy", b"update_metadata"); + + invoker(b"invoke"); + + rule(b"only_redemption_decreases_supply"); + rule(b"only_minting_increases_supply"); +} + +native fun invoke( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +); + + + +public fun only_redemption_decreases_supply( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + + setup_fresh(lsi, system_state, ctx); + let sui_pre = lsi.total_sui_supply(); + let lst_pre = lsi.total_lst_supply(); + + cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Sound state"); + invoke(target, lsi, system_state, ctx); + + let sui_post = lsi.total_sui_supply(); + let lst_post = lsi.total_lst_supply(); + let sui_decrease = sui_post < sui_pre; + let lst_decrease = lst_post < lst_pre; + + let decreased = sui_decrease || lst_decrease; + + cvlm_assert(!decreased || can_decrease_supply(target)); +} + +public fun only_minting_increases_supply( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + + setup_fresh(lsi, system_state, ctx); + let sui_pre = lsi.total_sui_supply(); + let lst_pre = lsi.total_lst_supply(); + + cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Sound state"); + invoke(target, lsi, system_state, ctx); + + let sui_post = lsi.total_sui_supply(); + let lst_post = lsi.total_lst_supply(); + let sui_increase = sui_post > sui_pre; + let lst_increase = lst_post > lst_pre; + + let increased = sui_increase || lst_increase; + + cvlm_assert(!increased || can_increase_supply(target)); +} \ No newline at end of file diff --git a/certora/spec/sources/accounting_value_conservation.move b/certora/spec/sources/accounting_value_conservation.move index 759f9b8..b5f25db 100644 --- a/certora/spec/sources/accounting_value_conservation.move +++ b/certora/spec/sources/accounting_value_conservation.move @@ -1,20 +1,17 @@ module spec::accounting_value_conservation; -use cvlm::asserts::{cvlm_assert}; +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::function::Function; -use cvlm::manifest::{target, invoker}; -use liquid_staking::liquid_staking::{LiquidStakingInfo}; -use spec::dummy::DummyToken; -use sui_system::sui_system::SuiSystemState; -use cvlm::manifest::rule; -use cvlm::asserts::cvlm_assume_msg; -use spec::common::setup_fresh; -use spec::accounting_total_sui_supply::total_supply_correct; -use cvlm::nondet::nondet; use cvlm::ghost::ghost_destroy; +use cvlm::manifest::{target, invoker, rule}; +use cvlm::nondet::nondet; +use liquid_staking::liquid_staking::LiquidStakingInfo; +use spec::accounting_total_sui_supply::total_supply_correct; +use spec::common::{setup_fresh, log, can_decrease_supply}; +use spec::dummy::DummyToken; use sui::coin::Coin; use sui::sui::SUI; -use spec::common::log; +use sui_system::sui_system::SuiSystemState; public fun cvlm_manifest() { // Public mut functions @@ -34,10 +31,9 @@ public fun cvlm_manifest() { rule(b"sui_value_conservation"); rule(b"lst_value_conservation"); - + rule(b"deposit_value_conservation"); rule(b"redeem_value_conservation"); - } native fun invoke( @@ -47,18 +43,12 @@ native fun invoke( ctx: &mut TxContext, ); - -fun can_decrease_supply(f: Function): bool { - f.name() == b"redeem" || f.name() == b"custom_redeem" -} - public fun sui_value_conservation( target: Function, lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { - setup_fresh(lsi, system_state, ctx); let sui_pre = lsi.total_sui_supply(); @@ -71,7 +61,6 @@ public fun sui_value_conservation( cvlm_assert(sui_post >= sui_pre); } - public fun lst_value_conservation( target: Function, lsi: &mut LiquidStakingInfo, @@ -88,7 +77,6 @@ public fun lst_value_conservation( cvlm_assert(lst_post >= lst_pre); } - public fun deposit_value_conservation( lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, @@ -108,14 +96,11 @@ public fun deposit_value_conservation( let sui_increase = lsi.total_sui_supply() - sui_pre; let fee_increase = lsi.fees() - fees_pre; - cvlm_assert(sui_increase + fee_increase == sui_value); - ghost_destroy(lst); } - public fun redeem_value_conservation( lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, @@ -143,6 +128,5 @@ public fun redeem_value_conservation( log(&sui_decrease); cvlm_assert(fee_increase + sui.value() == sui_decrease); - ghost_destroy(sui); -} \ No newline at end of file +} From d570a4f6837361d3b7f15c7652a59b6aba7fd73b Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 13 Jan 2026 15:51:41 +0100 Subject: [PATCH 30/68] Cleanup --- .github/workflows/certora.yml | 7 +++---- .../spec/sources/accounting_integrity.move | 20 ++++++------------- certora/spec/sources/common.move | 11 +++++++++- certora/spec/sources/summaries.move | 4 +--- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 862fbe3..d894064 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -1,15 +1,14 @@ name: Certora Prover on: - push: + pull_request: branches: - kel/specs - certora - pull_request: workflow_dispatch: jobs: - certora_run_health: + certora_run: runs-on: ubuntu-latest permissions: contents: read @@ -45,7 +44,7 @@ jobs: confs/integrity.conf confs/solvency.conf confs/validators_consistency.conf - job-name: "Verify Obligation Health Rules" + job-name: "Verification" certora-key: ${{ secrets.CERTORAKEY }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/certora/spec/sources/accounting_integrity.move b/certora/spec/sources/accounting_integrity.move index 1db29ed..830356d 100644 --- a/certora/spec/sources/accounting_integrity.move +++ b/certora/spec/sources/accounting_integrity.move @@ -1,17 +1,13 @@ module spec::accounting_integrity; -use cvlm::asserts::{cvlm_assert}; +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::function::Function; -use cvlm::manifest::{target, invoker}; -use liquid_staking::liquid_staking::{LiquidStakingInfo}; +use cvlm::manifest::{target, invoker, rule}; +use liquid_staking::liquid_staking::LiquidStakingInfo; +use spec::accounting_total_sui_supply::total_supply_correct; +use spec::common::{setup_fresh, can_decrease_supply, can_increase_supply}; use spec::dummy::DummyToken; use sui_system::sui_system::SuiSystemState; -use cvlm::manifest::rule; -use cvlm::asserts::cvlm_assume_msg; -use spec::common::setup_fresh; -use spec::accounting_total_sui_supply::total_supply_correct; -use spec::common::can_decrease_supply; -use spec::common::can_increase_supply; public fun cvlm_manifest() { // Public mut functions @@ -40,15 +36,12 @@ native fun invoke( ctx: &mut TxContext, ); - - public fun only_redemption_decreases_supply( target: Function, lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { - setup_fresh(lsi, system_state, ctx); let sui_pre = lsi.total_sui_supply(); let lst_pre = lsi.total_lst_supply(); @@ -72,7 +65,6 @@ public fun only_minting_increases_supply( system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { - setup_fresh(lsi, system_state, ctx); let sui_pre = lsi.total_sui_supply(); let lst_pre = lsi.total_lst_supply(); @@ -88,4 +80,4 @@ public fun only_minting_increases_supply( let increased = sui_increase || lst_increase; cvlm_assert(!increased || can_increase_supply(target)); -} \ No newline at end of file +} diff --git a/certora/spec/sources/common.move b/certora/spec/sources/common.move index b1e57be..fb35f43 100644 --- a/certora/spec/sources/common.move +++ b/certora/spec/sources/common.move @@ -3,6 +3,7 @@ module spec::common; use liquid_staking::liquid_staking::LiquidStakingInfo; use sui_system::sui_system::SuiSystemState; use cvlm::asserts::cvlm_assume_msg; +use cvlm::function::Function; public fun setup_fresh( @@ -31,4 +32,12 @@ public fun setup_fresh( lsi.refresh(system_state, ctx); } -public fun log(_: &T) {} \ No newline at end of file +public fun log(_: &T) {} + +public fun can_decrease_supply(f: Function): bool { + f.name() == b"redeem" || f.name() == b"custom_redeem" +} + +public fun can_increase_supply(f: Function): bool { + f.name() == b"mint" +} \ No newline at end of file diff --git a/certora/spec/sources/summaries.move b/certora/spec/sources/summaries.move index ff56bf9..b12b573 100644 --- a/certora/spec/sources/summaries.move +++ b/certora/spec/sources/summaries.move @@ -13,8 +13,6 @@ use sui::tx_context::epoch; use sui_system::staking_pool::{PoolTokenExchangeRate, StakedSui, StakingPool, FungibleStakedSui}; use sui_system::sui_system::SuiSystemState; use cvlm::asserts::cvlm_assert; -use sui::coin::TreasuryCap; -use sui::coin::Coin; public fun cvlm_manifest() { ghost(b"exchange_rate"); @@ -169,7 +167,7 @@ public fun redeem_fungible_staked_sui( sui_out } -fun calculate_fungible_staked_sui_withdraw_amount( +public(package) fun calculate_fungible_staked_sui_withdraw_amount( latest_exchange_rate: PoolTokenExchangeRate, fungible_staked_sui_value: u64, fungible_staked_sui_data_principal_amount: u64, // fungible_staked_sui_data.principal.value() From 7aded0759b8c3a2bb53517cade3016bbdb9589ba Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Wed, 14 Jan 2026 09:53:16 +0100 Subject: [PATCH 31/68] Rules for fees --- .github/workflows/certora.yml | 1 + certora/spec/confs/fees.conf | 19 +++++ certora/spec/sources/fees.move | 108 ++++++++++++++++++++++++++++ certora/spec/sources/integrity.move | 42 ----------- 4 files changed, 128 insertions(+), 42 deletions(-) create mode 100644 certora/spec/confs/fees.conf create mode 100644 certora/spec/sources/fees.move diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index d894064..99a169b 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -41,6 +41,7 @@ jobs: confs/accounting_no_lst_no_sui.conf confs/accounting_total_sui_supply.conf confs/accounting_value_conservation.conf + confs/fees.conf confs/integrity.conf confs/solvency.conf confs/validators_consistency.conf diff --git a/certora/spec/confs/fees.conf b/certora/spec/confs/fees.conf new file mode 100644 index 0000000..cd8f423 --- /dev/null +++ b/certora/spec/confs/fees.conf @@ -0,0 +1,19 @@ +{ + "optimistic_loop": true, + "server": "prover", + "prover_version": "master", + "rule": [ + "*fees*" + ], + "msg": "Integrity of fees", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true" + ] +} \ No newline at end of file diff --git a/certora/spec/sources/fees.move b/certora/spec/sources/fees.move new file mode 100644 index 0000000..a6c16dc --- /dev/null +++ b/certora/spec/sources/fees.move @@ -0,0 +1,108 @@ +module spec::fees; + +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; +use cvlm::function::Function; +use cvlm::ghost::ghost_destroy; +use cvlm::manifest::{rule, target}; +use cvlm::nondet::nondet; +use liquid_staking::fees::validate_fees; +use liquid_staking::liquid_staking::LiquidStakingInfo; +use spec::common::setup_fresh; +use spec::dummy::DummyToken; +use sui::coin::Coin; +use sui::sui::SUI; +use sui_system::sui_system::SuiSystemState; +use cvlm::manifest::invoker; + +public fun cvlm_manifest() { + target(@spec, b"dummy", b"mint"); + target(@spec, b"dummy", b"redeem"); + target(@spec, b"dummy", b"custom_redeem_request"); + target(@spec, b"dummy", b"custom_redeem"); + target(@spec, b"dummy", b"change_validator_priority"); + target(@spec, b"dummy", b"increase_validator_stake"); + target(@spec, b"dummy", b"decrease_validator_stake"); + target(@spec, b"dummy", b"collect_fees"); + target(@spec, b"dummy", b"update_fees"); + target(@spec, b"dummy", b"refresh"); + target(@spec, b"dummy", b"update_metadata"); + + invoker(b"invoke"); + + rule(b"fees_grow_monotonically"); + rule(b"fees_dont_eat_deposit"); + rule(b"fees_dont_eat_redemption"); +} + +native fun invoke( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +); + + +public fun fees_grow_monotonically( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + validate_fees(lsi.fee_config()); + let spread_fees_pre = lsi.fees(); + + invoke(target, lsi, system_state, ctx); + + let spread_fees_post = lsi.fees(); + + let collected = target.name() == b"collect_fees"; + let increased = spread_fees_post >= spread_fees_pre; + + // !collected -> increased <==> collected || increased + cvlm_assert(collected || increased); +} + + +public fun fees_dont_eat_redemption( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + validate_fees(lsi.fee_config()); + + let coin: Coin = nondet(); + + let fees_pre = lsi.fees(); + + cvlm_assume_msg(coin.value() > 0, b"Non-zero value"); + let sui = lsi.redeem(coin, system_state, ctx); + + let fees = lsi.fees() - fees_pre; + + cvlm_assume_msg(sui.value() + fees > 0, b"No lost funds"); + cvlm_assert(sui.value() > 0); + ghost_destroy(sui); +} + +public fun fees_dont_eat_deposit( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + validate_fees(lsi.fee_config()); + + let coin: Coin = nondet(); + + let fees_pre = lsi.fees(); + + cvlm_assume_msg(coin.value() > 0, b"Non-zero value"); + let lst = lsi.mint(system_state, coin, ctx); + let fees = lsi.fees() - fees_pre; + + cvlm_assume_msg(lst.value()+fees > 0, b"No lost funds"); + cvlm_assert(lst.value() > 0); + ghost_destroy(lst); +} diff --git a/certora/spec/sources/integrity.move b/certora/spec/sources/integrity.move index 5c293d7..d5d0b18 100644 --- a/certora/spec/sources/integrity.move +++ b/certora/spec/sources/integrity.move @@ -46,28 +46,6 @@ public fun no_lost_funds_on_redeem( ghost_destroy(sui); } -public fun fees_dont_eat_redemption( - lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - setup_fresh(lsi, system_state, ctx); - validate_fees(lsi.fee_config()); - - - let coin: Coin = nondet(); - - let fees_pre = lsi.fees(); - - cvlm_assume_msg(coin.value() > 0, b"Non-zero value"); - let sui = lsi.redeem(coin, system_state, ctx); - - let fees = lsi.fees() - fees_pre; - - cvlm_assume_msg(sui.value() + fees > 0, b"No lost funds"); - cvlm_assert(sui.value() > 0); - ghost_destroy(sui); -} public fun no_lost_funds_on_mint( @@ -90,27 +68,7 @@ public fun no_lost_funds_on_mint( } -public fun fees_dont_eat_deposit( - lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - setup_fresh(lsi, system_state, ctx); - validate_fees(lsi.fee_config()); - - - let coin: Coin = nondet(); - - let fees_pre = lsi.fees(); - - cvlm_assume_msg(coin.value() > 0, b"Non-zero value"); - let lst = lsi.mint(system_state, coin, ctx); - let fees = lsi.fees() - fees_pre; - cvlm_assume_msg(lst.value()+fees > 0, b"No lost funds"); - cvlm_assert(lst.value() > 0); - ghost_destroy(lst); -} public fun no_arbitrage_opportunity( From 830bc61d300ca02a334b94f350b26bb0da1c70aa Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Wed, 14 Jan 2026 09:54:16 +0100 Subject: [PATCH 32/68] Fix timeout in arbitrage opportunity rule --- certora/spec/confs/integrity.conf | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/certora/spec/confs/integrity.conf b/certora/spec/confs/integrity.conf index 437b300..e92a9a9 100644 --- a/certora/spec/confs/integrity.conf +++ b/certora/spec/confs/integrity.conf @@ -14,6 +14,8 @@ "-maxBlockCount 1000000", "-tacDumpsWithInternalFunctions true", "-callTraceVecElemCount 0", - "-dumpCodeSizeAnalysis true" + "-dumpCodeSizeAnalysis true", + "-splitParallel true", + "-dontStopAtFirstSplitTimeout true" ] } \ No newline at end of file From 27c6efa1418112e7bfb3f3df0c954c14358332b6 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Wed, 14 Jan 2026 09:54:31 +0100 Subject: [PATCH 33/68] wip: add liveness rule for redemptions --- certora/spec/sources/integrity.move | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/certora/spec/sources/integrity.move b/certora/spec/sources/integrity.move index d5d0b18..8c79e31 100644 --- a/certora/spec/sources/integrity.move +++ b/certora/spec/sources/integrity.move @@ -17,9 +17,10 @@ use sui::sui::SUI; public fun cvlm_manifest() { rule(b"no_lost_funds_on_redeem"); rule(b"no_lost_funds_on_mint"); - rule(b"fees_dont_eat_deposit"); - rule(b"fees_dont_eat_redemption"); rule(b"no_arbitrage_opportunity"); + + // Currently cannot be expressed + //rule(b"redemption_liveness"); } public fun no_lost_funds_on_redeem( @@ -69,6 +70,19 @@ public fun no_lost_funds_on_mint( +public fun redemption_liveness(lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + validate_fees(lsi.fee_config()); + + let lst: Coin = nondet(); + cvlm_assume_msg(lst.value() <= lsi.total_lst_supply(), b"Redeem at most the total supply"); + let sui_out = lsi.redeem(lst, system_state, ctx); + ghost_destroy(sui_out); + cvlm_assert(true); // need to asert the call to redeem did not abort. +} public fun no_arbitrage_opportunity( From 688fbad02a4d3a744c4b931dfe82b98158b103cf Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Wed, 14 Jan 2026 10:22:08 +0100 Subject: [PATCH 34/68] chore: rename --- .github/workflows/certora.yml | 2 +- .../spec/confs/{integrity.conf => mint_redeem_integrity.conf} | 2 +- .../spec/sources/{integrity.move => mint_redeem_integrity.move} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename certora/spec/confs/{integrity.conf => mint_redeem_integrity.conf} (94%) rename certora/spec/sources/{integrity.move => mint_redeem_integrity.move} (100%) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 99a169b..a1125f4 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -42,7 +42,7 @@ jobs: confs/accounting_total_sui_supply.conf confs/accounting_value_conservation.conf confs/fees.conf - confs/integrity.conf + confs/mint_redeem_integrity.conf confs/solvency.conf confs/validators_consistency.conf job-name: "Verification" diff --git a/certora/spec/confs/integrity.conf b/certora/spec/confs/mint_redeem_integrity.conf similarity index 94% rename from certora/spec/confs/integrity.conf rename to certora/spec/confs/mint_redeem_integrity.conf index e92a9a9..96bc951 100644 --- a/certora/spec/confs/integrity.conf +++ b/certora/spec/confs/mint_redeem_integrity.conf @@ -3,7 +3,7 @@ "server": "prover", "prover_version": "master", "rule": [ - "*integrity*" + "*mint_redeem_integrity*" ], "msg": "Various external flow integrity rules", "prover_args": [ diff --git a/certora/spec/sources/integrity.move b/certora/spec/sources/mint_redeem_integrity.move similarity index 100% rename from certora/spec/sources/integrity.move rename to certora/spec/sources/mint_redeem_integrity.move From bfa068a652df0b9772a944c018bb26320bfb5c09 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Wed, 14 Jan 2026 10:17:42 +0100 Subject: [PATCH 35/68] Disable nondet summary of vector::contains --- certora/spec/Move.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/certora/spec/Move.toml b/certora/spec/Move.toml index ae0f337..741ecc0 100644 --- a/certora/spec/Move.toml +++ b/certora/spec/Move.toml @@ -4,8 +4,8 @@ edition = "2024.beta" [dependencies] liquid_staking = { local = "../../contracts" } -cvlm = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "cvlm", rev = "main" } -certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "main" } +cvlm = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "cvlm", rev = "eric/noContains" } +certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "eric/noContains" } [addresses] spec = "0x0" \ No newline at end of file From 8617f4e171a953916efc2f4ae1a6e1abd2a860ef Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 26 Jan 2026 14:46:36 +0100 Subject: [PATCH 36/68] Simply fresh storage setup --- ...ing_integrity.conf => supply_control.conf} | 0 certora/spec/sources/common.move | 73 ++++++++++++++++--- certora/spec/sources/summaries.move | 4 +- ...ing_integrity.move => supply_control.move} | 0 4 files changed, 64 insertions(+), 13 deletions(-) rename certora/spec/confs/{accounting_integrity.conf => supply_control.conf} (100%) rename certora/spec/sources/{accounting_integrity.move => supply_control.move} (100%) diff --git a/certora/spec/confs/accounting_integrity.conf b/certora/spec/confs/supply_control.conf similarity index 100% rename from certora/spec/confs/accounting_integrity.conf rename to certora/spec/confs/supply_control.conf diff --git a/certora/spec/sources/common.move b/certora/spec/sources/common.move index fb35f43..b36a889 100644 --- a/certora/spec/sources/common.move +++ b/certora/spec/sources/common.move @@ -1,18 +1,16 @@ module spec::common; -use liquid_staking::liquid_staking::LiquidStakingInfo; -use sui_system::sui_system::SuiSystemState; use cvlm::asserts::cvlm_assume_msg; use cvlm::function::Function; +use liquid_staking::liquid_staking::LiquidStakingInfo; +use spec::summaries::{active_validators}; +use sui_system::sui_system::SuiSystemState; +use liquid_staking::storage::inactive_stake; +use liquid_staking::storage::get_sui_amount; - -public fun setup_fresh( +public fun setup( lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, ) { - cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Force refresh"); - let mut i = 0; while (i < lsi.storage().validators().length()) { let validator = &lsi.storage().validators()[i]; @@ -28,16 +26,67 @@ public fun setup_fresh( i = i+1; }; +} + + +public fun setup_fresh( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + + /* Refresh */ + let mut total_sui_supply = 0; - lsi.refresh(system_state, ctx); + let validator_addresses = active_validators(); + + let mut i = 0; + while (i < lsi.storage().validators().length()) { + let validator = &lsi.storage().validators()[i]; + let pool_id = validator.staking_pool_id(); + let active = validator.active_stake(); + let inactive = lsi.storage().validators()[i].inactive_stake(); + + cvlm_assume_msg(inactive.is_none(), b"No inactive stake"); + cvlm_assume_msg(active.is_some(), b"No empty validator"); + let active = active.borrow(); + cvlm_assume_msg(active.pool_id() == pool_id, b"Matching pool ids"); + + + cvlm_assume_msg( + validator_addresses.contains(&validator.validator_address()), + b"Validator is active", + ); + + + // We assume an exchange rate for this epoch exists + let er = lsi + .storage() + .get_latest_exchange_rate(&validator.staking_pool_id(), system_state, ctx) + .destroy_some(); + cvlm_assume_msg(validator.exchange_rate() == er, b"Validator has latest exchange rate"); + + let active_sui_amount = get_sui_amount(&er, active.value()); + cvlm_assume_msg(validator.total_sui_amount() == active_sui_amount, b"Valid amount"); + + total_sui_supply = total_sui_supply + active_sui_amount; + + i = i+1; + }; + + cvlm_assume_msg(lsi.storage().total_sui_supply() == total_sui_supply, b"Correct total sui supply"); + cvlm_assume_msg(lsi.storage().last_refresh_epoch() == ctx.epoch(), b"Set last refresh"); + /* End Refresh */ + } + public fun log(_: &T) {} public fun can_decrease_supply(f: Function): bool { - f.name() == b"redeem" || f.name() == b"custom_redeem" + f.name() == b"redeem" || f.name() == b"custom_redeem" } public fun can_increase_supply(f: Function): bool { - f.name() == b"mint" -} \ No newline at end of file + f.name() == b"mint" +} diff --git a/certora/spec/sources/summaries.move b/certora/spec/sources/summaries.move index b12b573..47f84f9 100644 --- a/certora/spec/sources/summaries.move +++ b/certora/spec/sources/summaries.move @@ -240,8 +240,10 @@ public fun pool_token_exchange_rate_at_epoch( some(get_exr(epoch, &id)).destroy_some() } +public native fun active_validators(): vector

; + public fun active_validator_addresses(_wrapper: &mut SuiSystemState): vector
{ - nondet() + active_validators() } public fun request_withdraw_stake_non_entry( diff --git a/certora/spec/sources/accounting_integrity.move b/certora/spec/sources/supply_control.move similarity index 100% rename from certora/spec/sources/accounting_integrity.move rename to certora/spec/sources/supply_control.move From 38ee23e4173d312a2aa5b36e12ddb273fd5822ed Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 26 Jan 2026 14:58:08 +0100 Subject: [PATCH 37/68] chore: munge visibility --- certora/munges/storage.patch | 55 +++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/certora/munges/storage.patch b/certora/munges/storage.patch index 6995d8f..8516613 100644 --- a/certora/munges/storage.patch +++ b/certora/munges/storage.patch @@ -1,5 +1,5 @@ diff --git a/contracts/sources/storage.move b/contracts/sources/storage.move -index d3de10c..a5fce28 100644 +index d3de10c..73efcfa 100644 --- a/contracts/sources/storage.move +++ b/contracts/sources/storage.move @@ -51,7 +51,7 @@ module liquid_staking::storage { @@ -11,7 +11,7 @@ index d3de10c..a5fce28 100644 Storage { sui_pool: balance::zero(), validator_infos: vector::empty(), -@@ -62,39 +62,39 @@ module liquid_staking::storage { +@@ -62,43 +62,47 @@ module liquid_staking::storage { } /* Public View Functions */ @@ -60,7 +60,25 @@ index d3de10c..a5fce28 100644 &self.exchange_rate } -@@ -126,7 +126,7 @@ module liquid_staking::storage { +- public(package) fun total_sui_amount(self: &ValidatorInfo): u64 { ++ public fun exchange_rate_mut(self: &ValidatorInfo): &PoolTokenExchangeRate { ++ &self.exchange_rate ++ } ++ ++ public fun total_sui_amount(self: &ValidatorInfo): u64 { + self.total_sui_amount + } + +@@ -115,7 +119,7 @@ module liquid_staking::storage { + i + } + +- fun is_empty(self: &ValidatorInfo): bool { ++ public fun is_empty(self: &ValidatorInfo): bool { + self.active_stake.is_none() && self.inactive_stake.is_none() && self.total_sui_amount == 0 + } + +@@ -126,7 +130,7 @@ module liquid_staking::storage { /// - Moves any inactive stake that can be converted to active stake. /// - Removes validators that have no stake. /// Returns true if the storage was updated. @@ -69,15 +87,38 @@ index d3de10c..a5fce28 100644 self: &mut Storage, system_state: &mut SuiSystemState, ctx: &mut TxContext -@@ -214,7 +214,6 @@ module liquid_staking::storage { - fun refresh_validator_info(self: &mut Storage, i: u64) { +@@ -189,7 +193,7 @@ module liquid_staking::storage { + // this may return none in the case where the staking pool is inactive or + // if sui system is currently in safe mode. In both these cases, the storage + // object has the latest exchange rate already. +- fun get_latest_exchange_rate( ++ public fun get_latest_exchange_rate( + self: &Storage, + staking_pool_id: &ID, + system_state: &mut SuiSystemState, +@@ -211,10 +215,9 @@ module liquid_staking::storage { + + /// Update the total sui amount for the validator and modify the + /// storage sui supply accordingly assumes the exchange rate is up to date +- fun refresh_validator_info(self: &mut Storage, i: u64) { ++ public fun refresh_validator_info(self: &mut Storage, i: u64) { let validator_info = &mut self.validator_infos[i]; self.total_sui_supply = self.total_sui_supply - validator_info.total_sui_amount; - let mut total_sui_amount = 0; if (validator_info.active_stake.is_some()) { let active_stake = validator_info.active_stake.borrow(); -@@ -543,7 +542,7 @@ module liquid_staking::storage { +@@ -390,7 +393,8 @@ module liquid_staking::storage { + ((target_unstake_sui_amount as u128) + * (fungible_staked_sui_amount as u128) + + (total_sui_amount as u128) +- - 1) ++ - 1 ++ ) + / (total_sui_amount as u128) + ) as u64; + +@@ -543,7 +547,7 @@ module liquid_staking::storage { } /* Private functions */ @@ -86,7 +127,7 @@ index d3de10c..a5fce28 100644 self: &mut Storage, system_state: &mut SuiSystemState, staking_pool_id: ID, -@@ -593,7 +592,7 @@ module liquid_staking::storage { +@@ -593,7 +597,7 @@ module liquid_staking::storage { } /// copied directly from staking_pool.move From 882b467dfc96fd9559f561718c4350c44e0f7748 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 26 Jan 2026 15:26:04 +0100 Subject: [PATCH 38/68] fix: timeouts in supply increase/decrease rules --- .github/workflows/certora.yml | 3 +- ...rol.conf => supply_control_decreases.conf} | 22 +++--- .../confs/supply_control_increases_p1.conf | 32 +++++++++ .../confs/supply_control_increases_p2.conf | 35 +++++++++ certora/spec/sources/supply_control.move | 71 +++++++++++++++---- 5 files changed, 141 insertions(+), 22 deletions(-) rename certora/spec/confs/{supply_control.conf => supply_control_decreases.conf} (54%) create mode 100644 certora/spec/confs/supply_control_increases_p1.conf create mode 100644 certora/spec/confs/supply_control_increases_p2.conf diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index a1125f4..0c04986 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -37,7 +37,8 @@ jobs: ecosystem: sui working-directory: certora/spec/ configurations: |- - confs/accounting_integrity.conf + confs/supply_control_decreases.conf + confs/supply_control_increases.conf confs/accounting_no_lst_no_sui.conf confs/accounting_total_sui_supply.conf confs/accounting_value_conservation.conf diff --git a/certora/spec/confs/supply_control.conf b/certora/spec/confs/supply_control_decreases.conf similarity index 54% rename from certora/spec/confs/supply_control.conf rename to certora/spec/confs/supply_control_decreases.conf index cb75399..da69fce 100644 --- a/certora/spec/confs/supply_control.conf +++ b/certora/spec/confs/supply_control_decreases.conf @@ -3,9 +3,10 @@ "server": "prover", "prover_version": "master", "rule": [ - "*accounting_integrity*" + "*only_redemption_decreases_sui_supply", + "*only_redemption_decreases_lst_supply" ], - "msg": "Various accounting integrity rules", + "msg": "Supply control - decrease rules", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", @@ -15,10 +16,15 @@ "-tacDumpsWithInternalFunctions true", "-callTraceVecElemCount 0", "-dumpCodeSizeAnalysis true", - "-depth 20", - "-splitParallel", - "true", - "-dontStopAtFirstSplitTimeout", - "true" + "-oldSplitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true", + "-splitParallelTimelimit", + "7000", + "-splitParallelInitialDepth", + "3", + "-numOfParallelSplits", + "7" ] -} \ No newline at end of file +} diff --git a/certora/spec/confs/supply_control_increases_p1.conf b/certora/spec/confs/supply_control_increases_p1.conf new file mode 100644 index 0000000..a881185 --- /dev/null +++ b/certora/spec/confs/supply_control_increases_p1.conf @@ -0,0 +1,32 @@ +{ + "optimistic_loop": true, + "server": "prover", + "prover_version": "master", + "rule": [ + "*only_minting_increases_sui_supply", + "*only_minting_increases_lst_supply" + ], + "method": [ + "spec::dummy::mint", + "spec::dummy::custom_redeem_request", + "spec::dummy::custom_redeem", + "spec::dummy::change_validator_priority", + "spec::dummy::decrease_validator_stake", + "spec::dummy::increase_validator_stake", + "spec::dummy::update_fees", + "spec::dummy::refresh", + "spec::dummy::update_metadata" + ], + "msg": "Supply control - increase rules", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true" + ], + "smt_timeout": 1800 +} \ No newline at end of file diff --git a/certora/spec/confs/supply_control_increases_p2.conf b/certora/spec/confs/supply_control_increases_p2.conf new file mode 100644 index 0000000..28253b4 --- /dev/null +++ b/certora/spec/confs/supply_control_increases_p2.conf @@ -0,0 +1,35 @@ +{ + "optimistic_loop": true, + "server": "prover", + "prover_version": "master", + "rule": [ + "*only_minting_increases_sui_supply", + "*only_minting_increases_lst_supply" + ], + "method" : [ + "spec::dummy::redeem", + "spec::dummy::collect_fees" + ], + "msg": "Supply control - increase rules", + + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true", + "-oldSplitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true", + "-splitParallelTimelimit", + "7000", + "-splitParallelInitialDepth", + "3", + "-numOfParallelSplits", + "7" + ] +} diff --git a/certora/spec/sources/supply_control.move b/certora/spec/sources/supply_control.move index 830356d..845dcfb 100644 --- a/certora/spec/sources/supply_control.move +++ b/certora/spec/sources/supply_control.move @@ -1,14 +1,17 @@ -module spec::accounting_integrity; +module spec::supply_control; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::function::Function; + use cvlm::manifest::{target, invoker, rule}; + use liquid_staking::liquid_staking::LiquidStakingInfo; use spec::accounting_total_sui_supply::total_supply_correct; use spec::common::{setup_fresh, can_decrease_supply, can_increase_supply}; use spec::dummy::DummyToken; use sui_system::sui_system::SuiSystemState; + public fun cvlm_manifest() { // Public mut functions target(@spec, b"dummy", b"mint"); @@ -25,8 +28,11 @@ public fun cvlm_manifest() { invoker(b"invoke"); - rule(b"only_redemption_decreases_supply"); - rule(b"only_minting_increases_supply"); + rule(b"only_redemption_decreases_sui_supply"); + rule(b"only_redemption_decreases_lst_supply"); + rule(b"only_minting_increases_sui_supply"); + rule(b"only_minting_increases_lst_supply"); + } native fun invoke( @@ -36,7 +42,7 @@ native fun invoke( ctx: &mut TxContext, ); -public fun only_redemption_decreases_supply( +public fun only_redemption_decreases_sui_supply( target: Function, lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, @@ -44,40 +50,79 @@ public fun only_redemption_decreases_supply( ) { setup_fresh(lsi, system_state, ctx); let sui_pre = lsi.total_sui_supply(); - let lst_pre = lsi.total_lst_supply(); cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Sound state"); invoke(target, lsi, system_state, ctx); let sui_post = lsi.total_sui_supply(); - let lst_post = lsi.total_lst_supply(); + let sui_decrease = sui_post < sui_pre; + + + let decreased = sui_decrease; + + cvlm_assert(!decreased || can_decrease_supply(target)); +} + +public fun only_redemption_decreases_lst_supply( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + let lst_pre = lsi.total_lst_supply(); + + invoke(target, lsi, system_state, ctx); + + let lst_post = lsi.total_lst_supply(); let lst_decrease = lst_post < lst_pre; - let decreased = sui_decrease || lst_decrease; + let decreased = lst_decrease; cvlm_assert(!decreased || can_decrease_supply(target)); } -public fun only_minting_increases_supply( +public fun only_minting_increases_lst_supply( target: Function, lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { setup_fresh(lsi, system_state, ctx); - let sui_pre = lsi.total_sui_supply(); + let lst_pre = lsi.total_lst_supply(); - cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Sound state"); invoke(target, lsi, system_state, ctx); - let sui_post = lsi.total_sui_supply(); let lst_post = lsi.total_lst_supply(); - let sui_increase = sui_post > sui_pre; + let lst_increase = lst_post > lst_pre; - let increased = sui_increase || lst_increase; + let increased = lst_increase; + + cvlm_assert(!increased || can_increase_supply(target)); +} + +public fun only_minting_increases_sui_supply( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + + let sui_pre = lsi.total_sui_supply(); + + + invoke(target, lsi, system_state, ctx); + + let sui_post = lsi.total_sui_supply(); + + let sui_increase = sui_post > sui_pre; + + + let increased = sui_increase; cvlm_assert(!increased || can_increase_supply(target)); } From 64bc4fe7f7ffd397363512e305f2838612b37fb0 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 26 Jan 2026 15:28:51 +0100 Subject: [PATCH 39/68] Verify soundness of refresh summary --- certora/spec/sources/common.move | 13 ++- .../spec/sources/validators_consistency.move | 103 +++++++++++++++--- 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/certora/spec/sources/common.move b/certora/spec/sources/common.move index b36a889..eba76b2 100644 --- a/certora/spec/sources/common.move +++ b/certora/spec/sources/common.move @@ -34,8 +34,6 @@ public fun setup_fresh( system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { - - /* Refresh */ let mut total_sui_supply = 0; let validator_addresses = active_validators(); @@ -47,7 +45,14 @@ public fun setup_fresh( let active = validator.active_stake(); let inactive = lsi.storage().validators()[i].inactive_stake(); + // There is no inactive state after a call to refresh + // This is verified in the rule "no_inactive_stake_after_refresh" cvlm_assume_msg(inactive.is_none(), b"No inactive stake"); + + // There are no empty validators after a call to refresh. + // This is verified in the rule "no_empty_validators_after_refresh" + // Since there is no inactive stake for this validator, not empty is equivalent to non-zero active stake. + // This is verified in the invariant "no_stake_no_sui" cvlm_assume_msg(active.is_some(), b"No empty validator"); let active = active.borrow(); cvlm_assume_msg(active.pool_id() == pool_id, b"Matching pool ids"); @@ -75,9 +80,7 @@ public fun setup_fresh( }; cvlm_assume_msg(lsi.storage().total_sui_supply() == total_sui_supply, b"Correct total sui supply"); - cvlm_assume_msg(lsi.storage().last_refresh_epoch() == ctx.epoch(), b"Set last refresh"); - /* End Refresh */ - + cvlm_assume_msg(lsi.storage().last_refresh_epoch() == ctx.epoch(), b"Set last refresh"); } diff --git a/certora/spec/sources/validators_consistency.move b/certora/spec/sources/validators_consistency.move index 4b5ccae..d036326 100644 --- a/certora/spec/sources/validators_consistency.move +++ b/certora/spec/sources/validators_consistency.move @@ -1,14 +1,15 @@ module spec::validators_consistency; -use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg, cvlm_assert_msg}; use cvlm::function::Function; use cvlm::ghost::ghost_destroy; use cvlm::manifest::{target, invoker, rule}; -use liquid_staking::storage::{Self, Storage, get_sui_amount, active_stake}; +use cvlm::nondet::nondet; +use liquid_staking::liquid_staking::LiquidStakingInfo; +use liquid_staking::storage::{Self, Storage, get_sui_amount, active_stake, ValidatorInfo}; +use spec::common::{log, setup}; +use spec::dummy::DummyToken; use sui_system::sui_system::SuiSystemState; -use spec::common::log; -use cvlm::asserts::cvlm_assert_msg; - public fun cvlm_manifest() { // Public mut functions @@ -32,11 +33,18 @@ public fun cvlm_manifest() { rule(b"total_sui_supply_correct_base"); rule(b"total_sui_supply_correct_step"); - + rule(b"no_duplicate_validators"); rule(b"can_add_correct"); rule(b"can_remove_correct"); rule(b"add_at_most_one"); + + rule(b"no_stake_no_sui_base"); + rule(b"no_stake_no_sui_step"); + + + rule(b"no_inactive_stake_after_refresh"); + rule(b"no_empty_validators_after_refresh"); } native fun invoke( @@ -118,8 +126,6 @@ public fun total_sui_supply_correct_step( cvlm_assert(supply_post == supply_expected_post); } - - fun can_add_validator(target: Function): bool { target.name() == b"get_or_add_validator_index_by_staking_pool_id_mut" || target.name() == b"join_stake" @@ -144,7 +150,6 @@ public fun can_add_correct( let allowed = can_add_validator(target); cvlm_assert(!appended || allowed); - } public fun can_remove_correct( @@ -163,14 +168,12 @@ public fun can_remove_correct( cvlm_assert(!removed || allowed); } - public fun no_duplicate_validators( strg: &mut Storage, staking_pool_id: ID, system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { - let validator_address = system_state.validator_address_by_pool_id(&staking_pool_id); let mut id_exists = false; @@ -186,9 +189,13 @@ public fun no_duplicate_validators( i = i + 1; }; - let index = strg.get_or_add_validator_index_by_staking_pool_id_mut(system_state, staking_pool_id, ctx); + let index = strg.get_or_add_validator_index_by_staking_pool_id_mut( + system_state, + staking_pool_id, + ctx, + ); let appended = index == infos_pre; - + log(&id_exists); log(&address_exists); log(&appended); @@ -229,3 +236,73 @@ public fun validators_upper_bound_step( invoke(target, strg, system_state, ctx); cvlm_assert(strg.validators().length() <= MAX_VALIDATORS); } + +fun validator_no_stake_no_sui(v: &ValidatorInfo): bool { + let no_active = v.active_stake().is_none(); + let no_inactive = v.inactive_stake().is_none(); + // (no active && no_inactive) => no sui + !(no_active && no_inactive) || v.total_sui_amount() == 0 +} + +fun no_stake_no_sui(strg: &Storage): bool { + let mut ret = true; + let mut i = 0; + while (i < strg.validators().length()) { + let v_i = &strg.validators()[i]; + ret = ret && validator_no_stake_no_sui(v_i); + i = i+1; + }; + ret +} + +public fun no_stake_no_sui_base(ctx: &mut TxContext) { + let strg = storage::new(ctx); + cvlm_assert(no_stake_no_sui(&strg)); + ghost_destroy(strg); +} + +public fun no_stake_no_sui_step( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + cvlm_assume_msg(no_stake_no_sui(strg), b"Assume in pre state"); + invoke(target, strg, system_state, ctx); + strg.refresh(system_state, ctx); + cvlm_assert(no_stake_no_sui(strg)); +} + +public fun no_inactive_stake_after_refresh( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup(lsi); + cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Force refresh"); + + lsi.refresh(system_state, ctx); + + let i = nondet(); + cvlm_assume_msg(i < lsi.storage().validators().length(), b""); + + let inactive = lsi.storage().validators()[i].inactive_stake(); + cvlm_assert(inactive.is_none()); +} + +public fun no_empty_validators_after_refresh( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup(lsi); + cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Force refresh"); + + lsi.refresh(system_state, ctx); + + let i = nondet(); + cvlm_assume_msg(i < lsi.storage().validators().length(), b""); + let validator = &lsi.storage().validators()[i]; + + cvlm_assert(!validator.is_empty()); +} From 19cb188958311820b94c407bdc475b60ff4ad44f Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 26 Jan 2026 15:29:41 +0100 Subject: [PATCH 40/68] nondet summary for version upgrade --- certora/spec/sources/summaries.move | 32 ++++++++++++++++------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/certora/spec/sources/summaries.move b/certora/spec/sources/summaries.move index 47f84f9..6065ea1 100644 --- a/certora/spec/sources/summaries.move +++ b/certora/spec/sources/summaries.move @@ -1,10 +1,11 @@ module spec::summaries; -use cvlm::asserts::{cvlm_assume_msg}; +use cvlm::asserts::{cvlm_assume_msg, cvlm_assert}; use cvlm::ghost::ghost_destroy; use cvlm::manifest::{summary, ghost}; use cvlm::nondet::nondet; use liquid_staking::storage::{Self, Storage}; +use liquid_staking::version::Version; use std::option::some; use sui::balance::Balance; use sui::object::id; @@ -12,7 +13,6 @@ use sui::sui::SUI; use sui::tx_context::epoch; use sui_system::staking_pool::{PoolTokenExchangeRate, StakedSui, StakingPool, FungibleStakedSui}; use sui_system::sui_system::SuiSystemState; -use cvlm::asserts::cvlm_assert; public fun cvlm_manifest() { ghost(b"exchange_rate"); @@ -32,7 +32,6 @@ public fun cvlm_manifest() { b"pool_token_exchange_rate_at_epoch", ); - ghost(b"fungible_total_supply"); ghost(b"fungible_total_principal"); @@ -48,6 +47,7 @@ public fun cvlm_manifest() { b"sui_system", b"redeem_fungible_staked_sui", ); + ghost(b"active_validators"); summary( b"active_validator_addresses", @sui_system, @@ -60,6 +60,13 @@ public fun cvlm_manifest() { b"sui_system", b"request_withdraw_stake_non_entry", ); + + summary( + b"assert_version_and_upgrade", + @liquid_staking, + b"version", + b"assert_version_and_upgrade", + ); } native fun validator_index(validator_address: address): u64; @@ -84,8 +91,6 @@ fun get_exr(epoch: u64, staking_pool_id: &ID): PoolTokenExchangeRate { er } - - native fun fungible_total_supply(pool: ID): &mut u64; native fun fungible_total_principal(pool: ID): &mut u64; @@ -112,34 +117,28 @@ public fun convert_to_fungible_staked_sui( let f_principal = fungible_total_supply(id); *f_total = *f_total + pool_token_amount; *f_principal = *f_principal + principal; - ghost_destroy(staked_sui); fss } - public fun redeem_fungible_staked_sui( _wrapper: &mut SuiSystemState, fungible_staked_sui: FungibleStakedSui, ctx: &TxContext, ): Balance { - let id = fungible_staked_sui.pool_id(); let epoch = ctx.epoch(); let value = fungible_staked_sui.value(); let principal = *fungible_total_principal(id); cvlm_assume_msg(principal >= value, b""); - - + let total_supply = *fungible_total_supply(id); cvlm_assume_msg(total_supply >= principal, b""); - let er = get_exr(epoch, &id); - // let ( // principal_amount, @@ -151,7 +150,7 @@ public fun redeem_fungible_staked_sui( // total_supply, // ); //let total_withdraw = principal_amount+rewards_amount; - + let total_withdraw = get_sui_amount(er, value); //fungible_staked_sui_data.total_supply = fungible_staked_sui_data.total_supply - value; @@ -184,7 +183,10 @@ public(package) fun calculate_fungible_staked_sui_withdraw_amount( // total_sui_amount, // ); - cvlm_assume_msg(fungible_staked_sui_data_principal_amount <= total_sui_amount, b"Principal amount is less than total sui amount"); + cvlm_assume_msg( + fungible_staked_sui_data_principal_amount <= total_sui_amount, + b"Principal amount is less than total sui amount", + ); // 2. how much do we need to withdraw from the rewards pool? let total_rewards = total_sui_amount - fungible_staked_sui_data_principal_amount; @@ -259,3 +261,5 @@ public fun request_withdraw_stake_non_entry( ghost_destroy(staked_sui); w } + +public(package) fun assert_version_and_upgrade(_version: &mut Version, _current_version: u16) {} From a5a4f7e84c4a0d5f93242d00c5f8b5be20c5ef12 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 26 Jan 2026 15:30:26 +0100 Subject: [PATCH 41/68] Add rule for spread fee rules --- certora/spec/sources/fees.move | 63 +++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/certora/spec/sources/fees.move b/certora/spec/sources/fees.move index a6c16dc..6c7e61e 100644 --- a/certora/spec/sources/fees.move +++ b/certora/spec/sources/fees.move @@ -3,7 +3,7 @@ module spec::fees; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::function::Function; use cvlm::ghost::ghost_destroy; -use cvlm::manifest::{rule, target}; +use cvlm::manifest::{rule, target, invoker}; use cvlm::nondet::nondet; use liquid_staking::fees::validate_fees; use liquid_staking::liquid_staking::LiquidStakingInfo; @@ -12,7 +12,7 @@ use spec::dummy::DummyToken; use sui::coin::Coin; use sui::sui::SUI; use sui_system::sui_system::SuiSystemState; -use cvlm::manifest::invoker; +use liquid_staking::liquid_staking::create_lst; public fun cvlm_manifest() { target(@spec, b"dummy", b"mint"); @@ -29,7 +29,11 @@ public fun cvlm_manifest() { invoker(b"invoke"); + rule(b"spread_fees_dont_exceed_sui_supply_base"); + rule(b"spread_fees_dont_exceed_sui_supply_step"); + rule(b"fees_grow_monotonically"); + rule(b"fees_dont_eat_deposit"); rule(b"fees_dont_eat_redemption"); } @@ -41,28 +45,63 @@ native fun invoke( ctx: &mut TxContext, ); +public fun spread_fees_dont_exceed_sui_supply_base( + ctx: &mut TxContext, +) { + let fee_config = nondet(); + let lst_treasury_cap = nondet(); + let (_cap, lsi) = create_lst(fee_config, lst_treasury_cap, ctx); + let spread_fees = lsi.fees(); + let sui = lsi.storage().total_sui_supply(); + cvlm_assert(spread_fees <= sui); -public fun fees_grow_monotonically( + ghost_destroy(_cap); + ghost_destroy(lsi); + +} + +public fun spread_fees_dont_exceed_sui_supply_step( target: Function, lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { - setup_fresh(lsi, system_state, ctx); - validate_fees(lsi.fee_config()); - let spread_fees_pre = lsi.fees(); + // This already assumes spread_fees < storage.sui_supply, we'll make it explicit nevertheless + setup_fresh(lsi, system_state, ctx); + validate_fees(lsi.fee_config()); + let spread_fees_pre = lsi.accrued_spread_fees(); + let sui_pre = lsi.storage().total_sui_supply(); - invoke(target, lsi, system_state, ctx); + cvlm_assume_msg(spread_fees_pre <= sui_pre, b"Assume in precondition"); - let spread_fees_post = lsi.fees(); + invoke(target, lsi, system_state, ctx); - let collected = target.name() == b"collect_fees"; - let increased = spread_fees_post >= spread_fees_pre; + let spread_fees_post = lsi.accrued_spread_fees(); + let sui_post = lsi.storage().total_sui_supply(); - // !collected -> increased <==> collected || increased - cvlm_assert(collected || increased); + cvlm_assert(spread_fees_post <= sui_post); } +public fun fees_grow_monotonically( + target: Function, + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + validate_fees(lsi.fee_config()); + let spread_fees_pre = lsi.fees(); + + invoke(target, lsi, system_state, ctx); + + let spread_fees_post = lsi.fees(); + + let collected = target.name() == b"collect_fees"; + let increased = spread_fees_post >= spread_fees_pre; + + // !collected -> increased <==> collected || increased + cvlm_assert(collected || increased); +} public fun fees_dont_eat_redemption( lsi: &mut LiquidStakingInfo, From a1e0c75a4600bdf637e5cf4f085073976c70adeb Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 26 Jan 2026 15:33:11 +0100 Subject: [PATCH 42/68] fix[ci]: renamed confs --- .github/workflows/certora.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 0c04986..90c257a 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -38,7 +38,8 @@ jobs: working-directory: certora/spec/ configurations: |- confs/supply_control_decreases.conf - confs/supply_control_increases.conf + confs/supply_control_increases_p1.conf + confs/supply_control_increases_p2.conf confs/accounting_no_lst_no_sui.conf confs/accounting_total_sui_supply.conf confs/accounting_value_conservation.conf From eedd668afc929feee0404acf2feb024dd8989a8c Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 26 Jan 2026 15:47:21 +0100 Subject: [PATCH 43/68] fix: bad module name --- certora/spec/sources/mint_redeem_integrity.move | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certora/spec/sources/mint_redeem_integrity.move b/certora/spec/sources/mint_redeem_integrity.move index 8c79e31..70c229d 100644 --- a/certora/spec/sources/mint_redeem_integrity.move +++ b/certora/spec/sources/mint_redeem_integrity.move @@ -1,4 +1,4 @@ -module spec::integrity; +module spec::mint_redeem_integrity; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::ghost::ghost_destroy; From 8519f3737c08afbc6265b36cfbe8ab92ecf8b3e4 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 26 Jan 2026 16:05:47 +0100 Subject: [PATCH 44/68] fix: increase smt timeouts --- certora/spec/confs/accounting_no_lst_no_sui.conf | 3 ++- certora/spec/confs/supply_control_increases_p2.conf | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/certora/spec/confs/accounting_no_lst_no_sui.conf b/certora/spec/confs/accounting_no_lst_no_sui.conf index a4b753f..8b4730d 100644 --- a/certora/spec/confs/accounting_no_lst_no_sui.conf +++ b/certora/spec/confs/accounting_no_lst_no_sui.conf @@ -20,5 +20,6 @@ "true", "-dontStopAtFirstSplitTimeout", "true" - ] + ], + "smt_timeout": 1800 } \ No newline at end of file diff --git a/certora/spec/confs/supply_control_increases_p2.conf b/certora/spec/confs/supply_control_increases_p2.conf index 28253b4..02a3974 100644 --- a/certora/spec/confs/supply_control_increases_p2.conf +++ b/certora/spec/confs/supply_control_increases_p2.conf @@ -31,5 +31,6 @@ "3", "-numOfParallelSplits", "7" - ] + ], + "smt_timeout": 1800 } From 26efc2dc4f0162287c6b677a8035a347cfcf5fa7 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 27 Jan 2026 12:54:11 +0100 Subject: [PATCH 45/68] fix: validator consistency soundness and missing rule --- .github/workflows/certora.yml | 5 ++- certora/munges/storage.patch | 43 +++++++++++++------ ...cy.conf => validators_consistency_p1.conf} | 6 ++- .../spec/confs/validators_consistency_p2.conf | 23 ++++++++++ .../spec/confs/validators_consistency_p3.conf | 20 +++++++++ .../spec/confs/validators_consistency_p4.conf | 21 +++++++++ .../spec/sources/validators_consistency.move | 13 +++--- 7 files changed, 109 insertions(+), 22 deletions(-) rename certora/spec/confs/{validators_consistency.conf => validators_consistency_p1.conf} (70%) create mode 100644 certora/spec/confs/validators_consistency_p2.conf create mode 100644 certora/spec/confs/validators_consistency_p3.conf create mode 100644 certora/spec/confs/validators_consistency_p4.conf diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 90c257a..5449634 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -46,7 +46,10 @@ jobs: confs/fees.conf confs/mint_redeem_integrity.conf confs/solvency.conf - confs/validators_consistency.conf + confs/validators_consistency_p1.conf + confs/validators_consistency_p2.conf + confs/validators_consistency_p3.conf + confs/validators_consistency_p4.conf job-name: "Verification" certora-key: ${{ secrets.CERTORAKEY }} env: diff --git a/certora/munges/storage.patch b/certora/munges/storage.patch index 8516613..0faa7df 100644 --- a/certora/munges/storage.patch +++ b/certora/munges/storage.patch @@ -1,8 +1,23 @@ diff --git a/contracts/sources/storage.move b/contracts/sources/storage.move -index d3de10c..73efcfa 100644 +index d3de10c..1d51580 100644 --- a/contracts/sources/storage.move +++ b/contracts/sources/storage.move -@@ -51,7 +51,7 @@ module liquid_staking::storage { +@@ -15,9 +15,13 @@ module liquid_staking::storage { + /* Constants */ + const MIN_STAKE_THRESHOLD: u64 = 1_000_000_000; + const MAX_SUI_SUPPLY: u64 = 10_000_000_000 * 1_000_000_000; +- const MAX_VALIDATORS: u64 = 50; ++ const MAX_VALIDATORS: u64 = 5; + const ACTIVE_STAKE_REDEEM_OFFSET: u64 = 100; + ++ public fun max_validators(): u64 { ++ MAX_VALIDATORS ++ } ++ + /// The Storage struct holds all stake for the LST. + public struct Storage has store { + /// Sui balance. Unstake operations deposit SUI here. +@@ -51,7 +55,7 @@ module liquid_staking::storage { extra_fields: Bag } @@ -11,7 +26,7 @@ index d3de10c..73efcfa 100644 Storage { sui_pool: balance::zero(), validator_infos: vector::empty(), -@@ -62,43 +62,47 @@ module liquid_staking::storage { +@@ -62,43 +66,47 @@ module liquid_staking::storage { } /* Public View Functions */ @@ -57,19 +72,19 @@ index d3de10c..73efcfa 100644 - public(package) fun exchange_rate(self: &ValidatorInfo): &PoolTokenExchangeRate { + public fun exchange_rate(self: &ValidatorInfo): &PoolTokenExchangeRate { ++ &self.exchange_rate ++ } ++ ++ public fun exchange_rate_mut(self: &ValidatorInfo): &PoolTokenExchangeRate { &self.exchange_rate } - public(package) fun total_sui_amount(self: &ValidatorInfo): u64 { -+ public fun exchange_rate_mut(self: &ValidatorInfo): &PoolTokenExchangeRate { -+ &self.exchange_rate -+ } -+ + public fun total_sui_amount(self: &ValidatorInfo): u64 { self.total_sui_amount } -@@ -115,7 +119,7 @@ module liquid_staking::storage { +@@ -115,7 +123,7 @@ module liquid_staking::storage { i } @@ -78,7 +93,7 @@ index d3de10c..73efcfa 100644 self.active_stake.is_none() && self.inactive_stake.is_none() && self.total_sui_amount == 0 } -@@ -126,7 +130,7 @@ module liquid_staking::storage { +@@ -126,7 +134,7 @@ module liquid_staking::storage { /// - Moves any inactive stake that can be converted to active stake. /// - Removes validators that have no stake. /// Returns true if the storage was updated. @@ -87,7 +102,7 @@ index d3de10c..73efcfa 100644 self: &mut Storage, system_state: &mut SuiSystemState, ctx: &mut TxContext -@@ -189,7 +193,7 @@ module liquid_staking::storage { +@@ -189,7 +197,7 @@ module liquid_staking::storage { // this may return none in the case where the staking pool is inactive or // if sui system is currently in safe mode. In both these cases, the storage // object has the latest exchange rate already. @@ -96,7 +111,7 @@ index d3de10c..73efcfa 100644 self: &Storage, staking_pool_id: &ID, system_state: &mut SuiSystemState, -@@ -211,10 +215,9 @@ module liquid_staking::storage { +@@ -211,10 +219,9 @@ module liquid_staking::storage { /// Update the total sui amount for the validator and modify the /// storage sui supply accordingly assumes the exchange rate is up to date @@ -108,7 +123,7 @@ index d3de10c..73efcfa 100644 let mut total_sui_amount = 0; if (validator_info.active_stake.is_some()) { let active_stake = validator_info.active_stake.borrow(); -@@ -390,7 +393,8 @@ module liquid_staking::storage { +@@ -390,7 +397,8 @@ module liquid_staking::storage { ((target_unstake_sui_amount as u128) * (fungible_staked_sui_amount as u128) + (total_sui_amount as u128) @@ -118,7 +133,7 @@ index d3de10c..73efcfa 100644 / (total_sui_amount as u128) ) as u64; -@@ -543,7 +547,7 @@ module liquid_staking::storage { +@@ -543,7 +551,7 @@ module liquid_staking::storage { } /* Private functions */ @@ -127,7 +142,7 @@ index d3de10c..73efcfa 100644 self: &mut Storage, system_state: &mut SuiSystemState, staking_pool_id: ID, -@@ -593,7 +597,7 @@ module liquid_staking::storage { +@@ -593,7 +601,7 @@ module liquid_staking::storage { } /// copied directly from staking_pool.move diff --git a/certora/spec/confs/validators_consistency.conf b/certora/spec/confs/validators_consistency_p1.conf similarity index 70% rename from certora/spec/confs/validators_consistency.conf rename to certora/spec/confs/validators_consistency_p1.conf index 33bf05d..c1f4474 100644 --- a/certora/spec/confs/validators_consistency.conf +++ b/certora/spec/confs/validators_consistency_p1.conf @@ -1,11 +1,13 @@ { "optimistic_loop": true, + "loop_iter": 2, "server": "prover", "prover_version": "master", "rule": [ - "*validators_consistency*" + "*validators_consistency*total_sui_supply_correct_*", + "*validators_consistency*no_stake_no_sui*" ], - "msg": "Consistency of the validators list", + "msg": "Validators' consistency: Total sui supply correct", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/confs/validators_consistency_p2.conf b/certora/spec/confs/validators_consistency_p2.conf new file mode 100644 index 0000000..0d9c453 --- /dev/null +++ b/certora/spec/confs/validators_consistency_p2.conf @@ -0,0 +1,23 @@ +{ + "optimistic_loop": true, + "loop_iter": 2, + "server": "prover", + "prover_version": "master", + "rule": [ + "*validators_consistency*no_duplicate_validators", + "*validators_consistency*can_add_correct", + "*validators_consistency*can_remove_correct", + "*validators_consistency*add_at_most_one" + ], + "msg": "List of validators: Consistency", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true" + ] +} \ No newline at end of file diff --git a/certora/spec/confs/validators_consistency_p3.conf b/certora/spec/confs/validators_consistency_p3.conf new file mode 100644 index 0000000..5f9bb94 --- /dev/null +++ b/certora/spec/confs/validators_consistency_p3.conf @@ -0,0 +1,20 @@ +{ + "optimistic_loop": true, // Cannot be pessimistic because there are internal loops that are not bounded + "loop_iter": 6, // one more than the upper bound, munged to 5 + "server": "prover", + "prover_version": "master", + "rule": [ + "*validators_consistency*validators_upper_bound*" + ], + "msg": "List of validators: Bounded", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true" + ] +} \ No newline at end of file diff --git a/certora/spec/confs/validators_consistency_p4.conf b/certora/spec/confs/validators_consistency_p4.conf new file mode 100644 index 0000000..966b449 --- /dev/null +++ b/certora/spec/confs/validators_consistency_p4.conf @@ -0,0 +1,21 @@ +{ + "optimistic_loop": true, + "loop_iter": 2, + "server": "prover", + "prover_version": "master", + "rule": [ + "*validators_consistency*no_inactive_stake_after_refresh", + "*validators_consistency*no_empty_validators_after_refresh" + ], + "msg": "Validators' consistency: Refresh integrity", + "prover_args": [ + "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", + "-disabledTransformations HOIST_LOOPS", + "-maxMergedBranchSize 1000000", + "-maxCommandCount 10000000", + "-maxBlockCount 1000000", + "-tacDumpsWithInternalFunctions true", + "-callTraceVecElemCount 0", + "-dumpCodeSizeAnalysis true" + ] +} \ No newline at end of file diff --git a/certora/spec/sources/validators_consistency.move b/certora/spec/sources/validators_consistency.move index d036326..755c414 100644 --- a/certora/spec/sources/validators_consistency.move +++ b/certora/spec/sources/validators_consistency.move @@ -10,6 +10,7 @@ use liquid_staking::storage::{Self, Storage, get_sui_amount, active_stake, Valid use spec::common::{log, setup}; use spec::dummy::DummyToken; use sui_system::sui_system::SuiSystemState; +use liquid_staking::storage::max_validators; public fun cvlm_manifest() { // Public mut functions @@ -39,6 +40,9 @@ public fun cvlm_manifest() { rule(b"can_remove_correct"); rule(b"add_at_most_one"); + rule(b"validators_upper_bound_base"); + rule(b"validators_upper_bound_step"); + rule(b"no_stake_no_sui_base"); rule(b"no_stake_no_sui_step"); @@ -217,12 +221,10 @@ public fun add_at_most_one( cvlm_assert(validators_post <= validators_pre + 1); } -// Same as in storage.move -const MAX_VALIDATORS: u64 = 50; public fun validators_upper_bound_base(ctx: &mut TxContext) { let strg = storage::new(ctx); - cvlm_assert(strg.validators().length() <= MAX_VALIDATORS); + cvlm_assert(strg.validators().length() <= max_validators()); ghost_destroy(strg); } @@ -232,9 +234,9 @@ public fun validators_upper_bound_step( system_state: &mut SuiSystemState, ctx: &mut TxContext, ) { - cvlm_assert_msg(strg.validators().length() <= MAX_VALIDATORS, b"Assume in pre state"); + cvlm_assume_msg(strg.validators().length() <= max_validators(), b"Assume in pre state"); invoke(target, strg, system_state, ctx); - cvlm_assert(strg.validators().length() <= MAX_VALIDATORS); + cvlm_assert(strg.validators().length() <= max_validators()); } fun validator_no_stake_no_sui(v: &ValidatorInfo): bool { @@ -297,6 +299,7 @@ public fun no_empty_validators_after_refresh( ) { setup(lsi); cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Force refresh"); + cvlm_assume_msg(no_stake_no_sui(lsi.storage()), b"Assume in pre state"); lsi.refresh(system_state, ctx); From 196117fb5f9507fd718ffeba52b5329bf0cb2ecf Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 27 Jan 2026 12:57:24 +0100 Subject: [PATCH 46/68] chore: add comments --- .../sources/accounting_no_lst_no_sui.move | 20 ++++++++++++ .../sources/accounting_total_sui_supply.move | 18 +++++++++++ .../accounting_value_conservation.move | 16 ++++++++++ certora/spec/sources/fees.move | 18 +++++++++++ .../spec/sources/liquid_staking_sanity.move | 7 +++++ .../spec/sources/mint_redeem_integrity.move | 13 ++++++++ certora/spec/sources/solvency.move | 22 +++++++++++-- certora/spec/sources/supply_control.move | 15 +++++++++ .../spec/sources/validators_consistency.move | 31 ++++++++++++++++++- 9 files changed, 156 insertions(+), 4 deletions(-) diff --git a/certora/spec/sources/accounting_no_lst_no_sui.move b/certora/spec/sources/accounting_no_lst_no_sui.move index dd0e5c4..3199f09 100644 --- a/certora/spec/sources/accounting_no_lst_no_sui.move +++ b/certora/spec/sources/accounting_no_lst_no_sui.move @@ -1,3 +1,9 @@ +/// Property: Token Supply Initialization Invariants +/// Description: Verifies that the liquid staking protocol maintains proper relationships between +/// LST (liquid staking token) and SUI supply. Specifically, this +/// ensures that an empty LST supply implies an empty SUI reserve (preventing orphaned SUI), and +/// conversely, that an empty SUI reserve implies no outstanding LST tokens (preventing unbacked tokens). + module spec::accounting_no_lst_no_sui; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; @@ -47,6 +53,8 @@ native fun invoke( ); +/// Checks that if there is no LST supply, then there is no SUI supply. +/// This prevents orphaned SUI that cannot be claimed through LST tokens. public fun no_lst_no_sui

(lsi: &LiquidStakingInfo

): bool { let lst = lsi.total_lst_supply(); let sui = lsi.total_sui_supply(); @@ -54,6 +62,8 @@ public fun no_lst_no_sui

(lsi: &LiquidStakingInfo

): bool { lst != 0 || sui == 0 } +/// Checks that if there is no SUI supply, then there is no LST supply. +/// This prevents unbacked LST tokens that cannot be redeemed for SUI. public fun no_sui_no_lst

(lsi: &LiquidStakingInfo

): bool { let lst = lsi.total_lst_supply(); let sui = lsi.total_sui_supply(); @@ -61,6 +71,8 @@ public fun no_sui_no_lst

(lsi: &LiquidStakingInfo

): bool { sui != 0 || lst == 0 } +/// Base case: Verifies that newly created liquid staking pools (both empty and with initial stake) +/// satisfy the invariant that zero LST supply implies zero SUI supply. public fun no_lst_no_sui_base( ctx: &mut TxContext, ) { @@ -86,6 +98,9 @@ public fun no_lst_no_sui_base( } +/// Inductive step: Verifies that all state-modifying operations preserve the invariant that +/// zero LST supply implies zero SUI supply. Assumes the system is solvent and has correct accounting +/// in the pre-state. public fun no_lst_no_sui_step( target: Function, lsi: &mut LiquidStakingInfo, @@ -114,6 +129,8 @@ public fun no_lst_no_sui_step( } +/// Base case: Verifies that newly created liquid staking pools (both empty and with initial stake) +/// satisfy the invariant that zero SUI supply implies zero LST supply. public fun no_sui_no_lst_base( ctx: &mut TxContext, ) { @@ -139,6 +156,9 @@ public fun no_sui_no_lst_base( } +/// Inductive step: Verifies that all state-modifying operations preserve the invariant that +/// zero SUI supply implies zero LST supply. Assumes the system is solvent and has correct accounting +/// in the pre-state. public fun no_sui_no_lst_step( target: Function, lsi: &mut LiquidStakingInfo, diff --git a/certora/spec/sources/accounting_total_sui_supply.move b/certora/spec/sources/accounting_total_sui_supply.move index 78342af..4457506 100644 --- a/certora/spec/sources/accounting_total_sui_supply.move +++ b/certora/spec/sources/accounting_total_sui_supply.move @@ -1,3 +1,11 @@ +/// Property: Total SUI Supply Accounting Accuracy +/// Description: Verifies that the total SUI supply tracked by the storage module precisely matches +/// the sum of all SUI held across different locations: the liquid pool, active stake across all validators, +/// and inactive stake awaiting activation. This property ensures that the protocol's internal accounting +/// accurately reflects the actual SUI holdings, preventing discrepancies that could lead to insolvency +/// or incorrect exchange rate calculations. The invariant is maintained across all storage operations +/// through inductive verification. + module spec::accounting_total_sui_supply; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; @@ -38,6 +46,7 @@ native fun invoke( ctx: &mut TxContext, ); +/// Computes the SUI value of active stake for a specific validator, accounting for exchange rate. fun staked_active(strg: &Storage, i: u64): u64 { let validator_info = &strg.validators()[i]; if (validator_info.active_stake().is_some()) { @@ -51,6 +60,7 @@ fun staked_active(strg: &Storage, i: u64): u64 { } } +/// Computes the SUI value of inactive stake for a specific validator. fun staked_inactive(strg: &Storage, i: u64): u64 { let validator_info = &strg.validators()[i]; if (validator_info.inactive_stake().is_some()) { @@ -61,6 +71,7 @@ fun staked_inactive(strg: &Storage, i: u64): u64 { } } +/// Computes the total SUI value (active + inactive) for a specific validator. fun validator_sui_supply(strg: &Storage, i: u64): u64 { let active_stake = staked_active(strg, i); let inactive_stake = staked_inactive(strg, i); @@ -68,6 +79,8 @@ fun validator_sui_supply(strg: &Storage, i: u64): u64 { active_stake + inactive_stake } +/// Computes the actual total SUI supply by summing the liquid pool and all validator stakes. +/// This is the ground truth used to verify the stored total_sui_supply value. fun current_supply(strg: &Storage): u64 { let mut i = 0; let mut v = strg.sui_pool().value(); @@ -79,18 +92,23 @@ fun current_supply(strg: &Storage): u64 { v } +/// Checks whether the stored total SUI supply matches the computed actual supply across all locations. public fun total_supply_correct(strg: &Storage): bool { let expected = current_supply(strg); let actual = strg.total_sui_supply(); expected == actual } +/// Base case: Verifies that newly created storage has correct total SUI supply accounting, +/// establishing the initial state for the invariant. public fun total_sui_supply_correct_base(ctx: &mut TxContext) { let strg = storage::new(ctx); cvlm_assert(total_supply_correct(&strg)); ghost_destroy(strg); } +/// Inductive step: Verifies that all storage operations preserve the invariant that the stored +/// total SUI supply matches the actual sum across all locations. Ensures accounting accuracy is maintained. public fun total_sui_supply_correct_step( target: Function, strg: &mut Storage, diff --git a/certora/spec/sources/accounting_value_conservation.move b/certora/spec/sources/accounting_value_conservation.move index b5f25db..8230958 100644 --- a/certora/spec/sources/accounting_value_conservation.move +++ b/certora/spec/sources/accounting_value_conservation.move @@ -1,3 +1,11 @@ +/// Property: Value Conservation +/// Description: Ensures that the liquid staking protocol conserves value across all operations. +/// For operations that do not involve deposits or redemptions, the total SUI and LST supplies must +/// be non-decreasing. For mint operations, the deposited SUI value must equal the sum of the LST backing +/// increase and protocol fees collected. For redeem operations, the burned LST value must equal the sum +/// of SUI returned to users and protocol fees. This comprehensive value conservation prevents any loss +/// or creation of value through protocol operations. + module spec::accounting_value_conservation; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; @@ -43,6 +51,8 @@ native fun invoke( ctx: &mut TxContext, ); +/// Verifies that for operations that cannot decrease SUI supply (non-redemption operations), +/// the total SUI backing remains non-decreasing. This prevents unauthorized SUI withdrawals. public fun sui_value_conservation( target: Function, lsi: &mut LiquidStakingInfo, @@ -61,6 +71,8 @@ public fun sui_value_conservation( cvlm_assert(sui_post >= sui_pre); } +/// Verifies that for operations that cannot decrease LST supply (non-redemption operations), +/// the total LST supply remains non-decreasing. This prevents unauthorized token burns. public fun lst_value_conservation( target: Function, lsi: &mut LiquidStakingInfo, @@ -77,6 +89,8 @@ public fun lst_value_conservation( cvlm_assert(lst_post >= lst_pre); } +/// Verifies that during mint operations, the deposited SUI value is fully accounted for as either +/// an increase in the SUI backing or as protocol fees. This ensures no value is lost during deposits. public fun deposit_value_conservation( lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, @@ -101,6 +115,8 @@ public fun deposit_value_conservation( ghost_destroy(lst); } +/// Verifies that during redemption operations, the decrease in SUI backing equals the sum of +/// SUI returned to the user and protocol fees collected. This ensures no value is lost during redemptions. public fun redeem_value_conservation( lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, diff --git a/certora/spec/sources/fees.move b/certora/spec/sources/fees.move index 6c7e61e..41cd53d 100644 --- a/certora/spec/sources/fees.move +++ b/certora/spec/sources/fees.move @@ -1,3 +1,11 @@ +/// Property: Fee Accounting Integrity +/// Description: Verifies that the protocol's fee accounting maintains critical invariants across all +/// operations. Accrued spread fees must never exceed the total SUI supply (preventing over-collection), +/// fees must grow monotonically except during collection operations (ensuring they are not lost), and +/// fees must never completely consume user deposits or redemptions (guaranteeing users always receive value). +/// These properties ensure the fee mechanism operates correctly without compromising user funds or +/// protocol accounting. + module spec::fees; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; @@ -45,6 +53,8 @@ native fun invoke( ctx: &mut TxContext, ); +/// Base case: Verifies that for newly created pools, accrued spread fees do not exceed the total SUI supply. +/// This establishes the initial state for the fee accounting invariant. public fun spread_fees_dont_exceed_sui_supply_base( ctx: &mut TxContext, ) { @@ -60,6 +70,8 @@ public fun spread_fees_dont_exceed_sui_supply_base( } +/// Inductive step: Verifies that all operations preserve the invariant that accrued spread fees +/// do not exceed the total SUI supply. This prevents the protocol from claiming fees it cannot honor. public fun spread_fees_dont_exceed_sui_supply_step( target: Function, lsi: &mut LiquidStakingInfo, @@ -82,6 +94,8 @@ public fun spread_fees_dont_exceed_sui_supply_step( cvlm_assert(spread_fees_post <= sui_post); } +/// Verifies that accrued fees either remain constant or increase across all operations, except +/// during explicit fee collection. This ensures fees are not lost or incorrectly reduced during operations. public fun fees_grow_monotonically( target: Function, lsi: &mut LiquidStakingInfo, @@ -103,6 +117,8 @@ public fun fees_grow_monotonically( cvlm_assert(collected || increased); } +/// Verifies that redemption fees do not completely consume the user's redemption, ensuring users +/// always receive a non-zero amount of SUI when redeeming non-zero LST tokens. public fun fees_dont_eat_redemption( lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, @@ -125,6 +141,8 @@ public fun fees_dont_eat_redemption( ghost_destroy(sui); } +/// Verifies that deposit fees do not completely consume the user's deposit, ensuring users +/// always receive a non-zero amount of LST tokens when depositing non-zero SUI. public fun fees_dont_eat_deposit( lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, diff --git a/certora/spec/sources/liquid_staking_sanity.move b/certora/spec/sources/liquid_staking_sanity.move index 2eca880..ab48c17 100644 --- a/certora/spec/sources/liquid_staking_sanity.move +++ b/certora/spec/sources/liquid_staking_sanity.move @@ -1,3 +1,10 @@ +/// Property: Sanity Checks +/// Description: Performs basic sanity verification on all liquid staking protocol functions to ensure +/// they can execute without reverting under valid preconditions. This serves as a smoke test to catch +/// fundamental implementation errors such as incorrect assertions, type mismatches, or logic errors that +/// would cause functions to always fail. Passing these checks confirms that the protocol's core operations +/// are at least executable under normal conditions. + module spec::liquid_staking_sanity; use cvlm::manifest::{ target, target_sanity }; diff --git a/certora/spec/sources/mint_redeem_integrity.move b/certora/spec/sources/mint_redeem_integrity.move index 70c229d..4ad5afb 100644 --- a/certora/spec/sources/mint_redeem_integrity.move +++ b/certora/spec/sources/mint_redeem_integrity.move @@ -1,3 +1,10 @@ +/// Property: Mint and Redemption Integrity +/// Description: Ensures the core deposit and withdrawal operations maintain fundamental integrity properties. +/// Users depositing non-zero amounts must receive non-zero value in return (either LST tokens or SUI plus fees), +/// preventing complete value loss. Additionally, this property verifies that there are no arbitrage opportunities +/// where a user could mint LST and immediately redeem it for more SUI than initially deposited. These guarantees +/// ensure fair pricing and protect users from loss of funds during the fundamental protocol operations. + module spec::mint_redeem_integrity; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; @@ -23,6 +30,8 @@ public fun cvlm_manifest() { //rule(b"redemption_liveness"); } +/// Verifies that redeeming a non-zero amount of LST tokens always results in receiving non-zero +/// total value (SUI returned plus fees), preventing complete loss of user funds during redemption. public fun no_lost_funds_on_redeem( lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, @@ -49,6 +58,8 @@ public fun no_lost_funds_on_redeem( +/// Verifies that depositing a non-zero amount of SUI always results in receiving non-zero total +/// value (LST tokens plus fees), preventing complete loss of user funds during minting. public fun no_lost_funds_on_mint( lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, @@ -85,6 +96,8 @@ public fun redemption_liveness(lsi: &mut LiquidStakingInfo, } +/// Verifies that no arbitrage opportunity exists where a user could deposit SUI, immediately redeem +/// the received LST tokens, and receive more SUI than initially deposited. This ensures fair pricing. public fun no_arbitrage_opportunity( lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, diff --git a/certora/spec/sources/solvency.move b/certora/spec/sources/solvency.move index 1f803c6..9d752c9 100644 --- a/certora/spec/sources/solvency.move +++ b/certora/spec/sources/solvency.move @@ -1,3 +1,11 @@ +/// Property: Protocol Solvency and Exchange Rate Monotonicity +/// Description: Verifies that the liquid staking protocol maintains solvency throughout its lifecycle, +/// ensuring that the SUI backing always meets or exceeds the LST supply (maintaining an exchange rate >= 1). +/// Additionally, this property verifies that the SUI/LST exchange rate is non-decreasing across operations, +/// protecting users from value dilution. Solvency is established at initialization and preserved through +/// induction across all state-modifying operations. The monotonic exchange rate ensures that LST tokens +/// never lose purchasing power relative to SUI over time. + module spec::solvency; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; @@ -50,6 +58,8 @@ native fun invoke( +/// Checks whether the protocol is solvent by verifying that the total SUI backing is at least +/// equal to the total LST supply. This ensures the exchange rate (SUI/LST) is at least 1:1. /// lsi.total_sui_supply()/lsi.total_lst_supply() >= 1 /// <==> lsi.total_sui_supply() >= lsi.total_lst_supply() public fun is_solvent(lsi: &LiquidStakingInfo): bool { @@ -59,7 +69,8 @@ public fun is_solvent(lsi: &LiquidStakingInfo): bool { sui_supply >= lst_supply } -/// The base case for the induction. +/// Base case: Verifies that newly created empty liquid staking pools are solvent. +/// This establishes the initial solvency invariant at pool creation. public fun solvency_base() { let fee_config = nondet(); let lst_treasury_cap = nondet(); @@ -72,7 +83,8 @@ public fun solvency_base() { ghost_destroy(lsi); } -/// The base case for the induction. +/// Base case: Verifies that newly created liquid staking pools initialized with existing stake are solvent. +/// This establishes the initial solvency invariant for pools created with pre-existing staked SUI. public fun solvency_base_staker() { let fee_config = nondet(); let mut system_state = nondet(); @@ -97,7 +109,8 @@ public fun solvency_base_staker() { } -/// The induction steps for the solvency invariant. +/// Inductive step: Verifies that all state-modifying operations preserve protocol solvency. +/// Assumes the protocol is solvent in the pre-state and proves it remains solvent after the operation. public fun solvency_step( target: Function, lsi: &mut LiquidStakingInfo, @@ -144,6 +157,9 @@ public fun insolvency_bound( } +/// Verifies that the SUI/LST exchange rate is non-decreasing across all operations. +/// This ensures LST holders never experience value dilution, as each LST token can always be +/// redeemed for at least as much SUI as it could previously. public fun monotonicity( target: Function, lsi: &mut LiquidStakingInfo, diff --git a/certora/spec/sources/supply_control.move b/certora/spec/sources/supply_control.move index 845dcfb..f4116b9 100644 --- a/certora/spec/sources/supply_control.move +++ b/certora/spec/sources/supply_control.move @@ -1,3 +1,10 @@ +/// Property: Supply Control Authorization +/// Description: Enforces strict access control on token supply modifications by verifying that only +/// authorized operations can increase or decrease the SUI and LST supplies. Specifically, only mint +/// operations can increase supplies, and only redemption operations can decrease supplies. This prevents +/// unauthorized minting or burning of tokens through administrative or operational functions, ensuring +/// that supply changes only occur through the intended user-facing deposit and withdrawal flows. + module spec::supply_control; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; @@ -42,6 +49,8 @@ native fun invoke( ctx: &mut TxContext, ); +/// Verifies that only authorized redemption operations can decrease the total SUI supply. +/// This prevents unauthorized withdrawal of SUI backing through non-redemption functions. public fun only_redemption_decreases_sui_supply( target: Function, lsi: &mut LiquidStakingInfo, @@ -64,6 +73,8 @@ public fun only_redemption_decreases_sui_supply( cvlm_assert(!decreased || can_decrease_supply(target)); } +/// Verifies that only authorized redemption operations can decrease the total LST supply. +/// This prevents unauthorized burning of LST tokens through non-redemption functions. public fun only_redemption_decreases_lst_supply( target: Function, lsi: &mut LiquidStakingInfo, @@ -83,6 +94,8 @@ public fun only_redemption_decreases_lst_supply( cvlm_assert(!decreased || can_decrease_supply(target)); } +/// Verifies that only authorized mint operations can increase the total LST supply. +/// This prevents unauthorized creation of LST tokens through non-mint functions. public fun only_minting_increases_lst_supply( target: Function, lsi: &mut LiquidStakingInfo, @@ -104,6 +117,8 @@ public fun only_minting_increases_lst_supply( cvlm_assert(!increased || can_increase_supply(target)); } +/// Verifies that only authorized mint operations can increase the total SUI supply backing. +/// This prevents unauthorized injection of SUI backing through non-mint functions. public fun only_minting_increases_sui_supply( target: Function, lsi: &mut LiquidStakingInfo, diff --git a/certora/spec/sources/validators_consistency.move b/certora/spec/sources/validators_consistency.move index 755c414..aa7d7ab 100644 --- a/certora/spec/sources/validators_consistency.move +++ b/certora/spec/sources/validators_consistency.move @@ -1,6 +1,15 @@ +/// Property: Validator Registry Consistency +/// Description: Ensures the internal validator registry maintains structural invariants critical for +/// correct protocol operation. The total SUI supply tracked in storage must equal the sum of SUI across +/// all validators plus the liquid pool. The registry must not contain duplicate validators (preventing +/// double-counting of stake), and validators can only be added or removed through authorized operations. +/// After refresh operations, the registry must contain no inactive stake or empty validator entries, +/// ensuring clean state. These properties guarantee accurate stake accounting and prevent inconsistencies +/// in the validator management system. + module spec::validators_consistency; -use cvlm::asserts::{cvlm_assert, cvlm_assume_msg, cvlm_assert_msg}; +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::function::Function; use cvlm::ghost::ghost_destroy; use cvlm::manifest::{target, invoker, rule}; @@ -99,6 +108,8 @@ fun current_supply(strg: &Storage): u64 { v } +/// Base case: Verifies that for newly created storage, the total SUI supply equals the computed +/// sum across all validators and the liquid pool. public fun total_sui_supply_correct_base(ctx: &mut TxContext) { let strg = storage::new(ctx); let supply = current_supply(&strg); @@ -107,6 +118,8 @@ public fun total_sui_supply_correct_base(ctx: &mut TxContext) { ghost_destroy(strg); } +/// Inductive step: Verifies that all storage operations preserve the invariant that total SUI supply +/// equals the sum across validators and the liquid pool, ensuring accurate accounting. public fun total_sui_supply_correct_step( target: Function, strg: &mut Storage, @@ -140,6 +153,8 @@ fun can_remove_validator(target: Function): bool { target.name() == b"refresh" } +/// Verifies that validators can only be added to the registry through explicitly authorized operations +/// (staking operations and direct validator addition), preventing unauthorized registry modifications. public fun can_add_correct( target: Function, strg: &mut Storage, @@ -156,6 +171,8 @@ public fun can_add_correct( cvlm_assert(!appended || allowed); } +/// Verifies that validators can only be removed from the registry through the refresh operation, +/// which cleans up empty validators, preventing unauthorized validator removal. public fun can_remove_correct( target: Function, strg: &mut Storage, @@ -172,6 +189,8 @@ public fun can_remove_correct( cvlm_assert(!removed || allowed); } +/// Verifies that adding a validator to the registry only occurs if neither its staking pool ID +/// nor its validator address already exists, preventing duplicate entries and double-counting. public fun no_duplicate_validators( strg: &mut Storage, staking_pool_id: ID, @@ -208,6 +227,8 @@ public fun no_duplicate_validators( cvlm_assert(!appended || (!id_exists && !address_exists )); } +/// Verifies that any single operation can add at most one validator to the registry, +/// preventing bulk additions that could bypass validation logic. public fun add_at_most_one( target: Function, strg: &mut Storage, @@ -257,12 +278,16 @@ fun no_stake_no_sui(strg: &Storage): bool { ret } +/// Base case: Verifies that for newly created storage, validators with no active or inactive stake +/// have zero total SUI amount recorded. public fun no_stake_no_sui_base(ctx: &mut TxContext) { let strg = storage::new(ctx); cvlm_assert(no_stake_no_sui(&strg)); ghost_destroy(strg); } +/// Inductive step: Verifies that all operations preserve the invariant that validators with no +/// active or inactive stake have zero total SUI recorded, preventing phantom stake. public fun no_stake_no_sui_step( target: Function, strg: &mut Storage, @@ -275,6 +300,8 @@ public fun no_stake_no_sui_step( cvlm_assert(no_stake_no_sui(strg)); } +/// Verifies that after a refresh operation completes, no validators in the registry contain +/// inactive stake, ensuring all stake has been properly activated or removed. public fun no_inactive_stake_after_refresh( lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, @@ -292,6 +319,8 @@ public fun no_inactive_stake_after_refresh( cvlm_assert(inactive.is_none()); } +/// Verifies that after a refresh operation completes, no validators in the registry are empty +/// (containing no stake), ensuring clean state and accurate registry size. public fun no_empty_validators_after_refresh( lsi: &mut LiquidStakingInfo, system_state: &mut SuiSystemState, From 5540879c08759cfda7bbd4687bfcb5b3d9d769d5 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 3 Feb 2026 14:23:43 +0100 Subject: [PATCH 47/68] chore: polishing --- .github/workflows/certora.yml | 5 +- ...edeem_integrity.conf => no_arbitrage.conf} | 2 +- certora/spec/confs/solvency.conf | 5 +- ...ncy_p4.conf => solvency_monotonicity.conf} | 15 +- .../spec/confs/validators_consistency_p1.conf | 2 +- .../spec/confs/validators_consistency_p2.conf | 11 +- ...ency_p3.conf => validators_integrity.conf} | 12 +- .../spec/sources/mint_redeem_integrity.move | 121 ----------- certora/spec/sources/no_arbitrage.move | 51 +++++ certora/spec/sources/solvency.move | 2 - .../spec/sources/validators_consistency.move | 202 +----------------- .../spec/sources/validators_integrity.move | 162 ++++++++++++++ 12 files changed, 251 insertions(+), 339 deletions(-) rename certora/spec/confs/{mint_redeem_integrity.conf => no_arbitrage.conf} (94%) rename certora/spec/confs/{validators_consistency_p4.conf => solvency_monotonicity.conf} (61%) rename certora/spec/confs/{validators_consistency_p3.conf => validators_integrity.conf} (54%) delete mode 100644 certora/spec/sources/mint_redeem_integrity.move create mode 100644 certora/spec/sources/no_arbitrage.move create mode 100644 certora/spec/sources/validators_integrity.move diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 5449634..5b73bfd 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -44,12 +44,11 @@ jobs: confs/accounting_total_sui_supply.conf confs/accounting_value_conservation.conf confs/fees.conf - confs/mint_redeem_integrity.conf + confs/no_arbitrage.conf confs/solvency.conf confs/validators_consistency_p1.conf confs/validators_consistency_p2.conf - confs/validators_consistency_p3.conf - confs/validators_consistency_p4.conf + confs/validators_integrity.conf job-name: "Verification" certora-key: ${{ secrets.CERTORAKEY }} env: diff --git a/certora/spec/confs/mint_redeem_integrity.conf b/certora/spec/confs/no_arbitrage.conf similarity index 94% rename from certora/spec/confs/mint_redeem_integrity.conf rename to certora/spec/confs/no_arbitrage.conf index 96bc951..b0d3061 100644 --- a/certora/spec/confs/mint_redeem_integrity.conf +++ b/certora/spec/confs/no_arbitrage.conf @@ -3,7 +3,7 @@ "server": "prover", "prover_version": "master", "rule": [ - "*mint_redeem_integrity*" + "*no_arbitrage*" ], "msg": "Various external flow integrity rules", "prover_args": [ diff --git a/certora/spec/confs/solvency.conf b/certora/spec/confs/solvency.conf index c0713b0..c24802a 100644 --- a/certora/spec/confs/solvency.conf +++ b/certora/spec/confs/solvency.conf @@ -2,8 +2,9 @@ "optimistic_loop": true, "server": "prover", "prover_version": "master", - "rule": [ - "*solvency*" + "rule": [ + "*solvency_base*", + "*solvency_step" ], "msg": "Reserve solvency", "prover_args": [ diff --git a/certora/spec/confs/validators_consistency_p4.conf b/certora/spec/confs/solvency_monotonicity.conf similarity index 61% rename from certora/spec/confs/validators_consistency_p4.conf rename to certora/spec/confs/solvency_monotonicity.conf index 966b449..b9d7763 100644 --- a/certora/spec/confs/validators_consistency_p4.conf +++ b/certora/spec/confs/solvency_monotonicity.conf @@ -1,13 +1,11 @@ { "optimistic_loop": true, - "loop_iter": 2, "server": "prover", "prover_version": "master", - "rule": [ - "*validators_consistency*no_inactive_stake_after_refresh", - "*validators_consistency*no_empty_validators_after_refresh" + "rule": [ + "*solvency*monotonicity" ], - "msg": "Validators' consistency: Refresh integrity", + "msg": "Reserve solvency monotonicity", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", @@ -16,6 +14,11 @@ "-maxBlockCount 1000000", "-tacDumpsWithInternalFunctions true", "-callTraceVecElemCount 0", - "-dumpCodeSizeAnalysis true" + "-dumpCodeSizeAnalysis true", + "-depth 20", + "-splitParallel", + "true", + "-dontStopAtFirstSplitTimeout", + "true" ] } \ No newline at end of file diff --git a/certora/spec/confs/validators_consistency_p1.conf b/certora/spec/confs/validators_consistency_p1.conf index c1f4474..92294de 100644 --- a/certora/spec/confs/validators_consistency_p1.conf +++ b/certora/spec/confs/validators_consistency_p1.conf @@ -4,7 +4,7 @@ "server": "prover", "prover_version": "master", "rule": [ - "*validators_consistency*total_sui_supply_correct_*", + "*validators_consistency*no_duplicate_validators", "*validators_consistency*no_stake_no_sui*" ], "msg": "Validators' consistency: Total sui supply correct", diff --git a/certora/spec/confs/validators_consistency_p2.conf b/certora/spec/confs/validators_consistency_p2.conf index 0d9c453..5f9bb94 100644 --- a/certora/spec/confs/validators_consistency_p2.conf +++ b/certora/spec/confs/validators_consistency_p2.conf @@ -1,15 +1,12 @@ { - "optimistic_loop": true, - "loop_iter": 2, + "optimistic_loop": true, // Cannot be pessimistic because there are internal loops that are not bounded + "loop_iter": 6, // one more than the upper bound, munged to 5 "server": "prover", "prover_version": "master", "rule": [ - "*validators_consistency*no_duplicate_validators", - "*validators_consistency*can_add_correct", - "*validators_consistency*can_remove_correct", - "*validators_consistency*add_at_most_one" + "*validators_consistency*validators_upper_bound*" ], - "msg": "List of validators: Consistency", + "msg": "List of validators: Bounded", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/confs/validators_consistency_p3.conf b/certora/spec/confs/validators_integrity.conf similarity index 54% rename from certora/spec/confs/validators_consistency_p3.conf rename to certora/spec/confs/validators_integrity.conf index 5f9bb94..945a1ce 100644 --- a/certora/spec/confs/validators_consistency_p3.conf +++ b/certora/spec/confs/validators_integrity.conf @@ -1,12 +1,16 @@ { - "optimistic_loop": true, // Cannot be pessimistic because there are internal loops that are not bounded - "loop_iter": 6, // one more than the upper bound, munged to 5 + "optimistic_loop": true, + "loop_iter": 2, "server": "prover", "prover_version": "master", "rule": [ - "*validators_consistency*validators_upper_bound*" + "*validators_integrity*can_add_correct", + "*validators_integrity*can_remove_correct", + "*validators_integrity*add_at_most_one", + "*validators_integrity*no_inactive_stake_after_refresh", + "*validators_integrity*no_empty_validators_after_refresh" ], - "msg": "List of validators: Bounded", + "msg": "List of validators: Consistency", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/sources/mint_redeem_integrity.move b/certora/spec/sources/mint_redeem_integrity.move deleted file mode 100644 index 4ad5afb..0000000 --- a/certora/spec/sources/mint_redeem_integrity.move +++ /dev/null @@ -1,121 +0,0 @@ -/// Property: Mint and Redemption Integrity -/// Description: Ensures the core deposit and withdrawal operations maintain fundamental integrity properties. -/// Users depositing non-zero amounts must receive non-zero value in return (either LST tokens or SUI plus fees), -/// preventing complete value loss. Additionally, this property verifies that there are no arbitrage opportunities -/// where a user could mint LST and immediately redeem it for more SUI than initially deposited. These guarantees -/// ensure fair pricing and protect users from loss of funds during the fundamental protocol operations. - -module spec::mint_redeem_integrity; - -use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; -use cvlm::ghost::ghost_destroy; -use cvlm::manifest::rule; -use cvlm::nondet::nondet; -use liquid_staking::fees::validate_fees; -use liquid_staking::liquid_staking::{LiquidStakingInfo}; -use spec::dummy::DummyToken; -use sui_system::sui_system::SuiSystemState; -use spec::common::setup_fresh; -use sui::coin::Coin; - -use sui::sui::SUI; - - -public fun cvlm_manifest() { - rule(b"no_lost_funds_on_redeem"); - rule(b"no_lost_funds_on_mint"); - rule(b"no_arbitrage_opportunity"); - - // Currently cannot be expressed - //rule(b"redemption_liveness"); -} - -/// Verifies that redeeming a non-zero amount of LST tokens always results in receiving non-zero -/// total value (SUI returned plus fees), preventing complete loss of user funds during redemption. -public fun no_lost_funds_on_redeem( - lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - setup_fresh(lsi, system_state, ctx); - - // Solvency is sufficient to make the rule pass. - // However, since the system is not solvent at all times, it is not safe to assume it here. - //cvlm_assume_msg(is_solvent(lsi), b"Solvency"); - - let coin: Coin = nondet(); - - let fees_pre = lsi.fees(); - - cvlm_assume_msg(coin.value() > 0, b"Non-zero value"); - let sui = lsi.redeem(coin, system_state, ctx); - - let fees = lsi.fees() - fees_pre; - - cvlm_assert(sui.value() + fees > 0); - ghost_destroy(sui); -} - - - -/// Verifies that depositing a non-zero amount of SUI always results in receiving non-zero total -/// value (LST tokens plus fees), preventing complete loss of user funds during minting. -public fun no_lost_funds_on_mint( - lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - setup_fresh(lsi, system_state, ctx); - - let coin: Coin = nondet(); - - let fees_pre = lsi.fees(); - - cvlm_assume_msg(coin.value() > 0, b"Non-zero value"); - let lst = lsi.mint(system_state, coin, ctx); - let fees = lsi.fees() - fees_pre; - - cvlm_assert(lst.value()+fees > 0); - ghost_destroy(lst); -} - - - -public fun redemption_liveness(lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - setup_fresh(lsi, system_state, ctx); - validate_fees(lsi.fee_config()); - - let lst: Coin = nondet(); - cvlm_assume_msg(lst.value() <= lsi.total_lst_supply(), b"Redeem at most the total supply"); - let sui_out = lsi.redeem(lst, system_state, ctx); - ghost_destroy(sui_out); - cvlm_assert(true); // need to asert the call to redeem did not abort. -} - - -/// Verifies that no arbitrage opportunity exists where a user could deposit SUI, immediately redeem -/// the received LST tokens, and receive more SUI than initially deposited. This ensures fair pricing. -public fun no_arbitrage_opportunity( - lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - setup_fresh(lsi, system_state, ctx); - validate_fees(lsi.fee_config()); - - // The rule fails if we have 0 LST but non-zero SUI supply - // This state, however, should not be possible to reach (check rule `no_lst_no_sui` in `solvency.move`) - cvlm_assume_msg(lsi.total_lst_supply() != 0 || lsi.total_sui_supply() == 0, b"No LST means no SUI supply"); - - let sui_in: Coin = nondet(); - let sui_in_value = sui_in.value(); - - let lst = lsi.mint(system_state, sui_in, ctx); - let sui_out = lsi.redeem(lst, system_state, ctx); - - cvlm_assert(sui_out.value() <= sui_in_value); - ghost_destroy(sui_out); -} diff --git a/certora/spec/sources/no_arbitrage.move b/certora/spec/sources/no_arbitrage.move new file mode 100644 index 0000000..8de79d0 --- /dev/null +++ b/certora/spec/sources/no_arbitrage.move @@ -0,0 +1,51 @@ +/// Property: Mint and Redemption Integrity +/// Description: Ensures the core deposit and withdrawal operations maintain fundamental integrity properties. +/// Users depositing non-zero amounts must receive non-zero value in return (either LST tokens or SUI plus fees), +/// preventing complete value loss. Additionally, this property verifies that there are no arbitrage opportunities +/// where a user could mint LST and immediately redeem it for more SUI than initially deposited. These guarantees +/// ensure fair pricing and protect users from loss of funds during the fundamental protocol operations. + +module spec::no_arbitrage; + +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; +use cvlm::ghost::ghost_destroy; +use cvlm::manifest::rule; +use cvlm::nondet::nondet; +use liquid_staking::fees::validate_fees; +use liquid_staking::liquid_staking::{LiquidStakingInfo}; +use spec::dummy::DummyToken; +use sui_system::sui_system::SuiSystemState; +use spec::common::setup_fresh; +use sui::coin::Coin; + +use sui::sui::SUI; + + +public fun cvlm_manifest() { + rule(b"no_arbitrage_opportunity"); +} + + +/// Verifies that no arbitrage opportunity exists where a user could deposit SUI, immediately redeem +/// the received LST tokens, and receive more SUI than initially deposited. This ensures fair pricing. +public fun no_arbitrage_opportunity( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup_fresh(lsi, system_state, ctx); + validate_fees(lsi.fee_config()); + + // The rule fails if we have 0 LST but non-zero SUI supply + // This state, however, should not be possible to reach (check rule `no_lst_no_sui` in `solvency.move`) + cvlm_assume_msg(lsi.total_lst_supply() != 0 || lsi.total_sui_supply() == 0, b"No LST means no SUI supply"); + + let sui_in: Coin = nondet(); + let sui_in_value = sui_in.value(); + + let lst = lsi.mint(system_state, sui_in, ctx); + let sui_out = lsi.redeem(lst, system_state, ctx); + + cvlm_assert(sui_out.value() <= sui_in_value); + ghost_destroy(sui_out); +} diff --git a/certora/spec/sources/solvency.move b/certora/spec/sources/solvency.move index 9d752c9..7b8a87b 100644 --- a/certora/spec/sources/solvency.move +++ b/certora/spec/sources/solvency.move @@ -123,8 +123,6 @@ public fun solvency_step( ); setup_fresh(lsi, system_state, ctx); - // cvlm_assume_msg(lsi.accrued_spread_fees() == 0, b"No fees"); - // cvlm_assume_msg(lsi.total_lst_supply() <= 10000 && lsi.total_lst_supply() <= 10000, b"Reasonable values for CEX"); cvlm_assume_msg(is_solvent(lsi), b"Assume solvency in pre state"); cvlm_assume_msg(total_supply_correct(lsi.storage()), b"Correct accounting"); diff --git a/certora/spec/sources/validators_consistency.move b/certora/spec/sources/validators_consistency.move index aa7d7ab..12a81ac 100644 --- a/certora/spec/sources/validators_consistency.move +++ b/certora/spec/sources/validators_consistency.move @@ -1,11 +1,12 @@ /// Property: Validator Registry Consistency /// Description: Ensures the internal validator registry maintains structural invariants critical for -/// correct protocol operation. The total SUI supply tracked in storage must equal the sum of SUI across -/// all validators plus the liquid pool. The registry must not contain duplicate validators (preventing -/// double-counting of stake), and validators can only be added or removed through authorized operations. -/// After refresh operations, the registry must contain no inactive stake or empty validator entries, -/// ensuring clean state. These properties guarantee accurate stake accounting and prevent inconsistencies -/// in the validator management system. +/// correct protocol operation. Validates that: (1) validators can only be added through authorized +/// staking operations, preventing unauthorized registry modifications; (2) validators can only be +/// removed through refresh operations; (3) at most one validator can be added per operation; (4) no +/// duplicate validators exist by staking pool ID or validator address; (5) the registry size never +/// exceeds the maximum validators limit; (6) validators with no active or inactive stake have zero +/// total SUI recorded; (7) after refresh, no inactive stake or empty validators remain in the registry. +/// These properties guarantee accurate stake accounting and structural integrity of the validator management system. module spec::validators_consistency; @@ -13,11 +14,8 @@ use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::function::Function; use cvlm::ghost::ghost_destroy; use cvlm::manifest::{target, invoker, rule}; -use cvlm::nondet::nondet; -use liquid_staking::liquid_staking::LiquidStakingInfo; -use liquid_staking::storage::{Self, Storage, get_sui_amount, active_stake, ValidatorInfo}; -use spec::common::{log, setup}; -use spec::dummy::DummyToken; +use liquid_staking::storage::{Self, Storage, active_stake, ValidatorInfo}; +use spec::common::{log}; use sui_system::sui_system::SuiSystemState; use liquid_staking::storage::max_validators; @@ -41,9 +39,6 @@ public fun cvlm_manifest() { invoker(b"invoke"); - rule(b"total_sui_supply_correct_base"); - rule(b"total_sui_supply_correct_step"); - rule(b"no_duplicate_validators"); rule(b"can_add_correct"); rule(b"can_remove_correct"); @@ -56,8 +51,6 @@ public fun cvlm_manifest() { rule(b"no_stake_no_sui_step"); - rule(b"no_inactive_stake_after_refresh"); - rule(b"no_empty_validators_after_refresh"); } native fun invoke( @@ -67,128 +60,6 @@ native fun invoke( ctx: &mut TxContext, ); -fun staked_active(strg: &Storage, i: u64): u64 { - let validator_info = &strg.validators()[i]; - if (validator_info.active_stake().is_some()) { - let active_stake = validator_info.active_stake().borrow(); - get_sui_amount( - validator_info.exchange_rate(), - active_stake.value(), - ) - } else { - 0 - } -} - -fun staked_inactive(strg: &Storage, i: u64): u64 { - let validator_info = &strg.validators()[i]; - if (validator_info.inactive_stake().is_some()) { - let inactive_stake = validator_info.inactive_stake().borrow(); - inactive_stake.staked_sui_amount() - } else { - 0 - } -} - -fun validator_sui_supply(strg: &Storage, i: u64): u64 { - let active_stake = staked_active(strg, i); - let inactive_stake = staked_inactive(strg, i); - - active_stake + inactive_stake -} - -fun current_supply(strg: &Storage): u64 { - let mut i = 0; - let mut v = strg.sui_pool().value(); - - while (i < strg.validators().length()) { - v = v + validator_sui_supply(strg, i); - i = i+1; - }; - v -} - -/// Base case: Verifies that for newly created storage, the total SUI supply equals the computed -/// sum across all validators and the liquid pool. -public fun total_sui_supply_correct_base(ctx: &mut TxContext) { - let strg = storage::new(ctx); - let supply = current_supply(&strg); - let supply_expected = strg.total_sui_supply(); - cvlm_assert(supply == supply_expected); - ghost_destroy(strg); -} - -/// Inductive step: Verifies that all storage operations preserve the invariant that total SUI supply -/// equals the sum across validators and the liquid pool, ensuring accurate accounting. -public fun total_sui_supply_correct_step( - target: Function, - strg: &mut Storage, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - cvlm_assume_msg(strg.validators().length() <= 1, b"Only one validator"); - - cvlm_assume_msg(ctx.epoch() > strg.last_refresh_epoch(), b"Assume fresh state"); - strg.refresh(system_state, ctx); - - let supply_pre = current_supply(strg); - let supply_expected_pre = strg.total_sui_supply(); - cvlm_assume_msg(supply_pre == supply_expected_pre, b"Assume invariant holds in pre state"); - - invoke(target, strg, system_state, ctx); - - strg.refresh(system_state, ctx); // No necessary but to be extra sure everything is up to date - let supply_post = current_supply(strg); - let supply_expected_post = strg.total_sui_supply(); - cvlm_assert(supply_post == supply_expected_post); -} - -fun can_add_validator(target: Function): bool { - target.name() == b"get_or_add_validator_index_by_staking_pool_id_mut" - || target.name() == b"join_stake" - || target.name() == b"join_fungible_stake" -} - -fun can_remove_validator(target: Function): bool { - target.name() == b"refresh" -} - -/// Verifies that validators can only be added to the registry through explicitly authorized operations -/// (staking operations and direct validator addition), preventing unauthorized registry modifications. -public fun can_add_correct( - target: Function, - strg: &mut Storage, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - let validators_pre = strg.validators().length(); - invoke(target, strg, system_state, ctx); - let validators_post = strg.validators().length(); - - let appended = validators_post > validators_pre; - let allowed = can_add_validator(target); - - cvlm_assert(!appended || allowed); -} - -/// Verifies that validators can only be removed from the registry through the refresh operation, -/// which cleans up empty validators, preventing unauthorized validator removal. -public fun can_remove_correct( - target: Function, - strg: &mut Storage, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - let validators_pre = strg.validators().length(); - invoke(target, strg, system_state, ctx); - let validators_post = strg.validators().length(); - - let removed = validators_post < validators_pre; - let allowed = can_remove_validator(target); - - cvlm_assert(!removed || allowed); -} - /// Verifies that adding a validator to the registry only occurs if neither its staking pool ID /// nor its validator address already exists, preventing duplicate entries and double-counting. public fun no_duplicate_validators( @@ -227,21 +98,6 @@ public fun no_duplicate_validators( cvlm_assert(!appended || (!id_exists && !address_exists )); } -/// Verifies that any single operation can add at most one validator to the registry, -/// preventing bulk additions that could bypass validation logic. -public fun add_at_most_one( - target: Function, - strg: &mut Storage, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - let validators_pre = strg.validators().length(); - invoke(target, strg, system_state, ctx); - let validators_post = strg.validators().length(); - - cvlm_assert(validators_post <= validators_pre + 1); -} - public fun validators_upper_bound_base(ctx: &mut TxContext) { let strg = storage::new(ctx); @@ -267,7 +123,7 @@ fun validator_no_stake_no_sui(v: &ValidatorInfo): bool { !(no_active && no_inactive) || v.total_sui_amount() == 0 } -fun no_stake_no_sui(strg: &Storage): bool { +public fun no_stake_no_sui(strg: &Storage): bool { let mut ret = true; let mut i = 0; while (i < strg.validators().length()) { @@ -300,41 +156,3 @@ public fun no_stake_no_sui_step( cvlm_assert(no_stake_no_sui(strg)); } -/// Verifies that after a refresh operation completes, no validators in the registry contain -/// inactive stake, ensuring all stake has been properly activated or removed. -public fun no_inactive_stake_after_refresh( - lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - setup(lsi); - cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Force refresh"); - - lsi.refresh(system_state, ctx); - - let i = nondet(); - cvlm_assume_msg(i < lsi.storage().validators().length(), b""); - - let inactive = lsi.storage().validators()[i].inactive_stake(); - cvlm_assert(inactive.is_none()); -} - -/// Verifies that after a refresh operation completes, no validators in the registry are empty -/// (containing no stake), ensuring clean state and accurate registry size. -public fun no_empty_validators_after_refresh( - lsi: &mut LiquidStakingInfo, - system_state: &mut SuiSystemState, - ctx: &mut TxContext, -) { - setup(lsi); - cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Force refresh"); - cvlm_assume_msg(no_stake_no_sui(lsi.storage()), b"Assume in pre state"); - - lsi.refresh(system_state, ctx); - - let i = nondet(); - cvlm_assume_msg(i < lsi.storage().validators().length(), b""); - let validator = &lsi.storage().validators()[i]; - - cvlm_assert(!validator.is_empty()); -} diff --git a/certora/spec/sources/validators_integrity.move b/certora/spec/sources/validators_integrity.move new file mode 100644 index 0000000..a71b35a --- /dev/null +++ b/certora/spec/sources/validators_integrity.move @@ -0,0 +1,162 @@ +/// Property: Validator Registry Consistency +/// Description: Ensures the internal validator registry maintains structural invariants critical for +/// correct protocol operation. Validates that: (1) validators can only be added through authorized +/// staking operations, preventing unauthorized registry modifications; (2) validators can only be +/// removed through refresh operations; (3) at most one validator can be added per operation; (4) no +/// duplicate validators exist by staking pool ID or validator address; (5) the registry size never +/// exceeds the maximum validators limit; (6) validators with no active or inactive stake have zero +/// total SUI recorded; (7) after refresh, no inactive stake or empty validators remain in the registry. +/// These properties guarantee accurate stake accounting and structural integrity of the validator management system. + +module spec::integrity; + +use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; +use cvlm::function::Function; +use cvlm::manifest::{target, invoker, rule}; +use cvlm::nondet::nondet; +use liquid_staking::liquid_staking::LiquidStakingInfo; +use liquid_staking::storage::{Storage}; +use spec::common::{setup}; +use spec::dummy::DummyToken; +use sui_system::sui_system::SuiSystemState; +use spec::validators_consistency::no_stake_no_sui; + +public fun cvlm_manifest() { + // Public mut functions + + target(@liquid_staking, b"storage", b"refresh"); + target(@liquid_staking, b"storage", b"change_validator_priority"); + target(@liquid_staking, b"storage", b"join_to_sui_pool"); + target(@liquid_staking, b"storage", b"join_stake"); + target(@liquid_staking, b"storage", b"join_fungible_stake"); + target(@liquid_staking, b"storage", b"join_inactive_stake_to_validator"); + target(@liquid_staking, b"storage", b"join_fungible_staked_sui_to_validator"); + target(@liquid_staking, b"storage", b"split_up_to_n_sui_from_sui_pool"); + target(@liquid_staking, b"storage", b"split_from_sui_pool"); + target(@liquid_staking, b"storage", b"unstake_approx_n_sui_from_validator"); + target(@liquid_staking, b"storage", b"unstake_approx_n_sui_from_active_stake"); + target(@liquid_staking, b"storage", b"unstake_approx_n_sui_from_inactive_stake"); + target(@liquid_staking, b"storage", b"split_n_sui"); + target(@liquid_staking, b"storage", b"get_or_add_validator_index_by_staking_pool_id_mut"); + + invoker(b"invoke"); + + rule(b"can_add_correct"); + rule(b"can_remove_correct"); + rule(b"add_at_most_one"); + + rule(b"no_inactive_stake_after_refresh"); + rule(b"no_empty_validators_after_refresh"); +} + +native fun invoke( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +); + + +fun can_add_validator(target: Function): bool { + target.name() == b"get_or_add_validator_index_by_staking_pool_id_mut" + || target.name() == b"join_stake" + || target.name() == b"join_fungible_stake" +} + +fun can_remove_validator(target: Function): bool { + target.name() == b"refresh" +} + +/// Verifies that validators can only be added to the registry through explicitly authorized operations +/// (staking operations and direct validator addition), preventing unauthorized registry modifications. +public fun can_add_correct( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + let validators_pre = strg.validators().length(); + invoke(target, strg, system_state, ctx); + let validators_post = strg.validators().length(); + + let appended = validators_post > validators_pre; + let allowed = can_add_validator(target); + + cvlm_assert(!appended || allowed); +} + +/// Verifies that validators can only be removed from the registry through the refresh operation, +/// which cleans up empty validators, preventing unauthorized validator removal. +public fun can_remove_correct( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + let validators_pre = strg.validators().length(); + invoke(target, strg, system_state, ctx); + let validators_post = strg.validators().length(); + + let removed = validators_post < validators_pre; + let allowed = can_remove_validator(target); + + cvlm_assert(!removed || allowed); +} + + + +/// Verifies that any single operation can add at most one validator to the registry, +/// preventing bulk additions that could bypass validation logic. +public fun add_at_most_one( + target: Function, + strg: &mut Storage, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + let validators_pre = strg.validators().length(); + invoke(target, strg, system_state, ctx); + let validators_post = strg.validators().length(); + + cvlm_assert(validators_post <= validators_pre + 1); +} + + +/// Verifies that after a refresh operation completes, no validators in the registry contain +/// inactive stake, ensuring all stake has been properly activated or removed. +public fun no_inactive_stake_after_refresh( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup(lsi); + cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Force refresh"); + + lsi.refresh(system_state, ctx); + + let i = nondet(); + cvlm_assume_msg(i < lsi.storage().validators().length(), b""); + + let inactive = lsi.storage().validators()[i].inactive_stake(); + cvlm_assert(inactive.is_none()); +} + +/// Verifies that after a refresh operation completes, no validators in the registry are empty +/// (containing no stake), ensuring clean state and accurate registry size. +public fun no_empty_validators_after_refresh( + lsi: &mut LiquidStakingInfo, + system_state: &mut SuiSystemState, + ctx: &mut TxContext, +) { + setup(lsi); + cvlm_assume_msg(ctx.epoch() > lsi.storage().last_refresh_epoch(), b"Force refresh"); + cvlm_assume_msg(no_stake_no_sui(lsi.storage()), b"Assume in pre state"); + + + lsi.refresh(system_state, ctx); + + let i = nondet(); + cvlm_assume_msg(i < lsi.storage().validators().length(), b""); + let validator = &lsi.storage().validators()[i]; + + cvlm_assert(!validator.is_empty()); +} From 6b72b4fb3bdce699a558aeb74fc5141329c7e293 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 3 Feb 2026 14:46:47 +0100 Subject: [PATCH 48/68] fix: invalid configurations --- .../spec/confs/validators_consistency_p1.conf | 2 +- .../spec/confs/validators_consistency_p2.conf | 2 +- certora/spec/confs/validators_integrity.conf | 2 +- .../spec/sources/validators_consistency.move | 18 +++++++--------- .../spec/sources/validators_integrity.move | 21 +++++++++---------- 5 files changed, 21 insertions(+), 24 deletions(-) diff --git a/certora/spec/confs/validators_consistency_p1.conf b/certora/spec/confs/validators_consistency_p1.conf index 92294de..4858b0f 100644 --- a/certora/spec/confs/validators_consistency_p1.conf +++ b/certora/spec/confs/validators_consistency_p1.conf @@ -7,7 +7,7 @@ "*validators_consistency*no_duplicate_validators", "*validators_consistency*no_stake_no_sui*" ], - "msg": "Validators' consistency: Total sui supply correct", + "msg": "Validators' consistency part 1", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/confs/validators_consistency_p2.conf b/certora/spec/confs/validators_consistency_p2.conf index 5f9bb94..6fda879 100644 --- a/certora/spec/confs/validators_consistency_p2.conf +++ b/certora/spec/confs/validators_consistency_p2.conf @@ -6,7 +6,7 @@ "rule": [ "*validators_consistency*validators_upper_bound*" ], - "msg": "List of validators: Bounded", + "msg": "Validators' consistency part 2", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/confs/validators_integrity.conf b/certora/spec/confs/validators_integrity.conf index 945a1ce..8844812 100644 --- a/certora/spec/confs/validators_integrity.conf +++ b/certora/spec/confs/validators_integrity.conf @@ -10,7 +10,7 @@ "*validators_integrity*no_inactive_stake_after_refresh", "*validators_integrity*no_empty_validators_after_refresh" ], - "msg": "List of validators: Consistency", + "msg": "Validator management integrity", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/sources/validators_consistency.move b/certora/spec/sources/validators_consistency.move index 12a81ac..6af9548 100644 --- a/certora/spec/sources/validators_consistency.move +++ b/certora/spec/sources/validators_consistency.move @@ -1,11 +1,9 @@ /// Property: Validator Registry Consistency /// Description: Ensures the internal validator registry maintains structural invariants critical for -/// correct protocol operation. Validates that: (1) validators can only be added through authorized -/// staking operations, preventing unauthorized registry modifications; (2) validators can only be -/// removed through refresh operations; (3) at most one validator can be added per operation; (4) no -/// duplicate validators exist by staking pool ID or validator address; (5) the registry size never -/// exceeds the maximum validators limit; (6) validators with no active or inactive stake have zero -/// total SUI recorded; (7) after refresh, no inactive stake or empty validators remain in the registry. +/// correct protocol operation. Validates that: (1) no duplicate validators exist by staking pool ID +/// or validator address; (2) the registry size never exceeds the maximum validators limit; (3) after +/// any operation followed by refresh, validators with no active or inactive stake have zero total SUI +/// recorded, preventing phantom stake. /// These properties guarantee accurate stake accounting and structural integrity of the validator management system. module spec::validators_consistency; @@ -40,10 +38,7 @@ public fun cvlm_manifest() { invoker(b"invoke"); rule(b"no_duplicate_validators"); - rule(b"can_add_correct"); - rule(b"can_remove_correct"); - rule(b"add_at_most_one"); - + rule(b"validators_upper_bound_base"); rule(b"validators_upper_bound_step"); @@ -99,12 +94,15 @@ public fun no_duplicate_validators( } +/// Base case: Verifies that newly created storage has a validator count within the maximum limit. public fun validators_upper_bound_base(ctx: &mut TxContext) { let strg = storage::new(ctx); cvlm_assert(strg.validators().length() <= max_validators()); ghost_destroy(strg); } +/// Inductive step: Verifies that all operations preserve the invariant that the validator count +/// never exceeds the maximum validators limit. public fun validators_upper_bound_step( target: Function, strg: &mut Storage, diff --git a/certora/spec/sources/validators_integrity.move b/certora/spec/sources/validators_integrity.move index a71b35a..c736cec 100644 --- a/certora/spec/sources/validators_integrity.move +++ b/certora/spec/sources/validators_integrity.move @@ -1,14 +1,13 @@ -/// Property: Validator Registry Consistency -/// Description: Ensures the internal validator registry maintains structural invariants critical for -/// correct protocol operation. Validates that: (1) validators can only be added through authorized -/// staking operations, preventing unauthorized registry modifications; (2) validators can only be -/// removed through refresh operations; (3) at most one validator can be added per operation; (4) no -/// duplicate validators exist by staking pool ID or validator address; (5) the registry size never -/// exceeds the maximum validators limit; (6) validators with no active or inactive stake have zero -/// total SUI recorded; (7) after refresh, no inactive stake or empty validators remain in the registry. -/// These properties guarantee accurate stake accounting and structural integrity of the validator management system. - -module spec::integrity; +/// Property: Validator Registry Integrity +/// Description: Verifies integrity properties of validator registry operations. Rules check that: +/// (1) validators can only be added through authorized staking operations (join_stake, join_fungible_stake) +/// or explicit validator addition (get_or_add_validator_index_by_staking_pool_id_mut), preventing +/// unauthorized registry modifications; (2) validators can only be removed through refresh operations; +/// (3) at most one validator can be added per operation; (4) after refresh, no inactive stake remains +/// in the registry; (5) after refresh, no empty validators remain in the registry. +/// These rules verify correct authorization and post-conditions for validator registry modifications. + +module spec::validators_integrity; use cvlm::asserts::{cvlm_assert, cvlm_assume_msg}; use cvlm::function::Function; From cae0fda5cdfad1c385799e2b9c47e957b8b7d2dc Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Wed, 4 Feb 2026 15:00:24 +0100 Subject: [PATCH 49/68] chore: Split CI steps to not hit resource limits --- .github/workflows/certora.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 5b73bfd..c1b607c 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -31,7 +31,7 @@ jobs: sui move -V - name: Apply patch run: ./certora/munges/munge.sh - - name: Submit Jobs to Certora Prover + - name: Submit Jobs to Certora Prover (Part I) uses: Certora/certora-run-action@v2 with: ecosystem: sui @@ -44,6 +44,16 @@ jobs: confs/accounting_total_sui_supply.conf confs/accounting_value_conservation.conf confs/fees.conf + job-name: "Verification" + certora-key: ${{ secrets.CERTORAKEY }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Submit Jobs to Certora Prover (Part II) + uses: Certora/certora-run-action@v2 + with: + ecosystem: sui + working-directory: certora/spec/ + configurations: |- confs/no_arbitrage.conf confs/solvency.conf confs/validators_consistency_p1.conf From 19a2cd23e9481a8468f314290593e19f5e9f18f3 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Wed, 4 Feb 2026 15:10:51 +0100 Subject: [PATCH 50/68] chore: Update CI job names --- .github/workflows/certora.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index c1b607c..9afddd2 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -44,7 +44,7 @@ jobs: confs/accounting_total_sui_supply.conf confs/accounting_value_conservation.conf confs/fees.conf - job-name: "Verification" + job-name: "Verification Part I" certora-key: ${{ secrets.CERTORAKEY }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -59,7 +59,7 @@ jobs: confs/validators_consistency_p1.conf confs/validators_consistency_p2.conf confs/validators_integrity.conf - job-name: "Verification" + job-name: "Verification Part II" certora-key: ${{ secrets.CERTORAKEY }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From be9b12f4d36be0a6def4111fe48587ee0c256a43 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Wed, 4 Feb 2026 18:58:50 +0100 Subject: [PATCH 51/68] fix: Sui supply reconciles only in next epoch --- .../sources/accounting_total_sui_supply.move | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/certora/spec/sources/accounting_total_sui_supply.move b/certora/spec/sources/accounting_total_sui_supply.move index 4457506..1aa6b9d 100644 --- a/certora/spec/sources/accounting_total_sui_supply.move +++ b/certora/spec/sources/accounting_total_sui_supply.move @@ -14,6 +14,7 @@ use cvlm::ghost::ghost_destroy; use cvlm::manifest::{target, invoker, rule}; use liquid_staking::storage::{Self, Storage, get_sui_amount, active_stake}; use sui_system::sui_system::SuiSystemState; +use cvlm::nondet::nondet; public fun cvlm_manifest() { // Public mut functions @@ -95,7 +96,7 @@ fun current_supply(strg: &Storage): u64 { /// Checks whether the stored total SUI supply matches the computed actual supply across all locations. public fun total_supply_correct(strg: &Storage): bool { let expected = current_supply(strg); - let actual = strg.total_sui_supply(); + let actual = strg.total_sui_supply(); expected == actual } @@ -120,13 +121,21 @@ public fun total_sui_supply_correct_step( cvlm_assume_msg(ctx.epoch() > strg.last_refresh_epoch(), b"Assume fresh state"); strg.refresh(system_state, ctx); - cvlm_assume_msg(total_supply_correct(strg), b"Assume invariant holds in pre state"); invoke(target, strg, system_state, ctx); - strg.refresh(system_state, ctx); // No necessary but to be extra sure everything is up to date - cvlm_assert(total_supply_correct(strg)); -} + // Advance epoch to force a refresh + // This is requiered because within a single epoch, unstaking can make the protocol’s stored total_sui_supply too high + // compared to the "actual" supply recomputed from the remaining staked sui. + // The unstake path updates accounting using the redeemed SUI amount and rounding during token splits, + // while the remaining active stake’s value (via the exchange-rate conversion) can drop by an extra unit due to truncation. + // Because refresh only runs once per epoch (last_refresh_epoch gate), this overstatement can persist until the next epoch, + // when refresh recomputes stake values and brings stored totals back in line. + let mut ctx2: TxContext = nondet(); + cvlm_assume_msg(ctx2.epoch() > strg.last_refresh_epoch(), b"Advance epoch so refresh can run"); + strg.refresh(system_state, &mut ctx2); + cvlm_assert(total_supply_correct(strg)); +} From 8cb8e58a251e421533a67e3740f406913c77ddde Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Thu, 5 Feb 2026 10:10:33 +0100 Subject: [PATCH 52/68] fix: validator consistency invariant only holds across epoch boundary --- .../sources/accounting_total_sui_supply.move | 16 +++++++++------- .../spec/sources/validators_consistency.move | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/certora/spec/sources/accounting_total_sui_supply.move b/certora/spec/sources/accounting_total_sui_supply.move index 1aa6b9d..62a1275 100644 --- a/certora/spec/sources/accounting_total_sui_supply.move +++ b/certora/spec/sources/accounting_total_sui_supply.move @@ -110,6 +110,14 @@ public fun total_sui_supply_correct_base(ctx: &mut TxContext) { /// Inductive step: Verifies that all storage operations preserve the invariant that the stored /// total SUI supply matches the actual sum across all locations. Ensures accounting accuracy is maintained. +/// +/// Note: This invariant only holds across epoch boundaries after refresh, as accounting can +/// temporarily drift within an epoch. The drift occurs because refresh_validator_info sets +/// total_sui_amount via get_sui_amount(...) which floors division, while unstaking paths (calling +/// redeem_and_update_accounting) debit total_sui_supply by the actual redeemed SUI from +/// redeem_fungible_staked_sui. Since flooring is not additive, partial unstakes can leave dust, +/// causing the stored total_sui_supply to be higher than the recomputed actual supply until refresh() +/// recomputes and reconciles it at the next epoch boundary. public fun total_sui_supply_correct_step( target: Function, strg: &mut Storage, @@ -125,13 +133,7 @@ public fun total_sui_supply_correct_step( invoke(target, strg, system_state, ctx); - // Advance epoch to force a refresh - // This is requiered because within a single epoch, unstaking can make the protocol’s stored total_sui_supply too high - // compared to the "actual" supply recomputed from the remaining staked sui. - // The unstake path updates accounting using the redeemed SUI amount and rounding during token splits, - // while the remaining active stake’s value (via the exchange-rate conversion) can drop by an extra unit due to truncation. - // Because refresh only runs once per epoch (last_refresh_epoch gate), this overstatement can persist until the next epoch, - // when refresh recomputes stake values and brings stored totals back in line. + // Force refresh at next epoch boundary to verify invariant holds let mut ctx2: TxContext = nondet(); cvlm_assume_msg(ctx2.epoch() > strg.last_refresh_epoch(), b"Advance epoch so refresh can run"); strg.refresh(system_state, &mut ctx2); diff --git a/certora/spec/sources/validators_consistency.move b/certora/spec/sources/validators_consistency.move index 6af9548..518a08c 100644 --- a/certora/spec/sources/validators_consistency.move +++ b/certora/spec/sources/validators_consistency.move @@ -16,6 +16,7 @@ use liquid_staking::storage::{Self, Storage, active_stake, ValidatorInfo}; use spec::common::{log}; use sui_system::sui_system::SuiSystemState; use liquid_staking::storage::max_validators; +use cvlm::nondet::nondet; public fun cvlm_manifest() { // Public mut functions @@ -142,6 +143,14 @@ public fun no_stake_no_sui_base(ctx: &mut TxContext) { /// Inductive step: Verifies that all operations preserve the invariant that validators with no /// active or inactive stake have zero total SUI recorded, preventing phantom stake. +/// +/// Note: This invariant only holds across epoch boundaries after refresh, as accounting can +/// temporarily drift within an epoch. The drift occurs because refresh_validator_info sets +/// total_sui_amount via get_sui_amount(...) which floors division, while unstaking paths (calling +/// redeem_and_update_accounting) debit total_sui_amount by the actual redeemed SUI from +/// redeem_fungible_staked_sui. Since flooring is not additive, partial unstakes can leave dust, +/// and after the last stake object is removed, total_sui_amount may still be > 0 until refresh() +/// recomputes and zeroes it at the next epoch boundary. public fun no_stake_no_sui_step( target: Function, strg: &mut Storage, @@ -150,7 +159,13 @@ public fun no_stake_no_sui_step( ) { cvlm_assume_msg(no_stake_no_sui(strg), b"Assume in pre state"); invoke(target, strg, system_state, ctx); - strg.refresh(system_state, ctx); + + // Force refresh at next epoch boundary to verify invariant holds + let mut ctx2: TxContext = nondet(); + cvlm_assume_msg(ctx2.epoch() < ctx.epoch(), b"Force refresh"); + strg.refresh(system_state, &mut ctx2); + + cvlm_assert(no_stake_no_sui(strg)); } From c9ff8a1a43beaa6996e494fd64b3bcac02d2f680 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Fri, 6 Feb 2026 14:22:53 +0100 Subject: [PATCH 53/68] chore: disable vacuity checks to mitigate timeout --- certora/spec/confs/validators_consistency_p2.conf | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/certora/spec/confs/validators_consistency_p2.conf b/certora/spec/confs/validators_consistency_p2.conf index 6fda879..db6e30a 100644 --- a/certora/spec/confs/validators_consistency_p2.conf +++ b/certora/spec/confs/validators_consistency_p2.conf @@ -1,12 +1,13 @@ { "optimistic_loop": true, // Cannot be pessimistic because there are internal loops that are not bounded - "loop_iter": 6, // one more than the upper bound, munged to 5 + "loop_iter": 6, // one more than the upper bound, munged to 5, + "rule_sanity": "none", // vacuity checks tend to time out for this rule "server": "prover", "prover_version": "master", "rule": [ "*validators_consistency*validators_upper_bound*" ], - "msg": "Validators' consistency part 2", + "msg": "Validators' consistency: List is upper bounded", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", From f4652aa35239844140d65d372b51163d8d9c230b Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Fri, 6 Feb 2026 15:18:42 +0100 Subject: [PATCH 54/68] chore: more accurate rule messages --- certora/spec/confs/accounting_value_conservation.conf | 2 +- certora/spec/confs/no_arbitrage.conf | 2 +- certora/spec/confs/solvency.conf | 2 +- certora/spec/confs/solvency_monotonicity.conf | 2 +- certora/spec/confs/supply_control_decreases.conf | 2 +- certora/spec/confs/supply_control_increases_p1.conf | 2 +- certora/spec/confs/supply_control_increases_p2.conf | 2 +- certora/spec/confs/validators_consistency_p1.conf | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/certora/spec/confs/accounting_value_conservation.conf b/certora/spec/confs/accounting_value_conservation.conf index 12de91f..8cbb50b 100644 --- a/certora/spec/confs/accounting_value_conservation.conf +++ b/certora/spec/confs/accounting_value_conservation.conf @@ -5,7 +5,7 @@ "rule": [ "*accounting_value_conservation*" ], - "msg": "SUI value conservation", + "msg": "LST and SUI value conservation", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/confs/no_arbitrage.conf b/certora/spec/confs/no_arbitrage.conf index b0d3061..c908593 100644 --- a/certora/spec/confs/no_arbitrage.conf +++ b/certora/spec/confs/no_arbitrage.conf @@ -5,7 +5,7 @@ "rule": [ "*no_arbitrage*" ], - "msg": "Various external flow integrity rules", + "msg": "No arbitrage opportunity", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/confs/solvency.conf b/certora/spec/confs/solvency.conf index c24802a..06f05e3 100644 --- a/certora/spec/confs/solvency.conf +++ b/certora/spec/confs/solvency.conf @@ -6,7 +6,7 @@ "*solvency_base*", "*solvency_step" ], - "msg": "Reserve solvency", + "msg": "LST solvency", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/confs/solvency_monotonicity.conf b/certora/spec/confs/solvency_monotonicity.conf index b9d7763..61c9fbc 100644 --- a/certora/spec/confs/solvency_monotonicity.conf +++ b/certora/spec/confs/solvency_monotonicity.conf @@ -5,7 +5,7 @@ "rule": [ "*solvency*monotonicity" ], - "msg": "Reserve solvency monotonicity", + "msg": "LST to SUI ratio monotonicity", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/confs/supply_control_decreases.conf b/certora/spec/confs/supply_control_decreases.conf index da69fce..a7920c5 100644 --- a/certora/spec/confs/supply_control_decreases.conf +++ b/certora/spec/confs/supply_control_decreases.conf @@ -6,7 +6,7 @@ "*only_redemption_decreases_sui_supply", "*only_redemption_decreases_lst_supply" ], - "msg": "Supply control - decrease rules", + "msg": "Supply control: decrease rules", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/confs/supply_control_increases_p1.conf b/certora/spec/confs/supply_control_increases_p1.conf index a881185..5fc2f6a 100644 --- a/certora/spec/confs/supply_control_increases_p1.conf +++ b/certora/spec/confs/supply_control_increases_p1.conf @@ -17,7 +17,7 @@ "spec::dummy::refresh", "spec::dummy::update_metadata" ], - "msg": "Supply control - increase rules", + "msg": "Supply control - increase rules (I)", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", diff --git a/certora/spec/confs/supply_control_increases_p2.conf b/certora/spec/confs/supply_control_increases_p2.conf index 02a3974..56dce38 100644 --- a/certora/spec/confs/supply_control_increases_p2.conf +++ b/certora/spec/confs/supply_control_increases_p2.conf @@ -10,7 +10,7 @@ "spec::dummy::redeem", "spec::dummy::collect_fees" ], - "msg": "Supply control - increase rules", + "msg": "Supply control - increase rules (II)", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", diff --git a/certora/spec/confs/validators_consistency_p1.conf b/certora/spec/confs/validators_consistency_p1.conf index 4858b0f..539837d 100644 --- a/certora/spec/confs/validators_consistency_p1.conf +++ b/certora/spec/confs/validators_consistency_p1.conf @@ -7,7 +7,7 @@ "*validators_consistency*no_duplicate_validators", "*validators_consistency*no_stake_no_sui*" ], - "msg": "Validators' consistency part 1", + "msg": "Validators' consistency ", "prover_args": [ "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", "-disabledTransformations HOIST_LOOPS", From 0011a54b8a6880252f0eccba91fa5b7c413abbaa Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Fri, 6 Feb 2026 10:00:49 -0800 Subject: [PATCH 55/68] Move CI test to Sui v1.62.1 and update certora dependencies to match --- .github/workflows/certora.yml | 2 +- certora/spec/Move.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 9afddd2..56ff8c4 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -24,7 +24,7 @@ jobs: run: | curl -sSfL https://raw.githubusercontent.com/Mystenlabs/suiup/main/install.sh | sh export PATH="$HOME/.local/bin:$PATH" - suiup install -y sui@1.53.2 + suiup install -y sui@mainnet-1.62.1 - name: Sui version run: | sui -V diff --git a/certora/spec/Move.toml b/certora/spec/Move.toml index 741ecc0..82c26dc 100644 --- a/certora/spec/Move.toml +++ b/certora/spec/Move.toml @@ -4,8 +4,8 @@ edition = "2024.beta" [dependencies] liquid_staking = { local = "../../contracts" } -cvlm = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "cvlm", rev = "eric/noContains" } -certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "eric/noContains" } +cvlm = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "cvlm", rev = "sui-v1.55.0-no-contains" } +certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "sui-v1.55.0-no-contains" } [addresses] spec = "0x0" \ No newline at end of file From 47dd8a25bcb2c69b9c046df903d9a70b58ad5510 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Mon, 9 Feb 2026 10:37:41 +0100 Subject: [PATCH 56/68] chore: update comments --- certora/spec/sources/no_arbitrage.move | 2 +- certora/spec/sources/validators_consistency.move | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/certora/spec/sources/no_arbitrage.move b/certora/spec/sources/no_arbitrage.move index 8de79d0..b2c7352 100644 --- a/certora/spec/sources/no_arbitrage.move +++ b/certora/spec/sources/no_arbitrage.move @@ -37,7 +37,7 @@ public fun no_arbitrage_opportunity( validate_fees(lsi.fee_config()); // The rule fails if we have 0 LST but non-zero SUI supply - // This state, however, should not be possible to reach (check rule `no_lst_no_sui` in `solvency.move`) + // This state, however, is not reachable (check rule `no_lst_no_sui` in `solvency.move`) cvlm_assume_msg(lsi.total_lst_supply() != 0 || lsi.total_sui_supply() == 0, b"No LST means no SUI supply"); let sui_in: Coin = nondet(); diff --git a/certora/spec/sources/validators_consistency.move b/certora/spec/sources/validators_consistency.move index 518a08c..2589cfd 100644 --- a/certora/spec/sources/validators_consistency.move +++ b/certora/spec/sources/validators_consistency.move @@ -2,8 +2,8 @@ /// Description: Ensures the internal validator registry maintains structural invariants critical for /// correct protocol operation. Validates that: (1) no duplicate validators exist by staking pool ID /// or validator address; (2) the registry size never exceeds the maximum validators limit; (3) after -/// any operation followed by refresh, validators with no active or inactive stake have zero total SUI -/// recorded, preventing phantom stake. +/// any operation followed by a forced epoch transition and refresh, validators with no active or inactive +/// stake have zero total SUI recorded, preventing phantom stake across epoch boundaries. /// These properties guarantee accurate stake accounting and structural integrity of the validator management system. module spec::validators_consistency; From 76aa33d4e0e9389d1402b37dbbe0d4e394937247 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 3 Mar 2026 09:05:47 +0100 Subject: [PATCH 57/68] fix: Adjust patches --- certora/munges/fees.patch | 6 ++--- certora/munges/munge.sh | 6 ++--- certora/munges/storage.patch | 48 +++++++++++++++++------------------- 3 files changed, 29 insertions(+), 31 deletions(-) diff --git a/certora/munges/fees.patch b/certora/munges/fees.patch index ca83af3..e3ada79 100644 --- a/certora/munges/fees.patch +++ b/certora/munges/fees.patch @@ -1,10 +1,10 @@ diff --git a/contracts/sources/fees.move b/contracts/sources/fees.move -index ae1cdd8..dd650c1 100644 +index 33df151..caa341c 100644 --- a/contracts/sources/fees.move +++ b/contracts/sources/fees.move -@@ -130,7 +130,7 @@ module liquid_staking::fees { +@@ -133,7 +133,7 @@ module liquid_staking::fees { // Note that while it's technically exploitable, we allow lsts to be created with 0 mint/redeem fees. - // This is because having a 0 fee LST is useful in certain cases where mint/redemption can only be done by + // This is because having a 0 fee LST is useful in certain cases where mint/redemption can only be done by // a single party. It is up to the pool creator to ensure that the fees are set correctly. - fun validate_fees(fees: &FeeConfig) { + public fun validate_fees(fees: &FeeConfig) { diff --git a/certora/munges/munge.sh b/certora/munges/munge.sh index 8987d18..2d0559c 100755 --- a/certora/munges/munge.sh +++ b/certora/munges/munge.sh @@ -1,3 +1,3 @@ -git apply certora/munges/liquid_staking.patch -git apply certora/munges/fees.patch -git apply certora/munges/storage.patch \ No newline at end of file +git apply -3 certora/munges/liquid_staking.patch +git apply -3 certora/munges/fees.patch +git apply -3 certora/munges/storage.patch \ No newline at end of file diff --git a/certora/munges/storage.patch b/certora/munges/storage.patch index 0faa7df..2f3e922 100644 --- a/certora/munges/storage.patch +++ b/certora/munges/storage.patch @@ -1,5 +1,5 @@ diff --git a/contracts/sources/storage.move b/contracts/sources/storage.move -index d3de10c..1d51580 100644 +index e729823..1023707 100644 --- a/contracts/sources/storage.move +++ b/contracts/sources/storage.move @@ -15,9 +15,13 @@ module liquid_staking::storage { @@ -16,9 +16,9 @@ index d3de10c..1d51580 100644 + /// The Storage struct holds all stake for the LST. public struct Storage has store { - /// Sui balance. Unstake operations deposit SUI here. + /// Sui balance. Unstake operations deposit SUI here. @@ -51,7 +55,7 @@ module liquid_staking::storage { - extra_fields: Bag + extra_fields: Bag, } - public(package) fun new(ctx: &mut TxContext): Storage { @@ -84,7 +84,7 @@ index d3de10c..1d51580 100644 self.total_sui_amount } -@@ -115,7 +123,7 @@ module liquid_staking::storage { +@@ -118,7 +126,7 @@ module liquid_staking::storage { i } @@ -93,17 +93,23 @@ index d3de10c..1d51580 100644 self.active_stake.is_none() && self.inactive_stake.is_none() && self.total_sui_amount == 0 } -@@ -126,7 +134,7 @@ module liquid_staking::storage { +@@ -129,10 +137,10 @@ module liquid_staking::storage { /// - Moves any inactive stake that can be converted to active stake. /// - Removes validators that have no stake. /// Returns true if the storage was updated. - public(package) fun refresh( +- self: &mut Storage, +- system_state: &mut SuiSystemState, +- ctx: &mut TxContext, + public fun refresh( - self: &mut Storage, - system_state: &mut SuiSystemState, - ctx: &mut TxContext -@@ -189,7 +197,7 @@ module liquid_staking::storage { - // this may return none in the case where the staking pool is inactive or ++ self: &mut Storage, ++ system_state: &mut SuiSystemState, ++ ctx: &mut TxContext + ): bool { + if (self.last_refresh_epoch == ctx.epoch()) { + return false +@@ -197,7 +205,7 @@ module liquid_staking::storage { + // this may return none in the case where the staking pool is inactive or // if sui system is currently in safe mode. In both these cases, the storage // object has the latest exchange rate already. - fun get_latest_exchange_rate( @@ -111,9 +117,9 @@ index d3de10c..1d51580 100644 self: &Storage, staking_pool_id: &ID, system_state: &mut SuiSystemState, -@@ -211,10 +219,9 @@ module liquid_staking::storage { +@@ -219,10 +227,9 @@ module liquid_staking::storage { - /// Update the total sui amount for the validator and modify the + /// Update the total sui amount for the validator and modify the /// storage sui supply accordingly assumes the exchange rate is up to date - fun refresh_validator_info(self: &mut Storage, i: u64) { + public fun refresh_validator_info(self: &mut Storage, i: u64) { @@ -123,26 +129,18 @@ index d3de10c..1d51580 100644 let mut total_sui_amount = 0; if (validator_info.active_stake.is_some()) { let active_stake = validator_info.active_stake.borrow(); -@@ -390,7 +397,8 @@ module liquid_staking::storage { - ((target_unstake_sui_amount as u128) - * (fungible_staked_sui_amount as u128) - + (total_sui_amount as u128) -- - 1) -+ - 1 -+ ) - / (total_sui_amount as u128) - ) as u64; - -@@ -543,7 +551,7 @@ module liquid_staking::storage { +@@ -568,8 +575,8 @@ module liquid_staking::storage { } /* Private functions */ - fun get_or_add_validator_index_by_staking_pool_id_mut( +- self: &mut Storage, + public fun get_or_add_validator_index_by_staking_pool_id_mut( - self: &mut Storage, ++ self: &mut Storage, system_state: &mut SuiSystemState, staking_pool_id: ID, -@@ -593,7 +601,7 @@ module liquid_staking::storage { + ctx: &mut TxContext, +@@ -620,7 +627,7 @@ module liquid_staking::storage { } /// copied directly from staking_pool.move From e0d0c382e278d0f30090efa32bc867f9a3c41b30 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 3 Mar 2026 09:06:05 +0100 Subject: [PATCH 58/68] chore: add package_summaries to gitignore --- certora/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/certora/.gitignore b/certora/.gitignore index c0e52d9..187518f 100644 --- a/certora/.gitignore +++ b/certora/.gitignore @@ -4,3 +4,4 @@ emv-*/ build/ Move.lock +package_summaries \ No newline at end of file From 0fd94f25f6ea4af80798149887ce41e46a4d50bc Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 3 Mar 2026 09:06:41 +0100 Subject: [PATCH 59/68] chore: force Sui dependencies to match the versions in cvlm/certora_sui_summaries --- certora/spec/Move.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/certora/spec/Move.toml b/certora/spec/Move.toml index 82c26dc..b1c5901 100644 --- a/certora/spec/Move.toml +++ b/certora/spec/Move.toml @@ -6,6 +6,9 @@ edition = "2024.beta" liquid_staking = { local = "../../contracts" } cvlm = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "cvlm", rev = "sui-v1.55.0-no-contains" } certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "sui-v1.55.0-no-contains" } +MoveStdlib = { override = true, git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "framework/testnet" } +Sui = { override = true, git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet" } +SuiSystem = { override = true, git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-system", rev = "framework/testnet" } [addresses] spec = "0x0" \ No newline at end of file From f1d73dfc10788ca157cc54cefb5cc168a04ef26c Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 3 Mar 2026 09:10:03 +0100 Subject: [PATCH 60/68] chore[ci]: bump sui version --- .github/workflows/certora.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 56ff8c4..4ed7a2f 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -24,7 +24,7 @@ jobs: run: | curl -sSfL https://raw.githubusercontent.com/Mystenlabs/suiup/main/install.sh | sh export PATH="$HOME/.local/bin:$PATH" - suiup install -y sui@mainnet-1.62.1 + suiup install -y sui@v1.63.1 - name: Sui version run: | sui -V From a27ce3606bc60e6533a9d112791a80a4f23ec656 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 3 Mar 2026 09:37:42 +0100 Subject: [PATCH 61/68] fix: method matching in confs --- .../confs/supply_control_increases_p1.conf | 18 +++++++++--------- .../confs/supply_control_increases_p2.conf | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/certora/spec/confs/supply_control_increases_p1.conf b/certora/spec/confs/supply_control_increases_p1.conf index 5fc2f6a..193985c 100644 --- a/certora/spec/confs/supply_control_increases_p1.conf +++ b/certora/spec/confs/supply_control_increases_p1.conf @@ -7,15 +7,15 @@ "*only_minting_increases_lst_supply" ], "method": [ - "spec::dummy::mint", - "spec::dummy::custom_redeem_request", - "spec::dummy::custom_redeem", - "spec::dummy::change_validator_priority", - "spec::dummy::decrease_validator_stake", - "spec::dummy::increase_validator_stake", - "spec::dummy::update_fees", - "spec::dummy::refresh", - "spec::dummy::update_metadata" + "0x0::dummy::mint", + "0x0::dummy::custom_redeem_request", + "0x0::dummy::custom_redeem", + "0x0::dummy::change_validator_priority", + "0x0::dummy::decrease_validator_stake", + "0x0::dummy::increase_validator_stake", + "0x0::dummy::update_fees", + "0x0::dummy::refresh", + "0x0::dummy::update_metadata" ], "msg": "Supply control - increase rules (I)", "prover_args": [ diff --git a/certora/spec/confs/supply_control_increases_p2.conf b/certora/spec/confs/supply_control_increases_p2.conf index 56dce38..a0cf2a5 100644 --- a/certora/spec/confs/supply_control_increases_p2.conf +++ b/certora/spec/confs/supply_control_increases_p2.conf @@ -7,8 +7,8 @@ "*only_minting_increases_lst_supply" ], "method" : [ - "spec::dummy::redeem", - "spec::dummy::collect_fees" + "0x0::dummy::redeem", + "0x0::dummy::collect_fees" ], "msg": "Supply control - increase rules (II)", From 79b8c88877bacc05e0c308c8c8c18acfc08b73e9 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 3 Mar 2026 09:55:56 +0100 Subject: [PATCH 62/68] chore[ci]: trigger on PR against main --- .github/workflows/certora.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 4ed7a2f..6f7f516 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -3,7 +3,7 @@ name: Certora Prover on: pull_request: branches: - - kel/specs + - main - certora workflow_dispatch: From 6793ecdd23a03f009d279d8de47a85694e8b4957 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 3 Mar 2026 11:23:58 +0100 Subject: [PATCH 63/68] chore: --- certora/munges/liquid_staking.patch | 0 certora/munges/munge.sh | 1 - certora/munges/record_patches.sh | 1 - certora/spec/Spec.conf | 13 ------------- 4 files changed, 15 deletions(-) delete mode 100644 certora/munges/liquid_staking.patch delete mode 100644 certora/spec/Spec.conf diff --git a/certora/munges/liquid_staking.patch b/certora/munges/liquid_staking.patch deleted file mode 100644 index e69de29..0000000 diff --git a/certora/munges/munge.sh b/certora/munges/munge.sh index 2d0559c..ec2072e 100755 --- a/certora/munges/munge.sh +++ b/certora/munges/munge.sh @@ -1,3 +1,2 @@ -git apply -3 certora/munges/liquid_staking.patch git apply -3 certora/munges/fees.patch git apply -3 certora/munges/storage.patch \ No newline at end of file diff --git a/certora/munges/record_patches.sh b/certora/munges/record_patches.sh index df50dd3..03ac8fb 100755 --- a/certora/munges/record_patches.sh +++ b/certora/munges/record_patches.sh @@ -1,3 +1,2 @@ -git diff HEAD:contracts/sources/liquid_staking.move contracts/sources/liquid_staking.move > certora/munges/liquid_staking.patch; git diff HEAD:contracts/sources/fees.move contracts/sources/fees.move > certora/munges/fees.patch; git diff HEAD:contracts/sources/storage.move contracts/sources/storage.move > certora/munges/storage.patch; \ No newline at end of file diff --git a/certora/spec/Spec.conf b/certora/spec/Spec.conf deleted file mode 100644 index 07c05bc..0000000 --- a/certora/spec/Spec.conf +++ /dev/null @@ -1,13 +0,0 @@ -{ - "optimistic_loop": true, - "prover_args": [ - "-maxConcurrentTransforms SIMPLIFIED:1,UNROLL:1,DSA:1", - "-disabledTransformations HOIST_LOOPS", - "-maxMergedBranchSize 1000000", - "-maxCommandCount 10000000", - "-maxBlockCount 1000000", - "-tacDumpsWithInternalFunctions true", - "-callTraceVecElemCount 0", - "-dumpCodeSizeAnalysis true", - ] -} \ No newline at end of file From f71b089751986ae9dd376a0f441676e53e5fac54 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Tue, 3 Mar 2026 11:24:21 +0100 Subject: [PATCH 64/68] chore: add readme --- certora/README.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 certora/README.md diff --git a/certora/README.md b/certora/README.md new file mode 100644 index 0000000..c01f070 --- /dev/null +++ b/certora/README.md @@ -0,0 +1,87 @@ +# Certora Formal Verification: Suilend Liquid Staking + +This directory contains Certora's formal verification of the Suilend liquid staking protocol, written in Move on Sui. + +## Directory Structure + +### `spec/` + +The primary verification package. Contains all specification files (written in Move using the `cvlm` framework), per-rule configuration files, and package metadata. + +- `spec/sources/`:Specification modules. Each `.move` file encodes one or more verifiable properties as rules. +- `spec/confs/`:Per-rule Certora Prover configuration files (`.conf`), one per property group. +- `spec/Move.toml`:Package manifest for spec sources. References the `liquid_staking` contract, the `cvlm` (Certora Verification Language for Move) library, Certora Sui framework summaries, and overrides for Sui system packages. + +### `munges/` + +Code modification scripts and patch files. These make internal functions accessible to the Prover by widening their visibility, and reduce `MAX_VALIDATORS` to make verification tractable. + +- `munge.sh`:Applies all patches before running verification. +- `unmunge.sh`:Reverts all patches. +- `record_patches.sh`:Regenerates patch files from current working-directory diffs against HEAD. +- `fees.patch`:Makes `validate_fees()` public. +- `storage.patch`:Widens visibility of view functions and internal helpers; reduces `MAX_VALIDATORS` from 50 to 5. + +> [!NOTE] +> The patches are applied as git patches and must be kept in sync with the source code. If the patched files change, `munge.sh` will fail to apply and verification will not run. Use `record_patches.sh` to regenerate the patches after updating the source. + +### `assumptions/` + +A separate package that verifies assumptions made by summaries in the main spec. Currently contains one rule proving that `liquid_staking::storage::get_sui_amount` is equivalent to `sui_system::staking_pool::get_sui_amount`. + +- `assumptions/sources/assumptions.move`:Assumption validation rules. +- `assumptions/Move.toml`:Package manifest. +- `Assumptions.conf`:Prover configuration for running assumption checks. + +## Certora Prover + +The Certora Prover is a formal verification tool for smart contracts. It statically proves or disproves properties expressed as rules in the Certora Verification Language for Move. + +## Running Instructions + +0. Install the latest certora prover by following the [installation guide](https://docs.certora.com/en/latest/docs/user-guide/install.html). + +1. From the repository root, apply the munges: + + ```sh + sh certora/munges/munge.sh + ``` + + This only needs to be done once per working copy. **Do not commit the munged files.** + +2. Change into the `certora/spec/` directory and run the desired verification job (see table below for all scripts). Example: + + ```sh + certoraRun confs/solvency.conf + ``` + + Note that `certora/spec/` must be the working directory for `certoraRun`, otherwise it will fail to compile. + +3. To revert munges: + + ```sh + sh certora/munges/unmunge.sh + ``` + +## High-Level Properties + +See the doc-comments in each spec file for detailed descriptions of individual rules. + +- **Solvency and Exchange Rate Monotonicity** (`solvency.move`, `solvency.conf`, `solvency_monotonicity.conf`): `total_sui_supply >= total_lst_supply` always holds; SUI/LST exchange rate is non-decreasing +- **Total SUI Supply Accounting** (`accounting_total_sui_supply.move`, `accounting_total_sui_supply.conf`): `total_sui_supply` equals the sum of the liquid SUI pool, active stake (via exchange rate), and inactive stake principal +- **Token Supply Initialization Invariants** (`accounting_no_lst_no_sui.move`, `accounting_no_lst_no_sui.conf`): zero LST implies zero SUI backing and vice versa +- **Value Conservation** (`accounting_value_conservation.move`, `accounting_value_conservation.conf`): no value is created or destroyed; on `mint`, deposited SUI equals backing increase plus fees; on `redeem`, SUI decrease equals SUI returned plus fees +- **Fee Accounting Integrity** (`fees.move`, `fees.conf`): accrued fees never exceed total SUI supply, grow monotonically except during collection, and never fully consume a deposit or redemption +- **Supply Control Authorization** (`supply_control.move`, `supply_control_decreases.conf`, `supply_control_increases_p1/p2.conf`): only `mint` can increase supplies; only `redeem`/`custom_redeem` can decrease them +- **No Arbitrage** (`no_arbitrage.move`, `no_arbitrage.conf`): a back-to-back `mint` then `redeem` never yields more SUI than was deposited +- **Validator Registry Consistency** (`validators_consistency.move`, `validators_consistency_p1/p2.conf`): no duplicate validators, registry never exceeds `MAX_VALIDATORS`, no-stake validators have zero `total_sui_amount` +- **Validator Registry Integrity** (`validators_integrity.move`, `validators_integrity.conf`): validators added only by authorized operations, removed only by `refresh`, at most one per call; `refresh` leaves no inactive stake or empty validators +- **Assumption Validation** (`assumptions/sources/assumptions.move`, `Assumptions.conf`): validates assumptions made in summaries + +## General Assumptions + +- **Loop unrolling.** All specs use `optimistic_loop: true`. `loop_iter` is set to 2 for most validator-list rules and 6 for `validators_upper_bound_step` (one above the munged `MAX_VALIDATORS = 5`). +- **Validator count reduction.** `storage.patch` reduces `MAX_VALIDATORS` from 50 to 5. Several inductive step rules further restrict to 1 validator for tractability. +- **`setup_fresh` precondition.** Most inductive steps model a post-`refresh` state: no inactive stake, all validators have active stake with matching pool IDs and up-to-date exchange rates, `total_sui_supply` is correct, and `last_refresh_epoch == ctx.epoch()`. Each assumption is justified by a separately verified rule. +- **Solvent exchange rates.** `summaries.move` assumes `sui_amount >= pool_token_amount` for all exchange rates, reflecting Sui system-level pool solvency. +- **`redeem_fungible_staked_sui` summary.** The full proportional split between principal and rewards is simplified to `get_sui_amount(er, value)`. An internal `cvlm_assert` checks this is an underapproximation of the actual withdrawal. From 21fc74fb947fe15968a6601549e0dc6516002b44 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Wed, 4 Mar 2026 08:12:53 +0100 Subject: [PATCH 65/68] Bump to sui 1.65.2 --- .github/workflows/certora.yml | 4 +++- certora/spec/Move.toml | 9 ++------- .../confs/supply_control_increases_p1.conf | 18 +++++++++--------- .../confs/supply_control_increases_p2.conf | 4 ++-- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 6f7f516..a275f9d 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -24,7 +24,7 @@ jobs: run: | curl -sSfL https://raw.githubusercontent.com/Mystenlabs/suiup/main/install.sh | sh export PATH="$HOME/.local/bin:$PATH" - suiup install -y sui@v1.63.1 + suiup install -y sui@1.65.2 - name: Sui version run: | sui -V @@ -34,6 +34,7 @@ jobs: - name: Submit Jobs to Certora Prover (Part I) uses: Certora/certora-run-action@v2 with: + cli-release: beta ecosystem: sui working-directory: certora/spec/ configurations: |- @@ -51,6 +52,7 @@ jobs: - name: Submit Jobs to Certora Prover (Part II) uses: Certora/certora-run-action@v2 with: + cli-release: beta ecosystem: sui working-directory: certora/spec/ configurations: |- diff --git a/certora/spec/Move.toml b/certora/spec/Move.toml index b1c5901..f9f89a9 100644 --- a/certora/spec/Move.toml +++ b/certora/spec/Move.toml @@ -5,10 +5,5 @@ edition = "2024.beta" [dependencies] liquid_staking = { local = "../../contracts" } cvlm = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "cvlm", rev = "sui-v1.55.0-no-contains" } -certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "sui-v1.55.0-no-contains" } -MoveStdlib = { override = true, git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "framework/testnet" } -Sui = { override = true, git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet" } -SuiSystem = { override = true, git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-system", rev = "framework/testnet" } - -[addresses] -spec = "0x0" \ No newline at end of file +certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "sui-v1.55.0-no-contains", rename-from = "certora" } +sui_system = { override = true, git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-system", rev = "framework/testnet" } \ No newline at end of file diff --git a/certora/spec/confs/supply_control_increases_p1.conf b/certora/spec/confs/supply_control_increases_p1.conf index 193985c..5fc2f6a 100644 --- a/certora/spec/confs/supply_control_increases_p1.conf +++ b/certora/spec/confs/supply_control_increases_p1.conf @@ -7,15 +7,15 @@ "*only_minting_increases_lst_supply" ], "method": [ - "0x0::dummy::mint", - "0x0::dummy::custom_redeem_request", - "0x0::dummy::custom_redeem", - "0x0::dummy::change_validator_priority", - "0x0::dummy::decrease_validator_stake", - "0x0::dummy::increase_validator_stake", - "0x0::dummy::update_fees", - "0x0::dummy::refresh", - "0x0::dummy::update_metadata" + "spec::dummy::mint", + "spec::dummy::custom_redeem_request", + "spec::dummy::custom_redeem", + "spec::dummy::change_validator_priority", + "spec::dummy::decrease_validator_stake", + "spec::dummy::increase_validator_stake", + "spec::dummy::update_fees", + "spec::dummy::refresh", + "spec::dummy::update_metadata" ], "msg": "Supply control - increase rules (I)", "prover_args": [ diff --git a/certora/spec/confs/supply_control_increases_p2.conf b/certora/spec/confs/supply_control_increases_p2.conf index a0cf2a5..56dce38 100644 --- a/certora/spec/confs/supply_control_increases_p2.conf +++ b/certora/spec/confs/supply_control_increases_p2.conf @@ -7,8 +7,8 @@ "*only_minting_increases_lst_supply" ], "method" : [ - "0x0::dummy::redeem", - "0x0::dummy::collect_fees" + "spec::dummy::redeem", + "spec::dummy::collect_fees" ], "msg": "Supply control - increase rules (II)", From 0f6a4ca65b36e8e7634664d2e87ef523aad3457e Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Wed, 4 Mar 2026 08:35:00 +0100 Subject: [PATCH 66/68] Pin cli version in CI --- .github/workflows/certora.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index a275f9d..c0a6823 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -34,6 +34,7 @@ jobs: - name: Submit Jobs to Certora Prover (Part I) uses: Certora/certora-run-action@v2 with: + cli-version: "8.10.1" cli-release: beta ecosystem: sui working-directory: certora/spec/ @@ -52,6 +53,7 @@ jobs: - name: Submit Jobs to Certora Prover (Part II) uses: Certora/certora-run-action@v2 with: + cli-version: "8.10.1" cli-release: beta ecosystem: sui working-directory: certora/spec/ From d938d0f14c848247f8758ed329259a7014072b39 Mon Sep 17 00:00:00 2001 From: Kevin Lotz Date: Wed, 4 Mar 2026 11:05:23 +0100 Subject: [PATCH 67/68] fix[ci]: missing conf --- .github/workflows/certora.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index c0a6823..aa4cbe8 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -60,6 +60,7 @@ jobs: configurations: |- confs/no_arbitrage.conf confs/solvency.conf + confs/solvency_monotonicity.conf confs/validators_consistency_p1.conf confs/validators_consistency_p2.conf confs/validators_integrity.conf From e4a1113ef48c5c9199704d865c8efd536040c47c Mon Sep 17 00:00:00 2001 From: ronanyeah Date: Thu, 5 Mar 2026 14:21:28 -0600 Subject: [PATCH 68/68] Workflow edit --- .github/workflows/certora.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index aa4cbe8..3518b61 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -1,11 +1,8 @@ name: Certora Prover on: - pull_request: - branches: - - main - - certora - workflow_dispatch: + push: + branches: [cvlm] jobs: certora_run: