Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion __tests__/contract-sync/listener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,40 @@ describe('SorobanEventListener', () => {
expect(onCheckpoint).toHaveBeenCalledWith(505)
})

it('awaits the callback for each event before advancing the checkpoint', async () => {
it('normalizes canonical typed event topics and extracts indexed identifiers', async () => {
vi.useRealTimers()
const canonicalListener = new SorobanEventListener({
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
contractAddresses: ['CA1234'],
initialLedger: 500,
})
const canonicalCallback = vi.fn()
canonicalListener.setCallback(canonicalCallback)

mockGetEvents.mockResolvedValue({
events: [{
topic: ['PaymentReleased', 'CA-CONTRACT', '7', 'GACTOR'],
value: { recipient: 'GRECIPIENT', amount: '250' },
ledger: 501,
txHash: 'tx-canonical',
}],
})
mockGetLatestLedger.mockResolvedValue({ sequence: 505 })

await (canonicalListener as any).poll()

expect(canonicalCallback).toHaveBeenCalledWith(expect.objectContaining({
event: 'payment_released',
milestoneId: 7,
amount: '250',
actor: 'GACTOR',
recipient: 'GRECIPIENT',
}))
canonicalListener.stop()
})

it('awaits the callback for each event before advancing the checkpoint', async () => {
// Uses real timers and calls the private poll() directly so the
// ordering can be observed deterministically without racing fake-timer
// microtask flushing against a manually-controlled promise.
Expand Down
29 changes: 29 additions & 0 deletions __tests__/contract-sync/types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
CANONICAL_SOROBAN_EVENTS,
normalizeSorobanEvent,
} from '@/lib/contract-sync/types'

describe('Soroban event standardization', () => {
it('exposes the canonical event vocabulary', () => {
expect(CANONICAL_SOROBAN_EVENTS).toContain('escrow_created')
expect(CANONICAL_SOROBAN_EVENTS).toContain('payment_released')
expect(CANONICAL_SOROBAN_EVENTS).toContain('dispute_created')
expect(CANONICAL_SOROBAN_EVENTS).toContain('stake_claimed')
})

it.each([
['EscrowCreated', 'escrow_created'],
['PaymentReleased', 'payment_released'],
['DisputeRaised', 'dispute_raised'],
['dispute_resolved', 'dispute_resolved'],
['fund', 'escrow_funded'],
['release', 'payment_released'],
])('normalizes %s to %s', (topic, expected) => {
expect(normalizeSorobanEvent(topic)).toBe(expected)
})

it('rejects unknown topics', () => {
expect(normalizeSorobanEvent('unknown_event')).toBeNull()
})
})
123 changes: 113 additions & 10 deletions contracts/contracts/dispute/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#![no_std]
use soroban_sdk::{
contract, contractimpl, contracttype, contracterror, token, Address, Env, String
contract, contractevent, contractimpl, contracttype, contracterror, token, Address, Env, String,
};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -35,22 +35,84 @@ pub enum DataKey {
VoteSupport(Address, u32), // User's vote choice (true = for, false = against)
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
#[contracterror]
#[repr(u32)]
pub enum Error {
/// ERR_ALREADY_INITIALIZED
AlreadyInitialized = 1,
/// ERR_NOT_INITIALIZED
NotInitialized = 2,
/// ERR_DISPUTE_NOT_FOUND
DisputeNotFound = 3,
/// ERR_DISPUTE_CLOSED
DisputeClosed = 4,
/// ERR_DISPUTE_NOT_CLOSED
DisputeNotClosed = 5,
/// ERR_VOTING_ENDED
VotingEnded = 6,
/// ERR_VOTING_NOT_ENDED
VotingNotEnded = 7,
/// ERR_INVALID_AMOUNT
ZeroAmount = 8,
/// ERR_ALREADY_VOTED
AlreadyVoted = 9,
/// ERR_NO_STAKE
NoStake = 10,
/// ERR_DISPUTE_NOT_RESOLVED
DisputeNotResolved = 11,
}

#[contractevent]
pub struct DisputeCreated {
#[topic]
pub contract_id: Address,
#[topic]
pub dispute_id: u32,
#[topic]
pub actor: Address,
pub disputed_amount: i128,
pub end_time: u64,
pub winner_address: Address,
pub loser_address: Address,
}

#[contractevent]
pub struct VoteCast {
#[topic]
pub contract_id: Address,
#[topic]
pub dispute_id: u32,
#[topic]
pub actor: Address,
pub amount: i128,
pub support: bool,
}

#[contractevent]
pub struct DisputeResolved {
#[topic]
pub contract_id: Address,
#[topic]
pub dispute_id: u32,
#[topic]
pub actor: Address,
pub recipient: Address,
pub disputed_amount: i128,
pub is_in_favor: bool,
}

#[contractevent]
pub struct StakeClaimed {
#[topic]
pub contract_id: Address,
#[topic]
pub dispute_id: u32,
#[topic]
pub actor: Address,
pub amount: i128,
}

#[contract]
pub struct DisputeContract;

Expand Down Expand Up @@ -106,13 +168,24 @@ impl DisputeContract {
votes_against: 0,
status: DisputeStatus::Open,
disputed_amount,
winner_address,
loser_address,
winner_address: winner_address.clone(),
loser_address: loser_address.clone(),
};

env.storage().instance().set(&DataKey::Dispute(dispute_id), &dispute);
env.storage().instance().set(&DataKey::DisputeCount, &(dispute_id + 1));

DisputeCreated {
contract_id: env.current_contract_address(),
dispute_id,
actor: caller,
disputed_amount,
end_time: current_time + duration,
winner_address,
loser_address,
}
.publish(&env);

Ok(dispute_id)
}

Expand Down Expand Up @@ -168,10 +241,22 @@ impl DisputeContract {

env.storage().instance().set(&DataKey::Dispute(dispute_id), &dispute);

VoteCast {
contract_id: env.current_contract_address(),
dispute_id,
actor: voter,
amount,
support,
}
.publish(&env);

Ok(())
}

pub fn resolve(env: Env, dispute_id: u32) -> Result<bool, Error> {
let caller = env.caller();
caller.require_auth();

let mut dispute: Dispute = env
.storage()
.instance()
Expand All @@ -195,16 +280,26 @@ impl DisputeContract {
.get(&DataKey::Token)
.ok_or(Error::NotInitialized)?;

let token_client = token::Client::new(&env, &token_address);

if is_in_favor {
token_client.transfer(&env.current_contract_address(), &dispute.winner_address, &dispute.disputed_amount);
let recipient = if is_in_favor {
dispute.winner_address.clone()
} else {
token_client.transfer(&env.current_contract_address(), &dispute.loser_address, &dispute.disputed_amount);
}
dispute.loser_address.clone()
};
let token_client = token::Client::new(&env, &token_address);
token_client.transfer(&env.current_contract_address(), &recipient, &dispute.disputed_amount);

env.storage().instance().set(&DataKey::Dispute(dispute_id), &dispute);

DisputeResolved {
contract_id: env.current_contract_address(),
dispute_id,
actor: caller,
recipient,
disputed_amount: dispute.disputed_amount,
is_in_favor,
}
.publish(&env);

Ok(is_in_favor)
}

Expand Down Expand Up @@ -241,6 +336,14 @@ impl DisputeContract {
// Remove stake to prevent double claiming
env.storage().instance().remove(&stake_key);

StakeClaimed {
contract_id: env.current_contract_address(),
dispute_id,
actor: voter,
amount,
}
.publish(&env);

Ok(())
}

Expand Down
Loading
Loading