Skip to content

Commit 3ed7077

Browse files
authored
Merge pull request #281 from abore9769/feature/multisig-arbitration
feat: multi-signature arbitration for high-value trades
2 parents 8d63b6e + d8322e8 commit 3ed7077

4 files changed

Lines changed: 318 additions & 0 deletions

File tree

contract/src/errors.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ pub enum ContractError {
9393
NoUpgradeInProgress = 63,
9494
/// Rollback window has passed; state cannot be reverted automatically.
9595
RollbackWindowExpired = 64,
96+
// Multi-sig arbitration errors (70–74)
97+
/// threshold == 0 or threshold > arbitrators count.
98+
InvalidMultiSigConfig = 70,
99+
/// Arbitrator has already cast a vote for this trade.
100+
AlreadyVoted = 71,
101+
/// Voting window has expired; no more votes accepted.
102+
VotingExpired = 72,
103+
/// Voting window has not yet expired; cannot force-resolve.
104+
VotingNotExpired = 73,
105+
/// No consensus reached among arbitrators.
106+
NoConsensus = 74,
96107
// Social feature errors (70-74)
97108
CannotFollowSelf = 70,
98109
NotFollowing = 71,

contract/src/events.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,26 @@ pub fn emit_upgrade_cancelled(env: &Env, cancelled_by: Address) {
350350
pub fn emit_upgrade_rolled_back(env: &Env, rolled_back_by: Address, restored_version: u32) {
351351
env.events().publish((cat_sys(), symbol_short!("up_rb")), EvUpgradeRolledBack { v: EVENT_VERSION, rolled_back_by, restored_version });
352352
}
353+
354+
// ---------------------------------------------------------------------------
355+
// Multi-sig arbitration events
356+
// ---------------------------------------------------------------------------
357+
358+
fn cat_multisig() -> Symbol { symbol_short!("multisig") }
359+
360+
#[contracttype] #[derive(Clone, Debug)]
361+
pub struct EvArbVoteCast { pub v: u32, pub trade_id: u64, pub arbitrator: Address, pub resolution: crate::types::DisputeResolution }
362+
#[contracttype] #[derive(Clone, Debug)]
363+
pub struct EvMultiSigConsensus { pub v: u32, pub trade_id: u64 }
364+
#[contracttype] #[derive(Clone, Debug)]
365+
pub struct EvMultiSigExpired { pub v: u32, pub trade_id: u64 }
366+
367+
pub fn emit_arbitrator_vote_cast(env: &Env, trade_id: u64, arbitrator: Address, resolution: crate::types::DisputeResolution) {
368+
env.events().publish((cat_multisig(), symbol_short!("ms_vote")), EvArbVoteCast { v: EVENT_VERSION, trade_id, arbitrator, resolution });
369+
}
370+
pub fn emit_multisig_consensus(env: &Env, trade_id: u64) {
371+
env.events().publish((cat_multisig(), symbol_short!("ms_cons")), EvMultiSigConsensus { v: EVENT_VERSION, trade_id });
372+
}
373+
pub fn emit_multisig_expired(env: &Env, trade_id: u64) {
374+
env.events().publish((cat_multisig(), symbol_short!("ms_exp")), EvMultiSigExpired { v: EVENT_VERSION, trade_id });
375+
}

contract/src/lib.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod amm;
55
mod errors;
66
mod events;
77
mod governance;
8+
mod multisig;
89
mod oracle;
910
mod privacy;
1011
mod queries;
@@ -22,6 +23,10 @@ use soroban_sdk::{contract, contractimpl, token::TokenClient, Address, BytesN, E
2223

2324
pub use errors::ContractError;
2425
pub use types::{
26+
ArbitrationConfig, ArbitratorVote, DisclosureGrant, DisputeResolution, MultiSigConfig,
27+
Proposal, ProposalAction, ProposalStatus, Subscription, SubscriptionTier, TierConfig,
28+
TemplateTerms, TemplateVersion, Trade, TradePrivacy, TradeStatus, TradeTemplate,
29+
UserTier, UserTierInfo, VotingSummary,
2530
ArbitratorReputation, DisclosureGrant, DisputeResolution, Proposal, ProposalAction,
2631
ProposalStatus, Subscription, SubscriptionTier, TierConfig, TemplateTerms, TemplateVersion,
2732
Trade, TradePrivacy, TradeStatus, TradeTemplate, UserTier, UserTierInfo,
@@ -206,6 +211,91 @@ impl StellarEscrowContract {
206211
}
207212

208213
// -------------------------------------------------------------------------
214+
// Multi-Signature Arbitration
215+
// -------------------------------------------------------------------------
216+
217+
/// Create a trade with multi-signature arbitration.
218+
/// All arbitrators in `config` must be registered; threshold must be > 0
219+
/// and ≤ arbitrators count.
220+
pub fn create_multisig_trade(
221+
env: Env,
222+
seller: Address,
223+
buyer: Address,
224+
amount: u64,
225+
config: MultiSigConfig,
226+
expiry_time: Option<u64>,
227+
currency: Option<Address>,
228+
) -> Result<u64, ContractError> {
229+
require_initialized(&env)?;
230+
require_not_paused(&env)?;
231+
if amount == 0 {
232+
return Err(ContractError::InvalidAmount);
233+
}
234+
if config.threshold == 0 || config.threshold > config.arbitrators.len() {
235+
return Err(ContractError::InvalidMultiSigConfig);
236+
}
237+
for i in 0..config.arbitrators.len() {
238+
if !has_arbitrator(&env, &config.arbitrators.get(i).unwrap()) {
239+
return Err(ContractError::ArbitratorNotRegistered);
240+
}
241+
}
242+
if let Some(expiry) = expiry_time {
243+
if expiry <= env.ledger().timestamp() {
244+
return Err(ContractError::InvalidExpiry);
245+
}
246+
}
247+
seller.require_auth();
248+
let token = currency.unwrap_or(get_usdc_token(&env)?);
249+
let trade_id = increment_trade_counter(&env)?;
250+
let fee = calc_fee(&env, &seller, amount)?;
251+
let trade = Trade {
252+
id: trade_id,
253+
seller: seller.clone(),
254+
buyer: buyer.clone(),
255+
amount,
256+
fee,
257+
arbitrator: Some(ArbitrationConfig::MultiSig(config)),
258+
status: TradeStatus::Created,
259+
expiry_time,
260+
currency: token,
261+
metadata: None,
262+
};
263+
save_trade(&env, trade_id, &trade);
264+
events::emit_trade_created(&env, trade_id, seller, buyer, amount, trade.currency);
265+
Ok(trade_id)
266+
}
267+
268+
/// Cast a vote on a disputed multi-sig trade.
269+
pub fn cast_vote(
270+
env: Env,
271+
trade_id: u64,
272+
arbitrator: Address,
273+
resolution: DisputeResolution,
274+
) -> Result<(), ContractError> {
275+
require_initialized(&env)?;
276+
require_not_paused(&env)?;
277+
multisig::cast_vote(&env, trade_id, &arbitrator, resolution)
278+
}
279+
280+
/// Return the current voting state for a multi-sig trade.
281+
pub fn get_voting_summary(env: Env, trade_id: u64) -> Result<VotingSummary, ContractError> {
282+
require_initialized(&env)?;
283+
multisig::voting_summary(&env, trade_id)
284+
}
285+
286+
/// Force-resolve a multi-sig dispute after the voting window expires without
287+
/// consensus. Defaults to refunding the buyer. Admin only.
288+
pub fn resolve_expired_dispute(
289+
env: Env,
290+
admin: Address,
291+
trade_id: u64,
292+
) -> Result<(), ContractError> {
293+
require_initialized(&env)?;
294+
require_not_paused(&env)?;
295+
require_admin(&env, &admin)?;
296+
let resolution = multisig::resolve_expired_dispute(&env, trade_id, &admin)?;
297+
let trade = get_trade(&env, trade_id)?;
298+
StellarEscrowContract::execute_dispute_resolution(env, trade_id, resolution, trade)
209299
// Arbitrator Reputation
210300
// -------------------------------------------------------------------------
211301

contract/src/multisig.rs

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/// Multi-signature arbitration for high-value trades.
2+
///
3+
/// Flow:
4+
/// 1. Seller calls `create_multisig_trade` with a `MultiSigConfig`
5+
/// (arbitrators list, threshold, voting_timeout_seconds).
6+
/// 2. Buyer or seller raises a dispute — voting window opens automatically.
7+
/// 3. Each panel arbitrator calls `cast_vote` with their preferred resolution.
8+
/// 4. Once `threshold` arbitrators agree on the same outcome, consensus is
9+
/// reached and `resolve_dispute` executes it.
10+
/// 5. If the window expires without consensus, admin calls
11+
/// `resolve_expired_dispute` — defaults to refunding the buyer.
12+
13+
use soroban_sdk::{Address, Env, Vec};
14+
15+
use crate::errors::ContractError;
16+
use crate::events;
17+
use crate::storage::{
18+
get_all_votes_for_trade, get_trade, has_arbitrator, has_arbitrator_voted,
19+
save_arbitrator_vote,
20+
};
21+
use crate::types::{
22+
ArbitrationConfig, ArbitratorVote, DisputeResolution, MultiSigConfig, Trade,
23+
TradeStatus, VotingSummary,
24+
};
25+
26+
// ---------------------------------------------------------------------------
27+
// Cast vote
28+
// ---------------------------------------------------------------------------
29+
30+
pub fn cast_vote(
31+
env: &Env,
32+
trade_id: u64,
33+
arbitrator: &Address,
34+
resolution: DisputeResolution,
35+
) -> Result<(), ContractError> {
36+
let trade = get_trade(env, trade_id)?;
37+
if trade.status != TradeStatus::Disputed {
38+
return Err(ContractError::InvalidStatus);
39+
}
40+
let config = extract_multisig_config(&trade)?;
41+
42+
if !is_panel_member(arbitrator, &config.arbitrators) {
43+
return Err(ContractError::Unauthorized);
44+
}
45+
if !has_arbitrator(env, arbitrator) {
46+
return Err(ContractError::ArbitratorNotRegistered);
47+
}
48+
if has_arbitrator_voted(env, trade_id, arbitrator) {
49+
return Err(ContractError::AlreadyVoted);
50+
}
51+
if is_voting_expired(env, &config) {
52+
return Err(ContractError::VotingExpired);
53+
}
54+
55+
arbitrator.require_auth();
56+
57+
save_arbitrator_vote(
58+
env,
59+
trade_id,
60+
arbitrator,
61+
&ArbitratorVote {
62+
arbitrator: arbitrator.clone(),
63+
resolution: resolution.clone(),
64+
timestamp: env.ledger().timestamp(),
65+
},
66+
);
67+
68+
events::emit_arbitrator_vote_cast(env, trade_id, arbitrator.clone(), resolution);
69+
Ok(())
70+
}
71+
72+
// ---------------------------------------------------------------------------
73+
// Voting summary / consensus
74+
// ---------------------------------------------------------------------------
75+
76+
pub fn voting_summary(env: &Env, trade_id: u64) -> Result<VotingSummary, ContractError> {
77+
let trade = get_trade(env, trade_id)?;
78+
let config = extract_multisig_config(&trade)?;
79+
80+
let votes = get_all_votes_for_trade(env, trade_id, &config.arbitrators);
81+
let votes_cast = votes.len() as u32;
82+
let total_arbitrators = config.arbitrators.len() as u32;
83+
let expired = is_voting_expired(env, &config);
84+
let threshold = config.threshold;
85+
86+
// Tally votes per resolution variant
87+
let mut release_to_buyer: u32 = 0;
88+
let mut release_to_seller: u32 = 0;
89+
// Partial votes: Vec of (buyer_bps, count)
90+
let mut partial: Vec<(u32, u32)> = Vec::new(env);
91+
92+
for i in 0..votes.len() {
93+
match votes.get(i).unwrap().resolution {
94+
DisputeResolution::ReleaseToBuyer => release_to_buyer += 1,
95+
DisputeResolution::ReleaseToSeller => release_to_seller += 1,
96+
DisputeResolution::Partial { buyer_bps } => {
97+
let mut found = false;
98+
for j in 0..partial.len() {
99+
let (bps, cnt) = partial.get(j).unwrap();
100+
if bps == buyer_bps {
101+
partial.set(j, (bps, cnt + 1));
102+
found = true;
103+
break;
104+
}
105+
}
106+
if !found {
107+
partial.push_back((buyer_bps, 1));
108+
}
109+
}
110+
}
111+
}
112+
113+
let consensus: Option<DisputeResolution> = if release_to_buyer >= threshold {
114+
Some(DisputeResolution::ReleaseToBuyer)
115+
} else if release_to_seller >= threshold {
116+
Some(DisputeResolution::ReleaseToSeller)
117+
} else {
118+
let mut found: Option<DisputeResolution> = None;
119+
for i in 0..partial.len() {
120+
let (bps, cnt) = partial.get(i).unwrap();
121+
if cnt >= threshold {
122+
found = Some(DisputeResolution::Partial { buyer_bps: bps });
123+
break;
124+
}
125+
}
126+
found
127+
};
128+
129+
if consensus.is_some() {
130+
events::emit_multisig_consensus(env, trade_id);
131+
}
132+
133+
Ok(VotingSummary {
134+
total_arbitrators,
135+
votes_cast,
136+
threshold,
137+
has_consensus: consensus.is_some(),
138+
consensus_resolution: consensus,
139+
voting_expired: expired,
140+
})
141+
}
142+
143+
// ---------------------------------------------------------------------------
144+
// Expired-dispute resolution
145+
// ---------------------------------------------------------------------------
146+
147+
/// Force-resolve after the voting window expires without consensus.
148+
/// Safe default: refund the buyer. Admin-only.
149+
pub fn resolve_expired_dispute(
150+
env: &Env,
151+
trade_id: u64,
152+
admin: &Address,
153+
) -> Result<DisputeResolution, ContractError> {
154+
let trade = get_trade(env, trade_id)?;
155+
if trade.status != TradeStatus::Disputed {
156+
return Err(ContractError::InvalidStatus);
157+
}
158+
let config = extract_multisig_config(&trade)?;
159+
if !is_voting_expired(env, &config) {
160+
return Err(ContractError::VotingNotExpired);
161+
}
162+
admin.require_auth();
163+
events::emit_multisig_expired(env, trade_id);
164+
Ok(DisputeResolution::ReleaseToBuyer)
165+
}
166+
167+
// ---------------------------------------------------------------------------
168+
// Helpers
169+
// ---------------------------------------------------------------------------
170+
171+
fn extract_multisig_config(trade: &Trade) -> Result<MultiSigConfig, ContractError> {
172+
match &trade.arbitrator {
173+
Some(ArbitrationConfig::MultiSig(cfg)) => Ok(cfg.clone()),
174+
_ => Err(ContractError::InvalidStatus),
175+
}
176+
}
177+
178+
fn is_panel_member(arbitrator: &Address, panel: &Vec<Address>) -> bool {
179+
for i in 0..panel.len() {
180+
if panel.get(i).unwrap() == *arbitrator {
181+
return true;
182+
}
183+
}
184+
false
185+
}
186+
187+
fn is_voting_expired(env: &Env, config: &MultiSigConfig) -> bool {
188+
match config.voting_started_at {
189+
Some(started) => {
190+
env.ledger().timestamp() > started.saturating_add(config.voting_timeout_seconds)
191+
}
192+
None => false,
193+
}
194+
}

0 commit comments

Comments
 (0)