From 6b59b250ba1e02894f3b33d1bd5bd8fddf86a25d Mon Sep 17 00:00:00 2001 From: Jerry_tekh Date: Tue, 25 Aug 2026 12:14:57 +0100 Subject: [PATCH 1/3] feat(contracts): add scValI128 and scValU64 helpers for Soroban numeric encoding Implements helper functions for encoding Soroban ScVal i128 and u64 numeric values: - scValI128(amount): encodes bigint or string to i128 ScVal - scValU64(val): encodes number to u64 ScVal Includes unit tests verifying boundary values (0, max u64, large i128 stroop values). Closes #153 --- src/services/stellar/utils/scval.utils.ts | 9 +++++ tests/unit/utils/scval.utils.test.ts | 48 +++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/services/stellar/utils/scval.utils.ts create mode 100644 tests/unit/utils/scval.utils.test.ts diff --git a/src/services/stellar/utils/scval.utils.ts b/src/services/stellar/utils/scval.utils.ts new file mode 100644 index 0000000..954ca15 --- /dev/null +++ b/src/services/stellar/utils/scval.utils.ts @@ -0,0 +1,9 @@ +import { nativeToScVal, xdr } from "stellar-sdk"; + +export function scValI128(amount: bigint | string): xdr.ScVal { + return nativeToScVal(BigInt(amount), { type: "i128" }); +} + +export function scValU64(val: number): xdr.ScVal { + return nativeToScVal(val, { type: "u64" }); +} diff --git a/tests/unit/utils/scval.utils.test.ts b/tests/unit/utils/scval.utils.test.ts new file mode 100644 index 0000000..79d0abe --- /dev/null +++ b/tests/unit/utils/scval.utils.test.ts @@ -0,0 +1,48 @@ +import { scValToNative } from "stellar-sdk"; +import { scValI128, scValU64 } from "../../../../src/services/stellar/utils/scval.utils"; + +describe("scValI128", () => { + it("should encode zero correctly", () => { + const val = scValI128(0n); + expect(scValToNative(val)).toBe(0n); + }); + + it("should encode a positive bigint", () => { + const val = scValI128(500_000_000n); + expect(scValToNative(val)).toBe(500_000_000n); + }); + + it("should encode a large i128 stroop value", () => { + const large = BigInt("115792089237316195423570985008687907853269984665640564039457584007913129639935"); + const val = scValI128(large); + expect(scValToNative(val)).toBe(large); + }); + + it("should encode from string input", () => { + const val = scValI128("1000000000"); + expect(scValToNative(val)).toBe(1000000000n); + }); + + it("should encode negative values", () => { + const val = scValI128(-500n); + expect(scValToNative(val)).toBe(-500n); + }); +}); + +describe("scValU64", () => { + it("should encode zero correctly", () => { + const val = scValU64(0); + expect(scValToNative(val)).toBe(0n); + }); + + it("should encode a timestamp", () => { + const val = scValU64(1770000000); + expect(scValToNative(val)).toBe(1770000000n); + }); + + it("should encode max u64", () => { + const maxU64 = 18446744073709551615; + const val = scValU64(maxU64); + expect(scValToNative(val)).toBe(18446744073709551615n); + }); +}); From 1239b3d7dad807785ec6b93b33a2d6c7c5df682f Mon Sep 17 00:00:00 2001 From: Jerry_tekh Date: Tue, 25 Aug 2026 12:26:21 +0100 Subject: [PATCH 2/3] feat(contracts): add scValSymbol and scValAddress helpers for Soroban ScVal encoding Adds centralized helpers for encoding Soroban ScVal Symbol and Address values: - scValSymbol(value): encodes string to symbol ScVal - scValAddress(address): encodes Stellar address to ScVal Includes unit tests verifying ScVal serialization and deserialization. Closes #152 --- src/services/stellar/utils/scval.utils.ts | 10 ++++++- tests/unit/utils/scval.utils.test.ts | 33 ++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/services/stellar/utils/scval.utils.ts b/src/services/stellar/utils/scval.utils.ts index 954ca15..47f640b 100644 --- a/src/services/stellar/utils/scval.utils.ts +++ b/src/services/stellar/utils/scval.utils.ts @@ -1,4 +1,4 @@ -import { nativeToScVal, xdr } from "stellar-sdk"; +import { nativeToScVal, Address, xdr } from "stellar-sdk"; export function scValI128(amount: bigint | string): xdr.ScVal { return nativeToScVal(BigInt(amount), { type: "i128" }); @@ -7,3 +7,11 @@ export function scValI128(amount: bigint | string): xdr.ScVal { export function scValU64(val: number): xdr.ScVal { return nativeToScVal(val, { type: "u64" }); } + +export function scValSymbol(val: string): xdr.ScVal { + return nativeToScVal(val, { type: "symbol" }); +} + +export function scValAddress(address: string): xdr.ScVal { + return new Address(address).toScVal(); +} diff --git a/tests/unit/utils/scval.utils.test.ts b/tests/unit/utils/scval.utils.test.ts index 79d0abe..02771a3 100644 --- a/tests/unit/utils/scval.utils.test.ts +++ b/tests/unit/utils/scval.utils.test.ts @@ -1,5 +1,5 @@ import { scValToNative } from "stellar-sdk"; -import { scValI128, scValU64 } from "../../../../src/services/stellar/utils/scval.utils"; +import { scValI128, scValU64, scValSymbol, scValAddress } from "../../../../src/services/stellar/utils/scval.utils"; describe("scValI128", () => { it("should encode zero correctly", () => { @@ -46,3 +46,34 @@ describe("scValU64", () => { expect(scValToNative(val)).toBe(18446744073709551615n); }); }); + +describe("scValSymbol", () => { + it("should encode an invoice ID", () => { + const val = scValSymbol("INV-2026-001"); + expect(scValToNative(val)).toBe("INV-2026-001"); + }); + + it("should encode a short symbol", () => { + const val = scValSymbol("create_escrow"); + expect(scValToNative(val)).toBe("create_escrow"); + }); + + it("should encode an empty string", () => { + const val = scValSymbol(""); + expect(scValToNative(val)).toBe(""); + }); +}); + +describe("scValAddress", () => { + it("should encode a Stellar address", () => { + const addr = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + const val = scValAddress(addr); + expect(scValToNative(val)).toBe(addr); + }); + + it("should encode a contract address", () => { + const addr = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + const val = scValAddress(addr); + expect(scValToNative(val)).toBe(addr); + }); +}); From edb47ced532d179589ab6721a8762ef1f31ada94 Mon Sep 17 00:00:00 2001 From: Jerry_tekh Date: Tue, 25 Aug 2026 12:47:59 +0100 Subject: [PATCH 3/3] test: add unit tests for verify-payment.service transaction build & error handling Adds comprehensive unit tests covering verifyPaymentTransaction(): - Mock Horizon.Server.transactions() responses for success and error codes - Payment amount delta comparison logic (within and outside allowed delta) - Retry behavior on 503 and network socket timeout - Transaction state transitions and conflict detection - Operation index disambiguation Closes #145 --- .../stellar/verify-payment.service.test.ts | 767 ++++++++++++++++++ 1 file changed, 767 insertions(+) create mode 100644 tests/unit/services/stellar/verify-payment.service.test.ts diff --git a/tests/unit/services/stellar/verify-payment.service.test.ts b/tests/unit/services/stellar/verify-payment.service.test.ts new file mode 100644 index 0000000..abe36a5 --- /dev/null +++ b/tests/unit/services/stellar/verify-payment.service.test.ts @@ -0,0 +1,767 @@ +import { VerifyPaymentService, PaymentVerificationInput } from "../../../../src/services/stellar/verify-payment.service"; +import { ServiceError } from "../../../../src/utils/service-error"; +import { InvestmentStatus, TransactionStatus, TransactionType } from "../../../../src/types/enums"; +import type { Investment } from "../../../../src/models/Investment.model"; +import type { Transaction } from "../../../../src/models/Transaction.model"; +import type { PaymentVerificationConfig } from "../../../../src/config/stellar"; + +describe("VerifyPaymentService", () => { + const TEST_TX_HASH = "abc123def456"; + const TEST_INVESTMENT_ID = "inv-001"; + const TEST_ESCROW_KEY = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + const TEST_USDC_ISSUER = "CBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + + const mockConfig: PaymentVerificationConfig = { + horizonUrl: "https://horizon-testnet.stellar.org", + usdcAssetCode: "USDC", + usdcAssetIssuer: TEST_USDC_ISSUER, + escrowPublicKey: TEST_ESCROW_KEY, + allowedAmountDelta: "0.0001", + retryAttempts: 3, + retryBaseDelayMs: 10, + }; + + const createMockInvestment = (overrides: Partial = {}): Investment => ({ + id: TEST_INVESTMENT_ID, + invoiceId: "invoice-001", + investorId: "user-001", + investmentAmount: "500.0000", + expectedReturn: "550.0000", + actualReturn: null, + status: InvestmentStatus.PENDING, + transactionHash: null, + stellarOperationIndex: null, + ...overrides, + } as Investment); + + const createMockTransaction = (overrides: Partial = {}): Transaction => ({ + id: "tx-001", + userId: "user-001", + investmentId: TEST_INVESTMENT_ID, + invoiceId: "invoice-001", + type: TransactionType.INVESTMENT, + amount: "500.0000", + status: TransactionStatus.PENDING, + stellarTxHash: null, + stellarOperationIndex: null, + ...overrides, + } as Transaction); + + const createMockFetch = (responses: Response[]): jest.MockedFunction => { + let callIndex = 0; + const mockFetch = jest.fn().mockImplementation(() => { + const response = responses[callIndex] ?? responses[responses.length - 1]; + callIndex += 1; + return Promise.resolve(response); + }); + return mockFetch as unknown as jest.MockedFunction; + }; + + const createJsonResponse = (data: unknown, status = 200): Response => { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + } as Response; + }; + + const createMockInvestmentReader = (investment: Investment | null) => ({ + findById: jest.fn().mockResolvedValue(investment), + }); + + const createMockTransactionRunner = ( + investment: Investment | null, + transactions: Transaction[], + ) => ({ + runInTransaction: jest.fn().mockImplementation(async (callback: any) => { + return callback({ + findInvestmentByIdForUpdate: jest.fn().mockResolvedValue(investment), + findTransactionsByInvestmentIdForUpdate: jest.fn().mockResolvedValue(transactions), + saveInvestment: jest.fn().mockImplementation((inv: Investment) => Promise.resolve({ + ...inv, + id: inv.id || "saved-inv", + })), + saveTransaction: jest.fn().mockImplementation((tx: Transaction) => Promise.resolve({ + ...tx, + id: tx.id || "saved-tx", + })), + createTransaction: jest.fn().mockImplementation((input: Partial) => ({ + id: "", + userId: "", + investmentId: null, + invoiceId: null, + type: TransactionType.INVESTMENT, + amount: "0", + status: TransactionStatus.PENDING, + stellarTxHash: null, + stellarOperationIndex: null, + ...input, + })), + }); + }), + }); + + const createService = (options: { + investment?: Investment | null; + lockedInvestment?: Investment | null; + transactions?: Transaction[]; + fetchResponses?: Response[]; + sleepFn?: (ms: number) => Promise; + } = {}) => { + const { + investment = createMockInvestment(), + lockedInvestment, + transactions = [], + fetchResponses = [], + sleepFn = jest.fn().mockResolvedValue(undefined), + } = options; + + const resolvedLocked = lockedInvestment !== undefined + ? lockedInvestment + : (investment ?? createMockInvestment()); + + const fetchImpl = createMockFetch(fetchResponses); + const reader = createMockInvestmentReader(investment); + const runner = createMockTransactionRunner(resolvedLocked, transactions); + + const service = new VerifyPaymentService({ + investmentReader: reader, + transactionRunner: runner, + config: mockConfig, + fetchImplementation: fetchImpl, + sleep: sleepFn, + }); + + return { service, fetchMock: fetchImpl, reader, runner, sleepFn }; + }; + + describe("verifyPayment", () => { + it("should throw investment_not_found when investment does not exist", async () => { + const { service } = createService({ investment: null }); + + await expect(service.verifyPayment({ + investmentId: "nonexistent", + stellarTxHash: TEST_TX_HASH, + })).rejects.toMatchObject({ + code: "investment_not_found", + statusCode: 404, + }); + }); + + it("should return already_verified when investment is confirmed with same tx", async () => { + const investment = createMockInvestment({ + status: InvestmentStatus.CONFIRMED, + transactionHash: TEST_TX_HASH, + stellarOperationIndex: 1, + }); + + const { service } = createService({ investment }); + + const result = await service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + operationIndex: 1, + }); + + expect(result.outcome).toBe("already_verified"); + expect(result.investmentId).toBe(TEST_INVESTMENT_ID); + }); + + it("should throw reconciliation_conflict when investment confirmed with different tx", async () => { + const investment = createMockInvestment({ + status: InvestmentStatus.CONFIRMED, + transactionHash: "different-hash", + stellarOperationIndex: 1, + }); + + const { service } = createService({ investment }); + + await expect(service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + })).rejects.toMatchObject({ + code: "reconciliation_conflict", + statusCode: 409, + }); + }); + + it("should verify payment successfully with matching operation", async () => { + const investment = createMockInvestment(); + const lockedInvestment = createMockInvestment(); + + const { service, fetchMock } = createService({ + investment, + lockedInvestment, + transactions: [], + fetchResponses: [ + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0000000", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + }); + + const result = await service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + }); + + expect(result.outcome).toBe("verified"); + expect(result.investmentId).toBe(TEST_INVESTMENT_ID); + expect(result.status).toBe(InvestmentStatus.CONFIRMED); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); + + describe("fetchAndValidatePayment - transaction validation", () => { + it("should throw transaction_not_found on 404 response", async () => { + const investment = createMockInvestment(); + + const { service } = createService({ + investment, + fetchResponses: [ + createJsonResponse({}, 404), + createJsonResponse({}, 404), + createJsonResponse({}, 404), + ], + }); + + await expect(service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: "nonexistent-hash", + })).rejects.toMatchObject({ + code: "transaction_not_found", + statusCode: 404, + }); + }); + + it("should throw invalid_payment when transaction was not successful", async () => { + const investment = createMockInvestment(); + + const { service } = createService({ + investment, + fetchResponses: [ + createJsonResponse({ successful: false }), + ], + }); + + await expect(service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + })).rejects.toMatchObject({ + code: "invalid_payment", + statusCode: 422, + }); + }); + + it("should throw invalid_payment when no payment operation matches", async () => { + const investment = createMockInvestment({ investmentAmount: "999.0000" }); + + const { service } = createService({ + investment, + fetchResponses: [ + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "999.0000000", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + }); + + await expect(service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + })).rejects.toMatchObject({ + code: "invalid_payment", + statusCode: 422, + }); + }); + + it("should throw invalid_payment when multiple payments match without operationIndex", async () => { + const investment = createMockInvestment(); + + const { service } = createService({ + investment, + fetchResponses: [ + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0000000", + to: TEST_ESCROW_KEY, + }, + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0000000", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + }); + + await expect(service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + })).rejects.toMatchObject({ + code: "invalid_payment", + statusCode: 422, + }); + }); + }); + + describe("amount delta comparison", () => { + it("should accept payment within allowed delta", async () => { + const investment = createMockInvestment({ investmentAmount: "500.0000" }); + const lockedInvestment = createMockInvestment({ investmentAmount: "500.0000" }); + + const { service } = createService({ + investment, + lockedInvestment, + transactions: [], + fetchResponses: [ + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0000500", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + }); + + const result = await service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + }); + + expect(result.outcome).toBe("verified"); + }); + + it("should reject payment outside allowed delta", async () => { + const investment = createMockInvestment({ investmentAmount: "500.0000" }); + + const { service } = createService({ + investment, + fetchResponses: [ + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.1000000", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + }); + + await expect(service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + })).rejects.toMatchObject({ + code: "invalid_payment", + statusCode: 422, + }); + }); + + it("should accept payment at exact boundary of delta", async () => { + const investment = createMockInvestment({ investmentAmount: "500.0000" }); + const lockedInvestment = createMockInvestment({ investmentAmount: "500.0000" }); + + const { service } = createService({ + investment, + lockedInvestment, + transactions: [], + fetchResponses: [ + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0001000", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + }); + + const result = await service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + }); + + expect(result.outcome).toBe("verified"); + }); + }); + + describe("retry behavior", () => { + it("should retry on 503 and succeed on subsequent attempt", async () => { + const investment = createMockInvestment(); + const lockedInvestment = createMockInvestment(); + + const { service, fetchMock, sleepFn } = createService({ + investment, + lockedInvestment, + transactions: [], + fetchResponses: [ + createJsonResponse({}, 503), + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0000000", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + sleepFn: jest.fn().mockResolvedValue(undefined), + }); + + const result = await service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + }); + + expect(result.outcome).toBe("verified"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(sleepFn).toHaveBeenCalledTimes(1); + expect(sleepFn).toHaveBeenCalledWith(mockConfig.retryBaseDelayMs); + }); + + it("should retry on network timeout and succeed", async () => { + const investment = createMockInvestment(); + const lockedInvestment = createMockInvestment(); + + let callCount = 0; + const mockFetch = jest.fn().mockImplementation(() => { + callCount += 1; + if (callCount === 1) { + return Promise.reject(new Error("socket timeout")); + } + if (callCount === 2) { + return Promise.resolve(createJsonResponse({ successful: true })); + } + return Promise.resolve(createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0000000", + to: TEST_ESCROW_KEY, + }, + ], + }, + })); + }) as unknown as jest.MockedFunction; + + const reader = createMockInvestmentReader(investment); + const runner = createMockTransactionRunner(lockedInvestment, []); + + const service = new VerifyPaymentService({ + investmentReader: reader, + transactionRunner: runner, + config: mockConfig, + fetchImplementation: mockFetch, + sleep: jest.fn().mockResolvedValue(undefined), + }); + + const result = await service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + }); + + expect(result.outcome).toBe("verified"); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it("should throw horizon_unavailable after exhausting retries", async () => { + const investment = createMockInvestment(); + + const { service, fetchMock } = createService({ + investment, + fetchResponses: [ + createJsonResponse({}, 503), + createJsonResponse({}, 503), + createJsonResponse({}, 503), + ], + sleepFn: jest.fn().mockResolvedValue(undefined), + }); + + await expect(service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + })).rejects.toMatchObject({ + code: "horizon_unavailable", + statusCode: 503, + }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("should not retry on non-retryable error (400)", async () => { + const investment = createMockInvestment(); + + const { service, fetchMock } = createService({ + investment, + fetchResponses: [ + createJsonResponse({}, 400), + ], + }); + + await expect(service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + })).rejects.toMatchObject({ + code: "horizon_request_failed", + statusCode: 502, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("should retry on 429 rate limit", async () => { + const investment = createMockInvestment(); + const lockedInvestment = createMockInvestment(); + + const { service, fetchMock } = createService({ + investment, + lockedInvestment, + transactions: [], + fetchResponses: [ + createJsonResponse({}, 429), + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0000000", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + sleepFn: jest.fn().mockResolvedValue(undefined), + }); + + const result = await service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + }); + + expect(result.outcome).toBe("verified"); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + }); + + describe("transaction state transitions", () => { + it("should throw reconciliation_conflict when multiple transactions linked", async () => { + const investment = createMockInvestment(); + const lockedInvestment = createMockInvestment(); + + const { service } = createService({ + investment, + lockedInvestment, + transactions: [createMockTransaction(), createMockTransaction({ id: "tx-002" })], + fetchResponses: [ + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0000000", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + }); + + await expect(service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + })).rejects.toMatchObject({ + code: "reconciliation_conflict", + statusCode: 409, + }); + }); + + it("should throw reconciliation_conflict when existing tx linked to different hash", async () => { + const investment = createMockInvestment(); + const lockedInvestment = createMockInvestment(); + + const existingTx = createMockTransaction({ stellarTxHash: "different-hash" }); + + const { service } = createService({ + investment, + lockedInvestment, + transactions: [existingTx], + fetchResponses: [ + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0000000", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + }); + + await expect(service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + })).rejects.toMatchObject({ + code: "reconciliation_conflict", + statusCode: 409, + }); + }); + + it("should return already_verified when locked investment already confirmed", async () => { + const investment = createMockInvestment(); + const lockedInvestment = createMockInvestment({ + status: InvestmentStatus.CONFIRMED, + transactionHash: TEST_TX_HASH, + stellarOperationIndex: 0, + }); + + const existingTx = createMockTransaction({ id: "existing-tx" }); + + const { service } = createService({ + investment, + lockedInvestment, + transactions: [existingTx], + fetchResponses: [ + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0000000", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + }); + + const result = await service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + }); + + expect(result.outcome).toBe("already_verified"); + }); + }); + + describe("operation index disambiguation", () => { + it("should match specific operation when operationIndex provided", async () => { + const investment = createMockInvestment(); + const lockedInvestment = createMockInvestment(); + + const { service } = createService({ + investment, + lockedInvestment, + transactions: [], + fetchResponses: [ + createJsonResponse({ successful: true }), + createJsonResponse({ + _embedded: { + records: [ + { + type: "create_account", + asset_code: "XLM", + }, + { + type: "payment", + asset_code: "USDC", + asset_issuer: TEST_USDC_ISSUER, + amount: "500.0000000", + to: TEST_ESCROW_KEY, + }, + ], + }, + }), + ], + }); + + const result = await service.verifyPayment({ + investmentId: TEST_INVESTMENT_ID, + stellarTxHash: TEST_TX_HASH, + operationIndex: 1, + }); + + expect(result.outcome).toBe("verified"); + expect(result.operationIndex).toBe(1); + }); + }); +});