Skip to content
Draft
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
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"buffer": "^6.0.3",
"concurrently": "^8.0.1",
"cross-env": "^7.0.3",
"eslint": "^9.25.0",
Expand Down
4 changes: 4 additions & 0 deletions packages/demo/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import {StrictMode} from "react"
import {createRoot} from "react-dom/client"
import {Buffer} from "buffer"
import {App} from "./app.tsx"

// Polyfill Buffer for FCL in browser
globalThis.Buffer = Buffer

createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
Expand Down
16 changes: 16 additions & 0 deletions packages/demo/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ export default defineConfig({
server: {
allowedHosts: true,
},
define: {
// Polyfill Buffer for browser
global: "globalThis",
},
resolve: {
alias: {
buffer: "buffer",
},
},
optimizeDeps: {
esbuildOptions: {
define: {
global: "globalThis",
},
},
},
build: {
chunkSizeWarningLimit: 1000,
rollupOptions: {
Expand Down
25 changes: 25 additions & 0 deletions packages/payments/cadence/scripts/get-coa-address.cdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import "EVM"

/// Get the EVM address for a Cadence account's COA (Cadence Owned Account)
/// Returns the EVM address as a hex string with 0x prefix, or nil if no COA exists
access(all) fun main(address: Address): String? {
let account = getAccount(address)

// Try to borrow the COA from the account's storage
let coaRef = account.capabilities.borrow<&EVM.CadenceOwnedAccount>(
/public/evm
)

if let coa = coaRef {
// Get the address bytes - it's a fixed-size array [UInt8; 20]
// Convert to variable-size array for String.encodeHex
let addressBytes = coa.address().bytes
let variableBytes: [UInt8] = []
for byte in addressBytes {
variableBytes.append(byte)
}
return "0x".concat(String.encodeHex(variableBytes))
}

return nil
}
15 changes: 15 additions & 0 deletions packages/payments/src/bridge-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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"
import GET_COA_ADDRESS_SCRIPT from "../cadence/scripts/get-coa-address.cdc"

interface BridgeQueryOptions {
flowClient: ReturnType<typeof createFlowClientCore>
Expand Down Expand Up @@ -71,3 +72,17 @@ export async function getTokenDecimals({
})
return Number(result)
}

/**
* Get the COA (Cadence Owned Account) EVM address for a Cadence account
*/
export async function getCoaAddress({
flowClient,
cadenceAddress,
}: BridgeQueryOptions & {cadenceAddress: string}): Promise<string | null> {
const result = await flowClient.query({
cadence: await resolveCadence(flowClient, GET_COA_ADDRESS_SCRIPT),
args: (arg: any, t: any) => [arg(cadenceAddress, t.Address)],
})
return result || null
}
173 changes: 84 additions & 89 deletions packages/payments/src/providers/relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ describe("relayProvider", () => {

const mockFlowClient = {
getChainId: jest.fn().mockResolvedValue("mainnet"),
query: jest.fn(),
config: jest.fn(),
} as any

beforeEach(() => {
Expand Down Expand Up @@ -81,8 +83,7 @@ describe("relayProvider", () => {
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")
expect(cryptoCap.currencies).toContain("0x...")
}
})

Expand All @@ -93,13 +94,17 @@ describe("relayProvider", () => {
id: 1,
depositEnabled: true,
disabled: false,
erc20Currencies: [{symbol: "USDC", supportsBridging: true}],
erc20Currencies: [
{symbol: "USDC", address: "0x...", supportsBridging: true},
],
},
{
id: 999,
depositEnabled: false, // Not enabled
disabled: false,
erc20Currencies: [{symbol: "USDC", supportsBridging: true}],
erc20Currencies: [
{symbol: "USDC", address: "0x...", supportsBridging: true},
],
},
],
}
Expand Down Expand Up @@ -150,37 +155,26 @@ describe("relayProvider", () => {
).rejects.toThrow("Fiat not supported")
})

it("should reject Cadence destinations", async () => {
it("should reject Cadence destinations without COA", async () => {
// Mock flowClient to simulate no COA found
;(mockFlowClient.query as jest.Mock).mockResolvedValueOnce(null)

const providerFactory = relayProvider()
const provider = providerFactory({flowClient: mockFlowClient})
const intent: CryptoFundingIntent = {
kind: "crypto",
destination: "eip155:747:0x8c5303eaa26202d6", // Cadence address (16 hex chars)
currency: "USDC",
currency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // Use EVM address
sourceChain: "eip155:1",
sourceCurrency: "USDC",
sourceCurrency: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
}

await expect(provider.startSession(intent)).rejects.toThrow(
"Cadence destination detected"
/No COA.*found/
)
})

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 = {
Expand All @@ -192,37 +186,36 @@ describe("relayProvider", () => {
sourceCurrency: "USDC", // Symbol not supported
}

// Should reject immediately without any API calls
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)
// First call: for source currency on origin chain (chain 1)
fetchSpy
.mockResolvedValueOnce({
ok: true,
json: async () => ({
currencies: [
{
symbol: "USDC",
address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
decimals: 6,
},
],
}),
json: async () => [
{
symbol: "USDC",
address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
decimals: 6,
},
],
})
// Second call: for destination currency on destination chain (chain 8453)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
currencies: [
{
symbol: "USDC",
address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
decimals: 6,
},
],
}),
json: async () => [
{
symbol: "USDC",
address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
decimals: 6,
},
],
})
// Mock quote API
.mockResolvedValueOnce({
Expand Down Expand Up @@ -259,31 +252,27 @@ describe("relayProvider", () => {
})

it("should throw if deposit address not found in response", async () => {
// Mock currencies API
// Mock currencies API (returns array directly)
fetchSpy
.mockResolvedValueOnce({
ok: true,
json: async () => ({
currencies: [
{
symbol: "USDC",
address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
decimals: 6,
},
],
}),
json: async () => [
{
symbol: "USDC",
address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
decimals: 6,
},
],
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
currencies: [
{
symbol: "USDC",
address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
decimals: 6,
},
],
}),
json: async () => [
{
symbol: "USDC",
address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
decimals: 6,
},
],
})
// Mock quote API with no deposit address
.mockResolvedValueOnce({
Expand Down Expand Up @@ -314,6 +303,8 @@ describe("relayProvider", () => {
const providerFactory = relayProvider()
const provider = providerFactory({flowClient: mockFlowClient})

let currenciesCallCount = 0

// Mock Relay API responses
fetchSpy.mockImplementation((url: string | Request | URL) => {
const urlString = url.toString()
Expand All @@ -322,40 +313,43 @@ describe("relayProvider", () => {
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,
},
],
},
]),
Promise.resolve({
chains: [
{
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")
// currencies/v2 is called twice - once for origin, once for destination
currenciesCallCount++

if (chainId === "1") {
if (currenciesCallCount === 1) {
// First call: origin chain (Ethereum, chain 1)
return Promise.resolve({
ok: true,
json: () =>
Expand All @@ -367,7 +361,8 @@ describe("relayProvider", () => {
},
]),
} as Response)
} else if (chainId === "747") {
} else {
// Second call: destination chain (Flow EVM, chain 747)
return Promise.resolve({
ok: true,
json: () =>
Expand Down
Loading