From 9322a5717a8d4dfa79561e51c9f35c9a395ba3d3 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sun, 9 Aug 2026 23:59:38 +0100 Subject: [PATCH] fix(cctp): pay Circle's Fast Transfer fee instead of offering zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every EVM payment took the slow path. `selectCrypto` built its burn with `speed: 'fast'` and `maxFee: 0n` hardcoded, and Circle does not reject that combination — it accepts the burn, then holds the message at `pending_confirmations` with `delayReason: insufficient_fee` and waits for hard finality instead. So the checkout promised "8–20 seconds via Circle CCTP V2" and delivered fifteen-plus minutes, with nothing in our logs to say why. The demotion is only visible in Iris's own response. Circle publishes the price per route. For Ethereum → Stellar it is 1 basis point on the fast tier and nothing on standard: GET /v2/burn/USDC/fees/0/27 [{"finalityThreshold":1000,"minimumFee":1}, {"finalityThreshold":2000,"minimumFee":0}] `BurnFeeService` reads that, caches it for five minutes per route, and converts basis points into a `maxFee` for the amount. The conversion rounds up and never returns zero 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 zero is the exact condition being fixed. One extra subunit is cheaper than a quarter-hour settlement. When the lookup fails the burn drops to standard finality rather than asking for fast and being demoted anyway. Slower, but it is what actually happens, and it is logged. Verified live: a 15 USDC burn now carries maxFee 1500 subunits at minFinalityThreshold 1000, where it previously carried 0. Tests: 317 (+14), covering the rounding floor, the fallback, per-route caching and the two selectCrypto paths. Co-Authored-By: Claude Opus 5 --- .../src/modules/cctp/burn-fee.service.spec.ts | 139 ++++++++++++++++++ apps/api/src/modules/cctp/burn-fee.service.ts | 124 ++++++++++++++++ apps/api/src/modules/cctp/cctp.module.ts | 4 +- .../modules/payments/payments.service.spec.ts | 83 +++++++++++ .../src/modules/payments/payments.service.ts | 26 +++- 5 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/modules/cctp/burn-fee.service.spec.ts create mode 100644 apps/api/src/modules/cctp/burn-fee.service.ts 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');