diff --git a/backend/services/stellar/__tests__/feeBumpService.test.ts b/backend/services/stellar/__tests__/feeBumpService.test.ts new file mode 100644 index 00000000..85f3b775 --- /dev/null +++ b/backend/services/stellar/__tests__/feeBumpService.test.ts @@ -0,0 +1,230 @@ +import { + FeeBumpService, + InMemoryFeeBumpStore, + TestnetStellarClient, + type MerchantFeeBumpConfig, + type FeeBumpRequest, + type StellarNetworkClient, +} from '../feeBumpService'; + +describe('FeeBumpService — Issue #1712', () => { + let store: InMemoryFeeBumpStore; + let service: FeeBumpService; + + const merchantConfig: MerchantFeeBumpConfig = { + merchantId: 'merchant-abc', + feeBumpEnabled: true, + sponsorAddress: 'GABC123STELLARADDRESS', + maxFeeStroops: 100, + }; + + const request: FeeBumpRequest = { + merchantId: 'merchant-abc', + subscriberId: 'sub-xyz', + innerTransactionXdr: 'AAAAAQAAAA...', + }; + + beforeEach(() => { + store = new InMemoryFeeBumpStore(); + service = new FeeBumpService(store, new TestnetStellarClient(), 'testnet'); + }); + + // ── configureMerchant ────────────────────────────────────────────────── + + describe('configureMerchant', () => { + it('persists a merchant config', async () => { + await service.configureMerchant(merchantConfig); + const stored = await service.getMerchantConfig('merchant-abc'); + expect(stored).toMatchObject(merchantConfig); + }); + + it('allows updating an existing config', async () => { + await service.configureMerchant(merchantConfig); + await service.configureMerchant({ ...merchantConfig, feeBumpEnabled: false }); + const stored = await service.getMerchantConfig('merchant-abc'); + expect(stored?.feeBumpEnabled).toBe(false); + }); + + it('throws when merchantId is missing', async () => { + await expect( + service.configureMerchant({ ...merchantConfig, merchantId: '' }), + ).rejects.toThrow('merchantId is required'); + }); + + it('throws when sponsorAddress is missing', async () => { + await expect( + service.configureMerchant({ ...merchantConfig, sponsorAddress: '' }), + ).rejects.toThrow('sponsorAddress is required'); + }); + + it('throws when maxFeeStroops is negative', async () => { + await expect( + service.configureMerchant({ ...merchantConfig, maxFeeStroops: -1 }), + ).rejects.toThrow('maxFeeStroops must be non-negative'); + }); + }); + + // ── opt-in check ───────────────────────────────────────────────────── + + describe('sponsorTransaction — opt-in check', () => { + it('returns error when merchant not configured', async () => { + const result = await service.sponsorTransaction(request); + expect(result.success).toBe(false); + expect(result.errorMessage).toMatch(/not enabled/i); + }); + + it('returns error when feeBumpEnabled is false', async () => { + await service.configureMerchant({ ...merchantConfig, feeBumpEnabled: false }); + const result = await service.sponsorTransaction(request); + expect(result.success).toBe(false); + expect(result.errorMessage).toMatch(/not enabled/i); + }); + }); + + // ── successful sponsorship ──────────────────────────────────────────── + + describe('sponsorTransaction — success path', () => { + beforeEach(async () => { + await service.configureMerchant(merchantConfig); + }); + + it('returns success with tx hashes and fee', async () => { + const result = await service.sponsorTransaction(request); + expect(result.success).toBe(true); + expect(result.feeBumpTransactionHash).toBeDefined(); + expect(result.innerTransactionHash).toBeDefined(); + expect(result.feeStroops).toBeGreaterThan(0); + }); + + it('records the sponsorship for accounting', async () => { + await service.sponsorTransaction(request); + const records = await service.getAccountingRecords('merchant-abc'); + expect(records).toHaveLength(1); + expect(records[0].merchantId).toBe('merchant-abc'); + expect(records[0].subscriberId).toBe('sub-xyz'); + expect(records[0].network).toBe('testnet'); + }); + + it('uses request maxFeeStroops when provided', async () => { + const mockClient: StellarNetworkClient = { + submitFeeBump: jest.fn(async (p) => ({ + feeBumpHash: 'hash1', + innerHash: 'inner1', + feeStroops: p.maxFeeStroops, + })), + }; + const svc = new FeeBumpService(store, mockClient, 'testnet'); + await svc.sponsorTransaction({ ...request, maxFeeStroops: 200 }); + expect(mockClient.submitFeeBump).toHaveBeenCalledWith( + expect.objectContaining({ maxFeeStroops: 200 }), + ); + }); + + it('falls back to config maxFeeStroops when not in request', async () => { + const mockClient: StellarNetworkClient = { + submitFeeBump: jest.fn(async (p) => ({ + feeBumpHash: 'hash1', + innerHash: 'inner1', + feeStroops: p.maxFeeStroops, + })), + }; + const svc = new FeeBumpService(store, mockClient, 'testnet'); + await service.configureMerchant({ ...merchantConfig, maxFeeStroops: 150 }); + await svc.sponsorTransaction(request); + expect(mockClient.submitFeeBump).toHaveBeenCalledWith( + expect.objectContaining({ maxFeeStroops: 150 }), + ); + }); + + it('accumulates multiple records across transactions', async () => { + await service.sponsorTransaction(request); + await service.sponsorTransaction({ ...request, subscriberId: 'sub-yyy' }); + const records = await service.getAccountingRecords('merchant-abc'); + expect(records).toHaveLength(2); + }); + }); + + // ── error handling ─────────────────────────────────────────────────── + + describe('sponsorTransaction — Stellar client error', () => { + it('returns failure result (no exception) when Stellar throws', async () => { + const failingClient: StellarNetworkClient = { + submitFeeBump: jest.fn().mockRejectedValue(new Error('Network timeout')), + }; + const svc = new FeeBumpService(store, failingClient, 'testnet'); + await svc.configureMerchant(merchantConfig); + const result = await svc.sponsorTransaction(request); + expect(result.success).toBe(false); + expect(result.errorMessage).toBe('Network timeout'); + }); + + it('does not write an accounting record on Stellar failure', async () => { + const failingClient: StellarNetworkClient = { + submitFeeBump: jest.fn().mockRejectedValue(new Error('tx_failed')), + }; + const svc = new FeeBumpService(store, failingClient, 'testnet'); + await svc.configureMerchant(merchantConfig); + await svc.sponsorTransaction(request); + const records = await svc.getAccountingRecords('merchant-abc'); + expect(records).toHaveLength(0); + }); + }); + + // ── getAccountingRecords ───────────────────────────────────────────── + + describe('getAccountingRecords', () => { + it('isolates records per merchant', async () => { + await service.configureMerchant(merchantConfig); + await service.configureMerchant({ + ...merchantConfig, + merchantId: 'merchant-other', + sponsorAddress: 'GOTHER', + }); + + await service.sponsorTransaction(request); + await service.sponsorTransaction({ ...request, merchantId: 'merchant-other' }); + + const recordsAbc = await service.getAccountingRecords('merchant-abc'); + const recordsOther = await service.getAccountingRecords('merchant-other'); + expect(recordsAbc).toHaveLength(1); + expect(recordsOther).toHaveLength(1); + }); + + it('returns empty array for merchant with no records', async () => { + const records = await service.getAccountingRecords('unknown-merchant'); + expect(records).toHaveLength(0); + }); + }); + + // ── TestnetStellarClient ───────────────────────────────────────────── + + describe('TestnetStellarClient', () => { + it('returns deterministic inner hash from XDR', async () => { + const client = new TestnetStellarClient(); + const r1 = await client.submitFeeBump({ + sponsorAddress: 'GABC', + innerTransactionXdr: 'XDR1', + maxFeeStroops: 100, + network: 'testnet', + }); + const r2 = await client.submitFeeBump({ + sponsorAddress: 'GABC', + innerTransactionXdr: 'XDR1', + maxFeeStroops: 100, + network: 'testnet', + }); + expect(r1.innerHash).toBe(r2.innerHash); + }); + + it('caps fee at maxFeeStroops (max 100)', async () => { + const client = new TestnetStellarClient(); + const result = await client.submitFeeBump({ + sponsorAddress: 'GABC', + innerTransactionXdr: 'XDR', + maxFeeStroops: 50, + network: 'testnet', + }); + expect(result.feeStroops).toBeLessThanOrEqual(50); + }); + }); +}); diff --git a/backend/services/stellar/feeBumpService.ts b/backend/services/stellar/feeBumpService.ts new file mode 100644 index 00000000..f721a916 --- /dev/null +++ b/backend/services/stellar/feeBumpService.ts @@ -0,0 +1,224 @@ +/** + * Fee-Bump (Sponsored Transaction) Service — Issue #1712 + * + * Allows merchants to sponsor network fees for subscribers who hold no XLM, + * unblocking checkout for new users. + * + * Design: + * - Fee-bump is opt-in per merchant via `MerchantFeeBumpConfig`. + * - Every sponsored fee is recorded in `FeeBumpRecord` for accounting. + * - `StellarNetworkClient` is injected for testability; the default + * implementation targets Stellar testnet, swapped to mainnet by config. + */ + +import { randomUUID } from 'crypto'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +/** Per-merchant opt-in configuration for fee-bump sponsorship. */ +export interface MerchantFeeBumpConfig { + merchantId: string; + /** Whether this merchant has opted in to sponsoring subscriber fees. */ + feeBumpEnabled: boolean; + /** Stellar account address that will pay the base fee. */ + sponsorAddress: string; + /** + * Maximum per-transaction fee the merchant is willing to sponsor, in stroops + * (1 XLM = 10_000_000 stroops). Defaults to 100 stroops (the Stellar base + * fee). + */ + maxFeeStroops: number; +} + +/** Immutable record of a sponsored fee written for accounting. */ +export interface FeeBumpRecord { + id: string; + merchantId: string; + subscriberId: string; + /** The inner-transaction hash whose fee was sponsored. */ + innerTransactionHash: string; + /** The outer fee-bump transaction hash returned by Stellar. */ + feeBumpTransactionHash: string; + feeStroops: number; + /** ISO-8601 timestamp of when the sponsorship was applied. */ + sponsoredAt: string; + /** Target network: 'testnet' | 'mainnet' */ + network: 'testnet' | 'mainnet'; +} + +export interface FeeBumpRequest { + merchantId: string; + subscriberId: string; + /** + * XDR-encoded inner transaction built and signed by the subscriber. + * The fee-bump wrapper re-signs this with the merchant's sponsor key. + */ + innerTransactionXdr: string; + /** Optional: desired max fee in stroops; falls back to config default. */ + maxFeeStroops?: number; +} + +export interface FeeBumpResult { + success: boolean; + feeBumpTransactionHash?: string; + innerTransactionHash?: string; + feeStroops?: number; + errorMessage?: string; +} + +/** Slim interface for the Stellar network client — injectable for tests. */ +export interface StellarNetworkClient { + /** Submit a fee-bump wrapper around an inner XDR and return tx hashes. */ + submitFeeBump(params: { + sponsorAddress: string; + innerTransactionXdr: string; + maxFeeStroops: number; + network: 'testnet' | 'mainnet'; + }): Promise<{ feeBumpHash: string; innerHash: string; feeStroops: number }>; +} + +/** In-memory store interface for merchant configs and fee records. */ +export interface FeeBumpStore { + getMerchantConfig(merchantId: string): Promise; + saveMerchantConfig(config: MerchantFeeBumpConfig): Promise; + saveFeeBumpRecord(record: FeeBumpRecord): Promise; + getFeeBumpRecords(merchantId: string): Promise; +} + +// ── Default in-memory store ─────────────────────────────────────────────── + +export class InMemoryFeeBumpStore implements FeeBumpStore { + private configs = new Map(); + private records: FeeBumpRecord[] = []; + + async getMerchantConfig(merchantId: string): Promise { + return this.configs.get(merchantId) ?? null; + } + + async saveMerchantConfig(config: MerchantFeeBumpConfig): Promise { + this.configs.set(config.merchantId, { ...config }); + } + + async saveFeeBumpRecord(record: FeeBumpRecord): Promise { + this.records.push({ ...record }); + } + + async getFeeBumpRecords(merchantId: string): Promise { + return this.records.filter(r => r.merchantId === merchantId); + } +} + +// ── Default Stellar client (testnet) ───────────────────────────────────── + +/** + * Stub Stellar client that simulates testnet submission. + * Replace with a real stellar-sdk implementation for production. + */ +export class TestnetStellarClient implements StellarNetworkClient { + async submitFeeBump(params: { + sponsorAddress: string; + innerTransactionXdr: string; + maxFeeStroops: number; + network: 'testnet' | 'mainnet'; + }): Promise<{ feeBumpHash: string; innerHash: string; feeStroops: number }> { + // Derive a deterministic-ish hash from the XDR for testnet simulation. + const innerHash = `inner_${Buffer.from(params.innerTransactionXdr).toString('hex').slice(0, 32)}`; + const feeBumpHash = `feebump_${params.sponsorAddress.slice(0, 8)}_${Date.now()}`; + return { + feeBumpHash, + innerHash, + feeStroops: Math.min(params.maxFeeStroops, 100), + }; + } +} + +// ── FeeBumpService ──────────────────────────────────────────────────────── + +export class FeeBumpService { + private readonly network: 'testnet' | 'mainnet'; + + constructor( + private readonly store: FeeBumpStore = new InMemoryFeeBumpStore(), + private readonly stellarClient: StellarNetworkClient = new TestnetStellarClient(), + network: 'testnet' | 'mainnet' = 'testnet', + ) { + this.network = network; + } + + /** + * Register or update a merchant's fee-bump configuration. + * This is how a merchant opts in (or out) of sponsoring fees. + */ + async configureMerchant(config: MerchantFeeBumpConfig): Promise { + if (!config.merchantId) throw new Error('merchantId is required'); + if (!config.sponsorAddress) throw new Error('sponsorAddress is required'); + if (config.maxFeeStroops < 0) throw new Error('maxFeeStroops must be non-negative'); + await this.store.saveMerchantConfig(config); + } + + /** + * Attempt to sponsor the fee for a subscriber's transaction. + * + * Returns a successful `FeeBumpResult` when the merchant has opted in and + * the Stellar submission succeeds. Returns an error result (no exception) + * when the merchant has not opted in — the caller should fall back to a + * regular (non-sponsored) transaction. + */ + async sponsorTransaction(request: FeeBumpRequest): Promise { + const config = await this.store.getMerchantConfig(request.merchantId); + + if (!config || !config.feeBumpEnabled) { + return { + success: false, + errorMessage: `Merchant ${request.merchantId} has not enabled fee-bump sponsorship`, + }; + } + + const maxFee = request.maxFeeStroops ?? config.maxFeeStroops; + + let submission: { feeBumpHash: string; innerHash: string; feeStroops: number }; + try { + submission = await this.stellarClient.submitFeeBump({ + sponsorAddress: config.sponsorAddress, + innerTransactionXdr: request.innerTransactionXdr, + maxFeeStroops: maxFee, + network: this.network, + }); + } catch (err) { + return { + success: false, + errorMessage: err instanceof Error ? err.message : String(err), + }; + } + + // Record the sponsorship for accounting. + const record: FeeBumpRecord = { + id: randomUUID(), + merchantId: request.merchantId, + subscriberId: request.subscriberId, + innerTransactionHash: submission.innerHash, + feeBumpTransactionHash: submission.feeBumpHash, + feeStroops: submission.feeStroops, + sponsoredAt: new Date().toISOString(), + network: this.network, + }; + await this.store.saveFeeBumpRecord(record); + + return { + success: true, + feeBumpTransactionHash: submission.feeBumpHash, + innerTransactionHash: submission.innerHash, + feeStroops: submission.feeStroops, + }; + } + + /** Retrieve all fee-bump records for a merchant (for accounting/reporting). */ + async getAccountingRecords(merchantId: string): Promise { + return this.store.getFeeBumpRecords(merchantId); + } + + /** Get a merchant's current fee-bump configuration. */ + async getMerchantConfig(merchantId: string): Promise { + return this.store.getMerchantConfig(merchantId); + } +}