- {#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();
+ });
+});