diff --git a/packages/payments/README.md b/packages/payments/README.md index 192ce1dd7..e7e1c45b6 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -31,15 +31,16 @@ const client = createPaymentsClient({ const session = await client.createSession({ kind: "crypto", - destination: "eip155:747:0xRecipient", // CAIP-10 format - currency: "USDC", // Symbol, EVM address, or Cadence vault identifier + destination: "eip155:747:0xRecipient", // CAIP-10: Flow EVM address + currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // USDC - EVM address or Cadence vault identifier amount: "100.0", // Human-readable decimal format - sourceChain: "eip155:1", // CAIP-2: source chain - sourceCurrency: "USDC", + sourceChain: "eip155:1", // CAIP-2: Ethereum mainnet + sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // USDC on Ethereum }) -// session.instructions contains provider-specific funding instructions -console.log(session.instructions.address) // e.g., deposit address +// session.instructions.address contains a unique deposit address +// User sends USDC on Ethereum to this address, Relay bridges it to Flow EVM +console.log(session.instructions.address) ``` ## Core Concepts @@ -52,10 +53,10 @@ Describes what the user wants to fund: interface CryptoFundingIntent { kind: "crypto" destination: string // CAIP-10 account identifier - currency: string // Token symbol, EVM address, or Cadence vault ID + currency: string // EVM address (0x...) or Cadence vault ID (A.xxx.Token.Vault) amount?: string // Human-readable amount (e.g., "100.50") sourceChain: string // CAIP-2 chain identifier - sourceCurrency: string // Source token identifier + sourceCurrency: string // Source token EVM address } ``` @@ -89,25 +90,22 @@ interface FundingProvider { ## Token Formats -The client accepts multiple token identifier formats: +The client accepts two token identifier formats: -**1. Symbols:** +**1. EVM Addresses (0x + 40 hex):** ```ts -currency: "USDC" +currency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" // USDC on Ethereum ``` -**2. EVM Addresses (0x + 40 hex):** +**2. Cadence Vault Identifiers:** ```ts -currency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" -``` - -**3. Cadence Vault Identifiers:** -```ts -currency: "A.1654653399040a61.FlowToken.Vault" +currency: "A.1654653399040a61.FlowToken.Vault" // FlowToken ``` When you provide a **Cadence vault identifier**, the client queries the **Flow EVM Bridge** to automatically convert the vault ID to the corresponding EVM address before passing to providers. +**Note:** Token symbols (e.g., "USDC", "USDF") are NOT supported. You must provide the full EVM address or Cadence vault identifier. + ## Development ```bash diff --git a/packages/payments/src/bridge-service.ts b/packages/payments/src/bridge-service.ts index 5b24ac7fa..231d984bf 100644 --- a/packages/payments/src/bridge-service.ts +++ b/packages/payments/src/bridge-service.ts @@ -6,13 +6,12 @@ import type {createFlowClientCore} from "@onflow/fcl-core" import {getContracts} from "@onflow/config" import flowJSON from "../flow.json" +import type {FlowNetwork} from "./constants" import GET_EVM_ADDRESS_SCRIPT from "../cadence/scripts/get-evm-address-from-vault.cdc" import GET_VAULT_TYPE_SCRIPT from "../cadence/scripts/get-vault-type-from-evm.cdc" import GET_TOKEN_DECIMALS_SCRIPT from "../cadence/scripts/get-token-decimals.cdc" -type FlowNetwork = "emulator" | "testnet" | "mainnet" - interface BridgeQueryOptions { flowClient: ReturnType } @@ -22,11 +21,10 @@ async function resolveCadence( flowClient: ReturnType, cadence: string ): Promise { - const chainId = await flowClient.getChainId() - const n = chainId.replace(/^flow-/, "").toLowerCase() - const network: FlowNetwork = n === "local" ? "emulator" : (n as FlowNetwork) + const chainId = (await flowClient.getChainId()) as FlowNetwork + const network = chainId === "local" ? "emulator" : chainId - const contracts = getContracts(flowJSON, network) + const contracts = getContracts(flowJSON, network) as Record return cadence.replace(/import\s+"(\w+)"/g, (match, name) => contracts[name] ? `import ${name} from 0x${contracts[name]}` : match ) diff --git a/packages/payments/src/client.test.ts b/packages/payments/src/client.test.ts index c0e5387a4..378dbc119 100644 --- a/packages/payments/src/client.test.ts +++ b/packages/payments/src/client.test.ts @@ -1,5 +1,10 @@ import {createPaymentsClient} from "./client" -import {FundingProvider, FundingIntent, FundingSession} from "./types" +import { + FundingProvider, + FundingProviderFactory, + FundingIntent, + FundingSession, +} from "./types" describe("createPaymentsClient", () => { const mockFlowClient = { @@ -15,7 +20,7 @@ describe("createPaymentsClient", () => { } const client = createPaymentsClient({ - providers: [mockProvider], + providers: [({flowClient}) => mockProvider], flowClient: mockFlowClient, }) expect(client.createSession).toBeInstanceOf(Function) @@ -36,7 +41,7 @@ describe("createPaymentsClient", () => { } const client = createPaymentsClient({ - providers: [mockProvider], + providers: [({flowClient}) => mockProvider], flowClient: mockFlowClient, }) const intent: FundingIntent = { @@ -74,7 +79,10 @@ describe("createPaymentsClient", () => { } const client = createPaymentsClient({ - providers: [failingProvider, workingProvider], + providers: [ + ({flowClient}) => failingProvider, + ({flowClient}) => workingProvider, + ], flowClient: mockFlowClient, }) const intent: FundingIntent = { @@ -106,7 +114,7 @@ describe("createPaymentsClient", () => { } const client = createPaymentsClient({ - providers: [provider1, provider2], + providers: [() => provider1, () => provider2], flowClient: mockFlowClient, }) const intent: FundingIntent = { @@ -169,7 +177,7 @@ describe("createPaymentsClient", () => { } const client = createPaymentsClient({ - providers: [provider1, provider2], + providers: [({flowClient}) => provider1, ({flowClient}) => provider2], flowClient: mockFlowClient, }) const intent: FundingIntent = { @@ -202,7 +210,7 @@ describe("createPaymentsClient", () => { } const client = createPaymentsClient({ - providers: [fiatProvider], + providers: [({flowClient}) => fiatProvider], flowClient: mockFlowClient, }) const intent: FundingIntent = { @@ -246,7 +254,7 @@ describe("createPaymentsClient", () => { } const client = createPaymentsClient({ - providers: [mockProvider], + providers: [() => mockProvider], flowClient: mockFlowClient, }) const intent: FundingIntent = { @@ -280,7 +288,7 @@ describe("createPaymentsClient", () => { } const client = createPaymentsClient({ - providers: [mockProvider], + providers: [() => mockProvider], flowClient: mockFlowClient, }) const intent: FundingIntent = { diff --git a/packages/payments/src/client.ts b/packages/payments/src/client.ts index 59fd6ba18..1a2494b52 100644 --- a/packages/payments/src/client.ts +++ b/packages/payments/src/client.ts @@ -1,4 +1,9 @@ -import type {FundingIntent, FundingSession, FundingProvider} from "./types" +import type { + FundingIntent, + FundingSession, + FundingProvider, + FundingProviderFactory, +} from "./types" import type {createFlowClientCore} from "@onflow/fcl-core" import {ADDRESS_PATTERN} from "./constants" import {getEvmAddressFromVaultType} from "./bridge-service" @@ -19,8 +24,8 @@ export interface PaymentsClient { * Configuration for creating a payments client */ export interface PaymentsClientConfig { - /** Array of funding providers to use (in priority order) */ - providers: FundingProvider[] + /** Array of funding provider factories to use (in priority order) */ + providers: FundingProviderFactory[] /** Flow client (FCL Core or SDK) for Cadence vault ID conversion */ flowClient: ReturnType } @@ -97,6 +102,11 @@ async function convertCadenceCurrencies( export function createPaymentsClient( config: PaymentsClientConfig ): PaymentsClient { + // Initialize providers by passing flowClient to each factory + const providers: FundingProvider[] = config.providers.map(factory => + factory({flowClient: config.flowClient}) + ) + return { async createSession(intent) { // Convert Cadence vault identifiers to EVM addresses @@ -106,7 +116,7 @@ export function createPaymentsClient( ) const providerErrors: {id?: string; error: any}[] = [] - for (const provider of config.providers) { + for (const provider of providers) { try { return await provider.startSession(processedIntent) } catch (err) { diff --git a/packages/payments/src/constants.ts b/packages/payments/src/constants.ts index 7188c1a3e..0c59eb034 100644 --- a/packages/payments/src/constants.ts +++ b/packages/payments/src/constants.ts @@ -57,3 +57,14 @@ export const ADDRESS_PATTERN = { /** Cadence vault identifier format: A.{address}.{contract}.Vault */ CADENCE_VAULT: /^A\.[a-fA-F0-9]+\.[A-Za-z0-9_]+\.Vault$/, } as const + +export type FlowNetwork = "local" | "testnet" | "mainnet" + +/** + * Flow EVM chain IDs mapped from Flow network names + */ +export const FLOW_EVM_CHAIN_IDS: Record = { + mainnet: 747, + testnet: 545, + local: 646, +} diff --git a/packages/payments/src/providers/index.ts b/packages/payments/src/providers/index.ts index b13e6cd6e..aad7ca5d9 100644 --- a/packages/payments/src/providers/index.ts +++ b/packages/payments/src/providers/index.ts @@ -1,5 +1,2 @@ -// Provider implementations are added in separate PRs -// e.g., export {relayProvider} from "./relay" - -// Placeholder export - providers will be added here -export {} +export {relayProvider} from "./relay" +export type {RelayConfig} from "./relay" diff --git a/packages/payments/src/providers/relay.test.ts b/packages/payments/src/providers/relay.test.ts new file mode 100644 index 000000000..2eeaeb0d8 --- /dev/null +++ b/packages/payments/src/providers/relay.test.ts @@ -0,0 +1,424 @@ +import {relayProvider} from "./relay" +import {CryptoFundingIntent} from "../types" + +describe("relayProvider", () => { + let fetchSpy: jest.SpyInstance + + const mockFlowClient = { + getChainId: jest.fn().mockResolvedValue("mainnet"), + } as any + + beforeEach(() => { + fetchSpy = jest.spyOn(global, "fetch") + }) + + afterEach(() => { + fetchSpy.mockRestore() + }) + + it("should create a provider with default config", () => { + const providerFactory = relayProvider() + const provider = providerFactory({flowClient: mockFlowClient}) + expect(provider.id).toBe("relay") + expect(provider.getCapabilities).toBeInstanceOf(Function) + expect(provider.startSession).toBeInstanceOf(Function) + }) + + it("should create a provider with custom apiUrl", () => { + const providerFactory = relayProvider({apiUrl: "https://custom.api"}) + const provider = providerFactory({flowClient: mockFlowClient}) + expect(provider.id).toBe("relay") + }) + + describe("getCapabilities", () => { + it("should fetch capabilities from Relay API", async () => { + const mockChains = { + chains: [ + { + id: 1, + name: "Ethereum", + depositEnabled: true, + disabled: false, + erc20Currencies: [ + {symbol: "USDC", address: "0x...", supportsBridging: true}, + ], + }, + { + id: 8453, + name: "Base", + depositEnabled: true, + disabled: false, + erc20Currencies: [ + {symbol: "USDC", address: "0x...", supportsBridging: true}, + ], + }, + { + id: 747, + name: "Flow EVM", + depositEnabled: true, + disabled: false, + erc20Currencies: [ + {symbol: "USDC", address: "0x...", supportsBridging: true}, + ], + }, + ], + } + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => mockChains, + }) + + const providerFactory = relayProvider() + const provider = providerFactory({flowClient: mockFlowClient}) + const capabilities = await provider.getCapabilities() + + expect(capabilities).toHaveLength(1) + expect(capabilities[0].type).toBe("crypto") + + const cryptoCap = capabilities[0] + if (cryptoCap.type === "crypto") { + expect(cryptoCap.sourceChains).toContain("eip155:1") + expect(cryptoCap.sourceChains).toContain("eip155:8453") + expect(cryptoCap.sourceChains).toContain("eip155:747") + expect(cryptoCap.sourceCurrencies).toContain("USDC") + expect(cryptoCap.currencies).toContain("USDC") + } + }) + + it("should filter out disabled chains", async () => { + const mockChains = { + chains: [ + { + id: 1, + depositEnabled: true, + disabled: false, + erc20Currencies: [{symbol: "USDC", supportsBridging: true}], + }, + { + id: 999, + depositEnabled: false, // Not enabled + disabled: false, + erc20Currencies: [{symbol: "USDC", supportsBridging: true}], + }, + ], + } + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => mockChains, + }) + + const providerFactory = relayProvider() + const provider = providerFactory({flowClient: mockFlowClient}) + const capabilities = await provider.getCapabilities() + + const cryptoCap = capabilities[0] + if (cryptoCap.type === "crypto") { + expect(cryptoCap.sourceChains).toContain("eip155:1") + expect(cryptoCap.sourceChains).not.toContain("eip155:999") + } + }) + + it("should throw if API fails", async () => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 500, + }) + + const providerFactory = relayProvider() + const provider = providerFactory({flowClient: mockFlowClient}) + + await expect(provider.getCapabilities()).rejects.toThrow( + "Failed to fetch Relay chains" + ) + }) + }) + + describe("startSession", () => { + it("should reject fiat intents", async () => { + const providerFactory = relayProvider() + const provider = providerFactory({flowClient: mockFlowClient}) + + await expect( + provider.startSession({ + kind: "fiat", + destination: "eip155:1:0x123", + currency: "USD", + paymentType: "card", + }) + ).rejects.toThrow("Fiat not supported") + }) + + it("should reject Cadence destinations", async () => { + const providerFactory = relayProvider() + const provider = providerFactory({flowClient: mockFlowClient}) + const intent: CryptoFundingIntent = { + kind: "crypto", + destination: "eip155:747:0x8c5303eaa26202d6", // Cadence address (16 hex chars) + currency: "USDC", + sourceChain: "eip155:1", + sourceCurrency: "USDC", + } + + await expect(provider.startSession(intent)).rejects.toThrow( + "Cadence destination detected" + ) + }) + + it("should reject symbol-based currency identifiers", async () => { + // Mock currencies API (required even though we reject symbols) + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + currencies: [ + { + symbol: "USDC", + address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + decimals: 6, + }, + ], + }), + }) + + const providerFactory = relayProvider() + const provider = providerFactory({flowClient: mockFlowClient}) + const intent: CryptoFundingIntent = { + kind: "crypto", + destination: "eip155:8453:0xF0AE622e463fa757Cf72243569E18Be7Df1996cd", + currency: "USDC", // Symbol not supported + amount: "1000.0", + sourceChain: "eip155:1", + sourceCurrency: "USDC", // Symbol not supported + } + + await expect(provider.startSession(intent)).rejects.toThrow( + /Invalid currency format/ + ) + }) + + it("should create session with explicit addresses", async () => { + // Mock currencies API for decimal lookup (even with addresses, we need decimals) + fetchSpy + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + currencies: [ + { + symbol: "USDC", + address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + decimals: 6, + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + currencies: [ + { + symbol: "USDC", + address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + decimals: 6, + }, + ], + }), + }) + // Mock quote API + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + steps: [ + { + id: "deposit", + depositAddress: "0xDEPOSITADDRESS1234567890123456789012", + }, + ], + }), + }) + + const providerFactory = relayProvider() + const provider = providerFactory({flowClient: mockFlowClient}) + const intent: CryptoFundingIntent = { + kind: "crypto", + destination: "eip155:8453:0xF0AE622e463fa757Cf72243569E18Be7Df1996cd", + currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // Direct address + amount: "1000.0", // Human-readable: 1000 USDC + sourceChain: "eip155:1", + sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // Direct address + } + + const session = await provider.startSession(intent) + + expect(session.kind).toBe("crypto") + if (session.kind === "crypto") { + expect(session.instructions.address).toBe( + "0xDEPOSITADDRESS1234567890123456789012" + ) + } + }) + + it("should throw if deposit address not found in response", async () => { + // Mock currencies API + fetchSpy + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + currencies: [ + { + symbol: "USDC", + address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + decimals: 6, + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + currencies: [ + { + symbol: "USDC", + address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + decimals: 6, + }, + ], + }), + }) + // Mock quote API with no deposit address + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + steps: [{id: "something", action: "do stuff"}], + }), + }) + + const providerFactory = relayProvider() + const provider = providerFactory({flowClient: mockFlowClient}) + const intent: CryptoFundingIntent = { + kind: "crypto", + destination: "eip155:8453:0xF0AE622e463fa757Cf72243569E18Be7Df1996cd", + currency: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + sourceChain: "eip155:1", + sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + } + + await expect(provider.startSession(intent)).rejects.toThrow( + "No deposit address found" + ) + }) + }) + + describe("Flow EVM decimals", () => { + it("should fetch decimals from Relay API for all tokens", async () => { + const providerFactory = relayProvider() + const provider = providerFactory({flowClient: mockFlowClient}) + + // Mock Relay API responses + fetchSpy.mockImplementation((url: string | Request | URL) => { + const urlString = url.toString() + + if (urlString.includes("/chains")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + id: "1", + name: "Ethereum", + depositEnabled: true, + erc20Currencies: [ + { + symbol: "USDC", + address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + decimals: 6, + }, + ], + }, + { + id: "747", + name: "Flow EVM", + depositEnabled: true, + erc20Currencies: [ + { + symbol: "FLOW", + address: "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e", + decimals: 18, + }, + ], + }, + ]), + } as Response) + } + + if (urlString.includes("/currencies")) { + const urlObj = new URL(urlString) + const chainId = urlObj.searchParams.get("chainId") + + if (chainId === "1") { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + symbol: "USDC", + address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + decimals: 6, + }, + ]), + } as Response) + } else if (chainId === "747") { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + symbol: "FLOW", + address: "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e", + decimals: 18, + }, + ]), + } as Response) + } + } + + if (urlString.includes("/quote")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + steps: [ + { + id: "deposit", + action: "Deposit", + depositAddress: + "0x1234567890123456789012345678901234567890", + }, + ], + }), + } as Response) + } + + return Promise.reject(new Error("Unexpected fetch")) + }) + + const intent: CryptoFundingIntent = { + kind: "crypto", + destination: "eip155:747:0xF0AE622e463fa757Cf72243569E18Be7Df1996cd", + currency: "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e", // Flow token on Flow EVM + amount: "1.5", // 1.5 FLOW + sourceChain: "eip155:1", + sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // USDC on Ethereum + } + + const session = await provider.startSession(intent) + + expect(session.kind).toBe("crypto") + if (session.kind === "crypto") { + expect(session.instructions.address).toBe( + "0x1234567890123456789012345678901234567890" + ) + } + }) + }) +}) diff --git a/packages/payments/src/providers/relay.ts b/packages/payments/src/providers/relay.ts new file mode 100644 index 000000000..fc0060935 --- /dev/null +++ b/packages/payments/src/providers/relay.ts @@ -0,0 +1,435 @@ +import type { + FundingProvider, + FundingProviderFactory, + FundingIntent, + FundingSession, + CryptoFundingIntent, + CryptoFundingSession, + ProviderCapability, +} from "../types" +import type {createFlowClientCore} from "@onflow/fcl-core" +import {parseCAIP2, parseCAIP10} from "../utils/caip" +import {isEvmAddress, isCadenceAddress} from "../utils/address" +import {getFlowEvmChainId} from "../utils/network" + +/** + * Configuration for the Relay funding provider + */ +export interface RelayConfig { + /** Optional custom Relay API URL (defaults to `https://api.relay.link`) */ + apiUrl?: string +} + +interface RelayQuoteRequest { + user: string + originChainId: string | number + destinationChainId: string | number + originCurrency: string + destinationCurrency: string + recipient: string + amount?: string + tradeType?: "EXACT_INPUT" | "EXACT_OUTPUT" + useDepositAddress?: boolean + refundTo?: string + usePermit?: boolean + useExternalLiquidity?: boolean + referrer?: string +} + +interface RelayQuoteResponse { + steps: Array<{ + id: string + action: string + description: string + kind: string + depositAddress?: string + requestId?: string + items?: Array<{ + status: string + data?: { + to?: string + data?: string + value?: string + } + }> + }> + fees?: { + gas?: { + amount: string + amountFormatted: string + currency: { + symbol: string + } + } + relayer?: { + amount: string + amountFormatted: string + currency: { + symbol: string + } + } + } +} + +interface RelayChain { + id: number + name: string + displayName?: string + depositEnabled?: boolean + disabled?: boolean + erc20Currencies?: Array<{ + symbol: string + address: string + decimals: number + supportsBridging?: boolean + }> + featuredTokens?: Array<{ + symbol: string + address: string + decimals: number + }> +} + +interface RelayCurrency { + address: string + symbol: string + name: string + decimals: number + chainId: number | string + metadata?: { + verified?: boolean + isNative?: boolean + logoURI?: string + } +} + +const DEFAULT_RELAY_API_URL = "https://api.relay.link" +const DEPOSIT_ADDRESS_TRADE_TYPE = "EXACT_INPUT" as const + +/** + * Create a Relay funding provider factory + * + * Relay is a cross-chain bridging protocol that enables crypto funding + * via deposit addresses. Users send funds on one chain, and Relay automatically + * bridges them to the destination chain. + * + * @param config - Optional configuration for the Relay provider + * @returns A funding provider factory that will be initialized by the payments client + * + * @example + * ```typescript + * import {createPaymentsClient} from "@onflow/payments" + * import {relayProvider} from "@onflow/payments/providers" + * import {createFlowClientCore} from "@onflow/fcl-core" + * + * const flowClient = createFlowClientCore({ ... }) + * + * const client = createPaymentsClient({ + * providers: [relayProvider()], // flowClient injected automatically + * flowClient, + * }) + * ``` + */ +export function relayProvider( + config: RelayConfig = {} +): FundingProviderFactory { + const apiUrl = config.apiUrl || DEFAULT_RELAY_API_URL + + return ({ + flowClient, + }: { + flowClient: ReturnType + }): FundingProvider => ({ + id: "relay", + + async getCapabilities(): Promise { + try { + // Derive Flow EVM chain ID from the flow client + const flowEvmChainId = await getFlowEvmChainId(flowClient) + + // Fetch supported chains from Relay + const chains = await getRelayChains(apiUrl) + + // Filter enabled chains with deposit support + const supportedChains = chains + .filter( + chain => + !chain.disabled && + chain.depositEnabled && + chain.erc20Currencies && + chain.erc20Currencies.length > 0 + ) + .map(chain => `eip155:${chain.id}`) + + // Collect currencies from Flow EVM (destination) + // These are the tokens that can be received on Flow + const flowCurrencies = new Set() + + chains.forEach(chain => { + const isFlowEVM = chain.id === flowEvmChainId + + if (isFlowEVM) { + // Add ERC20 currencies from Flow + if (chain.erc20Currencies) { + chain.erc20Currencies.forEach(currency => { + if (currency.supportsBridging) { + flowCurrencies.add(currency.symbol) + } + }) + } + // Also check featured tokens + if (chain.featuredTokens) { + chain.featuredTokens.forEach(token => { + flowCurrencies.add(token.symbol) + }) + } + } + }) + + // sourceCurrencies should match destination currencies + // You can only bridge tokens that exist on Flow + const flowCurrenciesArray = Array.from(flowCurrencies) + + return [ + { + type: "crypto", + sourceChains: supportedChains, + sourceCurrencies: flowCurrenciesArray, + currencies: flowCurrenciesArray, + }, + ] + } catch (error) { + throw new Error( + `Failed to fetch Relay capabilities: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + }, + + async startSession(intent: FundingIntent): Promise { + if (intent.kind === "fiat") { + throw new Error("Fiat not supported by relay provider") + } + + const cryptoIntent = intent as CryptoFundingIntent + + // Parse source chain + const {chainId: originChainId} = parseCAIP2(cryptoIntent.sourceChain) + + // Parse destination + const destination = parseCAIP10(cryptoIntent.destination) + const destinationChainId = toRelayChainId( + destination.namespace, + destination.chainId + ) + + // Detect if destination is Cadence (needs bridging after EVM funding) + const isCadenceDestination = isCadenceAddress(destination.address) + let actualDestination = destination.address + + // TODO: For Cadence destinations, we need to: + // 1. Determine the user's COA (Cadence Owned Account) EVM address + // 2. Fund the COA instead + // 3. Return instructions for the user to bridge COA -> Cadence + // For now, we reject Cadence destinations until this is implemented + if (isCadenceDestination) { + throw new Error( + `Cadence destination detected: ${destination.address}. ` + + `Automatic Cadence routing is not yet implemented. ` + + `Please provide the COA (Cadence Owned Account) EVM address instead. ` + + `Future versions will automatically route funds through the COA and provide bridging instructions.` + ) + } + + if (!isEvmAddress(actualDestination)) { + throw new Error( + `Invalid EVM address format: ${actualDestination}. ` + + `Expected 0x followed by 40 hexadecimal characters.` + ) + } + + // Resolve currency references to addresses and get decimals + // Only supports EVM addresses ("0x...") + // Note: Cadence vault identifiers are already converted by the client layer + const originCurrency = await resolveCurrency( + apiUrl, + originChainId, + cryptoIntent.sourceCurrency + ) + const destinationCurrency = await resolveCurrency( + apiUrl, + destinationChainId, + cryptoIntent.currency + ) + + // Convert human-readable amount to base units if provided + const amountInBaseUnits = cryptoIntent.amount + ? toBaseUnits(cryptoIntent.amount, originCurrency.decimals) + : undefined + + // Call Relay API with deposit address mode + const quote = await callRelayQuote(apiUrl, { + user: destination.address, + originChainId: parseInt(originChainId), + destinationChainId: parseInt(destinationChainId), + originCurrency: originCurrency.address, + destinationCurrency: destinationCurrency.address, + recipient: destination.address, + amount: amountInBaseUnits, + tradeType: DEPOSIT_ADDRESS_TRADE_TYPE, // Deposit addresses only work with EXACT_INPUT + useDepositAddress: true, + usePermit: false, + useExternalLiquidity: false, + }) + + // Extract deposit address and request ID + const {depositAddress, requestId} = extractDepositInfo(quote) + + const session: CryptoFundingSession = { + id: requestId, + providerId: "relay", + kind: "crypto", + instructions: { + address: depositAddress, + }, + } + + return session + }, + }) +} + +function toRelayChainId(namespace: string, chainId: string): string { + // For EVM chains, Relay uses numeric chain IDs + return chainId +} + +async function resolveCurrency( + apiUrl: string, + chainId: string, + currency: string +): Promise<{address: string; decimals: number}> { + // Reject Cadence addresses (not supported by Relay) + if (isCadenceAddress(currency)) { + throw new Error( + `Cadence address format detected for currency "${currency}". ` + + `Relay requires EVM token addresses (0x + 40 hex chars).` + ) + } + + // Fetch currency metadata from Relay API + const currencies = await getRelayCurrencies(apiUrl, chainId) + + // Must be an EVM address (0x + 40 hex chars) + if (!isEvmAddress(currency)) { + throw new Error( + `Invalid currency format: "${currency}". ` + + `Relay requires EVM token addresses (0x + 40 hex chars). ` + + `Token symbols (e.g., "USDC", "USDF") are not supported. ` + + `Please provide the full EVM address or Cadence vault identifier.` + ) + } + + // Find the address in the currency list to get decimals + const match = currencies.find( + c => c.address.toLowerCase() === currency.toLowerCase() + ) + + if (!match) { + throw new Error( + `Token address "${currency}" not found on chain ${chainId}. ` + + `Make sure it's supported by Relay.` + ) + } + + return {address: match.address, decimals: match.decimals} +} + +function toBaseUnits(amount: string, decimals: number): string { + // Remove any whitespace + const trimmed = amount.trim() + + // Split into integer and decimal parts + const parts = trimmed.split(".") + const integerPart = parts[0] || "0" + const decimalPart = parts[1] || "" + + // Pad or truncate decimal part to match token decimals + const paddedDecimal = decimalPart.padEnd(decimals, "0").slice(0, decimals) + + // Combine and remove leading zeros + const combined = integerPart + paddedDecimal + return BigInt(combined).toString() +} + +function extractDepositInfo(quote: RelayQuoteResponse): { + depositAddress: string + requestId: string +} { + // Look for a step with depositAddress field + for (const step of quote.steps) { + if (step.depositAddress) { + return { + depositAddress: step.depositAddress, + requestId: step.requestId || `relay-${Date.now()}`, // Fallback to generated ID + } + } + } + + throw new Error( + `No deposit address found in Relay quote response. Ensure useDepositAddress is set to true.` + ) +} + +async function callRelayQuote( + apiUrl: string, + request: RelayQuoteRequest +): Promise { + const response = await fetch(`${apiUrl}/quote`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Relay API error (${response.status}): ${errorText}`) + } + + return await response.json() +} + +async function getRelayChains(apiUrl: string): Promise { + const response = await fetch(`${apiUrl}/chains`, { + method: "GET", + }) + + if (!response.ok) { + throw new Error(`Failed to fetch Relay chains: ${response.status}`) + } + + const data = await response.json() + return data.chains || [] +} + +async function getRelayCurrencies( + apiUrl: string, + chainId: number | string +): Promise { + const response = await fetch(`${apiUrl}/currencies?chainId=${chainId}`, { + method: "GET", + }) + + if (!response.ok) { + throw new Error( + `Failed to fetch Relay currencies for chain ${chainId}: ${response.status}` + ) + } + + const data = await response.json() + // Response might be array or object with currencies property + return Array.isArray(data) ? data : data.currencies || data.data || [] +} diff --git a/packages/payments/src/types.ts b/packages/payments/src/types.ts index 4573b691c..f4fabb7ad 100644 --- a/packages/payments/src/types.ts +++ b/packages/payments/src/types.ts @@ -1,3 +1,5 @@ +import type {createFlowClientCore} from "@onflow/fcl-core" + /** * Base intent for funding a Flow account */ @@ -6,7 +8,7 @@ export interface BaseFundingIntent { kind: string /** Destination address in CAIP-10 format: `namespace:chainId:address` (e.g., `"eip155:747:0x..."`) */ destination: string - /** Token identifier - EVM address (`"0xa0b8..."`) or Cadence vault identifier (`"A.xxx.Token.Vault"`) */ + /** Token identifier - must be either an EVM address (`"0xa0b8..."`) or Cadence vault identifier (`"A.xxx.Token.Vault"`). Token symbols (e.g., "USDC") are NOT supported. */ currency: string /** Amount in human-readable decimal format (e.g., `"1.5"` for 1.5 tokens). Provider converts to appropriate format (base units for EVM, UFix64 for Cadence). */ amount?: string @@ -19,7 +21,7 @@ export interface CryptoFundingIntent extends BaseFundingIntent { kind: "crypto" /** Source blockchain in CAIP-2 format: `namespace:chainId` (e.g., `"eip155:1"` for Ethereum mainnet) */ sourceChain: string - /** Source token identifier - EVM address on the source chain */ + /** Source token identifier - must be an EVM address on the source chain (e.g., "0xa0b8..."). Token symbols are NOT supported. */ sourceCurrency: string } @@ -28,8 +30,8 @@ export interface CryptoFundingIntent extends BaseFundingIntent { */ export interface FiatFundingIntent extends BaseFundingIntent { kind: "fiat" - /** Payment method type (e.g., `"card"`, `"bank_transfer"`) */ - paymentType: string + /** Payment method type (e.g., `"card"`, `"bank_transfer"`) - Optional, provider may allow user to choose */ + paymentType?: string } /** @@ -88,7 +90,7 @@ import type {VM} from "./constants" * Base capabilities supported by a funding provider */ export interface BaseProviderCapability { - /** List of supported token identifiers (EVM addresses or Cadence vault IDs) */ + /** List of supported token identifiers - must be EVM addresses or Cadence vault IDs (symbols NOT supported) */ currencies?: string[] /** Minimum funding amount in human-readable format */ minAmount?: string @@ -143,3 +145,11 @@ export interface FundingProvider { */ startSession(intent: FundingIntent): Promise } + +/** + * Factory function that creates a FundingProvider + * Used for dependency injection of Flow client from PaymentsClient + */ +export type FundingProviderFactory = (params: { + flowClient: ReturnType +}) => FundingProvider diff --git a/packages/payments/src/utils/address.ts b/packages/payments/src/utils/address.ts new file mode 100644 index 000000000..f1c58de36 --- /dev/null +++ b/packages/payments/src/utils/address.ts @@ -0,0 +1,35 @@ +import {ADDRESS_PATTERN} from "../constants" + +/** + * Check if a string is a valid EVM address + * Format: 0x followed by 40 hexadecimal characters + * + * @param value - String to validate + * @returns True if the value is a valid EVM address + * + * @example + * ```typescript + * isEvmAddress("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913") // true + * isEvmAddress("0x1234") // false + * ``` + */ +export function isEvmAddress(value: string): boolean { + return ADDRESS_PATTERN.EVM.test(value) +} + +/** + * Check if a string is a valid Cadence address + * Format: 0x followed by 16 hexadecimal characters + * + * @param value - String to validate + * @returns True if the value is a valid Cadence address + * + * @example + * ```typescript + * isCadenceAddress("0x1654653399040a61") // true + * isCadenceAddress("0x1234") // false + * ``` + */ +export function isCadenceAddress(value: string): boolean { + return ADDRESS_PATTERN.CADENCE.test(value) +} diff --git a/packages/payments/src/utils/caip.ts b/packages/payments/src/utils/caip.ts new file mode 100644 index 000000000..b6be53885 --- /dev/null +++ b/packages/payments/src/utils/caip.ts @@ -0,0 +1,63 @@ +/** + * CAIP (Chain Agnostic Improvement Proposals) utilities + * https://github.com/ChainAgnostic/CAIPs + */ + +/** + * Parse a CAIP-2 chain identifier + * Format: "namespace:chainId" (e.g., "eip155:1" for Ethereum mainnet) + * + * @param caip2 - CAIP-2 formatted chain identifier + * @returns The chain ID portion + * @throws {Error} If the format is invalid + * + * @example + * ```typescript + * parseCAIP2("eip155:1") // "1" + * parseCAIP2("eip155:8453") // "8453" + * ``` + */ +export function parseCAIP2(caip2: string): { + namespace: string + chainId: string +} { + const parts = caip2.split(":") + if (parts.length !== 2) { + throw new Error(`Invalid CAIP-2 format: ${caip2}`) + } + return { + namespace: parts[0], + chainId: parts[1], + } +} + +/** + * Parse a CAIP-10 account identifier + * Format: "namespace:chainId:address" (e.g., "eip155:747:0x...") + * + * @param caip10 - CAIP-10 formatted account identifier + * @returns Parsed namespace, chainId, and address + * @throws {Error} If the format is invalid + * + * @example + * ```typescript + * parseCAIP10("eip155:747:0xABC123") + * // { namespace: "eip155", chainId: "747", address: "0xABC123" } + * ``` + */ +export function parseCAIP10(caip10: string): { + namespace: string + chainId: string + address: string +} { + const parts = caip10.split(":") + if (parts.length !== 3) { + throw new Error(`Invalid CAIP-10 format: ${caip10}`) + } + + return { + namespace: parts[0], + chainId: parts[1], + address: parts[2], + } +} diff --git a/packages/payments/src/utils/network.ts b/packages/payments/src/utils/network.ts new file mode 100644 index 000000000..8d90f52b1 --- /dev/null +++ b/packages/payments/src/utils/network.ts @@ -0,0 +1,31 @@ +import type {createFlowClientCore} from "@onflow/fcl-core" +import {FLOW_EVM_CHAIN_IDS, type FlowNetwork} from "../constants" + +/** + * Get the Flow EVM chain ID for the given Flow client + * + * @param flowClient - Flow client instance + * @returns Flow EVM chain ID (747 for mainnet, 545 for testnet, 646 for local/emulator) + * @throws {Error} If the network is not supported + * + * @example + * ```typescript + * const chainId = await getFlowEvmChainId(flowClient) + * // Returns 747 for mainnet, 545 for testnet, 646 for local + * ``` + */ +export async function getFlowEvmChainId( + flowClient: ReturnType +): Promise { + const chainId = (await flowClient.getChainId()) as FlowNetwork + const flowEvmChainId = FLOW_EVM_CHAIN_IDS[chainId] + + if (!flowEvmChainId) { + throw new Error( + `Unsupported Flow network: ${chainId}. ` + + `Supported networks: ${Object.keys(FLOW_EVM_CHAIN_IDS).join(", ")}` + ) + } + + return flowEvmChainId +}