Skip to content

Commit d45dc2e

Browse files
committed
feat: implement multi-signature dispute & escrow resolution (#1319)
Add arbitrated dispute resolution to payment streams: - types.rs: Add DisputeState enum (None/Initiated/Resolved), DisputeInitiatedData struct, and arbiter + dispute_state fields to Stream struct - errors.rs: Add NoArbiterConfigured, DisputeAlreadyActive, NoActiveDispute, InvalidDisputeSplit, DisputeInProgress variants - events.rs: Add DisputeInitiatedEvent and DisputeResolvedEvent - lib.rs: Add create_escrow_stream, initiate_dispute, resolve_dispute entrypoints; guard cancel_stream against active disputes - test.rs: 10+ unit tests covering all happy/sad paths including event emission, split validation, and cancel blocking All 140 tests pass. Closes #1319
1 parent 682c9b2 commit d45dc2e

5 files changed

Lines changed: 712 additions & 8 deletions

File tree

contracts/stream_contract/src/errors.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,14 @@ pub enum StreamError {
4242
/// Returned instead of letting `overflow-checks` panic and abort the whole
4343
/// transaction, so callers get a typed failure they can handle.
4444
ArithmeticOverflow = 16,
45+
/// Stream has no arbiter configured; dispute operations are unavailable.
46+
NoArbiterConfigured = 17,
47+
/// A dispute is already active on this stream.
48+
DisputeAlreadyActive = 18,
49+
/// No active dispute exists on this stream.
50+
NoActiveDispute = 19,
51+
/// The payout split does not equal the remaining deposit.
52+
InvalidDisputeSplit = 20,
53+
/// Operation is blocked because a dispute is currently active on this stream.
54+
DisputeInProgress = 21,
4555
}

contracts/stream_contract/src/events.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,26 @@ pub struct StreamCompletedEvent {
166166
pub recipient: Address,
167167
pub total_withdrawn: i128,
168168
}
169+
170+
/// Emitted when a dispute is initiated on an escrow-enabled stream.
171+
///
172+
/// Topic: `("dispute_initiated", stream_id)`
173+
#[contracttype]
174+
#[derive(Clone, Debug, Eq, PartialEq)]
175+
pub struct DisputeInitiatedEvent {
176+
pub stream_id: u64,
177+
pub initiator: Address,
178+
pub timestamp: u64,
179+
}
180+
181+
/// Emitted when an arbiter resolves a dispute and splits the remaining funds.
182+
///
183+
/// Topic: `("dispute_resolved", stream_id)`
184+
#[contracttype]
185+
#[derive(Clone, Debug, Eq, PartialEq)]
186+
pub struct DisputeResolvedEvent {
187+
pub stream_id: u64,
188+
pub arbiter: Address,
189+
pub sender_payout: i128,
190+
pub recipient_payout: i128,
191+
}

contracts/stream_contract/src/lib.rs

Lines changed: 250 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,16 @@ use soroban_sdk::{contract, contractimpl, token, vec, Address, Env, InvokeError,
3939

4040
use errors::StreamError;
4141
use events::{
42-
AdminTransferredEvent, FeeCollectedEvent, FeeConfigUpdatedEvent, InitializedEvent,
43-
RecipientTransferredEvent, StreamCancelledEvent, StreamCompletedEvent, StreamCreatedEvent,
44-
StreamPausedEvent, StreamResumedEvent, StreamToppedUpEvent, TokensWithdrawnEvent,
42+
AdminTransferredEvent, DisputeInitiatedEvent, DisputeResolvedEvent, FeeCollectedEvent,
43+
FeeConfigUpdatedEvent, InitializedEvent, RecipientTransferredEvent, StreamCancelledEvent,
44+
StreamCompletedEvent, StreamCreatedEvent, StreamPausedEvent, StreamResumedEvent,
45+
StreamToppedUpEvent, TokensWithdrawnEvent,
4546
};
4647
use storage::{
4748
config_exists, load_config, load_stream, next_stream_id, save_config, save_stream,
4849
try_load_config, try_load_stream,
4950
};
50-
use types::{BatchStreamInput, ProtocolConfig, Stream, StreamStatus};
51+
use types::{BatchStreamInput, DisputeInitiatedData, DisputeState, ProtocolConfig, Stream, StreamStatus};
5152

5253
/// Maximum allowed protocol fee: 1 000 bps = 10%.
5354
const MAX_FEE_RATE_BPS: u32 = 1_000;
@@ -263,6 +264,8 @@ impl StreamContract {
263264
paused: false,
264265
paused_at: None,
265266
status: StreamStatus::Active,
267+
arbiter: None,
268+
dispute_state: DisputeState::None,
266269
},
267270
);
268271

@@ -356,6 +359,8 @@ impl StreamContract {
356359
paused: false,
357360
paused_at: None,
358361
status: StreamStatus::Active,
362+
arbiter: None,
363+
dispute_state: DisputeState::None,
359364
};
360365
save_stream(&env, stream_id, &stream);
361366
env.events().publish(
@@ -419,6 +424,8 @@ impl StreamContract {
419424
paused: false,
420425
paused_at: None,
421426
status: StreamStatus::Active,
427+
arbiter: None,
428+
dispute_state: DisputeState::None,
422429
},
423430
);
424431
env.events().publish(
@@ -785,6 +792,11 @@ impl StreamContract {
785792
Self::validate_stream_ownership(&stream, &sender)?;
786793
Self::validate_stream_active(&stream)?;
787794

795+
// Block unilateral cancellation if a dispute is in progress.
796+
if let DisputeState::Initiated(_) = &stream.dispute_state {
797+
return Err(StreamError::DisputeInProgress);
798+
}
799+
788800
let now = env.ledger().timestamp();
789801
let accrued_amount = Self::calculate_claimable(&stream, now);
790802

@@ -942,6 +954,240 @@ impl StreamContract {
942954
Ok(new_end_time)
943955
}
944956

957+
// ─── Escrow & Dispute Resolution ──────────────────────────────────────────
958+
959+
/// Create a new escrow-enabled payment stream with an arbiter.
960+
///
961+
/// Identical to `create_stream` but accepts an `arbiter` address that can
962+
/// mediate disputes. Either party can initiate a dispute, which freezes
963+
/// accrual and blocks unilateral cancellation until the arbiter resolves it.
964+
///
965+
/// # Errors
966+
/// Same as `create_stream`.
967+
pub fn create_escrow_stream(
968+
env: Env,
969+
sender: Address,
970+
recipient: Address,
971+
token_address: Address,
972+
amount: i128,
973+
duration: u64,
974+
arbiter: Address,
975+
) -> Result<u64, StreamError> {
976+
sender.require_auth();
977+
978+
if amount <= 0 {
979+
return Err(StreamError::InvalidAmount);
980+
}
981+
if duration == 0 {
982+
return Err(StreamError::InvalidDuration);
983+
}
984+
Self::validate_token_contract(&env, &token_address)?;
985+
986+
let stream_id = next_stream_id(&env);
987+
let start_time = env.ledger().timestamp();
988+
989+
let token_client = token::Client::new(&env, &token_address);
990+
let contract_address = env.current_contract_address();
991+
token_client.transfer(&sender, &contract_address, &amount);
992+
993+
let net_amount = Self::collect_fee(&env, &token_address, amount, stream_id)?;
994+
let rate_per_second = net_amount / (duration as i128);
995+
996+
if rate_per_second == 0 {
997+
return Err(StreamError::InvalidRate);
998+
}
999+
1000+
save_stream(
1001+
&env,
1002+
stream_id,
1003+
&Stream {
1004+
sender: sender.clone(),
1005+
recipient: recipient.clone(),
1006+
token_address: token_address.clone(),
1007+
rate_per_second,
1008+
deposited_amount: net_amount,
1009+
withdrawn_amount: 0,
1010+
start_time,
1011+
last_update_time: start_time,
1012+
cliff_time: None,
1013+
is_active: true,
1014+
paused: false,
1015+
paused_at: None,
1016+
status: StreamStatus::Active,
1017+
arbiter: Some(arbiter),
1018+
dispute_state: DisputeState::None,
1019+
},
1020+
);
1021+
1022+
env.events().publish(
1023+
(Symbol::new(&env, "stream_created"), stream_id),
1024+
StreamCreatedEvent {
1025+
stream_id,
1026+
sender,
1027+
recipient,
1028+
rate_per_second,
1029+
token_address,
1030+
deposited_amount: net_amount,
1031+
start_time,
1032+
},
1033+
);
1034+
1035+
Ok(stream_id)
1036+
}
1037+
1038+
/// Initiate a dispute on an escrow-enabled stream.
1039+
///
1040+
/// Either the sender or recipient can initiate. Once initiated, token
1041+
/// accrual is frozen (stream is paused) and unilateral cancellation or
1042+
/// withdrawal is blocked until the arbiter resolves the dispute.
1043+
///
1044+
/// # Errors
1045+
/// - `StreamNotFound` — no stream exists with `stream_id`.
1046+
/// - `Unauthorized` — caller is neither sender nor recipient.
1047+
/// - `StreamNotActive` — stream is inactive.
1048+
/// - `NoArbiterConfigured` — stream has no arbiter.
1049+
/// - `DisputeAlreadyActive` — a dispute is already in progress.
1050+
pub fn initiate_dispute(
1051+
env: Env,
1052+
caller: Address,
1053+
stream_id: u64,
1054+
) -> Result<(), StreamError> {
1055+
caller.require_auth();
1056+
1057+
let mut stream = load_stream(&env, stream_id)?;
1058+
1059+
// Only sender or recipient may initiate.
1060+
if stream.sender != caller && stream.recipient != caller {
1061+
return Err(StreamError::Unauthorized);
1062+
}
1063+
if !stream.is_active {
1064+
return Err(StreamError::StreamNotActive);
1065+
}
1066+
if stream.arbiter.is_none() {
1067+
return Err(StreamError::NoArbiterConfigured);
1068+
}
1069+
if let DisputeState::Initiated(_) = &stream.dispute_state {
1070+
return Err(StreamError::DisputeAlreadyActive);
1071+
}
1072+
1073+
let now = env.ledger().timestamp();
1074+
1075+
// Freeze accrual by pausing the stream at the dispute moment.
1076+
if !stream.paused {
1077+
stream.paused = true;
1078+
stream.paused_at = Some(now);
1079+
stream.status = StreamStatus::Paused;
1080+
}
1081+
1082+
stream.dispute_state = DisputeState::Initiated(DisputeInitiatedData {
1083+
initiator: caller.clone(),
1084+
timestamp: now,
1085+
});
1086+
save_stream(&env, stream_id, &stream);
1087+
1088+
env.events().publish(
1089+
(Symbol::new(&env, "dispute_initiated"), stream_id),
1090+
DisputeInitiatedEvent {
1091+
stream_id,
1092+
initiator: caller,
1093+
timestamp: now,
1094+
},
1095+
);
1096+
1097+
Ok(())
1098+
}
1099+
1100+
/// Resolve an active dispute as the designated arbiter.
1101+
///
1102+
/// The arbiter splits the remaining deposit (after already-withdrawn
1103+
/// amounts) between sender and recipient. The split must be exact:
1104+
/// `sender_payout + recipient_payout == remaining_deposit`.
1105+
///
1106+
/// Both payouts are transferred atomically and the stream is terminated.
1107+
///
1108+
/// # Errors
1109+
/// - `StreamNotFound` — no stream exists with `stream_id`.
1110+
/// - `Unauthorized` — caller is not the stream's arbiter.
1111+
/// - `NoActiveDispute` — no dispute is in progress.
1112+
/// - `InvalidDisputeSplit` — payouts do not sum to remaining deposit.
1113+
pub fn resolve_dispute(
1114+
env: Env,
1115+
arbiter: Address,
1116+
stream_id: u64,
1117+
sender_payout: i128,
1118+
recipient_payout: i128,
1119+
) -> Result<(), StreamError> {
1120+
arbiter.require_auth();
1121+
1122+
let mut stream = load_stream(&env, stream_id)?;
1123+
1124+
// Verify the caller is the stream's arbiter.
1125+
match &stream.arbiter {
1126+
Some(a) if *a == arbiter => {}
1127+
_ => return Err(StreamError::Unauthorized),
1128+
}
1129+
1130+
// Verify a dispute is active.
1131+
match &stream.dispute_state {
1132+
DisputeState::Initiated(_) => {}
1133+
_ => return Err(StreamError::NoActiveDispute),
1134+
}
1135+
1136+
// The remaining deposit is everything not yet withdrawn.
1137+
let remaining = stream
1138+
.deposited_amount
1139+
.saturating_sub(stream.withdrawn_amount);
1140+
1141+
// Enforce exact split.
1142+
if sender_payout < 0 || recipient_payout < 0 {
1143+
return Err(StreamError::InvalidDisputeSplit);
1144+
}
1145+
if sender_payout
1146+
.checked_add(recipient_payout)
1147+
.ok_or(StreamError::ArithmeticOverflow)?
1148+
!= remaining
1149+
{
1150+
return Err(StreamError::InvalidDisputeSplit);
1151+
}
1152+
1153+
// Effects: terminate the stream.
1154+
stream.is_active = false;
1155+
stream.status = StreamStatus::Cancelled;
1156+
stream.paused = false;
1157+
stream.paused_at = Option::None;
1158+
stream.dispute_state = DisputeState::Resolved;
1159+
stream.withdrawn_amount = stream.deposited_amount;
1160+
1161+
let sender_addr = stream.sender.clone();
1162+
let recipient_addr = stream.recipient.clone();
1163+
1164+
// Persist state before external calls (CEI).
1165+
save_stream(&env, stream_id, &stream);
1166+
1167+
// Interactions: transfer payouts.
1168+
let token_client = token::Client::new(&env, &stream.token_address);
1169+
let contract_address = env.current_contract_address();
1170+
1171+
if sender_payout > 0 {
1172+
token_client.transfer(&contract_address, &sender_addr, &sender_payout);
1173+
}
1174+
if recipient_payout > 0 {
1175+
token_client.transfer(&contract_address, &recipient_addr, &recipient_payout);
1176+
}
1177+
1178+
env.events().publish(
1179+
(Symbol::new(&env, "dispute_resolved"), stream_id),
1180+
DisputeResolvedEvent {
1181+
stream_id,
1182+
arbiter,
1183+
sender_payout,
1184+
recipient_payout,
1185+
},
1186+
);
1187+
1188+
Ok(())
1189+
}
1190+
9451191
// ─── Read-only Queries ────────────────────────────────────────────────────
9461192

9471193
/// Returns the stream record for `stream_id`, or `None` if it does not exist.

0 commit comments

Comments
 (0)