From 49985d873aae72e5ea29a10b584fdded33204361 Mon Sep 17 00:00:00 2001 From: fathiaoyinloye Date: Sun, 19 Jul 2026 18:51:25 +0100 Subject: [PATCH] security: add audit checklist and formal verification tests --- .github/workflows/contracts.yml | 7 +++ README.md | 21 ++++++++ betting/Cargo.toml | 5 +- betting/src/lib.rs | 24 ++++++++- docs/formal-verification-specs.md | 32 ++++++++++++ docs/security-review-checklist.md | 20 +++++++ oracle/src/test.rs | 24 ++++----- player-nft/Cargo.toml | 3 ++ player-nft/src/lib.rs | 14 +++++ rewards/src/lib.rs | 4 +- vault/src/lib.rs | 1 + vault/src/test.rs | 87 +++++++++++++++---------------- 12 files changed, 180 insertions(+), 62 deletions(-) create mode 100644 README.md create mode 100644 docs/formal-verification-specs.md create mode 100644 docs/security-review-checklist.md diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index ca18252..82533ea 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -17,9 +17,16 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: wasm32v1-none + components: clippy - uses: Swatinem/rust-cache@v2 + - name: Install cargo-audit + run: cargo install cargo-audit --locked - name: Check workspace run: cargo check --all + - name: Run Clippy with warnings denied + run: cargo clippy --all-targets --all-features -- -D warnings + - name: Run dependency audit + run: cargo audit - name: Check contract WASM targets run: | cargo check -p renaissance-counter --target wasm32v1-none --release diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c02452 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Renaissance Contract + +This workspace contains the Soroban smart contracts for the Renaissance betting and NFT flow. + +## Security review and verification + +The repository now includes a structured security review package: + +- Audit checklist: [docs/security-review-checklist.md](docs/security-review-checklist.md) +- Formal verification specs: [docs/formal-verification-specs.md](docs/formal-verification-specs.md) + +## CI enforcement + +The contract workflow enforces: + +- `cargo clippy --all-targets --all-features -- -D warnings` +- `cargo audit` + +## Unsafe code policy + +The audited contract crates use `#![forbid(unsafe_code)]` and any future `unsafe` block must be documented with a justification before it is merged. diff --git a/betting/Cargo.toml b/betting/Cargo.toml index 790355c..1241c8b 100644 --- a/betting/Cargo.toml +++ b/betting/Cargo.toml @@ -10,4 +10,7 @@ crate-type = ["cdylib", "rlib"] # cdylib is required to emit optimized WASM bina [dependencies] soroban-sdk = { workspace = true } -renaissance-core = { workspace = true } \ No newline at end of file +renaissance-core = { workspace = true } + +[package.metadata] +security-review = "requires formal payout verification" \ No newline at end of file diff --git a/betting/src/lib.rs b/betting/src/lib.rs index 9531727..2b3b1ee 100644 --- a/betting/src/lib.rs +++ b/betting/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +#![forbid(unsafe_code)] //! `#![no_std]` `renaissance-betting` smart contract. //! @@ -624,7 +625,7 @@ mod test { (env, admin, oracle, token) } - fn initialize(env: &Env, admin: &Address) -> RenaissanceBettingContractClient<'_> { + fn initialize<'a>(env: &'a Env, admin: &'a Address) -> RenaissanceBettingContractClient<'a> { let contract_id = env.register_contract(None, RenaissanceBettingContract); let client = RenaissanceBettingContractClient::new(env, &contract_id); client.initialize(admin); @@ -659,7 +660,7 @@ mod test { let new_hash = BytesN::from_array(&env, &[9; 32]); client.upgrade(&new_hash).unwrap(); - let res = client.try_upgrade(&new_hash); + let res = client.try_upgrade(&admin, &new_hash); assert!(res.is_err()); let match_data = client.get_match(&20u64).unwrap(); @@ -1014,6 +1015,25 @@ mod test { assert!(res.is_err()); } + #[test] + fn test_claim_payout_matches_documented_formula() { + let (env, admin, oracle, token) = setup(); + let client = initialize(&env, &admin); + client.register_match(&21u64, &oracle, &token, &deadline_in(&env, 3_600)); + + let winner = Address::generate(&env); + let loser = Address::generate(&env); + mint(&env, &token, &winner, 1_000); + mint(&env, &token, &loser, 1_000); + + client.place_bet(&winner, &21u64, &Outcome::HomeWin, &100i128); + client.place_bet(&loser, &21u64, &Outcome::Draw, &300i128); + client.settle_bet(&oracle, &21u64, &Outcome::HomeWin); + + let payout = client.claim_payout(&winner, &21).unwrap(); + assert_eq!(payout, 400); + } + // ── refund_bet ──────────────────────────────────────────────────────────── #[test] diff --git a/docs/formal-verification-specs.md b/docs/formal-verification-specs.md new file mode 100644 index 0000000..d1b4459 --- /dev/null +++ b/docs/formal-verification-specs.md @@ -0,0 +1,32 @@ +# Formal verification specs + +## Betting contract: payout correctness + +For a settled match, let: + +- $w$ be the winning outcome pool +- $t$ be the total pool across all outcomes +- $b$ be the bettor's stake for the winning outcome + +The payout for a winning bet is: + +$$ +\text{payout} = b + \left\lfloor \frac{b \cdot (t - w)}{w} \right\rfloor +$$ + +The implementation must ensure: + +1. The payout is computed only for bets on the winning outcome. +2. The winning pool is strictly positive before division. +3. The payout is computed with checked arithmetic to avoid overflow. +4. The bet is marked as claimed and the transfer amount equals the computed payout. + +## Player NFT contract: ownership invariant + +The ownership invariant is: + +- Each token has exactly one current owner at any time. +- The owner identity returned by the contract must match the most recent successful transfer or mint event. +- Ownership transitions are monotonic with respect to the authorized transfer flow. + +The current implementation is intentionally a contract boundary with no transfer logic yet; the invariant is documented here so future NFT ownership logic can be verified against it. diff --git a/docs/security-review-checklist.md b/docs/security-review-checklist.md new file mode 100644 index 0000000..5b0b13e --- /dev/null +++ b/docs/security-review-checklist.md @@ -0,0 +1,20 @@ +# Security review checklist + +This checklist is intended for pre-mainnet review of the Renaissance contracts. + +## Checklist + +- [x] Reentrancy: review the transfer and claim flows for reentrancy assumptions and ensure state is updated before external token transfers. +- [x] Overflow / underflow: use checked arithmetic for pool totals, payouts, and any arithmetic derived from user balances. +- [x] Access control: admin-only actions require authenticated principals and are gated by explicit authorization checks. +- [x] Front-running: value-changing actions are ordered around immutable state updates and settlement remains a dedicated oracle-controlled entry point. +- [x] Oracle manipulation: only the configured oracle can settle a match and the oracle address is replaceable only before settlement. + +## Static analysis + +- CI runs `cargo clippy --all-targets --all-features -- -D warnings`. +- CI runs `cargo audit` to surface dependency vulnerabilities before mainnet deployment. + +## Unsafe code policy + +The audited contract crates use `#![forbid(unsafe_code)]`. Any future `unsafe` block must be accompanied by a short justification describing why it is required and how it is bounded. diff --git a/oracle/src/test.rs b/oracle/src/test.rs index 70802c5..c28e50e 100644 --- a/oracle/src/test.rs +++ b/oracle/src/test.rs @@ -1,7 +1,7 @@ #![cfg(test)] use super::*; -use soroban_sdk::testutils::Ledger; +use soroban_sdk::testutils::{Address as _, Ledger}; use soroban_sdk::{testutils::AddressEnvTestUtils, Address, Env, Vec}; #[test] @@ -71,7 +71,7 @@ fn test_submit_result_success() { let finished_at = now - 600; // 10 minutes ago oracle1.require_auth(); - client.submit_result(&match_id, &2, &1, &started_at, &finished_at); + client.submit_result(&oracle1, &match_id, &2, &1, &started_at, &finished_at); // Result shouldn't be finalized yet (needs second confirmation) assert!(!client.is_finalized(&match_id)); @@ -101,7 +101,7 @@ fn test_submit_result_unauthorized() { // Unauthorized address tries to submit let match_id = 123; - client.submit_result(&match_id, &2, &1, &(now - 3600), &(now - 600)); + client.submit_result(&unauthorized, &match_id, &2, &1, &(now - 3600), &(now - 600)); } #[test] @@ -161,11 +161,11 @@ fn test_double_submit() { // First submission from oracle1 oracle1.require_auth(); - client.submit_result(&match_id, &2, &1, &started_at, &finished_at); + client.submit_result(&oracle1, &match_id, &2, &1, &started_at, &finished_at); // Second submission from oracle2 for the same match - should fail oracle2.require_auth(); - client.submit_result(&match_id, &3, &1, &started_at, &finished_at); + client.submit_result(&oracle2, &match_id, &3, &1, &started_at, &finished_at); } #[test] @@ -194,11 +194,11 @@ fn test_confirm_and_finalize() { // Submit from oracle1 oracle1.require_auth(); - client.submit_result(&match_id, &2, &1, &started_at, &finished_at); + client.submit_result(&oracle1, &match_id, &2, &1, &started_at, &finished_at); // Confirm from oracle2 - this should finalize oracle2.require_auth(); - client.confirm_result(&match_id); + client.confirm_result(&oracle2, &match_id); // Check if finalized assert!(client.is_finalized(&match_id)); @@ -238,11 +238,11 @@ fn test_cannot_confirm_own_submission() { // Submit from oracle1 oracle1.require_auth(); - client.submit_result(&match_id, &2, &1, &started_at, &finished_at); + client.submit_result(&oracle1, &match_id, &2, &1, &started_at, &finished_at); // Try to confirm own submission - should fail oracle1.require_auth(); - client.confirm_result(&match_id); + client.confirm_result(&oracle1, &match_id); } #[test] @@ -274,13 +274,13 @@ fn test_double_confirm() { // Submit from oracle1 oracle1.require_auth(); - client.submit_result(&match_id, &2, &1, &started_at, &finished_at); + client.submit_result(&oracle1, &match_id, &2, &1, &started_at, &finished_at); // Confirm from oracle2 oracle2.require_auth(); - client.confirm_result(&match_id); + client.confirm_result(&oracle2, &match_id); // Try to confirm again from oracle2 - should fail oracle2.require_auth(); - client.confirm_result(&match_id); + client.confirm_result(&oracle2, &match_id); } diff --git a/player-nft/Cargo.toml b/player-nft/Cargo.toml index 5dc5e00..040bee9 100644 --- a/player-nft/Cargo.toml +++ b/player-nft/Cargo.toml @@ -12,3 +12,6 @@ crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } renaissance-core = { workspace = true } + +[package.metadata] +security-review = "requires formal ownership invariant review" diff --git a/player-nft/src/lib.rs b/player-nft/src/lib.rs index ff730f4..f87aaa8 100644 --- a/player-nft/src/lib.rs +++ b/player-nft/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +#![forbid(unsafe_code)] use soroban_sdk::{contract, contractimpl, Env, Symbol}; @@ -12,3 +13,16 @@ impl PlayerNftContract { Symbol::new(&env, "player_nft") } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn contract_name_is_stable() { + let env = Env::default(); + let contract_id = env.register_contract(None, PlayerNftContract); + let client = PlayerNftContractClient::new(&env, &contract_id); + assert_eq!(client.contract_name(), Symbol::new(&env, "player_nft")); + } +} diff --git a/rewards/src/lib.rs b/rewards/src/lib.rs index 44eb9ba..b5a218c 100644 --- a/rewards/src/lib.rs +++ b/rewards/src/lib.rs @@ -353,7 +353,7 @@ impl FanRewardsContract { #[cfg(test)] mod test { use super::*; - use soroban_sdk::{symbol_short, testutils::Address as _, Symbol}; + use soroban_sdk::{symbol_short, testutils::{Address as _, Events}, Symbol}; fn setup() -> (Env, Address, Address) { let env = Env::default(); @@ -391,7 +391,7 @@ mod test { assert!(res.is_err()); } - fn client(env: &Env, contract_id: &Address) -> FanRewardsContractClient { + fn client<'a>(env: &'a Env, contract_id: &'a Address) -> FanRewardsContractClient<'a> { FanRewardsContractClient::new(env, contract_id) } diff --git a/vault/src/lib.rs b/vault/src/lib.rs index ff54351..9356d89 100644 --- a/vault/src/lib.rs +++ b/vault/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +#![forbid(unsafe_code)] //! `renaissance-vault` token vault contract for betting stakes. //! diff --git a/vault/src/test.rs b/vault/src/test.rs index 6c98f96..3206ad3 100644 --- a/vault/src/test.rs +++ b/vault/src/test.rs @@ -1,9 +1,9 @@ #![cfg(test)] -use super::*; -use soroban_sdk::testutils::Ledger; -use soroban_sdk::token::Client; -use soroban_sdk::{testutils::AddressEnvTestUtils, Address, Env}; +use super::{RenaissanceVaultContract, RenaissanceVaultContractClient}; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::token::StellarAssetClient; +use soroban_sdk::{Address, Env}; #[test] fn test_initialize() { @@ -48,7 +48,7 @@ fn test_deposit_withdraw() { // Deploy and initialize token contract (mock SAC) let token_contract = env.register_stellar_asset_contract(admin.clone()); - let token_client = Client::new(&env, &token_contract.get_address()); + let token_client = StellarAssetClient::new(&env, &token_contract); // Deploy vault let vault_id = env.register_contract(None, RenaissanceVaultContract); @@ -62,28 +62,25 @@ fn test_deposit_withdraw() { // Deposit into vault user.require_auth(); - vault_client.deposit(&user, &500, &token_contract.get_address()); + vault_client.deposit(&user, &500, &token_contract); // Check balances - let user_balance = vault_client.get_user_balance(&user, &token_contract.get_address()); + let user_balance = vault_client.get_user_balance(&user, &token_contract); assert_eq!(user_balance.available, 500); assert_eq!(user_balance.locked, 0); assert_eq!( - vault_client.get_vault_balance(&token_contract.get_address()), + vault_client.get_vault_balance(&token_contract), 500 ); // Withdraw some user.require_auth(); - vault_client.withdraw(&user, &200, &token_contract.get_address()); + vault_client.withdraw(&user, &200, &token_contract); // Check updated balances - let user_balance = vault_client.get_user_balance(&user, &token_contract.get_address()); + let user_balance = vault_client.get_user_balance(&user, &token_contract); assert_eq!(user_balance.available, 300); - assert_eq!( - vault_client.get_vault_balance(&token_contract.get_address()), - 300 - ); + assert_eq!(vault_client.get_vault_balance(&token_contract), 300); } #[test] @@ -100,16 +97,16 @@ fn test_withdraw_insufficient_balance() { vault_client.initialize(&admin, &betting_contract); let user = Address::generate(&env); - let token_client = Client::new(&env, &token_contract.get_address()); + let token_client = StellarAssetClient::new(&env, &token_contract); token_client.mint(&user, &1000); // Deposit 300 user.require_auth(); - vault_client.deposit(&user, &300, &token_contract.get_address()); + vault_client.deposit(&user, &300, &token_contract); // Try to withdraw 400 - should fail user.require_auth(); - vault_client.withdraw(&user, &400, &token_contract.get_address()); + vault_client.withdraw(&user, &400, &token_contract); } #[test] @@ -125,22 +122,22 @@ fn test_lock_for_bet_success() { vault_client.initialize(&admin, &betting_contract); let user = Address::generate(&env); - let token_client = Client::new(&env, &token_contract.get_address()); + let token_client = StellarAssetClient::new(&env, &token_contract); token_client.mint(&user, &1000); // Deposit funds user.require_auth(); - vault_client.deposit(&user, &500, &token_contract.get_address()); + vault_client.deposit(&user, &500, &token_contract); // Betting contract locks funds for a bet betting_contract.require_auth(); - vault_client.lock_for_bet(&user, &200, &token_contract.get_address(), &123); + vault_client.lock_for_bet(&user, &200, &token_contract, &123); // Check balances after lock - let user_balance = vault_client.get_user_balance(&user, &token_contract.get_address()); + let user_balance = vault_client.get_user_balance(&user, &token_contract); assert_eq!(user_balance.available, 300); assert_eq!(user_balance.locked, 200); - assert!(vault_client.is_locked_for_bet(&123, &user, &token_contract.get_address())); + assert!(vault_client.is_locked_for_bet(&123, &user, &token_contract)); } #[test] @@ -157,16 +154,16 @@ fn test_lock_for_bet_insufficient_balance() { vault_client.initialize(&admin, &betting_contract); let user = Address::generate(&env); - let token_client = Client::new(&env, &token_contract.get_address()); + let token_client = StellarAssetClient::new(&env, &token_contract); token_client.mint(&user, &1000); // Deposit only 100 user.require_auth(); - vault_client.deposit(&user, &100, &token_contract.get_address()); + vault_client.deposit(&user, &100, &token_contract); // Try to lock 200 - should fail betting_contract.require_auth(); - vault_client.lock_for_bet(&user, &200, &token_contract.get_address(), &123); + vault_client.lock_for_bet(&user, &200, &token_contract, &123); } #[test] @@ -200,25 +197,25 @@ fn test_payout_success() { vault_client.initialize(&admin, &betting_contract); let winner = Address::generate(&env); - let token_client = Client::new(&env, &token_contract.get_address()); + let token_client = StellarAssetClient::new(&env, &token_contract); token_client.mint(&winner, &1000); // Deposit and lock funds winner.require_auth(); - vault_client.deposit(&winner, &500, &token_contract.get_address()); + vault_client.deposit(&winner, &500, &token_contract); betting_contract.require_auth(); - vault_client.lock_for_bet(&winner, &200, &token_contract.get_address(), &123); + vault_client.lock_for_bet(&winner, &200, &token_contract, &123); // Payout winnings betting_contract.require_auth(); - vault_client.payout(&winner, &300, &token_contract.get_address(), &123); + vault_client.payout(&winner, &300, &token_contract, &123); // Check final balances - let user_balance = vault_client.get_user_balance(&winner, &token_contract.get_address()); + let user_balance = vault_client.get_user_balance(&winner, &token_contract); assert_eq!(user_balance.available, 600); // 300 left + 300 winnings assert_eq!(user_balance.locked, 0); - assert!(!vault_client.is_locked_for_bet(&123, &winner, &token_contract.get_address())); + assert!(!vault_client.is_locked_for_bet(&123, &winner, &token_contract)); } #[test] @@ -234,23 +231,23 @@ fn test_emergency_withdraw_schedule_and_execute() { vault_client.initialize(&admin, &betting_contract); let to = Address::generate(&env); - let token_client = Client::new(&env, &token_contract.get_address()); + let token_client = StellarAssetClient::new(&env, &token_contract); // Simulate some tokens stuck in the contract token_client.mint(&vault_id, &1000); // Schedule emergency withdraw admin.require_auth(); - vault_client.emergency_withdraw(&token_contract.get_address(), &to, &500); + vault_client.emergency_withdraw(&token_contract, &to, &500); // Fast forward past 24 hours env.ledger().set_timestamp(24 * 60 * 60 + 1); // Execute admin.require_auth(); - vault_client.emergency_withdraw(&token_contract.get_address(), &to, &500); + vault_client.emergency_withdraw(&token_contract, &to, &500); - assert_eq!(token_client.balance(&to), 500); + assert_eq!(token_client.balance(&to), 500_i128); } #[test] @@ -267,21 +264,21 @@ fn test_emergency_withdraw_cancel() { let to = Address::generate(&env); admin.require_auth(); - vault_client.emergency_withdraw(&token_contract.get_address(), &to, &500); + vault_client.emergency_withdraw(&token_contract, &to, &500); // Cancel admin.require_auth(); - vault_client.cancel_emergency_withdraw(&token_contract.get_address()); + vault_client.cancel_emergency_withdraw(&token_contract); // Fast forward env.ledger().set_timestamp(24 * 60 * 60 + 1); // Execute should fail because it was cancelled // It will try to schedule again, so let's just make sure it doesn't transfer - let token_client = Client::new(&env, &token_contract.get_address()); + let token_client = StellarAssetClient::new(&env, &token_contract); token_client.mint(&vault_id, &1000); - vault_client.emergency_withdraw(&token_contract.get_address(), &to, &500); - assert_eq!(token_client.balance(&to), 0); // Not transferred, just scheduled again + vault_client.emergency_withdraw(&token_contract, &to, &500); + assert_eq!(token_client.balance(&to), 0_i128); // Not transferred, just scheduled again } #[test] @@ -346,25 +343,25 @@ fn test_recover_token() { // Tracked token let tracked_token = env.register_stellar_asset_contract(admin.clone()); let user = Address::generate(&env); - let token_client = Client::new(&env, &tracked_token.get_address()); + let token_client = StellarAssetClient::new(&env, &tracked_token); token_client.mint(&user, &1000); user.require_auth(); - vault_client.deposit(&user, &500, &tracked_token.get_address()); + vault_client.deposit(&user, &500, &tracked_token); // Recover should fail for tracked token let to = Address::generate(&env); admin.require_auth(); - let result = vault_client.try_recover_token(&tracked_token.get_address(), &to, &100); + let result = vault_client.try_recover_token(&tracked_token, &to, &100); assert!(result.is_err()); // Untracked token let untracked_token = env.register_stellar_asset_contract(admin.clone()); - let untracked_client = Client::new(&env, &untracked_token.get_address()); + let untracked_client = StellarAssetClient::new(&env, &untracked_token); untracked_client.mint(&vault_id, &500); // Send directly to contract // Recover should succeed admin.require_auth(); - vault_client.recover_token(&untracked_token.get_address(), &to, &500); + vault_client.recover_token(&untracked_token, &to, &500); assert_eq!(untracked_client.balance(&to), 500); }