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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,11 @@ STELLAR_NETWORK=testnet

# Request timeout (ms) for Soroban RPC calls. Default: 10000
SOROBAN_RPC_TIMEOUT_MS=10000

# ─── Escrow indexer ─────────────────────────────────────────────────────────────
# Deployed escrow Soroban contract id (the "C..." address) whose events the
# escrow indexer subscribes to.
ESCROW_CONTRACT_ID=

# Event topic emitted by the escrow contract when funds are locked. Default: escrow_funded
ESCROW_FUNDED_EVENT_TOPIC=escrow_funded
23 changes: 23 additions & 0 deletions src/config/escrow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Configuration for the escrow smart-contract indexer.
*
* ESCROW_CONTRACT_ID is the deployed Soroban contract address whose events
* (e.g. `escrow_funded`) the indexer subscribes to. It is intentionally
* separate from the generic Stellar/Soroban RPC config since it identifies
* a specific contract instance rather than network connection details.
*/
export interface EscrowIndexerConfig {
/** Deployed escrow contract id (Soroban "C..." address). */
contractId: string;
/** Event topic emitted by the contract when an escrow is funded. */
fundedEventTopic: string;
}

function resolveEscrowIndexerConfig(): EscrowIndexerConfig {
return {
contractId: process.env.ESCROW_CONTRACT_ID?.trim() ?? '',
fundedEventTopic: process.env.ESCROW_FUNDED_EVENT_TOPIC?.trim() || 'escrow_funded',
};
}

export const escrowIndexerConfig: EscrowIndexerConfig = resolveEscrowIndexerConfig();
60 changes: 60 additions & 0 deletions src/controllers/escrow.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Request, Response, NextFunction } from 'express';
import httpStatus from 'http-status-codes';
import { escrowService } from '../services/escrow.service';
import { syncEscrowFundedEvents } from '../indexer/escrowHandlers';
import { AppError } from '../utils/AppError';

/**
* EscrowController handles HTTP requests for escrow records and for
* manually triggering the escrow_funded indexer.
*
* Soroban has no push/webhook mechanism, so the sync endpoint lets
* operators (or a scheduled job) trigger a poll of the RPC node for new
* `escrow_funded` events on demand.
*/
export class EscrowController {
async getByDelivery(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const escrow = await escrowService.getByDeliveryId(req.params.deliveryId);
res.status(httpStatus.OK).json({
status: 'success',
data: escrow,
});
} catch (error) {
next(error);
}
}

async getByContract(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const escrow = await escrowService.getByContractId(req.params.contractId);
res.status(httpStatus.OK).json({
status: 'success',
data: escrow,
});
} catch (error) {
next(error);
}
}

async sync(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const startLedger = Number(req.body.startLedger);
if (!Number.isInteger(startLedger) || startLedger < 0) {
throw new AppError('startLedger must be a non-negative integer', httpStatus.BAD_REQUEST);
}

const contractId: string | undefined = req.body.contractId;
const summary = await syncEscrowFundedEvents(startLedger, contractId);

res.status(httpStatus.OK).json({
status: 'success',
data: summary,
});
} catch (error) {
next(error);
}
}
}

export const escrowController = new EscrowController();
170 changes: 170 additions & 0 deletions src/indexer/escrowHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { rpc as StellarRpc, scValToNative, xdr } from '@stellar/stellar-sdk';
import { sorobanRpcClient } from '../config/stellar';
import { escrowIndexerConfig } from '../config/escrow';
import { escrowService, EscrowFundedInput } from '../services/escrow.service';
import logger from '../config/logger';

/**
* Native representation of the `escrow_funded` event emitted by the escrow
* Soroban contract.
*
* Expected on-chain shape:
* topics: [Symbol("escrow_funded"), Bytes|String delivery_id]
* data: Map { amount: i128, asset: Symbol|Address, funded_by: Address }
*/
export interface EscrowFundedEventData {
deliveryId: string;
amount: number;
asset: string;
fundedBy?: string;
}

/** Result of processing a single raw contract event through the handler. */
export type EscrowEventProcessResult =
| { status: 'processed'; ledger: number; transactionHash: string }
| { status: 'ignored'; ledger: number; reason: string };

/** Summary returned after syncing a batch of `escrow_funded` events. */
export interface EscrowSyncSummary {
latestLedger: number;
cursor: string;
processed: number;
ignored: number;
results: EscrowEventProcessResult[];
}

/**
* Parse the `escrow_funded` event's topics/value into a typed payload.
*
* Returns `null` when the event does not match the expected shape (e.g. the
* data map is missing required keys), so the caller can skip it instead of
* crashing the indexer on an unrelated or malformed event.
*/
export function parseEscrowFundedEvent(
event: StellarRpc.Api.EventResponse,
): EscrowFundedEventData | null {
try {
const [, deliveryIdTopic] = event.topic;
if (!deliveryIdTopic) {
return null;
}

const deliveryId = scValToNative(deliveryIdTopic) as unknown;
if (typeof deliveryId !== 'string' || deliveryId.length === 0) {
return null;
}

const data = scValToNative(event.value) as Record<string, unknown>;

const rawAmount = data?.amount;
const amount =
typeof rawAmount === 'bigint'
? Number(rawAmount)
: typeof rawAmount === 'number'
? rawAmount
: NaN;

const asset = data?.asset;
const fundedBy = data?.funded_by;

if (!Number.isFinite(amount) || typeof asset !== 'string') {
return null;
}

return {
deliveryId,
amount,
asset,
fundedBy: typeof fundedBy === 'string' ? fundedBy : undefined,
};
} catch (err) {
logger.warn(
`[EscrowHandlers] Failed to parse escrow_funded event id=${event.id}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}

/**
* Handle a single raw `escrow_funded` contract event: parse it and persist
* the resulting escrow/delivery state via the service layer.
*/
export async function handleEscrowFundedEvent(
event: StellarRpc.Api.EventResponse,
contractId: string,
): Promise<EscrowEventProcessResult> {
const parsed = parseEscrowFundedEvent(event);

if (!parsed) {
return { status: 'ignored', ledger: event.ledger, reason: 'unparseable event payload' };
}

const input: EscrowFundedInput = {
contractId,
deliveryId: parsed.deliveryId,
amount: parsed.amount,
asset: parsed.asset,
fundedBy: parsed.fundedBy,
transactionHash: event.txHash,
ledger: event.ledger,
};

await escrowService.recordEscrowFunded(input);

return { status: 'processed', ledger: event.ledger, transactionHash: event.txHash };
}

/**
* Poll the Soroban RPC node for `escrow_funded` events emitted by the escrow
* contract and process each one.
*
* @param startLedger - Ledger to start querying from (inclusive). Callers
* are responsible for persisting the returned `cursor`
* to resume from on the next poll.
* @param contractId - Escrow contract id to query. Defaults to
* `ESCROW_CONTRACT_ID` from the environment.
*/
export async function syncEscrowFundedEvents(
startLedger: number,
contractId: string = escrowIndexerConfig.contractId,
): Promise<EscrowSyncSummary> {
if (!contractId) {
throw new Error('No escrow contract id configured. Set ESCROW_CONTRACT_ID.');
}

const response = await sorobanRpcClient.getEvents({
startLedger,
filters: [
{
type: 'contract',
contractIds: [contractId],
topics: [[xdr.ScVal.scvSymbol(escrowIndexerConfig.fundedEventTopic).toXDR('base64'), '*']],
},
],
});

const results: EscrowEventProcessResult[] = [];

for (const event of response.events) {
const result = await handleEscrowFundedEvent(event, contractId);
results.push(result);
}

const processed = results.filter((r) => r.status === 'processed').length;
const ignored = results.length - processed;

logger.info(
`[EscrowHandlers] Synced escrow_funded events — contract=${contractId} ` +
`processed=${processed} ignored=${ignored} latestLedger=${response.latestLedger}`,
);

return {
latestLedger: response.latestLedger,
cursor: response.cursor,
processed,
ignored,
results,
};
}
1 change: 1 addition & 0 deletions src/models/Delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface IDelivery extends Document {

export enum DeliveryStatus {
PENDING = 'pending',
FUNDED = 'funded',
ASSIGNED = 'assigned',
IN_PROGRESS = 'in_progress',
COMPLETED = 'completed',
Expand Down
20 changes: 20 additions & 0 deletions src/routes/escrow.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Router } from 'express';
import { escrowController } from '../controllers/escrow.controller';

/**
* Escrow routes.
*
* Mounted at /api/v1/escrow by the root router.
*
* Endpoints:
* GET /api/v1/escrow/delivery/:deliveryId — escrow record for a delivery
* GET /api/v1/escrow/contract/:contractId — escrow record for a contract id
* POST /api/v1/escrow/sync — manually trigger an escrow_funded indexer poll
*/
const router = Router();

router.get('/delivery/:deliveryId', escrowController.getByDelivery.bind(escrowController));
router.get('/contract/:contractId', escrowController.getByContract.bind(escrowController));
router.post('/sync', escrowController.sync.bind(escrowController));

export default router;
4 changes: 2 additions & 2 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import authRoutes from './authRoutes';
import deliveryCrudRoutes from './delivery.routes';
import deliveryStatusRoutes from './deliveries';
import adminRoutes from './adminRoutes';
import disputeRoutes from './disputeRoutes';
import escrowRoutes from './escrow.routes';

const router = Router();

router.use('/v1/auth', authRoutes);
router.use('/v1/deliveries', deliveryCrudRoutes);
router.use('/v1/deliveries', deliveryStatusRoutes);
router.use('/v1/admin', adminRoutes);
router.use('/v1/disputes', disputeRoutes);
router.use('/v1/escrow', escrowRoutes);

export default router;
Loading
Loading