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
18 changes: 18 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,31 @@ STELLAR_USDC_SAC_MAINNET="C..."
# - Parse customer-signed burn receipts to extract the Iris nonce
# - (Optionally) sign destination mints when self-relay is enabled
# Use a paid provider (Alchemy/Infura/QuickNode) in prod for rate limits.
#
# These are the MAINNET endpoints, used when STELLAR_NETWORK="mainnet".
RPC_ETHEREUM="https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"
RPC_BASE="https://base-mainnet.g.alchemy.com/v2/YOUR_KEY"
RPC_BNB="https://bsc-dataseed.binance.org/"
RPC_POLYGON="https://polygon-rpc.com"
RPC_ARBITRUM="https://arb1.arbitrum.io/rpc"
RPC_AVALANCHE="https://api.avax.network/ext/bc/C/rpc"

# When STELLAR_NETWORK="testnet" these are used instead. A chain id is the same
# string on both networks — `ethereum` means mainnet or Sepolia depending only
# on the network — so the variables above cannot serve both. Without the split,
# a testnet payment read Sepolia's USDC and TokenMessenger addresses off a
# mainnet node, where nothing is deployed at them.
#
# All five have working public defaults built in, so you can leave these unset
# and pay on testnet without signing up to any RPC provider. Set them to use
# your own; public endpoints are rate-limited.
#
# RPC_ETHEREUM_TESTNET default https://ethereum-sepolia-rpc.publicnode.com
# RPC_BASE_TESTNET default https://sepolia.base.org
# RPC_ARBITRUM_TESTNET default https://sepolia-rollup.arbitrum.io/rpc
# RPC_OPTIMISM_TESTNET default https://sepolia.optimism.io
# RPC_AVALANCHE_TESTNET default https://api.avax-test.network/ext/bc/C/rpc

# ── CCTP V2 (Circle Cross-Chain Transfer Protocol) ───────────────────────
# Fast = ~8-20s with a small per-transfer fee. Standard = ~15-19 min on
# EVM L1, effectively free. Configurable per quote at runtime — this is
Expand Down
98 changes: 98 additions & 0 deletions apps/api/src/modules/cctp/evm-cctp.client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { ConfigService } from '@nestjs/config';
import { EvmCctpClient } from './evm-cctp.client';

function makeConfig(overrides: Record<string, string | undefined> = {}) {
return {
get: jest.fn((key: string) => overrides[key]),
} as unknown as ConfigService;
}

/**
* Reaches the private URL resolver directly. The alternative is driving it
* through `parseBurnReceipt`, which would build a real JsonRpcProvider and go
* to the network — these cases are about which URL is chosen, not about
* talking to it.
*/
function rpcUrlFor(client: EvmCctpClient, chainId: string): string {
return (
client as unknown as { rpcUrlFor(chainId: string): string }
).rpcUrlFor(chainId);
}

describe('EvmCctpClient RPC selection', () => {
describe('on testnet', () => {
const testnet = (overrides: Record<string, string | undefined> = {}) =>
new EvmCctpClient(
makeConfig({ STELLAR_NETWORK: 'testnet', ...overrides }),
);

// The regression this guards: a chain id is the same string on both
// networks, so `RPC_ETHEREUM` — a mainnet URL — was being used to read
// Sepolia contract addresses. Nothing is deployed at those addresses on
// mainnet, so every testnet burn lookup failed.
it('ignores the mainnet RPC and uses the public testnet endpoint', () => {
const client = testnet({
RPC_ETHEREUM: 'https://eth-mainnet.example/v2/key',
});

const url = rpcUrlFor(client, 'ethereum');

expect(url).not.toContain('eth-mainnet');
expect(url).toBe('https://ethereum-sepolia-rpc.publicnode.com');
});

it('prefers an explicit RPC_<CHAIN>_TESTNET over the public default', () => {
const client = testnet({
RPC_ETHEREUM_TESTNET: 'https://my-own-sepolia.example',
});

expect(rpcUrlFor(client, 'ethereum')).toBe(
'https://my-own-sepolia.example',
);
});

it.each([
['ethereum', 'https://ethereum-sepolia-rpc.publicnode.com'],
['base', 'https://sepolia.base.org'],
['arbitrum', 'https://sepolia-rollup.arbitrum.io/rpc'],
['optimism', 'https://sepolia.optimism.io'],
['avalanche', 'https://api.avax-test.network/ext/bc/C/rpc'],
])(
'has a working default for %s so no key is needed',
(chain, expected) => {
expect(rpcUrlFor(testnet(), chain)).toBe(expected);
},
);

it('names the variable to set for a chain with no default', () => {
expect(() => rpcUrlFor(testnet(), 'polygon')).toThrow(
/RPC_POLYGON_TESTNET/,
);
});
});

describe('on mainnet', () => {
const mainnet = (overrides: Record<string, string | undefined> = {}) =>
new EvmCctpClient(
makeConfig({ STELLAR_NETWORK: 'mainnet', ...overrides }),
);

it('uses the configured per-chain RPC', () => {
const client = mainnet({
RPC_ETHEREUM: 'https://eth-mainnet.example/v2/key',
});

expect(rpcUrlFor(client, 'ethereum')).toBe(
'https://eth-mainnet.example/v2/key',
);
});

// Falling back to a public endpoint for real money would be a poor favour:
// rate limits and an unaudited third party are not what you want moving
// customer funds, and silence about it is worse.
it('refuses to fall back to a public endpoint', () => {
expect(() => rpcUrlFor(mainnet(), 'ethereum')).toThrow(/RPC_ETHEREUM/);
expect(() => rpcUrlFor(mainnet(), 'ethereum')).not.toThrow(/publicnode/);
});
});
});
78 changes: 67 additions & 11 deletions apps/api/src/modules/cctp/evm-cctp.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,25 @@ const FINALITY_THRESHOLD = {
* burns (customer's wallet does that), and only signs the destination
* mint if explicitly configured to self-relay.
*/
/**
* Public testnet endpoints, used when no `RPC_<CHAIN>_TESTNET` is configured.
*
* These are the chains' own published endpoints, not a third party's. They are
* rate-limited and unsuitable for production — which is fine, because this map
* is only consulted when `env === 'testnet'`. The point is that cloning the
* repo and paying on Sepolia should not first require signing up to Alchemy.
*
* Networks match the testnets CCTP V2 addresses in contracts.ts refer to:
* Sepolia, Base Sepolia, Arbitrum Sepolia, OP Sepolia and Avalanche Fuji.
*/
const PUBLIC_TESTNET_RPC: Record<string, string> = {
ethereum: 'https://ethereum-sepolia-rpc.publicnode.com',
base: 'https://sepolia.base.org',
arbitrum: 'https://sepolia-rollup.arbitrum.io/rpc',
optimism: 'https://sepolia.optimism.io',
avalanche: 'https://api.avax-test.network/ext/bc/C/rpc',
};

@Injectable()
export class EvmCctpClient {
private readonly logger = new Logger(EvmCctpClient.name);
Expand Down Expand Up @@ -246,26 +265,63 @@ export class EvmCctpClient {
/* ─────────────────────────────── internals ────────────────────── */

/**
* Returns a (cached) RPC provider for `chainId`. Reads the per-chain
* RPC URL from `RPC_<UPPERCASE_ID>` env. Throws with a clear error if
* the env var is missing — fast-fail beats a `null is not a function`
* mystery at runtime.
* Returns a (cached) RPC provider for `chainId`, on the network this client
* is configured for.
*
* The chain id does not change between networks — `ethereum` is `ethereum`
* whether we mean mainnet or Sepolia; only the contract addresses switch on
* `env`. So a single `RPC_ETHEREUM` cannot serve both, and when it was the
* only lookup, a testnet payment read Sepolia's USDC and TokenMessenger
* addresses off a *mainnet* node. Nothing is deployed at those addresses
* there, so the calls came back as empty reverts — or, if the mainnet URL
* carried a stale key, as a bare 401 that looked like a credentials problem
* rather than a wrong-network one.
*
* On testnet the per-chain override is `RPC_<CHAIN>_TESTNET`, falling back to
* that chain's public endpoint so a local testnet run needs no third-party
* key at all. Mainnet keeps `RPC_<CHAIN>` and no default: guessing a public
* endpoint for real money is not a favour.
*/
private providerFor(chainId: string): ethers.JsonRpcProvider {
const cached = this.providers.get(chainId);
if (cached) return cached;

const envKey = `RPC_${chainId.toUpperCase()}`;
const rpcUrl = this.config.get<string>(envKey);
if (!rpcUrl) {
const rpcUrl = this.rpcUrlFor(chainId);
const provider = new ethers.JsonRpcProvider(rpcUrl);
this.providers.set(chainId, provider);
return provider;
}

private rpcUrlFor(chainId: string): string {
const upper = chainId.toUpperCase();

if (this.env === 'testnet') {
const envKey = `RPC_${upper}_TESTNET`;
const configured = this.config.get<string>(envKey);
if (configured) return configured;

const fallback = PUBLIC_TESTNET_RPC[chainId];
if (fallback) {
this.logger.debug(
`No ${envKey} set for "${chainId}" — using the public testnet endpoint ${fallback}. ` +
`Set ${envKey} to use your own provider.`,
);
return fallback;
}

throw new Error(
`missing RPC endpoint for chain "${chainId}" (set ${envKey})`,
`missing testnet RPC endpoint for chain "${chainId}" (set ${envKey})`,
);
}

const provider = new ethers.JsonRpcProvider(rpcUrl);
this.providers.set(chainId, provider);
return provider;
const envKey = `RPC_${upper}`;
const configured = this.config.get<string>(envKey);
if (!configured) {
throw new Error(
`missing RPC endpoint for chain "${chainId}" (set ${envKey})`,
);
}
return configured;
}

/**
Expand Down
Loading