Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions apps/api/src/modules/cctp/burn-fee.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) {
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);
});
});
});
124 changes: 124 additions & 0 deletions apps/api/src/modules/cctp/burn-fee.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, { bps: number; at: number }>();
private static readonly TTL_MS = 5 * 60 * 1000;

constructor(private readonly config: ConfigService) {
this.env = cctpEnvFromStellarNetwork(
this.config.get<string>('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<number | null> {
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;
}
}
4 changes: 3 additions & 1 deletion apps/api/src/modules/cctp/cctp.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {}
83 changes: 83 additions & 0 deletions apps/api/src/modules/payments/payments.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading