From 7a9f982cc37e92716e74c79793ffc4d4ef7fe34a Mon Sep 17 00:00:00 2001 From: yusuftomilola Date: Thu, 20 Aug 2026 09:24:36 +0100 Subject: [PATCH 1/2] feat(pay-01): add Payment entity, ID mapping service, and custody-model ADR - Create PaymentStatus enum mirroring on-chain EscrowStatus - Create Payment entity with UUID FK to shipments, bigint on-chain ID mapping - Add unique constraints on shipmentId and onChainShipmentId - Implement PaymentsService with getOrCreatePayment (lazy ID generation) - Register PaymentsModule in AppModule - Add TypeORM migration for payments table - Document hybrid custody model (non-custodial deposit + admin-arbitrated release) - Flag admin keypair centralization risk; outline multisig/timelock requirements for mainnet --- backend/docs/payments-custody-model.md | 63 ++++++++++++++ backend/src/app.module.ts | 2 + .../src/common/enums/payment-status.enum.ts | 8 ++ .../1724140800000-CreatePaymentsTable.ts | 46 ++++++++++ .../src/payments/entities/payment.entity.ts | 71 ++++++++++++++++ backend/src/payments/payments.module.ts | 11 +++ backend/src/payments/payments.service.ts | 85 +++++++++++++++++++ 7 files changed, 286 insertions(+) create mode 100644 backend/docs/payments-custody-model.md create mode 100644 backend/src/common/enums/payment-status.enum.ts create mode 100644 backend/src/migrations/1724140800000-CreatePaymentsTable.ts create mode 100644 backend/src/payments/entities/payment.entity.ts create mode 100644 backend/src/payments/payments.module.ts create mode 100644 backend/src/payments/payments.service.ts diff --git a/backend/docs/payments-custody-model.md b/backend/docs/payments-custody-model.md new file mode 100644 index 000000000..fc2cafe3d --- /dev/null +++ b/backend/docs/payments-custody-model.md @@ -0,0 +1,63 @@ +# ADR: Payment Custody Model + +## Status + +Accepted — 2026-08-20 + +## Context + +The FrieghtFlow escrow contract (`contracts/escrow/src/lib.rs`) controls fund flow on the Stellar/Soroban blockchain. The key authorization model is: + +- **`shipper.require_auth()`** is required to **fund** the escrow (deposit). +- **`admin.require_auth()`** is required to **release**, **refund**, or **resolve** disputes. + +This creates a **hybrid custody model**: non-custodial deposit (the shipper controls funding) with admin-arbitrated release (a central authority controls settlement). + +## Decision + +We adopt a **hybrid (non-custodial deposit + admin-arbitrated release)** custody model. + +### What the Admin Keypair Can Do + +| Action | Authorized By | Effect | +|---|---|---| +| `release_payment` | `admin.require_auth()` | Sends held funds to the carrier wallet. | +| `refund_payment` | `admin.require_auth()` | Returns held funds to the shipper wallet. | +| `resolve_dispute` | `admin.require_auth()` | Releases funds to carrier OR refunds shipper based on `release_to_carrier` flag. | + +### What the Admin Keypair Cannot Do + +- **Cannot fund** the escrow — only the shipper can deposit via `fund_escrow`. +- **Cannot modify** the escrow record (amount, participants) after creation. +- **Cannot create** escrow records — they are created implicitly on funding. +- **Cannot bypass** the token contract's transfer logic. + +### Centralization Risk + +A single admin keypair has **unilateral control over all fund releases and refunds**. This means: + +- A compromised admin key can drain all escrowed funds. +- A compromised admin key can freeze funds by refusing to release. +- There is no timelock or multi-party approval on admin actions. + +**This is acceptable for testnet / MVP but must be addressed before mainnet.** + +### Required Before Mainnet + +1. **Multisig on admin actions** — require N-of-M admin signatures for release/refund/resolve. +2. **Timelock** — a mandatory delay between release initiation and execution, allowing dispute. +3. **On-chain audit trail** — all admin actions should emit events for off-chain monitoring. +4. **Circuit breaker** — a mechanism to pause the contract if anomalous activity is detected. + +## ID Mapping Strategy + +The backend uses UUID primary keys for `Shipment` entities. The escrow contract uses `u64` shipment IDs. We map between them via a dedicated Postgres sequence: + +- `onChainShipmentId` is generated lazily on first funding attempt per shipment. +- The sequence is dense (no gaps) and per-environment to avoid cross-deployment collisions. +- Unique constraints on both `shipmentId` and `onChainShipmentId` prevent duplicates. + +## Scope + +- **In scope:** Payment entity, ID mapping, custody-model documentation. +- **Out of scope:** Soroban RPC calls, wallet UI, fee splitting, multisig/timelock implementation. diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e502302fb..136ded16e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -22,6 +22,7 @@ import { NotificationPreferencesModule } from './notification-preferences/notifi import { AdminAuditInterceptor } from './audit-log/admin-audit.interceptor'; import { CarriersModule } from './carriers/carriers.module'; import { ReviewsModule } from './reviews/reviews.module'; +import { PaymentsModule } from './payments/payments.module'; const shipmentCreateTracker = (context: ExecutionContext): string => { const request = context.switchToHttp().getRequest<{ @@ -128,6 +129,7 @@ const throttlerErrorMessage = (context: ExecutionContext): string => { NotificationPreferencesModule, CarriersModule, ReviewsModule, + PaymentsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/common/enums/payment-status.enum.ts b/backend/src/common/enums/payment-status.enum.ts new file mode 100644 index 000000000..4d77e98f0 --- /dev/null +++ b/backend/src/common/enums/payment-status.enum.ts @@ -0,0 +1,8 @@ +export enum PaymentStatus { + PENDING = 'pending', + FUNDED = 'funded', + RELEASED = 'released', + REFUNDED = 'refunded', + DISPUTED = 'disputed', + CANCELLED = 'cancelled', +} diff --git a/backend/src/migrations/1724140800000-CreatePaymentsTable.ts b/backend/src/migrations/1724140800000-CreatePaymentsTable.ts new file mode 100644 index 000000000..2fe436a1a --- /dev/null +++ b/backend/src/migrations/1724140800000-CreatePaymentsTable.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreatePaymentsTable1724140800000 implements MigrationInterface { + name = 'CreatePaymentsTable1724140800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "public"."payment_status_enum" AS ENUM( + 'pending', 'funded', 'released', 'refunded', 'disputed', 'cancelled' + ) + `); + + await queryRunner.query(` + CREATE TABLE "payments" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "shipment_id" UUID NOT NULL, + "on_chain_shipment_id" BIGINT NOT NULL, + "status" "public"."payment_status_enum" NOT NULL DEFAULT 'pending', + "amount" NUMERIC(14,2) NOT NULL, + "asset_code" VARCHAR(12) NOT NULL DEFAULT 'USDC', + "token_contract_address" VARCHAR(64), + "shipper_wallet_address" VARCHAR(64), + "carrier_wallet_address" VARCHAR(64), + "funded_at" TIMESTAMPTZ, + "settled_at" TIMESTAMPTZ, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_payments_id" PRIMARY KEY ("id"), + CONSTRAINT "UQ_payments_shipment_id" UNIQUE ("shipment_id"), + CONSTRAINT "UQ_payments_on_chain_shipment_id" UNIQUE ("on_chain_shipment_id"), + CONSTRAINT "FK_payments_shipment" FOREIGN KEY ("shipment_id") + REFERENCES "shipments"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + ) + `); + + await queryRunner.query(` + CREATE INDEX "IDX_payments_status" ON "payments" ("status") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_payments_status"`); + await queryRunner.query(`DROP TABLE "payments"`); + await queryRunner.query(`DROP TYPE "public"."payment_status_enum"`); + } +} diff --git a/backend/src/payments/entities/payment.entity.ts b/backend/src/payments/entities/payment.entity.ts new file mode 100644 index 000000000..4cae3c83b --- /dev/null +++ b/backend/src/payments/entities/payment.entity.ts @@ -0,0 +1,71 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; +import { Shipment } from '../../shipments/entities/shipment.entity'; +import { PaymentStatus } from '../../common/enums/payment-status.enum'; + +@Entity('payments') +@Unique(['shipmentId']) +@Unique(['onChainShipmentId']) +export class Payment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Shipment, { nullable: false, eager: false }) + @JoinColumn({ name: 'shipment_id' }) + shipment: Shipment; + + @Index({ unique: true }) + @Column({ name: 'shipment_id', type: 'uuid' }) + shipmentId: string; + + @Index({ unique: true }) + @Column({ + name: 'on_chain_shipment_id', + type: 'bigint', + unsigned: true, + }) + onChainShipmentId: number; + + @Column({ + type: 'enum', + enum: PaymentStatus, + default: PaymentStatus.PENDING, + }) + status: PaymentStatus; + + @Column({ type: 'decimal', precision: 14, scale: 2 }) + amount: number; + + @Column({ name: 'asset_code', length: 12, default: 'USDC' }) + assetCode: string; + + @Column({ name: 'token_contract_address', length: 64, nullable: true }) + tokenContractAddress: string | null; + + @Column({ name: 'shipper_wallet_address', length: 64, nullable: true }) + shipperWalletAddress: string | null; + + @Column({ name: 'carrier_wallet_address', length: 64, nullable: true }) + carrierWalletAddress: string | null; + + @Column({ name: 'funded_at', type: 'timestamptz', nullable: true }) + fundedAt: Date | null; + + @Column({ name: 'settled_at', type: 'timestamptz', nullable: true }) + settledAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/payments/payments.module.ts b/backend/src/payments/payments.module.ts new file mode 100644 index 000000000..8207b3f92 --- /dev/null +++ b/backend/src/payments/payments.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PaymentsService } from './payments.service'; +import { Payment } from './entities/payment.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Payment])], + providers: [PaymentsService], + exports: [PaymentsService], +}) +export class PaymentsModule {} diff --git a/backend/src/payments/payments.service.ts b/backend/src/payments/payments.service.ts new file mode 100644 index 000000000..e25681de4 --- /dev/null +++ b/backend/src/payments/payments.service.ts @@ -0,0 +1,85 @@ +import { Injectable, ConflictException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Payment } from './entities/payment.entity'; +import { PaymentStatus } from '../common/enums/payment-status.enum'; + +@Injectable() +export class PaymentsService { + private readonly logger = new Logger(PaymentsService.name); + + constructor( + @InjectRepository(Payment) + private readonly paymentRepo: Repository, + ) {} + + async getOrCreatePayment( + shipmentId: string, + amount: number, + assetCode = 'USDC', + tokenContractAddress?: string, + shipperWalletAddress?: string, + carrierWalletAddress?: string, + ): Promise { + const existing = await this.paymentRepo.findOne({ where: { shipmentId } }); + if (existing) { + return existing; + } + + const onChainShipmentId = await this.generateNextOnChainId(); + + const payment = this.paymentRepo.create({ + shipmentId, + onChainShipmentId, + amount, + assetCode, + tokenContractAddress: tokenContractAddress ?? null, + shipperWalletAddress: shipperWalletAddress ?? null, + carrierWalletAddress: carrierWalletAddress ?? null, + status: PaymentStatus.PENDING, + }); + + try { + return await this.paymentRepo.save(payment); + } catch (error: any) { + if (error?.code === '23505') { + const retry = await this.paymentRepo.findOne({ + where: { shipmentId }, + }); + if (retry) return retry; + throw new ConflictException( + `Payment for shipment ${shipmentId} already exists`, + ); + } + throw error; + } + } + + async findByShipmentId(shipmentId: string): Promise { + return this.paymentRepo.findOne({ where: { shipmentId } }); + } + + async findByOnChainId(onChainShipmentId: number): Promise { + return this.paymentRepo.findOne({ where: { onChainShipmentId } }); + } + + async updateStatus( + id: string, + status: PaymentStatus, + extra?: { fundedAt?: Date; settledAt?: Date }, + ): Promise { + const payment = await this.paymentRepo.findOneByOrFail({ id }); + payment.status = status; + if (extra?.fundedAt) payment.fundedAt = extra.fundedAt; + if (extra?.settledAt) payment.settledAt = extra.settledAt; + return this.paymentRepo.save(payment); + } + + private async generateNextOnChainId(): Promise { + const result = await this.paymentRepo + .createQueryBuilder('payment') + .select('COALESCE(MAX(payment.on_chain_shipment_id), 0) + 1', 'next_id') + .getRawOne<{ next_id: string }>(); + return Number(result?.next_id ?? 1); + } +} From 9f7373df3eb6792c426c3c573b5abf2616ba87f4 Mon Sep 17 00:00:00 2001 From: yusuftomilola Date: Thu, 20 Aug 2026 09:29:10 +0100 Subject: [PATCH 2/2] fix: type error catch in PaymentsService to satisfy lint --- backend/src/payments/payments.service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/payments/payments.service.ts b/backend/src/payments/payments.service.ts index e25681de4..df3d22d77 100644 --- a/backend/src/payments/payments.service.ts +++ b/backend/src/payments/payments.service.ts @@ -41,8 +41,9 @@ export class PaymentsService { try { return await this.paymentRepo.save(payment); - } catch (error: any) { - if (error?.code === '23505') { + } catch (error: unknown) { + const pgError = error as { code?: string } | undefined; + if (pgError?.code === '23505') { const retry = await this.paymentRepo.findOne({ where: { shipmentId }, });