diff --git a/apps/api/src/modules/cctp/burn-fee.service.spec.ts b/apps/api/src/modules/cctp/burn-fee.service.spec.ts new file mode 100644 index 0000000..9458ee3 --- /dev/null +++ b/apps/api/src/modules/cctp/burn-fee.service.spec.ts @@ -0,0 +1,139 @@ +import type { ConfigService } from '@nestjs/config'; +import { + BurnFeeService, + FINALITY_FAST, + FINALITY_STANDARD, +} from './burn-fee.service'; + +function makeService(network = 'testnet') { + return new BurnFeeService({ + get: jest.fn((key: string) => + key === 'STELLAR_NETWORK' ? network : undefined, + ), + } as unknown as ConfigService); +} + +function mockFetch(impl: () => Promise) { + global.fetch = jest.fn(impl) as unknown as typeof fetch; +} + +/** Circle's real shape for Ethereum → Stellar at the time of writing. */ +const CIRCLE_FEES = [ + { finalityThreshold: 1000, minimumFee: 1 }, + { finalityThreshold: 2000, minimumFee: 0 }, +]; + +const ok = (body: unknown) => + Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) }); + +describe('BurnFeeService', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('maxFeeFor', () => { + const svc = makeService(); + + // The bug this guards. A 0.01 USDC payment is 10_000 subunits; 1 bp of it + // is exactly 1 subunit. Anything smaller truncates toward zero, and a + // maxFee of 0 is precisely the "insufficient fee" that gets a Fast + // Transfer silently demoted to a fifteen-minute one. + it('never returns zero for a non-zero rate', () => { + expect(svc.maxFeeFor(10_000n, 1)).toBe(1n); + expect(svc.maxFeeFor(1n, 1)).toBe(1n); + expect(svc.maxFeeFor(100n, 1)).toBe(1n); + }); + + it('rounds up rather than down', () => { + // 1 bp of 15_001 subunits is 1.5001 — must not floor to 1. + expect(svc.maxFeeFor(15_001n, 1)).toBe(2n); + }); + + it('computes whole-basis-point amounts exactly', () => { + expect(svc.maxFeeFor(1_000_000n, 1)).toBe(100n); // 1 USDC, 1bp + expect(svc.maxFeeFor(1_000_000n, 14)).toBe(1400n); + }); + + it('is zero only when the rate itself is', () => { + expect(svc.maxFeeFor(1_000_000n, 0)).toBe(0n); + }); + }); + + describe('minimumFeeBps', () => { + it("returns Circle's quoted rate for the fast tier", async () => { + mockFetch(() => ok(CIRCLE_FEES)); + await expect( + makeService().minimumFeeBps(0, 27, FINALITY_FAST), + ).resolves.toBe(1); + }); + + it('returns the free rate for standard finality', async () => { + mockFetch(() => ok(CIRCLE_FEES)); + await expect( + makeService().minimumFeeBps(0, 27, FINALITY_STANDARD), + ).resolves.toBe(0); + }); + + it('queries the sandbox on testnet and the live API on mainnet', async () => { + mockFetch(() => ok(CIRCLE_FEES)); + await makeService('testnet').minimumFeeBps(0, 27); + expect(global.fetch).toHaveBeenCalledWith( + 'https://iris-api-sandbox.circle.com/v2/burn/USDC/fees/0/27', + expect.anything(), + ); + + mockFetch(() => ok(CIRCLE_FEES)); + await makeService('mainnet').minimumFeeBps(0, 27); + expect(global.fetch).toHaveBeenCalledWith( + 'https://iris-api.circle.com/v2/burn/USDC/fees/0/27', + expect.anything(), + ); + }); + + // Null means "fall back to standard finality". Returning 0 instead would + // read as "fast is free" and send a burn that gets demoted anyway — the + // exact failure this service exists to prevent. + it('returns null when Circle errors', async () => { + mockFetch(() => + Promise.resolve({ + ok: false, + status: 503, + json: () => Promise.resolve({}), + }), + ); + await expect(makeService().minimumFeeBps(0, 27)).resolves.toBeNull(); + }); + + it('returns null when the request fails outright', async () => { + mockFetch(() => Promise.reject(new Error('network down'))); + await expect(makeService().minimumFeeBps(0, 27)).resolves.toBeNull(); + }); + + it('returns null when the tier is not quoted for the route', async () => { + mockFetch(() => ok([{ finalityThreshold: 2000, minimumFee: 0 }])); + await expect( + makeService().minimumFeeBps(0, 27, FINALITY_FAST), + ).resolves.toBeNull(); + }); + + it('caches per route so a burn does not re-query on every quote', async () => { + mockFetch(() => ok(CIRCLE_FEES)); + const svc = makeService(); + + await svc.minimumFeeBps(0, 27); + await svc.minimumFeeBps(0, 27); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('does not let one route cache-poison another', async () => { + mockFetch(() => ok(CIRCLE_FEES)); + const svc = makeService(); + + await svc.minimumFeeBps(0, 27); + await svc.minimumFeeBps(6, 27); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/apps/api/src/modules/cctp/burn-fee.service.ts b/apps/api/src/modules/cctp/burn-fee.service.ts new file mode 100644 index 0000000..f4d360e --- /dev/null +++ b/apps/api/src/modules/cctp/burn-fee.service.ts @@ -0,0 +1,124 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + cctpEnvFromStellarNetwork, + IRIS_BASE_URL, + type CctpEnv, +} from './contracts.js'; + +/** Fast Transfer. Circle charges for it. */ +export const FINALITY_FAST = 1000; +/** Hard finality. Free, but minutes rather than seconds. */ +export const FINALITY_STANDARD = 2000; + +const REQUEST_TIMEOUT_MS = 8000; + +/** Circle quotes its minimum in basis points of the burn amount. */ +const BPS_DENOMINATOR = 10_000n; + +interface IrisFeeEntry { + finalityThreshold?: number; + minimumFee?: number; +} + +/** + * What a Fast Transfer costs, according to Circle. + * + * This exists because a burn that asks for Fast Transfer while offering + * `maxFee: 0` is not rejected — it is silently demoted. Circle keeps the + * message at `pending_confirmations` with `delayReason: insufficient_fee` and + * waits for hard finality instead, so a payment advertised as "8–20 seconds" + * quietly becomes a fifteen-minute one, with nothing in our own logs to say so. + * + * The fee is small (1 bp on Ethereum → Stellar at the time of writing, i.e. one + * subunit on a one-cent payment) but it has to be non-zero and it has to be + * asked for. + */ +@Injectable() +export class BurnFeeService { + private readonly logger = new Logger(BurnFeeService.name); + private readonly env: CctpEnv; + + /** Cached per source→destination pair; Circle's fees move rarely. */ + private readonly cache = new Map(); + private static readonly TTL_MS = 5 * 60 * 1000; + + constructor(private readonly config: ConfigService) { + this.env = cctpEnvFromStellarNetwork( + this.config.get('STELLAR_NETWORK'), + ); + } + + /** + * Minimum fee in basis points for `finalityThreshold` on this route, or + * `null` if Circle could not be reached or quotes no such tier. + * + * Null is a real answer, not an error: the caller's job is to fall back to + * standard finality rather than send a burn that will be demoted anyway. + */ + async minimumFeeBps( + sourceDomain: number, + destinationDomain: number, + finalityThreshold: number = FINALITY_FAST, + ): Promise { + const key = `${sourceDomain}:${destinationDomain}:${finalityThreshold}`; + const hit = this.cache.get(key); + if (hit && Date.now() - hit.at < BurnFeeService.TTL_MS) return hit.bps; + + const url = `${IRIS_BASE_URL[this.env]}/v2/burn/USDC/fees/${sourceDomain}/${destinationDomain}`; + + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + let res: Response; + try { + res = await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timer); + } + + if (!res.ok) { + this.logger.warn( + `Circle fee lookup ${sourceDomain}→${destinationDomain} returned ${res.status}; falling back to standard finality.`, + ); + return null; + } + + const rows = (await res.json()) as IrisFeeEntry[]; + const row = rows.find((r) => r.finalityThreshold === finalityThreshold); + if (!row || typeof row.minimumFee !== 'number') { + this.logger.warn( + `Circle quotes no tier ${finalityThreshold} for ${sourceDomain}→${destinationDomain}; falling back to standard finality.`, + ); + return null; + } + + this.cache.set(key, { bps: row.minimumFee, at: Date.now() }); + return row.minimumFee; + } catch (err) { + this.logger.warn( + `Circle fee lookup failed for ${sourceDomain}→${destinationDomain}: ${ + err instanceof Error ? err.message : String(err) + }. Falling back to standard finality.`, + ); + return null; + } + } + + /** + * Converts Circle's basis points into a `maxFee` for `amount`. + * + * Rounds up, and never returns 0 for a non-zero rate: 1 bp of a one-cent + * payment is 0.0001 USDC, which truncates to nothing in 6-decimal subunits — + * and a maxFee of 0 is exactly the "insufficient fee" the caller is trying to + * avoid. Paying one subunit more than strictly required beats being demoted + * to a fifteen-minute settlement over a rounding error. + */ + maxFeeFor(amountSubunits: bigint, bps: number): bigint { + if (bps <= 0) return 0n; + const numerator = amountSubunits * BigInt(bps); + const rounded = + numerator / BPS_DENOMINATOR + (numerator % BPS_DENOMINATOR ? 1n : 0n); + return rounded > 0n ? rounded : 1n; + } +} diff --git a/apps/api/src/modules/cctp/cctp.module.ts b/apps/api/src/modules/cctp/cctp.module.ts index c0f19ca..78595d6 100644 --- a/apps/api/src/modules/cctp/cctp.module.ts +++ b/apps/api/src/modules/cctp/cctp.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AttestationService } from './attestation.service.js'; +import { BurnFeeService } from './burn-fee.service.js'; import { CctpService } from './cctp.service.js'; import { EvmCctpClient } from './evm-cctp.client.js'; import { ForwarderService } from './forwarder.service.js'; @@ -24,12 +25,13 @@ import { StellarCctpClient } from './stellar-cctp.client.js'; imports: [ConfigModule], providers: [ AttestationService, + BurnFeeService, ForwarderService, EvmCctpClient, StellarCctpClient, RouterService, CctpService, ], - exports: [RouterService, CctpService], + exports: [RouterService, CctpService, BurnFeeService], }) export class CctpModule {} diff --git a/apps/api/src/modules/payments/payments.service.spec.ts b/apps/api/src/modules/payments/payments.service.spec.ts index 528d0e7..eb742e3 100644 --- a/apps/api/src/modules/payments/payments.service.spec.ts +++ b/apps/api/src/modules/payments/payments.service.spec.ts @@ -9,6 +9,7 @@ import { StellarService } from '../stellar/stellar.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { LinksService } from '../links/links.service'; import { CctpService } from '../cctp/cctp.service'; +import { BurnFeeService } from '../cctp/burn-fee.service'; import { NotificationsService } from '../notifications/notifications.service'; import { EscrowService } from '../escrow/escrow.service'; @@ -112,6 +113,19 @@ describe('PaymentsService', () => { prepareBurn: jest.fn(), }; + // Circle's fee lookup. Defaults to the real Ethereum → Stellar answer (1 bp) + // so selectCrypto takes the Fast Transfer path these tests describe; a test + // that wants the standard-finality fallback can resolve null instead. + const burnFeeService = { + minimumFeeBps: jest.fn().mockResolvedValue(1), + maxFeeFor: jest.fn((amount: bigint, bps: number) => { + if (bps <= 0) return 0n; + const n = amount * BigInt(bps); + const r = n / 10_000n + (n % 10_000n ? 1n : 0n); + return r > 0n ? r : 1n; + }), + }; + // BullMQ queue double — submitBurn enqueues a cctp.observe job after // recording the source tx hash. Tests assert the call was made; the // worker itself is exercised by its own spec. @@ -183,6 +197,7 @@ describe('PaymentsService', () => { { provide: WebhooksService, useValue: webhooksService }, { provide: LinksService, useValue: linksService }, { provide: CctpService, useValue: cctpService }, + { provide: BurnFeeService, useValue: burnFeeService }, // BullMQ uses a stringly-typed token (`getQueueToken(name)`) for // queue injection. We replicate it here without pulling the BullMQ // helper into the test — keeps the test surface tiny. @@ -349,6 +364,74 @@ describe('PaymentsService', () => { }); }); + describe('selectCrypto — EVM source, Fast Transfer fee', () => { + const evmPayment = { + ...paymentRecord, + destAmount: new Prisma.Decimal('0.01'), + merchant: { + id: 'merchant_123', + settlementAddress: + 'GBBN5WUDNH5P7ZG3CKJIWZ6CXQY2PXL23H2K36QIE53UGAXWZDWJP3D7', + }, + quote: null, + }; + + beforeEach(() => { + prisma.payment.findUnique.mockResolvedValue(evmPayment); + prisma.quote.findUnique.mockResolvedValue({ + id: 'qt_1', + fromAmount: new Prisma.Decimal('0.01'), + fromAsset: 'USDC', + fromChain: 'ethereum', + toAmount: new Prisma.Decimal('0.01'), + toAsset: 'USDC', + toChain: 'stellar', + rate: new Prisma.Decimal(1), + feeAmount: new Prisma.Decimal(0), + feeBps: 50, + expiresAt: new Date(Date.now() + 600_000), + }); + quotesService.createQuote.mockResolvedValue({ id: 'qt_1' }); + cctpService.prepareBurn.mockResolvedValue({ + to: '0xTokenMessenger', + data: '0xdeadbeef', + value: '0x0', + description: 'burn', + }); + }); + + // Circle does not reject a Fast Transfer that offers maxFee 0 — it accepts + // the burn and quietly waits for hard finality, reporting + // `delayReason: insufficient_fee`. So the burn must carry a real fee, or + // the checkout's "8–20 seconds" is a lie every single time. + it('pays the fee Circle asks for rather than offering zero', async () => { + await service.selectCrypto('pay_123', 'ethereum'); + + const [req] = cctpService.prepareBurn.mock.calls.at(-1) as [ + { speed: string; maxFee: bigint }, + ]; + expect(req.speed).toBe('fast'); + // 0.01 USDC = 10_000 subunits; 1 bp of that is exactly 1. + expect(req.maxFee).toBe(1n); + expect(req.maxFee).not.toBe(0n); + }); + + // Sending a fast burn we know will be demoted would mean the status page + // promises seconds and delivers a quarter of an hour. Standard finality is + // slower but it is the truth. + it('drops to standard finality when the fee cannot be looked up', async () => { + burnFeeService.minimumFeeBps.mockResolvedValueOnce(null); + + await service.selectCrypto('pay_123', 'ethereum'); + + const [req] = cctpService.prepareBurn.mock.calls.at(-1) as [ + { speed: string; maxFee: bigint }, + ]; + expect(req.speed).toBe('standard'); + expect(req.maxFee).toBe(0n); + }); + }); + describe('selectCrypto — Stellar-native source', () => { const merchantWithWallet = { ...paymentRecord, diff --git a/apps/api/src/modules/payments/payments.service.ts b/apps/api/src/modules/payments/payments.service.ts index b6204a9..ff9ea3a 100644 --- a/apps/api/src/modules/payments/payments.service.ts +++ b/apps/api/src/modules/payments/payments.service.ts @@ -26,6 +26,7 @@ import { WebhooksService } from '../webhooks/webhooks.service'; import { LinksService } from '../links/links.service'; import { CctpService } from '../cctp/cctp.service'; import { getDomain, type DomainEntry } from '../cctp/domains'; +import { BurnFeeService, FINALITY_FAST } from '../cctp/burn-fee.service'; import { getEvmContracts, getUsdcAddress, @@ -178,6 +179,7 @@ export class PaymentsService implements OnModuleInit, OnModuleDestroy { private readonly webhooksService: WebhooksService, private readonly linksService: LinksService, private readonly cctpService: CctpService, + private readonly burnFees: BurnFeeService, @InjectQueue(CCTP_OBSERVE_QUEUE) private readonly cctpQueue: Queue, @InjectQueue(SETTLEMENT_HOLD_QUEUE) private readonly holdQueue: Queue, private readonly escrowService: EscrowService, @@ -944,14 +946,34 @@ export class PaymentsService implements OnModuleInit, OnModuleDestroy { amountSubunits, ]); + // Fast Transfer is not free, and asking for it without paying is not an + // error Circle reports back — it accepts the burn, then holds the message + // at `pending_confirmations` with `delayReason: insufficient_fee` and waits + // for hard finality anyway. This asked for `speed: 'fast'` with + // `maxFee: 0n`, so every payment took the slow path while the checkout + // promised 8–20 seconds. + // + // Ask Circle what the tier costs and offer exactly that. If the lookup + // fails, drop to standard finality rather than send a burn we know will be + // demoted: slower, but it is what actually happens, and the logs say so. + const destinationDomain = getDomain('stellar')!.domain; + const feeBps = await this.burnFees.minimumFeeBps( + source.domain, + destinationDomain, + FINALITY_FAST, + ); + const speed = feeBps === null ? 'standard' : 'fast'; + const maxFee = + feeBps === null ? 0n : this.burnFees.maxFeeFor(amountSubunits, feeBps); + const burnPayload = await this.cctpService.prepareBurn({ fromChain: sourceChain, toChain: 'stellar', amount: amountSubunits, recipient, - speed: 'fast', + speed, mintMode: 'forwarder', - maxFee: 0n, + maxFee, }); if ('xdr' in burnPayload) { throw new Error('Unexpected Stellar payload for EVM source');