From 3118b9d7a89bcc515edf6fdc9850655be4704c35 Mon Sep 17 00:00:00 2001 From: Asem- Abdelhady Date: Wed, 11 Mar 2026 14:13:54 +0100 Subject: [PATCH 1/9] feat: solana to evm flow --- src/lib/libraries/flowProgress.ts | 1 + src/lib/screens/ReceiveMessage.svelte | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/lib/libraries/flowProgress.ts b/src/lib/libraries/flowProgress.ts index f47cf8b..534f2cd 100644 --- a/src/lib/libraries/flowProgress.ts +++ b/src/lib/libraries/flowProgress.ts @@ -26,6 +26,7 @@ import store from "$lib/state.svelte"; const PROGRESS_TTL_MS = 30_000; const OrderStatus_Claimed = 2; const OrderStatus_Refunded = 3; +const SOLANA_DEVNET_CHAIN_ID = 1151111081099712n; export type FlowCheckState = { allFilled: boolean; diff --git a/src/lib/screens/ReceiveMessage.svelte b/src/lib/screens/ReceiveMessage.svelte index 240cde5..3469b47 100644 --- a/src/lib/screens/ReceiveMessage.svelte +++ b/src/lib/screens/ReceiveMessage.svelte @@ -30,6 +30,8 @@ import { orderToIntent, SolanaStandardOrderIntent } from "@lifi/intent"; import { compactTypes } from "@lifi/intent"; + const SOLANA_DEVNET_CHAIN_ID = 1151111081099712n; + // 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. let { From af04862526f23a7a494c4ba1e9e79321b1812c3e Mon Sep 17 00:00:00 2001 From: Asem- Abdelhady Date: Fri, 13 Mar 2026 20:05:37 +0100 Subject: [PATCH 2/9] fix: hardcoded solana id to config --- src/lib/libraries/flowProgress.ts | 3 ++- src/lib/screens/ReceiveMessage.svelte | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/libraries/flowProgress.ts b/src/lib/libraries/flowProgress.ts index 534f2cd..d76b1cc 100644 --- a/src/lib/libraries/flowProgress.ts +++ b/src/lib/libraries/flowProgress.ts @@ -1,5 +1,6 @@ import { BYTES32_ZERO, + chainMap, COMPACT, INPUT_SETTLER_COMPACT_LIFI, INPUT_SETTLER_ESCROW_LIFI, @@ -26,7 +27,7 @@ import store from "$lib/state.svelte"; const PROGRESS_TTL_MS = 30_000; const OrderStatus_Claimed = 2; const OrderStatus_Refunded = 3; -const SOLANA_DEVNET_CHAIN_ID = 1151111081099712n; +const SOLANA_DEVNET_CHAIN_ID = BigInt(chainMap.solanaDevnet.id); export type FlowCheckState = { allFilled: boolean; diff --git a/src/lib/screens/ReceiveMessage.svelte b/src/lib/screens/ReceiveMessage.svelte index 3469b47..e772749 100644 --- a/src/lib/screens/ReceiveMessage.svelte +++ b/src/lib/screens/ReceiveMessage.svelte @@ -30,7 +30,7 @@ import { orderToIntent, SolanaStandardOrderIntent } from "@lifi/intent"; import { compactTypes } from "@lifi/intent"; - const SOLANA_DEVNET_CHAIN_ID = 1151111081099712n; + const SOLANA_DEVNET_CHAIN_ID = BigInt(chainMap.solanaDevnet.id); // 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. From 52bd7fc6ddaa5486cc7d86805c93424a2fcbc3ce Mon Sep 17 00:00:00 2001 From: Asem- Abdelhady Date: Fri, 13 Mar 2026 20:27:16 +0100 Subject: [PATCH 3/9] support: generic solana --- src/lib/libraries/flowProgress.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/libraries/flowProgress.ts b/src/lib/libraries/flowProgress.ts index d76b1cc..f47cf8b 100644 --- a/src/lib/libraries/flowProgress.ts +++ b/src/lib/libraries/flowProgress.ts @@ -1,6 +1,5 @@ import { BYTES32_ZERO, - chainMap, COMPACT, INPUT_SETTLER_COMPACT_LIFI, INPUT_SETTLER_ESCROW_LIFI, @@ -27,7 +26,6 @@ import store from "$lib/state.svelte"; const PROGRESS_TTL_MS = 30_000; const OrderStatus_Claimed = 2; const OrderStatus_Refunded = 3; -const SOLANA_DEVNET_CHAIN_ID = BigInt(chainMap.solanaDevnet.id); export type FlowCheckState = { allFilled: boolean; From b370342f6f5ebe124ebfbf50cdfa55e947356b9f Mon Sep 17 00:00:00 2001 From: Asem- Abdelhady Date: Fri, 13 Mar 2026 20:36:45 +0100 Subject: [PATCH 4/9] refactor: make Solana integration generic on V2-114 (post-rebase) - flowProgress, Finalise, ReceiveMessage: replace solanaDevnetConnection and SOLANA_DEVNET_CHAIN_ID with getSolanaConnection(chainId) / isSolanaChain() - solanaValidateLib: revert borsh BinaryWriter (CJS-only, not ESM-compatible in Vite); use Buffer.writeBigUInt64LE directly instead Co-Authored-By: Claude Sonnet 4.6 --- src/lib/screens/ReceiveMessage.svelte | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/screens/ReceiveMessage.svelte b/src/lib/screens/ReceiveMessage.svelte index e772749..240cde5 100644 --- a/src/lib/screens/ReceiveMessage.svelte +++ b/src/lib/screens/ReceiveMessage.svelte @@ -30,8 +30,6 @@ import { orderToIntent, SolanaStandardOrderIntent } from "@lifi/intent"; import { compactTypes } from "@lifi/intent"; - const SOLANA_DEVNET_CHAIN_ID = BigInt(chainMap.solanaDevnet.id); - // 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. let { From 95109f2e7a779ccbb25b02d1dcb9e691f765fd80 Mon Sep 17 00:00:00 2001 From: Asem- Abdelhady Date: Fri, 13 Mar 2026 11:38:39 +0100 Subject: [PATCH 5/9] feat: evem to solana --- src/lib/abi/polymeroracle.ts | 52 ++++ src/lib/config.ts | 22 +- src/lib/libraries/flowProgress.ts | 52 +++- src/lib/libraries/solanaFillLib.ts | 359 ++++++++++++++++++++++++++ src/lib/libraries/solver.ts | 168 +++++++++--- src/lib/screens/FillIntent.svelte | 119 +++++++-- src/lib/screens/Finalise.svelte | 10 +- src/lib/screens/ReceiveMessage.svelte | 50 ++-- src/lib/state.svelte.ts | 12 +- src/routes/polymer/+server.ts | 41 ++- tests/fixtures/orderFixtures.ts | 2 +- tests/unit/intentList.test.ts | 2 +- 12 files changed, 771 insertions(+), 118 deletions(-) create mode 100644 src/lib/libraries/solanaFillLib.ts diff --git a/src/lib/abi/polymeroracle.ts b/src/lib/abi/polymeroracle.ts index 6f29738..5c5d542 100644 --- a/src/lib/abi/polymeroracle.ts +++ b/src/lib/abi/polymeroracle.ts @@ -83,6 +83,32 @@ export const POLYMER_ORACLE_ABI = [ outputs: [], stateMutability: "nonpayable" }, + { + type: "function", + name: "receiveSolanaMessage", + inputs: [ + { + name: "proofs", + type: "bytes[]", + internalType: "bytes[]" + } + ], + outputs: [], + stateMutability: "nonpayable" + }, + { + type: "function", + name: "receiveSolanaMessage", + inputs: [ + { + name: "proof", + type: "bytes", + internalType: "bytes" + } + ], + outputs: [], + stateMutability: "nonpayable" + }, { type: "event", name: "OutputProven", @@ -124,6 +150,11 @@ export const POLYMER_ORACLE_ABI = [ name: "ContextOutOfRange", inputs: [] }, + { + type: "error", + name: "InvalidSolanaMessage", + inputs: [] + }, { type: "error", name: "NotDivisible", @@ -145,6 +176,27 @@ export const POLYMER_ORACLE_ABI = [ name: "NotProven", inputs: [] }, + { + type: "error", + name: "NotSolanaMessage", + inputs: [] + }, + { + type: "error", + name: "SolanaProgramIdMismatch", + inputs: [ + { + name: "returnedProgramId", + type: "bytes32", + internalType: "bytes32" + }, + { + name: "messageProgramId", + type: "bytes32", + internalType: "bytes32" + } + ] + }, { type: "error", name: "WrongEventSignature", diff --git a/src/lib/config.ts b/src/lib/config.ts index d9fd79c..3692bb1 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -110,18 +110,18 @@ export const WORMHOLE_ORACLE: Partial> = { [base.id]: "0x0000000000000000000000000000000000000000" }; export const POLYMER_ORACLE: Partial> = { - [ethereum.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", - [arbitrum.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", - [base.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", - [megaeth.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", - [katana.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", - [polygon.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", - [bsc.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", + [ethereum.id]: "0xe68b03ff7277fbe1a9c5d1f9c5ec30a220e3cb36", + [arbitrum.id]: "0xe68b03ff7277fbe1a9c5d1f9c5ec30a220e3cb36", + [base.id]: "0xe68b03ff7277fbe1a9c5d1f9c5ec30a220e3cb36", + [megaeth.id]: "0xe68b03ff7277fbe1a9c5d1f9c5ec30a220e3cb36", + [katana.id]: "0xe68b03ff7277fbe1a9c5d1f9c5ec30a220e3cb36", + [polygon.id]: "0xe68b03ff7277fbe1a9c5d1f9c5ec30a220e3cb36", + [bsc.id]: "0xe68b03ff7277fbe1a9c5d1f9c5ec30a220e3cb36", // testnet - [sepolia.id]: "0xa056B481CD36eE61b0C417403A1d48aF481378b3", - [baseSepolia.id]: "0xa056B481CD36eE61b0C417403A1d48aF481378b3", - [arbitrumSepolia.id]: "0xa056B481CD36eE61b0C417403A1d48aF481378b3", - [optimismSepolia.id]: "0xa056B481CD36eE61b0C417403A1d48aF481378b3", + [sepolia.id]: "0xe68b03ff7277fbe1a9c5d1f9c5ec30a220e3cb36", + [baseSepolia.id]: "0xe68b03ff7277fbe1a9c5d1f9c5ec30a220e3cb36", + [arbitrumSepolia.id]: "0xe68b03ff7277fbe1a9c5d1f9c5ec30a220e3cb36", + [optimismSepolia.id]: "0xe68b03ff7277fbe1a9c5d1f9c5ec30a220e3cb36", [solanaDevnet.id]: SOLANA_PDAS.devnet.POLYMER_ORACLE, [solanaMainnet.id]: SOLANA_PDAS.mainnet.POLYMER_ORACLE }; diff --git a/src/lib/libraries/flowProgress.ts b/src/lib/libraries/flowProgress.ts index f47cf8b..e1b2276 100644 --- a/src/lib/libraries/flowProgress.ts +++ b/src/lib/libraries/flowProgress.ts @@ -20,6 +20,8 @@ import { addressToBytes32, bytes32ToAddress } from "@lifi/intent"; import { orderToIntent, SolanaStandardOrderIntent } from "@lifi/intent"; import { getOrFetchRpc } from "$lib/libraries/rpcCache"; import { deriveAttestationPda } from "$lib/libraries/solanaValidateLib"; +import { isSolanaSubmittedFillRecord } from "$lib/libraries/solanaFillLib"; +import { encodeCommonPayload, encodeFillDescription } from "$lib/libraries/solanaValidateLib"; import type { MandateOutput, OrderContainer } from "@lifi/intent"; import store from "$lib/state.svelte"; @@ -41,12 +43,15 @@ export function getOutputStorageKey(output: MandateOutput) { }); } -function isValidHash(hash: string | undefined): hash is `0x${string}` { - return !!hash && hash.startsWith("0x") && hash.length === 66; +function hasFillReference(hash: string | undefined): hash is string { + return typeof hash === "string" && hash.length > 0; } async function isOutputFilled(orderId: `0x${string}`, output: MandateOutput) { const outputKey = getOutputStorageKey(output); + if (isSolanaChain(output.chainId)) { + return Boolean(store.fillTransactions[outputKey]); + } return getOrFetchRpc( `progress:filled:${orderId}:${outputKey}`, async () => { @@ -69,19 +74,46 @@ async function isOutputValidatedOnChain( inputChain: bigint, orderContainer: OrderContainer, output: MandateOutput, - fillTransactionHash: `0x${string}` + fillTransactionHash: string ) { const outputKey = getOutputStorageKey(output); - const cachedReceipt = store.getTransactionReceipt(output.chainId, fillTransactionHash); + if (isSolanaChain(output.chainId)) { + const record = store.getTransactionReceipt(output.chainId, fillTransactionHash); + if (!isSolanaSubmittedFillRecord(record)) return false; + const outputHash = keccak256( + encodeFillDescription( + record.solverBytes32, + orderId, + record.fillTimestamp, + encodeCommonPayload(output) + ) + ); + return getOrFetchRpc( + `progress:solana-proven-evm:${orderId}:${inputChain.toString()}:${outputKey}:${fillTransactionHash}`, + async () => { + const sourceChainClient = getClient(inputChain); + return sourceChainClient.readContract({ + address: orderContainer.order.inputOracle, + abi: POLYMER_ORACLE_ABI, + functionName: "isProven", + args: [output.chainId, output.oracle, output.settler, outputHash] + }); + }, + { ttlMs: PROGRESS_TTL_MS } + ); + } + + const evmFillTransactionHash = fillTransactionHash as `0x${string}`; + const cachedReceipt = store.getTransactionReceipt(output.chainId, evmFillTransactionHash); const receipt = ( cachedReceipt ? cachedReceipt : await getOrFetchRpc( - `progress:receipt:${output.chainId.toString()}:${fillTransactionHash}`, + `progress:receipt:${output.chainId.toString()}:${evmFillTransactionHash}`, async () => { const outputClient = getClient(output.chainId); return outputClient.getTransactionReceipt({ - hash: fillTransactionHash + hash: evmFillTransactionHash }); }, { ttlMs: PROGRESS_TTL_MS } @@ -93,7 +125,7 @@ async function isOutputValidatedOnChain( }; if (!cachedReceipt) { store - .saveTransactionReceipt(output.chainId, fillTransactionHash, receipt) + .saveTransactionReceipt(output.chainId, evmFillTransactionHash, receipt) .catch((error) => console.warn("saveTransactionReceipt error", error)); } @@ -105,7 +137,7 @@ async function isOutputValidatedOnChain( const logs = parseEventLogs({ abi: COIN_FILLER_ABI, eventName: "OutputFilled", - logs: (receipt as unknown as { logs: any[] }).logs + logs: receipt.logs as never }); const expectedHash = hashStruct({ types: compactTypes, @@ -239,7 +271,7 @@ async function isInputChainFinalised(chainId: bigint, container: OrderContainer) export async function getOrderProgressChecks( orderContainer: OrderContainer, - fillTransactions: Record + fillTransactions: Record ): Promise { try { const intent = orderToIntent(orderContainer); @@ -260,7 +292,7 @@ export async function getOrderProgressChecks( inputChains.flatMap((inputChain) => outputs.map(async (output) => { const fillHash = fillTransactions[getOutputStorageKey(output)]; - if (!isValidHash(fillHash)) return false; + if (!hasFillReference(fillHash)) return false; return isOutputValidatedOnChain(orderId, inputChain, orderContainer, output, fillHash); }) ) diff --git a/src/lib/libraries/solanaFillLib.ts b/src/lib/libraries/solanaFillLib.ts new file mode 100644 index 0000000..6ac7e28 --- /dev/null +++ b/src/lib/libraries/solanaFillLib.ts @@ -0,0 +1,359 @@ +import { keccak256 } from "viem"; +import polymerIdl from "../abi/polymer_oracle.json"; +import { + BYTES32_ZERO, + SOLANA_INTENTS_PROTOCOL, + SOLANA_OUTPUT_SETTLER_SIMPLE, + SOLANA_POLYMER_ORACLE +} from "../config"; +import { getOutputHash } from "@lifi/intent"; +import { encodeCommonPayload } from "./solanaValidateLib"; +import type { MandateOutput } from "@lifi/intent"; + +const OUTPUT_SETTLER_SIMPLE_IDL = { + address: SOLANA_OUTPUT_SETTLER_SIMPLE, + metadata: { + name: "outputSettlerSimple", + version: "0.0.0", + spec: "0.1.0" + }, + instructions: [ + { + name: "fill", + discriminator: [168, 96, 183, 163, 92, 10, 40, 160], + accounts: [ + { name: "filler", writable: true, signer: true }, + { name: "recipient", writable: true }, + { name: "outputSettlerSimple" }, + { name: "fillerTokenAccount", writable: true }, + { name: "recipientTokenAccount", writable: true }, + { name: "mint" }, + { name: "fillId", writable: true }, + { name: "localAttestation", writable: true }, + { name: "intentsProtocolProgram" }, + { name: "tokenProgram" }, + { name: "associatedTokenProgram" }, + { name: "systemProgram" } + ], + args: [ + { name: "orderId", type: { array: ["u8", 32] } }, + { name: "mandateOutput", type: { defined: { name: "mandateOutput" } } }, + { name: "fillDeadline", type: "u64" }, + { name: "fillerData", type: "bytes" } + ] + }, + { + name: "nativeFill", + discriminator: [49, 10, 255, 151, 120, 148, 73, 30], + accounts: [ + { name: "filler", writable: true, signer: true }, + { name: "recipient", writable: true }, + { name: "outputSettlerSimple" }, + { name: "fillId", writable: true }, + { name: "localAttestation", writable: true }, + { name: "intentsProtocolProgram" }, + { name: "systemProgram" } + ], + args: [ + { name: "orderId", type: { array: ["u8", 32] } }, + { name: "mandateOutput", type: { defined: { name: "mandateOutput" } } }, + { name: "fillDeadline", type: "u64" }, + { name: "fillerData", type: "bytes" } + ] + } + ], + types: [ + { + name: "mandateOutput", + type: { + kind: "struct", + fields: [ + { name: "oracle", type: { array: ["u8", 32] } }, + { name: "settler", type: { array: ["u8", 32] } }, + { name: "chainId", type: { array: ["u8", 32] } }, + { name: "token", type: { array: ["u8", 32] } }, + { name: "amount", type: { array: ["u8", 32] } }, + { name: "recipient", type: { array: ["u8", 32] } }, + { name: "callbackData", type: "bytes" }, + { name: "context", type: "bytes" } + ] + } + } + ] +} as const; + +export const SOLANA_POLYMER_SOURCE_CHAIN_ID = 2; + +export type SolanaSubmittedFillRecord = { + kind: "solanaOutputSubmittedFill"; + fillSignature: string; + submitSignature: string; + fillTimestamp: number; + solverBytes32: `0x${string}`; + localAttestation: string; + submitSlot: number; + submitLogIndex: number; + orderId: `0x${string}`; +}; + +export function isSolanaSubmittedFillRecord(value: unknown): value is SolanaSubmittedFillRecord { + if (!value || typeof value !== "object") return false; + return (value as { kind?: string }).kind === "solanaOutputSubmittedFill"; +} + +function hexToBytes32(hex: `0x${string}`): number[] { + return Array.from(Buffer.from(hex.slice(2), "hex")); +} + +function bigintToBeBytes32(n: bigint): number[] { + return Array.from(Buffer.from(n.toString(16).padStart(64, "0"), "hex")); +} + +function encodeFillDescriptionWithoutTimestamp( + solverBytes32: `0x${string}`, + orderId: `0x${string}`, + commonPayload: Buffer +): Buffer { + return Buffer.concat([ + Buffer.from(solverBytes32.slice(2), "hex"), + Buffer.from(orderId.slice(2), "hex"), + commonPayload + ]); +} + +function encodeFillDescription( + solverBytes32: `0x${string}`, + orderId: `0x${string}`, + timestamp: number, + commonPayload: Buffer +): Buffer { + const ts = Buffer.alloc(4); + ts.writeUInt32BE(timestamp >>> 0, 0); + return Buffer.concat([ + Buffer.from(solverBytes32.slice(2), "hex"), + Buffer.from(orderId.slice(2), "hex"), + ts, + commonPayload + ]); +} + +function findProveLogIndex(logMessages: string[]): number { + return logMessages.findIndex((entry) => entry.includes("Program log: Prove: program:")); +} + +async function computeGlobalLogIndex( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + connection: any, + slot: number, + signature: string, + transactionLogIndex: number +): Promise { + const block = await connection.getBlock(slot, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + transactionDetails: "full", + rewards: false + }); + if (!block?.transactions) return transactionLogIndex; + + let logOffset = 0; + for (const tx of block.transactions) { + const signatures = tx.transaction.signatures as string[]; + const logMessages = tx.meta?.logMessages ?? []; + if (signatures.includes(signature)) { + return logOffset + transactionLogIndex; + } + logOffset += logMessages.length; + } + + return transactionLogIndex; +} + +export async function fillAndSubmitSolanaOutput(params: { + orderId: `0x${string}`; + output: MandateOutput; + fillDeadline: number; + solverBytes32: `0x${string}`; + solanaPublicKey: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + walletAdapter: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + connection: any; +}): Promise { + const { BN, AnchorProvider, 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 { + orderId, + output, + fillDeadline, + solverBytes32, + solanaPublicKey, + walletAdapter, + connection + } = params; + const filler = new PublicKey(solanaPublicKey); + const outputSettlerProgramId = new PublicKey(SOLANA_OUTPUT_SETTLER_SIMPLE); + const intentsProtocolProgramId = new PublicKey(SOLANA_INTENTS_PROTOCOL); + const polymerProgramId = new PublicKey(SOLANA_POLYMER_ORACLE); + + const anchorWallet = { + publicKey: filler, + signTransaction: (tx: unknown) => walletAdapter.signTransaction(tx), + signAllTransactions: (txs: unknown[]) => walletAdapter.signAllTransactions(txs) + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const provider = new AnchorProvider(connection, anchorWallet as any, { commitment: "confirmed" }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const outputSettlerProgram = new Program(OUTPUT_SETTLER_SIMPLE_IDL as any, provider); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const polymerProgram = new Program(polymerIdl as any, provider); + + const [outputSettlerPda] = PublicKey.findProgramAddressSync( + [Buffer.from("output_settler_simple")], + outputSettlerProgramId + ); + const [polymerOraclePda] = PublicKey.findProgramAddressSync( + [Buffer.from("polymer")], + polymerProgramId + ); + const outputHash = Buffer.from(getOutputHash(output).slice(2), "hex"); + const [fillId] = PublicKey.findProgramAddressSync( + [Buffer.from(orderId.slice(2), "hex"), outputHash], + outputSettlerProgramId + ); + + const commonPayload = encodeCommonPayload(output); + const dataHash = Buffer.from( + keccak256(encodeFillDescriptionWithoutTimestamp(solverBytes32, orderId, commonPayload)).slice( + 2 + ), + "hex" + ); + const [localAttestation] = PublicKey.findProgramAddressSync( + [ + Buffer.from("local_attestation"), + outputSettlerPda.toBuffer(), + Buffer.from(output.oracle.slice(2), "hex"), + dataHash + ], + intentsProtocolProgramId + ); + + const outputArg = { + oracle: hexToBytes32(output.oracle), + settler: hexToBytes32(output.settler), + chainId: bigintToBeBytes32(output.chainId), + token: hexToBytes32(output.token), + amount: bigintToBeBytes32(output.amount), + recipient: hexToBytes32(output.recipient), + callbackData: + output.callbackData === "0x" + ? Buffer.alloc(0) + : Buffer.from(output.callbackData.slice(2), "hex"), + context: output.context === "0x" ? Buffer.alloc(0) : Buffer.from(output.context.slice(2), "hex") + }; + + let fillSignature: string; + if (output.token === BYTES32_ZERO) { + const recipient = new PublicKey(Buffer.from(output.recipient.slice(2), "hex")); + fillSignature = await outputSettlerProgram.methods + .nativeFill( + Array.from(Buffer.from(orderId.slice(2), "hex")), + outputArg as never, + new BN(fillDeadline), + Buffer.from(solverBytes32.slice(2), "hex") + ) + .accounts({ + filler, + recipient, + outputSettlerSimple: outputSettlerPda, + fillId, + localAttestation, + intentsProtocolProgram: intentsProtocolProgramId, + systemProgram: SystemProgram.programId + }) + .rpc({ commitment: "confirmed" }); + } else { + const recipient = new PublicKey(Buffer.from(output.recipient.slice(2), "hex")); + const mint = new PublicKey(Buffer.from(output.token.slice(2), "hex")); + const fillerTokenAccount = getAssociatedTokenAddressSync(mint, filler, false); + const recipientTokenAccount = getAssociatedTokenAddressSync(mint, recipient, false); + fillSignature = await outputSettlerProgram.methods + .fill( + Array.from(Buffer.from(orderId.slice(2), "hex")), + outputArg as never, + new BN(fillDeadline), + Buffer.from(solverBytes32.slice(2), "hex") + ) + .accounts({ + filler, + recipient, + outputSettlerSimple: outputSettlerPda, + fillerTokenAccount, + recipientTokenAccount, + mint, + fillId, + localAttestation, + intentsProtocolProgram: intentsProtocolProgramId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId + }) + .rpc({ commitment: "confirmed" }); + } + + const localAttestationInfo = await connection.getAccountInfo(localAttestation, "confirmed"); + if (!localAttestationInfo || localAttestationInfo.data.length < 13) { + throw new Error("Could not read Solana local attestation after fill"); + } + const fillTimestamp = localAttestationInfo.data.readUInt32LE(8); + const fillDescription = encodeFillDescription( + solverBytes32, + orderId, + fillTimestamp, + commonPayload + ); + + const submitSignature = await polymerProgram.methods + .submit(outputSettlerPda, [fillDescription] as never) + .accounts({ + submitter: filler, + oraclePolymer: polymerOraclePda, + intentsProtocolProgram: intentsProtocolProgramId + }) + .remainingAccounts([{ pubkey: localAttestation, isWritable: false, isSigner: false }]) + .rpc({ commitment: "confirmed" }); + + const submitTx = await connection.getTransaction(submitSignature, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0 + }); + if (!submitTx?.meta?.logMessages || submitTx.slot === undefined) { + throw new Error("Could not load Solana polymer submit transaction logs"); + } + const submitLogIndex = findProveLogIndex(submitTx.meta.logMessages); + if (submitLogIndex === -1) { + throw new Error("Could not find Polymer prove log in Solana submit transaction"); + } + const submitGlobalLogIndex = await computeGlobalLogIndex( + connection, + submitTx.slot, + submitSignature, + submitLogIndex + ); + + return { + kind: "solanaOutputSubmittedFill", + fillSignature, + submitSignature, + fillTimestamp, + solverBytes32, + localAttestation: localAttestation.toBase58(), + submitSlot: submitTx.slot, + submitLogIndex: submitGlobalLogIndex, + orderId + }; +} diff --git a/src/lib/libraries/solver.ts b/src/lib/libraries/solver.ts index 35e8300..a18e4c6 100644 --- a/src/lib/libraries/solver.ts +++ b/src/lib/libraries/solver.ts @@ -1,4 +1,13 @@ -import { BYTES32_ZERO, COIN_FILLER, getChain, getClient, getOracle, type WC } from "$lib/config"; +import { + BYTES32_ZERO, + COIN_FILLER, + getChain, + getClient, + getOracle, + chainMap, + SOLANA_POLYMER_ORACLE, + type WC +} from "$lib/config"; import { hashStruct, maxUint256, parseEventLogs } from "viem"; import type { MandateOutput, OrderContainer } from "@lifi/intent"; import { addressToBytes32, bytes32ToAddress } from "@lifi/intent"; @@ -10,6 +19,7 @@ import { orderToIntent, StandardOrderIntent, MultichainOrderIntent } from "@lifi import { compactTypes } from "@lifi/intent"; import store from "$lib/state.svelte"; import { finaliseIntent } from "./intentExecution"; +import { SOLANA_POLYMER_SOURCE_CHAIN_ID, isSolanaSubmittedFillRecord } from "./solanaFillLib"; /** * @notice Class for solving intents. Functions called by solvers. @@ -22,11 +32,7 @@ export class Solver { return new Promise((resolve) => setTimeout(resolve, ms)); } - private static async persistReceipt( - chainId: number | bigint, - txHash: `0x${string}`, - receipt: unknown - ) { + private static async persistReceipt(chainId: number | bigint, txHash: string, receipt: unknown) { try { await store.saveTransactionReceipt(chainId, txHash, receipt); } catch (error) { @@ -48,6 +54,14 @@ export class Solver { return receipt; } + private static getSolanaFillRecord(output: MandateOutput, txRef: string) { + const record = store.getTransactionReceipt(output.chainId, txRef); + if (!isSolanaSubmittedFillRecord(record)) { + throw new Error(`Missing Solana fill metadata for output ${txRef}`); + } + return record; + } + static fill( walletClient: WC, args: { @@ -170,11 +184,66 @@ export class Solver { if (existingValidation) return existingValidation; const validationPromise = (async () => { - if ( - !fillTransactionHash || - !fillTransactionHash.startsWith("0x") || - fillTransactionHash.length !== 66 - ) { + if (!fillTransactionHash) { + throw new Error(`Invalid fill transaction reference: ${fillTransactionHash}`); + } + + if (output.chainId === BigInt(chainMap.solanaDevnet.id)) { + const record = Solver.getSolanaFillRecord(output, fillTransactionHash); + let proof: string | undefined; + const polymerKey = `${SOLANA_POLYMER_SOURCE_CHAIN_ID}:${record.submitSignature}:${SOLANA_POLYMER_ORACLE}`; + let polymerIndex: number | undefined = Solver.polymerRequestIndexByLog.get(polymerKey); + for (const waitMs of [1000, 2000, 4000, 8000, 16000, 32000]) { + const response = await axios.post( + `/polymer`, + { + srcChainId: SOLANA_POLYMER_SOURCE_CHAIN_ID, + txSignature: record.submitSignature, + programID: SOLANA_POLYMER_ORACLE, + polymerIndex, + mainnet: mainnet + }, + { timeout: 15_000 } + ); + const dat = response.data as { + proof: undefined | string; + polymerIndex: number; + }; + polymerIndex = dat.polymerIndex; + if (polymerIndex !== undefined) { + Solver.polymerRequestIndexByLog.set(polymerKey, polymerIndex); + } + if (dat.proof) { + proof = dat.proof; + break; + } + await Solver.sleep(waitMs); + } + if (!proof) { + throw new Error( + `Polymer proof unavailable for Solana output. Request used srcChainId=${SOLANA_POLYMER_SOURCE_CHAIN_ID}, txSignature=${record.submitSignature}, programID=${SOLANA_POLYMER_ORACLE}. Try again after the submit transaction is indexed.` + ); + } + if (preHook) await preHook(Number(sourceChainId)); + const transactionHash = await walletClient.writeContract({ + chain: getChain(sourceChainId), + account: account(), + address: order.inputOracle, + abi: POLYMER_ORACLE_ABI, + functionName: "receiveSolanaMessage", + args: [`0x${proof.replace("0x", "")}`] + }); + const result = await getClient(sourceChainId).waitForTransactionReceipt({ + hash: transactionHash, + timeout: 120_000, + pollingInterval: 2_000 + }); + await Solver.persistReceipt(sourceChainId, transactionHash, result); + if (postHook) await postHook(); + return result; + } + + if (!fillTransactionHash.startsWith("0x") || fillTransactionHash.length !== 66) { throw new Error(`Invalid fill transaction hash: ${fillTransactionHash}`); } @@ -321,26 +390,6 @@ export class Solver { `Fill transaction hash count (${fillTransactionHashes.length}) does not match output count (${order.outputs.length}).` ); } - for (let i = 0; i < fillTransactionHashes.length; i++) { - const hash = fillTransactionHashes[i]; - if (!hash || !hash.startsWith("0x") || hash.length !== 66) { - throw new Error(`Invalid fill tx hash at index ${i}: ${hash}`); - } - } - const transactionReceipts = await Promise.all( - fillTransactionHashes.map((fth, i) => - Solver.getReceiptCachedOrRpc(order.outputs[i].chainId, fth as `0x${string}`) - ) - ); - const blocks = await Promise.all( - transactionReceipts.map((r, i) => { - return getClient(order.outputs[i].chainId).getBlock({ - blockHash: r.blockHash - }); - }) - ); - const fillTimestamps = blocks.map((b) => b.timestamp); - if (preHook) await preHook(Number(sourceChainId)); const expectedChainId = Number(sourceChainId); const connectedChainId = await walletClient.getChainId(); @@ -350,12 +399,57 @@ export class Solver { ); } - const solveParams = fillTimestamps.map((fillTimestamp) => { - return { - timestamp: Number(fillTimestamp), - solver: addressToBytes32(account()) - }; - }); + const solveParams = await Promise.all( + order.outputs.map(async (output, i) => { + const txRef = fillTransactionHashes[i]; + if (!txRef) { + throw new Error(`Missing fill transaction reference at index ${i}`); + } + if (output.chainId === BigInt(chainMap.solanaDevnet.id)) { + const record = Solver.getSolanaFillRecord(output, txRef); + return { + timestamp: record.fillTimestamp, + solver: record.solverBytes32 + }; + } + if (!txRef.startsWith("0x") || txRef.length !== 66) { + throw new Error(`Invalid fill tx hash at index ${i}: ${txRef}`); + } + const transactionReceipt = await Solver.getReceiptCachedOrRpc( + output.chainId, + txRef as `0x${string}` + ); + const logs = parseEventLogs({ + abi: COIN_FILLER_ABI, + eventName: "OutputFilled", + logs: transactionReceipt.logs + }); + const expectedOutputHash = hashStruct({ + types: compactTypes, + primaryType: "MandateOutput", + data: output + }); + const matchingLog = logs.find((log) => { + const logOutputHash = hashStruct({ + types: compactTypes, + primaryType: "MandateOutput", + data: log.args.output + }); + return logOutputHash === expectedOutputHash; + }); + if (!matchingLog) { + throw new Error(`Could not find matching OutputFilled log for output ${i}`); + } + const fillTimestamp = + typeof matchingLog.args.timestamp === "number" + ? matchingLog.args.timestamp + : Number(matchingLog.args.timestamp); + return { + timestamp: fillTimestamp, + solver: matchingLog.args.solver as `0x${string}` + }; + }) + ); const transactionHash = await finaliseIntent({ intent, diff --git a/src/lib/screens/FillIntent.svelte b/src/lib/screens/FillIntent.svelte index bd5eccb..3ec982c 100644 --- a/src/lib/screens/FillIntent.svelte +++ b/src/lib/screens/FillIntent.svelte @@ -1,12 +1,22 @@ + {:else if hasSolanaOutput} + +
+ + +
+
{/if} {#each sortOutputsByChain(orderContainer) as chainIdAndOutputs} @@ -153,6 +223,7 @@ {@const chainStatuses = chainIdAndOutputs[1].map( (output) => fillStatuses[outputKey(output)] )} + {@const isSolanaOutputChain = chainIdAndOutputs[0] === BigInt(chainMap.solanaDevnet.id)} {#if chainStatuses.some((status) => status === undefined)} + {:else if isSolanaOutputChain && !solanaWallet.connected} + {:else} v == BYTES32_ZERO) ? "default" : "muted"} buttonFunction={chainStatuses.every((v) => v == BYTES32_ZERO) - ? fillWrapper( - chainIdAndOutputs[1], - Solver.fill( - store.walletClient, - { - orderContainer, - outputs: chainIdAndOutputs[1], - solverBytes32: - isSolanaToEvm && isValidSolanaAddress(solanaSolverAddress) - ? solanaAddressToBytes32(solanaSolverAddress) - : undefined - }, - { - preHook, - account - } + ? isSolanaOutputChain + ? solanaFillWrapper(chainIdAndOutputs[1]) + : fillWrapper( + chainIdAndOutputs[1], + Solver.fill( + store.walletClient, + { + orderContainer, + outputs: chainIdAndOutputs[1], + solverBytes32: + isSolanaToEvm && isValidSolanaAddress(solanaSolverAddress) + ? solanaAddressToBytes32(solanaSolverAddress) + : undefined + }, + { + preHook, + account + } + ) ) - ) : async () => {}} > {#snippet name()} diff --git a/src/lib/screens/Finalise.svelte b/src/lib/screens/Finalise.svelte index d30bf6e..26a3517 100644 --- a/src/lib/screens/Finalise.svelte +++ b/src/lib/screens/Finalise.svelte @@ -89,8 +89,10 @@ const fillTransactionHashesFor = (container: OrderContainer) => container.order.outputs.map((output) => store.fillTransactions[outputKey(output)]); - const isValidFillTxHash = (hash: unknown): hash is `0x${string}` => - typeof hash === "string" && hash.startsWith("0x") && hash.length === 66; + const hasClaimFillReference = (inputChain: bigint, hash: unknown): hash is string => + isSolanaChain(inputChain) + ? typeof hash === "string" && hash.startsWith("0x") && hash.length === 66 + : typeof hash === "string" && hash.length > 0; // Order status enum const OrderStatus_None = 0; @@ -326,7 +328,9 @@ {:else} {@const fillTransactionHashes = fillTransactionHashesFor(orderContainer)} - {@const canClaim = fillTransactionHashes.every((hash) => isValidFillTxHash(hash))} + {@const canClaim = fillTransactionHashes.every((hash) => + hasClaimFillReference(inputChain, hash) + )} {#if !canClaim}