diff --git a/.env.example b/.env.example index 54425c9..89d830a 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ DATABASE_URL=postgresql://user:password@localhost:5432/mux_db?sslmode=require # ------------------------------------------------------------ PORT=3000 +# Git commit SHA of the running build, exposed via GET /health for build +# identity/traceability. Injected by CI/Docker (--build-arg GIT_SHA=...); +# defaults to "unknown" if not set. +GIT_SHA= + # Maximum JSON/form request body size in bytes (default: 102400 / 100 KiB). # Requests above this limit receive HTTP 413. JSON_BODY_LIMIT_BYTES=102400 diff --git a/Dockerfile b/Dockerfile index 7deb6d0..2ce626f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,11 @@ COPY --from=builder /app/dist ./dist COPY --from=builder /app/src/generated ./src/generated COPY prisma ./prisma +# Build identity: pass --build-arg GIT_SHA=$(git rev-parse HEAD) so it's +# exposed via GET /health. Defaults to "unknown" for local/dev builds. +ARG GIT_SHA=unknown +ENV GIT_SHA=$GIT_SHA + EXPOSE 3000 CMD ["node", "dist/main"] diff --git a/docs/MAINNET-PAYMENT-FEATURE-FLAG.md b/docs/MAINNET-PAYMENT-FEATURE-FLAG.md new file mode 100644 index 0000000..3596e46 --- /dev/null +++ b/docs/MAINNET-PAYMENT-FEATURE-FLAG.md @@ -0,0 +1,13 @@ +# Mainnet Payment Submit Feature Flag + +- `FEATURE_MAINNET_PAYMENT_SUBMIT` (boolean, default: false) + - When `true`, `POST /transactions/fee-bump` requests with `network: "MAINNET"` are submitted to Horizon mainnet as normal. + - When `false` or unset, MAINNET submissions are rejected with HTTP 403 (Forbidden) and message: "Mainnet payment submission is not available at this time. (Flag: mainnet_payment_submit)". `TESTNET` submissions are unaffected — the flag is only consulted when `network === "MAINNET"`. + +Notes: +- Implemented as a kill-switch check inside `FeeBumpService.submitFeeBump` (not the route-level `FeatureFlagGuard`), because the decision depends on the `network` field in the request body rather than being fixed per-route. +- Reuses the existing `FeatureFlagService.isEnabled()` helper and the `FEATURE_` env var convention (e.g. `FEATURE_MAINNET_PAYMENT_SUBMIT=true`). +- Rejections happen before any wallet key material is decrypted or any call to Horizon is made. + +Operational guidance: +- Keep this flag off in production until mainnet payment submission has been reviewed and approved for general availability; flip it on per-environment via env/secret config. diff --git a/src/auth/auth-metrics.integration.spec.ts b/src/auth/auth-metrics.integration.spec.ts index 106da18..606d6d7 100644 --- a/src/auth/auth-metrics.integration.spec.ts +++ b/src/auth/auth-metrics.integration.spec.ts @@ -6,8 +6,15 @@ * for every meaningful auth outcome. */ import { Test, TestingModule } from '@nestjs/testing'; -import { BadRequestException, ForbiddenException } from '@nestjs/common'; -import { AuthOrchestrator } from './auth-orchestrator.service'; +import { + BadRequestException, + ForbiddenException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { + AuthOrchestrator, + EXTERNAL_AUTH_FAILURE_MESSAGE, +} from './auth-orchestrator.service'; import { AuthMetricsService } from './auth-metrics.service'; import { IdempotentUserService } from '../users/idempotent-user.service'; import { WalletCreationOrchestrator } from '../wallets/wallet-creation-orchestrator.service'; @@ -202,12 +209,23 @@ describe('AuthOrchestrator — metrics integration', () => { }); describe('unknown error', () => { - it('records failure_unknown for generic DB errors', async () => { + it('records failure_unknown for generic DB errors, without leaking the raw cause', async () => { userService.findOrCreateUser.mockRejectedValue(new Error('DB down')); - await expect( - orchestrator.handleAuthentication({ authId: 'auth-abc' }), - ).rejects.toThrow('Authentication failed: DB down'); + let caught: unknown; + try { + await orchestrator.handleAuthentication({ authId: 'auth-abc' }); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(ServiceUnavailableException); + expect((caught as ServiceUnavailableException).message).toBe( + EXTERNAL_AUTH_FAILURE_MESSAGE, + ); + expect((caught as ServiceUnavailableException).message).not.toContain( + 'DB down', + ); const snap = metricsService.getSnapshot(); expect(snap.outcomes.failure_unknown).toBe(1); diff --git a/src/auth/auth-orchestrator.controller.ts b/src/auth/auth-orchestrator.controller.ts index 95341af..02a8650 100644 --- a/src/auth/auth-orchestrator.controller.ts +++ b/src/auth/auth-orchestrator.controller.ts @@ -171,6 +171,21 @@ export class AuthOrchestratorController { }, }, }) + @ApiResponse({ + status: 503, + description: + 'Service Unavailable — an unclassified downstream failure occurred ' + + '(e.g. database or Stellar network unreachable). The message is a ' + + 'consolidated, generic string; internal error details are never ' + + 'exposed to callers and are logged server-side only.', + schema: { + example: { + statusCode: 503, + message: 'Authentication failed. Please try again later.', + error: 'Service Unavailable', + }, + }, + }) @Public() @Post('authenticate') @UseGuards(AuthRateLimitGuard) diff --git a/src/auth/auth-orchestrator.integration.spec.ts b/src/auth/auth-orchestrator.integration.spec.ts index 5fa903d..f6b9a05 100644 --- a/src/auth/auth-orchestrator.integration.spec.ts +++ b/src/auth/auth-orchestrator.integration.spec.ts @@ -11,7 +11,11 @@ * - Error propagation from collaborators */ import { Test, TestingModule } from '@nestjs/testing'; -import { AuthOrchestrator } from './auth-orchestrator.service'; +import { ServiceUnavailableException } from '@nestjs/common'; +import { + AuthOrchestrator, + EXTERNAL_AUTH_FAILURE_MESSAGE, +} from './auth-orchestrator.service'; import { IdempotentUserService } from '../users/idempotent-user.service'; import { WalletCreationOrchestrator } from '../wallets/wallet-creation-orchestrator.service'; import { WalletNetwork, WalletStatus } from '../wallets/domain/wallet.model'; @@ -229,17 +233,28 @@ describe('AuthOrchestrator (integration harness)', () => { // ------------------------------------------------------------------------- describe('error propagation', () => { - it('wraps user service errors in an Authentication failed error', async () => { + it('wraps user service errors in a consolidated, generic 503 — never leaking the raw cause', async () => { userService.findOrCreateUser.mockRejectedValue( new Error('DB unavailable'), ); - await expect( - orchestrator.handleAuthentication({ authId: 'auth-abc' }), - ).rejects.toThrow('Authentication failed: DB unavailable'); + let caught: unknown; + try { + await orchestrator.handleAuthentication({ authId: 'auth-abc' }); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(ServiceUnavailableException); + expect((caught as ServiceUnavailableException).message).toBe( + EXTERNAL_AUTH_FAILURE_MESSAGE, + ); + expect((caught as ServiceUnavailableException).message).not.toContain( + 'DB unavailable', + ); }); - it('wraps wallet creation errors in an Authentication failed error', async () => { + it('wraps wallet creation errors in a consolidated, generic 503 — never leaking the raw cause', async () => { userService.findOrCreateUser.mockResolvedValue({ user: makeUser(), isNewUser: true, @@ -249,9 +264,20 @@ describe('AuthOrchestrator (integration harness)', () => { new Error('Stellar unavailable'), ); - await expect( - orchestrator.handleAuthentication({ authId: 'auth-abc' }), - ).rejects.toThrow('Authentication failed: Stellar unavailable'); + let caught: unknown; + try { + await orchestrator.handleAuthentication({ authId: 'auth-abc' }); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(ServiceUnavailableException); + expect((caught as ServiceUnavailableException).message).toBe( + EXTERNAL_AUTH_FAILURE_MESSAGE, + ); + expect((caught as ServiceUnavailableException).message).not.toContain( + 'Stellar unavailable', + ); }); }); diff --git a/src/auth/auth-orchestrator.service.ts b/src/auth/auth-orchestrator.service.ts index 92ae17f..57e78c6 100644 --- a/src/auth/auth-orchestrator.service.ts +++ b/src/auth/auth-orchestrator.service.ts @@ -4,6 +4,7 @@ import { ForbiddenException, BadRequestException, HttpException, + ServiceUnavailableException, } from '@nestjs/common'; import { IdempotentUserService, @@ -22,6 +23,16 @@ import { IdempotencyService } from '../common/idempotency/idempotency.service'; import { AuthMetricsService } from './auth-metrics.service'; import { RequestContextService } from '../common/request-context/request-context.service'; +/** + * Single consolidated message returned to external callers for any + * unclassified authentication failure (downstream DB/Stellar/wallet errors, + * etc). Never interpolates the underlying error — those details are logged + * server-side only, so partner-facing responses stay consistent and never + * leak internal infrastructure state. + */ +export const EXTERNAL_AUTH_FAILURE_MESSAGE = + 'Authentication failed. Please try again later.'; + export interface AuthenticationRequest { authId: string; email?: string; @@ -327,7 +338,10 @@ export class AuthOrchestrator { // Only record 'failure_unknown' if not already classified above const latency = Date.now() - startTime; this.authMetrics.recordAttempt('failure_unknown', latency); - throw new Error(`Authentication failed: ${error.message}`); + // Consolidated, generic message — the real cause (DB/Stellar/etc) was + // already logged above via this.logger.error(); never forward + // downstream error text to external callers. + throw new ServiceUnavailableException(EXTERNAL_AUTH_FAILURE_MESSAGE); } } diff --git a/src/balance-indexer/dto/balance-filter.dto.ts b/src/balance-indexer/dto/balance-filter.dto.ts index 41ac5be..766dc9a 100644 --- a/src/balance-indexer/dto/balance-filter.dto.ts +++ b/src/balance-indexer/dto/balance-filter.dto.ts @@ -1,6 +1,7 @@ import { IsEnum, IsOptional, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { AssetType } from '../domain/balance.model'; +import { IsStellarPublicKey } from '../../common/stellar/is-stellar-public-key.validator'; export class BalanceFilterDto { @ApiProperty({ @@ -27,7 +28,7 @@ export class BalanceFilterDto { description: 'Filter by asset issuer', required: false, }) - @IsString({ message: 'assetIssuer must be a string' }) @IsOptional() + @IsStellarPublicKey() assetIssuer?: string; } diff --git a/src/balance-indexer/dto/reconcile-balance.dto.ts b/src/balance-indexer/dto/reconcile-balance.dto.ts index da6c28f..5e1324c 100644 --- a/src/balance-indexer/dto/reconcile-balance.dto.ts +++ b/src/balance-indexer/dto/reconcile-balance.dto.ts @@ -1,6 +1,7 @@ import { IsEnum, IsOptional, IsString, IsNotEmpty } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { AssetType } from '../domain/balance.model'; +import { IsStellarPublicKey } from '../../common/stellar/is-stellar-public-key.validator'; export class ReconcileBalanceDto { @ApiProperty({ @@ -26,7 +27,7 @@ export class ReconcileBalanceDto { description: 'Asset issuer account ID (required if assetType is CREDIT_ALPHANUM4 or CREDIT_ALPHANUM12)', required: false, }) - @IsString({ message: 'assetIssuer must be a string' }) @IsOptional() + @IsStellarPublicKey() assetIssuer?: string; } diff --git a/src/common/stellar/is-stellar-public-key.validator.spec.ts b/src/common/stellar/is-stellar-public-key.validator.spec.ts new file mode 100644 index 0000000..8c5a322 --- /dev/null +++ b/src/common/stellar/is-stellar-public-key.validator.spec.ts @@ -0,0 +1,63 @@ +import { validate } from 'class-validator'; +import { IsStellarPublicKey } from './is-stellar-public-key.validator'; + +class Fixture { + @IsStellarPublicKey() + publicKey: string; +} + +const VALID_KEY = + 'GBUQWP3BOUZX34ZONKXRBTLNNDOWR5HLCVPL2B4XNCLJTLMUMLTSOGBM'; + +describe('IsStellarPublicKey', () => { + it('passes for a valid Stellar public key (checksum-correct)', async () => { + const fixture = new Fixture(); + fixture.publicKey = VALID_KEY; + + const errors = await validate(fixture); + + expect(errors).toHaveLength(0); + }); + + it('fails for a checksum-corrupted key that still matches the shape regex', async () => { + const fixture = new Fixture(); + // Flip the last character — same length/prefix, invalid checksum. + fixture.publicKey = VALID_KEY.slice(0, -1) + (VALID_KEY.endsWith('A') ? 'B' : 'A'); + + const errors = await validate(fixture); + + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toEqual( + expect.objectContaining({ + isStellarPublicKey: expect.stringContaining('publicKey'), + }), + ); + }); + + it('fails for a secret seed (S...) passed where a public key is expected', async () => { + const fixture = new Fixture(); + fixture.publicKey = 'SBUQWP3BOUZX34ZONKXRBTLNNDOWR5HLCVPL2B4XNCLJTLMUMLTSOGBM'; + + const errors = await validate(fixture); + + expect(errors).toHaveLength(1); + }); + + it('fails for non-string input', async () => { + const fixture = new Fixture(); + (fixture as unknown as { publicKey: unknown }).publicKey = 12345; + + const errors = await validate(fixture); + + expect(errors).toHaveLength(1); + }); + + it('fails for an empty string', async () => { + const fixture = new Fixture(); + fixture.publicKey = ''; + + const errors = await validate(fixture); + + expect(errors).toHaveLength(1); + }); +}); diff --git a/src/common/stellar/is-stellar-public-key.validator.ts b/src/common/stellar/is-stellar-public-key.validator.ts new file mode 100644 index 0000000..613913d --- /dev/null +++ b/src/common/stellar/is-stellar-public-key.validator.ts @@ -0,0 +1,33 @@ +import { + registerDecorator, + ValidationOptions, + ValidationArguments, +} from 'class-validator'; +import { StrKeyHelper } from '../../key-management/utils'; + +/** + * Validates that a property is a well-formed Stellar Ed25519 public key + * (StrKey "G..." address), using stellar-sdk's checksum validation rather + * than a bare regex — catches typos/bit-flips that a shape-only check would miss. + */ +export function IsStellarPublicKey(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isStellarPublicKey', + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown, _args: ValidationArguments) { + return ( + typeof value === 'string' && + StrKeyHelper.isValidEd25519PublicKey(value) + ); + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid Stellar public key (StrKey "G..." address)`; + }, + }, + }); + }; +} diff --git a/src/health/health.controller.spec.ts b/src/health/health.controller.spec.ts new file mode 100644 index 0000000..69f5526 --- /dev/null +++ b/src/health/health.controller.spec.ts @@ -0,0 +1,104 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +function makeController(overrides: { + checkImpl?: jest.Mock; + gitSha?: string; +}) { + const mockHealthCheckService = { + check: + overrides.checkImpl ?? + jest.fn().mockResolvedValue({ + status: 'ok', + info: { database: { status: 'up' } }, + error: {}, + details: { database: { status: 'up' } }, + }), + }; + const mockPrismaIndicator = { pingCheck: jest.fn() }; + const mockPrisma = {}; + const mockConfigService = { + get: jest.fn().mockImplementation((key: string, defaultValue: string) => { + if (key === 'GIT_SHA') return overrides.gitSha ?? defaultValue; + return defaultValue; + }), + }; + + const controller = new HealthController( + mockHealthCheckService as any, + mockPrismaIndicator as any, + mockPrisma as any, + mockConfigService as any, + ); + + return { controller, mockHealthCheckService, mockConfigService }; +} + +describe('HealthController', () => { + describe('check – success path', () => { + it('returns the health result with build.gitSha included', async () => { + const { controller } = makeController({ gitSha: 'abc1234' }); + + const result = await controller.check(); + + expect(result).toEqual({ + status: 'ok', + info: { database: { status: 'up' } }, + error: {}, + details: { database: { status: 'up' } }, + build: { gitSha: 'abc1234' }, + }); + }); + + it('defaults gitSha to "unknown" when GIT_SHA is not set', async () => { + const { controller } = makeController({}); + + const result = await controller.check(); + + expect((result as any).build).toEqual({ gitSha: 'unknown' }); + }); + }); + + describe('check – failure path', () => { + it('re-throws 503 with build.gitSha merged into the error body when the DB is down', async () => { + const dbError = new ServiceUnavailableException({ + status: 'error', + info: {}, + error: { database: { status: 'down', message: 'connection refused' } }, + details: { + database: { status: 'down', message: 'connection refused' }, + }, + }); + + const { controller } = makeController({ + checkImpl: jest.fn().mockRejectedValue(dbError), + gitSha: 'deadbeef', + }); + + await expect(controller.check()).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + + try { + await controller.check(); + throw new Error('expected controller.check() to throw'); + } catch (err) { + expect(err).toBeInstanceOf(ServiceUnavailableException); + const response = (err as ServiceUnavailableException).getResponse() as any; + expect(response.build).toEqual({ gitSha: 'deadbeef' }); + expect(response.error.database.status).toBe('down'); + // No secrets, only a commit hash, ever end up in the response. + expect(JSON.stringify(response)).not.toMatch(/secret|private[_-]?key/i); + } + }); + + it('re-throws non-ServiceUnavailableException errors unchanged', async () => { + const otherError = new Error('unexpected failure'); + const { controller } = makeController({ + checkImpl: jest.fn().mockRejectedValue(otherError), + }); + + await expect(controller.check()).rejects.toThrow('unexpected failure'); + }); + }); +}); diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 88f30b7..2fc7524 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,26 +1,83 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; import { HealthCheck, HealthCheckService, PrismaHealthIndicator, } from '@nestjs/terminus'; +import { ConfigService } from '@nestjs/config'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { PrismaService } from '../prisma/prisma.service'; import { Public } from '../auth/public.decorator'; +@ApiTags('health') @Controller('health') export class HealthController { constructor( private readonly health: HealthCheckService, private readonly prismaIndicator: PrismaHealthIndicator, private readonly prisma: PrismaService, + private readonly configService: ConfigService, ) {} @Public() @Get() @HealthCheck() - check() { - return this.health.check([ - () => this.prismaIndicator.pingCheck('database', this.prisma), - ]); + @ApiOperation({ + summary: 'Health check, including build identity (git SHA)', + }) + @ApiResponse({ + status: 200, + description: 'Service is healthy', + schema: { + example: { + status: 'ok', + info: { database: { status: 'up' } }, + error: {}, + details: { database: { status: 'up' } }, + build: { gitSha: 'a1b2c3d4e5f6' }, + }, + }, + }) + @ApiResponse({ + status: 503, + description: 'Service is unhealthy (e.g. database unreachable)', + schema: { + example: { + status: 'error', + info: {}, + error: { database: { status: 'down', message: 'connection refused' } }, + details: { database: { status: 'down', message: 'connection refused' } }, + build: { gitSha: 'a1b2c3d4e5f6' }, + }, + }, + }) + async check() { + const build = { gitSha: this.getGitSha() }; + + try { + const result = await this.health.check([ + () => this.prismaIndicator.pingCheck('database', this.prisma), + ]); + return { ...result, build }; + } catch (err) { + if (err instanceof ServiceUnavailableException) { + const response = err.getResponse(); + const body = + typeof response === 'object' && response !== null + ? response + : { message: response }; + throw new ServiceUnavailableException({ ...body, build }); + } + throw err; + } + } + + /** + * Git SHA of the running build, injected at container build time via the + * GIT_SHA env var (see Dockerfile). Never sourced from anything that could + * leak secrets — just a commit hash. + */ + private getGitSha(): string { + return this.configService.get('GIT_SHA', 'unknown'); } } diff --git a/src/transactions/dto/create-transaction.dto.ts b/src/transactions/dto/create-transaction.dto.ts index 844cf33..8624bec 100644 --- a/src/transactions/dto/create-transaction.dto.ts +++ b/src/transactions/dto/create-transaction.dto.ts @@ -1,4 +1,5 @@ import { MemoType } from '../../common/stellar/memo.util'; +import { IsStellarPublicKey } from '../../common/stellar/is-stellar-public-key.validator'; import { IsEnum, IsNotEmpty, @@ -17,9 +18,6 @@ import { AssetType } from '../../balance-indexer/domain/balance.model'; /** Positive decimal amount — must be > 0, e.g. "10", "0.0000001", "922337203685.4775807" */ const AMOUNT_REGEX = /^(?!0(\.0+)?$)\d+(\.\d{1,7})?$/; -/** Stellar public key: G followed by 55 uppercase alphanumeric chars */ -const STELLAR_PUBLIC_KEY_REGEX = /^G[A-Z0-9]{55}$/; - export class TransactionAssetDto { @IsEnum(AssetType) type: AssetType; @@ -33,10 +31,7 @@ export class TransactionAssetDto { /** Required for non-native assets */ @ValidateIf((o) => o.type !== AssetType.NATIVE) - @IsString() - @Matches(STELLAR_PUBLIC_KEY_REGEX, { - message: 'issuer must be a valid Stellar public key', - }) + @IsStellarPublicKey() issuer?: string; } diff --git a/src/transactions/dto/fee-bump-transaction.dto.ts b/src/transactions/dto/fee-bump-transaction.dto.ts index 2367660..6a81b36 100644 --- a/src/transactions/dto/fee-bump-transaction.dto.ts +++ b/src/transactions/dto/fee-bump-transaction.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsIn } from 'class-validator'; +import { IsStellarPublicKey } from '../../common/stellar/is-stellar-public-key.validator'; /** * Request body for the fee-bump submission endpoint. @@ -34,8 +35,7 @@ export class FeeBumpTransactionDto { 'Stellar public key of the fee-source (sponsor) account that will pay the fee.', example: 'GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEF', }) - @IsString() - @IsNotEmpty() + @IsStellarPublicKey() feeSourcePublicKey: string; /** @@ -72,8 +72,7 @@ export class FeeBumpTransactionDto { enum: ['TESTNET', 'MAINNET'], example: 'TESTNET', }) - @IsString() - @IsNotEmpty() + @IsIn(['TESTNET', 'MAINNET']) network: 'TESTNET' | 'MAINNET'; } diff --git a/src/transactions/fee-bump.service.spec.ts b/src/transactions/fee-bump.service.spec.ts index 640c708..368afc0 100644 --- a/src/transactions/fee-bump.service.spec.ts +++ b/src/transactions/fee-bump.service.spec.ts @@ -1,4 +1,8 @@ -import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; +import { + BadRequestException, + ServiceUnavailableException, + HttpException, +} from '@nestjs/common'; import { FeeBumpService } from './fee-bump.service'; import { TransactionStatus } from './domain/transaction.model'; @@ -61,6 +65,7 @@ function makeService(overrides: { horizonPost?: jest.Mock; getDecryptedPrivateKey?: jest.Mock; updateStatus?: jest.Mock; + mainnetPaymentSubmitEnabled?: boolean; }) { const mockHttp = (createRequestIdAwareAxios as jest.Mock)(); if (overrides.horizonPost) { @@ -84,16 +89,29 @@ function makeService(overrides: { }), }; + const mockFeatureFlagService = { + isEnabled: jest + .fn() + .mockReturnValue(overrides.mainnetPaymentSubmitEnabled ?? true), + }; + const service = new FeeBumpService( mockConfigService as any, mockWalletsService as any, mockTransactionsService as any, + mockFeatureFlagService as any, ); // Inject the mock http directly (service as any).http = mockHttp; - return { service, mockHttp, mockWalletsService, mockTransactionsService }; + return { + service, + mockHttp, + mockWalletsService, + mockTransactionsService, + mockFeatureFlagService, + }; } // --------------------------------------------------------------------------- @@ -162,6 +180,63 @@ describe('FeeBumpService', () => { }); }); + // ------------------------------------------------------------------------- + // Mainnet feature flag + // ------------------------------------------------------------------------- + describe('submitFeeBump – mainnet_payment_submit feature flag', () => { + it('rejects MAINNET submission with 403 when the flag is disabled', async () => { + const mockPost = jest.fn(); + const { service, mockFeatureFlagService } = makeService({ + horizonPost: mockPost, + mainnetPaymentSubmitEnabled: false, + }); + + await expect( + service.submitFeeBump({ ...VALID_DTO, network: 'MAINNET' }), + ).rejects.toThrow(HttpException); + + expect(mockFeatureFlagService.isEnabled).toHaveBeenCalledWith( + 'mainnet_payment_submit', + ); + // Never reaches Horizon once the flag denies the request. + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('allows MAINNET submission when the flag is enabled', async () => { + const mockPost = jest.fn().mockResolvedValue({ + data: { hash: 'mainnet-hash', successful: true }, + status: 200, + }); + const { service } = makeService({ + horizonPost: mockPost, + mainnetPaymentSubmitEnabled: true, + }); + + const result = await service.submitFeeBump({ + ...VALID_DTO, + network: 'MAINNET', + }); + + expect(result.stellarHash).toBe('mainnet-hash'); + expect(mockPost).toHaveBeenCalled(); + }); + + it('does not consult the flag for TESTNET submissions', async () => { + const mockPost = jest.fn().mockResolvedValue({ + data: { hash: 'testnet-hash', successful: true }, + status: 200, + }); + const { service, mockFeatureFlagService } = makeService({ + horizonPost: mockPost, + mainnetPaymentSubmitEnabled: false, + }); + + await service.submitFeeBump({ ...VALID_DTO, network: 'TESTNET' }); + + expect(mockFeatureFlagService.isEnabled).not.toHaveBeenCalled(); + }); + }); + // ------------------------------------------------------------------------- // Failure paths // ------------------------------------------------------------------------- diff --git a/src/transactions/fee-bump.service.ts b/src/transactions/fee-bump.service.ts index 503eb21..fb7e0eb 100644 --- a/src/transactions/fee-bump.service.ts +++ b/src/transactions/fee-bump.service.ts @@ -4,8 +4,11 @@ import { BadRequestException, ServiceUnavailableException, NotFoundException, + HttpException, + HttpStatus, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { FeatureFlagService } from '../common/feature-flags/feature-flag.service'; import { TransactionBuilder, Transaction, @@ -52,6 +55,7 @@ export class FeeBumpService { private readonly configService: ConfigService, private readonly walletsService: WalletsService, private readonly transactionsService: TransactionsService, + private readonly featureFlagService: FeatureFlagService, ) { const testnetUrl = this.configService.get( 'STELLAR_HORIZON_URL', @@ -80,6 +84,24 @@ export class FeeBumpService { network, } = dto; + // --- 0. Mainnet kill-switch ------------------------------------------------ + if ( + network === 'MAINNET' && + !this.featureFlagService.isEnabled('mainnet_payment_submit') + ) { + this.logger.warn( + 'Rejected fee-bump submission: mainnet_payment_submit flag is disabled', + ); + throw new HttpException( + { + statusCode: HttpStatus.FORBIDDEN, + message: + 'Mainnet payment submission is not available at this time. (Flag: mainnet_payment_submit)', + }, + HttpStatus.FORBIDDEN, + ); + } + // --- 1. Decode inner transaction ----------------------------------------- let innerTx: Transaction; try { diff --git a/src/transactions/transactions.controller.ts b/src/transactions/transactions.controller.ts index fc038f5..e70ef7a 100644 --- a/src/transactions/transactions.controller.ts +++ b/src/transactions/transactions.controller.ts @@ -194,6 +194,11 @@ export class TransactionsController { }, }) @ApiResponse({ status: 400, description: 'Invalid XDR or Horizon rejection' }) + @ApiResponse({ + status: 403, + description: + 'Mainnet payment submission is disabled (mainnet_payment_submit feature flag is off)', + }) @ApiResponse({ status: 503, description: 'Horizon unavailable' }) @Post('fee-bump') @SensitiveEndpoint()