Skip to content

Commit e12309e

Browse files
Caleb PetersonCaleb Peterson
authored andcommitted
Merge remote-tracking branch 'refs/remotes/pr/1109' into integration/all-open-prs
# Conflicts: # contracts/Cargo.toml # contracts/README.md
2 parents 6a03dbc + 587f3d8 commit e12309e

24 files changed

Lines changed: 15952 additions & 0 deletions

contracts/Cargo.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

contracts/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ members = [
1414
"contracts/payment-adapter",
1515
"contracts/voucher-ledger",
1616
"contracts/fee-collector",
17+
"contracts/resolver-registry",
1718
]
1819

1920
[workspace.dependencies]

contracts/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ contracts/
2929
│ ├── allowance/ # Recurring allowance / spending-limit authority
3030
│ ├── escrow/ # Payment holding escrow contract
3131
│ ├── payment-adapter/ # Multi-token renewal settlement adapter
32+
│ ├── resolver-registry/ # Dispute arbitration / resolver registry
3233
│ ├── subscription_logging/ # On-chain audit trail logging contract
3334
│ ├── subscription_renewal/ # Main subscription renewal logic contract
3435
│ ├── voucher-ledger/ # Gift-card voucher mint / redeem / void ledger
@@ -158,13 +159,22 @@ cargo test
158159
- `redeem_voucher` - Voucher recipient redeems part or all of the balance.
159160
- `void_voucher` - Admin voids an active voucher and clears any remaining balance.
160161
- `get_voucher` / `balance` / `is_active` - Read voucher state and remaining balance.
162+
### 10. Resolver Registry Contract (`contracts/contracts/resolver-registry/`)
163+
**Purpose**: Decentralize escrow dispute resolution from a single admin arbiter to a voting set of arbiters. When a configurable quorum agrees on an outcome, the registry issues a binding `resolve_dispute` cross-contract call into the escrow. (Wire it up by setting the escrow's `arbiter` to the registry's contract address.)
164+
- `init` - Initialize with an admin and an initial quorum.
165+
- `add_arbiter` / `remove_arbiter` - Admin manages the arbiter voting set.
166+
- `set_quorum` - Admin adjusts the number of matching votes required to bind an outcome.
167+
- `open_case` - An arbiter or admin opens a dispute case bound to an escrow agreement.
168+
- `vote` - An arbiter votes to release (1) or refund (2); reaching quorum fires the binding escrow callback.
169+
- `get_case` / `get_case_count` / `get_quorum` / `get_arbiters` / `is_arbiter` / `get_vote` - Queries.
161170

162171
## Contract Development Roadmap
163172

164173
### Completed (MVP Stage)
165174
- [x] On-chain subscription registry and tracking
166175
- [x] Multi-agent renewal registry with scope controls
167176
- [x] Secure escrow agreements with arbiter-mediated dispute resolution
177+
- [x] Decentralized dispute arbitration via a quorum-voting resolver registry
168178
- [x] Non-custodial virtual cards with disposable/auto-close behavior
169179
- [x] On-chain audit logging system
170180
- [x] Recurring allowance authority with per-period and absolute spending caps
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "resolver-registry"
3+
version = "0.0.1"
4+
edition = "2021"
5+
publish = false
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
10+
[dependencies]
11+
soroban-sdk = { workspace = true }
12+
13+
[dev-dependencies]
14+
soroban-sdk = { workspace = true, features = ["testutils"] }
15+
proptest = { workspace = true }
16+
escrow = { path = "../escrow" }
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#![cfg(test)]
2+
extern crate std;
3+
4+
use escrow::{EscrowContract, EscrowContractClient, EscrowState};
5+
use proptest::prelude::*;
6+
use soroban_sdk::{
7+
testutils::{Address as _, EnvTestConfig},
8+
token::StellarAssetClient,
9+
Address, Env, String,
10+
};
11+
use std::panic::{catch_unwind, AssertUnwindSafe};
12+
use std::vec::Vec as StdVec;
13+
14+
use super::{CaseStatus, ResolverRegistry, ResolverRegistryClient};
15+
16+
const AMOUNT: i128 = 1_000_000_000;
17+
18+
fn fuzz_env() -> Env {
19+
Env::new_with_config(EnvTestConfig {
20+
capture_snapshot_at_drop: false,
21+
..EnvTestConfig::default()
22+
})
23+
}
24+
25+
struct Ctx {
26+
registry: ResolverRegistryClient<'static>,
27+
escrow: EscrowContractClient<'static>,
28+
arbiters: StdVec<Address>,
29+
}
30+
31+
fn setup(quorum: u32, num_arbiters: usize) -> Ctx {
32+
let env = fuzz_env();
33+
env.mock_all_auths();
34+
35+
let admin = Address::generate(&env);
36+
let payer = Address::generate(&env);
37+
let payee = Address::generate(&env);
38+
39+
let registry_id = env.register(ResolverRegistry, ());
40+
let registry = ResolverRegistryClient::new(&env, &registry_id);
41+
registry.init(&admin, &quorum);
42+
43+
let mut arbiters = StdVec::new();
44+
for _ in 0..num_arbiters {
45+
let a = Address::generate(&env);
46+
registry.add_arbiter(&a);
47+
arbiters.push(a);
48+
}
49+
50+
let escrow_id_addr = env.register(EscrowContract, ());
51+
let escrow = EscrowContractClient::new(&env, &escrow_id_addr);
52+
escrow.init(&admin);
53+
54+
let sac = env.register_stellar_asset_contract_v2(admin.clone());
55+
let token = sac.address();
56+
StellarAssetClient::new(&env, &token).mint(&payer, &(AMOUNT * 2));
57+
58+
let expiry = env.ledger().timestamp() + 86_400;
59+
let desc = String::from_str(&env, "fuzz");
60+
let id = escrow.create_escrow(&payer, &payee, &registry_id, &token, &AMOUNT, &expiry, &desc);
61+
escrow.deposit(&id);
62+
escrow.raise_dispute(&id, &payer);
63+
64+
Ctx {
65+
registry,
66+
escrow,
67+
arbiters,
68+
}
69+
}
70+
71+
proptest! {
72+
#![proptest_config(ProptestConfig::with_cases(12))]
73+
74+
/// Casting exactly `quorum` identical votes resolves the case to that
75+
/// outcome and drives the real escrow into the matching terminal state.
76+
#[test]
77+
fn fuzz_quorum_resolves_escrow(
78+
quorum in 1u32..=4u32,
79+
outcome in 1u32..=2u32,
80+
) {
81+
let n = quorum as usize + 1; // always enough arbiters to reach quorum
82+
let ctx = setup(quorum, n);
83+
// escrow_id is always 1 (single escrow per env).
84+
let case = ctx.registry.open_case(&ctx.arbiters[0], &ctx.escrow.address, &1);
85+
86+
for i in 0..(quorum as usize) {
87+
ctx.registry.vote(&ctx.arbiters[i], &case, &outcome);
88+
}
89+
90+
let resolved = ctx.registry.get_case(&case);
91+
prop_assert_eq!(resolved.status, CaseStatus::Resolved);
92+
prop_assert_eq!(resolved.outcome, outcome);
93+
94+
let expected = if outcome == 1 { EscrowState::Released } else { EscrowState::Refunded };
95+
prop_assert_eq!(ctx.escrow.get_escrow(&1).state, expected);
96+
}
97+
98+
/// Fewer than `quorum` votes never resolves the case and never touches the
99+
/// escrow, which stays disputed.
100+
#[test]
101+
fn fuzz_sub_quorum_never_resolves(
102+
quorum in 2u32..=4u32,
103+
outcome in 1u32..=2u32,
104+
) {
105+
let n = quorum as usize + 1;
106+
let ctx = setup(quorum, n);
107+
let case = ctx.registry.open_case(&ctx.arbiters[0], &ctx.escrow.address, &1);
108+
109+
for i in 0..(quorum as usize - 1) {
110+
ctx.registry.vote(&ctx.arbiters[i], &case, &outcome);
111+
}
112+
113+
prop_assert_eq!(ctx.registry.get_case(&case).status, CaseStatus::Open);
114+
prop_assert_eq!(ctx.escrow.get_escrow(&1).state, EscrowState::Disputed);
115+
}
116+
117+
/// An arbiter can never double-vote regardless of ordering.
118+
#[test]
119+
fn fuzz_no_double_vote(outcome in 1u32..=2u32) {
120+
let ctx = setup(3, 3);
121+
let case = ctx.registry.open_case(&ctx.arbiters[0], &ctx.escrow.address, &1);
122+
ctx.registry.vote(&ctx.arbiters[0], &case, &outcome);
123+
124+
let result = catch_unwind(AssertUnwindSafe(|| {
125+
ctx.registry.vote(&ctx.arbiters[0], &case, &outcome);
126+
}));
127+
prop_assert!(result.is_err(), "double vote must panic");
128+
prop_assert_eq!(ctx.registry.get_case(&case).votes_release + ctx.registry.get_case(&case).votes_refund, 1);
129+
}
130+
}

0 commit comments

Comments
 (0)