diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9e4956f..95a45ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,10 @@ jobs: run: bun install --frozen-lockfile - name: Run checks run: bun run check + env: + PRIVATE_POLYMER_MAINNET_ZONE_API_KEY: ${{ secrets.PRIVATE_POLYMER_MAINNET_ZONE_API_KEY }} + PRIVATE_POLYMER_TESTNET_ZONE_API_KEY: ${{ secrets.PRIVATE_POLYMER_TESTNET_ZONE_API_KEY }} + PUBLIC_WALLET_CONNECT_PROJECT_ID: ${{ secrets.PUBLIC_WALLET_CONNECT_PROJECT_ID }} - name: Run unit tests run: bun run test:unit - name: Upload coverage artifact diff --git a/bun.lock b/bun.lock index 5eec092..886db9d 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "cat-swapper", "dependencies": { "@electric-sql/pglite": "^0.3.15", - "@lifi/intent": "0.0.3-alpha.1", + "@lifi/intent": "0.0.4", "@metamask/sdk": "^0.34.0", "@sveltejs/adapter-cloudflare": "^7.0.3", "@wagmi/connectors": "^7.2.1", @@ -219,7 +219,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], - "@lifi/intent": ["@lifi/intent@0.0.3-alpha.1", "", { "dependencies": { "ky": "^1.12.0", "viem": "~2.45.1" } }, "sha512-dzEcS8U5buW7nLpkMmC0kj5R7EQC7l8l1mBd9IWr6R5/vSnSF3kO9zVaNQOmrbezSOS9TROrOpGDUWc847d/Uw=="], + "@lifi/intent": ["@lifi/intent@0.0.4", "", { "dependencies": { "borsh": "^2.0.0", "ky": "^1.12.0", "viem": "~2.45.1" } }, "sha512-T9wJGAY6sW6JcunEusXIvehxZcg2pRkaK0b+PUpSje2234yfZSmmcUPS1QTRxB6Iq6XROYopl6aUBIqzTHznew=="], "@metamask/json-rpc-engine": ["@metamask/json-rpc-engine@8.0.2", "", { "dependencies": { "@metamask/rpc-errors": "^6.2.1", "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^8.3.0" } }, "sha512-IoQPmql8q7ABLruW7i4EYVHWUbF74yrp63bRuXV5Zf9BQwcn5H9Ww1eLtROYvI1bUXwOiHZ6qT5CWTrDc/t/AA=="], @@ -441,6 +441,8 @@ "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "borsh": ["borsh@2.0.0", "", {}, "sha512-kc9+BgR3zz9+cjbwM8ODoUB4fs3X3I5A/HtX7LZKxCLaMrEeDFoBpnhZY//DTS1VZBSs6S5v46RZRbZjRFspEg=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], diff --git a/package.json b/package.json index dbfc3dc..bcb2b76 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "vite": "^7.1.1" }, "dependencies": { - "@lifi/intent": "0.0.3-alpha.1", "@electric-sql/pglite": "^0.3.15", + "@lifi/intent": "0.0.4", "@metamask/sdk": "^0.34.0", "@sveltejs/adapter-cloudflare": "^7.0.3", "@wagmi/connectors": "^7.2.1", diff --git a/src/lib/components/InputTokenModal.svelte b/src/lib/components/InputTokenModal.svelte index ea7bcf3..b2fbc81 100644 --- a/src/lib/components/InputTokenModal.svelte +++ b/src/lib/components/InputTokenModal.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/screens/IssueIntent.svelte b/src/lib/screens/IssueIntent.svelte index 167d326..b5a09ee 100644 --- a/src/lib/screens/IssueIntent.svelte +++ b/src/lib/screens/IssueIntent.svelte @@ -13,9 +13,10 @@ import { ResetPeriod } from "@lifi/intent"; import type { AppCreateIntentOptions } from "$lib/appTypes"; import { isAddress } from "viem"; + import { isValidSolanaAddress, solanaAddressToBytes32 } from "$lib/utils/solana"; + import { SOLANA_CHAIN_IDS } from "$lib/config"; const bigIntSum = (...nums: bigint[]) => nums.reduce((a, b) => a + b, 0n); - const REQUIRED_INPUT_USDC_RAW = 100n; let { scroll, @@ -34,12 +35,36 @@ const resolveExclusiveFor = (value: string): `0x${string}` | undefined => isAddress(value, { strict: false }) ? value : undefined; + const resolveEvmRecipient = (value: string): `0x${string}` | undefined => + isAddress(value, { strict: false }) ? (value as `0x${string}`) : undefined; + const resolveSolanaRecipient = (value: string): `0x${string}` | undefined => + isValidSolanaAddress(value) ? solanaAddressToBytes32(value) : undefined; + + const hasSolanaOutput = $derived( + store.outputTokens.some((t) => SOLANA_CHAIN_IDS.has(t.token.chainId)) + ); + const hasEvmOutput = $derived( + store.outputTokens.some((t) => !SOLANA_CHAIN_IDS.has(t.token.chainId)) + ); + + // When both Solana and EVM outputs are selected, Solana recipient takes priority since the + // library supports a single outputRecipient for all outputs. EVM recipient is only used when + // there are no Solana outputs. + const outputRecipient = $derived.by((): `0x${string}` | undefined => { + if (hasSolanaOutput) return resolveSolanaRecipient(store.solanaRecipient); + return resolveEvmRecipient(store.recipient); + }); + + // A valid Solana recipient is required to encode a usable cross-chain intent. + const solanaRecipientMissing = $derived(hasSolanaOutput && !outputRecipient); + const intentOptions = $derived.by( (): AppCreateIntentOptions => ({ exclusiveFor: resolveExclusiveFor(store.exclusiveFor), inputTokens: store.inputTokens, outputTokens: store.outputTokens, verifier: store.verifier, + outputRecipient, lock: store.intentType === "compact" ? { @@ -276,6 +301,41 @@
+ {#if hasEvmOutput} +
+ EVM Recipient + 0 && !resolveEvmRecipient(store.recipient) + ? "error" + : "default"} + bind:value={store.recipient} + /> +
+ {/if} + {#if hasSolanaOutput} +
+ Solana Recipient + 0 && + !resolveSolanaRecipient(store.solanaRecipient) + ? "error" + : "default"} + bind:value={store.solanaRecipient} + /> +
+ {/if}
Verifier {#if sameChain} @@ -313,13 +373,13 @@
- {#if !true} + {#if solanaRecipientMissing} {:else if !allowanceCheck} diff --git a/src/lib/screens/ReceiveMessage.svelte b/src/lib/screens/ReceiveMessage.svelte index a721f0f..2fe9bda 100644 --- a/src/lib/screens/ReceiveMessage.svelte +++ b/src/lib/screens/ReceiveMessage.svelte @@ -12,7 +12,7 @@ import ChainActionRow from "$lib/components/ui/ChainActionRow.svelte"; import TokenAmountChip from "$lib/components/ui/TokenAmountChip.svelte"; import store from "$lib/state.svelte"; - import { orderToIntent } from "@lifi/intent"; + import { containerToIntent } from "$lib/utils/intent"; import { compactTypes } from "@lifi/intent"; // This script needs to be updated to be able to fetch the associated events of fills. Currently, this presents an issue since it can only fill single outputs. @@ -110,7 +110,7 @@ $effect(() => { refreshValidation; - const intent = orderToIntent(orderContainer); + const intent = containerToIntent(orderContainer); const orderId = intent.orderId(); if (autoScrolledOrderId === orderId) return; @@ -167,7 +167,7 @@ description="Click on each output and wait until they turn green. Polymer does not support batch validation. Continue to the right." >
- {#each orderToIntent(orderContainer).inputChains() as inputChain} + {#each containerToIntent(orderContainer).inputChains() as inputChain} {#snippet action()} diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index 145bc99..791ccbb 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -2,7 +2,7 @@ import type { OrderContainer } from "@lifi/intent"; import type { AppTokenContext } from "./appTypes"; import { ALWAYS_OK_ALLOCATOR, - clientsById, + evmClientsById, coinList, COMPACT, INPUT_SETTLER_COMPACT_LIFI, @@ -23,7 +23,7 @@ import { transactionReceipts as transactionReceiptsTable } from "./schema"; import { and, eq } from "drizzle-orm"; -import { orderToIntent } from "@lifi/intent"; +import { containerToIntent } from "./utils/intent"; import { getOrFetchRpc, invalidateRpcPrefix } from "./libraries/rpcCache"; import { getCurrentConnection, @@ -49,7 +49,7 @@ class Store { async saveOrderToDb(order: OrderContainer) { if (!browser) return; if (!db) await initDb(); - const orderId = orderToIntent(order).orderId(); + const orderId = containerToIntent(order).orderId(); const now = Math.floor(Date.now() / 1000); const id = (order as any).id ?? (typeof crypto !== "undefined" ? crypto.randomUUID() : String(now)); @@ -89,7 +89,7 @@ class Store { console.warn("saveOrderToDb db write failed", { orderId, error }); } } - const idx = this.orders.findIndex((o) => orderToIntent(o).orderId() === orderId); + const idx = this.orders.findIndex((o) => containerToIntent(o).orderId() === orderId); if (idx >= 0) this.orders[idx] = order; else this.orders.push(order); } @@ -273,6 +273,8 @@ class Store { allocatorId = $state(ALWAYS_OK_ALLOCATOR); verifier = $state("polymer"); exclusiveFor: string = $state(""); + recipient: string = $state(""); + solanaRecipient: string = $state(""); useExclusiveForQuoteRequest = $state(false); invalidateWalletReadCache(scope: "all" | "balance" | "allowance" | "compact" = "all") { @@ -386,17 +388,18 @@ class Store { scopeKey: string; fetcher: ( asset: `0x${string}`, - client: (typeof clientsById)[keyof typeof clientsById] + client: (typeof evmClientsById)[keyof typeof evmClientsById] ) => Promise; }) { const { bucket, ttlMs, isMainnet, scopeKey, fetcher } = opts; const resolved: Record>> = {}; for (const token of coinList(isMainnet)) { + if (!evmClientsById[token.chainId]) continue; // skip non-EVM chains (e.g. Solana) if (!resolved[token.chainId]) resolved[token.chainId] = {}; const key = `${bucket}:${isMainnet ? "mainnet" : "testnet"}:${token.chainId}:${token.address}:${scopeKey}`; resolved[token.chainId][token.address] = getOrFetchRpc( key, - () => fetcher(token.address, clientsById[token.chainId]), + () => fetcher(token.address, evmClientsById[token.chainId]), { ttlMs } ); } diff --git a/src/lib/utils/intent.ts b/src/lib/utils/intent.ts new file mode 100644 index 0000000..b5967ad --- /dev/null +++ b/src/lib/utils/intent.ts @@ -0,0 +1,29 @@ +import { + orderToIntent, + SOLANA_MAINNET_CHAIN_ID, + SOLANA_TESTNET_CHAIN_ID, + SOLANA_DEVNET_CHAIN_ID, + StandardEVMIntent, + StandardSolanaIntent, + MultichainOrderIntent +} from "@lifi/intent"; +import type { OrderContainer } from "@lifi/intent"; + +const SOLANA_CHAIN_IDS = new Set([ + SOLANA_MAINNET_CHAIN_ID, + SOLANA_TESTNET_CHAIN_ID, + SOLANA_DEVNET_CHAIN_ID +]); + +export function containerToIntent( + container: OrderContainer +): StandardEVMIntent | StandardSolanaIntent | MultichainOrderIntent { + const { inputSettler, order } = container; + if (!("originChainId" in order)) { + return orderToIntent({ namespace: "eip155", inputSettler, order }); + } + if (SOLANA_CHAIN_IDS.has(order.originChainId)) { + return orderToIntent({ namespace: "solana", inputSettler, order }); + } + return orderToIntent({ namespace: "eip155", inputSettler, order }); +} diff --git a/src/lib/utils/solana.ts b/src/lib/utils/solana.ts new file mode 100644 index 0000000..2098e65 --- /dev/null +++ b/src/lib/utils/solana.ts @@ -0,0 +1,18 @@ +import { base58 } from "@scure/base"; + +export function isValidSolanaAddress(addr: string): boolean { + try { + const bytes = base58.decode(addr); + return bytes.length === 32; + } catch { + return false; + } +} + +export function solanaAddressToBytes32(addr: string): `0x${string}` { + const bytes = base58.decode(addr); + if (bytes.length !== 32) throw new Error(`Invalid Solana address: ${addr}`); + return `0x${Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("")}`; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2b98745..af83649 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -13,7 +13,7 @@ import ConnectWallet from "$lib/screens/ConnectWallet.svelte"; import FlowStepTracker from "$lib/components/ui/FlowStepTracker.svelte"; import store from "$lib/state.svelte"; - import { orderToIntent } from "@lifi/intent"; + import { containerToIntent } from "$lib/utils/intent"; // Fix bigint so we can json serialize it: (BigInt.prototype as any).toJSON = function () { @@ -67,8 +67,8 @@ const orderContainer = { ...order, allocatorSignature, sponsorSignature }; // Deduplicate: only add if not already present - const orderId = orderToIntent(orderContainer).orderId(); - const alreadyExists = store.orders.some((o) => orderToIntent(o).orderId() === orderId); + const orderId = containerToIntent(orderContainer).orderId(); + const alreadyExists = store.orders.some((o) => containerToIntent(o).orderId() === orderId); if (alreadyExists) return; store.orders.push(orderContainer); @@ -100,18 +100,18 @@ let scrollStepProgress = $state(0); async function importOrderById(orderId: `0x${string}`): Promise<"inserted" | "updated"> { const importedOrder = await intentApi.getOrderByOnChainOrderId(orderId); - const importedOrderId = orderToIntent(importedOrder).orderId(); + const importedOrderId = containerToIntent(importedOrder).orderId(); const existingIndex = store.orders.findIndex( - (o) => orderToIntent(o).orderId() === importedOrderId + (o) => containerToIntent(o).orderId() === importedOrderId ); await store.saveOrderToDb(importedOrder); selectedOrder = - store.orders.find((o) => orderToIntent(o).orderId() === importedOrderId) ?? importedOrder; + store.orders.find((o) => containerToIntent(o).orderId() === importedOrderId) ?? importedOrder; return existingIndex >= 0 ? "updated" : "inserted"; } async function deleteOrderById(orderId: `0x${string}`): Promise { await store.deleteOrderFromDb(orderId); - if (selectedOrder && orderToIntent(selectedOrder).orderId() === orderId) { + if (selectedOrder && containerToIntent(selectedOrder).orderId() === orderId) { selectedOrder = undefined; } } diff --git a/tests/unit/recipientField.test.ts b/tests/unit/recipientField.test.ts new file mode 100644 index 0000000..565405a --- /dev/null +++ b/tests/unit/recipientField.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "bun:test"; +import { isAddress } from "viem"; +import { isValidSolanaAddress, solanaAddressToBytes32 } from "../../src/lib/utils/solana"; + +// Mirrors the resolveRecipient helper in IssueIntent.svelte +const resolveRecipient = (value: string): `0x${string}` | undefined => + isAddress(value, { strict: false }) ? (value as `0x${string}`) : undefined; + +describe("resolveRecipient", () => { + it("returns the address for a valid checksummed EVM address", () => { + const addr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; + expect(resolveRecipient(addr)).toBe(addr); + }); + + it("returns the address for a valid lowercase EVM address (strict: false)", () => { + const addr = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"; + expect(resolveRecipient(addr)).toBe(addr); + }); + + it("returns undefined for an empty string", () => { + expect(resolveRecipient("")).toBeUndefined(); + }); + + it("returns undefined for a partial address", () => { + expect(resolveRecipient("0x1234")).toBeUndefined(); + }); + + it("returns undefined for arbitrary non-address text", () => { + expect(resolveRecipient("alice.eth")).toBeUndefined(); + }); + + it("returns undefined for a hex string that is too long", () => { + expect(resolveRecipient("0x" + "a".repeat(42))).toBeUndefined(); + }); +}); + +describe("outputRecipient in AppCreateIntentOptions", () => { + it("is undefined when recipient field is empty", () => { + const recipient = ""; + const outputRecipient = resolveRecipient(recipient); + expect(outputRecipient).toBeUndefined(); + }); + + it("is set when a valid address is provided", () => { + const recipient = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; + const outputRecipient = resolveRecipient(recipient); + expect(outputRecipient).toBe(recipient); + }); + + it("is undefined for an invalid address, so wallet default is used", () => { + const recipient = "not-an-address"; + const outputRecipient = resolveRecipient(recipient); + expect(outputRecipient).toBeUndefined(); + }); +}); + +// Known Solana devnet USDC mint and its expected bytes32 encoding +const USDC_DEVNET_B58 = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; +const USDC_DEVNET_BYTES32 = "0x3b442cb3912157f13a933d0134282d032b5ffecd01a2dbf1b7790608df002ea7"; + +describe("isValidSolanaAddress", () => { + it("returns true for a valid 32-byte base58 pubkey", () => { + expect(isValidSolanaAddress(USDC_DEVNET_B58)).toBe(true); + }); + + it("returns true for a well-known system program address", () => { + expect(isValidSolanaAddress("11111111111111111111111111111111")).toBe(true); + }); + + it("returns false for an empty string", () => { + expect(isValidSolanaAddress("")).toBe(false); + }); + + it("returns false for an EVM address", () => { + expect(isValidSolanaAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")).toBe(false); + }); + + it("returns false for invalid base58 characters (0, O, I, l)", () => { + expect(isValidSolanaAddress("0OIl" + "1".repeat(28))).toBe(false); + }); + + it("returns false for a base58 string that decodes to fewer than 32 bytes", () => { + expect(isValidSolanaAddress("1111111")).toBe(false); + }); +}); + +describe("solanaAddressToBytes32", () => { + it("encodes a known devnet USDC mint to its expected bytes32", () => { + expect(solanaAddressToBytes32(USDC_DEVNET_B58)).toBe(USDC_DEVNET_BYTES32); + }); + + it("returns a 66-character hex string (0x + 64 hex chars)", () => { + const result = solanaAddressToBytes32(USDC_DEVNET_B58); + expect(result).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it("throws for an invalid Solana address", () => { + expect(() => solanaAddressToBytes32("not-valid")).toThrow(); + }); + + it("throws for a base58 string that decodes to fewer than 32 bytes", () => { + expect(() => solanaAddressToBytes32("1111111")).toThrow(); + }); +});