From cf2890ec5d29c4e4759f267699d8f3f041c4bb0c Mon Sep 17 00:00:00 2001 From: AbdulmujibOladayo Date: Sat, 22 Aug 2026 11:40:27 +0100 Subject: [PATCH 1/2] feat(wallets): custodial Stellar onboarding & non-custodial upgrade path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the wallet_accounts model with two custody paths: - Custodial: KeyCustodyService generates a Stellar keypair server-side and envelope-encrypts the secret at rest (AES-256-GCM data key, wrapped by a swappable KeyManagementService abstraction). No other module ever sees a decrypted key; every decrypt is audited in wallet_key_access_log. Provisioning is idempotent under concurrent requests via the same DB-unique-constraint pattern as PaymentsService. - Non-custodial: single-use nonce challenge-response linking, verified against the claimed Stellar public key. Linking an external wallet while a custodial one exists upgrades that account in place. A minimal ledger (wallet_ledger_entries) gives custodial wallets a store-credit-style balance via an admin funding stub — real on-chain transfer is out of scope per the issue. Frontend ships a wallet status card with the balance framed as store credit and the raw address tucked behind an "Advanced" disclosure. Closes #1573 --- backend/.env.example | 11 + backend/src/app.module.ts | 2 + .../1787394742847-CreateWalletTables.ts | 154 ++++++ backend/src/wallets/README.md | 59 +++ backend/src/wallets/dto/fund-wallet.dto.ts | 16 + .../dto/link-challenge-response.dto.ts | 10 + backend/src/wallets/dto/verify-link.dto.ts | 18 + .../src/wallets/dto/wallet-response.dto.ts | 35 ++ .../wallets/entities/wallet-account.entity.ts | 49 ++ .../entities/wallet-key-access-log.entity.ts | 35 ++ .../entities/wallet-key-material.entity.ts | 57 ++ .../entities/wallet-ledger-entry.entity.ts | 48 ++ .../entities/wallet-link-challenge.entity.ts | 36 ++ .../wallets/enums/wallet-custody-type.enum.ts | 4 + .../enums/wallet-ledger-entry-type.enum.ts | 4 + .../src/wallets/enums/wallet-status.enum.ts | 5 + .../key-custody/key-custody.service.spec.ts | 124 +++++ .../key-custody/key-custody.service.ts | 146 +++++ .../key-management.service.spec.ts | 47 ++ .../key-custody/key-management.service.ts | 72 +++ backend/src/wallets/wallets.controller.ts | 112 ++++ backend/src/wallets/wallets.module.ts | 27 + backend/src/wallets/wallets.service.spec.ts | 500 ++++++++++++++++++ backend/src/wallets/wallets.service.ts | 364 +++++++++++++ frontend/app/wallet/page.tsx | 21 + .../components/wallet/wallet-status-card.tsx | 217 ++++++++ frontend/lib/wallet-api.ts | 72 +++ 27 files changed, 2245 insertions(+) create mode 100644 backend/src/database/migrations/1787394742847-CreateWalletTables.ts create mode 100644 backend/src/wallets/README.md create mode 100644 backend/src/wallets/dto/fund-wallet.dto.ts create mode 100644 backend/src/wallets/dto/link-challenge-response.dto.ts create mode 100644 backend/src/wallets/dto/verify-link.dto.ts create mode 100644 backend/src/wallets/dto/wallet-response.dto.ts create mode 100644 backend/src/wallets/entities/wallet-account.entity.ts create mode 100644 backend/src/wallets/entities/wallet-key-access-log.entity.ts create mode 100644 backend/src/wallets/entities/wallet-key-material.entity.ts create mode 100644 backend/src/wallets/entities/wallet-ledger-entry.entity.ts create mode 100644 backend/src/wallets/entities/wallet-link-challenge.entity.ts create mode 100644 backend/src/wallets/enums/wallet-custody-type.enum.ts create mode 100644 backend/src/wallets/enums/wallet-ledger-entry-type.enum.ts create mode 100644 backend/src/wallets/enums/wallet-status.enum.ts create mode 100644 backend/src/wallets/key-custody/key-custody.service.spec.ts create mode 100644 backend/src/wallets/key-custody/key-custody.service.ts create mode 100644 backend/src/wallets/key-custody/key-management.service.spec.ts create mode 100644 backend/src/wallets/key-custody/key-management.service.ts create mode 100644 backend/src/wallets/wallets.controller.ts create mode 100644 backend/src/wallets/wallets.module.ts create mode 100644 backend/src/wallets/wallets.service.spec.ts create mode 100644 backend/src/wallets/wallets.service.ts create mode 100644 frontend/app/wallet/page.tsx create mode 100644 frontend/components/wallet/wallet-status-card.tsx create mode 100644 frontend/lib/wallet-api.ts diff --git a/backend/.env.example b/backend/.env.example index a6d7b635..724093b6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8480b6be..77e63199 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ @@ -27,6 +28,7 @@ import { PaymentsModule } from './payments/payments.module'; }), AuthModule, PaymentsModule, + WalletsModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/database/migrations/1787394742847-CreateWalletTables.ts b/backend/src/database/migrations/1787394742847-CreateWalletTables.ts new file mode 100644 index 00000000..c2249949 --- /dev/null +++ b/backend/src/database/migrations/1787394742847-CreateWalletTables.ts @@ -0,0 +1,154 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateWalletTables1787394742847 implements MigrationInterface { + name = 'CreateWalletTables1787394742847'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/backend/src/wallets/README.md b/backend/src/wallets/README.md new file mode 100644 index 00000000..0ee5d0ba --- /dev/null +++ b/backend/src/wallets/README.md @@ -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. diff --git a/backend/src/wallets/dto/fund-wallet.dto.ts b/backend/src/wallets/dto/fund-wallet.dto.ts new file mode 100644 index 00000000..1a4cc50d --- /dev/null +++ b/backend/src/wallets/dto/fund-wallet.dto.ts @@ -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; +} diff --git a/backend/src/wallets/dto/link-challenge-response.dto.ts b/backend/src/wallets/dto/link-challenge-response.dto.ts new file mode 100644 index 00000000..a563196d --- /dev/null +++ b/backend/src/wallets/dto/link-challenge-response.dto.ts @@ -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; +} diff --git a/backend/src/wallets/dto/verify-link.dto.ts b/backend/src/wallets/dto/verify-link.dto.ts new file mode 100644 index 00000000..f138c978 --- /dev/null +++ b/backend/src/wallets/dto/verify-link.dto.ts @@ -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; +} diff --git a/backend/src/wallets/dto/wallet-response.dto.ts b/backend/src/wallets/dto/wallet-response.dto.ts new file mode 100644 index 00000000..423568a5 --- /dev/null +++ b/backend/src/wallets/dto/wallet-response.dto.ts @@ -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; + } +} diff --git a/backend/src/wallets/entities/wallet-account.entity.ts b/backend/src/wallets/entities/wallet-account.entity.ts new file mode 100644 index 00000000..4260a118 --- /dev/null +++ b/backend/src/wallets/entities/wallet-account.entity.ts @@ -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; +} diff --git a/backend/src/wallets/entities/wallet-key-access-log.entity.ts b/backend/src/wallets/entities/wallet-key-access-log.entity.ts new file mode 100644 index 00000000..7e724226 --- /dev/null +++ b/backend/src/wallets/entities/wallet-key-access-log.entity.ts @@ -0,0 +1,35 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * Append-only audit trail: one row per decrypt operation performed by + * KeyCustodyService, regardless of outcome. Never contains key material. + */ +@Entity('wallet_key_access_log') +export class WalletKeyAccessLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ type: 'uuid', name: 'wallet_account_id' }) + walletAccountId: string; + + /** e.g. 'SYSTEM' for an automated signing call, or the acting admin's user id. */ + @Column({ type: 'varchar', name: 'actor' }) + actor: string; + + /** Why the key was decrypted — e.g. 'custodial-funding-signature'. */ + @Column({ type: 'varchar' }) + reason: string; + + @Column({ type: 'boolean' }) + successful: boolean; + + @CreateDateColumn({ name: 'occurred_at' }) + occurredAt: Date; +} diff --git a/backend/src/wallets/entities/wallet-key-material.entity.ts b/backend/src/wallets/entities/wallet-key-material.entity.ts new file mode 100644 index 00000000..6cd5d7a8 --- /dev/null +++ b/backend/src/wallets/entities/wallet-key-material.entity.ts @@ -0,0 +1,57 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * Envelope-encrypted custodial secret key. Only KeyCustodyService ever + * reads this table — no other module should inject this entity's + * repository. The plaintext secret is never stored, logged, or returned by + * any API; only `encryptedSecret` (ciphertext) and the wrapped data key + * live here, both opaque to everything except KeyCustodyService. + */ +@Entity('wallet_key_material') +@Index('uq_wallet_key_material_wallet_account_id', ['walletAccountId'], { + unique: true, +}) +export class WalletKeyMaterial { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', name: 'wallet_account_id' }) + walletAccountId: string; + + /** Identifies which KEK wrapped the data key — enables key rotation. */ + @Column({ type: 'varchar', name: 'kms_key_id' }) + kmsKeyId: string; + + /** Base64 AES-256-GCM-wrapped data encryption key. */ + @Column({ type: 'text', name: 'wrapped_data_key' }) + wrappedDataKey: string; + + /** Base64 IV used to wrap the data key. */ + @Column({ type: 'varchar', name: 'wrapped_data_key_iv' }) + wrappedDataKeyIv: string; + + /** Base64 GCM auth tag for the wrapped data key. */ + @Column({ type: 'varchar', name: 'wrapped_data_key_tag' }) + wrappedDataKeyTag: string; + + /** Base64 AES-256-GCM ciphertext of the Stellar secret key. */ + @Column({ type: 'text', name: 'encrypted_secret' }) + encryptedSecret: string; + + /** Base64 IV used to encrypt the secret with the data key. */ + @Column({ type: 'varchar', name: 'encrypted_secret_iv' }) + encryptedSecretIv: string; + + /** Base64 GCM auth tag for the encrypted secret. */ + @Column({ type: 'varchar', name: 'encrypted_secret_tag' }) + encryptedSecretTag: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/wallets/entities/wallet-ledger-entry.entity.ts b/backend/src/wallets/entities/wallet-ledger-entry.entity.ts new file mode 100644 index 00000000..ce0c66a1 --- /dev/null +++ b/backend/src/wallets/entities/wallet-ledger-entry.entity.ts @@ -0,0 +1,48 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { WalletLedgerEntryType } from '../enums/wallet-ledger-entry-type.enum'; + +/** + * Minimal funding rail: a custodial wallet's balance is + * SUM(CREDIT) - SUM(DEBIT) over this table, recorded as ledger entries + * rather than real on-chain transfers. A full fiat-to-crypto on/off-ramp + * is out of scope for this issue (see #1573) — this just makes the + * custodial balance real enough to demo the payment flows that depend on + * it. + */ +@Entity('wallet_ledger_entries') +export class WalletLedgerEntry { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ type: 'uuid', name: 'wallet_account_id' }) + walletAccountId: string; + + @Column({ type: 'enum', enum: WalletLedgerEntryType }) + type: WalletLedgerEntryType; + + /** Minor units — never a float. */ + @Column({ + type: 'bigint', + transformer: { to: (v: number) => v, from: (v: string) => parseInt(v, 10) }, + }) + amount: number; + + @Column({ type: 'varchar', length: 3 }) + currency: string; + + @Column({ type: 'text' }) + reason: string; + + @Column({ type: 'uuid', name: 'actor_id', nullable: true }) + actorId: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/wallets/entities/wallet-link-challenge.entity.ts b/backend/src/wallets/entities/wallet-link-challenge.entity.ts new file mode 100644 index 00000000..19a37ca4 --- /dev/null +++ b/backend/src/wallets/entities/wallet-link-challenge.entity.ts @@ -0,0 +1,36 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * Single-use nonce issued for the non-custodial challenge-response linking + * flow. `consumedAt` is set atomically (an UPDATE ... WHERE consumed_at IS + * NULL) the moment a challenge is used, so a captured-and-replayed + * signature can never link twice — see WalletsService.verifyAndLinkExternalWallet. + */ +@Entity('wallet_link_challenges') +export class WalletLinkChallenge { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ type: 'uuid', name: 'user_id' }) + userId: string; + + @Index('uq_wallet_link_challenges_nonce', { unique: true }) + @Column({ type: 'varchar' }) + nonce: string; + + @Column({ type: 'timestamptz', name: 'expires_at' }) + expiresAt: Date; + + @Column({ type: 'timestamptz', name: 'consumed_at', nullable: true }) + consumedAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/wallets/enums/wallet-custody-type.enum.ts b/backend/src/wallets/enums/wallet-custody-type.enum.ts new file mode 100644 index 00000000..f5d1a49c --- /dev/null +++ b/backend/src/wallets/enums/wallet-custody-type.enum.ts @@ -0,0 +1,4 @@ +export enum WalletCustodyType { + CUSTODIAL = 'CUSTODIAL', + EXTERNAL = 'EXTERNAL', +} diff --git a/backend/src/wallets/enums/wallet-ledger-entry-type.enum.ts b/backend/src/wallets/enums/wallet-ledger-entry-type.enum.ts new file mode 100644 index 00000000..ea485791 --- /dev/null +++ b/backend/src/wallets/enums/wallet-ledger-entry-type.enum.ts @@ -0,0 +1,4 @@ +export enum WalletLedgerEntryType { + CREDIT = 'CREDIT', + DEBIT = 'DEBIT', +} diff --git a/backend/src/wallets/enums/wallet-status.enum.ts b/backend/src/wallets/enums/wallet-status.enum.ts new file mode 100644 index 00000000..4363c0b6 --- /dev/null +++ b/backend/src/wallets/enums/wallet-status.enum.ts @@ -0,0 +1,5 @@ +export enum WalletStatus { + PENDING = 'PENDING', + ACTIVE = 'ACTIVE', + DISABLED = 'DISABLED', +} diff --git a/backend/src/wallets/key-custody/key-custody.service.spec.ts b/backend/src/wallets/key-custody/key-custody.service.spec.ts new file mode 100644 index 00000000..11496665 --- /dev/null +++ b/backend/src/wallets/key-custody/key-custody.service.spec.ts @@ -0,0 +1,124 @@ +import { randomBytes } from 'crypto'; +import { Keypair } from '@stellar/stellar-sdk'; +import { KeyCustodyService } from './key-custody.service'; +import { EnvelopeKeyManagementService } from './key-management.service'; + +const STELLAR_SECRET_PATTERN = /^S[A-Z0-9]{55}$/; +const STELLAR_PUBLIC_PATTERN = /^G[A-Z0-9]{55}$/; + +function makeManager(materialRepository: any) { + return { getRepository: jest.fn().mockReturnValue(materialRepository) }; +} + +describe('KeyCustodyService', () => { + let keyMaterialRepository: any; + let accessLogRepository: any; + let kms: EnvelopeKeyManagementService; + let service: KeyCustodyService; + let stored: any; + + beforeEach(() => { + stored = null; + keyMaterialRepository = { + create: jest.fn((data) => ({ ...data })), + save: jest.fn(async (entity) => { + stored = { id: 'material-1', ...entity }; + return stored; + }), + findOne: jest.fn(async () => stored), + }; + accessLogRepository = { + create: jest.fn((data) => ({ ...data })), + save: jest.fn(async (entity) => ({ id: 'log-1', ...entity })), + }; + const config = { + get: jest.fn().mockReturnValue(randomBytes(32).toString('base64')), + }; + kms = new EnvelopeKeyManagementService(config as any); + service = new KeyCustodyService( + keyMaterialRepository, + accessLogRepository, + kms, + ); + }); + + describe('provisionKeypair', () => { + it('returns a valid Stellar public address and persists only ciphertext', async () => { + const manager = makeManager(keyMaterialRepository); + + const address = await service.provisionKeypair('wallet-1', manager); + + expect(address).toMatch(STELLAR_PUBLIC_PATTERN); + expect(keyMaterialRepository.save).toHaveBeenCalledTimes(1); + expect(stored.walletAccountId).toBe('wallet-1'); + expect(stored.encryptedSecret).not.toMatch(STELLAR_SECRET_PATTERN); + expect(stored.encryptedSecret).not.toContain(address); + }); + + it('never leaves the plaintext secret recoverable from what was persisted', async () => { + const manager = makeManager(keyMaterialRepository); + await service.provisionKeypair('wallet-1', manager); + + const persistedJson = JSON.stringify(stored); + expect(persistedJson).not.toMatch(STELLAR_SECRET_PATTERN); + }); + }); + + describe('sign', () => { + it('produces a signature that verifies against the provisioned public address, without exposing the secret', async () => { + const manager = makeManager(keyMaterialRepository); + const address = await service.provisionKeypair('wallet-1', manager); + const payload = Buffer.from('some-nonce-or-transaction-hash'); + + const signature = await service.sign( + 'wallet-1', + payload, + 'SYSTEM', + 'test-signing', + ); + + expect(Keypair.fromPublicKey(address).verify(payload, signature)).toBe( + true, + ); + expect(accessLogRepository.save).toHaveBeenCalledTimes(1); + expect(accessLogRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + walletAccountId: 'wallet-1', + actor: 'SYSTEM', + reason: 'test-signing', + successful: true, + }), + ); + }); + + it('fails cleanly and logs a failed access when key material is missing (KMS/decrypt unavailable)', async () => { + keyMaterialRepository.findOne.mockResolvedValueOnce(null); + + await expect( + service.sign('missing-wallet', Buffer.from('x'), 'SYSTEM', 'r'), + ).rejects.toThrow('Custodial wallet is unavailable for signing'); + + expect(accessLogRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ successful: false }), + ); + }); + + it('never includes the secret in a thrown error message', async () => { + const manager = makeManager(keyMaterialRepository); + await service.provisionKeypair('wallet-1', manager); + stored.encryptedSecretTag = 'tampered-tag-not-base64-gcm-tag'; + + let caught: unknown; + try { + await service.sign('wallet-1', Buffer.from('x'), 'SYSTEM', 'r'); + } catch (error) { + caught = error; + } + + expect(caught).toBeDefined(); + expect(String((caught as Error).message)).not.toMatch( + STELLAR_SECRET_PATTERN, + ); + }); + }); +}); diff --git a/backend/src/wallets/key-custody/key-custody.service.ts b/backend/src/wallets/key-custody/key-custody.service.ts new file mode 100644 index 00000000..76478622 --- /dev/null +++ b/backend/src/wallets/key-custody/key-custody.service.ts @@ -0,0 +1,146 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'; +import { EntityManager, Repository } from 'typeorm'; +import { Keypair } from '@stellar/stellar-sdk'; +import { WalletKeyMaterial } from '../entities/wallet-key-material.entity'; +import { WalletKeyAccessLog } from '../entities/wallet-key-access-log.entity'; +import { EnvelopeKeyManagementService } from './key-management.service'; + +const DATA_KEY_ALGORITHM = 'aes-256-gcm'; + +/** + * The only module allowed to touch a decrypted custodial secret key. No + * other service should ever inject Repository directly. + * A decrypted key exists only inside a single synchronous stretch of this + * file and is never returned, logged, or attached to an error. + */ +@Injectable() +export class KeyCustodyService { + constructor( + @InjectRepository(WalletKeyMaterial) + private readonly keyMaterialRepository: Repository, + @InjectRepository(WalletKeyAccessLog) + private readonly accessLogRepository: Repository, + private readonly kms: EnvelopeKeyManagementService, + ) {} + + /** + * Generates a new Stellar keypair, encrypts the secret under a + * fresh data key (itself wrapped by the KMS), and persists it via the + * given manager so the insert can share the caller's transaction with + * the WalletAccount insert. Returns the public address only. + */ + async provisionKeypair( + walletAccountId: string, + manager: EntityManager, + ): Promise { + const keypair = Keypair.random(); + const dataKey = randomBytes(32); + + const secretIv = randomBytes(12); + const secretCipher = createCipheriv( + DATA_KEY_ALGORITHM, + dataKey, + secretIv, + ); + const encryptedSecret = Buffer.concat([ + secretCipher.update(Buffer.from(keypair.secret(), 'utf8')), + secretCipher.final(), + ]); + + const wrappedDataKey = await this.kms.wrapDataKey(dataKey); + dataKey.fill(0); // best-effort scrub once it's no longer needed + + await manager.getRepository(WalletKeyMaterial).save( + manager.getRepository(WalletKeyMaterial).create({ + walletAccountId, + kmsKeyId: wrappedDataKey.keyId, + wrappedDataKey: wrappedDataKey.wrapped, + wrappedDataKeyIv: wrappedDataKey.iv, + wrappedDataKeyTag: wrappedDataKey.tag, + encryptedSecret: encryptedSecret.toString('base64'), + encryptedSecretIv: secretIv.toString('base64'), + encryptedSecretTag: secretCipher.getAuthTag().toString('base64'), + }), + ); + + return keypair.publicKey(); + } + + /** + * Decrypts the custodial key just long enough to sign `payload`, logs + * the access (success or failure), and returns only the signature. + * Never returns or logs the decrypted secret. If the KMS/decrypt step + * fails, the caller sees a clean error — there is no insecure fallback. + */ + async sign( + walletAccountId: string, + payload: Buffer, + actor: string, + reason: string, + ): Promise { + try { + const signature = await this.decryptAndSign(walletAccountId, payload); + await this.logAccess(walletAccountId, actor, reason, true); + return signature; + } catch { + await this.logAccess(walletAccountId, actor, reason, false); + throw new InternalServerErrorException( + 'Custodial wallet is unavailable for signing', + ); + } + } + + private async decryptAndSign( + walletAccountId: string, + payload: Buffer, + ): Promise { + const material = await this.keyMaterialRepository.findOne({ + where: { walletAccountId }, + }); + if (!material) { + throw new Error('No key material for wallet account'); + } + + const dataKey = await this.kms.unwrapDataKey({ + keyId: material.kmsKeyId, + wrapped: material.wrappedDataKey, + iv: material.wrappedDataKeyIv, + tag: material.wrappedDataKeyTag, + }); + + const decipher = createDecipheriv( + DATA_KEY_ALGORITHM, + dataKey, + Buffer.from(material.encryptedSecretIv, 'base64'), + ); + decipher.setAuthTag(Buffer.from(material.encryptedSecretTag, 'base64')); + const secret = Buffer.concat([ + decipher.update(Buffer.from(material.encryptedSecret, 'base64')), + decipher.final(), + ]); + dataKey.fill(0); + + const keypair = Keypair.fromSecret(secret.toString('utf8')); + secret.fill(0); + + return keypair.sign(payload); + } + + private async logAccess( + walletAccountId: string, + actor: string, + reason: string, + successful: boolean, + ): Promise { + await this.accessLogRepository.save( + this.accessLogRepository.create({ + walletAccountId, + actor, + reason, + successful, + }), + ); + } +} diff --git a/backend/src/wallets/key-custody/key-management.service.spec.ts b/backend/src/wallets/key-custody/key-management.service.spec.ts new file mode 100644 index 00000000..14010da8 --- /dev/null +++ b/backend/src/wallets/key-custody/key-management.service.spec.ts @@ -0,0 +1,47 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { randomBytes } from 'crypto'; +import { EnvelopeKeyManagementService } from './key-management.service'; + +const MASTER_KEY = randomBytes(32).toString('base64'); + +describe('EnvelopeKeyManagementService', () => { + function makeService(masterKey: string | undefined = MASTER_KEY) { + const config = { get: jest.fn().mockReturnValue(masterKey) }; + return new EnvelopeKeyManagementService(config as any); + } + + it('wraps and unwraps a data key back to the original bytes', async () => { + const service = makeService(); + const dataKey = randomBytes(32); + + const wrapped = await service.wrapDataKey(dataKey); + const unwrapped = await service.unwrapDataKey(wrapped); + + expect(unwrapped.equals(dataKey)).toBe(true); + }); + + it('produces ciphertext that does not contain the plaintext data key', async () => { + const service = makeService(); + const dataKey = randomBytes(32); + + const wrapped = await service.wrapDataKey(dataKey); + + expect(wrapped.wrapped).not.toContain(dataKey.toString('base64')); + }); + + it('fails to unwrap with a tampered auth tag', async () => { + const service = makeService(); + const wrapped = await service.wrapDataKey(randomBytes(32)); + wrapped.tag = randomBytes(16).toString('base64'); + + await expect(service.unwrapDataKey(wrapped)).rejects.toThrow(); + }); + + it('throws cleanly (no fallback) when no master key is configured', async () => { + const service = makeService(undefined); + + await expect(service.wrapDataKey(randomBytes(32))).rejects.toThrow( + InternalServerErrorException, + ); + }); +}); diff --git a/backend/src/wallets/key-custody/key-management.service.ts b/backend/src/wallets/key-custody/key-management.service.ts new file mode 100644 index 00000000..9266fb38 --- /dev/null +++ b/backend/src/wallets/key-custody/key-management.service.ts @@ -0,0 +1,72 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +export interface WrappedKey { + keyId: string; + wrapped: string; // base64 + iv: string; // base64 + tag: string; // base64 +} + +/** + * Abstraction over "envelope-encrypt a data key with a KEK". The local + * implementation below wraps with an AES-256-GCM master key from config; + * a cloud KMS (AWS KMS, GCP KMS, etc.) implements the same interface as a + * drop-in replacement — nothing outside this file needs to change, and + * this whole module is small enough to become a separate deployable later. + */ +export interface KeyManagementService { + wrapDataKey(dataKey: Buffer): Promise; + unwrapDataKey(wrapped: WrappedKey): Promise; +} + +const ALGORITHM = 'aes-256-gcm'; + +@Injectable() +export class EnvelopeKeyManagementService implements KeyManagementService { + constructor(private readonly config: ConfigService) {} + + async wrapDataKey(dataKey: Buffer): Promise { + const masterKey = this.getMasterKey(); + const iv = randomBytes(12); + const cipher = createCipheriv(ALGORITHM, masterKey, iv); + const wrapped = Buffer.concat([cipher.update(dataKey), cipher.final()]); + return { + keyId: 'local-master-key-v1', + wrapped: wrapped.toString('base64'), + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64'), + }; + } + + async unwrapDataKey(wrapped: WrappedKey): Promise { + const masterKey = this.getMasterKey(); + const decipher = createDecipheriv( + ALGORITHM, + masterKey, + Buffer.from(wrapped.iv, 'base64'), + ); + decipher.setAuthTag(Buffer.from(wrapped.tag, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(wrapped.wrapped, 'base64')), + decipher.final(), + ]); + } + + private getMasterKey(): Buffer { + const configured = this.config.get('WALLET_KMS_MASTER_KEY'); + if (!configured) { + throw new InternalServerErrorException( + 'Wallet key management is unavailable', + ); + } + const key = Buffer.from(configured, 'base64'); + if (key.length !== 32) { + throw new InternalServerErrorException( + 'Wallet key management is unavailable', + ); + } + return key; + } +} diff --git a/backend/src/wallets/wallets.controller.ts b/backend/src/wallets/wallets.controller.ts new file mode 100644 index 00000000..8a2af1d3 --- /dev/null +++ b/backend/src/wallets/wallets.controller.ts @@ -0,0 +1,112 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/interfaces/authenticated-request.interface'; +import { UserRole } from '../auth/enums/user-role.enum'; +import { WalletsService } from './wallets.service'; +import { WalletResponseDto } from './dto/wallet-response.dto'; +import { LinkChallengeResponseDto } from './dto/link-challenge-response.dto'; +import { VerifyLinkDto } from './dto/verify-link.dto'; +import { FundWalletDto } from './dto/fund-wallet.dto'; + +@ApiTags('wallets') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('wallets') +export class WalletsController { + constructor(private readonly walletsService: WalletsService) {} + + @Get('me') + @ApiOperation({ summary: "Get the current user's wallet status" }) + @ApiResponse({ status: 200, type: WalletResponseDto }) + async getMine( + @CurrentUser() currentUser: RequestUser, + ): Promise { + const view = await this.walletsService.getWalletStatus(currentUser.id); + return WalletResponseDto.fromView(view); + } + + @Post('provision') + @ApiOperation({ + summary: + 'Provision a custodial wallet for the current user (idempotent)', + }) + @ApiResponse({ status: 201, type: WalletResponseDto }) + async provision( + @CurrentUser() currentUser: RequestUser, + ): Promise { + await this.walletsService.provisionCustodialWallet(currentUser.id); + const view = await this.walletsService.getWalletStatus(currentUser.id); + return WalletResponseDto.fromView(view); + } + + @Post('link/challenge') + @ApiOperation({ + summary: 'Issue a single-use nonce to sign with an external wallet', + }) + @ApiResponse({ status: 201, type: LinkChallengeResponseDto }) + async requestChallenge( + @CurrentUser() currentUser: RequestUser, + ): Promise { + return this.walletsService.createLinkChallenge(currentUser.id); + } + + @Post('link/verify') + @ApiOperation({ + summary: + 'Verify a signed challenge and link (or upgrade to) an external wallet', + }) + @ApiResponse({ status: 200, type: WalletResponseDto }) + async verifyLink( + @CurrentUser() currentUser: RequestUser, + @Body() dto: VerifyLinkDto, + ): Promise { + await this.walletsService.verifyAndLinkExternalWallet( + currentUser.id, + dto.nonce, + dto.address, + dto.signature, + ); + const view = await this.walletsService.getWalletStatus(currentUser.id); + return WalletResponseDto.fromView(view); + } + + @Post(':userId/fund') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ + summary: + 'Admin-only: credit a custodial wallet (reason required, audited)', + }) + @ApiResponse({ status: 201, type: WalletResponseDto }) + async fund( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: FundWalletDto, + @CurrentUser() currentUser: RequestUser, + ): Promise { + await this.walletsService.fundCustodialWallet( + userId, + dto.amount, + dto.reason, + currentUser.id, + ); + const view = await this.walletsService.getWalletStatus(userId); + return WalletResponseDto.fromView(view); + } +} diff --git a/backend/src/wallets/wallets.module.ts b/backend/src/wallets/wallets.module.ts new file mode 100644 index 00000000..4b1c5be3 --- /dev/null +++ b/backend/src/wallets/wallets.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { WalletAccount } from './entities/wallet-account.entity'; +import { WalletKeyMaterial } from './entities/wallet-key-material.entity'; +import { WalletKeyAccessLog } from './entities/wallet-key-access-log.entity'; +import { WalletLinkChallenge } from './entities/wallet-link-challenge.entity'; +import { WalletLedgerEntry } from './entities/wallet-ledger-entry.entity'; +import { WalletsService } from './wallets.service'; +import { WalletsController } from './wallets.controller'; +import { KeyCustodyService } from './key-custody/key-custody.service'; +import { EnvelopeKeyManagementService } from './key-custody/key-management.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + WalletAccount, + WalletKeyMaterial, + WalletKeyAccessLog, + WalletLinkChallenge, + WalletLedgerEntry, + ]), + ], + controllers: [WalletsController], + providers: [WalletsService, KeyCustodyService, EnvelopeKeyManagementService], + exports: [WalletsService], +}) +export class WalletsModule {} diff --git a/backend/src/wallets/wallets.service.spec.ts b/backend/src/wallets/wallets.service.spec.ts new file mode 100644 index 00000000..43a6f7ee --- /dev/null +++ b/backend/src/wallets/wallets.service.spec.ts @@ -0,0 +1,500 @@ +import { + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { Keypair } from '@stellar/stellar-sdk'; +import { WalletsService } from './wallets.service'; +import { WalletAccount } from './entities/wallet-account.entity'; +import { WalletLedgerEntry } from './entities/wallet-ledger-entry.entity'; +import { WalletLinkChallenge } from './entities/wallet-link-challenge.entity'; +import { WalletCustodyType } from './enums/wallet-custody-type.enum'; +import { WalletStatus } from './enums/wallet-status.enum'; + +function uniqueViolation(constraint: string) { + return Object.assign( + new Error('duplicate key value violates unique constraint'), + { code: '23505', constraint }, + ); +} + +function makeAccount(overrides: Partial = {}): WalletAccount { + return { + id: 'wallet-1', + userId: 'user-1', + address: 'GCUSTODIALADDRESSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + custodyType: WalletCustodyType.CUSTODIAL, + status: WalletStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as WalletAccount; +} + +function makeChallenge( + overrides: Partial = {}, +): WalletLinkChallenge { + return { + id: 'challenge-1', + userId: 'user-1', + nonce: 'nonce-1', + expiresAt: new Date(Date.now() + 60_000), + consumedAt: null, + createdAt: new Date(), + ...overrides, + } as WalletLinkChallenge; +} + +describe('WalletsService', () => { + let walletAccountRepository: any; + let ledgerRepository: any; + let challengeRepository: any; + let keyCustody: { provisionKeypair: jest.Mock; sign: jest.Mock }; + let config: { get: jest.Mock }; + let service: WalletsService; + let transaction: jest.Mock; + + function build() { + service = new WalletsService( + walletAccountRepository, + ledgerRepository, + challengeRepository, + keyCustody as any, + config as any, + ); + } + + beforeEach(() => { + transaction = jest.fn(async (cb: (m: any) => unknown) => cb(undefined)); + walletAccountRepository = { + findOne: jest.fn(), + manager: { transaction }, + }; + ledgerRepository = { + createQueryBuilder: jest.fn(), + }; + challengeRepository = { + create: jest.fn((data: any) => ({ ...data })), + save: jest.fn(async (entity: any) => ({ id: 'challenge-1', ...entity })), + }; + keyCustody = { + provisionKeypair: jest.fn().mockResolvedValue('GNEWLYPROVISIONEDADDR'), + sign: jest.fn(), + }; + config = { get: jest.fn().mockReturnValue(300) }; + build(); + }); + + describe('provisionCustodialWallet', () => { + it('returns the existing wallet without starting a transaction when already provisioned', async () => { + const existing = makeAccount(); + walletAccountRepository.findOne.mockResolvedValueOnce(existing); + + const result = await service.provisionCustodialWallet('user-1'); + + expect(result).toBe(existing); + expect(transaction).not.toHaveBeenCalled(); + expect(keyCustody.provisionKeypair).not.toHaveBeenCalled(); + }); + + it('creates a new active custodial wallet and provisions its key material', async () => { + walletAccountRepository.findOne.mockResolvedValueOnce(null); + let saved: any = null; + const accountRepo = { + create: jest.fn((data: any) => ({ ...data })), + save: jest.fn(async (entity: any) => { + saved = { id: saved?.id ?? 'wallet-1', ...entity }; + return saved; + }), + }; + const manager = { getRepository: jest.fn().mockReturnValue(accountRepo) }; + transaction.mockImplementationOnce(async (cb: (m: any) => unknown) => + cb(manager), + ); + + const result = await service.provisionCustodialWallet('user-1'); + + expect(keyCustody.provisionKeypair).toHaveBeenCalledWith( + 'wallet-1', + manager, + ); + expect(result.status).toBe(WalletStatus.ACTIVE); + expect(result.address).toBe('GNEWLYPROVISIONEDADDR'); + expect(result.custodyType).toBe(WalletCustodyType.CUSTODIAL); + }); + + it('recovers the winner instead of provisioning a second keypair when two requests race', async () => { + const winner = makeAccount({ id: 'wallet-winner' }); + walletAccountRepository.findOne + .mockResolvedValueOnce(null) // pre-check: not yet committed by the winner + .mockResolvedValueOnce(winner); // recovery lookup after losing the insert + + const accountRepo = { + create: jest.fn((data: any) => ({ ...data })), + save: jest + .fn() + .mockRejectedValueOnce( + uniqueViolation('uq_wallet_accounts_user_id'), + ), + }; + const manager = { getRepository: jest.fn().mockReturnValue(accountRepo) }; + transaction.mockImplementationOnce(async (cb: (m: any) => unknown) => + cb(manager), + ); + + const result = await service.provisionCustodialWallet('user-1'); + + expect(result).toBe(winner); + expect(keyCustody.provisionKeypair).not.toHaveBeenCalled(); + }); + }); + + describe('fundCustodialWallet', () => { + function makeFundManager(account: WalletAccount | null) { + const accountQueryBuilder = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn(async () => account), + }; + const ledgerRepo = { + create: jest.fn((data: any) => ({ ...data })), + save: jest.fn(async (entity: any) => ({ id: 'entry-1', ...entity })), + }; + const manager = { + getRepository: jest.fn((entity: unknown) => + entity === WalletAccount + ? { createQueryBuilder: jest.fn(() => accountQueryBuilder) } + : ledgerRepo, + ), + }; + return { manager, ledgerRepo }; + } + + it('rejects a non-positive amount before touching the database', async () => { + await expect( + service.fundCustodialWallet('user-1', 0, 'top-up', 'admin-1'), + ).rejects.toThrow(BadRequestException); + expect(transaction).not.toHaveBeenCalled(); + }); + + it('rejects a missing reason', async () => { + await expect( + service.fundCustodialWallet('user-1', 100, ' ', 'admin-1'), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects funding a wallet that does not exist', async () => { + const { manager } = makeFundManager(null); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + await expect( + service.fundCustodialWallet('user-1', 100, 'top-up', 'admin-1'), + ).rejects.toThrow(NotFoundException); + }); + + it('rejects funding a non-custodial (external) wallet directly', async () => { + const { manager } = makeFundManager( + makeAccount({ custodyType: WalletCustodyType.EXTERNAL }), + ); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + await expect( + service.fundCustodialWallet('user-1', 100, 'top-up', 'admin-1'), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects funding a wallet that is not active', async () => { + const { manager } = makeFundManager( + makeAccount({ status: WalletStatus.PENDING }), + ); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + await expect( + service.fundCustodialWallet('user-1', 100, 'top-up', 'admin-1'), + ).rejects.toThrow(BadRequestException); + }); + + it('records a CREDIT ledger entry for an active custodial wallet', async () => { + const account = makeAccount(); + const { manager, ledgerRepo } = makeFundManager(account); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + const result = await service.fundCustodialWallet( + 'user-1', + 1_000_000, + 'admin top-up', + 'admin-1', + ); + + expect(result).toBe(account); + expect(ledgerRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + walletAccountId: account.id, + type: 'CREDIT', + amount: 1_000_000, + reason: 'admin top-up', + actorId: 'admin-1', + }), + ); + }); + }); + + describe('getWalletStatus', () => { + it('reports an unprovisioned wallet as such, with a zero balance', async () => { + walletAccountRepository.findOne.mockResolvedValueOnce(null); + + const status = await service.getWalletStatus('user-1'); + + expect(status).toEqual({ account: null, balance: 0, currency: 'XLM' }); + }); + + it('sums the ledger to compute the balance for a provisioned wallet', async () => { + const account = makeAccount(); + walletAccountRepository.findOne.mockResolvedValueOnce(account); + const queryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ balance: '5000' }), + }; + ledgerRepository.createQueryBuilder.mockReturnValue(queryBuilder); + + const status = await service.getWalletStatus('user-1'); + + expect(status).toEqual({ account, balance: 5000, currency: 'XLM' }); + }); + }); + + describe('createLinkChallenge', () => { + it('issues a nonce with the configured TTL', async () => { + config.get.mockReturnValue(120); + const before = Date.now(); + + const { nonce, expiresAt } = await service.createLinkChallenge('user-1'); + + expect(nonce).toHaveLength(64); // 32 random bytes, hex-encoded + expect(expiresAt.getTime()).toBeGreaterThanOrEqual(before + 120_000); + expect(challengeRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', nonce, consumedAt: null }), + ); + }); + }); + + describe('verifyAndLinkExternalWallet', () => { + function makeLinkManager( + challenge: WalletLinkChallenge | null, + existingAccount: WalletAccount | null, + accountSaveImpl?: jest.Mock, + ) { + const challengeQueryBuilder = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn(async () => challenge), + }; + const challengeRepo = { + createQueryBuilder: jest.fn(() => challengeQueryBuilder), + save: jest.fn(async (entity: any) => entity), + }; + const accountQueryBuilder = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn(async () => existingAccount), + }; + const accountRepo = { + createQueryBuilder: jest.fn(() => accountQueryBuilder), + create: jest.fn((data: any) => ({ ...data })), + save: + accountSaveImpl ?? + jest.fn(async (entity: any) => ({ id: 'wallet-1', ...entity })), + }; + const manager = { + getRepository: jest.fn((entity: unknown) => + entity === WalletLinkChallenge ? challengeRepo : accountRepo, + ), + }; + return { manager, accountRepo, challengeRepo }; + } + + function signedNonce(nonce: string) { + const keypair = Keypair.random(); + const signature = keypair + .sign(Buffer.from(nonce, 'utf8')) + .toString('base64'); + return { address: keypair.publicKey(), signature }; + } + + it('rejects a non-Stellar address before starting a transaction', async () => { + await expect( + service.verifyAndLinkExternalWallet( + 'user-1', + 'nonce-1', + 'not-a-stellar-address', + 'sig', + ), + ).rejects.toThrow(BadRequestException); + expect(transaction).not.toHaveBeenCalled(); + }); + + it('links a fresh external wallet given a valid signed challenge', async () => { + const challenge = makeChallenge(); + const { address, signature } = signedNonce(challenge.nonce); + const { manager, accountRepo } = makeLinkManager(challenge, null); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + const result = await service.verifyAndLinkExternalWallet( + 'user-1', + challenge.nonce, + address, + signature, + ); + + expect(result.custodyType).toBe(WalletCustodyType.EXTERNAL); + expect(result.address).toBe(address); + expect(accountRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ custodyType: WalletCustodyType.EXTERNAL }), + ); + }); + + it('upgrades an existing custodial wallet to external on link', async () => { + const challenge = makeChallenge(); + const { address, signature } = signedNonce(challenge.nonce); + const existing = makeAccount({ custodyType: WalletCustodyType.CUSTODIAL }); + const { manager, accountRepo } = makeLinkManager(challenge, existing); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + const result = await service.verifyAndLinkExternalWallet( + 'user-1', + challenge.nonce, + address, + signature, + ); + + expect(result.id).toBe(existing.id); + expect(accountRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: existing.id, + custodyType: WalletCustodyType.EXTERNAL, + address, + }), + ); + }); + + it('rejects an invalid signature without linking anything', async () => { + const challenge = makeChallenge(); + const { address } = signedNonce(challenge.nonce); + const { manager, accountRepo } = makeLinkManager(challenge, null); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + await expect( + service.verifyAndLinkExternalWallet( + 'user-1', + challenge.nonce, + address, + Buffer.from('not-a-real-signature').toString('base64'), + ), + ).rejects.toThrow(BadRequestException); + expect(accountRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects a replayed (already-consumed) challenge', async () => { + const challenge = makeChallenge({ consumedAt: new Date() }); + const { address, signature } = signedNonce(challenge.nonce); + const { manager, accountRepo } = makeLinkManager(challenge, null); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + await expect( + service.verifyAndLinkExternalWallet( + 'user-1', + challenge.nonce, + address, + signature, + ), + ).rejects.toThrow(ConflictException); + expect(accountRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects an expired challenge', async () => { + const challenge = makeChallenge({ + expiresAt: new Date(Date.now() - 1000), + }); + const { address, signature } = signedNonce(challenge.nonce); + const { manager, accountRepo } = makeLinkManager(challenge, null); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + await expect( + service.verifyAndLinkExternalWallet( + 'user-1', + challenge.nonce, + address, + signature, + ), + ).rejects.toThrow(BadRequestException); + expect(accountRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects an address already claimed by another account', async () => { + const challenge = makeChallenge(); + const { address, signature } = signedNonce(challenge.nonce); + const { manager } = makeLinkManager( + challenge, + null, + jest + .fn() + .mockRejectedValueOnce( + uniqueViolation('uq_wallet_accounts_external_address'), + ), + ); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + await expect( + service.verifyAndLinkExternalWallet( + 'user-1', + challenge.nonce, + address, + signature, + ), + ).rejects.toThrow(ConflictException); + }); + + it('is idempotent when re-linking the same already-linked address', async () => { + const challenge = makeChallenge(); + const { address, signature } = signedNonce(challenge.nonce); + const existing = makeAccount({ + custodyType: WalletCustodyType.EXTERNAL, + address, + }); + const { manager, accountRepo } = makeLinkManager(challenge, existing); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + const result = await service.verifyAndLinkExternalWallet( + 'user-1', + challenge.nonce, + address, + signature, + ); + + expect(result).toBe(existing); + expect(accountRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects linking a second, different external wallet over an existing link', async () => { + const challenge = makeChallenge(); + const { address, signature } = signedNonce(challenge.nonce); + const existing = makeAccount({ + custodyType: WalletCustodyType.EXTERNAL, + address: 'GDIFFERENTEXTERNALADDRESSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }); + const { manager, accountRepo } = makeLinkManager(challenge, existing); + transaction.mockImplementationOnce((cb: any) => cb(manager)); + + await expect( + service.verifyAndLinkExternalWallet( + 'user-1', + challenge.nonce, + address, + signature, + ), + ).rejects.toThrow(ConflictException); + expect(accountRepo.save).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/wallets/wallets.service.ts b/backend/src/wallets/wallets.service.ts new file mode 100644 index 00000000..b4856a25 --- /dev/null +++ b/backend/src/wallets/wallets.service.ts @@ -0,0 +1,364 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomBytes } from 'crypto'; +import { EntityManager, Repository } from 'typeorm'; +import { Keypair, StrKey } from '@stellar/stellar-sdk'; +import { WalletAccount } from './entities/wallet-account.entity'; +import { WalletLedgerEntry } from './entities/wallet-ledger-entry.entity'; +import { WalletLinkChallenge } from './entities/wallet-link-challenge.entity'; +import { WalletCustodyType } from './enums/wallet-custody-type.enum'; +import { WalletStatus } from './enums/wallet-status.enum'; +import { WalletLedgerEntryType } from './enums/wallet-ledger-entry-type.enum'; +import { KeyCustodyService } from './key-custody/key-custody.service'; + +const WALLET_ACCOUNT_USER_ID_CONSTRAINT = 'uq_wallet_accounts_user_id'; +const WALLET_ACCOUNT_ADDRESS_CONSTRAINT = 'uq_wallet_accounts_external_address'; +const WALLET_LINK_CHALLENGE_NONCE_CONSTRAINT = + 'uq_wallet_link_challenges_nonce'; +const POSTGRES_UNIQUE_VIOLATION = '23505'; + +/** Single asset tracked by the custodial ledger for this issue's scope. */ +const LEDGER_ASSET = 'XLM'; + +export interface WalletStatusView { + account: WalletAccount | null; + balance: number; + currency: string; +} + +/** + * Custodial wallet provisioning + non-custodial linking (issue #1573). + * Never handles a decrypted secret directly — key generation and signing + * are delegated to KeyCustodyService, the sole module allowed to do that. + */ +@Injectable() +export class WalletsService { + constructor( + @InjectRepository(WalletAccount) + private readonly walletAccountRepository: Repository, + @InjectRepository(WalletLedgerEntry) + private readonly ledgerRepository: Repository, + @InjectRepository(WalletLinkChallenge) + private readonly challengeRepository: Repository, + private readonly keyCustody: KeyCustodyService, + private readonly config: ConfigService, + ) {} + + /** + * Idempotent under concurrent requests: the DB unique constraint on + * (user_id) is the actual source of truth. A keypair is only ever + * generated and persisted by the transaction that wins that constraint — + * the loser recovers here without ever provisioning key material. + */ + async provisionCustodialWallet(userId: string): Promise { + const existing = await this.walletAccountRepository.findOne({ + where: { userId }, + }); + if (existing) { + return existing; + } + + try { + return await this.walletAccountRepository.manager.transaction( + (manager) => this.insertCustodialWallet(manager, userId), + ); + } catch (error) { + if ( + this.isUniqueViolation(error) && + this.violatedConstraint(error) === WALLET_ACCOUNT_USER_ID_CONSTRAINT + ) { + const winner = await this.walletAccountRepository.findOne({ + where: { userId }, + }); + if (winner) { + return winner; + } + } + throw error; + } + } + + async getWalletStatus(userId: string): Promise { + const account = await this.walletAccountRepository.findOne({ + where: { userId }, + }); + if (!account) { + return { account: null, balance: 0, currency: LEDGER_ASSET }; + } + const balance = await this.getBalance(account.id); + return { account, balance, currency: LEDGER_ASSET }; + } + + /** + * Admin-only funding stub: records a ledger credit, it does not move + * real on-chain funds. Enough to make the payment flows that depend on + * a funded custodial wallet demoable — see issue #1573's scope note. + */ + async fundCustodialWallet( + userId: string, + amount: number, + reason: string, + actorId: string, + ): Promise { + if (!Number.isInteger(amount) || amount <= 0) { + throw new BadRequestException( + 'Funding amount must be a positive integer (minor units)', + ); + } + if (!reason?.trim()) { + throw new BadRequestException('Funding reason is required'); + } + + return this.walletAccountRepository.manager.transaction( + async (manager) => { + const account = await manager + .getRepository(WalletAccount) + .createQueryBuilder('wallet_account') + .setLock('pessimistic_write') + .where('wallet_account.user_id = :userId', { userId }) + .getOne(); + + if (!account) { + throw new NotFoundException('No wallet found for this user'); + } + if (account.custodyType !== WalletCustodyType.CUSTODIAL) { + throw new BadRequestException( + 'Only custodial wallets can be funded directly', + ); + } + if (account.status !== WalletStatus.ACTIVE) { + throw new BadRequestException('Wallet is not active'); + } + + await manager.getRepository(WalletLedgerEntry).save( + manager.getRepository(WalletLedgerEntry).create({ + walletAccountId: account.id, + type: WalletLedgerEntryType.CREDIT, + amount, + currency: LEDGER_ASSET, + reason, + actorId, + }), + ); + + return account; + }, + ); + } + + async createLinkChallenge( + userId: string, + ): Promise<{ nonce: string; expiresAt: Date }> { + const ttlSeconds = this.config.get( + 'WALLET_LINK_CHALLENGE_TTL_SECONDS', + 300, + ); + const nonce = randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + ttlSeconds * 1000); + + try { + await this.challengeRepository.save( + this.challengeRepository.create({ + userId, + nonce, + expiresAt, + consumedAt: null, + }), + ); + } catch (error) { + if ( + this.isUniqueViolation(error) && + this.violatedConstraint(error) === + WALLET_LINK_CHALLENGE_NONCE_CONSTRAINT + ) { + // Astronomically unlikely nonce collision — caller just retries. + throw new ConflictException( + 'Could not issue a challenge, please try again', + ); + } + throw error; + } + + return { nonce, expiresAt }; + } + + /** + * Verifies a signed challenge and links (or, for an existing custodial + * user, upgrades to) an external wallet. The challenge row is locked for + * the duration of the check-and-consume so a captured signature can + * never be replayed to link twice, even under concurrent requests. + */ + async verifyAndLinkExternalWallet( + userId: string, + nonce: string, + address: string, + signatureBase64: string, + ): Promise { + if (!StrKey.isValidEd25519PublicKey(address)) { + throw new BadRequestException('Not a valid Stellar public address'); + } + + try { + return await this.walletAccountRepository.manager.transaction( + async (manager) => { + const challenge = await this.claimChallenge(manager, userId, nonce); + this.verifySignature(address, nonce, signatureBase64); + return this.upsertExternalWallet(manager, userId, address, challenge); + }, + ); + } catch (error) { + if ( + this.isUniqueViolation(error) && + this.violatedConstraint(error) === WALLET_ACCOUNT_ADDRESS_CONSTRAINT + ) { + throw new ConflictException( + 'This wallet address is already linked to another account', + ); + } + throw error; + } + } + + private async insertCustodialWallet( + manager: EntityManager, + userId: string, + ): Promise { + const repository = manager.getRepository(WalletAccount); + const account = await repository.save( + repository.create({ + userId, + address: 'pending', + custodyType: WalletCustodyType.CUSTODIAL, + status: WalletStatus.PENDING, + }), + ); + + const address = await this.keyCustody.provisionKeypair( + account.id, + manager, + ); + + account.address = address; + account.status = WalletStatus.ACTIVE; + return repository.save(account); + } + + private async getBalance(walletAccountId: string): Promise { + const result = await this.ledgerRepository + .createQueryBuilder('entry') + .select( + `COALESCE(SUM(CASE WHEN entry.type = 'CREDIT' THEN entry.amount ELSE -entry.amount END), 0)`, + 'balance', + ) + .where('entry.wallet_account_id = :walletAccountId', { + walletAccountId, + }) + .getRawOne<{ balance: string }>(); + return Number(result?.balance ?? 0); + } + + private async claimChallenge( + manager: EntityManager, + userId: string, + nonce: string, + ): Promise { + const challenge = await manager + .getRepository(WalletLinkChallenge) + .createQueryBuilder('challenge') + .setLock('pessimistic_write') + .where('challenge.nonce = :nonce', { nonce }) + .getOne(); + + if (!challenge || challenge.userId !== userId) { + throw new BadRequestException('Invalid or unknown challenge'); + } + if (challenge.consumedAt) { + throw new ConflictException('This challenge has already been used'); + } + if (challenge.expiresAt.getTime() < Date.now()) { + throw new BadRequestException('This challenge has expired'); + } + + challenge.consumedAt = new Date(); + return manager.getRepository(WalletLinkChallenge).save(challenge); + } + + private verifySignature( + address: string, + nonce: string, + signatureBase64: string, + ): void { + let valid: boolean; + try { + const keypair = Keypair.fromPublicKey(address); + valid = keypair.verify( + Buffer.from(nonce, 'utf8'), + Buffer.from(signatureBase64, 'base64'), + ); + } catch { + valid = false; + } + if (!valid) { + throw new BadRequestException('Signature verification failed'); + } + } + + private async upsertExternalWallet( + manager: EntityManager, + userId: string, + address: string, + _challenge: WalletLinkChallenge, + ): Promise { + const repository = manager.getRepository(WalletAccount); + const existing = await repository + .createQueryBuilder('wallet_account') + .setLock('pessimistic_write') + .where('wallet_account.user_id = :userId', { userId }) + .getOne(); + + if (!existing) { + return repository.save( + repository.create({ + userId, + address, + custodyType: WalletCustodyType.EXTERNAL, + status: WalletStatus.ACTIVE, + }), + ); + } + + if (existing.custodyType === WalletCustodyType.EXTERNAL) { + if (existing.address === address) { + return existing; + } + throw new ConflictException( + 'An external wallet is already linked to this account', + ); + } + + // Custodial -> external upgrade path: the custodial key material and + // ledger entries are left in place for audit history; only the + // account's active custody pointer changes. + existing.address = address; + existing.custodyType = WalletCustodyType.EXTERNAL; + existing.status = WalletStatus.ACTIVE; + return repository.save(existing); + } + + private isUniqueViolation(error: unknown): boolean { + const code = (error as any)?.code ?? (error as any)?.driverError?.code; + return code === POSTGRES_UNIQUE_VIOLATION; + } + + private violatedConstraint(error: unknown): string | undefined { + return ( + (error as any)?.constraint ?? (error as any)?.driverError?.constraint + ); + } +} diff --git a/frontend/app/wallet/page.tsx b/frontend/app/wallet/page.tsx new file mode 100644 index 00000000..619267b6 --- /dev/null +++ b/frontend/app/wallet/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import Cookies from "js-cookie"; +import { WalletStatusCard } from "@/components/wallet/wallet-status-card"; + +export default function WalletPage() { + const accessToken = Cookies.get("accessToken"); + + return ( +
+

Wallet

+ {accessToken ? ( + + ) : ( +

+ Sign in to see your balance. +

+ )} +
+ ); +} diff --git a/frontend/components/wallet/wallet-status-card.tsx b/frontend/components/wallet/wallet-status-card.tsx new file mode 100644 index 00000000..39e0743f --- /dev/null +++ b/frontend/components/wallet/wallet-status-card.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + getWalletStatus, + provisionCustodialWallet, + requestLinkChallenge, + verifyLinkChallenge, + type WalletStatusResponse, +} from "@/lib/wallet-api"; + +function formatBalance(minorUnits: number, currency: string): string { + return `${(minorUnits / 10_000_000).toFixed(2)} ${currency} credit`; +} + +/** + * Onboarding / settings widget for a user's payment wallet. Framed as a + * store-credit balance, not a crypto wallet — the raw address only shows + * up behind the "Advanced" disclosure, and a "connect your own wallet" + * option is always available for someone who wants self-custody instead. + */ +export function WalletStatusCard({ accessToken }: { accessToken: string }) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [linking, setLinking] = useState(false); + const [nonce, setNonce] = useState(null); + const [linkAddress, setLinkAddress] = useState(""); + const [linkSignature, setLinkSignature] = useState(""); + + useEffect(() => { + let cancelled = false; + getWalletStatus(accessToken) + .then((result) => { + if (!cancelled) setStatus(result); + }) + .catch((err: Error) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [accessToken]); + + async function handleGetStarted() { + setBusy(true); + setError(null); + try { + const result = await provisionCustodialWallet(accessToken); + setStatus(result); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setBusy(false); + } + } + + async function handleRequestLink() { + setBusy(true); + setError(null); + try { + const challenge = await requestLinkChallenge(accessToken); + setNonce(challenge.nonce); + setLinking(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setBusy(false); + } + } + + async function handleVerifyLink() { + if (!nonce) return; + setBusy(true); + setError(null); + try { + const result = await verifyLinkChallenge(accessToken, { + nonce, + address: linkAddress.trim(), + signature: linkSignature.trim(), + }); + setStatus(result); + setLinking(false); + setNonce(null); + setLinkAddress(""); + setLinkSignature(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setBusy(false); + } + } + + if (loading) { + return ( +
+ Loading your wallet… +
+ ); + } + + return ( +
+

Your balance

+ + {error && ( +

{error}

+ )} + + {!status?.provisioned && ( +
+

+ You don't have a payment balance set up yet. We'll create + one for you automatically the first time you need it — no + downloads or extra passwords required. +

+ +
+ )} + + {status?.provisioned && ( +
+

+ {formatBalance(status.balance, status.currency)} +

+

+ {status.custodyType === "CUSTODIAL" + ? "This works like a store credit balance — we hold it for you and you spend it on bookings." + : "This balance lives in a wallet you control."} +

+ +
+ + Advanced + +

+ {status.walletAddress} +

+
+ + {status.custodyType === "CUSTODIAL" && !linking && ( + + )} +
+ )} + + {linking && nonce && ( +
+

+ In your wallet app, sign this one-time code, then paste your + address and the resulting signature below. +

+

+ {nonce} +

+ setLinkAddress(e.target.value)} + className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-transparent px-3 py-2 text-sm" + /> + setLinkSignature(e.target.value)} + className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-transparent px-3 py-2 text-sm" + /> +
+ + +
+

+ If you lose access to this wallet, we cannot recover it for you — + we never hold the keys to a connected wallet. +

+
+ )} +
+ ); +} diff --git a/frontend/lib/wallet-api.ts b/frontend/lib/wallet-api.ts new file mode 100644 index 00000000..21a3d2d2 --- /dev/null +++ b/frontend/lib/wallet-api.ts @@ -0,0 +1,72 @@ +export type WalletCustodyType = "CUSTODIAL" | "EXTERNAL"; +export type WalletStatus = "PENDING" | "ACTIVE" | "DISABLED"; + +export interface WalletStatusResponse { + provisioned: boolean; + walletAddress: string | null; + custodyType: WalletCustodyType | null; + status: WalletStatus | null; + balance: number; + currency: string; +} + +export interface LinkChallengeResponse { + nonce: string; + expiresAt: string; +} + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; + +async function walletFetch( + path: string, + accessToken: string, + init?: RequestInit, +): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + ...init?.headers, + }, + }); + + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(body?.message ?? `Wallet request failed (${response.status})`); + } + + return response.json() as Promise; +} + +export function getWalletStatus(accessToken: string): Promise { + return walletFetch("/wallets/me", accessToken); +} + +export function provisionCustodialWallet( + accessToken: string, +): Promise { + return walletFetch("/wallets/provision", accessToken, { + method: "POST", + }); +} + +export function requestLinkChallenge( + accessToken: string, +): Promise { + return walletFetch( + "/wallets/link/challenge", + accessToken, + { method: "POST" }, + ); +} + +export function verifyLinkChallenge( + accessToken: string, + params: { nonce: string; address: string; signature: string }, +): Promise { + return walletFetch("/wallets/link/verify", accessToken, { + method: "POST", + body: JSON.stringify(params), + }); +} From 7630407700e6c4f94ce5702d402168095acd1dc0 Mon Sep 17 00:00:00 2001 From: AbdulmujibOladayo Date: Sat, 22 Aug 2026 11:43:55 +0100 Subject: [PATCH 2/2] fix(wallets): fix CI failures in key-custody unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - key-custody.service.spec.ts: mock EntityManager didn't structurally satisfy provisionKeypair's EntityManager parameter type; type the mock factory's return as any (it's a test double, not a real one). - key-management.service.spec.ts: makeService's default parameter (`= MASTER_KEY`) was silently substituted even when the "no master key configured" test explicitly passed `undefined` — JS applies a default parameter on any undefined argument, explicit or not. That let a real gap through: the test asserted a clean failure but was actually exercising the configured-master-key path. Drop the default and require every call site to pass its key explicitly. --- .../wallets/key-custody/key-custody.service.spec.ts | 2 +- .../key-custody/key-management.service.spec.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/backend/src/wallets/key-custody/key-custody.service.spec.ts b/backend/src/wallets/key-custody/key-custody.service.spec.ts index 11496665..efd2fa51 100644 --- a/backend/src/wallets/key-custody/key-custody.service.spec.ts +++ b/backend/src/wallets/key-custody/key-custody.service.spec.ts @@ -6,7 +6,7 @@ import { EnvelopeKeyManagementService } from './key-management.service'; const STELLAR_SECRET_PATTERN = /^S[A-Z0-9]{55}$/; const STELLAR_PUBLIC_PATTERN = /^G[A-Z0-9]{55}$/; -function makeManager(materialRepository: any) { +function makeManager(materialRepository: any): any { return { getRepository: jest.fn().mockReturnValue(materialRepository) }; } diff --git a/backend/src/wallets/key-custody/key-management.service.spec.ts b/backend/src/wallets/key-custody/key-management.service.spec.ts index 14010da8..4965546f 100644 --- a/backend/src/wallets/key-custody/key-management.service.spec.ts +++ b/backend/src/wallets/key-custody/key-management.service.spec.ts @@ -5,13 +5,17 @@ import { EnvelopeKeyManagementService } from './key-management.service'; const MASTER_KEY = randomBytes(32).toString('base64'); describe('EnvelopeKeyManagementService', () => { - function makeService(masterKey: string | undefined = MASTER_KEY) { + // No default value here on purpose: a default triggered by an explicit + // `undefined` argument would silently swallow the "no master key + // configured" case below (JS substitutes the default whenever the + // argument is `undefined`, explicit or not). + function makeService(masterKey: string | undefined) { const config = { get: jest.fn().mockReturnValue(masterKey) }; return new EnvelopeKeyManagementService(config as any); } it('wraps and unwraps a data key back to the original bytes', async () => { - const service = makeService(); + const service = makeService(MASTER_KEY); const dataKey = randomBytes(32); const wrapped = await service.wrapDataKey(dataKey); @@ -21,7 +25,7 @@ describe('EnvelopeKeyManagementService', () => { }); it('produces ciphertext that does not contain the plaintext data key', async () => { - const service = makeService(); + const service = makeService(MASTER_KEY); const dataKey = randomBytes(32); const wrapped = await service.wrapDataKey(dataKey); @@ -30,7 +34,7 @@ describe('EnvelopeKeyManagementService', () => { }); it('fails to unwrap with a tampered auth tag', async () => { - const service = makeService(); + const service = makeService(MASTER_KEY); const wrapped = await service.wrapDataKey(randomBytes(32)); wrapped.tag = randomBytes(16).toString('base64');