Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions backend/src/common/enums/payment-status.enum.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
34 changes: 34 additions & 0 deletions backend/src/migrations/1724227200000-AddPaymentFundingFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddPaymentFundingFields1724227200000
implements MigrationInterface
{
name = 'AddPaymentFundingFields1724227200000';

public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
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.
}
}
7 changes: 7 additions & 0 deletions backend/src/payments/dto/submit-payment.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';

export class SubmitPaymentDto {
@IsString()
@IsNotEmpty()
signedXdr: string;
}
9 changes: 9 additions & 0 deletions backend/src/payments/dto/test-sign-and-submit-payment.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions backend/src/payments/entities/payment.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
132 changes: 132 additions & 0 deletions backend/src/payments/errors/payment-flow.errors.ts
Original file line number Diff line number Diff line change
@@ -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,
},
);
}
}
41 changes: 41 additions & 0 deletions backend/src/payments/errors/stellar-error-mapper.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
99 changes: 99 additions & 0 deletions backend/src/payments/payments.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
7 changes: 6 additions & 1 deletion backend/src/payments/payments.module.ts
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
Loading
Loading