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
63 changes: 63 additions & 0 deletions backend/docs/payments-custody-model.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -128,6 +129,7 @@ const throttlerErrorMessage = (context: ExecutionContext): string => {
NotificationPreferencesModule,
CarriersModule,
ReviewsModule,
PaymentsModule,
],
controllers: [AppController],
providers: [
Expand Down
8 changes: 8 additions & 0 deletions backend/src/common/enums/payment-status.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export enum PaymentStatus {
PENDING = 'pending',
FUNDED = 'funded',
RELEASED = 'released',
REFUNDED = 'refunded',
DISPUTED = 'disputed',
CANCELLED = 'cancelled',
}
46 changes: 46 additions & 0 deletions backend/src/migrations/1724140800000-CreatePaymentsTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

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

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP INDEX "IDX_payments_status"`);
await queryRunner.query(`DROP TABLE "payments"`);
await queryRunner.query(`DROP TYPE "public"."payment_status_enum"`);
}
}
71 changes: 71 additions & 0 deletions backend/src/payments/entities/payment.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions backend/src/payments/payments.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
86 changes: 86 additions & 0 deletions backend/src/payments/payments.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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<Payment>,
) {}

async getOrCreatePayment(
shipmentId: string,
amount: number,
assetCode = 'USDC',
tokenContractAddress?: string,
shipperWalletAddress?: string,
carrierWalletAddress?: string,
): Promise<Payment> {
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: unknown) {
const pgError = error as { code?: string } | undefined;
if (pgError?.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<Payment | null> {
return this.paymentRepo.findOne({ where: { shipmentId } });
}

async findByOnChainId(onChainShipmentId: number): Promise<Payment | null> {
return this.paymentRepo.findOne({ where: { onChainShipmentId } });
}

async updateStatus(
id: string,
status: PaymentStatus,
extra?: { fundedAt?: Date; settledAt?: Date },
): Promise<Payment> {
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<number> {
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);
}
}
Loading