diff --git a/.env.example b/.env.example index e984d46..539be66 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,12 @@ DATABASE_URL=postgresql://user:password@localhost:5432/stellarsettle # Stellar STELLAR_NETWORK=testnet STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org +STELLAR_USDC_ASSET_CODE=USDC +STELLAR_USDC_ASSET_ISSUER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +STELLAR_ESCROW_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +STELLAR_VERIFY_ALLOWED_AMOUNT_DELTA=0.0001 +STELLAR_VERIFY_RETRY_ATTEMPTS=3 +STELLAR_VERIFY_RETRY_BASE_DELAY_MS=250 PLATFORM_SECRET_KEY=SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # Smart Contracts diff --git a/Readme.md b/Readme.md index d0478b7..2219696 100644 --- a/Readme.md +++ b/Readme.md @@ -90,6 +90,12 @@ DATABASE_URL=postgresql://user:password@localhost:5432/stellarsettle # Stellar STELLAR_NETWORK=testnet STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org +STELLAR_USDC_ASSET_CODE=USDC +STELLAR_USDC_ASSET_ISSUER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +STELLAR_ESCROW_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +STELLAR_VERIFY_ALLOWED_AMOUNT_DELTA=0.0001 +STELLAR_VERIFY_RETRY_ATTEMPTS=3 +STELLAR_VERIFY_RETRY_BASE_DELAY_MS=250 PLATFORM_SECRET_KEY=SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # Smart Contracts @@ -216,4 +222,4 @@ MIT License - see [LICENSE](LICENSE) file for details --- -Built with ❤️ on Stellar \ No newline at end of file +Built with ❤️ on Stellar diff --git a/src/config/stellar.ts b/src/config/stellar.ts new file mode 100644 index 0000000..4c51163 --- /dev/null +++ b/src/config/stellar.ts @@ -0,0 +1,65 @@ +export interface PaymentVerificationConfig { + horizonUrl: string; + usdcAssetCode: string; + usdcAssetIssuer: string; + escrowPublicKey: string; + allowedAmountDelta: string; + retryAttempts: number; + retryBaseDelayMs: number; +} + +const DEFAULT_ALLOWED_AMOUNT_DELTA = "0.0001"; +const DEFAULT_RETRY_ATTEMPTS = 3; +const DEFAULT_RETRY_BASE_DELAY_MS = 250; + +function requireEnv(value: string | undefined, name: string): string { + if (!value) { + throw new Error(`${name} is required.`); + } + + return value; +} + +function parsePositiveInteger(value: string | undefined, fallback: number, name: string): number { + if (!value) { + return fallback; + } + + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer.`); + } + + return parsed; +} + +export function getPaymentVerificationConfig(): PaymentVerificationConfig { + return { + horizonUrl: requireEnv(process.env.STELLAR_HORIZON_URL, "STELLAR_HORIZON_URL"), + usdcAssetCode: requireEnv( + process.env.STELLAR_USDC_ASSET_CODE, + "STELLAR_USDC_ASSET_CODE", + ), + usdcAssetIssuer: requireEnv( + process.env.STELLAR_USDC_ASSET_ISSUER, + "STELLAR_USDC_ASSET_ISSUER", + ), + escrowPublicKey: requireEnv( + process.env.STELLAR_ESCROW_PUBLIC_KEY, + "STELLAR_ESCROW_PUBLIC_KEY", + ), + allowedAmountDelta: + process.env.STELLAR_VERIFY_ALLOWED_AMOUNT_DELTA ?? DEFAULT_ALLOWED_AMOUNT_DELTA, + retryAttempts: parsePositiveInteger( + process.env.STELLAR_VERIFY_RETRY_ATTEMPTS, + DEFAULT_RETRY_ATTEMPTS, + "STELLAR_VERIFY_RETRY_ATTEMPTS", + ), + retryBaseDelayMs: parsePositiveInteger( + process.env.STELLAR_VERIFY_RETRY_BASE_DELAY_MS, + DEFAULT_RETRY_BASE_DELAY_MS, + "STELLAR_VERIFY_RETRY_BASE_DELAY_MS", + ), + }; +} diff --git a/src/migrations/1731700000000-AddInvestmentPaymentVerification.ts b/src/migrations/1731700000000-AddInvestmentPaymentVerification.ts new file mode 100644 index 0000000..a5dab1f --- /dev/null +++ b/src/migrations/1731700000000-AddInvestmentPaymentVerification.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddInvestmentPaymentVerification1731700000000 + implements MigrationInterface +{ + name = "AddInvestmentPaymentVerification1731700000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "investments" + ADD COLUMN "stellar_operation_index" integer; + `); + + await queryRunner.query(` + ALTER TABLE "transactions" + ADD COLUMN "investment_id" uuid, + ADD COLUMN "stellar_operation_index" integer; + `); + + await queryRunner.query(` + CREATE INDEX "idx_transactions_investment_id" + ON "transactions" ("investment_id"); + `); + + await queryRunner.query(` + ALTER TABLE "transactions" + ADD CONSTRAINT "FK_transactions_investment" + FOREIGN KEY ("investment_id") REFERENCES "investments"("id") + ON DELETE SET NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "transactions" + DROP CONSTRAINT "FK_transactions_investment"; + `); + + await queryRunner.query(` + DROP INDEX "public"."idx_transactions_investment_id"; + `); + + await queryRunner.query(` + ALTER TABLE "transactions" + DROP COLUMN "stellar_operation_index", + DROP COLUMN "investment_id"; + `); + + await queryRunner.query(` + ALTER TABLE "investments" + DROP COLUMN "stellar_operation_index"; + `); + } +} diff --git a/src/models/Investment.model.ts b/src/models/Investment.model.ts index 9c639ae..6cedf8f 100644 --- a/src/models/Investment.model.ts +++ b/src/models/Investment.model.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, DeleteDateColumn, ManyToOne, + OneToMany, JoinColumn, Index, } from "typeorm"; @@ -46,6 +47,9 @@ export class Investment { @Column({ name: "transaction_hash", type: "varchar", length: 64, nullable: true }) transactionHash!: string | null; + @Column({ name: "stellar_operation_index", type: "integer", nullable: true }) + stellarOperationIndex!: number | null; + @CreateDateColumn({ name: "created_at" }) createdAt!: Date; @@ -62,4 +66,7 @@ export class Investment { @ManyToOne("User", "investments", { onDelete: "CASCADE" }) @JoinColumn({ name: "investor_id" }) investor!: User; + + @OneToMany("Transaction", "investment") + transactions!: import("./Transaction.model").Transaction[]; } diff --git a/src/models/Transaction.model.ts b/src/models/Transaction.model.ts index becbe29..791afc2 100644 --- a/src/models/Transaction.model.ts +++ b/src/models/Transaction.model.ts @@ -7,6 +7,7 @@ import { Index, } from "typeorm"; import { TransactionType, TransactionStatus } from "../types/enums"; +import type { Investment } from "./Investment.model"; @Entity("transactions") export class Transaction { @@ -17,6 +18,10 @@ export class Transaction { @Index("idx_transactions_user_id") userId!: string; + @Column({ name: "investment_id", type: "uuid", nullable: true }) + @Index("idx_transactions_investment_id") + investmentId!: string | null; + @Column({ type: "enum", enum: TransactionType, @@ -30,6 +35,9 @@ export class Transaction { @Column({ name: "stellar_tx_hash", type: "varchar", length: 64, nullable: true }) stellarTxHash!: string | null; + @Column({ name: "stellar_operation_index", type: "integer", nullable: true }) + stellarOperationIndex!: number | null; + @Column({ type: "enum", enum: TransactionStatus, @@ -44,4 +52,8 @@ export class Transaction { @ManyToOne("User", "transactions", { onDelete: "CASCADE" }) @JoinColumn({ name: "user_id" }) user!: import("./User.model").User; + + @ManyToOne("Investment", "transactions", { onDelete: "SET NULL", nullable: true }) + @JoinColumn({ name: "investment_id" }) + investment!: Investment | null; } diff --git a/src/services/stellar/verify-payment.service.ts b/src/services/stellar/verify-payment.service.ts new file mode 100644 index 0000000..ce70b78 --- /dev/null +++ b/src/services/stellar/verify-payment.service.ts @@ -0,0 +1,436 @@ +import { DataSource, Repository } from "typeorm"; +import type { PaymentVerificationConfig } from "../../config/stellar"; +import { Investment } from "../../models/Investment.model"; +import { Transaction } from "../../models/Transaction.model"; +import { InvestmentStatus, TransactionStatus, TransactionType } from "../../types/enums"; +import { ServiceError } from "../../utils/service-error"; + +type FetchLike = typeof fetch; +type SleepFn = (ms: number) => Promise; + +interface HorizonTransactionResponse { + successful: boolean; +} + +interface HorizonPaymentOperation { + id?: string; + type: string; + asset_code?: string; + asset_issuer?: string; + amount?: string; + to?: string; +} + +interface HorizonOperationsResponse { + _embedded?: { + records?: HorizonPaymentOperation[]; + }; +} + +export interface PaymentVerificationInput { + investmentId: string; + stellarTxHash: string; + operationIndex?: number; +} + +export interface PaymentVerificationResult { + outcome: "verified" | "already_verified"; + investmentId: string; + stellarTxHash: string; + operationIndex: number; + transactionId: string; + status: InvestmentStatus.CONFIRMED; +} + +interface PaymentMatch { + operationIndex: number; + amount: string; + destination: string; + assetCode: string; + assetIssuer: string; +} + +interface InvestmentReader { + findById(investmentId: string): Promise; +} + +interface PaymentVerificationUnitOfWork { + findInvestmentByIdForUpdate(investmentId: string): Promise; + findTransactionsByInvestmentIdForUpdate(investmentId: string): Promise; + saveInvestment(investment: Investment): Promise; + saveTransaction(transaction: Transaction): Promise; + createTransaction(input: Partial): Transaction; +} + +interface PaymentTransactionRunner { + runInTransaction( + callback: (unitOfWork: PaymentVerificationUnitOfWork) => Promise, + ): Promise; +} + +interface VerifyPaymentServiceDependencies { + investmentReader: InvestmentReader; + transactionRunner: PaymentTransactionRunner; + config: PaymentVerificationConfig; + fetchImplementation?: FetchLike; + sleep?: SleepFn; +} + +export class VerifyPaymentService { + private readonly investmentReader: InvestmentReader; + private readonly transactionRunner: PaymentTransactionRunner; + private readonly config: PaymentVerificationConfig; + private readonly fetchImplementation: FetchLike; + private readonly sleep: SleepFn; + + constructor(dependencies: VerifyPaymentServiceDependencies) { + this.investmentReader = dependencies.investmentReader; + this.transactionRunner = dependencies.transactionRunner; + this.config = dependencies.config; + this.fetchImplementation = dependencies.fetchImplementation ?? fetch; + this.sleep = dependencies.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + } + + async verifyPayment( + input: PaymentVerificationInput, + ): Promise { + const investment = await this.investmentReader.findById(input.investmentId); + + if (!investment) { + throw new ServiceError("investment_not_found", "Investment not found.", 404); + } + + if (investment.status === InvestmentStatus.CONFIRMED) { + if ( + investment.transactionHash === input.stellarTxHash && + investment.stellarOperationIndex === (input.operationIndex ?? investment.stellarOperationIndex) + ) { + return { + outcome: "already_verified", + investmentId: investment.id, + stellarTxHash: input.stellarTxHash, + operationIndex: investment.stellarOperationIndex ?? input.operationIndex ?? 0, + transactionId: "", + status: InvestmentStatus.CONFIRMED, + }; + } + + throw new ServiceError( + "reconciliation_conflict", + "Investment is already confirmed with a different Stellar payment.", + 409, + ); + } + + const matchedPayment = await this.fetchAndValidatePayment( + input.stellarTxHash, + investment.investmentAmount, + input.operationIndex, + ); + + return this.transactionRunner.runInTransaction(async (unitOfWork) => { + const lockedInvestment = await unitOfWork.findInvestmentByIdForUpdate(input.investmentId); + + if (!lockedInvestment) { + throw new ServiceError("investment_not_found", "Investment not found.", 404); + } + + const linkedTransactions = await unitOfWork.findTransactionsByInvestmentIdForUpdate( + lockedInvestment.id, + ); + + if (linkedTransactions.length > 1) { + throw new ServiceError( + "reconciliation_conflict", + "Multiple transaction rows are linked to the same investment.", + 409, + ); + } + + if (lockedInvestment.status === InvestmentStatus.CONFIRMED) { + const transaction = linkedTransactions[0]; + + if ( + lockedInvestment.transactionHash === input.stellarTxHash && + lockedInvestment.stellarOperationIndex === matchedPayment.operationIndex + ) { + return { + outcome: "already_verified" as const, + investmentId: lockedInvestment.id, + stellarTxHash: input.stellarTxHash, + operationIndex: matchedPayment.operationIndex, + transactionId: transaction?.id ?? "", + status: InvestmentStatus.CONFIRMED as const, + }; + } + + throw new ServiceError( + "reconciliation_conflict", + "Investment was confirmed by another transaction while verification was in progress.", + 409, + ); + } + + const existingTransaction = linkedTransactions[0]; + + if ( + existingTransaction && + existingTransaction.stellarTxHash && + existingTransaction.stellarTxHash !== input.stellarTxHash + ) { + throw new ServiceError( + "reconciliation_conflict", + "Transaction row is already linked to a different Stellar hash.", + 409, + ); + } + + const transaction = + existingTransaction ?? + unitOfWork.createTransaction({ + investmentId: lockedInvestment.id, + userId: lockedInvestment.investorId, + type: TransactionType.INVESTMENT, + amount: lockedInvestment.investmentAmount, + status: TransactionStatus.PENDING, + }); + + transaction.userId = lockedInvestment.investorId; + transaction.investmentId = lockedInvestment.id; + transaction.type = TransactionType.INVESTMENT; + transaction.amount = lockedInvestment.investmentAmount; + transaction.status = TransactionStatus.COMPLETED; + transaction.stellarTxHash = input.stellarTxHash; + transaction.stellarOperationIndex = matchedPayment.operationIndex; + + lockedInvestment.status = InvestmentStatus.CONFIRMED; + lockedInvestment.transactionHash = input.stellarTxHash; + lockedInvestment.stellarOperationIndex = matchedPayment.operationIndex; + + const savedTransaction = await unitOfWork.saveTransaction(transaction); + await unitOfWork.saveInvestment(lockedInvestment); + + return { + outcome: "verified" as const, + investmentId: lockedInvestment.id, + stellarTxHash: input.stellarTxHash, + operationIndex: matchedPayment.operationIndex, + transactionId: savedTransaction.id, + status: InvestmentStatus.CONFIRMED as const, + }; + }); + } + + private async fetchAndValidatePayment( + stellarTxHash: string, + expectedAmount: string, + operationIndex?: number, + ): Promise { + const transaction = await this.fetchJson( + `/transactions/${stellarTxHash}`, + ); + + if (!transaction.successful) { + throw new ServiceError( + "invalid_payment", + "The Stellar transaction was not successful.", + 422, + ); + } + + const operations = await this.fetchJson( + `/transactions/${stellarTxHash}/operations?limit=200&order=asc`, + ); + + const paymentOperations = (operations._embedded?.records ?? []) + .map((operation, index) => ({ + ...operation, + operationIndex: index, + })) + .filter((operation) => operation.type === "payment"); + + const matchingOperations = paymentOperations.filter((operation) => { + if (operationIndex !== undefined && operation.operationIndex !== operationIndex) { + return false; + } + + return ( + operation.asset_code === this.config.usdcAssetCode && + operation.asset_issuer === this.config.usdcAssetIssuer && + operation.to === this.config.escrowPublicKey && + operation.amount !== undefined && + amountsWithinDelta( + operation.amount, + expectedAmount, + this.config.allowedAmountDelta, + ) + ); + }); + + if (matchingOperations.length === 0) { + throw new ServiceError( + "invalid_payment", + "No Stellar payment operation matched the expected asset, amount, and destination.", + 422, + ); + } + + if (matchingOperations.length > 1) { + throw new ServiceError( + "invalid_payment", + "Multiple payment operations matched. Supply operationIndex to disambiguate.", + 422, + ); + } + + const match = matchingOperations[0]; + + return { + operationIndex: match.operationIndex, + amount: match.amount ?? expectedAmount, + destination: match.to ?? "", + assetCode: match.asset_code ?? "", + assetIssuer: match.asset_issuer ?? "", + }; + } + + private async fetchJson(path: string): Promise { + const url = new URL(path, ensureTrailingSlash(this.config.horizonUrl)).toString(); + + for (let attempt = 1; attempt <= this.config.retryAttempts; attempt += 1) { + try { + const response = await this.fetchImplementation(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (response.status === 404) { + throw new ServiceError( + "transaction_not_found", + "The Stellar transaction could not be found in Horizon.", + 404, + ); + } + + if (response.status >= 500 || response.status === 429) { + throw new RetryableHorizonError(`Transient Horizon response: ${response.status}`); + } + + if (!response.ok) { + throw new ServiceError( + "horizon_request_failed", + "Horizon rejected the verification request.", + 502, + ); + } + + return (await response.json()) as T; + } catch (error) { + if (error instanceof ServiceError) { + throw error; + } + + if (attempt === this.config.retryAttempts) { + throw new ServiceError( + "horizon_unavailable", + "Horizon is temporarily unavailable. Please retry later.", + 503, + ); + } + + await this.sleep(this.config.retryBaseDelayMs * 2 ** (attempt - 1)); + } + } + + throw new ServiceError( + "horizon_unavailable", + "Horizon is temporarily unavailable. Please retry later.", + 503, + ); + } +} + +class TypeOrmInvestmentReader implements InvestmentReader { + constructor(private readonly repository: Repository) {} + + findById(investmentId: string): Promise { + return this.repository.findOne({ + where: { id: investmentId }, + }); + } +} + +class TypeOrmTransactionRunner implements PaymentTransactionRunner { + constructor(private readonly dataSource: DataSource) {} + + runInTransaction( + callback: (unitOfWork: PaymentVerificationUnitOfWork) => Promise, + ): Promise { + return this.dataSource.transaction(async (manager) => + callback({ + findInvestmentByIdForUpdate: (investmentId: string) => + manager.getRepository(Investment).findOne({ + where: { id: investmentId }, + }), + findTransactionsByInvestmentIdForUpdate: (investmentId: string) => + manager.getRepository(Transaction).find({ + where: { investmentId }, + }), + saveInvestment: (investment: Investment) => + manager.getRepository(Investment).save(investment), + saveTransaction: (transaction: Transaction) => + manager.getRepository(Transaction).save(transaction), + createTransaction: (input: Partial) => + manager.getRepository(Transaction).create(input), + }), + ); + } +} + +export function createVerifyPaymentService( + dataSource: DataSource, + config: PaymentVerificationConfig, +): VerifyPaymentService { + return new VerifyPaymentService({ + investmentReader: new TypeOrmInvestmentReader(dataSource.getRepository(Investment)), + transactionRunner: new TypeOrmTransactionRunner(dataSource), + config, + }); +} + +class RetryableHorizonError extends Error {} + +function ensureTrailingSlash(value: string): string { + return value.endsWith("/") ? value : `${value}/`; +} + +function amountsWithinDelta(actual: string, expected: string, delta: string): boolean { + const scale = 7; + const actualValue = toScaledBigInt(actual, scale); + const expectedValue = toScaledBigInt(expected, scale); + const deltaValue = toScaledBigInt(delta, scale); + + const difference = actualValue >= expectedValue + ? actualValue - expectedValue + : expectedValue - actualValue; + + return difference <= deltaValue; +} + +function toScaledBigInt(value: string, scale: number): bigint { + const normalized = value.trim(); + + if (!/^-?\d+(\.\d+)?$/.test(normalized)) { + throw new ServiceError("invalid_amount", `Invalid decimal amount: ${value}`, 500); + } + + const isNegative = normalized.startsWith("-"); + const unsignedValue = isNegative ? normalized.slice(1) : normalized; + const [wholePart, fractionalPart = ""] = unsignedValue.split("."); + const paddedFraction = `${fractionalPart}${"0".repeat(scale)}`.slice(0, scale); + const scaled = BigInt(`${wholePart}${paddedFraction}`); + + return isNegative ? -scaled : scaled; +} diff --git a/src/utils/service-error.ts b/src/utils/service-error.ts new file mode 100644 index 0000000..a51551d --- /dev/null +++ b/src/utils/service-error.ts @@ -0,0 +1,13 @@ +export class ServiceError extends Error { + code: string; + statusCode: number; + details?: unknown; + + constructor(code: string, message: string, statusCode = 400, details?: unknown) { + super(message); + this.name = "ServiceError"; + this.code = code; + this.statusCode = statusCode; + this.details = details; + } +} diff --git a/tests/verify-payment.service.test.ts b/tests/verify-payment.service.test.ts new file mode 100644 index 0000000..dccd39e --- /dev/null +++ b/tests/verify-payment.service.test.ts @@ -0,0 +1,286 @@ +import crypto from "crypto"; +import { Investment } from "../src/models/Investment.model"; +import { Transaction } from "../src/models/Transaction.model"; +import { VerifyPaymentService } from "../src/services/stellar/verify-payment.service"; +import { InvestmentStatus, TransactionStatus, TransactionType } from "../src/types/enums"; +import { ServiceError } from "../src/utils/service-error"; + +interface MockResponseInit { + ok: boolean; + status: number; + body: unknown; +} + +function createMockResponse({ ok, status, body }: MockResponseInit): Response { + return { + ok, + status, + json: async () => body, + } as Response; +} + +function createInvestment(overrides: Partial = {}): Investment { + return { + id: overrides.id ?? crypto.randomUUID(), + invoiceId: overrides.invoiceId ?? crypto.randomUUID(), + investorId: overrides.investorId ?? crypto.randomUUID(), + investmentAmount: overrides.investmentAmount ?? "100.0000", + expectedReturn: overrides.expectedReturn ?? "105.0000", + actualReturn: overrides.actualReturn ?? null, + status: overrides.status ?? InvestmentStatus.PENDING, + transactionHash: overrides.transactionHash ?? null, + stellarOperationIndex: overrides.stellarOperationIndex ?? null, + createdAt: overrides.createdAt ?? new Date(), + updatedAt: overrides.updatedAt ?? new Date(), + deletedAt: overrides.deletedAt ?? null, + invoice: overrides.invoice as Investment["invoice"], + investor: overrides.investor as Investment["investor"], + transactions: overrides.transactions ?? [], + }; +} + +function createTransaction(overrides: Partial = {}): Transaction { + return { + id: overrides.id ?? crypto.randomUUID(), + userId: overrides.userId ?? crypto.randomUUID(), + investmentId: overrides.investmentId ?? null, + type: overrides.type ?? TransactionType.INVESTMENT, + amount: overrides.amount ?? "100.0000", + stellarTxHash: overrides.stellarTxHash ?? null, + stellarOperationIndex: overrides.stellarOperationIndex ?? null, + status: overrides.status ?? TransactionStatus.PENDING, + timestamp: overrides.timestamp ?? new Date(), + user: overrides.user as Transaction["user"], + investment: overrides.investment as Transaction["investment"], + }; +} + +function createServiceContext() { + const investment = createInvestment(); + const transactions = new Map(); + const investmentStore = new Map([[investment.id, investment]]); + const sleep = jest.fn(async () => undefined); + const fetchImplementation = jest.fn, Parameters>(); + + const service = new VerifyPaymentService({ + investmentReader: { + findById: async (investmentId) => investmentStore.get(investmentId) ?? null, + }, + transactionRunner: { + runInTransaction: async (callback) => + callback({ + findInvestmentByIdForUpdate: async (investmentId) => + investmentStore.get(investmentId) ?? null, + findTransactionsByInvestmentIdForUpdate: async (investmentId) => + transactions.get(investmentId) ?? [], + saveInvestment: async (lockedInvestment) => { + investmentStore.set(lockedInvestment.id, lockedInvestment); + return lockedInvestment; + }, + saveTransaction: async (transaction) => { + const current = transactions.get(transaction.investmentId ?? "") ?? []; + if (!current.find((item) => item.id === transaction.id)) { + current.push(transaction); + } + transactions.set(transaction.investmentId ?? "", current); + return transaction; + }, + createTransaction: (input) => createTransaction(input), + }), + }, + config: { + horizonUrl: "https://horizon-testnet.stellar.org", + usdcAssetCode: "USDC", + usdcAssetIssuer: "GDUKMGUGDZQK6YHZZ7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXN5R4OT", + escrowPublicKey: "GCFXROWPUBKEYEXAMPLE7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXNOPE", + allowedAmountDelta: "0.0001", + retryAttempts: 3, + retryBaseDelayMs: 10, + }, + fetchImplementation, + sleep, + }); + + return { + service, + investment, + transactions, + fetchImplementation, + sleep, + investmentStore, + }; +} + +describe("VerifyPaymentService", () => { + it("verifies a Horizon payment and confirms the investment idempotently", async () => { + const context = createServiceContext(); + const stellarTxHash = "abc123"; + + context.fetchImplementation + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + body: { successful: true }, + }), + ) + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + body: { + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: + "GDUKMGUGDZQK6YHZZ7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXN5R4OT", + amount: "100.0000", + to: "GCFXROWPUBKEYEXAMPLE7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXNOPE", + }, + ], + }, + }, + }), + ); + + const result = await context.service.verifyPayment({ + investmentId: context.investment.id, + stellarTxHash, + }); + + expect(result.outcome).toBe("verified"); + expect(result.status).toBe(InvestmentStatus.CONFIRMED); + expect(context.investmentStore.get(context.investment.id)?.transactionHash).toBe( + stellarTxHash, + ); + + const savedTransactions = context.transactions.get(context.investment.id) ?? []; + expect(savedTransactions).toHaveLength(1); + expect(savedTransactions[0].status).toBe(TransactionStatus.COMPLETED); + expect(savedTransactions[0].stellarTxHash).toBe(stellarTxHash); + + const secondResult = await context.service.verifyPayment({ + investmentId: context.investment.id, + stellarTxHash, + operationIndex: 0, + }); + + expect(secondResult.outcome).toBe("already_verified"); + expect(context.transactions.get(context.investment.id)).toHaveLength(1); + }); + + it("retries transient Horizon failures up to three attempts", async () => { + const context = createServiceContext(); + + context.fetchImplementation + .mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 503, + body: {}, + }), + ) + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + body: { successful: true }, + }), + ) + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + body: { + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: + "GDUKMGUGDZQK6YHZZ7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXN5R4OT", + amount: "100.0000", + to: "GCFXROWPUBKEYEXAMPLE7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXNOPE", + }, + ], + }, + }, + }), + ); + + const result = await context.service.verifyPayment({ + investmentId: context.investment.id, + stellarTxHash: "retry-hash", + }); + + expect(result.outcome).toBe("verified"); + expect(context.fetchImplementation).toHaveBeenCalledTimes(3); + expect(context.sleep).toHaveBeenCalledTimes(1); + expect(context.sleep).toHaveBeenCalledWith(10); + }); + + it("returns stable service errors when Horizon cannot find the transaction", async () => { + const context = createServiceContext(); + + context.fetchImplementation.mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 404, + body: {}, + }), + ); + + await expect( + context.service.verifyPayment({ + investmentId: context.investment.id, + stellarTxHash: "missing-hash", + }), + ).rejects.toMatchObject({ + code: "transaction_not_found", + statusCode: 404, + }); + }); + + it("rejects payments that do not match the configured asset, amount, and destination", async () => { + const context = createServiceContext(); + + context.fetchImplementation + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + body: { successful: true }, + }), + ) + .mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + body: { + _embedded: { + records: [ + { + type: "payment", + asset_code: "XLM", + amount: "99.0000", + to: "GBADDESTINATION", + }, + ], + }, + }, + }), + ); + + await expect( + context.service.verifyPayment({ + investmentId: context.investment.id, + stellarTxHash: "bad-payment", + }), + ).rejects.toMatchObject({ + code: "invalid_payment", + statusCode: 422, + }); + }); +});