Skip to content

Commit 62820bb

Browse files
feat: support Solana→EVM intent issuing
- Add solanaEscrowLib to open escrow on Solana chain via Anchor program - Add Solana input chain branch in intentFactory using openSolanaEscrow - Add EVM recipient field in IssueIntent for Solana→EVM intents - Update flowProgress, intentList, Finalise, ReceiveMessage to handle StandardSolanaIntent type
1 parent d6d4f5a commit 62820bb

8 files changed

Lines changed: 459 additions & 21 deletions

File tree

src/lib/libraries/flowProgress.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { hashStruct, keccak256 } from "viem";
1515
import { compactTypes } from "@lifi/intent";
1616
import { getOutputHash, encodeMandateOutput } from "@lifi/intent";
1717
import { addressToBytes32, bytes32ToAddress } from "@lifi/intent";
18-
import { orderToIntent } from "@lifi/intent";
18+
import { orderToIntent, StandardSolanaIntent } from "@lifi/intent";
1919
import { getOrFetchRpc } from "$lib/libraries/rpcCache";
2020
import type { MandateOutput, OrderContainer } from "@lifi/intent";
2121
import store from "$lib/state.svelte";
@@ -187,14 +187,16 @@ export async function getOrderProgressChecks(
187187
try {
188188
const intent = orderToIntent(orderContainer);
189189
const orderId = intent.orderId();
190-
const inputChains = intent.inputChains();
191190
const outputs = orderContainer.order.outputs;
192191

193192
const filledStates = await Promise.all(
194193
outputs.map((output) => isOutputFilled(orderId, output))
195194
);
196195
const allFilled = outputs.length > 0 && filledStates.every(Boolean);
197196

197+
const inputChains =
198+
intent instanceof StandardSolanaIntent ? [intent.inputChain()] : intent.inputChains();
199+
198200
let allValidated = false;
199201
if (allFilled && inputChains.length > 0) {
200202
const validatedPairs = await Promise.all(

src/lib/libraries/intentFactory.ts

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import {
22
getChain,
33
getClient,
4+
getSolanaConnection,
45
INPUT_SETTLER_COMPACT_LIFI,
56
INPUT_SETTLER_ESCROW_LIFI,
67
isSolanaChain,
78
MULTICHAIN_INPUT_SETTLER_ESCROW,
9+
SOLANA_INPUT_SETTLER_ESCROW,
810
type WC
911
} from "$lib/config";
12+
import solanaWallet from "$lib/utils/solana-wallet.svelte";
1013
import { maxUint256 } from "viem";
1114
import type {
1215
CreateIntentOptions,
@@ -15,14 +18,23 @@ import type {
1518
NoSignature,
1619
OrderContainer,
1720
Signature,
18-
StandardOrder
21+
StandardOrder,
22+
StandardSolana
1923
} from "@lifi/intent";
2024
import type { AppCreateIntentOptions, AppTokenContext } from "$lib/appTypes";
2125
import { ERC20_ABI } from "$lib/abi/erc20";
22-
import { Intent, IntentApi, StandardEVMIntent, MultichainOrderIntent } from "@lifi/intent";
26+
import {
27+
Intent,
28+
IntentApi,
29+
StandardSolanaIntent,
30+
StandardEVMIntent,
31+
MultichainOrderIntent
32+
} from "@lifi/intent";
2333
import { store } from "$lib/state.svelte";
2434
import { depositAndRegisterCompact, openEscrowIntent, signIntentCompact } from "./intentExecution";
2535
import { intentDeps } from "./coreDeps";
36+
import { solanaAddressToBytes32 } from "$lib/utils/solana";
37+
import { openSolanaEscrow } from "./solanaEscrowLib";
2638

2739
function toCoreTokenContext(input: AppTokenContext): TokenContext {
2840
return {
@@ -101,7 +113,7 @@ export class IntentFactory {
101113
}
102114

103115
private saveOrder(options: {
104-
order: StandardOrder | MultichainOrder;
116+
order: StandardOrder | StandardSolana | MultichainOrder;
105117
inputSettler: `0x${string}`;
106118
sponsorSignature?: Signature | NoSignature;
107119
allocatorSignature?: Signature | NoSignature;
@@ -206,18 +218,61 @@ export class IntentFactory {
206218

207219
openIntent(opts: AppCreateIntentOptions) {
208220
return async () => {
209-
const { inputTokens, account } = opts;
221+
const { inputTokens, outputTokens, account } = opts;
210222
const inputChain = inputTokens[0].token.chainId;
211223

212-
if (this.preHook) await this.preHook(inputChain);
213-
const intent = new Intent(toCoreCreateIntentOptions(opts), intentDeps).order() as
214-
| StandardEVMIntent
215-
| MultichainOrderIntent;
216-
const transactionHashes = await openEscrowIntent(intent, account(), this.walletClient);
217-
this.saveOrder({
218-
order: intent.asOrder(),
219-
inputSettler: store.inputSettler
220-
});
224+
let transactionHashes: string[];
225+
226+
if (isSolanaChain(inputChain)) {
227+
if (!solanaWallet.adapter || !solanaWallet.publicKey) {
228+
throw new Error("Solana wallet not connected");
229+
}
230+
// outputRecipient: Solana recipient for Solana outputs, EVM wallet for EVM outputs
231+
const outputRecipient = opts.outputRecipient ?? account();
232+
const solanaOrderIntent = new Intent(
233+
{
234+
exclusiveFor: opts.exclusiveFor,
235+
inputTokens: [toCoreTokenContext(inputTokens[0])],
236+
outputTokens: outputTokens.map(toCoreTokenContext),
237+
verifier: opts.verifier,
238+
account: solanaAddressToBytes32(solanaWallet.publicKey),
239+
outputRecipient,
240+
lock: { type: "escrow" }
241+
},
242+
intentDeps
243+
).singlechain() as StandardSolanaIntent;
244+
// fillDeadline must be strictly less than expires (unix seconds).
245+
// Subtract 1 second so the Solana program's strict-less-than check passes.
246+
const baseOrder = solanaOrderIntent.asOrder();
247+
const solanaOrder = { ...baseOrder, fillDeadline: baseOrder.expires - 1 };
248+
try {
249+
transactionHashes = [
250+
await openSolanaEscrow({
251+
order: solanaOrder,
252+
solanaPublicKey: solanaWallet.publicKey,
253+
walletAdapter: solanaWallet.adapter,
254+
connection: getSolanaConnection(inputChain)
255+
})
256+
];
257+
} catch (e) {
258+
const message = e instanceof Error ? e.message : String(e);
259+
throw new Error(`Failed to open Solana escrow: ${message}`);
260+
}
261+
this.saveOrder({
262+
order: solanaOrder,
263+
inputSettler: solanaAddressToBytes32(SOLANA_INPUT_SETTLER_ESCROW)
264+
});
265+
} else {
266+
if (this.preHook) await this.preHook(inputChain);
267+
const intent = new Intent(toCoreCreateIntentOptions(opts), intentDeps).order() as
268+
| StandardEVMIntent
269+
| MultichainOrderIntent;
270+
transactionHashes = await openEscrowIntent(intent, account(), this.walletClient);
271+
this.saveOrder({
272+
order: intent.asOrder(),
273+
inputSettler: store.inputSettler
274+
});
275+
}
221276

222277
if (this.postHook) await this.postHook();
223278

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { keccak256 } from "viem";
2+
import idl from "../abi/input_settler_escrow.json";
3+
import { SOLANA_INPUT_SETTLER_ESCROW, SOLANA_POLYMER_ORACLE } from "../config";
4+
import type { MandateOutput, StandardSolana } from "@lifi/intent";
5+
import type { SignerWalletAdapter } from "@solana/wallet-adapter-base";
6+
import type { Connection } from "@solana/web3.js";
7+
8+
const SOLANA_CONFIRMATION_TIMEOUT_MS = 60_000;
9+
10+
/** Convert a 0x-prefixed hex string (32 bytes) to a number[] */
11+
function hexToBytes32(hex: `0x${string}`): number[] {
12+
return Array.from(Buffer.from(hex.slice(2), "hex"));
13+
}
14+
15+
/** Convert a bigint to a 32-byte big-endian number[] */
16+
function bigintToBeBytes32(n: bigint): number[] {
17+
return Array.from(Buffer.from(n.toString(16).padStart(64, "0"), "hex"));
18+
}
19+
20+
/**
21+
* Open a Solana→EVM intent by calling input_settler_escrow.open() on Solana devnet.
22+
*
23+
* @param order StandardSolana from @lifi/intent
24+
* @param solanaPublicKey Base58-encoded Solana wallet public key (becomes order.user)
25+
* @param walletAdapter Connected Solana wallet adapter (Phantom, Solflare, …)
26+
* @param connection Solana Connection instance
27+
* @returns Solana transaction signature string
28+
*/
29+
export async function openSolanaEscrow(params: {
30+
order: StandardSolana;
31+
solanaPublicKey: string;
32+
walletAdapter: SignerWalletAdapter;
33+
connection: Connection;
34+
}): Promise<string> {
35+
const { order, solanaPublicKey, walletAdapter, connection } = params;
36+
37+
if (!order.inputs.length) throw new Error("StandardSolana order has no inputs");
38+
39+
// Dynamic imports to avoid CJS/ESM bundling issues with Rollup
40+
const { AnchorProvider, BN, Program } = await import("@coral-xyz/anchor");
41+
const { PublicKey, SystemProgram } = await import("@solana/web3.js");
42+
const { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync } =
43+
await import("@solana/spl-token");
44+
45+
const userPubkey = new PublicKey(solanaPublicKey);
46+
const inputSettlerProgramId = new PublicKey(SOLANA_INPUT_SETTLER_ESCROW);
47+
const polymerProgramId = new PublicKey(SOLANA_POLYMER_ORACLE);
48+
49+
// Wrap the wallet adapter as an Anchor-compatible wallet.
50+
// Cast through any so Transaction/VersionedTransaction generics align with Anchor's expectations.
51+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
52+
const anchorWallet = {
53+
publicKey: userPubkey,
54+
signTransaction: (tx: any) => walletAdapter.signTransaction(tx),
55+
signAllTransactions: (txs: any[]) => walletAdapter.signAllTransactions(txs)
56+
};
57+
58+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
59+
const typedIdl = idl as any;
60+
const provider = new AnchorProvider(connection as any, anchorWallet as any, {
61+
commitment: "confirmed"
62+
});
63+
// Program converts the IDL to camelCase internally; its coder uses camelCase field names.
64+
// A standalone BorshCoder(rawIdl) would use snake_case names and fail to encode camelCase objects.
65+
const program = new Program(typedIdl, provider);
66+
67+
// Derive polymer oracle PDA (seed: "polymer", program: SOLANA_POLYMER_ORACLE)
68+
const [polymerOraclePda] = PublicKey.findProgramAddressSync(
69+
[Buffer.from("polymer")],
70+
polymerProgramId
71+
);
72+
73+
// Derive input settler escrow PDA (seed: "input_settler_escrow", program: SOLANA_INPUT_SETTLER_ESCROW)
74+
const [inputSettlerEscrowPda] = PublicKey.findProgramAddressSync(
75+
[Buffer.from("input_settler_escrow")],
76+
inputSettlerProgramId
77+
);
78+
79+
// Extract input token from StandardSolana.
80+
// Solana token IDs are full 32-byte public keys stored as bigint — do NOT use idToToken()
81+
// which strips the first 12 bytes (EVM-only helper that returns 20-byte addresses).
82+
const tokenIdHex = order.inputs[0][0].toString(16).padStart(64, "0");
83+
const inputMint = new PublicKey(Buffer.from(tokenIdHex, "hex"));
84+
const inputAmount = new BN(order.inputs[0][1].toString());
85+
86+
// Build Anchor-format order.
87+
// Field names are camelCase here; Anchor's BorshCoder maps them to the IDL's snake_case names.
88+
const anchorOrder = {
89+
user: userPubkey,
90+
nonce: new BN(order.nonce.toString()),
91+
originChainId: new BN(order.originChainId.toString()),
92+
expires: order.expires,
93+
fillDeadline: order.fillDeadline,
94+
inputOracle: polymerOraclePda,
95+
input: { token: inputMint, amount: inputAmount },
96+
outputs: order.outputs.map((o: MandateOutput) => ({
97+
oracle: hexToBytes32(o.oracle),
98+
settler: hexToBytes32(o.settler),
99+
chainId: bigintToBeBytes32(o.chainId),
100+
token: hexToBytes32(o.token),
101+
amount: bigintToBeBytes32(o.amount),
102+
recipient: hexToBytes32(o.recipient),
103+
callbackData:
104+
o.callbackData === "0x" ? Buffer.alloc(0) : Buffer.from(o.callbackData.slice(2), "hex"),
105+
context: o.context === "0x" ? Buffer.alloc(0) : Buffer.from(o.context.slice(2), "hex")
106+
}))
107+
};
108+
109+
// Compute orderId = keccak256(borsh(anchorOrder)) — mirrors Rust's StandardOrder::derive_id().
110+
// Anchor's BorshCoder normalizes IDL type names to camelCase internally, so even
111+
// though the IDL defines this as "StandardOrder", the registry key is "standardOrder".
112+
let encoded: Uint8Array;
113+
try {
114+
encoded = program.coder.types.encode("standardOrder", anchorOrder);
115+
} catch (e) {
116+
const message = e instanceof Error ? e.message : String(e);
117+
throw new Error(`Borsh encoding failed for standardOrder: ${message}`);
118+
}
119+
120+
const orderIdHex = keccak256(encoded);
121+
const orderId = Buffer.from(orderIdHex.slice(2), "hex");
122+
123+
// Derive orderContext PDA (seeds: ["order_context", orderId], program: SOLANA_INPUT_SETTLER_ESCROW)
124+
const [orderContext] = PublicKey.findProgramAddressSync(
125+
[Buffer.from("order_context"), orderId],
126+
inputSettlerProgramId
127+
);
128+
129+
// ATA for the user (must already exist — user has a balance)
130+
const userTokenAccount = getAssociatedTokenAddressSync(inputMint, userPubkey, false);
131+
// ATA for the order PDA (created by the Anchor instruction)
132+
const orderPdaTokenAccount = getAssociatedTokenAddressSync(inputMint, orderContext, true);
133+
134+
// Call input_settler_escrow.open(order) with a confirmation timeout.
135+
const signature = await Promise.race([
136+
program.methods
137+
.open(anchorOrder)
138+
.accounts({
139+
user: userPubkey,
140+
inputSettlerEscrow: inputSettlerEscrowPda,
141+
userTokenAccount,
142+
orderContext,
143+
orderPdaTokenAccount,
144+
mint: inputMint,
145+
tokenProgram: TOKEN_PROGRAM_ID,
146+
associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
147+
systemProgram: SystemProgram.programId
148+
})
149+
.rpc({ commitment: "confirmed" }),
150+
new Promise<never>((_, reject) =>
151+
setTimeout(
152+
() =>
153+
reject(
154+
new Error(
155+
`Solana transaction timed out after ${SOLANA_CONFIRMATION_TIMEOUT_MS / 1000}s`
156+
)
157+
),
158+
SOLANA_CONFIRMATION_TIMEOUT_MS
159+
)
160+
)
161+
]);
162+
163+
return signature;
164+
}

src/lib/screens/Finalise.svelte

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import { SETTLER_ESCROW_ABI } from "$lib/abi/escrow";
2323
import { idToToken } from "@lifi/intent";
2424
import store from "$lib/state.svelte";
25-
import { orderToIntent } from "@lifi/intent";
25+
import { orderToIntent, StandardSolanaIntent } from "@lifi/intent";
2626
import { hashStruct } from "viem";
2727
import { compactTypes } from "@lifi/intent";
2828
@@ -41,7 +41,11 @@
4141
let refreshClaimed = $state(0);
4242
let claimedByChain = $state<Record<string, boolean>>({});
4343
let claimStatusRun = 0;
44-
const inputChains = $derived(orderToIntent(orderContainer).inputChains());
44+
const inputChains = $derived.by(() => {
45+
const intent = orderToIntent(orderContainer);
46+
if (intent instanceof StandardSolanaIntent) return [intent.inputChain()];
47+
return intent.inputChains();
48+
});
4549
const getInputsForChain = (container: OrderContainer, inputChain: bigint): [bigint, bigint][] => {
4650
const { order } = container;
4751
if ("originChainId" in order) {

0 commit comments

Comments
 (0)