diff --git a/.github/workflows/rust-wasm.yml b/.github/workflows/rust-wasm.yml index 5fcbe7ad..1b21c263 100644 --- a/.github/workflows/rust-wasm.yml +++ b/.github/workflows/rust-wasm.yml @@ -6,6 +6,7 @@ on: - "contracts/**" - "Cargo.toml" - "Cargo.lock" + - "scripts/check_bridge_abi.sh" - ".github/workflows/rust-wasm.yml" push: branches: @@ -14,6 +15,7 @@ on: - "contracts/**" - "Cargo.toml" - "Cargo.lock" + - "scripts/check_bridge_abi.sh" - ".github/workflows/rust-wasm.yml" jobs: @@ -177,3 +179,17 @@ jobs: name: testnet-deployment path: deployment.json retention-days: 7 + + bridge-abi: + name: Bridge ABI compatibility + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Issue #1305: the bridge-compat contract must keep its ABI shape and + # event signatures stable across vault upgrades. The checker is pure + # text analysis, so it runs without a Rust toolchain � and therefore + # gates even while the workspace does not compile. + - name: Verify bridge-compat ABI against snapshot + run: bash scripts/check_bridge_abi.sh diff --git a/contracts/bridge-compat/abi_snapshot.json b/contracts/bridge-compat/abi_snapshot.json new file mode 100644 index 00000000..7e2f9138 --- /dev/null +++ b/contracts/bridge-compat/abi_snapshot.json @@ -0,0 +1,198 @@ +{ + "bridge": { + "functions": [ + "pub fn admin(env: Env) -> Option
", + "pub fn confirm_transfer( env: Env, transfer_id: u64, ) -> Result<(), BridgeError>", + "pub fn default_provider(env: Env) -> Option", + "pub fn estimate_transfer( env: Env, amount: i128, dest_chain: u32, provider_id: Option, ) -> Result", + "pub fn fail_transfer( env: Env, transfer_id: u64, ) -> Result<(), BridgeError>", + "pub fn initialize(env: Env, admin: Address, token: Address) -> Result<(), BridgeError>", + "pub fn provider(env: Env, provider_id: u32) -> Option", + "pub fn provider_count(env: Env) -> u32", + "pub fn register_provider( env: Env, name: String, kind: BridgeProviderKind, endpoint: Address, fee_bps: i128, max_transfer: i128, supported_chains: Vec, ) -> Result", + "pub fn set_default_provider( env: Env, provider_id: u32, ) -> Result<(), BridgeError>", + "pub fn set_provider_enabled( env: Env, provider_id: u32, enabled: bool, ) -> Result<(), BridgeError>", + "pub fn set_provider_fee( env: Env, provider_id: u32, fee_bps: i128, ) -> Result<(), BridgeError>", + "pub fn set_transfer_limits( env: Env, limits: TransferLimits, ) -> Result<(), BridgeError>", + "pub fn token(env: Env) -> Option
", + "pub fn transfer(env: Env, transfer_id: u64) -> Option", + "pub fn transfer_limits(env: Env) -> TransferLimits", + "pub fn transfer_out( env: Env, sender: Address, recipient: Bytes, amount: i128, dest_chain: u32, provider_id: Option, ) -> Result", + "pub fn user_transfer_count(env: Env, user: Address) -> i128" + ], + "events": [ + "brgadd", + "brgdef", + "brgdone", + "brgfail", + "brgout", + "brgtog" + ] + }, + "vault": { + "functions": [ + "pub fn accept_admin(env: Env, proposal_id: u32) -> Result<(), VaultError>", + "pub fn accrue_korean_debt_yield(env: Env) -> Result", + "pub fn accrue_yield(env: Env, amount: i128) -> Result<(), VaultError>", + "pub fn activate_strategy_registration(env: Env, strategy: Address) -> Result<(), VaultError>", + "pub fn add_shipment( env: Env, shipment_id: u64, status: ShipmentStatus, ) -> Result<(), VaultError>", + "pub fn admin(env: Env) -> Option
", + "pub fn admin_param_change_interval(env: Env) -> u64", + "pub fn admin_proposal(env: Env, proposal_id: u32) -> Option", + "pub fn balance(env: Env, user: Address) -> i128", + "pub fn balance_at(env: Env, user: Address, checkpoint_id: u32) -> i128", + "pub fn batch_deposit( env: Env, relayer: Address, entries: Vec, ) -> Result", + "pub fn benji_strategy(env: Env) -> Address", + "pub fn calculate_assets(env: Env, shares: i128) -> Result", + "pub fn calculate_shares(env: Env, assets: i128) -> Result", + "pub fn cancel_admin_rotation(env: Env, proposal_id: u32) -> Result<(), VaultError>", + "pub fn cancel_emergency_action(env: Env, proposal_id: u32) -> Result<(), VaultError>", + "pub fn cancel_fee_bps_change(env: Env) -> Result<(), VaultError>", + "pub fn cancel_fee_curve_change(env: Env) -> Result<(), VaultError>", + "pub fn cancel_price_oracle_change(env: Env) -> Result<(), VaultError>", + "pub fn cancel_treasury_change(env: Env) -> Result<(), VaultError>", + "pub fn claim_all_fees(env: Env) -> Result<(), VaultError>", + "pub fn claim_fees(env: Env) -> Result<(), VaultError>", + "pub fn configure_korean_strategy(env: Env, strategy: Address)", + "pub fn confirm_emergency_action( env: Env, confirmer: Address, proposal_id: u32, ) -> Result<(), VaultError>", + "pub fn contract_version(env: Env) -> String", + "pub fn create_checkpoint(env: Env) -> u32", + "pub fn create_strategy_proposal(env: Env, proposer: Address, strategy: Address) -> u32", + "pub fn deposit(env: Env, user: Address, amount: i128) -> Result", + "pub fn diagnostics(env: Env) -> Result", + "pub fn diagnostics_enabled(env: Env) -> bool", + "pub fn divest(env: Env, amount: i128)", + "pub fn effective_fee_bps(env: Env) -> i128", + "pub fn emergency_approver_primary(env: Env) -> Option
", + "pub fn emergency_approver_secondary(env: Env) -> Option
", + "pub fn emergency_dispute_window(env: Env) -> u64", + "pub fn emergency_proposal(env: Env, proposal_id: u32) -> Option", + "pub fn execute_fee_bps_change(env: Env) -> Result<(), VaultError>", + "pub fn execute_fee_curve_change(env: Env) -> Result<(), VaultError>", + "pub fn execute_price_oracle_change(env: Env) -> Result<(), VaultError>", + "pub fn execute_strategy_proposal(env: Env, proposal_id: u32) -> Result<(), VaultError>", + "pub fn execute_treasury_change(env: Env) -> Result<(), VaultError>", + "pub fn execute_withdrawal(env: Env, user: Address) -> Result", + "pub fn fee_bps(env: Env) -> i128", + "pub fn fee_curve(env: Env) -> fee_curve::FeeCurve", + "pub fn finalize_governance_migration(env: Env)", + "pub fn gasless_deposit( env: Env, relayer: Address, user: Address, amount: i128, ) -> Result", + "pub fn governance_signers(env: Env) -> Option>", + "pub fn governance_threshold(env: Env) -> u32", + "pub fn idle_total_assets(env: Env) -> i128", + "pub fn initialize(env: Env, admin: Address, token: Address) -> Result<(), VaultError>", + "pub fn invest(env: Env, amount: i128) -> Result<(), VaultError>", + "pub fn is_gasless_relayer(env: Env, relayer: Address) -> bool", + "pub fn is_oracle_enabled(env: Env) -> bool", + "pub fn is_paused(env: Env) -> bool", + "pub fn is_performance_fee_enabled(env: Env) -> bool", + "pub fn is_relayer(env: Env, relayer: Address) -> bool", + "pub fn is_strategy_whitelisted(env: Env, strategy: Address) -> bool", + "pub fn korean_strategy(env: Env) -> Address", + "pub fn large_withdrawal_threshold(env: Env) -> i128", + "pub fn max_batch_size(env: Env) -> u32", + "pub fn max_conc_bps(env: Env) -> i128", + "pub fn max_deploy_bps(env: Env) -> i128", + "pub fn max_vault_tvl(env: Env) -> i128", + "pub fn metadata(env: Env) -> ContractMetadata", + "pub fn migrate_storage(env: Env, target_version: u32) -> Result<(), VaultError>", + "pub fn min_deposit(env: Env) -> i128", + "pub fn min_liquidity_buffer(env: Env) -> i128", + "pub fn oracle_heartbeat(env: Env) -> u64", + "pub fn pause(env: Env, reason: PauseReason)", + "pub fn pause_reason(env: Env) -> Option", + "pub fn pause_with_role( env: Env, caller: Address, reason: PauseReason, ) -> Result<(), VaultError>", + "pub fn pauser(env: Env) -> Option
", + "pub fn pending_admin(env: Env) -> Option
", + "pub fn pending_fee_bps_change(env: Env) -> Option", + "pub fn pending_fee_curve_change(env: Env) -> Option", + "pub fn pending_price_oracle_change(env: Env) -> Option", + "pub fn pending_treasury_change(env: Env) -> Option", + "pub fn per_user_cap(env: Env) -> i128", + "pub fn performance_fee_bps(env: Env) -> i128", + "pub fn performance_incentive_pool(env: Env) -> Option
", + "pub fn price_oracle(env: Env) -> Option
", + "pub fn process_withdrawal_queue(env: Env, max_entries: u32) -> u32", + "pub fn propose_admin(env: Env, new_admin: Address) -> u32", + "pub fn propose_emergency_action( env: Env, initiator: Address, kind: emergency::EmergencyActionKind, pause_reason_code: u32, divest_amount: Option, wasm_hash: Option>, ) -> Result", + "pub fn queue_fee_bps_change(env: Env, new_bps: i128) -> Result", + "pub fn queue_fee_curve_change(env: Env, curve: fee_curve::FeeCurve) -> Result", + "pub fn queue_price_oracle_change(env: Env, oracle: Address) -> Result", + "pub fn queue_treasury_change(env: Env, treasury: Address) -> Result", + "pub fn rebalance( env: Env, from_strategy: Address, to_strategy: Address, amount: i128, min_divest_value: i128, min_invest_value: i128, ) -> Result<(), VaultError>", + "pub fn record_strategy_heartbeat(env: Env, strategy: Address) -> Result<(), VaultError>", + "pub fn register_strategy(env: Env, strategy: Address) -> Result<(), VaultError>", + "pub fn report_benji_yield(env: Env, strategy: Address, amount: i128) -> Result<(), VaultError>", + "pub fn require_governance_threshold( env: Env, approvals: Vec
, ) -> Result<(), VaultError>", + "pub fn retire_strategy(env: Env, strategy: Address) -> Result<(), VaultError>", + "pub fn sensitive_timelock_delay(env: Env) -> u64", + "pub fn set_admin_param_change_interval(env: Env, seconds: u64) -> Result<(), VaultError>", + "pub fn set_dao_threshold(env: Env, threshold: i128) -> Result<(), VaultError>", + "pub fn set_diagnostics_enabled(env: Env, enabled: bool) -> Result<(), VaultError>", + "pub fn set_emergency_approvers(env: Env, primary: Address, secondary: Address)", + "pub fn set_emergency_dispute_window(env: Env, seconds: u64)", + "pub fn set_gasless_relayer(env: Env, relayer: Address, approved: bool)", + "pub fn set_governance_signers( env: Env, signers: Vec
, threshold: u32, migration_deadline: u64, ) -> Result<(), VaultError>", + "pub fn set_large_withdrawal_threshold(env: Env, threshold: i128) -> Result<(), VaultError>", + "pub fn set_max_batch_size(env: Env, size: u32) -> Result<(), VaultError>", + "pub fn set_max_conc_bps(env: Env, bps: i128) -> Result<(), VaultError>", + "pub fn set_max_deployed_bps(env: Env, bps: i128) -> Result<(), VaultError>", + "pub fn set_max_vault_tvl(env: Env, tvl: i128) -> Result<(), VaultError>", + "pub fn set_min_deposit(env: Env, new_min: i128) -> Result<(), VaultError>", + "pub fn set_min_liquidity_buffer(env: Env, new_buffer: i128) -> Result<(), VaultError>", + "pub fn set_oracle_enabled(env: Env, enabled: bool) -> Result<(), VaultError>", + "pub fn set_oracle_heartbeat(env: Env, seconds: u64) -> Result<(), VaultError>", + "pub fn set_pauser(env: Env, pauser: Option
) -> Result<(), VaultError>", + "pub fn set_per_user_cap(env: Env, cap: i128) -> Result<(), VaultError>", + "pub fn set_performance_fee_bps(env: Env, bps: i128) -> Result<(), VaultError>", + "pub fn set_performance_fee_enabled( env: Env, enabled: bool, ) -> Result<(), VaultError>", + "pub fn set_performance_incentive_pool( env: Env, pool: Address, ) -> Result<(), VaultError>", + "pub fn set_relayer(env: Env, relayer: Address, approved: bool)", + "pub fn set_sensitive_timelock_delay(env: Env, seconds: u64) -> Result<(), VaultError>", + "pub fn set_strategy(env: Env, strategy: Address) -> Result<(), VaultError>", + "pub fn set_strategy_cap(env: Env, strategy: Address, cap: i128)", + "pub fn set_strategy_heartbeat(env: Env, seconds: u64)", + "pub fn set_strategy_risk_threshold( env: Env, strategy: Address, threshold: i128, ) -> Result<(), VaultError>", + "pub fn set_strategy_switch_cooldown(env: Env, seconds: u64) -> Result<(), VaultError>", + "pub fn set_stress_limits( env: Env, concentration_bps: i128, deployed_bps: i128, ) -> Result<(), VaultError>", + "pub fn set_stress_mode(env: Env, enabled: bool) -> Result<(), VaultError>", + "pub fn set_treasury_claim_quota(env: Env, epoch_duration: u64, max_claim_amount: i128)", + "pub fn set_withdrawal_cooldown(env: Env, seconds: u64) -> Result<(), VaultError>", + "pub fn share_price(env: Env) -> i128", + "pub fn shipment_ids_by_status( env: Env, status: ShipmentStatus, cursor: Option, page_size: u32, ) -> Result", + "pub fn simulate_emergency_unwind( env: Env, estimated_slippage_bps: i128, estimated_fee_bps: i128, ) -> emergency::EmergencyUnwindResult", + "pub fn snapshot_user_balance(env: Env, user: Address)", + "pub fn storage_key_registry(env: Env) -> storage_registry::ValidateRegistryResult", + "pub fn storage_version(env: Env) -> u32", + "pub fn strategy(env: Env) -> Option
", + "pub fn strategy_cap(env: Env, strategy: Address) -> i128", + "pub fn strategy_heartbeat(env: Env) -> u64", + "pub fn strategy_last_heartbeat(env: Env, strategy: Address) -> Option", + "pub fn strategy_registration_state(env: Env, strategy: Address) -> Option", + "pub fn strategy_risk_threshold(env: Env, strategy: Address) -> i128", + "pub fn strategy_switch_cooldown(env: Env) -> u64", + "pub fn strategy_switch_cooldown_remaining(env: Env) -> u64", + "pub fn strategy_watermark(env: Env, strategy: Address) -> i128", + "pub fn stress_mode(env: Env) -> bool", + "pub fn token(env: Env) -> Address", + "pub fn total_assets(env: Env) -> Result", + "pub fn total_assets_at(env: Env, checkpoint_id: u32) -> i128", + "pub fn total_shares(env: Env) -> i128", + "pub fn total_shares_at(env: Env, checkpoint_id: u32) -> i128", + "pub fn treasury(env: Env) -> Option
", + "pub fn treasury_balance(env: Env) -> i128", + "pub fn treasury_rollover_excess(env: Env) -> i128", + "pub fn unpause(env: Env)", + "pub fn unpause_with_role(env: Env, caller: Address) -> Result<(), VaultError>", + "pub fn update_shipment_status( env: Env, shipment_id: u64, new_status: ShipmentStatus, ) -> Result<(), VaultError>", + "pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>)", + "pub fn user_deposit(env: Env, user: Address) -> i128", + "pub fn utilization_bps(env: Env) -> i128", + "pub fn vote_on_proposal( env: Env, voter: Address, proposal_id: u32, support: bool, weight: i128, ) -> Result<(), VaultError>", + "pub fn whitelist_strategy( env: Env, strategy: Address, approved: bool, ) -> Result<(), VaultError>", + "pub fn withdraw(env: Env, user: Address, shares: i128) -> Result", + "pub fn withdrawal_cooldown(env: Env) -> u64", + "pub fn withdrawal_queue_length(env: Env) -> u64" + ] + } +} diff --git a/scripts/check_bridge_abi.sh b/scripts/check_bridge_abi.sh new file mode 100644 index 00000000..a956fca7 --- /dev/null +++ b/scripts/check_bridge_abi.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# Bridge-compat ABI shape check (issue #1305). +# +# Verifies that the bridge-compat contract's ABI (exported functions + event +# topics) still matches the approved snapshot, and that the vault entry +# points the bridge integration relies on are still present after upgrades. +# +# Pure text analysis: runs without compiling, so it also gates while the +# workspace does not build. Regenerate the snapshot after an intentional +# change with: bash scripts/check_bridge_abi.sh --update +# +# Exit status: 0 when the surface matches, 1 on drift (with a diff). + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BRIDGE_SRC="$ROOT/contracts/bridge-compat/src" +VAULT_SRC="$ROOT/contracts/vault/src" +SNAPSHOT="$ROOT/contracts/bridge-compat/abi_snapshot.json" + +# Test-only sources never contribute to a contract ABI. +is_test_source() { + case "$1" in + */tests/*|*/test.rs|*_tests.rs|*/fuzz_*.rs|*/deposit_withdraw_props.rs|*/benji_strategy.rs) + return 0 ;; + *) return 1 ;; + esac +} + +# Emit normalized `name(params) -> Ret` signatures for every `pub fn` inside a +# #[contractimpl] block found in the given files. +# +# The impl block ends at the first column-0 `}`: inside an impl, no other +# top-level item can start, so that brace necessarily closes it. Brace +# counting is deliberately avoided — string literals and comments routinely +# contain unbalanced braces. +extract_functions() { + for file in "$@"; do + is_test_source "$file" && continue + awk ' + /#\[contractimpl\]/ { want_impl = 1; next } + want_impl && /^[[:space:]]*impl[[:space:]]/ { in_impl = 1; want_impl = 0; next } + in_impl && /^\}/ { in_impl = 0; next } + in_impl && /^[[:space:]]*pub[[:space:]]+fn[[:space:]]/ { + sig = $0 + while (sig !~ /\{/) { + if ((getline nextline) <= 0) break + sig = sig " " nextline + } + sub(/\{.*$/, "", sig) + gsub(/[[:space:]]+/, " ", sig) + gsub(/^ | $/, "", sig) + print sig + } + ' "$file" + done | sort -u +} + +# Emit every symbol_short! topic used by non-test sources. +extract_events() { + for file in "$@"; do + is_test_source "$file" && continue + grep -o 'symbol_short!("[^"]*")' "$file" 2>/dev/null || true + done | sed 's/symbol_short!("//; s/")//' | sort -u +} + +json_array() { + # json_array < each line on stdin > prints "a", "b", ... + local first=1 + while IFS= read -r line; do + [ -z "$line" ] && continue + if [ "$first" -eq 1 ]; then first=0; else printf ",\n"; fi + # identifiers/topics contain no quotes; escape backslashes defensively + printf ' "%s"' "$(printf '%s' "$line" | sed 's/\\/\\\\/g')" + done +} + +generate_snapshot() { + shopt -s nullglob + local bridge_sources=("$BRIDGE_SRC"/*.rs) + local vault_sources=("$VAULT_SRC"/*.rs) + bridge_fns=$(extract_functions "${bridge_sources[@]}") + bridge_events=$(extract_events "${bridge_sources[@]}") + vault_fns=$(extract_functions "${vault_sources[@]}") + { + printf '{\n' + printf ' "bridge": {\n' + printf ' "functions": [\n' + printf '%s\n' "$bridge_fns" | json_array + printf '\n ],\n' + printf ' "events": [\n' + printf '%s\n' "$bridge_events" | json_array + printf '\n ]\n' + printf ' },\n' + printf ' "vault": {\n' + printf ' "functions": [\n' + printf '%s\n' "$vault_fns" | json_array + printf '\n ]\n' + printf ' }\n' + printf '}\n' + } +} + +if [ "${1:-}" = "--update" ]; then + generate_snapshot > "$SNAPSHOT" + echo "snapshot written to $SNAPSHOT" + exit 0 +fi + +if [ ! -f "$SNAPSHOT" ]; then + echo "error: snapshot $SNAPSHOT does not exist; run with --update to create it" >&2 + exit 1 +fi + +tmp_current="$(mktemp)" +trap 'rm -f "$tmp_current"' EXIT +generate_snapshot > "$tmp_current" + +if diff -u "$SNAPSHOT" "$tmp_current"; then + echo "bridge ABI matches snapshot" +else + echo "" + echo "error: bridge-compat ABI surface drifted from contracts/bridge-compat/abi_snapshot.json" >&2 + echo "If the change is intentional, regenerate with: bash scripts/check_bridge_abi.sh --update" >&2 + exit 1 +fi