From 8f9b6363bc9fccb9e90a1ac7801a7da9bfb47266 Mon Sep 17 00:00:00 2001 From: AbdulmujibOladayo Date: Sat, 22 Aug 2026 10:22:15 +0100 Subject: [PATCH] feat(payments): add funding initiation and idempotent escrow submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /shipments/:id/payment (builds+simulates an unsigned fund_escrow transaction for the shipper to sign client-side, non-custodial) and POST /shipments/:id/payment/:paymentId/submit (accepts the signed XDR and submits it). A new FUNDING payment status is claimed atomically (PENDING -> FUNDING) before the chain call, so concurrent duplicate submit requests can never both reach the network — the loser gets a structured 409 instead. Stellar/escrow contract failures (insufficient balance, missing token allowance, contract rejection) map to typed 4xx errors instead of a generic 500. Shipment.price converts to the contract's i128 base units via a documented fixed 7-decimal shift (MVP, no live FX). Also adds a test-only shipper-signing path (ALLOW_TEST_SIGNING, disabled by default) so the flow is verifiable end-to-end on testnet before the frontend wallet-signing UI exists. Closes #1276 --- backend/.env.example | 5 + backend/src/app.module.ts | 7 + .../src/common/enums/payment-status.enum.ts | 5 + .../1724227200000-AddPaymentFundingFields.ts | 34 ++ .../src/payments/dto/submit-payment.dto.ts | 7 + .../dto/test-sign-and-submit-payment.dto.ts | 9 + .../src/payments/entities/payment.entity.ts | 9 + .../payments/errors/payment-flow.errors.ts | 132 +++++ .../payments/errors/stellar-error-mapper.ts | 41 ++ backend/src/payments/payments.controller.ts | 99 ++++ backend/src/payments/payments.module.ts | 7 +- backend/src/payments/payments.service.spec.ts | 470 ++++++++++++++++++ backend/src/payments/payments.service.ts | 294 ++++++++++- .../payments/settlement-asset.util.spec.ts | 15 + backend/src/payments/settlement-asset.util.ts | 16 + .../stellar/stellar-contract.service.spec.ts | 102 +++- .../src/stellar/stellar-contract.service.ts | 72 +++ 17 files changed, 1321 insertions(+), 3 deletions(-) create mode 100644 backend/src/migrations/1724227200000-AddPaymentFundingFields.ts create mode 100644 backend/src/payments/dto/submit-payment.dto.ts create mode 100644 backend/src/payments/dto/test-sign-and-submit-payment.dto.ts create mode 100644 backend/src/payments/errors/payment-flow.errors.ts create mode 100644 backend/src/payments/errors/stellar-error-mapper.ts create mode 100644 backend/src/payments/payments.controller.ts create mode 100644 backend/src/payments/payments.service.spec.ts create mode 100644 backend/src/payments/settlement-asset.util.spec.ts create mode 100644 backend/src/payments/settlement-asset.util.ts diff --git a/backend/.env.example b/backend/.env.example index 6b03dd4d..9fa4d91e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -42,3 +42,8 @@ STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 ESCROW_CONTRACT_ADDRESS=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX TOKEN_CONTRACT_ADDRESS=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX PLATFORM_ADMIN_SECRET=SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +# Payment funding (issue #1276) — test-only shipper-signing path, for +# verifying funding end-to-end on testnet before the frontend wallet-signing +# UI exists. Must never be true in production. +ALLOW_TEST_SIGNING=false diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9439cb9d..8773507a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -106,6 +106,13 @@ const throttlerErrorMessage = (context: ExecutionContext): string => { then: Joi.required(), otherwise: Joi.optional(), }), + // Test-only shipper-signing path for verifying the funding flow + // (issue #1276) end-to-end before the frontend wallet UI exists. + // Must never be true in production. + ALLOW_TEST_SIGNING: Joi.boolean() + .truthy('true') + .falsy('false') + .default(false), }), validationOptions: { allowUnknown: true, diff --git a/backend/src/common/enums/payment-status.enum.ts b/backend/src/common/enums/payment-status.enum.ts index 4d77e98f..6e0cd2ee 100644 --- a/backend/src/common/enums/payment-status.enum.ts +++ b/backend/src/common/enums/payment-status.enum.ts @@ -1,5 +1,10 @@ export enum PaymentStatus { PENDING = 'pending', + // Claimed for an in-flight chain submission — see PaymentsService.submitFunding. + // Distinct from PENDING so a concurrent duplicate submit request can detect + // "someone already claimed this" via an atomic conditional update instead + // of a read-then-write race (issue #1276 concurrency requirement). + FUNDING = 'funding', FUNDED = 'funded', RELEASED = 'released', REFUNDED = 'refunded', diff --git a/backend/src/migrations/1724227200000-AddPaymentFundingFields.ts b/backend/src/migrations/1724227200000-AddPaymentFundingFields.ts new file mode 100644 index 00000000..d4a28a79 --- /dev/null +++ b/backend/src/migrations/1724227200000-AddPaymentFundingFields.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPaymentFundingFields1724227200000 + implements MigrationInterface +{ + name = 'AddPaymentFundingFields1724227200000'; + + public async up(queryRunner: QueryRunner): Promise { + // FUNDING marks a payment claimed for an in-flight chain submission — + // see PaymentStatus enum comment / issue #1276's duplicate-submit + // concurrency requirement. Not used within this same transaction, so + // this is safe on PG12+ without the "unsafe use of new value" error. + await queryRunner.query(` + ALTER TYPE "public"."payment_status_enum" ADD VALUE IF NOT EXISTS 'funding' + `); + + await queryRunner.query(` + ALTER TABLE "payments" + ADD COLUMN "stellar_tx_hash" VARCHAR(64), + ADD COLUMN "failure_reason" VARCHAR(64) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "payments" + DROP COLUMN "failure_reason", + DROP COLUMN "stellar_tx_hash" + `); + // Postgres has no DROP VALUE for enums — reverting the 'funding' enum + // value would require recreating the type, which isn't safe to do + // automatically without knowing whether any row already uses it. + } +} diff --git a/backend/src/payments/dto/submit-payment.dto.ts b/backend/src/payments/dto/submit-payment.dto.ts new file mode 100644 index 00000000..ec5f1f03 --- /dev/null +++ b/backend/src/payments/dto/submit-payment.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class SubmitPaymentDto { + @IsString() + @IsNotEmpty() + signedXdr: string; +} diff --git a/backend/src/payments/dto/test-sign-and-submit-payment.dto.ts b/backend/src/payments/dto/test-sign-and-submit-payment.dto.ts new file mode 100644 index 00000000..5e83b7b0 --- /dev/null +++ b/backend/src/payments/dto/test-sign-and-submit-payment.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +// TEST ONLY — see PaymentsService.testSignAndSubmitFunding. Never used by +// the real (non-custodial) diner/shipper-facing flow. +export class TestSignAndSubmitPaymentDto { + @IsString() + @IsNotEmpty() + shipperSecret: string; +} diff --git a/backend/src/payments/entities/payment.entity.ts b/backend/src/payments/entities/payment.entity.ts index 4cae3c83..af7a25a8 100644 --- a/backend/src/payments/entities/payment.entity.ts +++ b/backend/src/payments/entities/payment.entity.ts @@ -63,6 +63,15 @@ export class Payment { @Column({ name: 'settled_at', type: 'timestamptz', nullable: true }) settledAt: Date | null; + // Set once submitFunding() successfully reaches Horizon/Soroban RPC. + @Column({ name: 'stellar_tx_hash', length: 64, nullable: true }) + stellarTxHash: string | null; + + // Typed failure code (see errors/payment-flow.errors.ts), not a raw + // stack trace — safe to surface back to the shipper-facing client. + @Column({ name: 'failure_reason', length: 64, nullable: true }) + failureReason: string | null; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/backend/src/payments/errors/payment-flow.errors.ts b/backend/src/payments/errors/payment-flow.errors.ts new file mode 100644 index 00000000..730d5d73 --- /dev/null +++ b/backend/src/payments/errors/payment-flow.errors.ts @@ -0,0 +1,132 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +/** + * Every structured payment-flow error carries a stable `code` in its + * response body, distinct from the HTTP status, so a client can branch on + * the specific failure (issue #1276: "actionable, distinguishable error, + * not generic 500"). + */ +export type PaymentFlowErrorCode = + | 'SHIPMENT_NOT_ACCEPTED' + | 'FORBIDDEN_PAYMENT_ACTION' + | 'PAYMENT_ALREADY_FUNDED' + | 'PAYMENT_ALREADY_IN_FLIGHT' + | 'MISSING_WALLET_ADDRESS' + | 'ESCROW_SIMULATION_FAILED' + | 'ESCROW_SUBMISSION_FAILED' + | 'ESCROW_CONTRACT_REJECTED'; + +export abstract class PaymentFlowError extends HttpException { + protected constructor( + readonly code: PaymentFlowErrorCode, + message: string, + status: HttpStatus, + details?: unknown, + ) { + super({ code, message, details }, status); + } +} + +export class ShipmentNotAcceptedError extends PaymentFlowError { + constructor() { + super( + 'SHIPMENT_NOT_ACCEPTED', + 'Funding requires the shipment to be in ACCEPTED status', + HttpStatus.BAD_REQUEST, + ); + } +} + +export class ForbiddenPaymentActionError extends PaymentFlowError { + constructor() { + super( + 'FORBIDDEN_PAYMENT_ACTION', + 'Only the shipment owner can fund this shipment', + HttpStatus.FORBIDDEN, + ); + } +} + +export class PaymentAlreadyFundedError extends PaymentFlowError { + constructor(readonly paymentId: string) { + super( + 'PAYMENT_ALREADY_FUNDED', + 'This shipment has already been funded', + HttpStatus.CONFLICT, + { paymentId }, + ); + } +} + +/** + * Thrown when a duplicate submit request loses the atomic PENDING→FUNDING + * claim (see PaymentsService.submitFunding) — the concurrency guarantee + * required by issue #1276 ("concurrent duplicate funding requests never + * produce two chain calls"). + */ +export class PaymentAlreadyInFlightError extends PaymentFlowError { + constructor(readonly paymentId: string) { + super( + 'PAYMENT_ALREADY_IN_FLIGHT', + 'A funding submission for this shipment is already in progress', + HttpStatus.CONFLICT, + { paymentId }, + ); + } +} + +export class MissingWalletAddressError extends PaymentFlowError { + constructor(readonly party: 'shipper' | 'carrier') { + super( + 'MISSING_WALLET_ADDRESS', + `The ${party} has not configured a Stellar wallet address`, + HttpStatus.UNPROCESSABLE_ENTITY, + { party }, + ); + } +} + +/** + * Simulation failed before anything was submitted — most commonly the + * shipper's token balance or `approve` allowance to the escrow contract is + * insufficient (the escrow contract's `fund_escrow` calls + * `token.transfer_from`, which panics inside the token contract, not the + * escrow contract, for either case). + */ +export class EscrowSimulationFailedError extends PaymentFlowError { + constructor(details?: unknown) { + super( + 'ESCROW_SIMULATION_FAILED', + "The funding transaction could not be simulated — check the shipper wallet's balance and token allowance", + HttpStatus.UNPROCESSABLE_ENTITY, + details, + ); + } +} + +export class EscrowSubmissionFailedError extends PaymentFlowError { + constructor(details?: unknown) { + super( + 'ESCROW_SUBMISSION_FAILED', + 'The funding transaction was rejected by the network', + HttpStatus.UNPROCESSABLE_ENTITY, + details, + ); + } +} + +export class EscrowContractRejectedError extends PaymentFlowError { + constructor( + readonly escrowErrorCode: number, + message: string, + ) { + super( + 'ESCROW_CONTRACT_REJECTED', + message, + HttpStatus.UNPROCESSABLE_ENTITY, + { + escrowErrorCode, + }, + ); + } +} diff --git a/backend/src/payments/errors/stellar-error-mapper.ts b/backend/src/payments/errors/stellar-error-mapper.ts new file mode 100644 index 00000000..79708655 --- /dev/null +++ b/backend/src/payments/errors/stellar-error-mapper.ts @@ -0,0 +1,41 @@ +import { + ChainTimeoutError, + EscrowContractError, + SimulationError, + StellarIntegrationError, + SubmissionError, +} from '../../stellar/errors/stellar-integration.errors'; +import { + EscrowContractRejectedError, + EscrowSimulationFailedError, + EscrowSubmissionFailedError, + PaymentFlowError, +} from './payment-flow.errors'; + +/** + * Maps a StellarContractService failure to a structured, distinguishable + * PaymentFlowError instead of letting a generic 500 reach the shipper + * (issue #1276 acceptance criteria). + */ +export function mapStellarFundingError(error: unknown): PaymentFlowError { + if (error instanceof EscrowContractError) { + return new EscrowContractRejectedError(error.code, error.message); + } + if (error instanceof SimulationError) { + return new EscrowSimulationFailedError({ rawError: error.rawError }); + } + if (error instanceof SubmissionError || error instanceof ChainTimeoutError) { + return new EscrowSubmissionFailedError({ + rawResponse: + error instanceof SubmissionError ? error.rawResponse : undefined, + message: error.message, + }); + } + if (error instanceof StellarIntegrationError) { + return new EscrowSubmissionFailedError({ message: error.message }); + } + + return new EscrowSubmissionFailedError( + error instanceof Error ? error.message : String(error), + ); +} diff --git a/backend/src/payments/payments.controller.ts b/backend/src/payments/payments.controller.ts new file mode 100644 index 00000000..229d6f5c --- /dev/null +++ b/backend/src/payments/payments.controller.ts @@ -0,0 +1,99 @@ +import { + Body, + Controller, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiConflictResponse, + ApiForbiddenResponse, + ApiOperation, + ApiTags, + ApiUnprocessableEntityResponse, +} from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { UserRole } from '../common/enums/role.enum'; +import { User } from '../users/entities/user.entity'; +import { PaymentsService } from './payments.service'; +import { SubmitPaymentDto } from './dto/submit-payment.dto'; +import { TestSignAndSubmitPaymentDto } from './dto/test-sign-and-submit-payment.dto'; + +@ApiTags('payments') +@ApiBearerAuth() +@Controller('shipments/:shipmentId/payment') +@UseGuards(RolesGuard) +@Roles(UserRole.SHIPPER, UserRole.ADMIN) +export class PaymentsController { + constructor(private readonly paymentsService: PaymentsService) {} + + @Post() + @ApiOperation({ + summary: + 'Build an unsigned funding transaction for an accepted shipment (shipper only)', + description: + 'Returns unsigned XDR for the shipper wallet to sign client-side. Safe to call repeatedly while unfunded.', + }) + @ApiForbiddenResponse({ description: "Not this shipment's shipper" }) + @ApiConflictResponse({ description: 'Already funded or funding in progress' }) + @ApiUnprocessableEntityResponse({ + description: + 'Missing wallet address, insufficient balance/allowance, or contract rejection', + }) + initiate( + @Param('shipmentId', ParseUUIDPipe) shipmentId: string, + @CurrentUser() user: User, + ) { + return this.paymentsService.initiateFunding(shipmentId, user.id); + } + + @Post(':paymentId/submit') + @ApiOperation({ + summary: 'Submit the shipper-signed funding transaction (shipper only)', + description: + "Accepts the XDR signed by the shipper's own wallet and submits it to the escrow contract.", + }) + @ApiForbiddenResponse({ description: "Not this shipment's shipper" }) + @ApiConflictResponse({ description: 'Already funded or funding in progress' }) + @ApiUnprocessableEntityResponse({ + description: 'Submission rejected by the network or the escrow contract', + }) + submit( + @Param('shipmentId', ParseUUIDPipe) shipmentId: string, + @Param('paymentId', ParseUUIDPipe) paymentId: string, + @CurrentUser() user: User, + @Body() dto: SubmitPaymentDto, + ) { + return this.paymentsService.submitFunding( + shipmentId, + paymentId, + user.id, + dto.signedXdr, + ); + } + + @Post(':paymentId/test-sign-and-submit') + @ApiOperation({ + summary: + 'TEST ONLY — signs with a provided secret and submits (shipper only)', + description: + 'Disabled unless ALLOW_TEST_SIGNING="true". Lets the funding flow be verified end-to-end on testnet before the frontend wallet-signing UI exists. Never use in production.', + }) + testSignAndSubmit( + @Param('shipmentId', ParseUUIDPipe) shipmentId: string, + @Param('paymentId', ParseUUIDPipe) paymentId: string, + @CurrentUser() user: User, + @Body() dto: TestSignAndSubmitPaymentDto, + ) { + return this.paymentsService.testSignAndSubmitFunding( + shipmentId, + paymentId, + user.id, + dto.shipperSecret, + ); + } +} diff --git a/backend/src/payments/payments.module.ts b/backend/src/payments/payments.module.ts index 8207b3f9..1bf1adf6 100644 --- a/backend/src/payments/payments.module.ts +++ b/backend/src/payments/payments.module.ts @@ -1,10 +1,15 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PaymentsService } from './payments.service'; +import { PaymentsController } from './payments.controller'; import { Payment } from './entities/payment.entity'; +import { Shipment } from '../shipments/entities/shipment.entity'; +import { User } from '../users/entities/user.entity'; +import { StellarModule } from '../stellar/stellar.module'; @Module({ - imports: [TypeOrmModule.forFeature([Payment])], + imports: [TypeOrmModule.forFeature([Payment, Shipment, User]), StellarModule], + controllers: [PaymentsController], providers: [PaymentsService], exports: [PaymentsService], }) diff --git a/backend/src/payments/payments.service.spec.ts b/backend/src/payments/payments.service.spec.ts new file mode 100644 index 00000000..94396514 --- /dev/null +++ b/backend/src/payments/payments.service.spec.ts @@ -0,0 +1,470 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Keypair } from '@stellar/stellar-sdk'; +import { PaymentsService } from './payments.service'; +import { Payment } from './entities/payment.entity'; +import { PaymentStatus } from '../common/enums/payment-status.enum'; +import { Shipment } from '../shipments/entities/shipment.entity'; +import { ShipmentStatus } from '../common/enums/shipment-status.enum'; +import { User } from '../users/entities/user.entity'; +import { StellarContractService } from '../stellar/stellar-contract.service'; +import { + EscrowContractRejectedError, + ForbiddenPaymentActionError, + MissingWalletAddressError, + PaymentAlreadyFundedError, + PaymentAlreadyInFlightError, + ShipmentNotAcceptedError, +} from './errors/payment-flow.errors'; +import { + EscrowContractError, + SimulationError, +} from '../stellar/errors/stellar-integration.errors'; +import { EscrowErrorCode } from '../stellar/errors/escrow-error-code.enum'; + +const mockPaymentRepo = () => ({ + create: jest.fn((data: Partial) => ({ ...data }) as Payment), + save: jest.fn( + (entity: Partial) => ({ id: 'payment-1', ...entity }) as Payment, + ), + findOne: jest.fn(), + update: jest.fn(), + createQueryBuilder: jest.fn(), +}); + +const mockShipmentRepo = () => ({ findOne: jest.fn() }); +const mockUserRepo = () => ({ findOne: jest.fn() }); +const mockStellarContractService = () => ({ + buildFundEscrowTransaction: jest.fn(), + submitSignedTransaction: jest.fn(), + fundEscrow: jest.fn(), +}); +const mockConfigService = (values: Record = {}) => ({ + get: jest.fn((key: string): string | undefined => values[key]), +}); + +const shipperKeypair = Keypair.random(); + +function makeShipment(overrides: Partial = {}): Shipment { + return { + id: 'shipment-1', + shipperId: 'shipper-1', + carrierId: 'carrier-1', + status: ShipmentStatus.ACCEPTED, + price: 100, + ...overrides, + } as Shipment; +} + +function makeUser(overrides: Partial = {}): User { + return { + id: 'shipper-1', + walletAddress: shipperKeypair.publicKey(), + ...overrides, + } as User; +} + +function makePayment(overrides: Partial = {}): Payment { + return { + id: 'payment-1', + shipmentId: 'shipment-1', + onChainShipmentId: 1, + status: PaymentStatus.PENDING, + amount: 100, + assetCode: 'USDC', + shipperWalletAddress: shipperKeypair.publicKey(), + carrierWalletAddress: 'GCARRIER', + ...overrides, + } as Payment; +} + +describe('PaymentsService', () => { + let service: PaymentsService; + let paymentRepo: ReturnType; + let shipmentRepo: ReturnType; + let userRepo: ReturnType; + let stellarContractService: ReturnType; + let config: ReturnType; + + beforeEach(async () => { + config = mockConfigService(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PaymentsService, + { provide: getRepositoryToken(Payment), useFactory: mockPaymentRepo }, + { + provide: getRepositoryToken(Shipment), + useFactory: mockShipmentRepo, + }, + { provide: getRepositoryToken(User), useFactory: mockUserRepo }, + { + provide: StellarContractService, + useFactory: mockStellarContractService, + }, + { provide: ConfigService, useValue: config }, + ], + }).compile(); + + service = module.get(PaymentsService); + paymentRepo = module.get(getRepositoryToken(Payment)); + shipmentRepo = module.get(getRepositoryToken(Shipment)); + userRepo = module.get(getRepositoryToken(User)); + stellarContractService = module.get(StellarContractService); + }); + + describe('initiateFunding', () => { + it('throws ForbiddenPaymentActionError when the requester is not the shipper', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + + await expect( + service.initiateFunding('shipment-1', 'someone-else'), + ).rejects.toThrow(ForbiddenPaymentActionError); + }); + + it('throws ShipmentNotAcceptedError when the shipment is not ACCEPTED', async () => { + shipmentRepo.findOne.mockResolvedValue( + makeShipment({ status: ShipmentStatus.PENDING }), + ); + + await expect( + service.initiateFunding('shipment-1', 'shipper-1'), + ).rejects.toThrow(ShipmentNotAcceptedError); + }); + + it('throws MissingWalletAddressError when the shipper has no wallet', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + userRepo.findOne.mockImplementation(({ where: { id } }) => + Promise.resolve( + id === 'shipper-1' + ? makeUser({ walletAddress: null }) + : makeUser({ id: 'carrier-1', walletAddress: 'GCARRIER' }), + ), + ); + + await expect( + service.initiateFunding('shipment-1', 'shipper-1'), + ).rejects.toThrow(MissingWalletAddressError); + }); + + it('throws MissingWalletAddressError when the carrier has no wallet', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + userRepo.findOne.mockImplementation(({ where: { id } }) => + Promise.resolve( + id === 'shipper-1' + ? makeUser() + : makeUser({ id: 'carrier-1', walletAddress: null }), + ), + ); + + await expect( + service.initiateFunding('shipment-1', 'shipper-1'), + ).rejects.toThrow(MissingWalletAddressError); + }); + + it('creates the payment row and returns unsigned XDR on first funding attempt', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + userRepo.findOne.mockImplementation(({ where: { id } }) => + Promise.resolve( + id === 'shipper-1' + ? makeUser() + : makeUser({ id: 'carrier-1', walletAddress: 'GCARRIER' }), + ), + ); + paymentRepo.findOne.mockResolvedValue(null); + paymentRepo.createQueryBuilder.mockReturnValue({ + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ next_id: '1' }), + }); + stellarContractService.buildFundEscrowTransaction.mockResolvedValue( + 'unsigned-xdr', + ); + + const result = await service.initiateFunding('shipment-1', 'shipper-1'); + + expect(result).toEqual({ + paymentId: 'payment-1', + status: PaymentStatus.PENDING, + unsignedXdr: 'unsigned-xdr', + }); + expect( + stellarContractService.buildFundEscrowTransaction, + ).toHaveBeenCalledWith( + shipperKeypair.publicKey(), + 'GCARRIER', + 1n, + 1_000_000_000n, // 100 * 10^7 + ); + }); + + it('throws PaymentAlreadyFundedError when the payment is already FUNDED', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + userRepo.findOne.mockImplementation(({ where: { id } }) => + Promise.resolve( + id === 'shipper-1' + ? makeUser() + : makeUser({ id: 'carrier-1', walletAddress: 'GCARRIER' }), + ), + ); + paymentRepo.findOne.mockResolvedValue( + makePayment({ status: PaymentStatus.FUNDED }), + ); + + await expect( + service.initiateFunding('shipment-1', 'shipper-1'), + ).rejects.toThrow(PaymentAlreadyFundedError); + }); + + it('throws PaymentAlreadyInFlightError when a submission is already in progress', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + userRepo.findOne.mockImplementation(({ where: { id } }) => + Promise.resolve( + id === 'shipper-1' + ? makeUser() + : makeUser({ id: 'carrier-1', walletAddress: 'GCARRIER' }), + ), + ); + paymentRepo.findOne.mockResolvedValue( + makePayment({ status: PaymentStatus.FUNDING }), + ); + + await expect( + service.initiateFunding('shipment-1', 'shipper-1'), + ).rejects.toThrow(PaymentAlreadyInFlightError); + }); + + it('refreshes a stale carrier binding on an existing PENDING row (carrier reassigned)', async () => { + shipmentRepo.findOne.mockResolvedValue( + makeShipment({ carrierId: 'new-carrier' }), + ); + userRepo.findOne.mockImplementation(({ where: { id } }) => + Promise.resolve( + id === 'shipper-1' + ? makeUser() + : makeUser({ id: 'new-carrier', walletAddress: 'GNEWCARRIER' }), + ), + ); + paymentRepo.findOne.mockResolvedValue( + makePayment({ carrierWalletAddress: 'GOLDCARRIER' }), + ); + stellarContractService.buildFundEscrowTransaction.mockResolvedValue( + 'unsigned-xdr', + ); + + await service.initiateFunding('shipment-1', 'shipper-1'); + + expect(paymentRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ carrierWalletAddress: 'GNEWCARRIER' }), + ); + expect( + stellarContractService.buildFundEscrowTransaction, + ).toHaveBeenCalledWith( + shipperKeypair.publicKey(), + 'GNEWCARRIER', + 1n, + expect.any(BigInt), + ); + }); + + it('maps a simulation failure (e.g. insufficient balance/allowance) to a structured error', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + userRepo.findOne.mockImplementation(({ where: { id } }) => + Promise.resolve( + id === 'shipper-1' + ? makeUser() + : makeUser({ id: 'carrier-1', walletAddress: 'GCARRIER' }), + ), + ); + paymentRepo.findOne.mockResolvedValue(makePayment()); + stellarContractService.buildFundEscrowTransaction.mockRejectedValue( + new SimulationError('sim failed', 'HostError: insufficient balance'), + ); + + await expect( + service.initiateFunding('shipment-1', 'shipper-1'), + ).rejects.toMatchObject({ code: 'ESCROW_SIMULATION_FAILED' }); + }); + }); + + describe('submitFunding', () => { + it('claims the payment, submits, and marks it FUNDED', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + paymentRepo.findOne.mockResolvedValue(makePayment()); + paymentRepo.update.mockResolvedValueOnce({ affected: 1 }); + stellarContractService.submitSignedTransaction.mockResolvedValue({ + txHash: 'tx-hash-1', + status: 'PENDING', + }); + + const result = await service.submitFunding( + 'shipment-1', + 'payment-1', + 'shipper-1', + 'signed-xdr', + ); + + expect(result).toEqual({ + paymentId: 'payment-1', + status: PaymentStatus.FUNDED, + stellarTxHash: 'tx-hash-1', + }); + expect(paymentRepo.update).toHaveBeenCalledWith( + { id: 'payment-1', status: PaymentStatus.PENDING }, + { status: PaymentStatus.FUNDING }, + ); + expect(paymentRepo.update).toHaveBeenCalledWith( + 'payment-1', + expect.objectContaining({ + status: PaymentStatus.FUNDED, + stellarTxHash: 'tx-hash-1', + }), + ); + }); + + it('throws ForbiddenPaymentActionError when the requester is not the shipper', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + + await expect( + service.submitFunding( + 'shipment-1', + 'payment-1', + 'someone-else', + 'signed-xdr', + ), + ).rejects.toThrow(ForbiddenPaymentActionError); + }); + + it('throws NotFoundException when the payment does not exist for this shipment', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + paymentRepo.findOne.mockResolvedValue(null); + + await expect( + service.submitFunding( + 'shipment-1', + 'missing-payment', + 'shipper-1', + 'signed-xdr', + ), + ).rejects.toThrow(NotFoundException); + }); + + it('throws PaymentAlreadyFundedError without attempting the claim when already FUNDED', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + paymentRepo.findOne.mockResolvedValue( + makePayment({ status: PaymentStatus.FUNDED }), + ); + + await expect( + service.submitFunding( + 'shipment-1', + 'payment-1', + 'shipper-1', + 'signed-xdr', + ), + ).rejects.toThrow(PaymentAlreadyFundedError); + expect(paymentRepo.update).not.toHaveBeenCalled(); + }); + + it('throws PaymentAlreadyInFlightError when the atomic claim loses the race (concurrent duplicate submit)', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + paymentRepo.findOne.mockResolvedValue(makePayment()); + // Another concurrent request already claimed it — 0 rows matched. + paymentRepo.update.mockResolvedValueOnce({ affected: 0 }); + + await expect( + service.submitFunding( + 'shipment-1', + 'payment-1', + 'shipper-1', + 'signed-xdr', + ), + ).rejects.toThrow(PaymentAlreadyInFlightError); + expect( + stellarContractService.submitSignedTransaction, + ).not.toHaveBeenCalled(); + }); + + it('releases the claim back to PENDING and maps the error on a contract rejection', async () => { + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + paymentRepo.findOne.mockResolvedValue(makePayment()); + paymentRepo.update.mockResolvedValueOnce({ affected: 1 }); + stellarContractService.submitSignedTransaction.mockRejectedValue( + new EscrowContractError( + EscrowErrorCode.AlreadyFunded, + 'HostError: Error(Contract, #4)', + ), + ); + + await expect( + service.submitFunding( + 'shipment-1', + 'payment-1', + 'shipper-1', + 'signed-xdr', + ), + ).rejects.toThrow(EscrowContractRejectedError); + + expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { + status: PaymentStatus.PENDING, + failureReason: 'ESCROW_CONTRACT_REJECTED', + }); + }); + }); + + describe('testSignAndSubmitFunding', () => { + it('is disabled unless ALLOW_TEST_SIGNING is "true"', async () => { + config.get.mockReturnValue(undefined); + + await expect( + service.testSignAndSubmitFunding( + 'shipment-1', + 'payment-1', + 'shipper-1', + shipperKeypair.secret(), + ), + ).rejects.toThrow(ForbiddenException); + }); + + it('rejects a secret that does not match the payment shipper wallet', async () => { + config.get.mockReturnValue('true'); + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + paymentRepo.findOne.mockResolvedValue(makePayment()); + + await expect( + service.testSignAndSubmitFunding( + 'shipment-1', + 'payment-1', + 'shipper-1', + Keypair.random().secret(), + ), + ).rejects.toThrow(ForbiddenException); + }); + + it('signs with the provided secret and submits via fundEscrow when enabled', async () => { + config.get.mockReturnValue('true'); + shipmentRepo.findOne.mockResolvedValue(makeShipment()); + paymentRepo.findOne.mockResolvedValue(makePayment()); + paymentRepo.update.mockResolvedValueOnce({ affected: 1 }); + stellarContractService.fundEscrow.mockResolvedValue({ + txHash: 'test-tx-hash', + status: 'PENDING', + }); + + const result = await service.testSignAndSubmitFunding( + 'shipment-1', + 'payment-1', + 'shipper-1', + shipperKeypair.secret(), + ); + + expect(result.status).toBe(PaymentStatus.FUNDED); + expect(stellarContractService.fundEscrow).toHaveBeenCalledWith( + expect.any(Keypair), + 'GCARRIER', + 1n, + expect.any(BigInt), + ); + }); + }); +}); diff --git a/backend/src/payments/payments.service.ts b/backend/src/payments/payments.service.ts index df3d22d7..29efc6cb 100644 --- a/backend/src/payments/payments.service.ts +++ b/backend/src/payments/payments.service.ts @@ -1,8 +1,51 @@ -import { Injectable, ConflictException, Logger } from '@nestjs/common'; +import { + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; import { Repository } from 'typeorm'; +import { Keypair } from '@stellar/stellar-sdk'; import { Payment } from './entities/payment.entity'; import { PaymentStatus } from '../common/enums/payment-status.enum'; +import { Shipment } from '../shipments/entities/shipment.entity'; +import { ShipmentStatus } from '../common/enums/shipment-status.enum'; +import { User } from '../users/entities/user.entity'; +import { StellarContractService } from '../stellar/stellar-contract.service'; +import { ContractCallResult } from '../stellar/escrow-record.interface'; +import { priceToBaseUnits } from './settlement-asset.util'; +import { mapStellarFundingError } from './errors/stellar-error-mapper'; +import { + ForbiddenPaymentActionError, + MissingWalletAddressError, + PaymentAlreadyFundedError, + PaymentAlreadyInFlightError, + ShipmentNotAcceptedError, +} from './errors/payment-flow.errors'; + +export interface InitiateFundingResult { + paymentId: string; + status: PaymentStatus; + unsignedXdr: string; +} + +export interface SubmitFundingResult { + paymentId: string; + status: PaymentStatus; + stellarTxHash: string | null; +} + +// A payment beyond this point is either funded or otherwise terminal for +// this issue's purposes — funding it again (or funding a still-in-flight +// one) is always a 4xx, never a fresh chain call. +const NON_FUNDABLE_STATUSES = new Set([ + PaymentStatus.FUNDED, + PaymentStatus.RELEASED, + PaymentStatus.REFUNDED, +]); @Injectable() export class PaymentsService { @@ -11,6 +54,12 @@ export class PaymentsService { constructor( @InjectRepository(Payment) private readonly paymentRepo: Repository, + @InjectRepository(Shipment) + private readonly shipmentRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly stellarContractService: StellarContractService, + private readonly config: ConfigService, ) {} async getOrCreatePayment( @@ -83,4 +132,247 @@ export class PaymentsService { .getRawOne<{ next_id: string }>(); return Number(result?.next_id ?? 1); } + + // ── Funding (issue #1276) ──────────────────────────────────────────────── + + /** + * Builds (and simulates) an unsigned `fund_escrow` transaction for the + * shipment's shipper to sign client-side — the platform never holds + * shipper keys (non-custodial deposit model). Safe to call repeatedly + * while the payment is still PENDING: simulation is read-only, so a + * double-click here never risks a duplicate chain call (that guarantee + * is enforced in submitFunding, where the actual chain call happens). + */ + async initiateFunding( + shipmentId: string, + requesterId: string, + ): Promise { + const shipment = await this.getShipment(shipmentId); + if (shipment.shipperId !== requesterId) { + throw new ForbiddenPaymentActionError(); + } + if (shipment.status !== ShipmentStatus.ACCEPTED) { + throw new ShipmentNotAcceptedError(); + } + + const [shipper, carrier] = await Promise.all([ + this.getUser(shipment.shipperId), + this.getUser(shipment.carrierId as string), + ]); + if (!shipper.walletAddress) { + throw new MissingWalletAddressError('shipper'); + } + if (!carrier.walletAddress) { + throw new MissingWalletAddressError('carrier'); + } + + const payment = await this.claimPaymentRowForFunding( + shipment, + carrier.walletAddress, + shipper.walletAddress, + ); + + const amount = priceToBaseUnits(Number(shipment.price)); + try { + const unsignedXdr = + await this.stellarContractService.buildFundEscrowTransaction( + shipper.walletAddress, + carrier.walletAddress, + BigInt(payment.onChainShipmentId), + amount, + ); + return { paymentId: payment.id, status: payment.status, unsignedXdr }; + } catch (error) { + throw mapStellarFundingError(error); + } + } + + /** + * Submits the shipper-signed `fund_escrow` XDR from initiateFunding. + * Atomically claims the payment row (PENDING → FUNDING) before making the + * chain call, so two concurrent submit requests for the same payment can + * never both submit — the loser sees PaymentAlreadyInFlightError instead + * (issue #1276's tested concurrency requirement). + */ + async submitFunding( + shipmentId: string, + paymentId: string, + requesterId: string, + signedXdr: string, + ): Promise { + const payment = await this.getFundablePayment( + shipmentId, + paymentId, + requesterId, + ); + return this.claimAndSubmit(payment, () => + this.stellarContractService.submitSignedTransaction(signedXdr), + ); + } + + /** + * Test-only path (issue #1276: "a shipper can fund a real testnet escrow + * end-to-end via the test-signing path") for verifying the funding flow + * before the frontend wallet UI (a later issue) exists to sign + * client-side. Disabled unless ALLOW_TEST_SIGNING="true" — never a + * substitute for the non-custodial submit endpoint above. + */ + async testSignAndSubmitFunding( + shipmentId: string, + paymentId: string, + requesterId: string, + shipperSecret: string, + ): Promise { + if (String(this.config.get('ALLOW_TEST_SIGNING')) !== 'true') { + throw new ForbiddenException( + 'Test-signing path is disabled (ALLOW_TEST_SIGNING is not "true")', + ); + } + + const payment = await this.getFundablePayment( + shipmentId, + paymentId, + requesterId, + ); + const signer = Keypair.fromSecret(shipperSecret); + if (signer.publicKey() !== payment.shipperWalletAddress) { + throw new ForbiddenException( + "The provided secret does not match this payment's shipper wallet address", + ); + } + + return this.claimAndSubmit(payment, () => + this.stellarContractService.fundEscrow( + signer, + payment.carrierWalletAddress as string, + BigInt(payment.onChainShipmentId), + priceToBaseUnits(Number(payment.amount)), + ), + ); + } + + private async getFundablePayment( + shipmentId: string, + paymentId: string, + requesterId: string, + ): Promise { + const shipment = await this.getShipment(shipmentId); + if (shipment.shipperId !== requesterId) { + throw new ForbiddenPaymentActionError(); + } + + const payment = await this.paymentRepo.findOne({ + where: { id: paymentId, shipmentId }, + }); + if (!payment) { + throw new NotFoundException( + `Payment ${paymentId} not found for shipment ${shipmentId}`, + ); + } + if (NON_FUNDABLE_STATUSES.has(payment.status)) { + throw new PaymentAlreadyFundedError(payment.id); + } + return payment; + } + + /** + * Atomically claims `payment` (PENDING → FUNDING) before invoking `submit` + * — the actual chain call — so two concurrent callers can never both + * submit for the same payment; the loser sees PaymentAlreadyInFlightError + * instead (issue #1276's tested concurrency requirement). On failure the + * claim is released back to PENDING so a legitimate retry is possible. + */ + private async claimAndSubmit( + payment: Payment, + submit: () => Promise, + ): Promise { + const claim = await this.paymentRepo.update( + { id: payment.id, status: PaymentStatus.PENDING }, + { status: PaymentStatus.FUNDING }, + ); + if (claim.affected !== 1) { + throw new PaymentAlreadyInFlightError(payment.id); + } + + try { + const result = await submit(); + + await this.paymentRepo.update(payment.id, { + status: PaymentStatus.FUNDED, + fundedAt: new Date(), + stellarTxHash: result.txHash, + failureReason: null, + }); + + return { + paymentId: payment.id, + status: PaymentStatus.FUNDED, + stellarTxHash: result.txHash, + }; + } catch (error) { + const mapped = mapStellarFundingError(error); + await this.paymentRepo.update(payment.id, { + status: PaymentStatus.PENDING, + failureReason: mapped.code, + }); + throw mapped; + } + } + + private async getShipment(shipmentId: string): Promise { + const shipment = await this.shipmentRepo.findOne({ + where: { id: shipmentId }, + }); + if (!shipment) { + throw new NotFoundException(`Shipment ${shipmentId} not found`); + } + return shipment; + } + + private async getUser(userId: string): Promise { + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException(`User ${userId} not found`); + } + return user; + } + + // Creates the Payment row on first funding attempt for this shipment; on + // a later attempt for an already-PENDING row, refreshes the carrier + // binding so a carrier reassignment since the row was created (dispute / + // cancel-before-funding, new bid accepted) is never silently reused + // stale (issue #1276 edge case). + private async claimPaymentRowForFunding( + shipment: Shipment, + carrierWalletAddress: string, + shipperWalletAddress: string, + ): Promise { + const payment = await this.getOrCreatePayment( + shipment.id, + Number(shipment.price), + 'USDC', + undefined, + shipperWalletAddress, + carrierWalletAddress, + ); + + if (NON_FUNDABLE_STATUSES.has(payment.status)) { + throw new PaymentAlreadyFundedError(payment.id); + } + if (payment.status === PaymentStatus.FUNDING) { + throw new PaymentAlreadyInFlightError(payment.id); + } + + if ( + payment.carrierWalletAddress !== carrierWalletAddress || + payment.shipperWalletAddress !== shipperWalletAddress + ) { + payment.carrierWalletAddress = carrierWalletAddress; + payment.shipperWalletAddress = shipperWalletAddress; + payment.failureReason = null; + await this.paymentRepo.save(payment); + } + + return payment; + } } diff --git a/backend/src/payments/settlement-asset.util.spec.ts b/backend/src/payments/settlement-asset.util.spec.ts new file mode 100644 index 00000000..6217bb2d --- /dev/null +++ b/backend/src/payments/settlement-asset.util.spec.ts @@ -0,0 +1,15 @@ +import { priceToBaseUnits } from './settlement-asset.util'; + +describe('priceToBaseUnits', () => { + it('converts a whole-dollar price to 7-decimal base units', () => { + expect(priceToBaseUnits(50)).toBe(500_000_000n); + }); + + it('converts a fractional-cent price with rounding', () => { + expect(priceToBaseUnits(1234.56)).toBe(12_345_600_000n); + }); + + it('handles zero', () => { + expect(priceToBaseUnits(0)).toBe(0n); + }); +}); diff --git a/backend/src/payments/settlement-asset.util.ts b/backend/src/payments/settlement-asset.util.ts new file mode 100644 index 00000000..491a21b5 --- /dev/null +++ b/backend/src/payments/settlement-asset.util.ts @@ -0,0 +1,16 @@ +// MVP settlement-asset decision (issue #1276): TOKEN_CONTRACT_ADDRESS must be +// a USD-pegged SEP-41 asset (e.g. USDC on Stellar), and Shipment.price (fiat +// decimal, e.g. 1234.56) converts to the contract's i128 base units via a +// fixed decimal shift — no live FX. Stellar SAC-wrapped assets and classic +// assets both use 7 decimal places (see contracts/escrow/src/lib.rs test +// AMOUNT: "500_000_000 // 50 XLM in stroops (7 decimals)"), so this assumes +// the configured token also uses 7. Deliberate simplification, documented +// here rather than derived from the token contract at call time. +export const SETTLEMENT_ASSET_DECIMALS = 7; + +const BASE_UNITS_PER_ASSET_UNIT = 10 ** SETTLEMENT_ASSET_DECIMALS; + +/** Converts a fiat-decimal shipment price (e.g. 1234.56) to the contract's raw i128 base units. */ +export function priceToBaseUnits(price: number): bigint { + return BigInt(Math.round(price * BASE_UNITS_PER_ASSET_UNIT)); +} diff --git a/backend/src/stellar/stellar-contract.service.spec.ts b/backend/src/stellar/stellar-contract.service.spec.ts index 310970f9..e18fcafe 100644 --- a/backend/src/stellar/stellar-contract.service.spec.ts +++ b/backend/src/stellar/stellar-contract.service.spec.ts @@ -1,5 +1,14 @@ import { randomBytes } from 'crypto'; -import { Address, Keypair, nativeToScVal, xdr } from '@stellar/stellar-sdk'; +import { + Account, + Address, + BASE_FEE, + Keypair, + nativeToScVal, + Operation, + TransactionBuilder, + xdr, +} from '@stellar/stellar-sdk'; import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { StellarContractService } from './stellar-contract.service'; @@ -288,6 +297,97 @@ describe('StellarContractService', () => { }); }); + describe('non-custodial funding (issue #1276)', () => { + it('buildFundEscrowTransaction builds and simulates without signing or submitting', async () => { + const service = await readyService(); + + mockSimulateTransaction.mockResolvedValueOnce( + successSim(xdr.ScVal.scvVoid()), + ); + mockAssembleTransaction.mockImplementation((rawTx: unknown) => ({ + build: () => rawTx, + })); + + const xdrString = await service.buildFundEscrowTransaction( + shipperKeypair.publicKey(), + carrierKeypair.publicKey(), + 42n, + 500_000_000n, + ); + + expect(typeof xdrString).toBe('string'); + expect(xdrString.length).toBeGreaterThan(0); + expect(mockGetAccount).toHaveBeenCalledWith(shipperKeypair.publicKey()); + expect(mockSendTransaction).not.toHaveBeenCalled(); + }); + + it('buildFundEscrowTransaction maps a simulation failure to a typed error', async () => { + const service = await readyService(); + + mockSimulateTransaction.mockResolvedValueOnce( + errorSim('HostError: Error(WasmVm, InvalidAction)'), + ); + + await expect( + service.buildFundEscrowTransaction( + shipperKeypair.publicKey(), + carrierKeypair.publicKey(), + 42n, + 500_000_000n, + ), + ).rejects.toThrow(SimulationError); + }); + + it('submitSignedTransaction submits an already-signed transaction and returns the result', async () => { + const service = await readyService(); + const networkPassphrase = 'Test SDF Network ; September 2015'; + + const source = new Account(shipperKeypair.publicKey(), '100'); + const tx = new TransactionBuilder(source, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(Operation.manageData({ name: 'test', value: 'ok' })) + .setTimeout(30) + .build(); + tx.sign(shipperKeypair); + + mockSendTransaction.mockResolvedValueOnce({ + status: 'PENDING', + hash: 'signed-tx-hash', + }); + + const result = await service.submitSignedTransaction(tx.toXDR()); + + expect(result).toEqual({ txHash: 'signed-tx-hash', status: 'PENDING' }); + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + }); + + it('submitSignedTransaction throws SubmissionError when the network rejects it', async () => { + const service = await readyService(); + const networkPassphrase = 'Test SDF Network ; September 2015'; + + const source = new Account(shipperKeypair.publicKey(), '100'); + const tx = new TransactionBuilder(source, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(Operation.manageData({ name: 'test', value: 'ok' })) + .setTimeout(30) + .build(); + tx.sign(shipperKeypair); + + mockSendTransaction.mockResolvedValueOnce({ + status: 'ERROR', + hash: 'bad-hash', + }); + + await expect(service.submitSignedTransaction(tx.toXDR())).rejects.toThrow( + SubmissionError, + ); + }); + }); + describe('read methods', () => { it('decodes a full EscrowRecord from getEscrow', async () => { const service = await readyService(); diff --git a/backend/src/stellar/stellar-contract.service.ts b/backend/src/stellar/stellar-contract.service.ts index f03f2b04..caa565a5 100644 --- a/backend/src/stellar/stellar-contract.service.ts +++ b/backend/src/stellar/stellar-contract.service.ts @@ -8,6 +8,7 @@ import { nativeToScVal, scValToNative, SorobanRpc, + Transaction, TransactionBuilder, xdr, } from '@stellar/stellar-sdk'; @@ -108,6 +109,69 @@ export class StellarContractService implements OnModuleInit { ); } + /** + * Builds and simulates a `fund_escrow` call for `shipperPublicKey` but does + * NOT sign it — the platform never holds shipper keys (non-custodial + * deposit model, issue #1276). Returns the prepared, unsigned transaction + * as a base64 XDR envelope for the shipper's own wallet to sign. + */ + async buildFundEscrowTransaction( + shipperPublicKey: string, + carrierAddress: string, + shipmentId: bigint, + amount: bigint, + ): Promise { + this.assertEnabled(); + const sourceAccount = await this.server.getAccount(shipperPublicKey); + + const rawTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + this.contract.call( + 'fund_escrow', + new Address(shipperPublicKey).toScVal(), + new Address(carrierAddress).toScVal(), + nativeToScVal(shipmentId, { type: 'u64' }), + nativeToScVal(amount, { type: 'i128' }), + ), + ) + .setTimeout(TX_TIMEOUT_SECONDS) + .build(); + + const sim = await this.server.simulateTransaction(rawTx); + this.throwIfSimulationFailed(sim, 'fund_escrow'); + + let prepared; + try { + prepared = SorobanRpc.assembleTransaction(rawTx, sim).build(); + } catch (error) { + throw new SimulationError( + 'Failed to assemble "fund_escrow" from its simulation', + error instanceof Error ? error.message : String(error), + ); + } + + return prepared.toXDR(); + } + + /** + * Submits a `fund_escrow` transaction the shipper's own wallet has already + * signed (the counterpart to buildFundEscrowTransaction — see ADR-style + * note there on why the backend never signs this one itself). + */ + async submitSignedTransaction( + signedXdr: string, + ): Promise { + this.assertEnabled(); + const prepared = TransactionBuilder.fromXDR( + signedXdr, + this.networkPassphrase, + ) as Transaction; + return this.submitPrepared(prepared, 'fund_escrow'); + } + /** Either party raises a dispute. `signer` must be the shipper's or carrier's keypair. */ async raiseDispute( signer: Keypair, @@ -254,6 +318,14 @@ export class StellarContractService implements OnModuleInit { prepared.sign(signer); + return this.submitPrepared(prepared, method); + } + + /** Shared submit + status-mapping tail for both self-signed and externally-signed calls. */ + private async submitPrepared( + prepared: Transaction, + method: string, + ): Promise { let sendResult; try { sendResult = await this.server.sendTransaction(prepared);