Skip to content

Commit 779956c

Browse files
soyayaclaude
andauthored
Add real cross-implementation diff and genuine migration verification (#226) (#745)
Issue #226 asked for differential testing between contract implementations, but the existing framework (commit 0169685) only diffed hello-world against a second instance of itself, despite its own doc comment claiming to compare "hello-world vs lending". This strengthens both real gaps found on review: - Add a ContractAdapter trait and a genuine LendingAdapter (wrapping the separate `lending` contract crate) in diff_harness.rs, and a new hello_world_vs_lending_test.rs that runs the same deposit/ zero-amount/position-reflects-balance checks across both implementations. Borrow/repay/withdraw are intentionally NOT cross-diffed — hello-world borrows against existing collateral, while lending's borrow atomically deposits new collateral and borrows in one call, requiring collateral_amount > 0 every time. Forcing a 1:1 comparison there would misrepresent what's tested; documented under "Known Structural Differences". - Replace the placebo migration test (which only re-created a client handle to an untouched contract) with a real one: lending's own migration_verification_test.rs now drives the actual UpgradeManager governance lifecycle (propose -> approve -> queue timelock -> advance ledger -> execute, and separately execute -> rollback) and asserts a live lending position is untouched by it. Neither hello-world nor lending actually swaps WASM code anywhere in this repo (UpgradeManager only tracks an approved hash/version in storage), so a true WASM-swap migration test isn't feasible without a separate compiled .wasm artifact and build step -- documented as a known limitation rather than faked. - Fix an unrelated but blocking bug found while touching this crate: hello-world/src/tests/mod.rs declared four prop-test modules (prop_arithmetic_test, prop_interest_test, prop_liquidation_test, prop_deposit_test) whose source files were never created for this crate -- a hard compile error blocking the entire crate, including the differential tests. Removed the phantom declarations (the real equivalents already exist in the `lending` crate under different names). - Wire the new tests into the CI differential-test step. Note: this environment has no Rust/cargo toolchain, so none of this could be compiled or run locally -- reviewed carefully against existing, working test code in both crates instead. CI will be the first real compile. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent aaba39a commit 779956c

8 files changed

Lines changed: 344 additions & 13 deletions

File tree

.github/workflows/ci-cd.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ jobs:
100100
run: |
101101
cd stellar-lend
102102
cargo test --package hello-world --lib tests::differential_test -- --nocapture 2>&1 | tee differential-test-report.txt
103+
cargo test --package hello-world --lib tests::hello_world_vs_lending_test -- --nocapture 2>&1 | tee -a differential-test-report.txt
103104
cargo test --package hello-world --lib tests::migration_verification_test -- --nocapture 2>&1 | tee -a differential-test-report.txt
105+
cargo test --package stellarlend-lending --lib migration_verification_test -- --nocapture 2>&1 | tee -a differential-test-report.txt
104106
grep "test result:" differential-test-report.txt
105107
106108
- name: Upload differential test report

stellar-lend/contracts/hello-world/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ stellar-macros = { version = "0.6.0" }
1919

2020
[dev-dependencies]
2121
soroban-sdk = { workspace = true, features = ["testutils"] }
22+
stellarlend-lending = { path = "../lending" }

stellar-lend/contracts/hello-world/DIFFERENTIAL_TEST_REPORT.md

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,36 @@
22

33
## What This Tests
44

5-
Differential testing runs the **same inputs against two independent contract instances** and asserts their outputs are identical. This catches subtle behavioral regressions that unit tests miss — especially after upgrades or refactors.
5+
Differential testing runs the **same inputs against two independent contract implementations** and asserts their outputs are identical. This catches subtle behavioral regressions that unit tests miss — especially after upgrades or refactors.
6+
7+
Two flavors are covered:
8+
9+
1. **Same implementation, two instances** (`differential_test.rs`) — regression guard: two fresh `hello-world` instances must always agree.
10+
2. **Genuinely different implementations** (`hello_world_vs_lending_test.rs`) — compares `hello-world` against the separate `lending` contract crate via a shared `ContractAdapter` trait.
611

712
## Files
813

914
| File | Purpose |
1015
|---|---|
11-
| `src/tests/diff_harness.rs` | `HwAdapter`, `PositionSnapshot`, `DivergenceReport` — core harness |
12-
| `src/tests/differential_test.rs` | Property comparison tests (deposit, borrow, repay, zero-amount, sequential) |
13-
| `src/tests/migration_verification_test.rs` | Storage layout survives upgrade (collateral, debt, admin, multi-user) |
16+
| `src/tests/diff_harness.rs` | `HwAdapter`, `LendingAdapter`, `ContractAdapter` trait, `PositionSnapshot`, `DivergenceReport` — core harness |
17+
| `src/tests/differential_test.rs` | Same-implementation property comparison tests (deposit, borrow, repay, zero-amount, sequential) |
18+
| `src/tests/hello_world_vs_lending_test.rs` | Cross-implementation comparison: `hello-world` vs `lending` (deposit only — see below) |
19+
| `src/tests/migration_verification_test.rs` | hello-world: storage read-back sanity across a re-created client handle (weak — see "Known Structural Differences") |
20+
| `../lending/src/migration_verification_test.rs` | lending: drives the *real* `UpgradeManager` governance lifecycle (propose → approve → queue timelock → execute/rollback) and confirms it doesn't disturb lending's own application state |
1421

1522
## Running Locally
1623

1724
```bash
1825
cd stellar-lend
19-
# All differential tests
2026
cargo test --package hello-world --lib tests::differential_test -- --nocapture
27+
cargo test --package hello-world --lib tests::hello_world_vs_lending_test -- --nocapture
2128
cargo test --package hello-world --lib tests::migration_verification_test -- --nocapture
29+
cargo test --package stellarlend-lending --lib migration_verification_test -- --nocapture
2230
```
2331

2432
## How Divergences Are Reported
2533

26-
If two instances return different results for the same input, the test panics with:
34+
If two instances/implementations return different results for the same input, the test panics with:
2735

2836
```
2937
[DIVERGENCE] deposit: v1=Ok(true) v2=Err(())
@@ -36,13 +44,30 @@ If two instances return different results for the same input, the test panics wi
3644
|---|---|
3745
| Non-deterministic behavior | Ledger timestamp pinned via `env.ledger().set_timestamp()` before each test |
3846
| State-dependent outputs | Tests run full sequences: deposit → borrow → repay → check position |
39-
| Zero-amount inputs | Explicit test asserting both instances reject consistently |
40-
| Storage layout across upgrades | `migration_verification_test.rs` reads raw storage keys via `env.as_contract()` |
47+
| Zero-amount inputs | Explicit tests asserting both same-implementation instances *and* both cross-implementation contracts reject consistently |
48+
| Storage layout across upgrades | `migration_verification_test.rs` (hello-world) reads raw storage keys via `env.as_contract()`; `migration_verification_test.rs` (lending) drives the real `UpgradeManager` state machine including its 48h timelock |
4149
| Multiple users | Multi-user migration test with 5 users and distinct amounts |
50+
| Performance differences | Not covered — no benchmark comparison between implementations yet |
51+
52+
## Known Structural Differences (why some operations aren't cross-implementation-diffed)
53+
54+
`hello-world` and `lending` have genuinely different domain models, discovered while building the cross-implementation harness:
55+
56+
- **Borrow**: `hello-world.borrow_asset` borrows against previously-deposited collateral. `lending.borrow` is atomic — it deposits new collateral *and* borrows in the same call, and rejects `collateral_amount <= 0`. There is no way to call it "borrow only, against existing collateral" the way hello-world does, so a literal same-inputs comparison would require synthetically inventing a matching action for one side, which would test the harness's own workaround rather than the contracts. **Not compared.**
57+
- **Asset model**: `hello-world` takes `asset: Option<Address>` (single/native asset). `lending` requires `asset: Address` on every call (multi-asset). The `ContractAdapter` trait picks one fixed `Address` per adapter instance to keep `deposit` comparable.
58+
- **Position shape**: `hello-world::Position { collateral, debt }` vs `lending::UserPositionSummary { collateral_balance, debt_balance, collateral_value, debt_value, health_factor }`. Only the two directly-equivalent raw balance fields are compared; value/health-factor fields depend on an oracle neither adapter configures.
59+
60+
If `lending`'s API changes to make borrow/repay/withdraw genuinely comparable (e.g. a non-atomic borrow-against-existing-collateral entry point is added), extend `ContractAdapter` and `hello_world_vs_lending_test.rs` accordingly.
61+
62+
## Known Limitation: No Real WASM-Swap Migration Test
63+
64+
Neither `hello-world` nor `lending` currently exposes a real "upgrade this contract's code" entry point — `UpgradeManager` (`common/src/upgrade.rs`, used by `lending`/`amm`/`bridge`) only tracks an *approved* WASM hash + version in storage; it never calls Soroban's `env.deployer().update_current_contract_wasm(..)`. Genuinely testing storage-layout survival across a real code upgrade would require compiling and checking in a separate `.wasm` artifact for an "old" version and loading it via `env.register_contract_wasm(..)` — a build-pipeline addition, not a test-code change, and out of scope here. `lending/src/migration_verification_test.rs` instead verifies the real, available claim: driving the actual governance lifecycle to completion (and to rollback) does not disturb a live position. hello-world's own `migration_verification_test.rs` is weaker (it only re-creates a client handle to an untouched contract) because hello-world doesn't integrate `UpgradeManager` at all.
65+
66+
Separately, `scripts/migration-simulator` and `environments/migration-sandbox` already provide real migration dry-run tooling against a forked network — that's operational tooling, not part of this Rust unit-test suite.
4267

4368
## CI Integration
4469

45-
Differential tests run as a dedicated CI step in `.github/workflows/ci-cd.yml` and upload `differential-test-report.txt` as an artifact on every push/PR. A failure here means a behavioral regression was introduced.
70+
Differential and migration-verification tests (both crates) run as a dedicated CI step in `.github/workflows/ci-cd.yml` and upload `differential-test-report.txt` as an artifact on every push/PR. A failure here means a behavioral regression was introduced.
4671

4772
## Known Acceptable Divergences
4873

stellar-lend/contracts/hello-world/src/tests/diff_harness.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use soroban_sdk::{testutils::Address as _, Address, Env};
1212

1313
use crate::{HelloContract, HelloContractClient};
14+
use stellarlend_lending::{LendingContract, LendingContractClient};
1415

1516
// ── Shared position snapshot ──────────────────────────────────────────────
1617

@@ -106,3 +107,77 @@ impl<'a> HwAdapter<'a> {
106107
}
107108
}
108109
}
110+
111+
// ── Cross-implementation adapter trait ────────────────────────────────────
112+
//
113+
// Lets a single set of property tests run against genuinely different
114+
// contract implementations (not just two instances of the same one).
115+
// Only `deposit`/`get_position` are part of this trait: hello-world and
116+
// lending have incompatible domain models for borrow (hello-world borrows
117+
// against previously-deposited collateral; lending's `borrow` atomically
118+
// deposits new collateral *and* borrows in the same call, and always
119+
// requires a positive `collateral_amount`), so forcing a 1:1 comparison
120+
// there would require synthetic workarounds that misrepresent what's being
121+
// tested. See "Known Structural Differences" in DIFFERENTIAL_TEST_REPORT.md.
122+
pub trait ContractAdapter {
123+
fn deposit(&self, user: &Address, amount: i128) -> Result<i128, ()>;
124+
fn get_position(&self, user: &Address) -> PositionSnapshot;
125+
}
126+
127+
impl<'a> ContractAdapter for HwAdapter<'a> {
128+
fn deposit(&self, user: &Address, amount: i128) -> Result<i128, ()> {
129+
HwAdapter::deposit(self, user, amount)
130+
}
131+
132+
fn get_position(&self, user: &Address) -> PositionSnapshot {
133+
HwAdapter::get_position(self, user)
134+
}
135+
}
136+
137+
// ── lending adapter — a genuinely separate contract implementation ───────
138+
139+
pub struct LendingAdapter<'a> {
140+
pub client: LendingContractClient<'a>,
141+
pub env: &'a Env,
142+
pub admin: Address,
143+
pub asset: Address,
144+
}
145+
146+
impl<'a> LendingAdapter<'a> {
147+
pub fn new(env: &'a Env) -> Self {
148+
let contract_id = env.register(LendingContract, ());
149+
let client = LendingContractClient::new(env, &contract_id);
150+
let admin = Address::generate(env);
151+
let asset = Address::generate(env);
152+
// debt_ceiling / min_borrow_amount are unused by the deposit-only
153+
// comparison but required by `initialize`.
154+
client.initialize(&admin, &1_000_000_000_000, &1);
155+
Self { client, env, admin, asset }
156+
}
157+
158+
pub fn deposit(&self, user: &Address, amount: i128) -> Result<i128, ()> {
159+
self.client
160+
.try_deposit_collateral(user, &self.asset, &amount)
161+
.map_err(|_| ())
162+
.and_then(|r| r.map_err(|_| ()))
163+
.map(|_| self.get_position(user).collateral)
164+
}
165+
166+
pub fn get_position(&self, user: &Address) -> PositionSnapshot {
167+
let pos = self.client.get_user_position(user);
168+
PositionSnapshot {
169+
collateral: pos.collateral_balance,
170+
debt: pos.debt_balance,
171+
}
172+
}
173+
}
174+
175+
impl<'a> ContractAdapter for LendingAdapter<'a> {
176+
fn deposit(&self, user: &Address, amount: i128) -> Result<i128, ()> {
177+
LendingAdapter::deposit(self, user, amount)
178+
}
179+
180+
fn get_position(&self, user: &Address) -> PositionSnapshot {
181+
LendingAdapter::get_position(self, user)
182+
}
183+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//! Differential tests comparing two *genuinely different* contract
2+
//! implementations: `hello-world` (this crate) vs the separate `lending`
3+
//! crate (`stellarlend_lending::LendingContract`).
4+
//!
5+
//! This is the part of issue #226 ("Different contract implementations —
6+
//! e.g. upgraded vs original") that `differential_test.rs` does not cover:
7+
//! that file only diffs hello-world against a second instance of itself.
8+
//!
9+
//! Scope: only `deposit` + the resulting position are compared. See
10+
//! "Known Structural Differences" in DIFFERENTIAL_TEST_REPORT.md for why
11+
//! borrow/repay/withdraw are excluded — the two contracts' domain models
12+
//! for those operations are not comparable without misrepresenting what's
13+
//! being tested.
14+
15+
#![cfg(test)]
16+
17+
use soroban_sdk::{testutils::Address as _, Address, Env};
18+
19+
use super::diff_harness::{ContractAdapter, DivergenceReport, HwAdapter, LendingAdapter};
20+
21+
fn make_env() -> Env {
22+
let env = Env::default();
23+
env.mock_all_auths();
24+
env.ledger().set_timestamp(1_000_000);
25+
env
26+
}
27+
28+
fn diff_deposit_cross(
29+
a: &dyn ContractAdapter,
30+
b: &dyn ContractAdapter,
31+
user_a: &Address,
32+
user_b: &Address,
33+
amount: i128,
34+
reports: &mut Vec<DivergenceReport>,
35+
) {
36+
let r1 = a.deposit(user_a, amount).map(|v| v > 0);
37+
let r2 = b.deposit(user_b, amount).map(|v| v > 0);
38+
if r1 != r2 {
39+
reports.push(DivergenceReport::new("cross_deposit", r1, r2));
40+
}
41+
}
42+
43+
fn assert_no_divergences(reports: &[DivergenceReport]) {
44+
if !reports.is_empty() {
45+
let msgs: Vec<String> = reports
46+
.iter()
47+
.map(|r| format!("[DIVERGENCE] {}: v1={} v2={}", r.operation, r.v1, r.v2))
48+
.collect();
49+
panic!("Divergences detected:\n{}", msgs.join("\n"));
50+
}
51+
}
52+
53+
/// A successful deposit must be accepted by both implementations.
54+
#[test]
55+
fn test_cross_impl_deposit_accepted_consistently() {
56+
let env = make_env();
57+
let hw = HwAdapter::new(&env);
58+
let lending = LendingAdapter::new(&env);
59+
let user_a = Address::generate(&env);
60+
let user_b = Address::generate(&env);
61+
let mut reports = Vec::new();
62+
63+
diff_deposit_cross(&hw, &lending, &user_a, &user_b, 1_000_000, &mut reports);
64+
assert_no_divergences(&reports);
65+
}
66+
67+
/// A zero-amount deposit must be rejected by both implementations.
68+
#[test]
69+
fn test_cross_impl_zero_deposit_rejected_consistently() {
70+
let env = make_env();
71+
let hw = HwAdapter::new(&env);
72+
let lending = LendingAdapter::new(&env);
73+
let user_a = Address::generate(&env);
74+
let user_b = Address::generate(&env);
75+
let mut reports = Vec::new();
76+
77+
diff_deposit_cross(&hw, &lending, &user_a, &user_b, 0, &mut reports);
78+
assert_no_divergences(&reports);
79+
}
80+
81+
/// After a successful deposit, both implementations must report non-zero
82+
/// collateral for that user (exact scale/units are implementation-specific,
83+
/// so only the "collateral was recorded" property is compared).
84+
#[test]
85+
fn test_cross_impl_deposit_reflected_in_position() {
86+
let env = make_env();
87+
let hw = HwAdapter::new(&env);
88+
let lending = LendingAdapter::new(&env);
89+
let user_a = Address::generate(&env);
90+
let user_b = Address::generate(&env);
91+
92+
hw.deposit(&user_a, 2_000_000).expect("hello-world deposit should succeed");
93+
lending
94+
.deposit(&user_b, 2_000_000)
95+
.expect("lending deposit should succeed");
96+
97+
let pos_a = hw.get_position(&user_a);
98+
let pos_b = lending.get_position(&user_b);
99+
100+
assert!(pos_a.collateral > 0, "hello-world collateral must be recorded");
101+
assert!(pos_b.collateral > 0, "lending collateral must be recorded");
102+
assert_eq!(
103+
pos_a.debt, 0,
104+
"hello-world debt must remain zero after a deposit-only flow"
105+
);
106+
assert_eq!(
107+
pos_b.debt, 0,
108+
"lending debt must remain zero after a deposit-only flow"
109+
);
110+
}

stellar-lend/contracts/hello-world/src/tests/mod.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod diff_harness;
22
pub mod differential_test;
3+
pub mod hello_world_vs_lending_test;
34
pub mod migration_verification_test;
45
pub mod access_control_regression_test;
56
pub mod admin_test;
@@ -54,9 +55,13 @@ pub mod test_utils;
5455
pub mod amm_compound_test;
5556

5657
// Property-based tests (proptest)
57-
pub mod prop_arithmetic_test;
58-
pub mod prop_interest_test;
59-
pub mod prop_liquidation_test;
58+
//
59+
// prop_arithmetic_test, prop_interest_test, prop_liquidation_test, and
60+
// prop_deposit_test were declared here by a prior refactor but their source
61+
// files were never added for this crate (the equivalent property tests live
62+
// in the separate `lending` crate as borrow_prop_test.rs, interest_rate_prop_test.rs,
63+
// invariant_prop_test.rs, and deposit_prop_test.rs). Declaring a `mod` for a
64+
// nonexistent file is a hard compile error, so the phantom declarations are
65+
// removed rather than left broken.
6066
pub mod prop_fees_test;
6167
pub mod prop_supply_cap_test;
62-
mod prop_deposit_test;

stellar-lend/contracts/lending/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ mod insurance_test;
111111
#[cfg(test)]
112112
mod math_safety_test;
113113
#[cfg(test)]
114+
mod migration_verification_test;
115+
#[cfg(test)]
114116
mod pause_test;
115117
#[cfg(test)]
116118
mod reentrancy_fuzz_test;

0 commit comments

Comments
 (0)