From fae06497b90eda854780141372cf6bc7d00d1a0e Mon Sep 17 00:00:00 2001 From: Oseji Fabian Daniel Date: Tue, 25 Aug 2026 18:18:18 +0000 Subject: [PATCH] feat: standardize Soroban contract errors and events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #191 Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- __tests__/contract-sync/listener.test.ts | 35 ++- __tests__/contract-sync/types.test.ts | 29 +++ contracts/contracts/dispute/src/lib.rs | 123 +++++++++- contracts/contracts/escrow/src/lib.rs | 220 +++++++++++++++++- docs/soroban-contract-events.md | 65 ++++++ lib/contract-sync/index.ts | 10 +- lib/contract-sync/listener.ts | 169 +++++++++----- lib/contract-sync/mapper.ts | 66 +++--- lib/contract-sync/types.ts | 87 ++++++- .../009_contract_event_standardization.sql | 21 ++ 10 files changed, 706 insertions(+), 119 deletions(-) create mode 100644 __tests__/contract-sync/types.test.ts create mode 100644 docs/soroban-contract-events.md create mode 100644 lib/db/migrations/009_contract_event_standardization.sql diff --git a/__tests__/contract-sync/listener.test.ts b/__tests__/contract-sync/listener.test.ts index 6a3e59d..9855fb7 100644 --- a/__tests__/contract-sync/listener.test.ts +++ b/__tests__/contract-sync/listener.test.ts @@ -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. diff --git a/__tests__/contract-sync/types.test.ts b/__tests__/contract-sync/types.test.ts new file mode 100644 index 0000000..dc0c13f --- /dev/null +++ b/__tests__/contract-sync/types.test.ts @@ -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() + }) +}) diff --git a/contracts/contracts/dispute/src/lib.rs b/contracts/contracts/dispute/src/lib.rs index 3a1d4cd..065120a 100644 --- a/contracts/contracts/dispute/src/lib.rs +++ b/contracts/contracts/dispute/src/lib.rs @@ -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)] @@ -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; @@ -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) } @@ -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 { + let caller = env.caller(); + caller.require_auth(); + let mut dispute: Dispute = env .storage() .instance() @@ -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) } @@ -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(()) } diff --git a/contracts/contracts/escrow/src/lib.rs b/contracts/contracts/escrow/src/lib.rs index 25c4d2c..8cf2e49 100644 --- a/contracts/contracts/escrow/src/lib.rs +++ b/contracts/contracts/escrow/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, contracterror, token, Address, Env, String, Vec, symbol_short, + contract, contractevent, contractimpl, contracttype, contracterror, token, Address, Env, String, Vec, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -42,23 +42,151 @@ pub enum DataKey { MilestoneIds, } -#[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_ALREADY_FUNDED AlreadyFunded = 3, + /// ERR_NOT_FUNDED NotFunded = 4, + /// ERR_MILESTONE_NOT_FOUND MilestoneNotFound = 5, + /// ERR_INVALID_STATE InvalidMilestoneStatus = 6, + /// ERR_UNAUTHORIZED Unauthorized = 7, + /// ERR_INVALID_AMOUNT ZeroAmount = 8, + /// ERR_INSUFFICIENT_APPROVALS InsufficientApprovals = 9, + /// ERR_ALREADY_APPROVED AlreadyApproved = 10, + /// ERR_DEADLINE_EXCEEDED DeadlineExceeded = 11, + /// ERR_ALREADY_EXPIRED AlreadyExpired = 12, } +/// Standardized event schemas. Topics are indexed by Soroban RPC consumers; +/// non-topic fields are emitted as the event data payload. +#[contractevent] +pub struct EscrowCreated { + #[topic] + pub contract_id: Address, + #[topic] + pub actor: Address, + pub client: Address, + pub freelancer: Address, + pub arbiter: Address, + pub token: Address, + pub milestone_count: u32, +} + +#[contractevent] +pub struct EscrowFunded { + #[topic] + pub contract_id: Address, + #[topic] + pub actor: Address, + pub amount: i128, +} + +#[contractevent] +pub struct MilestoneSubmitted { + #[topic] + pub contract_id: Address, + #[topic] + pub milestone_id: u32, + #[topic] + pub actor: Address, + pub amount: i128, + pub deadline: u64, +} + +#[contractevent] +pub struct MilestoneApproved { + #[topic] + pub contract_id: Address, + #[topic] + pub milestone_id: u32, + #[topic] + pub actor: Address, +} + +#[contractevent] +pub struct MilestoneConfirmed { + #[topic] + pub contract_id: Address, + #[topic] + pub milestone_id: u32, + #[topic] + pub actor: Address, +} + +#[contractevent] +pub struct PaymentReleased { + #[topic] + pub contract_id: Address, + #[topic] + pub milestone_id: u32, + #[topic] + pub actor: Address, + pub recipient: Address, + pub amount: i128, +} + +#[contractevent] +pub struct DisputeRaised { + #[topic] + pub contract_id: Address, + #[topic] + pub milestone_id: u32, + #[topic] + pub actor: Address, +} + +#[contractevent] +pub struct RefundIssued { + #[topic] + pub contract_id: Address, + #[topic] + pub milestone_id: u32, + #[topic] + pub actor: Address, + pub recipient: Address, + pub amount: i128, +} + +#[contractevent] +pub struct DisputeResolved { + #[topic] + pub contract_id: Address, + #[topic] + pub milestone_id: u32, + #[topic] + pub actor: Address, + pub recipient: Address, + pub amount: i128, + pub release_to_freelancer: bool, +} + +#[contractevent] +pub struct MilestoneExpired { + #[topic] + pub contract_id: Address, + #[topic] + pub milestone_id: u32, + #[topic] + pub actor: Address, + pub recipient: Address, + pub amount: i128, +} + #[contract] pub struct EscrowContract; @@ -73,6 +201,8 @@ impl EscrowContract { token: Address, milestones: Vec, ) -> Result<(), Error> { + admin.require_auth(); + if env.storage().instance().has(&DataKey::Client) { return Err(Error::AlreadyInitialized); } @@ -102,7 +232,16 @@ impl EscrowContract { env.storage().instance().set(&DataKey::MilestoneIds, &ids); env.storage().instance().set(&DataKey::IsFunded, &false); - env.events().publish((symbol_short!("init"),), (client, freelancer, arbiter)); + EscrowCreated { + contract_id: env.current_contract_address(), + actor: admin, + client, + freelancer, + arbiter, + token, + milestone_count: ids.len(), + } + .publish(&env); Ok(()) } @@ -144,7 +283,12 @@ impl EscrowContract { env.storage().instance().set(&DataKey::IsFunded, &true); - env.events().publish((symbol_short!("fund"),), (total_amount,)); + EscrowFunded { + contract_id: env.current_contract_address(), + actor: client, + amount: total_amount, + } + .publish(&env); Ok(()) } @@ -165,7 +309,14 @@ impl EscrowContract { milestone.status = MilestoneStatus::Submitted; env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); - env.events().publish((symbol_short!("submit"),), (milestone_id,)); + MilestoneSubmitted { + contract_id: env.current_contract_address(), + milestone_id, + actor: freelancer, + amount: milestone.amount, + deadline: milestone.deadline, + } + .publish(&env); Ok(()) } @@ -187,7 +338,12 @@ impl EscrowContract { milestone.status = MilestoneStatus::Approved; env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); - env.events().publish((symbol_short!("approve"),), (milestone_id,)); + MilestoneApproved { + contract_id: env.current_contract_address(), + milestone_id, + actor: client, + } + .publish(&env); Ok(()) } @@ -208,7 +364,12 @@ impl EscrowContract { milestone.freelancer_approved = true; env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); - env.events().publish((symbol_short!("confirm"),), (milestone_id,)); + MilestoneConfirmed { + contract_id: env.current_contract_address(), + milestone_id, + actor: freelancer, + } + .publish(&env); Ok(()) } @@ -240,7 +401,14 @@ impl EscrowContract { let token_client = token::Client::new(&env, &token_address); token_client.transfer(&env.current_contract_address(), &freelancer, &transfer_amount); - env.events().publish((symbol_short!("release"),), (milestone_id, transfer_amount)); + PaymentReleased { + contract_id: env.current_contract_address(), + milestone_id, + actor: caller, + recipient: freelancer, + amount: transfer_amount, + } + .publish(&env); Ok(()) } @@ -268,7 +436,14 @@ impl EscrowContract { let token_client = token::Client::new(&env, &token_address); token_client.transfer(&env.current_contract_address(), &client, &transfer_amount); - env.events().publish((symbol_short!("refund"),), (milestone_id, transfer_amount)); + RefundIssued { + contract_id: env.current_contract_address(), + milestone_id, + actor: caller, + recipient: client, + amount: transfer_amount, + } + .publish(&env); Ok(()) } @@ -297,7 +472,12 @@ impl EscrowContract { milestone.freelancer_approved = false; env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); - env.events().publish((symbol_short!("dispute"),), (milestone_id,)); + DisputeRaised { + contract_id: env.current_contract_address(), + milestone_id, + actor: caller, + } + .publish(&env); Ok(()) } @@ -330,12 +510,21 @@ impl EscrowContract { let token_client = token::Client::new(&env, &token_address); token_client.transfer(&env.current_contract_address(), &recipient, &transfer_amount); - env.events().publish((symbol_short!("resolve"),), (milestone_id, release_to_freelancer)); + DisputeResolved { + contract_id: env.current_contract_address(), + milestone_id, + actor: arbiter, + recipient, + amount: transfer_amount, + release_to_freelancer, + } + .publish(&env); Ok(()) } pub fn auto_expire(env: Env, milestone_id: u32) -> Result<(), Error> { + let caller = env.caller(); let mut milestone: Milestone = env.storage().instance().get(&DataKey::Milestone(milestone_id)).ok_or(Error::MilestoneNotFound)?; if milestone.deadline == 0 { @@ -362,7 +551,14 @@ impl EscrowContract { let token_client = token::Client::new(&env, &token_address); token_client.transfer(&env.current_contract_address(), &client, &transfer_amount); - env.events().publish((symbol_short!("expire"),), (milestone_id, transfer_amount)); + MilestoneExpired { + contract_id: env.current_contract_address(), + milestone_id, + actor: caller, + recipient: client, + amount: transfer_amount, + } + .publish(&env); Ok(()) } diff --git a/docs/soroban-contract-events.md b/docs/soroban-contract-events.md new file mode 100644 index 0000000..060aa00 --- /dev/null +++ b/docs/soroban-contract-events.md @@ -0,0 +1,65 @@ +# Soroban Contract Errors and Events + +This document defines the public integration contract for the `escrow` and `dispute` Soroban contracts. + +## Errors + +Contract methods that can fail return `Result<_, Error>`. Soroban exposes the enum variant as a stable numeric contract error. Do not parse human-readable panic text; use the generated client `try_*` methods and match the error enum. + +| Contract | Code | Variant | Integration code | +| --- | ---: | --- | --- | +| Escrow | 1 | `AlreadyInitialized` | `ERR_ALREADY_INITIALIZED` | +| Escrow | 2 | `NotInitialized` | `ERR_NOT_INITIALIZED` | +| Escrow | 3 | `AlreadyFunded` | `ERR_ALREADY_FUNDED` | +| Escrow | 4 | `NotFunded` | `ERR_NOT_FUNDED` | +| Escrow | 5 | `MilestoneNotFound` | `ERR_MILESTONE_NOT_FOUND` | +| Escrow | 6 | `InvalidMilestoneStatus` | `ERR_INVALID_STATE` | +| Escrow | 7 | `Unauthorized` | `ERR_UNAUTHORIZED` | +| Escrow | 8 | `ZeroAmount` | `ERR_INVALID_AMOUNT` | +| Escrow | 9 | `InsufficientApprovals` | `ERR_INSUFFICIENT_APPROVALS` | +| Escrow | 10 | `AlreadyApproved` | `ERR_ALREADY_APPROVED` | +| Escrow | 11 | `DeadlineExceeded` | `ERR_DEADLINE_EXCEEDED` | +| Escrow | 12 | `AlreadyExpired` | `ERR_ALREADY_EXPIRED` | +| Dispute | 1 | `AlreadyInitialized` | `ERR_ALREADY_INITIALIZED` | +| Dispute | 2 | `NotInitialized` | `ERR_NOT_INITIALIZED` | +| Dispute | 3 | `DisputeNotFound` | `ERR_DISPUTE_NOT_FOUND` | +| Dispute | 4 | `DisputeClosed` | `ERR_DISPUTE_CLOSED` | +| Dispute | 5 | `DisputeNotClosed` | `ERR_DISPUTE_NOT_CLOSED` | +| Dispute | 6 | `VotingEnded` | `ERR_VOTING_ENDED` | +| Dispute | 7 | `VotingNotEnded` | `ERR_VOTING_NOT_ENDED` | +| Dispute | 8 | `ZeroAmount` | `ERR_INVALID_AMOUNT` | +| Dispute | 9 | `AlreadyVoted` | `ERR_ALREADY_VOTED` | +| Dispute | 10 | `NoStake` | `ERR_NO_STAKE` | +| Dispute | 11 | `DisputeNotResolved` | `ERR_DISPUTE_NOT_RESOLVED` | + +Numeric values are append-only ABI values. If a new failure is added, append a new variant and never renumber an existing one. + +## Events + +All standardized events use the PascalCase type names below in contract source and the equivalent snake_case event name in RPC/indexer storage. Every event includes `contract_id` as an indexed topic. `actor`, when present, is the wallet that initiated the action. + +### Escrow events + +| Event | Indexed topics | Data payload | +| --- | --- | --- | +| `EscrowCreated` / `escrow_created` | `contract_id`, `actor` | `client`, `freelancer`, `arbiter`, `token`, `milestone_count` | +| `EscrowFunded` / `escrow_funded` | `contract_id`, `actor` | `amount` | +| `MilestoneSubmitted` / `milestone_submitted` | `contract_id`, `milestone_id`, `actor` | `amount`, `deadline` | +| `MilestoneApproved` / `milestone_approved` | `contract_id`, `milestone_id`, `actor` | none | +| `MilestoneConfirmed` / `milestone_confirmed` | `contract_id`, `milestone_id`, `actor` | none | +| `PaymentReleased` / `payment_released` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount` | +| `DisputeRaised` / `dispute_raised` | `contract_id`, `milestone_id`, `actor` | none | +| `RefundIssued` / `refund_issued` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount` | +| `DisputeResolved` / `dispute_resolved` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount`, `release_to_freelancer` | +| `MilestoneExpired` / `milestone_expired` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount` | + +### Dispute events + +| Event | Indexed topics | Data payload | +| --- | --- | --- | +| `DisputeCreated` / `dispute_created` | `contract_id`, `dispute_id`, `actor` | `disputed_amount`, `end_time`, `winner_address`, `loser_address` | +| `VoteCast` / `vote_cast` | `contract_id`, `dispute_id`, `actor` | `amount`, `support` | +| `DisputeResolved` / `dispute_resolved` | `contract_id`, `dispute_id`, `actor` | `recipient`, `disputed_amount`, `is_in_favor` | +| `StakeClaimed` / `stake_claimed` | `contract_id`, `dispute_id`, `actor` | `amount` | + +The event listener accepts both these canonical names and the pre-standardization topics (`init`, `fund`, `submit`, `approve`, `confirm`, `release`, `refund`, `dispute`, `resolve`, `expire`). New integrations should use canonical names only. diff --git a/lib/contract-sync/index.ts b/lib/contract-sync/index.ts index 88cf6bb..8744a58 100644 --- a/lib/contract-sync/index.ts +++ b/lib/contract-sync/index.ts @@ -4,6 +4,8 @@ export { SyncQueue } from './queue' export { mapEventToAction } from './mapper' export type { + CanonicalSorobanContractEvent, + LegacySorobanContractEvent, SorobanContractEvent, SorobanEventPayload, SyncStatus, @@ -15,5 +17,11 @@ export type { ContractStatusUpdate, MilestoneStatusUpdate, } from './mapper' -export { getDefaultMaxRetries, getBackoffDelay, ESCROW_EVENT_TOPIC_PREFIX } from './types' +export { + CANONICAL_SOROBAN_EVENTS, + getDefaultMaxRetries, + getBackoffDelay, + normalizeSorobanEvent, + ESCROW_EVENT_TOPIC_PREFIX, +} from './types' diff --git a/lib/contract-sync/listener.ts b/lib/contract-sync/listener.ts index 382fb73..c45cb1e 100644 --- a/lib/contract-sync/listener.ts +++ b/lib/contract-sync/listener.ts @@ -1,4 +1,8 @@ import Server from '@stellar/stellar-sdk' +import { + CANONICAL_SOROBAN_EVENTS, + normalizeSorobanEvent, +} from './types' import type { SorobanContractEvent, SorobanEventPayload } from './types' export type EventCallback = (payload: SorobanEventPayload) => void | Promise @@ -30,13 +34,10 @@ export class SorobanEventListener { private callback: EventCallback | null = null private readonly onCheckpoint: CheckpointCallback | null private timer: ReturnType | null = null - private lastLedger: number = 0 + private lastLedger = 0 private running = false - private readonly EVENT_NAMES: SorobanContractEvent[] = [ - 'init', 'fund', 'submit', 'approve', 'confirm', - 'release', 'refund', 'dispute', 'resolve', 'expire', - ] + private readonly EVENT_NAMES = CANONICAL_SOROBAN_EVENTS constructor(options: SorobanListenerOptions) { this.server = new Server(options.rpcUrl) @@ -54,11 +55,6 @@ export class SorobanEventListener { this.callback = cb } - /** - * Seeds the resume point before `start()` is called (e.g. from a - * checkpoint persisted by a previous run). No-op once the listener is - * already running. - */ setInitialLedger(ledgerSequence: number): void { if (!this.running && ledgerSequence > 0) { this.lastLedger = ledgerSequence @@ -69,14 +65,11 @@ export class SorobanEventListener { if (this.running) return this.running = true - // Only fall back to "start from now" when we have no persisted checkpoint - // to resume from — resuming from a checkpoint means events emitted while - // the listener was offline still get picked up on the first poll. if (this.lastLedger === 0) { try { const info = await this.server.getLatestLedger() this.lastLedger = info.sequence - } catch (err) { + } catch { console.warn('[SorobanListener] Could not get latest ledger, starting from 0') } } @@ -136,23 +129,13 @@ export class SorobanEventListener { try { const events = await this.server.getEvents({ startLedger: startSeq, - filters: [ - { - type: 'contract', - contractIds: [contractAddress], - }, - ], - pagination: { - limit: 100, - }, + filters: [{ type: 'contract', contractIds: [contractAddress] }], + pagination: { limit: 100 }, }) for (const event of events.events) { const parsed = this.parseSorobanEvent(event, contractAddress) if (parsed) { - // Awaited so the checkpoint only advances once the event is - // durably enqueued/logged — otherwise a crash between "advance - // checkpoint" and "persist event" would permanently drop it. await this.callback!(parsed) } } @@ -171,40 +154,33 @@ export class SorobanEventListener { if (!topic || topic.length === 0) return null const eventName = this.decodeEventName(topic[0]) - if (!eventName || !this.EVENT_NAMES.includes(eventName as SorobanContractEvent)) { - return null - } + if (!eventName || !this.EVENT_NAMES.includes(eventName)) return null const rawData = event.value ?? event.data ?? [] const data = Array.isArray(rawData) ? rawData : [rawData] - - let milestoneId: number | undefined - let amount: string | undefined - - if (eventName === 'fund') { - amount = this.extractAmount(data) - } else if (['submit', 'approve', 'confirm'].includes(eventName)) { - milestoneId = this.extractMilestoneId(data) - } else if (['release', 'refund', 'expire'].includes(eventName)) { - milestoneId = this.extractMilestoneId(data) - amount = this.extractAmount(data, 1) - } else if (eventName === 'dispute' || eventName === 'resolve') { - milestoneId = this.extractMilestoneId(data) - } else if (eventName === 'init') { - milestoneId = undefined - } + const legacy = this.isLegacyTopic(topic[0]) + const milestoneId = this.extractMilestoneId(eventName, topic, data, legacy) + const disputeId = this.extractDisputeId(eventName, topic, data, legacy) + const amount = this.extractEventField(eventName, data, 'amount', legacy) + const actor = this.extractActor(eventName, topic, data, legacy) + const recipient = this.extractEventField(eventName, data, 'recipient', legacy) + const support = this.extractBooleanField(data, 'support') + const releaseToFreelancer = this.extractBooleanField(data, 'release_to_freelancer') return { event: eventName as SorobanContractEvent, contractAddress, ledgerSequence: event.ledger ?? event.ledgerSequence ?? 0, - timestamp: event.ledgerClosedAt - ? new Date(event.ledgerClosedAt).getTime() - : Date.now(), + timestamp: event.ledgerClosedAt ? new Date(event.ledgerClosedAt).getTime() : Date.now(), txHash: event.txHash ?? event.id ?? 'unknown', data, milestoneId, + disputeId, amount, + actor, + recipient, + support, + releaseToFreelancer, } } catch (err) { console.error('[SorobanListener] Failed to parse event:', err) @@ -212,29 +188,94 @@ export class SorobanEventListener { } } - private decodeEventName(topicPart: any): string | null { - if (typeof topicPart === 'string') return topicPart.toLowerCase() - if (typeof topicPart === 'object' && topicPart !== null) { - if (topicPart.symbol) return topicPart.symbol.toLowerCase() - if (topicPart.toString) return topicPart.toString().toLowerCase() + private decodeEventName(topicPart: any): ReturnType { + if (typeof topicPart === 'string') return normalizeSorobanEvent(topicPart) + if (topicPart && typeof topicPart === 'object') { + if (typeof topicPart.symbol === 'string') return normalizeSorobanEvent(topicPart.symbol) + if (typeof topicPart.toString === 'function') return normalizeSorobanEvent(topicPart.toString()) } return null } - private extractMilestoneId(data: any[], index = 0): number | undefined { - const val = data[index] - if (typeof val === 'number') return val - if (typeof val === 'string') return parseInt(val, 10) - if (val?.toNumber) return val.toNumber() - if (val?.toString) return parseInt(val.toString(), 10) + private isLegacyTopic(topicPart: any): boolean { + if (typeof topicPart !== 'string') return false + return ['init', 'fund', 'submit', 'approve', 'confirm', 'release', 'refund', 'dispute', 'resolve', 'expire'] + .includes(topicPart.toLowerCase()) + } + + private extractMilestoneId( + eventName: string, + topic: any[], + data: any[], + legacy: boolean + ): number | undefined { + if (['milestone_submitted', 'milestone_approved', 'milestone_confirmed', 'payment_released', 'dispute_raised', 'refund_issued', 'dispute_resolved', 'milestone_expired', 'dispute_created', 'vote_cast', 'stake_claimed'].includes(eventName)) { + return this.extractNumber(topic[legacy ? 1 : 2]) ?? this.extractNumber(data[0]) + } + return undefined + } + + private extractDisputeId( + eventName: string, + topic: any[], + data: any[], + legacy: boolean + ): number | undefined { + if (!['dispute_created', 'vote_cast', 'stake_claimed'].includes(eventName)) return undefined + return this.extractNumber(topic[legacy ? 1 : 2]) ?? this.extractNumber(data[0]) + } + + private extractActor(eventName: string, topic: any[], data: any[], legacy: boolean): string | undefined { + if (legacy) return undefined + return this.extractString(topic[3] ?? topic[2]) ?? this.extractEventField(eventName, data, 'actor', false) + } + + private extractEventField( + eventName: string, + data: any[], + field: string, + legacy: boolean + ): string | undefined { + if (legacy) { + if (field === 'amount' && ['escrow_funded'].includes(eventName)) return this.extractString(data[0]) + if (field === 'amount' && ['payment_released', 'refund_issued', 'milestone_expired'].includes(eventName)) return this.extractString(data[1]) + return undefined + } + + const value = data[0] + if (value && typeof value === 'object' && !Array.isArray(value)) { + return this.extractString(value[field] ?? value[this.toCamelCase(field)]) + } + if (field === 'amount') return this.extractString(data[0]) return undefined } - private extractAmount(data: any[], index = 0): string | undefined { - const val = data[index] - if (typeof val === 'string') return val - if (typeof val === 'number') return String(val) - if (val?.toString) return val.toString() + private extractBooleanField(data: any[], field: string): boolean | undefined { + const value = data[0] + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const result = value[field] ?? value[this.toCamelCase(field)] + return typeof result === 'boolean' ? result : undefined + } + + private extractNumber(value: any): number | undefined { + if (typeof value === 'number') return value + if (typeof value === 'string' && /^-?\d+$/.test(value)) return Number(value) + if (value?.toNumber) return value.toNumber() + if (value?.toString) { + const parsed = Number(value.toString()) + return Number.isFinite(parsed) ? parsed : undefined + } + return undefined + } + + private extractString(value: any): string | undefined { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'bigint') return String(value) + if (value?.toString) return value.toString() return undefined } + + private toCamelCase(value: string): string { + return value.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()) + } } diff --git a/lib/contract-sync/mapper.ts b/lib/contract-sync/mapper.ts index c8d000b..b4a9d23 100644 --- a/lib/contract-sync/mapper.ts +++ b/lib/contract-sync/mapper.ts @@ -1,4 +1,8 @@ +import { + normalizeSorobanEvent, +} from './types' import type { + CanonicalSorobanContractEvent, SorobanContractEvent, SorobanEventPayload, } from './types' @@ -40,8 +44,10 @@ function nowISO(): string { } export function mapEventToAction(event: SorobanContractEvent, data: SorobanEventPayload): SyncAction { - switch (event) { - case 'init': + const normalizedEvent = normalizeSorobanEvent(String(event)) + + switch (normalizedEvent as CanonicalSorobanContractEvent | null) { + case 'escrow_created': return { kind: 'noop', contractUpdate: null, @@ -50,7 +56,7 @@ export function mapEventToAction(event: SorobanContractEvent, data: SorobanEvent disputeInfo: null, } - case 'fund': + case 'escrow_funded': return { kind: 'update_contract', contractUpdate: { @@ -64,7 +70,7 @@ export function mapEventToAction(event: SorobanContractEvent, data: SorobanEvent disputeInfo: null, } - case 'submit': + case 'milestone_submitted': return { kind: 'update_milestone', contractUpdate: null, @@ -76,19 +82,8 @@ export function mapEventToAction(event: SorobanContractEvent, data: SorobanEvent disputeInfo: null, } - case 'approve': - return { - kind: 'update_milestone', - contractUpdate: null, - milestoneUpdate: { - status: 'approved', - approvedAt: nowISO(), - }, - milestoneId: data.milestoneId ?? null, - disputeInfo: null, - } - - case 'confirm': + case 'milestone_approved': + case 'milestone_confirmed': return { kind: 'update_milestone', contractUpdate: null, @@ -100,7 +95,7 @@ export function mapEventToAction(event: SorobanContractEvent, data: SorobanEvent disputeInfo: null, } - case 'release': + case 'payment_released': return { kind: 'update_both', contractUpdate: { @@ -116,7 +111,7 @@ export function mapEventToAction(event: SorobanContractEvent, data: SorobanEvent disputeInfo: null, } - case 'refund': + case 'refund_issued': return { kind: 'update_both', contractUpdate: { @@ -133,7 +128,7 @@ export function mapEventToAction(event: SorobanContractEvent, data: SorobanEvent disputeInfo: null, } - case 'dispute': + case 'dispute_raised': return { kind: 'update_both', contractUpdate: { @@ -149,22 +144,37 @@ export function mapEventToAction(event: SorobanContractEvent, data: SorobanEvent }, } - case 'resolve': + case 'dispute_resolved': { + const resolvedToFreelancer = data.releaseToFreelancer !== false return { kind: 'update_both', contractUpdate: { - contractStatus: 'completed', - completedAt: nowISO(), - }, - milestoneUpdate: { - status: 'paid', - paidAt: nowISO(), + escrowStatus: resolvedToFreelancer ? 'fully_released' : 'refunded', + contractStatus: resolvedToFreelancer ? 'completed' : 'cancelled', + ...(resolvedToFreelancer + ? { completedAt: nowISO() } + : { cancelledAt: nowISO(), cancelledReason: 'Dispute resolved in client favor' }), }, + milestoneUpdate: resolvedToFreelancer + ? { status: 'paid', paidAt: nowISO() } + : { status: 'refunded', rejectionReason: 'Dispute resolved in client favor' }, milestoneId: data.milestoneId ?? null, disputeInfo: null, } + } + + case 'dispute_created': + case 'vote_cast': + case 'stake_claimed': + return { + kind: 'noop', + contractUpdate: null, + milestoneUpdate: null, + milestoneId: null, + disputeInfo: null, + } - case 'expire': + case 'milestone_expired': return { kind: 'update_milestone', contractUpdate: null, diff --git a/lib/contract-sync/types.ts b/lib/contract-sync/types.ts index 08e51d5..1c73c28 100644 --- a/lib/contract-sync/types.ts +++ b/lib/contract-sync/types.ts @@ -1,4 +1,28 @@ -export type SorobanContractEvent = +/** + * Canonical event names emitted by the Soroban contracts. + * + * These values intentionally use snake_case because they are the stable wire + * representation used by Soroban symbols, the event indexer, and Postgres. + * The PascalCase names in the contract source become these symbols through + * Soroban's contract-event macro. + */ +export type CanonicalSorobanContractEvent = + | 'escrow_created' + | 'escrow_funded' + | 'milestone_submitted' + | 'milestone_approved' + | 'milestone_confirmed' + | 'payment_released' + | 'dispute_raised' + | 'refund_issued' + | 'dispute_resolved' + | 'milestone_expired' + | 'dispute_created' + | 'vote_cast' + | 'stake_claimed' + +/** Legacy topics emitted by contracts deployed before event standardization. */ +export type LegacySorobanContractEvent = | 'init' | 'fund' | 'submit' @@ -10,6 +34,58 @@ export type SorobanContractEvent = | 'resolve' | 'expire' +export type SorobanContractEvent = CanonicalSorobanContractEvent | LegacySorobanContractEvent + +export const CANONICAL_SOROBAN_EVENTS: readonly CanonicalSorobanContractEvent[] = [ + 'escrow_created', + 'escrow_funded', + 'milestone_submitted', + 'milestone_approved', + 'milestone_confirmed', + 'payment_released', + 'dispute_raised', + 'refund_issued', + 'dispute_resolved', + 'milestone_expired', + 'dispute_created', + 'vote_cast', + 'stake_claimed', +] + +const EVENT_ALIASES: Record = { + // Legacy topics. + init: 'escrow_created', + fund: 'escrow_funded', + submit: 'milestone_submitted', + approve: 'milestone_approved', + confirm: 'milestone_confirmed', + release: 'payment_released', + refund: 'refund_issued', + dispute: 'dispute_raised', + resolve: 'dispute_resolved', + expire: 'milestone_expired', + // Contract-event struct names, as returned by Soroban RPC symbols. + escrowcreated: 'escrow_created', + escrowfunded: 'escrow_funded', + milestonesubmitted: 'milestone_submitted', + milestoneapproved: 'milestone_approved', + milestoneconfirmed: 'milestone_confirmed', + paymentreleased: 'payment_released', + disputeraised: 'dispute_raised', + refundissued: 'refund_issued', + disputeresolved: 'dispute_resolved', + milestoneexpired: 'milestone_expired', + disputecreated: 'dispute_created', + votecast: 'vote_cast', + stakeclaimed: 'stake_claimed', +} + +/** Convert a canonical, legacy, or PascalCase topic to the wire event name. */ +export function normalizeSorobanEvent(topic: string): CanonicalSorobanContractEvent | null { + const normalized = topic.replace(/([a-z])([A-Z])/g, '$1_$2').replace(/-/g, '_').toLowerCase() + return EVENT_ALIASES[normalized] ?? EVENT_ALIASES[normalized.replace(/_/g, '')] ?? null +} + export type SyncStatus = 'pending' | 'processing' | 'success' | 'failed' | 'dead_letter' export interface SorobanEventPayload { @@ -20,7 +96,12 @@ export interface SorobanEventPayload { txHash: string data: unknown[] milestoneId?: number + disputeId?: number amount?: string + actor?: string + recipient?: string + support?: boolean + releaseToFreelancer?: boolean } export interface ContractSyncLog { @@ -63,9 +144,9 @@ export function getBackoffDelay(retryCount: number): number { * while an item is in memory). */ export function buildSyncDedupeKey( - payload: Pick + payload: Pick ): string { - return `${payload.txHash}:${payload.event}:${payload.milestoneId ?? 0}` + return `${payload.txHash}:${payload.event}:${payload.milestoneId ?? payload.disputeId ?? 0}` } export const ESCROW_EVENT_TOPIC_PREFIX = 'escrow_event' diff --git a/lib/db/migrations/009_contract_event_standardization.sql b/lib/db/migrations/009_contract_event_standardization.sql new file mode 100644 index 0000000..18d9710 --- /dev/null +++ b/lib/db/migrations/009_contract_event_standardization.sql @@ -0,0 +1,21 @@ +-- Contract event standardization for issue #191. +-- Existing short names remain valid for historical rows and old deployments. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'contract_sync_event_type') THEN + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'escrow_created'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'escrow_funded'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'milestone_submitted'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'milestone_approved'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'milestone_confirmed'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'payment_released'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'dispute_raised'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'refund_issued'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'dispute_resolved'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'milestone_expired'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'dispute_created'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'vote_cast'; + ALTER TYPE contract_sync_event_type ADD VALUE IF NOT EXISTS 'stake_claimed'; + END IF; +END; +$$;