Module 5: Trade Lifecycle
State machines, transitions, and dispute resolution across the protocol.
5.1 Trade State Machine
All three implementations share the same fundamental state machine for trades:
5.2 State Definitions
CosmWasm Trade State
#[cw_serde]
pub enum TradeState {
RequestCreated,
RequestAccepted,
RequestCanceled,
RequestExpired,
EscrowFunded,
EscrowRefunded,
FiatDeposited,
EscrowReleased,
EscrowDisputed,
SettledForMaker,
SettledForTaker,
}
#[cw_serde]
pub struct Trade {
pub id: u64,
pub offer_id: u64,
pub buyer: Addr,
pub seller: Addr,
pub state: TradeState,
pub amount: Uint128,
pub fiat_amount: Uint128,
pub denom: String,
pub fiat_currency: FiatCurrency,
pub arbitrator: Option<Addr>,
pub state_history: Vec<StateChange>,
pub created_at: Timestamp,
pub expires_at: Timestamp,
}
// State history for audit trail
#[cw_serde]
pub struct StateChange {
pub from: TradeState,
pub to: TradeState,
pub actor: Addr,
pub timestamp: Timestamp,
}
Solidity Trade State
enum TradeState {
RequestCreated,
RequestAccepted,
RequestCanceled,
RequestExpired,
EscrowFunded,
EscrowRefunded,
FiatDeposited,
EscrowReleased,
Disputed,
DisputeResolved
}
struct TradeData {
uint256 id;
uint256 offerId;
address buyer;
address seller;
TradeState state;
uint256 amount;
uint256 fiatAmount;
address token;
string fiatCurrency;
address arbitrator;
uint256 createdAt;
uint256 expiresAt;
}
// State history via events
event TradeStateChanged(
uint256 indexed tradeId,
TradeState fromState,
TradeState toState,
address actor,
uint256 timestamp
);
Solana Trade State
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq)]
pub enum TradeState {
RequestCreated,
RequestAccepted,
RequestCanceled,
RequestExpired,
EscrowFunded,
EscrowRefunded,
FiatDeposited,
EscrowReleased,
Disputed,
DisputeResolved,
}
#[account]
pub struct Trade {
pub id: u64,
pub offer_id: u64,
pub buyer: Pubkey,
pub seller: Pubkey,
pub state: TradeState,
pub amount: u64,
pub fiat_amount: u64,
pub token_mint: Pubkey,
pub fiat_currency: [u8; 3],
pub arbitrator: Option<Pubkey>,
pub created_at: i64,
pub expires_at: i64,
pub buyer_contact: String,
pub seller_contact: String,
pub bump: u8,
}
// Events via Anchor emit!
#[event]
pub struct TradeStateChanged {
pub trade_id: u64,
pub from_state: TradeState,
pub to_state: TradeState,
pub actor: Pubkey,
pub timestamp: i64,
}
5.3 Enforcing Valid Transitions
The state machine must enforce valid transitions to prevent invalid states:
CosmWasm Transitions
impl TradeState {
pub fn can_transition_to(
&self,
next: &TradeState
) -> bool {
use TradeState::*;
match (self, next) {
// From RequestCreated
(RequestCreated, RequestAccepted) => true,
(RequestCreated, RequestCanceled) => true,
(RequestCreated, RequestExpired) => true,
// From RequestAccepted
(RequestAccepted, EscrowFunded) => true,
(RequestAccepted, RequestCanceled) => true,
// From EscrowFunded
(EscrowFunded, FiatDeposited) => true,
(EscrowFunded, EscrowRefunded) => true,
// From FiatDeposited
(FiatDeposited, EscrowReleased) => true,
(FiatDeposited, EscrowDisputed) => true,
// From Disputed
(EscrowDisputed, SettledForMaker) => true,
(EscrowDisputed, SettledForTaker) => true,
_ => false,
}
}
}
// Usage in handler
fn transition_state(
trade: &mut Trade,
new_state: TradeState,
actor: &Addr,
time: Timestamp,
) -> Result<(), ContractError> {
if !trade.state.can_transition_to(&new_state) {
return Err(ContractError::InvalidTransition {
from: trade.state.clone(),
to: new_state,
});
}
trade.state_history.push(StateChange {
from: trade.state.clone(),
to: new_state.clone(),
actor: actor.clone(),
timestamp: time,
});
trade.state = new_state;
Ok(())
}
Solidity Transitions
contract Trade {
// Modifier for valid transitions
modifier validTransition(
uint256 tradeId,
TradeState expectedCurrent,
TradeState newState
) {
TradeData storage trade = trades[tradeId];
require(
trade.state == expectedCurrent,
"Invalid current state"
);
require(
_isValidTransition(expectedCurrent, newState),
"Invalid state transition"
);
_;
}
function _isValidTransition(
TradeState from,
TradeState to
) internal pure returns (bool) {
if (from == TradeState.RequestCreated) {
return to == TradeState.RequestAccepted ||
to == TradeState.RequestCanceled ||
to == TradeState.RequestExpired;
}
if (from == TradeState.RequestAccepted) {
return to == TradeState.EscrowFunded ||
to == TradeState.RequestCanceled;
}
if (from == TradeState.EscrowFunded) {
return to == TradeState.FiatDeposited ||
to == TradeState.EscrowRefunded;
}
if (from == TradeState.FiatDeposited) {
return to == TradeState.EscrowReleased ||
to == TradeState.Disputed;
}
if (from == TradeState.Disputed) {
return to == TradeState.DisputeResolved;
}
return false;
}
function _setState(
uint256 tradeId,
TradeState newState
) internal {
TradeState oldState = trades[tradeId].state;
trades[tradeId].state = newState;
emit TradeStateChanged(
tradeId, oldState, newState,
msg.sender, block.timestamp
);
}
}
Solana Transitions
impl TradeState {
pub fn can_transition_to(
&self,
next: &TradeState
) -> bool {
use TradeState::*;
matches!(
(self, next),
(RequestCreated, RequestAccepted) |
(RequestCreated, RequestCanceled) |
(RequestCreated, RequestExpired) |
(RequestAccepted, EscrowFunded) |
(RequestAccepted, RequestCanceled) |
(EscrowFunded, FiatDeposited) |
(EscrowFunded, EscrowRefunded) |
(FiatDeposited, EscrowReleased) |
(FiatDeposited, Disputed) |
(Disputed, DisputeResolved)
)
}
}
// Constraint in account validation
#[derive(Accounts)]
pub struct FundEscrow<'info> {
#[account(
mut,
constraint = trade.state == TradeState::RequestAccepted
@ TradeError::InvalidState
)]
pub trade: Account<'info, Trade>,
// ...
}
// Helper function
pub fn transition_to(
trade: &mut Trade,
new_state: TradeState,
clock: &Clock,
) -> Result<()> {
require!(
trade.state.can_transition_to(&new_state),
TradeError::InvalidTransition
);
let old_state = trade.state.clone();
trade.state = new_state.clone();
trade.updated_at = clock.unix_timestamp;
emit!(TradeStateChanged {
trade_id: trade.id,
from_state: old_state,
to_state: new_state,
actor: trade.buyer, // or appropriate
timestamp: clock.unix_timestamp,
});
Ok(())
}
5.4 Dispute Resolution
When a trade is disputed, an arbitrator resolves it:
CosmWasm Dispute
pub fn execute_dispute(
deps: DepsMut,
env: Env,
info: MessageInfo,
trade_id: u64,
) -> Result<Response, ContractError> {
let mut trade = TRADES.load(deps.storage, trade_id)?;
// Only after fiat deposited
if trade.state != TradeState::FiatDeposited {
return Err(ContractError::InvalidState {});
}
// Only buyer or seller can dispute
if info.sender != trade.buyer && info.sender != trade.seller {
return Err(ContractError::Unauthorized {});
}
// Assign random arbitrator
let arbitrator = select_arbitrator(
deps.as_ref(),
&trade.fiat_currency,
&env.block,
)?;
trade.arbitrator = Some(arbitrator.clone());
trade.state = TradeState::EscrowDisputed;
trade.dispute_initiated_at = Some(env.block.time);
TRADES.save(deps.storage, trade_id, &trade)?;
Ok(Response::new()
.add_attribute("action", "dispute")
.add_attribute("arbitrator", arbitrator))
}
pub fn execute_settle_dispute(
deps: DepsMut,
info: MessageInfo,
trade_id: u64,
winner: DisputeWinner,
) -> Result<Response, ContractError> {
let mut trade = TRADES.load(deps.storage, trade_id)?;
// Only arbitrator can settle
if trade.arbitrator != Some(info.sender.clone()) {
return Err(ContractError::NotArbitrator {});
}
// Determine recipient
let recipient = match winner {
DisputeWinner::Maker => &trade.seller,
DisputeWinner::Taker => &trade.buyer,
};
// Calculate arbitrator fee
let arb_fee = trade.escrow_amount * arb_fee_pct;
let winner_amount = trade.escrow_amount - arb_fee;
// Send to winner and arbitrator
let msgs = vec![
bank_send(recipient, winner_amount, &trade.denom),
bank_send(&info.sender, arb_fee, &trade.denom),
];
trade.state = match winner {
DisputeWinner::Maker => TradeState::SettledForMaker,
DisputeWinner::Taker => TradeState::SettledForTaker,
};
Ok(Response::new().add_messages(msgs))
}
Solidity Dispute
struct DisputeInfo {
uint256 tradeId;
address initiator;
uint256 initiatedAt;
address arbitrator;
string buyerEvidence;
string sellerEvidence;
address winner;
uint256 resolvedAt;
}
mapping(uint256 => DisputeInfo) public disputes;
function initiateDispute(
uint256 tradeId,
string calldata evidence
) external {
TradeData storage trade = trades[tradeId];
require(
trade.state == TradeState.FiatDeposited,
"Invalid state"
);
require(
msg.sender == trade.buyer ||
msg.sender == trade.seller,
"Not trade party"
);
// Assign arbitrator
address arbitrator = arbitratorManager
.assignArbitrator(trade.fiatCurrency);
disputes[tradeId] = DisputeInfo({
tradeId: tradeId,
initiator: msg.sender,
initiatedAt: block.timestamp,
arbitrator: arbitrator,
buyerEvidence: msg.sender == trade.buyer
? evidence : "",
sellerEvidence: msg.sender == trade.seller
? evidence : "",
winner: address(0),
resolvedAt: 0
});
trade.state = TradeState.Disputed;
emit DisputeInitiated(tradeId, msg.sender, arbitrator);
}
function resolveDispute(
uint256 tradeId,
address winner
) external nonReentrant {
DisputeInfo storage dispute = disputes[tradeId];
TradeData storage trade = trades[tradeId];
require(
msg.sender == dispute.arbitrator,
"Not arbitrator"
);
require(
winner == trade.buyer || winner == trade.seller,
"Invalid winner"
);
// Calculate and distribute
uint256 total = escrowBalances[tradeId];
uint256 arbFee = (total * hub.arbitratorFeeBps) / 10000;
uint256 winnerAmount = total - arbFee;
escrowBalances[tradeId] = 0;
dispute.winner = winner;
dispute.resolvedAt = block.timestamp;
trade.state = TradeState.DisputeResolved;
IERC20(trade.token).safeTransfer(winner, winnerAmount);
IERC20(trade.token).safeTransfer(msg.sender, arbFee);
}
Solana Dispute
#[derive(Accounts)]
pub struct InitiateDispute<'info> {
#[account(
constraint = initiator.key() == trade.buyer ||
initiator.key() == trade.seller
)]
pub initiator: Signer<'info>,
#[account(
mut,
constraint = trade.state == TradeState::FiatDeposited
)]
pub trade: Account<'info, Trade>,
#[account(
seeds = [b"arbitrator_registry"],
bump
)]
pub arbitrator_registry: Account<'info, ArbitratorRegistry>,
}
pub fn initiate_dispute(
ctx: Context<InitiateDispute>
) -> Result<()> {
let trade = &mut ctx.accounts.trade;
let clock = Clock::get()?;
// Select arbitrator randomly
let arbitrator = select_arbitrator(
&ctx.accounts.arbitrator_registry,
&trade.fiat_currency,
clock.slot,
)?;
trade.arbitrator = Some(arbitrator);
trade.state = TradeState::Disputed;
trade.dispute_initiated_at = Some(clock.unix_timestamp);
emit!(DisputeInitiated {
trade_id: trade.id,
initiator: ctx.accounts.initiator.key(),
arbitrator,
});
Ok(())
}
#[derive(Accounts)]
pub struct ResolveDispute<'info> {
#[account(
constraint = arbitrator.key() == trade.arbitrator.unwrap()
@ TradeError::NotArbitrator
)]
pub arbitrator: Signer<'info>,
#[account(
mut,
constraint = trade.state == TradeState::Disputed
)]
pub trade: Account<'info, Trade>,
// Winner's token account
#[account(mut)]
pub winner_token_account: Account<'info, TokenAccount>,
// Arbitrator's token account for fee
#[account(mut)]
pub arbitrator_token_account: Account<'info, TokenAccount>,
// Vault accounts...
}
Knowledge Check
Test Your Understanding
1. Why is state history tracking important in a trading protocol?
2. When can a dispute be initiated in the trade lifecycle?
3. How does Solidity track state change history?