Skip to content

Commit 587f3d8

Browse files
vibenedictclaude
andcommitted
feat(contracts): dispute arbitration / resolver registry (#1040)
Escrow dispute resolution was admin-only (a single arbiter). Add a standalone resolver-registry contract that decentralizes it into a set of arbiters who vote on dispute outcomes. - admin-managed arbiter set (add/remove) and configurable quorum - per-escrow dispute cases with release/refund vote tallies and one-vote-per-arbiter enforcement - on reaching quorum, a binding resolve_dispute cross-contract call is issued into the escrow (registry acts as the escrow's arbiter) - 17 unit tests (incl. real escrow integration proving funds move) plus 3 property/fuzz tests; builds for wasm32v1-none Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 10a9ba1 commit 587f3d8

24 files changed

Lines changed: 15960 additions & 0 deletions

contracts/Cargo.lock

Lines changed: 16 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
@@ -10,6 +10,7 @@ members = [
1010
"contracts/zk-payment-verifier",
1111
"contracts/payment-channel",
1212
"contracts/contract-upgrade",
13+
"contracts/resolver-registry",
1314
]
1415

1516
[workspace.dependencies]

contracts/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ contracts/
2727
│ ├── src/ # SubscriptionRegistry contract source
2828
│ ├── agent-registry/ # Authorized agents registry contract
2929
│ ├── escrow/ # Payment holding escrow contract
30+
│ ├── resolver-registry/ # Dispute arbitration / resolver registry
3031
│ ├── subscription_logging/ # On-chain audit trail logging contract
3132
│ ├── subscription_renewal/ # Main subscription renewal logic contract
3233
│ └── virtual-card/ # Non-custodial virtual card contract
@@ -132,12 +133,22 @@ cargo test
132133
- `record_log` - Appends a log entry (Reminder, Approval, Renewal, Failure, Retry, Cancellation).
133134
- `get_logs` - Query logs for a specific subscription.
134135

136+
### 7. Resolver Registry Contract (`contracts/contracts/resolver-registry/`)
137+
**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.)
138+
- `init` - Initialize with an admin and an initial quorum.
139+
- `add_arbiter` / `remove_arbiter` - Admin manages the arbiter voting set.
140+
- `set_quorum` - Admin adjusts the number of matching votes required to bind an outcome.
141+
- `open_case` - An arbiter or admin opens a dispute case bound to an escrow agreement.
142+
- `vote` - An arbiter votes to release (1) or refund (2); reaching quorum fires the binding escrow callback.
143+
- `get_case` / `get_case_count` / `get_quorum` / `get_arbiters` / `is_arbiter` / `get_vote` - Queries.
144+
135145
## Contract Development Roadmap
136146

137147
### Completed (MVP Stage)
138148
- [x] On-chain subscription registry and tracking
139149
- [x] Multi-agent renewal registry with scope controls
140150
- [x] Secure escrow agreements with arbiter-mediated dispute resolution
151+
- [x] Decentralized dispute arbitration via a quorum-voting resolver registry
141152
- [x] Non-custodial virtual cards with disposable/auto-close behavior
142153
- [x] On-chain audit logging system
143154

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)