diff --git a/api/src/app.ts b/api/src/app.ts index 12f197c7..1b91eed2 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -2,6 +2,7 @@ import express, { Application, Request, Response, NextFunction } from 'express'; import helmet from 'helmet'; import cors from 'cors'; import rateLimit from 'express-rate-limit'; +import { Pool } from 'pg'; import currenciesRouter from './routes/currencies'; import limitsRouter from './routes/limits'; import { createAnchorsRouter } from './routes/anchors'; @@ -9,6 +10,7 @@ import docsRouter from './routes/docs'; import settlementsRouter from './routes/settlements'; import { createRemittancesRouter, RemittancesRouterOptions } from './routes/remittances'; import { createAdminRouter } from './routes/admin'; +import { createAnalyticsRouter } from './routes/analytics'; import { ErrorResponse } from './types'; import { AnchorStore } from './db/anchorStore'; import { Server as SocketIOServer } from 'socket.io'; @@ -78,6 +80,14 @@ export function createApp(options: AppOptions = {}): Application { // Admin utilities — read-only operations (simulate-upgrade, etc.) app.use('/api/admin', createAdminRouter()); + // Corridor analytics (Issue #482) + const analyticsPool = process.env.DATABASE_URL + ? new Pool({ connectionString: process.env.DATABASE_URL, max: 5 }) + : null; + if (analyticsPool) { + app.use('/api/analytics', createAnalyticsRouter(analyticsPool)); + } + // API documentation app.use('/api/docs', docsRouter); diff --git a/api/src/routes/admin.ts b/api/src/routes/admin.ts index 030383f2..f4c4ad23 100644 --- a/api/src/routes/admin.ts +++ b/api/src/routes/admin.ts @@ -1,5 +1,7 @@ import { Router, Request, Response } from 'express'; import { ErrorResponse } from '../types'; +import { Pool } from 'pg'; +import { AdminConfirmationService, HighRiskOperation } from '../../../backend/src/admin-confirmation'; function timestamp(): string { return new Date().toISOString(); @@ -92,6 +94,15 @@ function simulateUpgrade(wasmHashHex: string): { }; } +const HIGH_RISK_OPS: HighRiskOperation[] = ['withdraw_fees', 'remove_agent', 'update_fee']; + +function getConfirmationService(): AdminConfirmationService | null { + const dbUrl = process.env.DATABASE_URL; + if (!dbUrl) return null; + const pool = new Pool({ connectionString: dbUrl }); + return new AdminConfirmationService(pool); +} + export function createAdminRouter(): Router { const router = Router(); @@ -207,5 +218,93 @@ export function createAdminRouter(): Router { }); }); + // ── Multi-step admin confirmation (#481) ────────────────────────────────── + + /** + * POST /api/admin/actions + * Initiate a high-risk operation requiring a second admin to confirm. + */ + router.post('/actions', async (req: Request, res: Response) => { + if (!isAdminAuthorized(req)) { + return sendError(res, 401, 'Admin authentication required', 'UNAUTHORIZED'); + } + + const { operation, initiated_by, params } = req.body as Record; + + if (!operation || !HIGH_RISK_OPS.includes(operation as HighRiskOperation)) { + return sendError(res, 400, `operation must be one of: ${HIGH_RISK_OPS.join(', ')}`, 'INVALID_OPERATION'); + } + if (typeof initiated_by !== 'string' || !initiated_by) { + return sendError(res, 400, 'initiated_by is required', 'MISSING_FIELD'); + } + + const svc = getConfirmationService(); + if (!svc) return sendError(res, 503, 'Database not configured', 'DB_UNAVAILABLE'); + + try { + await svc.initTable(); + const action = await svc.initiate( + operation as HighRiskOperation, + initiated_by, + (params as Record) ?? {} + ); + return res.status(201).json({ success: true, data: action, timestamp: timestamp() }); + } catch (err) { + return sendError(res, 500, err instanceof Error ? err.message : 'Failed to initiate action', 'INITIATE_FAILED'); + } + }); + + /** + * GET /api/admin/actions + * List all pending (unconfirmed, non-expired) high-risk actions. + */ + router.get('/actions', async (req: Request, res: Response) => { + if (!isAdminAuthorized(req)) { + return sendError(res, 401, 'Admin authentication required', 'UNAUTHORIZED'); + } + + const svc = getConfirmationService(); + if (!svc) return sendError(res, 503, 'Database not configured', 'DB_UNAVAILABLE'); + + try { + await svc.initTable(); + const actions = await svc.listPending(); + return res.json({ success: true, data: actions, timestamp: timestamp() }); + } catch (err) { + return sendError(res, 500, err instanceof Error ? err.message : 'Failed to list actions', 'LIST_FAILED'); + } + }); + + /** + * POST /api/admin/actions/:id/confirm + * Second admin confirms a pending high-risk action. + */ + router.post('/actions/:id/confirm', async (req: Request, res: Response) => { + if (!isAdminAuthorized(req)) { + return sendError(res, 401, 'Admin authentication required', 'UNAUTHORIZED'); + } + + const { confirmed_by } = req.body as Record; + if (typeof confirmed_by !== 'string' || !confirmed_by) { + return sendError(res, 400, 'confirmed_by is required', 'MISSING_FIELD'); + } + + const svc = getConfirmationService(); + if (!svc) return sendError(res, 503, 'Database not configured', 'DB_UNAVAILABLE'); + + try { + await svc.initTable(); + const action = await svc.confirm(req.params.id, confirmed_by); + return res.json({ success: true, data: action, timestamp: timestamp() }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Confirmation failed'; + const isNotFound = msg.includes('not found'); + const isExpired = msg.includes('expired'); + const isSelf = msg.includes('cannot confirm'); + const status = isNotFound ? 404 : isExpired || isSelf ? 409 : 500; + return sendError(res, status, msg, 'CONFIRM_FAILED'); + } + }); + return router; } diff --git a/api/src/routes/analytics.ts b/api/src/routes/analytics.ts new file mode 100644 index 00000000..673b5e57 --- /dev/null +++ b/api/src/routes/analytics.ts @@ -0,0 +1,136 @@ +/** + * GET /api/analytics/corridors (#482) + * + * Returns remittance volume, fees, and success/failure rates per corridor + * (currency/country pair) sourced from the contract_events table. + * + * Query params: + * range {string} - Time range: 7d | 30d | 90d (default: 30d) + */ + +import { Router, Request, Response } from 'express'; +import { Pool } from 'pg'; +import { ErrorResponse } from '../types'; + +export interface CorridorStat { + source_currency: string; + destination_country: string; + total_volume: number; + transaction_count: number; + success_count: number; + failure_count: number; + success_rate: number; + avg_fee: number; + total_fees: number; +} + +export interface CorridorAnalyticsResponse { + success: true; + data: { + range: string; + corridors: CorridorStat[]; + top_by_volume: CorridorStat[]; + }; + timestamp: string; +} + +const VALID_RANGES: Record = { + '7d': '7 days', + '30d': '30 days', + '90d': '90 days', +}; + +function timestamp(): string { + return new Date().toISOString(); +} + +function sendError(res: Response, status: number, message: string, code: string): Response { + return res.status(status).json({ success: false, error: { message, code }, timestamp: timestamp() }); +} + +export function createAnalyticsRouter(pool: Pool): Router { + const router = Router(); + + router.get('/corridors', async (req: Request, res: Response) => { + const rangeParam = typeof req.query.range === 'string' ? req.query.range : '30d'; + + if (!VALID_RANGES[rangeParam]) { + return sendError(res, 400, `range must be one of: ${Object.keys(VALID_RANGES).join(', ')}`, 'INVALID_RANGE'); + } + + const interval = VALID_RANGES[rangeParam]; + + try { + // contract_events stores raw_data JSONB with currency/country fields when available. + // We aggregate by (source_currency, destination_country) from raw_data. + const result = await pool.query<{ + source_currency: string; + destination_country: string; + total_volume: string; + transaction_count: string; + success_count: string; + failure_count: string; + avg_fee: string; + total_fees: string; + }>( `SELECT + COALESCE(raw_data->>'source_currency', raw_data->>'currency', 'USDC') AS source_currency, + COALESCE(raw_data->>'destination_country', raw_data->>'country', 'UNKNOWN') AS destination_country, + SUM(COALESCE(amount, 0)) AS total_volume, + COUNT(*) AS transaction_count, + COUNT(*) FILTER (WHERE event_type = 'remittance_completed') AS success_count, + COUNT(*) FILTER (WHERE event_type IN ('remittance_failed', 'remittance_cancelled')) AS failure_count, + AVG(COALESCE(fee, 0)) AS avg_fee, + SUM(COALESCE(fee, 0)) AS total_fees + FROM contract_events + WHERE timestamp >= NOW() - INTERVAL '${interval}' + AND event_type IN ('remittance_created', 'remittance_completed', 'remittance_failed', 'remittance_cancelled') + GROUP BY source_currency, destination_country + ORDER BY total_volume DESC NULLS LAST`, + [] + ); + + const corridors: CorridorStat[] = result.rows.map((row: { + source_currency: string; + destination_country: string; + total_volume: string; + transaction_count: string; + success_count: string; + failure_count: string; + avg_fee: string; + total_fees: string; + }) => { + const total = parseInt(row.transaction_count, 10); + const success = parseInt(row.success_count, 10); + return { + source_currency: row.source_currency, + destination_country: row.destination_country, + total_volume: parseFloat(row.total_volume) || 0, + transaction_count: total, + success_count: success, + failure_count: parseInt(row.failure_count, 10), + success_rate: total > 0 ? Math.round((success / total) * 10000) / 100 : 0, + avg_fee: parseFloat(row.avg_fee) || 0, + total_fees: parseFloat(row.total_fees) || 0, + }; + }); + + const top_by_volume = [...corridors] + .sort((a, b) => b.total_volume - a.total_volume) + .slice(0, 10); + + const response: CorridorAnalyticsResponse = { + success: true, + data: { range: rangeParam, corridors, top_by_volume }, + timestamp: timestamp(), + }; + + return res.json(response); + } catch (err) { + // eslint-disable-next-line no-console + void err; + return sendError(res, 500, 'Failed to fetch corridor analytics', 'ANALYTICS_ERROR'); + } + }); + + return router; +} diff --git a/backend/src/admin-confirmation.ts b/backend/src/admin-confirmation.ts new file mode 100644 index 00000000..5c3664c0 --- /dev/null +++ b/backend/src/admin-confirmation.ts @@ -0,0 +1,154 @@ +/** + * Multi-step Admin Confirmation (#481) + * + * High-risk operations (withdraw_fees, remove_agent, update_fee) require + * a second admin to confirm before execution. Pending actions expire after 1 hour. + */ + +import { Pool } from 'pg'; +import { v4 as uuidv4 } from 'uuid'; +import { AdminAuditLogService } from './admin-audit-log'; + +export type HighRiskOperation = 'withdraw_fees' | 'remove_agent' | 'update_fee'; + +export interface PendingAdminAction { + id: string; + operation: HighRiskOperation; + initiated_by: string; + params: Record; + expires_at: Date; + confirmed_by: string | null; + confirmed_at: Date | null; + created_at: Date; +} + +const EXPIRY_HOURS = 1; + +export class AdminConfirmationService { + private auditLog: AdminAuditLogService; + + constructor(private pool: Pool) { + this.auditLog = new AdminAuditLogService(pool); + } + + /** Create the pending_admin_actions table if it doesn't exist. */ + async initTable(): Promise { + await this.pool.query(` + CREATE TABLE IF NOT EXISTS pending_admin_actions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + operation VARCHAR(50) NOT NULL, + initiated_by VARCHAR(56) NOT NULL, + params JSONB NOT NULL DEFAULT '{}', + expires_at TIMESTAMP NOT NULL, + confirmed_by VARCHAR(56), + confirmed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_paa_expires ON pending_admin_actions(expires_at); + CREATE INDEX IF NOT EXISTS idx_paa_operation ON pending_admin_actions(operation); + `); + } + + /** + * Initiate a high-risk operation. Returns the pending action ID. + * The initiating admin cannot also confirm. + */ + async initiate( + operation: HighRiskOperation, + initiatedBy: string, + params: Record + ): Promise { + const expiresAt = new Date(Date.now() + EXPIRY_HOURS * 60 * 60 * 1000); + + const result = await this.pool.query( + `INSERT INTO pending_admin_actions (operation, initiated_by, params, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [operation, initiatedBy, JSON.stringify(params), expiresAt] + ); + + const action = result.rows[0]; + + await this.auditLog.log({ + admin_address: initiatedBy, + action: `${operation}.initiated`, + target: action.id, + params_json: params, + tx_hash: null, + }); + + return action; + } + + /** + * Confirm a pending action. The confirming admin must differ from the initiator. + * Returns the confirmed action on success, throws on failure. + */ + async confirm( + actionId: string, + confirmingAdmin: string + ): Promise { + const existing = await this.get(actionId); + + if (!existing) { + throw new Error(`Pending action not found: ${actionId}`); + } + if (existing.confirmed_by) { + throw new Error('Action already confirmed'); + } + if (new Date() > existing.expires_at) { + throw new Error('Pending action has expired'); + } + if (existing.initiated_by === confirmingAdmin) { + throw new Error('The initiating admin cannot confirm their own action'); + } + + const result = await this.pool.query( + `UPDATE pending_admin_actions + SET confirmed_by = $1, confirmed_at = NOW() + WHERE id = $2 + RETURNING *`, + [confirmingAdmin, actionId] + ); + + const action = result.rows[0]; + + await this.auditLog.log({ + admin_address: confirmingAdmin, + action: `${existing.operation}.confirmed`, + target: actionId, + params_json: existing.params, + tx_hash: null, + }); + + return action; + } + + /** Fetch a single pending action by ID. */ + async get(id: string): Promise { + const result = await this.pool.query( + `SELECT * FROM pending_admin_actions WHERE id = $1`, + [id] + ); + return result.rows[0] ?? null; + } + + /** List all pending (unconfirmed, non-expired) actions. */ + async listPending(): Promise { + const result = await this.pool.query( + `SELECT * FROM pending_admin_actions + WHERE confirmed_by IS NULL AND expires_at > NOW() + ORDER BY created_at DESC` + ); + return result.rows; + } + + /** Delete expired unconfirmed actions (housekeeping). */ + async purgeExpired(): Promise { + const result = await this.pool.query( + `DELETE FROM pending_admin_actions + WHERE confirmed_by IS NULL AND expires_at <= NOW()` + ); + return result.rowCount ?? 0; + } +} diff --git a/backend/src/kyc-expiry-notifier.ts b/backend/src/kyc-expiry-notifier.ts new file mode 100644 index 00000000..37e143b3 --- /dev/null +++ b/backend/src/kyc-expiry-notifier.ts @@ -0,0 +1,74 @@ +/** + * KYC Expiry Notifier (#480) + * + * Queries user_kyc_status for records expiring within the next 7 days + * and dispatches a `kyc.expiry_warning` webhook to all subscribers. + */ + +import { Pool } from 'pg'; +import { v4 as uuidv4 } from 'uuid'; +import { IWebhookStore } from './webhooks/store'; +import { WebhookDispatcher } from './webhooks/dispatcher'; +import type { KycExpiryWarningPayload } from './webhooks/types'; + +const WARN_DAYS = 7; +const RENEWAL_BASE_URL = process.env.KYC_RENEWAL_BASE_URL ?? 'https://app.swiftremit.io/kyc/renew'; + +export class KycExpiryNotifier { + private dispatcher: WebhookDispatcher; + + constructor(private pool: Pool, store: IWebhookStore) { + this.dispatcher = new WebhookDispatcher(store); + } + + /** + * Find KYC records expiring in the next WARN_DAYS days and send warnings. + * Returns the number of notifications dispatched. + */ + async run(): Promise { + const result = await this.pool.query<{ + user_id: string; + anchor_id: string; + expires_at: Date; + }>( + `SELECT user_id, anchor_id, expires_at + FROM user_kyc_status + WHERE expires_at IS NOT NULL + AND expires_at > NOW() + AND expires_at <= NOW() + INTERVAL '${WARN_DAYS} days' + AND status = 'approved'` + ); + + let dispatched = 0; + + for (const row of result.rows) { + const expiresAt = new Date(row.expires_at); + const daysUntilExpiry = Math.ceil( + (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + + const payload: KycExpiryWarningPayload = { + event: 'kyc.expiry_warning', + id: uuidv4(), + timestamp: new Date().toISOString(), + data: { + user_id: row.user_id, + anchor_id: row.anchor_id, + expires_at: expiresAt.toISOString(), + days_until_expiry: daysUntilExpiry, + renewal_url: `${RENEWAL_BASE_URL}?user=${encodeURIComponent(row.user_id)}&anchor=${encodeURIComponent(row.anchor_id)}`, + }, + }; + + try { + await this.dispatcher.dispatch('kyc.expiry_warning', payload); + dispatched++; + } catch (err) { + console.error('KYC expiry notification failed', { user_id: row.user_id, anchor_id: row.anchor_id, err }); + } + } + + console.log(`KYC expiry notifier: ${dispatched} notification(s) dispatched`); + return dispatched; + } +} diff --git a/backend/src/scheduler.ts b/backend/src/scheduler.ts index 486b5c89..421302b7 100644 --- a/backend/src/scheduler.ts +++ b/backend/src/scheduler.ts @@ -6,6 +6,8 @@ import { KycService } from './kyc-service'; import { Sep24Service } from './sep24-service'; import { SorobanRpc, Keypair } from '@stellar/stellar-sdk'; import { SwiftRemitClient } from '../../sdk/src/client.js'; +import { KycExpiryNotifier } from './kyc-expiry-notifier'; +import { createWebhookStore } from './webhooks/store'; const verifier = new AssetVerifier(); const kycService = new KycService(); @@ -43,6 +45,12 @@ export async function startBackgroundJobs() { await extendContractStorageTtl(); }); + // Send KYC expiry warnings daily at 08:00 UTC + cron.schedule('0 8 * * *', async () => { + console.log('Starting KYC expiry notification job...'); + await notifyKycExpiries(); + }); + console.log('Background jobs scheduled'); } @@ -114,7 +122,6 @@ async function pollSep24Transactions() { /** * Extend contract storage TTLs to prevent data loss. - * * Calls `extend_storage_ttl` on the SwiftRemit contract using the admin keypair * configured via environment variables. Runs daily so TTLs never expire between * scheduled runs. @@ -154,3 +161,13 @@ async function extendContractStorageTtl() { console.error('Failed to extend contract storage TTLs:', error); } } + +async function notifyKycExpiries() { + try { + const store = createWebhookStore(pool); + const notifier = new KycExpiryNotifier(pool, store); + await notifier.run(); + } catch (error) { + console.error('Error in KYC expiry notification job:', error); + } +} diff --git a/backend/src/webhooks/service.ts b/backend/src/webhooks/service.ts index 13fdd68a..7634bf5a 100644 --- a/backend/src/webhooks/service.ts +++ b/backend/src/webhooks/service.ts @@ -56,6 +56,7 @@ export class WebhookService { 'remittance.completed', 'remittance.failed', 'remittance.cancelled', + 'kyc.expiry_warning', ]; for (const event of request.events) { diff --git a/backend/src/webhooks/types.ts b/backend/src/webhooks/types.ts index 8a7f67bf..436a43de 100644 --- a/backend/src/webhooks/types.ts +++ b/backend/src/webhooks/types.ts @@ -9,7 +9,8 @@ export type EventType = | 'remittance.updated' | 'remittance.completed' | 'remittance.failed' - | 'remittance.cancelled'; + | 'remittance.cancelled' + | 'kyc.expiry_warning'; export interface WebhookSubscriber { id: string; @@ -86,3 +87,16 @@ export interface DeadLetterRecord { createdAt: Date; replayedAt?: Date; } + +export interface KycExpiryWarningData { + user_id: string; + anchor_id: string; + expires_at: string; + days_until_expiry: number; + renewal_url: string; +} + +export interface KycExpiryWarningPayload extends WebhookPayload { + event: 'kyc.expiry_warning'; + data: KycExpiryWarningData; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 00ac57e4..6e5b6eab 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import CreateRemittance from './components/CreateRemittance' import RemittanceList from './components/RemittanceList' import AgentPanel from './components/AgentPanel' import ErrorBoundary from './components/ErrorBoundary' +import CorridorAnalytics from './components/CorridorAnalytics' function App() { const { t } = useTranslation() @@ -65,6 +66,10 @@ function App() { contractId={contractId} /> + + + + )} diff --git a/frontend/src/components/CorridorAnalytics.jsx b/frontend/src/components/CorridorAnalytics.jsx new file mode 100644 index 00000000..3a17522c --- /dev/null +++ b/frontend/src/components/CorridorAnalytics.jsx @@ -0,0 +1,117 @@ +import { useState, useEffect } from 'react'; + +const API_BASE = import.meta.env.VITE_API_URL ?? ''; +const RANGES = ['7d', '30d', '90d']; + +function BarChart({ data, valueKey, labelKey, label }) { + if (!data.length) return null; + const max = Math.max(...data.map((d) => d[valueKey])); + return ( +
+ {label} + {data.slice(0, 10).map((row, i) => ( +
+ + {row[labelKey]} + +
0 ? `${(row[valueKey] / max) * 200}px` : 0, + minWidth: 2, + }} + /> + {Number(row[valueKey]).toLocaleString()} +
+ ))} +
+ ); +} + +export default function CorridorAnalytics() { + const [range, setRange] = useState('30d'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + fetch(`${API_BASE}/api/analytics/corridors?range=${range}`) + .then((r) => r.json()) + .then((json) => { + if (json.success) setData(json.data); + else setError(json.error?.message ?? 'Unknown error'); + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [range]); + + const corridors = data?.corridors ?? []; + const corridorLabels = corridors.map((c) => `${c.source_currency}→${c.destination_country}`); + const withLabel = corridors.map((c, i) => ({ ...c, label: corridorLabels[i] })); + + return ( +
+

Corridor Analytics

+ +
+ {RANGES.map((r) => ( + + ))} +
+ + {loading &&

Loading…

} + {error &&

Error: {error}

} + + {!loading && !error && data && ( + <> + + + +
+ + + + {['Corridor', 'Volume', 'Txns', 'Success %', 'Avg Fee', 'Total Fees'].map((h) => ( + + ))} + + + + {corridors.map((c, i) => ( + + + + + + + + + ))} + {corridors.length === 0 && ( + + )} + +
{h}
{c.source_currency} → {c.destination_country}{Number(c.total_volume).toLocaleString()}{c.transaction_count}{c.success_rate}%{Number(c.avg_fee).toFixed(2)}{Number(c.total_fees).toLocaleString()}
No data for this period
+
+ + )} +
+ ); +} diff --git a/sdk/QUICKSTART.md b/sdk/QUICKSTART.md new file mode 100644 index 00000000..31b9ba32 --- /dev/null +++ b/sdk/QUICKSTART.md @@ -0,0 +1,144 @@ +# SwiftRemit SDK — Quickstart + +End-to-end Node.js example: connect wallet → create remittance → monitor status → confirm payout. + +## Prerequisites + +```bash +npm install @swiftremit/sdk @stellar/stellar-sdk +``` + +Set the following environment variables (or copy `.env.testnet` from the repo root): + +``` +CONTRACT_ID=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +SENDER_SECRET=SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +AGENT_SECRET=SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +USDC_TOKEN=CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA +``` + +## Complete example + +```typescript +import { SwiftRemitClient, toStroops, fromStroops } from "@swiftremit/sdk"; +import { Keypair, Asset, TransactionBuilder, Networks, Operation } from "@stellar/stellar-sdk"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +const client = new SwiftRemitClient({ + contractId: process.env.CONTRACT_ID!, + networkPassphrase: process.env.NETWORK_PASSPHRASE!, + rpcUrl: process.env.SOROBAN_RPC_URL!, +}); + +const senderKeypair = Keypair.fromSecret(process.env.SENDER_SECRET!); +const agentKeypair = Keypair.fromSecret(process.env.AGENT_SECRET!); + +async function main() { + // ── 1. Check agent is registered ────────────────────────────────────────── + const agentAddress = agentKeypair.publicKey(); + const isRegistered = await client.isAgentRegistered(senderKeypair.publicKey(), agentAddress); + if (!isRegistered) { + throw new Error(`Agent ${agentAddress} is not registered. Ask an admin to call registerAgent().`); + } + console.log("✓ Agent is registered"); + + // ── 2. Approve USDC spend (SAC allowance) ───────────────────────────────── + // The contract pulls USDC from the sender via token.transfer_from, so the + // sender must approve the contract to spend the amount + fee first. + // This step uses the Stellar Asset Contract (SAC) for USDC. + const amount = toStroops(50); // 50 USDC in stroops + console.log(`Approving ${fromStroops(amount)} USDC for contract…`); + // NOTE: In a real app use the USDC SAC's `approve` method here. + // Omitted for brevity — see the Stellar docs for SAC token approval. + + // ── 3. Create remittance ─────────────────────────────────────────────────── + console.log("Creating remittance…"); + let tx; + try { + tx = await client.createRemittance({ + sender: senderKeypair.publicKey(), + agent: agentAddress, + amount, + token: process.env.USDC_TOKEN, + }); + } catch (err: any) { + console.error("createRemittance failed:", err.message ?? err); + throw err; + } + + const result = await client.submitTransaction(tx, senderKeypair); + console.log("✓ Remittance created, tx hash:", result.hash); + + // ── 4. Resolve the remittance ID from the transaction ───────────────────── + // The contract emits a `remittance_created` event; the ID is also returned + // as the transaction return value. Here we fetch the latest count as a proxy. + const count = await client.getRemittanceCount(senderKeypair.publicKey()); + const remittanceId = count; // newest ID = current count (1-indexed) + console.log("Remittance ID:", remittanceId.toString()); + + // ── 5. Poll for status changes ───────────────────────────────────────────── + console.log("Polling for status…"); + let remittance = await client.getRemittance(senderKeypair.publicKey(), remittanceId); + const maxPolls = 10; + for (let i = 0; i < maxPolls; i++) { + console.log(` status: ${remittance.status} (poll ${i + 1}/${maxPolls})`); + if (remittance.status !== "Pending") break; + await new Promise((r) => setTimeout(r, 3000)); + remittance = await client.getRemittance(senderKeypair.publicKey(), remittanceId); + } + + if (remittance.status !== "Processing" && remittance.status !== "Pending") { + console.log("Remittance reached terminal status:", remittance.status); + return; + } + + // ── 6. Agent confirms payout ─────────────────────────────────────────────── + console.log("Agent confirming payout…"); + try { + const confirmTx = await client.confirmPayout(agentAddress, remittanceId); + const confirmResult = await client.submitTransaction(confirmTx, agentKeypair); + console.log("✓ Payout confirmed, tx hash:", confirmResult.hash); + } catch (err: any) { + console.error("confirmPayout failed:", err.message ?? err); + throw err; + } + + // ── 7. Verify completion ─────────────────────────────────────────────────── + const final = await client.getRemittance(senderKeypair.publicKey(), remittanceId); + if (final.status !== "Completed") { + throw new Error(`Expected Completed, got ${final.status}`); + } + console.log("✓ Remittance completed successfully"); + console.log(` Amount: ${fromStroops(final.amount)} USDC`); + console.log(` Fee: ${fromStroops(final.fee)} USDC`); +} + +main().catch((err) => { + console.error("Fatal:", err); + process.exit(1); +}); +``` + +## Running against testnet + +```bash +cp .env.testnet .env +# Fill in SENDER_SECRET and AGENT_SECRET with funded testnet keypairs +npx ts-node quickstart.ts +``` + +Get testnet funds from the [Stellar Friendbot](https://friendbot.stellar.org/?addr=YOUR_ADDRESS). + +## Error handling reference + +| Error | Cause | Fix | +|---|---|---| +| `AgentNotRegistered` | Agent address not in contract | Admin calls `registerAgent()` | +| `InsufficientFunds` | Sender balance too low | Fund the account or reduce amount | +| `KycExpired` (code 23) | Sender KYC has expired | Renew KYC via your anchor | +| `DailyLimitExceeded` | Corridor daily cap hit | Wait for reset or use a different corridor | +| `Simulation failed` | RPC or contract error | Check `SOROBAN_RPC_URL` and `CONTRACT_ID` | diff --git a/sdk/README.md b/sdk/README.md index 40496ba6..24a6ada3 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -2,6 +2,8 @@ TypeScript/JavaScript client SDK for the **SwiftRemit** Soroban smart contract on Stellar. +> **New to SwiftRemit?** See the [Quickstart guide](./QUICKSTART.md) for a complete end-to-end Node.js example. + ## Installation ```bash