diff --git a/apps/backend/notifications/README.md b/apps/backend/notifications/README.md index 26f8340..66a460e 100644 --- a/apps/backend/notifications/README.md +++ b/apps/backend/notifications/README.md @@ -9,3 +9,13 @@ pnpm --filter @delego/notifications dev ``` Health check: `GET http://localhost:3015/health` + +## Environment Variables + +- `ESCROW_CONTRACT_ID`: The Soroban contract ID for the escrow contract, to listen for events. +- `SOROBAN_RPC_URL`: The Stellar RPC URL to poll for on-chain events. +- `REDIS_URL`: Redis connection URL, used for worker idempotency and deduplication. +- `DATABASE_URL`: PostgreSQL connection URL, used for wallet lookup adapter. +- `SENDGRID_API_KEY`: SendGrid API key for emails. +- `FROM_EMAIL`: Sender email address. +- `LOG_LEVEL`: Log level (e.g. info, debug). diff --git a/apps/backend/notifications/src/escrowListener.test.ts b/apps/backend/notifications/src/escrowListener.test.ts new file mode 100644 index 0000000..0c7581a --- /dev/null +++ b/apps/backend/notifications/src/escrowListener.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { startEscrowEventListener, stopEscrowEventListener } from "./escrowListener.js"; +import { rpc as SorobanRpc, xdr, nativeToScVal } from "@stellar/stellar-sdk"; +import { Redis } from "ioredis"; +import * as idempotency from "./idempotency.js"; +import { setWalletLookupAdapter, resetWalletLookupAdapter } from "./walletLookup.js"; +import * as email from "../email/index.js"; +import * as push from "../push/index.js"; + +// Mock dependencies +vi.mock("ioredis", () => { + const Redis = vi.fn(); + Redis.prototype.get = vi.fn().mockResolvedValue(null); + Redis.prototype.set = vi.fn().mockResolvedValue("OK"); + Redis.prototype.smembers = vi.fn().mockResolvedValue([]); + return { Redis }; +}); + +vi.mock("./idempotency.js", () => ({ + checkAndMarkDispatched: vi.fn().mockResolvedValue(true), +})); + +vi.mock("../email/index.js", () => ({ + sendEmail: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../push/index.js", () => ({ + sendPushNotification: vi.fn().mockResolvedValue(undefined), +})); + +describe("escrowListener", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + + // Mock the Stellar SDK RPC Server + const mockGetLatestLedger = vi.fn().mockResolvedValue({ sequence: 100 }); + const mockGetEvents = vi.fn().mockResolvedValue({ events: [] }); + + SorobanRpc.Server.prototype.getLatestLedger = mockGetLatestLedger; + SorobanRpc.Server.prototype.getEvents = mockGetEvents; + + setWalletLookupAdapter({ + lookupByWalletAddress: vi.fn().mockImplementation(async (address: string) => { + if (address === "merchant-addr") { + return { + walletAddress: "merchant-addr", + userId: "merchant-123", + email: "merchant@example.com", + pushEnabled: false, + }; + } + return null; + }), + }); + }); + + afterEach(() => { + stopEscrowEventListener(); + resetWalletLookupAdapter(); + vi.useRealTimers(); + }); + + it("decodes on-chain EscrowCreatedEvent and dispatches notification", async () => { + const mockGetEvents = vi.fn().mockResolvedValue({ + events: [ + { + type: "contract", + inSuccessfulContractCall: true, + contractId: "C123", + ledger: 100, + txHash: "abcd", + id: "event-1", + topic: [ + nativeToScVal("escrow"), + nativeToScVal("created"), + ], + value: nativeToScVal({ + buyer: "buyer-addr", + seller: "merchant-addr", + amount: 1000n, // u128 map + order_id: "order-99", + }), + }, + ], + }); + SorobanRpc.Server.prototype.getEvents = mockGetEvents; + + startEscrowEventListener("http://localhost:8000", "C123"); + + // Fast forward to allow async operations to run + await vi.runOnlyPendingTimersAsync(); + + expect(mockGetEvents).toHaveBeenCalled(); + expect(idempotency.checkAndMarkDispatched).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + userId: "merchant-123", + eventType: "escrow_created", + }) + ); + expect(email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: "merchant@example.com", + templateName: "escrow-funded", + }) + ); + }); +}); diff --git a/apps/backend/notifications/src/escrowListener.ts b/apps/backend/notifications/src/escrowListener.ts new file mode 100644 index 0000000..2b946ae --- /dev/null +++ b/apps/backend/notifications/src/escrowListener.ts @@ -0,0 +1,252 @@ +import { rpc as SorobanRpc, scValToNative } from "@stellar/stellar-sdk"; +import { createLogger } from "@delego/utils"; +import { Redis } from "ioredis"; +import { getWalletLookupAdapter } from "./walletLookup.js"; +import { checkAndMarkDispatched } from "./idempotency.js"; +import { sendEmail } from "../email/index.js"; +import { sendPushNotification, type PushPayload, type PushSubscription } from "../push/index.js"; + +const log = createLogger( + "notifications:escrow-listener", + process.env.LOG_LEVEL ?? "info" +); + +const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379"); + +export interface EscrowContractEvent { + contractId: string; + eventType: "escrow_created" | "escrow_released" | "escrow_refunded" | "escrow_disputed"; + orderId: string; + buyer: string; + merchant: string; + amountStroops: string; + ledger: number; + txHash: string; +} + +let isRunning = false; +let timeoutId: NodeJS.Timeout | null = null; + +export function startEscrowEventListener(rpcUrl: string, contractId: string): void { + if (isRunning) { + log.warn("Escrow event listener is already running"); + return; + } + isRunning = true; + + const server = new SorobanRpc.Server(rpcUrl); + log.info("Starting escrow event listener", { rpcUrl, contractId }); + + const poll = async () => { + if (!isRunning) return; + + try { + const latestLedgerResponse = await server.getLatestLedger(); + const latestLedger = latestLedgerResponse.sequence; + + const lastProcessedKey = `escrow_listener:last_ledger:${contractId}`; + const lastProcessedStr = await redis.get(lastProcessedKey); + + let startLedger = lastProcessedStr ? parseInt(lastProcessedStr, 10) + 1 : latestLedger - 100; + + if (startLedger > latestLedger) { + startLedger = latestLedger; + } + + if (startLedger <= latestLedger) { + const eventsResponse = await server.getEvents({ + startLedger, + filters: [ + { + type: "contract", + contractIds: [contractId], + topics: [["*"]], // Match any topics for this contract + }, + ], + limit: 1000, + }); + + for (const event of eventsResponse.events) { + if (event.type !== "contract" || !event.inSuccessfulContractCall) { + continue; + } + + try { + const topics = event.topic.map(t => scValToNative(t)); + if (topics[0] !== "escrow" || !topics[1]) continue; + + const eventTypeRaw = topics[1]; + let eventType: EscrowContractEvent["eventType"]; + + switch (eventTypeRaw) { + case "created": + eventType = "escrow_created"; + break; + case "resolved": + eventType = "escrow_released"; + break; + case "refunded": + eventType = "escrow_refunded"; + break; + case "disputed": + eventType = "escrow_disputed"; + break; + default: + continue; + } + + const value = scValToNative(event.value); + + if (eventTypeRaw === "resolved" && value && value.release_to_seller === false) { + eventType = "escrow_refunded"; + } + if (eventTypeRaw === "resolved" && value && value.release_to_seller === true) { + eventType = "escrow_released"; + } + + const escrowEvent: EscrowContractEvent = { + contractId: event.contractId, + eventType, + orderId: value.order_id || value.orderId || "", + buyer: value.buyer || "", + merchant: value.seller || value.merchant || "", + amountStroops: value.amount ? value.amount.toString() : "0", + ledger: event.ledger, + txHash: event.txHash, + }; + + await dispatchEscrowNotification(escrowEvent, `${event.txHash}-${event.id}`); + } catch (e) { + log.warn("Failed to parse or process event", { error: e, event }); + } + } + + await redis.set(lastProcessedKey, latestLedger.toString()); + } + } catch (error) { + log.error("Error polling escrow events", { error }); + } + + if (isRunning) { + timeoutId = setTimeout(poll, 5000); + } + }; + + poll(); +} + +export function stopEscrowEventListener(): void { + isRunning = false; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } +} + +async function dispatchEscrowNotification(event: EscrowContractEvent, eventId: string) { + const adapter = getWalletLookupAdapter(); + + // Decide who needs the notification + let targetAddress: string | null = null; + let subject = ""; + let templateName = ""; + let body = ""; + + if (event.eventType === "escrow_created") { + // Escrow created by buyer, notify merchant + targetAddress = event.merchant; + subject = "New Escrow Funded"; + templateName = "escrow-funded"; + body = `A new escrow for order ${event.orderId} has been funded.`; + } else if (event.eventType === "escrow_released") { + // Funds released to merchant, notify merchant + targetAddress = event.merchant; + subject = "Escrow Released"; + templateName = "escrow-released"; + body = `Funds for order ${event.orderId} have been released to you.`; + } else if (event.eventType === "escrow_refunded") { + // Funds refunded to buyer, notify buyer + targetAddress = event.buyer; + subject = "Escrow Refunded"; + templateName = "escrow-refunded"; + body = `Funds for order ${event.orderId} have been refunded to you.`; + } else if (event.eventType === "escrow_disputed") { + // Disputed, notify both? For simplicity, notify merchant + targetAddress = event.merchant; + subject = "Escrow Disputed"; + templateName = "escrow-disputed"; + body = `A dispute has been opened for order ${event.orderId}.`; + } + + if (!targetAddress) return; + + const target = await adapter.lookupByWalletAddress(targetAddress); + if (!target) { + log.info("No notification target found for wallet", { targetAddress, eventId }); + return; + } + + const tasks: Promise[] = []; + + // Email + if (target.email) { + const shouldSend = await checkAndMarkDispatched(redis, { + userId: target.userId, + channel: "email", + eventType: event.eventType, + eventId, + }); + + if (shouldSend) { + tasks.push( + sendEmail({ + to: target.email, + subject, + templateName, + templateData: { + orderId: event.orderId, + amount: event.amountStroops, + }, + }).catch((err) => + log.error("Failed to send escrow email", { error: err, userId: target.userId }) + ) + ); + } + } + + // Push + if (target.pushEnabled) { + const shouldSend = await checkAndMarkDispatched(redis, { + userId: target.userId, + channel: "push", + eventType: event.eventType, + eventId, + }); + + if (shouldSend) { + const payload: PushPayload = { + title: subject, + body, + data: { + type: event.eventType, + orderId: event.orderId, + amount: event.amountStroops, + }, + }; + + tasks.push( + (async () => { + const subscriptions = await redis.smembers(`push:subscriptions:${target.userId}`); + for (const subStr of subscriptions) { + const sub = JSON.parse(subStr) as PushSubscription; + await sendPushNotification(sub, payload).catch((err) => + log.error("Failed to send escrow push", { error: err, userId: target.userId }) + ); + } + })() + ); + } + } + + await Promise.all(tasks); +} diff --git a/apps/backend/notifications/src/index.ts b/apps/backend/notifications/src/index.ts index d57bdaa..63e8187 100644 --- a/apps/backend/notifications/src/index.ts +++ b/apps/backend/notifications/src/index.ts @@ -9,6 +9,7 @@ import { dispatchTransactionApproval, } from "./dispatcher.js"; import { getVapidPublicKey } from "../push/index.js"; +import { startEscrowEventListener } from "./escrowListener.js"; import type { IncomingMessage, ServerResponse } from "node:http"; const SERVICE_NAME = "notifications"; @@ -125,3 +126,11 @@ const server = startHttpServer({ }); initWebSocketServer(server); + +const escrowContractId = process.env.ESCROW_CONTRACT_ID; +const rpcUrl = process.env.SOROBAN_RPC_URL; +if (escrowContractId && rpcUrl) { + startEscrowEventListener(rpcUrl, escrowContractId); +} else { + log.warn("Escrow listener not started: ESCROW_CONTRACT_ID or SOROBAN_RPC_URL missing"); +} diff --git a/apps/backend/payments/README.md b/apps/backend/payments/README.md index d3893f3..41d064c 100644 --- a/apps/backend/payments/README.md +++ b/apps/backend/payments/README.md @@ -9,3 +9,26 @@ pnpm --filter @delego/payments dev ``` Health check: `GET http://localhost:3014/health` + +## Database-Backed Transaction Ledger + +To sync off-chain transaction status and maintain idempotency, the payments service tracks transaction confirmation state via the `soroban_transaction_ledger` table. + +### Schema Details + +The ledger persists transaction data with the following fields: +- `hash` (`VARCHAR(64)`): Primary key (Stellar transaction hash). +- `order_id` (`VARCHAR(255)`): Links transaction back to a payment/order workflow. +- `contract_id` (`VARCHAR(255)`): Soroban contract address. +- `method` (`VARCHAR(100)`): Called smart contract method (e.g. `initialize`, `create_escrow`). +- `status` (`VARCHAR(20)`): State of confirmation (`PENDING`, `CONFIRMED`, `FAILED`). +- `error_details` (`TEXT`): Failure reason, if applicable. +- `submitted_at`, `confirmed_at`, `created_at`, `updated_at`: Timestamps. + +### Configuration / Environment Variables + +Ensure the following environment variables are set in the service environment: +- `DATABASE_URL`: Connection string for PostgreSQL database. +- `DATABASE_POOL_MIN`: Minimum DB connection pool size (default: `2`). +- `DATABASE_POOL_MAX`: Maximum DB connection pool size (default: `10`). +- `LOG_LEVEL`: Logging verbosity level. diff --git a/apps/backend/payments/events/index.ts b/apps/backend/payments/events/index.ts index bd6ba15..a83b315 100644 --- a/apps/backend/payments/events/index.ts +++ b/apps/backend/payments/events/index.ts @@ -12,6 +12,7 @@ import { createRequire } from "node:module"; import { createLogger } from "@delego/utils"; +import { SorobanTransactionLedger } from "../src/models/SorobanTransactionLedger.js"; const log = createLogger("payments:events", process.env.LOG_LEVEL ?? "info"); @@ -180,3 +181,90 @@ export function emitPaymentEvent(event: { }) ); } + +// --------------------------------------------------------------------------- +// Database-Backed Transaction Ledger (Issue #52) +// --------------------------------------------------------------------------- + +/** + * Logs a new transaction submission to the ledger database with PENDING status. + * If the transaction has already been logged, updates its details/status as needed. + */ +export async function logSubmission( + hash: string, + method: string, + orderId?: string, + contractId?: string +): Promise { + log.info("Logging transaction submission in ledger", { hash, method, orderId, contractId }); + try { + const [ledgerEntry, created] = await SorobanTransactionLedger.findOrCreate({ + where: { hash }, + defaults: { + hash, + method, + status: "PENDING", + orderId: orderId || null, + contractId: contractId || null, + submittedAt: new Date(), + }, + }); + + if (!created) { + log.warn("Transaction submission ledger entry already exists, updating for retry", { hash }); + ledgerEntry.status = "PENDING"; + ledgerEntry.method = method; + if (orderId) ledgerEntry.orderId = orderId; + if (contractId) ledgerEntry.contractId = contractId; + await ledgerEntry.save(); + } + + return ledgerEntry; + } catch (err) { + log.error("Failed to log transaction submission in ledger", { + hash, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +/** + * Updates the confirmation status of a logged transaction entry to CONFIRMED or FAILED. + */ +export async function updateLedgerStatus( + hash: string, + status: string, + error?: string +): Promise { + log.info("Updating transaction ledger status", { hash, status, error }); + if (status !== "CONFIRMED" && status !== "FAILED") { + throw new Error(`Invalid status update: ${status}. Must be CONFIRMED or FAILED.`); + } + + try { + const ledgerEntry = await SorobanTransactionLedger.findByPk(hash); + if (!ledgerEntry) { + log.error("Transaction ledger entry not found for update", { hash }); + throw new Error(`Transaction ledger entry not found for hash: ${hash}`); + } + + ledgerEntry.status = status; + if (status === "CONFIRMED") { + ledgerEntry.confirmedAt = new Date(); + ledgerEntry.errorDetails = null; + } else { + ledgerEntry.errorDetails = error || "Transaction failed"; + } + + await ledgerEntry.save(); + return ledgerEntry; + } catch (err) { + log.error("Failed to update transaction ledger status", { + hash, + status, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} diff --git a/apps/backend/payments/package.json b/apps/backend/payments/package.json index de4a4c5..9e9eae0 100644 --- a/apps/backend/payments/package.json +++ b/apps/backend/payments/package.json @@ -16,7 +16,10 @@ "dependencies": { "@delego/types": "workspace:*", "@delego/utils": "workspace:*", - "ioredis": "^5.11.1" + "ioredis": "^5.11.1", + "pg": "^8.21.0", + "pg-hstore": "^2.3.4", + "sequelize": "^6.37.8" }, "devDependencies": { "tsx": "^4.19.0", diff --git a/apps/backend/payments/src/db.ts b/apps/backend/payments/src/db.ts new file mode 100644 index 0000000..ffae8ef --- /dev/null +++ b/apps/backend/payments/src/db.ts @@ -0,0 +1,31 @@ +import { Sequelize } from "sequelize"; +import { createLogger } from "@delego/utils"; + +const log = createLogger("payments:db", process.env.LOG_LEVEL ?? "info"); + +const databaseUrl = process.env.DATABASE_URL ?? "postgresql://delego:delego@localhost:5432/delego"; + +export const sequelize = new Sequelize(databaseUrl, { + dialect: "postgres", + logging: (msg) => log.debug(msg), + pool: { + min: Number(process.env.DATABASE_POOL_MIN ?? 2), + max: Number(process.env.DATABASE_POOL_MAX ?? 10), + acquire: 30000, + idle: 10000, + }, + define: { + underscored: true, + timestamps: true, + }, +}); + +export async function connectDb(): Promise { + try { + await sequelize.authenticate(); + log.info("Database connection established successfully in payments service."); + } catch (err) { + log.error("Unable to connect to the database in payments service", err instanceof Error ? { error: err.message } : { error: String(err) }); + throw err; + } +} diff --git a/apps/backend/payments/src/models/SorobanTransactionLedger.ts b/apps/backend/payments/src/models/SorobanTransactionLedger.ts new file mode 100644 index 0000000..6bde524 --- /dev/null +++ b/apps/backend/payments/src/models/SorobanTransactionLedger.ts @@ -0,0 +1,67 @@ +import { Model, DataTypes } from "sequelize"; +import { sequelize } from "../db.js"; + +export class SorobanTransactionLedger extends Model { + public hash!: string; + public orderId!: string | null; + public contractId!: string | null; + public method!: string; + public status!: "PENDING" | "CONFIRMED" | "FAILED"; + public errorDetails!: string | null; + public submittedAt!: Date; + public confirmedAt!: Date | null; + public readonly createdAt!: Date; + public readonly updatedAt!: Date; +} + +SorobanTransactionLedger.init( + { + hash: { + type: DataTypes.STRING(64), + primaryKey: true, + }, + orderId: { + type: DataTypes.STRING(255), + allowNull: true, + field: "order_id", + }, + contractId: { + type: DataTypes.STRING(255), + allowNull: true, + field: "contract_id", + }, + method: { + type: DataTypes.STRING(100), + allowNull: false, + }, + status: { + type: DataTypes.STRING(20), + allowNull: false, + validate: { + isIn: [["PENDING", "CONFIRMED", "FAILED"]], + }, + }, + errorDetails: { + type: DataTypes.TEXT, + allowNull: true, + field: "error_details", + }, + submittedAt: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + field: "submitted_at", + }, + confirmedAt: { + type: DataTypes.DATE, + allowNull: true, + field: "confirmed_at", + }, + }, + { + sequelize, + modelName: "SorobanTransactionLedger", + tableName: "soroban_transaction_ledger", + timestamps: true, + underscored: true, + } +); diff --git a/database/migrations/004_soroban_transaction_ledger.sql b/database/migrations/004_soroban_transaction_ledger.sql new file mode 100644 index 0000000..ee39278 --- /dev/null +++ b/database/migrations/004_soroban_transaction_ledger.sql @@ -0,0 +1,24 @@ +-- Migration: 004_soroban_transaction_ledger.sql +-- Description: Create soroban_transaction_ledger table + +-- Up migration +CREATE TABLE IF NOT EXISTS soroban_transaction_ledger ( + hash VARCHAR(64) PRIMARY KEY, + order_id VARCHAR(255), + contract_id VARCHAR(255), + method VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL CHECK (status IN ('PENDING', 'CONFIRMED', 'FAILED')), + error_details TEXT, + submitted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + confirmed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_soroban_tx_ledger_status ON soroban_transaction_ledger(status); +CREATE INDEX IF NOT EXISTS idx_soroban_tx_ledger_order_id ON soroban_transaction_ledger(order_id); + +-- Down migration +DROP INDEX IF EXISTS idx_soroban_tx_ledger_order_id; +DROP INDEX IF EXISTS idx_soroban_tx_ledger_status; +DROP TABLE IF EXISTS soroban_transaction_ledger; diff --git a/scripts/setup/migrate.js b/scripts/setup/migrate.js index 9e801cd..91d135c 100644 --- a/scripts/setup/migrate.js +++ b/scripts/setup/migrate.js @@ -18,6 +18,31 @@ async function run() { console.log("[delego] db:migrate — running initial schema migration..."); await client.query(sql); console.log("[delego] db:migrate — schema migrated successfully."); + + // Run versioned migrations from database/migrations + const migrationsDir = path.join(__dirname, "../../database/migrations"); + if (fs.existsSync(migrationsDir)) { + const files = fs.readdirSync(migrationsDir) + .filter(f => f.endsWith(".sql")) + .sort(); + + for (const file of files) { + console.log(`[delego] db:migrate — running migration: ${file}...`); + const migrationPath = path.join(migrationsDir, file); + const migrationSql = fs.readFileSync(migrationPath, "utf8"); + + try { + await client.query(migrationSql); + console.log(`[delego] db:migrate — migration ${file} applied successfully.`); + } catch (err) { + if (err.message && err.message.includes("already exists")) { + console.log(`[delego] db:migrate — warning: relation or type in ${file} already exists, skipping...`); + } else { + throw err; + } + } + } + } } catch (err) { console.error("[delego] db:migrate — migration failed:", err); process.exit(1); diff --git a/tests/unit/src/payments-ledger.test.js b/tests/unit/src/payments-ledger.test.js new file mode 100644 index 0000000..2e80c09 --- /dev/null +++ b/tests/unit/src/payments-ledger.test.js @@ -0,0 +1,75 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { logSubmission, updateLedgerStatus } from "../../../apps/backend/payments/dist/events/index.js"; +import { SorobanTransactionLedger } from "../../../apps/backend/payments/dist/src/models/SorobanTransactionLedger.js"; + +describe("Soroban Transaction Ledger", () => { + let originalFindOrCreate; + let originalFindByPk; + + let mockLedgerEntries = {}; + + before(() => { + originalFindOrCreate = SorobanTransactionLedger.findOrCreate; + originalFindByPk = SorobanTransactionLedger.findByPk; + + // Stub findOrCreate + SorobanTransactionLedger.findOrCreate = async ({ where, defaults }) => { + const hash = where.hash; + const exists = mockLedgerEntries[hash]; + if (exists) { + return [exists, false]; + } + const newEntry = { + ...defaults, + save: async function () { + mockLedgerEntries[this.hash] = this; + return this; + }, + }; + mockLedgerEntries[hash] = newEntry; + return [newEntry, true]; + }; + + // Stub findByPk + SorobanTransactionLedger.findByPk = async (hash) => { + return mockLedgerEntries[hash] || null; + }; + }); + + after(() => { + SorobanTransactionLedger.findOrCreate = originalFindOrCreate; + SorobanTransactionLedger.findByPk = originalFindByPk; + }); + + it("should log transaction submission with PENDING status", async () => { + const hash = "0000000000000000000000000000000000000000000000000000000000000001"; + const method = "create_escrow"; + const orderId = "order_123"; + const contractId = "contract_abc"; + + const entry = await logSubmission(hash, method, orderId, contractId); + assert.equal(entry.hash, hash); + assert.equal(entry.method, method); + assert.equal(entry.status, "PENDING"); + assert.equal(entry.orderId, orderId); + assert.equal(entry.contractId, contractId); + }); + + it("should update ledger status to CONFIRMED", async () => { + const hash = "0000000000000000000000000000000000000000000000000000000000000001"; + + const entry = await updateLedgerStatus(hash, "CONFIRMED"); + assert.equal(entry.status, "CONFIRMED"); + assert.ok(entry.confirmedAt); + assert.equal(entry.errorDetails, null); + }); + + it("should update ledger status to FAILED with error details", async () => { + const hash = "0000000000000000000000000000000000000000000000000000000000000001"; + + const entry = await updateLedgerStatus(hash, "FAILED", "Simulation failed"); + assert.equal(entry.status, "FAILED"); + assert.equal(entry.errorDetails, "Simulation failed"); + }); +});