diff --git a/src/lib/libraries/flowProgress.ts b/src/lib/libraries/flowProgress.ts index 510e33f..4a40607 100644 --- a/src/lib/libraries/flowProgress.ts +++ b/src/lib/libraries/flowProgress.ts @@ -15,7 +15,7 @@ import { hashStruct, keccak256 } from "viem"; import { compactTypes } from "@lifi/intent"; import { getOutputHash, encodeMandateOutput } from "@lifi/intent"; import { addressToBytes32, bytes32ToAddress } from "@lifi/intent"; -import { orderToIntent } from "@lifi/intent"; +import { orderToIntent, StandardSolanaIntent } from "@lifi/intent"; import { getOrFetchRpc } from "$lib/libraries/rpcCache"; import type { MandateOutput, OrderContainer } from "@lifi/intent"; import store from "$lib/state.svelte"; @@ -187,7 +187,6 @@ export async function getOrderProgressChecks( try { const intent = orderToIntent(orderContainer); const orderId = intent.orderId(); - const inputChains = intent.inputChains(); const outputs = orderContainer.order.outputs; const filledStates = await Promise.all( @@ -195,6 +194,9 @@ export async function getOrderProgressChecks( ); const allFilled = outputs.length > 0 && filledStates.every(Boolean); + const inputChains = + intent instanceof StandardSolanaIntent ? [intent.inputChain()] : intent.inputChains(); + let allValidated = false; if (allFilled && inputChains.length > 0) { const validatedPairs = await Promise.all( diff --git a/src/lib/libraries/intentFactory.ts b/src/lib/libraries/intentFactory.ts index 3210a37..eb0026b 100644 --- a/src/lib/libraries/intentFactory.ts +++ b/src/lib/libraries/intentFactory.ts @@ -1,12 +1,15 @@ import { getChain, getClient, + getSolanaConnection, INPUT_SETTLER_COMPACT_LIFI, INPUT_SETTLER_ESCROW_LIFI, isSolanaChain, MULTICHAIN_INPUT_SETTLER_ESCROW, + SOLANA_INPUT_SETTLER_ESCROW, type WC } from "$lib/config"; +import solanaWallet from "$lib/utils/solana-wallet.svelte"; import { maxUint256 } from "viem"; import type { CreateIntentOptions, @@ -15,14 +18,23 @@ import type { NoSignature, OrderContainer, Signature, - StandardOrder + StandardOrder, + StandardSolana } from "@lifi/intent"; import type { AppCreateIntentOptions, AppTokenContext } from "$lib/appTypes"; import { ERC20_ABI } from "$lib/abi/erc20"; -import { Intent, IntentApi, StandardEVMIntent, MultichainOrderIntent } from "@lifi/intent"; +import { + Intent, + IntentApi, + StandardSolanaIntent, + StandardEVMIntent, + MultichainOrderIntent +} from "@lifi/intent"; import { store } from "$lib/state.svelte"; import { depositAndRegisterCompact, openEscrowIntent, signIntentCompact } from "./intentExecution"; import { intentDeps } from "./coreDeps"; +import { solanaAddressToBytes32 } from "$lib/utils/solana"; +import { openSolanaEscrow } from "./solanaEscrowLib"; function toCoreTokenContext(input: AppTokenContext): TokenContext { return { @@ -101,7 +113,7 @@ export class IntentFactory { } private saveOrder(options: { - order: StandardOrder | MultichainOrder; + order: StandardOrder | StandardSolana | MultichainOrder; inputSettler: `0x${string}`; sponsorSignature?: Signature | NoSignature; allocatorSignature?: Signature | NoSignature; @@ -206,18 +218,61 @@ export class IntentFactory { openIntent(opts: AppCreateIntentOptions) { return async () => { - const { inputTokens, account } = opts; + const { inputTokens, outputTokens, account } = opts; const inputChain = inputTokens[0].token.chainId; - if (this.preHook) await this.preHook(inputChain); - const intent = new Intent(toCoreCreateIntentOptions(opts), intentDeps).order() as - | StandardEVMIntent - | MultichainOrderIntent; - const transactionHashes = await openEscrowIntent(intent, account(), this.walletClient); - this.saveOrder({ - order: intent.asOrder(), - inputSettler: store.inputSettler - }); + let transactionHashes: string[]; + + if (isSolanaChain(inputChain)) { + if (!solanaWallet.adapter || !solanaWallet.publicKey) { + throw new Error("Solana wallet not connected"); + } + // outputRecipient: Solana recipient for Solana outputs, EVM wallet for EVM outputs + const outputRecipient = opts.outputRecipient ?? account(); + const solanaOrderIntent = new Intent( + { + exclusiveFor: opts.exclusiveFor, + inputTokens: [toCoreTokenContext(inputTokens[0])], + outputTokens: outputTokens.map(toCoreTokenContext), + verifier: opts.verifier, + account: solanaAddressToBytes32(solanaWallet.publicKey), + outputRecipient, + lock: { type: "escrow" } + }, + intentDeps + ).singlechain() as StandardSolanaIntent; + // fillDeadline must be strictly less than expires (unix seconds). + // Subtract 1 second so the Solana program's strict-less-than check passes. + const baseOrder = solanaOrderIntent.asOrder(); + const solanaOrder = { ...baseOrder, fillDeadline: baseOrder.expires - 1 }; + try { + transactionHashes = [ + await openSolanaEscrow({ + order: solanaOrder, + solanaPublicKey: solanaWallet.publicKey, + walletAdapter: solanaWallet.adapter, + connection: getSolanaConnection(inputChain) + }) + ]; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Failed to open Solana escrow: ${message}`); + } + this.saveOrder({ + order: solanaOrder, + inputSettler: solanaAddressToBytes32(SOLANA_INPUT_SETTLER_ESCROW) + }); + } else { + if (this.preHook) await this.preHook(inputChain); + const intent = new Intent(toCoreCreateIntentOptions(opts), intentDeps).order() as + | StandardEVMIntent + | MultichainOrderIntent; + transactionHashes = await openEscrowIntent(intent, account(), this.walletClient); + this.saveOrder({ + order: intent.asOrder(), + inputSettler: store.inputSettler + }); + } if (this.postHook) await this.postHook(); diff --git a/src/lib/libraries/solanaEscrowLib.ts b/src/lib/libraries/solanaEscrowLib.ts new file mode 100644 index 0000000..1706292 --- /dev/null +++ b/src/lib/libraries/solanaEscrowLib.ts @@ -0,0 +1,164 @@ +import { keccak256 } from "viem"; +import idl from "../abi/input_settler_escrow.json"; +import { SOLANA_INPUT_SETTLER_ESCROW, SOLANA_POLYMER_ORACLE } from "../config"; +import type { MandateOutput, StandardSolana } from "@lifi/intent"; +import type { SignerWalletAdapter } from "@solana/wallet-adapter-base"; +import type { Connection } from "@solana/web3.js"; + +const SOLANA_CONFIRMATION_TIMEOUT_MS = 60_000; + +/** Convert a 0x-prefixed hex string (32 bytes) to a number[] */ +function hexToBytes32(hex: `0x${string}`): number[] { + return Array.from(Buffer.from(hex.slice(2), "hex")); +} + +/** Convert a bigint to a 32-byte big-endian number[] */ +function bigintToBeBytes32(n: bigint): number[] { + return Array.from(Buffer.from(n.toString(16).padStart(64, "0"), "hex")); +} + +/** + * Open a Solana→EVM intent by calling input_settler_escrow.open() on Solana devnet. + * + * @param order StandardSolana from @lifi/intent + * @param solanaPublicKey Base58-encoded Solana wallet public key (becomes order.user) + * @param walletAdapter Connected Solana wallet adapter (Phantom, Solflare, …) + * @param connection Solana Connection instance + * @returns Solana transaction signature string + */ +export async function openSolanaEscrow(params: { + order: StandardSolana; + solanaPublicKey: string; + walletAdapter: SignerWalletAdapter; + connection: Connection; +}): Promise { + const { order, solanaPublicKey, walletAdapter, connection } = params; + + if (!order.inputs.length) throw new Error("StandardSolana order has no inputs"); + + // Dynamic imports to avoid CJS/ESM bundling issues with Rollup + const { AnchorProvider, BN, Program } = await import("@coral-xyz/anchor"); + const { PublicKey, SystemProgram } = await import("@solana/web3.js"); + const { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync } = + await import("@solana/spl-token"); + + const userPubkey = new PublicKey(solanaPublicKey); + const inputSettlerProgramId = new PublicKey(SOLANA_INPUT_SETTLER_ESCROW); + const polymerProgramId = new PublicKey(SOLANA_POLYMER_ORACLE); + + // Wrap the wallet adapter as an Anchor-compatible wallet. + // Cast through any so Transaction/VersionedTransaction generics align with Anchor's expectations. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anchorWallet = { + publicKey: userPubkey, + signTransaction: (tx: any) => walletAdapter.signTransaction(tx), + signAllTransactions: (txs: any[]) => walletAdapter.signAllTransactions(txs) + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const typedIdl = idl as any; + const provider = new AnchorProvider(connection as any, anchorWallet as any, { + commitment: "confirmed" + }); + // Program converts the IDL to camelCase internally; its coder uses camelCase field names. + // A standalone BorshCoder(rawIdl) would use snake_case names and fail to encode camelCase objects. + const program = new Program(typedIdl, provider); + + // Derive polymer oracle PDA (seed: "polymer", program: SOLANA_POLYMER_ORACLE) + const [polymerOraclePda] = PublicKey.findProgramAddressSync( + [Buffer.from("polymer")], + polymerProgramId + ); + + // Derive input settler escrow PDA (seed: "input_settler_escrow", program: SOLANA_INPUT_SETTLER_ESCROW) + const [inputSettlerEscrowPda] = PublicKey.findProgramAddressSync( + [Buffer.from("input_settler_escrow")], + inputSettlerProgramId + ); + + // Extract input token from StandardSolana. + // Solana token IDs are full 32-byte public keys stored as bigint — do NOT use idToToken() + // which strips the first 12 bytes (EVM-only helper that returns 20-byte addresses). + const tokenIdHex = order.inputs[0][0].toString(16).padStart(64, "0"); + const inputMint = new PublicKey(Buffer.from(tokenIdHex, "hex")); + const inputAmount = new BN(order.inputs[0][1].toString()); + + // Build Anchor-format order. + // Field names are camelCase here; Anchor's BorshCoder maps them to the IDL's snake_case names. + const anchorOrder = { + user: userPubkey, + nonce: new BN(order.nonce.toString()), + originChainId: new BN(order.originChainId.toString()), + expires: order.expires, + fillDeadline: order.fillDeadline, + inputOracle: polymerOraclePda, + input: { token: inputMint, amount: inputAmount }, + outputs: order.outputs.map((o: MandateOutput) => ({ + oracle: hexToBytes32(o.oracle), + settler: hexToBytes32(o.settler), + chainId: bigintToBeBytes32(o.chainId), + token: hexToBytes32(o.token), + amount: bigintToBeBytes32(o.amount), + recipient: hexToBytes32(o.recipient), + callbackData: + o.callbackData === "0x" ? Buffer.alloc(0) : Buffer.from(o.callbackData.slice(2), "hex"), + context: o.context === "0x" ? Buffer.alloc(0) : Buffer.from(o.context.slice(2), "hex") + })) + }; + + // Compute orderId = keccak256(borsh(anchorOrder)) — mirrors Rust's StandardOrder::derive_id(). + // Anchor's BorshCoder normalizes IDL type names to camelCase internally, so even + // though the IDL defines this as "StandardOrder", the registry key is "standardOrder". + let encoded: Uint8Array; + try { + encoded = program.coder.types.encode("standardOrder", anchorOrder); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Borsh encoding failed for standardOrder: ${message}`); + } + + const orderIdHex = keccak256(encoded); + const orderId = Buffer.from(orderIdHex.slice(2), "hex"); + + // Derive orderContext PDA (seeds: ["order_context", orderId], program: SOLANA_INPUT_SETTLER_ESCROW) + const [orderContext] = PublicKey.findProgramAddressSync( + [Buffer.from("order_context"), orderId], + inputSettlerProgramId + ); + + // ATA for the user (must already exist — user has a balance) + const userTokenAccount = getAssociatedTokenAddressSync(inputMint, userPubkey, false); + // ATA for the order PDA (created by the Anchor instruction) + const orderPdaTokenAccount = getAssociatedTokenAddressSync(inputMint, orderContext, true); + + // Call input_settler_escrow.open(order) with a confirmation timeout. + const signature = await Promise.race([ + program.methods + .open(anchorOrder) + .accounts({ + user: userPubkey, + inputSettlerEscrow: inputSettlerEscrowPda, + userTokenAccount, + orderContext, + orderPdaTokenAccount, + mint: inputMint, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId + }) + .rpc({ commitment: "confirmed" }), + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `Solana transaction timed out after ${SOLANA_CONFIRMATION_TIMEOUT_MS / 1000}s` + ) + ), + SOLANA_CONFIRMATION_TIMEOUT_MS + ) + ) + ]); + + return signature; +} diff --git a/src/lib/screens/Finalise.svelte b/src/lib/screens/Finalise.svelte index 5dcf169..a7aa3af 100644 --- a/src/lib/screens/Finalise.svelte +++ b/src/lib/screens/Finalise.svelte @@ -22,7 +22,7 @@ import { SETTLER_ESCROW_ABI } from "$lib/abi/escrow"; import { idToToken } from "@lifi/intent"; import store from "$lib/state.svelte"; - import { orderToIntent } from "@lifi/intent"; + import { orderToIntent, StandardSolanaIntent } from "@lifi/intent"; import { hashStruct } from "viem"; import { compactTypes } from "@lifi/intent"; @@ -41,7 +41,11 @@ let refreshClaimed = $state(0); let claimedByChain = $state>({}); let claimStatusRun = 0; - const inputChains = $derived(orderToIntent(orderContainer).inputChains()); + const inputChains = $derived.by(() => { + const intent = orderToIntent(orderContainer); + if (intent instanceof StandardSolanaIntent) return [intent.inputChain()]; + return intent.inputChains(); + }); const getInputsForChain = (container: OrderContainer, inputChain: bigint): [bigint, bigint][] => { const { order } = container; if ("originChainId" in order) { diff --git a/src/lib/screens/IssueIntent.svelte b/src/lib/screens/IssueIntent.svelte index ecaa030..4fb2b78 100644 --- a/src/lib/screens/IssueIntent.svelte +++ b/src/lib/screens/IssueIntent.svelte @@ -179,6 +179,9 @@ return uniqueChains.length; }); + const hasEvmOutput = $derived( + store.outputTokens.some(({ token }) => !isSolanaChain(token.chainId)) + ); const hasSolanaOutput = $derived( store.outputTokens.some(({ token }) => isSolanaChain(token.chainId)) ); @@ -186,6 +189,11 @@ store.inputTokens.some(({ token }) => isSolanaChain(token.chainId)) ); + const evmRecipientValid = $derived( + !hasEvmOutput || + store.recipient.trim().length === 0 || + isAddress(store.recipient, { strict: false }) + ); // When there's a Solana output, a non-empty valid Solana recipient is required. // Empty string is NOT accepted: the intent library would throw at submission time // rather than disabling the button, giving the user a poor experience. @@ -193,7 +201,7 @@ !hasSolanaOutput || (store.solanaRecipient.trim().length > 0 && isValidSolanaAddress(store.solanaRecipient)) ); - const recipientValid = $derived(solanaRecipientValid); + const recipientValid = $derived(evmRecipientValid && solanaRecipientValid); const sameChain = $derived.by(() => { if (numInputChains > 1) return false; @@ -244,6 +252,10 @@ inputTokens={store.inputTokens} bind:outputTokens={store.outputTokens} {account} + recipient={() => + evmRecipientValid && store.recipient.length > 0 + ? (store.recipient as `0x${string}`) + : undefined} > {/snippet} @@ -316,6 +328,26 @@ > +
+ EVM Recipient + 0 && !evmRecipientValid + ? "error" + : "default"} + bind:value={store.recipient} + /> +
- Enter Solana Recipient + {#if !evmRecipientValid && !solanaRecipientValid} + Fix Recipients + {:else if !evmRecipientValid} + Fix EVM Recipient + {:else} + Enter Solana Recipient + {/if} {:else if !allowanceCheck && !hasSolanaInput} diff --git a/src/lib/screens/ReceiveMessage.svelte b/src/lib/screens/ReceiveMessage.svelte index a721f0f..49ac1ee 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 { orderToIntent, StandardSolanaIntent } from "@lifi/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. @@ -31,6 +31,12 @@ account: () => `0x${string}`; } = $props(); + const inputChainsDerived = $derived.by(() => { + const intent = orderToIntent(orderContainer); + if (intent instanceof StandardSolanaIntent) return [intent.inputChain()]; + return intent.inputChains(); + }); + let refreshValidation = $state(0); let autoScrolledOrderId = $state<`0x${string}` | null>(null); let validationRun = 0; @@ -114,6 +120,20 @@ const orderId = intent.orderId(); if (autoScrolledOrderId === orderId) return; + if (intent instanceof StandardSolanaIntent) { + // TODO: Proof relay and claim for Solana→EVM intents are not yet implemented. + // Buttons are rendered but disabled. The validate/claim step requires relaying a Solana + // transaction receipt through the Polymer oracle to the EVM input settler escrow. + // Initialize statuses to false so the UI renders the (disabled) buttons. + const inputChain = intent.inputChain(); + const nextStatuses: Record = {}; + for (const output of orderContainer.order.outputs) { + nextStatuses[validationKey(inputChain, output)] = false; + } + validationStatuses = nextStatuses; + return; + } + const inputChains = intent.inputChains(); const outputs = orderContainer.order.outputs; const fillTxHashes = outputs.map((output) => { @@ -167,7 +187,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 inputChainsDerived as inputChain} {#snippet action()} diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index 6e40a35..1125599 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -295,6 +295,7 @@ class Store { intentType = $state<"escrow" | "compact">("escrow"); allocatorId = $state(ALWAYS_OK_ALLOCATOR); verifier = $state("polymer"); + recipient: string = $state(""); solanaRecipient: string = $state(""); exclusiveFor: string = $state(""); diff --git a/tests/unit/solanaEscrow.test.ts b/tests/unit/solanaEscrow.test.ts new file mode 100644 index 0000000..fa0a4fb --- /dev/null +++ b/tests/unit/solanaEscrow.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, mock, beforeEach, afterAll } from "bun:test"; +import type { StandardSolana } from "@lifi/intent"; + +// --------------------------------------------------------------------------- +// Minimal fixture +// --------------------------------------------------------------------------- + +const SOLANA_PUBKEY = "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"; +// SPL mint as 32-byte bigint (USDC devnet: 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU) +const USDC_MINT_BIGINT = 0x3b442cb3912157f13a933d0134282d032b5ffecd01a2dbf1b7790608df002ea7n; + +function makeOrder(overrides: Partial = {}): StandardSolana { + const now = Math.floor(Date.now() / 1000); + return { + user: ("0x" + "11".repeat(32)) as `0x${string}`, + nonce: 1n, + originChainId: 1151111081099712n, // Solana devnet + expires: now + 600, + fillDeadline: now + 599, + inputOracle: ("0x" + "aa".repeat(32)) as `0x${string}`, + inputs: [[USDC_MINT_BIGINT, 1_000_000n]], + outputs: [ + { + oracle: ("0x" + "bb".repeat(32)) as `0x${string}`, + settler: ("0x" + "cc".repeat(32)) as `0x${string}`, + chainId: 11155111n, + token: ("0x" + "dd".repeat(32)) as `0x${string}`, + amount: 990_000n, + recipient: ("0x" + "ee".repeat(32)) as `0x${string}`, + callbackData: "0x", + context: "0x" + } + ], + ...overrides + }; +} + +// --------------------------------------------------------------------------- +// Mock heavy Solana/Anchor dependencies before importing the module under test. +// We mock only @coral-xyz/anchor and @solana/spl-token — NOT @solana/web3.js +// directly, to avoid leaking into solanaUtils.test.ts which uses the real module. +// --------------------------------------------------------------------------- + +const mockRpc = mock(async () => "mock-signature-abc123"); +const mockAccounts = mock(() => ({ rpc: mockRpc })); +const mockOpen = mock(() => ({ accounts: mockAccounts })); +const mockEncode = mock(() => new Uint8Array(32)); + +mock.module("@coral-xyz/anchor", () => ({ + AnchorProvider: class { + constructor() {} + }, + BN: class { + constructor(public v: string) {} + toString() { + return this.v; + } + }, + Program: class { + methods = { open: mockOpen }; + coder = { types: { encode: mockEncode } }; + constructor() {} + } +})); + +mock.module("@solana/spl-token", () => ({ + ASSOCIATED_TOKEN_PROGRAM_ID: "mockAtaProgramId", + TOKEN_PROGRAM_ID: "mockTokenProgramId", + getAssociatedTokenAddressSync: mock(() => "mockAta") +})); + +// Config constants used inside the module +mock.module("$lib/config", () => ({ + SOLANA_INPUT_SETTLER_ESCROW: "5QngyaYhNscSebqV4DwYQhk333p5CMP8A9yyLX3pPyXC", + SOLANA_POLYMER_ORACLE: "C2rAFLS6xQ78t18rK5s9madY9fztbhTaHwShgYtzonk7" +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("openSolanaEscrow", () => { + const walletAdapter = { + signTransaction: mock(async (tx: any) => tx), + signAllTransactions: mock(async (txs: any[]) => txs) + } as any; + const connection = {} as any; + + beforeEach(() => { + mockRpc.mockClear(); + mockEncode.mockClear(); + }); + + afterAll(() => { + mock.restore(); + }); + + it("throws immediately when order.inputs is empty", async () => { + const { openSolanaEscrow } = await import("../../src/lib/libraries/solanaEscrowLib"); + const order = makeOrder({ inputs: [] as any }); + await expect( + openSolanaEscrow({ order, solanaPublicKey: SOLANA_PUBKEY, walletAdapter, connection }) + ).rejects.toThrow("StandardSolana order has no inputs"); + }); + + it("returns the transaction signature on the happy path", async () => { + const { openSolanaEscrow } = await import("../../src/lib/libraries/solanaEscrowLib"); + const result = await openSolanaEscrow({ + order: makeOrder(), + solanaPublicKey: SOLANA_PUBKEY, + walletAdapter, + connection + }); + expect(result).toBe("mock-signature-abc123"); + }); + + it("wraps Borsh encode errors with context", async () => { + mockEncode.mockImplementationOnce(() => { + throw new Error("unknown type key"); + }); + const { openSolanaEscrow } = await import("../../src/lib/libraries/solanaEscrowLib"); + await expect( + openSolanaEscrow({ + order: makeOrder(), + solanaPublicKey: SOLANA_PUBKEY, + walletAdapter, + connection + }) + ).rejects.toThrow("Borsh encoding failed for standardOrder: unknown type key"); + }); + + it("rejects with a timeout error when the RPC call hangs", async () => { + // Make .rpc() hang forever + mockRpc.mockImplementationOnce(() => new Promise(() => {})); + + // Patch the timeout to 50 ms so the test doesn't wait 60 s + const realSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = ((fn: () => void, _ms: number) => { + return realSetTimeout(fn, 50); + }) as any; + + const { openSolanaEscrow } = await import("../../src/lib/libraries/solanaEscrowLib"); + await expect( + openSolanaEscrow({ + order: makeOrder(), + solanaPublicKey: SOLANA_PUBKEY, + walletAdapter, + connection + }) + ).rejects.toThrow("timed out"); + + globalThis.setTimeout = realSetTimeout; + }); +});