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
11 changes: 11 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,14 @@ PAYMENT_RECONCILE_MAX_BATCH=500
PAYMENT_MANUAL_REVIEW_AFTER_HOURS=24
# Logs a WARN-level alert when the manual-review queue depth exceeds this.
PAYMENT_MANUAL_REVIEW_ALERT_THRESHOLD=20

# Wallets — custodial onboarding & non-custodial linking (issue #1573)
# 32-byte AES-256 master key, base64-encoded, used to envelope-encrypt each
# custodial wallet's Stellar secret key. Generate with:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Rotate by introducing a new KeyManagementService implementation keyed by
# a new kmsKeyId — never by editing this value in place.
WALLET_KMS_MASTER_KEY=change-me-32-byte-base64-key
# How long a non-custodial linking challenge (nonce) stays valid before it
# must be re-requested.
WALLET_LINK_CHALLENGE_TTL_SECONDS=300
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { PaymentsModule } from './payments/payments.module';
import { WalletsModule } from './wallets/wallets.module';

@Module({
imports: [
Expand All @@ -27,6 +28,7 @@ import { PaymentsModule } from './payments/payments.module';
}),
AuthModule,
PaymentsModule,
WalletsModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
154 changes: 154 additions & 0 deletions backend/src/database/migrations/1787394742847-CreateWalletTables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

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

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE "wallet_accounts_custody_type_enum" AS ENUM (
'CUSTODIAL', 'EXTERNAL'
)
`);
await queryRunner.query(`
CREATE TYPE "wallet_accounts_status_enum" AS ENUM (
'PENDING', 'ACTIVE', 'DISABLED'
)
`);
await queryRunner.query(`
CREATE TYPE "wallet_ledger_entries_type_enum" AS ENUM ('CREDIT', 'DEBIT')
`);

// No secret material — see wallet_key_material.
await queryRunner.query(`
CREATE TABLE "wallet_accounts" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL,
"address" varchar NOT NULL,
"custody_type" "wallet_accounts_custody_type_enum" NOT NULL,
"status" "wallet_accounts_status_enum" NOT NULL DEFAULT 'PENDING',
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "pk_wallet_accounts" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "uq_wallet_accounts_user_id"
ON "wallet_accounts" ("user_id")
`);
await queryRunner.query(`
CREATE INDEX "idx_wallet_accounts_address" ON "wallet_accounts" ("address")
`);
// At most one EXTERNAL wallet_account can claim a given external
// address — custodial addresses (freshly generated, never reused) are
// exempt so this can't collide with them.
await queryRunner.query(`
CREATE UNIQUE INDEX "uq_wallet_accounts_external_address"
ON "wallet_accounts" ("address")
WHERE "custody_type" = 'EXTERNAL'
`);

// Envelope-encrypted custodial secret. Only KeyCustodyService reads
// this table. Never contains plaintext key material.
await queryRunner.query(`
CREATE TABLE "wallet_key_material" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"wallet_account_id" uuid NOT NULL,
"kms_key_id" varchar NOT NULL,
"wrapped_data_key" text NOT NULL,
"wrapped_data_key_iv" varchar NOT NULL,
"wrapped_data_key_tag" varchar NOT NULL,
"encrypted_secret" text NOT NULL,
"encrypted_secret_iv" varchar NOT NULL,
"encrypted_secret_tag" varchar NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "pk_wallet_key_material" PRIMARY KEY ("id"),
CONSTRAINT "fk_wallet_key_material_wallet_account" FOREIGN KEY ("wallet_account_id")
REFERENCES "wallet_accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "uq_wallet_key_material_wallet_account_id"
ON "wallet_key_material" ("wallet_account_id")
`);

// Append-only decrypt audit log.
await queryRunner.query(`
CREATE TABLE "wallet_key_access_log" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"wallet_account_id" uuid NOT NULL,
"actor" varchar NOT NULL,
"reason" varchar NOT NULL,
"successful" boolean NOT NULL,
"occurred_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "pk_wallet_key_access_log" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`
CREATE INDEX "idx_wallet_key_access_log_wallet_account_id"
ON "wallet_key_access_log" ("wallet_account_id")
`);

// Single-use challenge-response nonces for external wallet linking.
await queryRunner.query(`
CREATE TABLE "wallet_link_challenges" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL,
"nonce" varchar NOT NULL,
"expires_at" timestamptz NOT NULL,
"consumed_at" timestamptz,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "pk_wallet_link_challenges" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`
CREATE INDEX "idx_wallet_link_challenges_user_id"
ON "wallet_link_challenges" ("user_id")
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "uq_wallet_link_challenges_nonce"
ON "wallet_link_challenges" ("nonce")
`);

// Store-credit style balance ledger for custodial wallets — see
// wallet_ledger_entries' entity doc for why this isn't a real
// on-chain balance.
await queryRunner.query(`
CREATE TABLE "wallet_ledger_entries" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"wallet_account_id" uuid NOT NULL,
"type" "wallet_ledger_entries_type_enum" NOT NULL,
"amount" bigint NOT NULL,
"currency" varchar(3) NOT NULL,
"reason" text NOT NULL,
"actor_id" uuid,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "pk_wallet_ledger_entries" PRIMARY KEY ("id"),
CONSTRAINT "fk_wallet_ledger_entries_wallet_account" FOREIGN KEY ("wallet_account_id")
REFERENCES "wallet_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
)
`);
await queryRunner.query(`
CREATE INDEX "idx_wallet_ledger_entries_wallet_account_id"
ON "wallet_ledger_entries" ("wallet_account_id")
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "idx_wallet_ledger_entries_wallet_account_id"`);
await queryRunner.query(`DROP TABLE "wallet_ledger_entries"`);
await queryRunner.query(`DROP INDEX "uq_wallet_link_challenges_nonce"`);
await queryRunner.query(`DROP INDEX "idx_wallet_link_challenges_user_id"`);
await queryRunner.query(`DROP TABLE "wallet_link_challenges"`);
await queryRunner.query(`DROP INDEX "idx_wallet_key_access_log_wallet_account_id"`);
await queryRunner.query(`DROP TABLE "wallet_key_access_log"`);
await queryRunner.query(`DROP INDEX "uq_wallet_key_material_wallet_account_id"`);
await queryRunner.query(`DROP TABLE "wallet_key_material"`);
await queryRunner.query(`DROP INDEX "uq_wallet_accounts_external_address"`);
await queryRunner.query(`DROP INDEX "idx_wallet_accounts_address"`);
await queryRunner.query(`DROP INDEX "uq_wallet_accounts_user_id"`);
await queryRunner.query(`DROP TABLE "wallet_accounts"`);
await queryRunner.query(`DROP TYPE "wallet_ledger_entries_type_enum"`);
await queryRunner.query(`DROP TYPE "wallet_accounts_status_enum"`);
await queryRunner.query(`DROP TYPE "wallet_accounts_custody_type_enum"`);
}
}
59 changes: 59 additions & 0 deletions backend/src/wallets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Wallets module

Custodial Stellar wallet onboarding and non-custodial "connect your own
wallet" linking (issue #1573), payment track item 4/7. Builds on #1570's
payment domain model — `WalletAccount#userId` is the same id space as
`Payment#userId`.

## Two custody paths, one account-to-wallet model

- **Custodial** — `WalletsService.provisionCustodialWallet` generates a
Stellar keypair server-side and stores the secret encrypted at rest.
Nothing outside `KeyCustodyService` ever sees the plaintext secret; only
the public `walletAddress` is exposed via the API.
- **External** — `WalletsService.createLinkChallenge` /
`verifyAndLinkExternalWallet` implement challenge-response linking: the
server issues a single-use nonce, the user signs it with their own
wallet (e.g. Freighter), the server verifies the signature against the
claimed public key. The server never has custody here.

A user starts with at most one `wallet_accounts` row. Linking an external
wallet when the user already has a custodial one **upgrades** that row in
place (`custodyType: CUSTODIAL → EXTERNAL`) rather than creating a second
one — the custodial key material and ledger history are left in place for
audit purposes, they just stop being the active wallet.

## Key custody

`KeyCustodyService` is the only module allowed to touch a decrypted
custodial secret — see its file header. It uses envelope encryption
(`EnvelopeKeyManagementService`, `KeyManagementService` interface) with a
local AES-256-GCM master key from `WALLET_KMS_MASTER_KEY`; swapping in a
real cloud KMS means implementing the same interface, nothing else in
this module changes. Every decrypt (via `KeyCustodyService.sign`) is
recorded in `wallet_key_access_log`, success or failure.

If the KMS/decrypt step fails, `sign()` throws a clean
`InternalServerErrorException` — there is no fallback to an unencrypted
path.

## Funding

`WalletsService.fundCustodialWallet` (admin-only) records a
`wallet_ledger_entries` credit; it does not move real on-chain funds. A
custodial wallet's balance is `SUM(CREDIT) - SUM(DEBIT)` over that table.
A full fiat-to-crypto on/off-ramp is out of scope for this issue — this is
just enough to make the on-chain payment rail that depends on a funded
wallet demoable.

## Idempotency and concurrency

Both `provisionCustodialWallet` and the external-link upsert follow the
same pattern as `PaymentsService.initiate` (#1570): the relevant unique
index (`uq_wallet_accounts_user_id`, `uq_wallet_accounts_external_address`)
is the real source of truth, and a losing concurrent request recovers by
re-reading instead of erroring. A custodial keypair is only ever generated
*after* the `wallet_accounts` insert has won that race, so a double-click
can never provision two keypairs for one user. Challenge consumption is
guarded by a row lock (`claimChallenge`) so a captured signature can't be
replayed to link twice.
16 changes: 16 additions & 0 deletions backend/src/wallets/dto/fund-wallet.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsPositive, IsString } from 'class-validator';

export class FundWalletDto {
@ApiProperty({
description: 'Amount to credit, in minor units (e.g. stroops)',
example: 1000000,
})
@IsInt()
@IsPositive()
amount: number;

@ApiProperty({ description: 'Why this wallet is being funded — audited' })
@IsString()
reason: string;
}
10 changes: 10 additions & 0 deletions backend/src/wallets/dto/link-challenge-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';

export class LinkChallengeResponseDto {
@ApiProperty({
description: 'Single-use nonce to sign with the external wallet',
})
nonce: string;

@ApiProperty() expiresAt: Date;
}
18 changes: 18 additions & 0 deletions backend/src/wallets/dto/verify-link.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';

export class VerifyLinkDto {
@ApiProperty({ description: 'The nonce issued by the challenge endpoint' })
@IsString()
nonce: string;

@ApiProperty({ description: 'The external wallet public address' })
@IsString()
address: string;

@ApiProperty({
description: 'Base64-encoded signature of the nonce, signed by address',
})
@IsString()
signature: string;
}
35 changes: 35 additions & 0 deletions backend/src/wallets/dto/wallet-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { WalletCustodyType } from '../enums/wallet-custody-type.enum';
import { WalletStatus } from '../enums/wallet-status.enum';
import { WalletStatusView } from '../wallets.service';

/**
* Deliberately excludes any key-material field — there is no field here
* that could ever be populated with a decrypted secret, by construction.
*/
export class WalletResponseDto {
@ApiProperty({
description: 'Whether the user has provisioned or linked a wallet yet',
})
provisioned: boolean;

@ApiProperty({ nullable: true }) walletAddress: string | null;
@ApiProperty({ enum: WalletCustodyType, nullable: true })
custodyType: WalletCustodyType | null;
@ApiProperty({ enum: WalletStatus, nullable: true })
status: WalletStatus | null;
@ApiProperty({ description: 'Balance in minor units (e.g. stroops)' })
balance: number;
@ApiProperty() currency: string;

static fromView(view: WalletStatusView): WalletResponseDto {
const dto = new WalletResponseDto();
dto.provisioned = view.account !== null;
dto.walletAddress = view.account?.address ?? null;
dto.custodyType = view.account?.custodyType ?? null;
dto.status = view.account?.status ?? null;
dto.balance = view.balance;
dto.currency = view.currency;
return dto;
}
}
49 changes: 49 additions & 0 deletions backend/src/wallets/entities/wallet-account.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { WalletCustodyType } from '../enums/wallet-custody-type.enum';
import { WalletStatus } from '../enums/wallet-status.enum';

/**
* One row per user. No secret material lives on this table — see
* WalletKeyMaterial for encrypted custodial key storage, kept in a
* separate table so a query/export of this one can never leak a key.
*/
@Entity('wallet_accounts')
@Index('uq_wallet_accounts_user_id', ['userId'], { unique: true })
@Index('uq_wallet_accounts_external_address', ['address'], {
unique: true,
where: `"custody_type" = 'EXTERNAL'`,
})
export class WalletAccount {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ type: 'uuid', name: 'user_id' })
userId: string;

@Index()
@Column({ type: 'varchar' })
address: string;

@Column({ type: 'enum', enum: WalletCustodyType, name: 'custody_type' })
custodyType: WalletCustodyType;

@Column({
type: 'enum',
enum: WalletStatus,
default: WalletStatus.PENDING,
})
status: WalletStatus;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
Loading
Loading