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 adbddce..ec1f370 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -109,18 +109,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]: "0x00d5b500ECa100F7cdeDC800eC631Aca00BaAC00", - [baseSepolia.id]: "0x00d5b500ECa100F7cdeDC800eC631Aca00BaAC00", - [arbitrumSepolia.id]: "0x00d5b500ECa100F7cdeDC800eC631Aca00BaAC00", - [optimismSepolia.id]: "0x00d5b500ECa100F7cdeDC800eC631Aca00BaAC00", + [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/coreDeps.ts b/src/lib/libraries/coreDeps.ts index a952b05..510ad76 100644 --- a/src/lib/libraries/coreDeps.ts +++ b/src/lib/libraries/coreDeps.ts @@ -1,11 +1,15 @@ import { COIN_FILLER, INPUT_SETTLER_COMPACT_LIFI, + INPUT_SETTLER_ESCROW_LIFI, MULTICHAIN_INPUT_SETTLER_COMPACT, + MULTICHAIN_INPUT_SETTLER_ESCROW, POLYMER_ORACLE, + SOLANA_INPUT_SETTLER_ESCROW, SOLANA_PDAS, WORMHOLE_ORACLE } from "$lib/config"; +import { solanaAddressToBytes32 } from "$lib/utils/solana"; import type { IntentDeps, OrderContainerValidationDeps } from "@lifi/intent"; function isNonZeroAddress(value: string | undefined): value is `0x${string}` { @@ -25,7 +29,13 @@ export const intentDeps: IntentDeps = { }; export const orderValidationDeps: OrderContainerValidationDeps = { - inputSettlers: [INPUT_SETTLER_COMPACT_LIFI, MULTICHAIN_INPUT_SETTLER_COMPACT], + inputSettlers: [ + INPUT_SETTLER_COMPACT_LIFI, + INPUT_SETTLER_ESCROW_LIFI, + MULTICHAIN_INPUT_SETTLER_COMPACT, + MULTICHAIN_INPUT_SETTLER_ESCROW, + solanaAddressToBytes32(SOLANA_INPUT_SETTLER_ESCROW) + ], allowedInputOracles({ chainId, sameChainFill }) { const key = Number(chainId); if (!Number.isFinite(key)) return undefined; diff --git a/src/lib/libraries/flowProgress.ts b/src/lib/libraries/flowProgress.ts index 4549a70..5d34ec0 100644 --- a/src/lib/libraries/flowProgress.ts +++ b/src/lib/libraries/flowProgress.ts @@ -19,8 +19,14 @@ import { getOutputHash, encodeMandateOutput } from "@lifi/intent"; import { addressToBytes32, bytes32ToAddress } from "@lifi/intent"; import { orderToIntent } from "@lifi/intent"; import { getOrFetchRpc } from "$lib/libraries/rpcCache"; -import { deriveAttestationPda } from "$lib/libraries/solanaValidateLib"; -import type { MandateOutput, OrderContainer } from "@lifi/intent"; +import { + deriveAttestationPda, + encodeCommonPayload, + encodeFillDescription +} from "$lib/libraries/solanaValidateLib"; +import { isSolanaSubmittedFillRecord } from "$lib/libraries/solanaFillLib"; +import { deriveOrderContextPda } from "$lib/libraries/solanaFinaliseLib"; +import type { MandateOutput, OrderContainer, SolanaStandardOrder } from "@lifi/intent"; import store from "$lib/state.svelte"; const PROGRESS_TTL_MS = 30_000; @@ -41,12 +47,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 +78,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 } @@ -89,10 +125,11 @@ async function isOutputValidatedOnChain( ) as { blockHash: `0x${string}`; from: `0x${string}`; + logs: unknown[]; }; if (!cachedReceipt) { store - .saveTransactionReceipt(output.chainId, fillTransactionHash, receipt) + .saveTransactionReceipt(output.chainId, evmFillTransactionHash, receipt) .catch((error) => console.warn("saveTransactionReceipt error", error)); } @@ -104,7 +141,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, @@ -178,10 +215,25 @@ async function isOutputValidatedOnChain( async function isInputChainFinalised(chainId: bigint, container: OrderContainer) { const { order, inputSettler } = container; - const inputChainClient = getClient(chainId); const intent = orderToIntent(container); const orderId = intent.orderId(); + if (isSolanaChain(chainId)) { + return getOrFetchRpc( + `progress:finalised:solana:${orderId}:${chainId.toString()}`, + async () => { + const { PublicKey } = await import("@solana/web3.js"); + const conn = getSolanaConnection(chainId); + const pdaBase58 = await deriveOrderContextPda(order as SolanaStandardOrder); + const info = await conn.getAccountInfo(new PublicKey(pdaBase58)); + return info === null; + }, + { ttlMs: PROGRESS_TTL_MS } + ); + } + + const inputChainClient = getClient(chainId); + if ( inputSettler === INPUT_SETTLER_ESCROW_LIFI || inputSettler === MULTICHAIN_INPUT_SETTLER_ESCROW @@ -238,7 +290,7 @@ async function isInputChainFinalised(chainId: bigint, container: OrderContainer) export async function getOrderProgressChecks( orderContainer: OrderContainer, - fillTransactions: Record + fillTransactions: Record ): Promise { try { const intent = orderToIntent(orderContainer); @@ -257,7 +309,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/intentFactory.ts b/src/lib/libraries/intentFactory.ts index 62d67e7..2637014 100644 --- a/src/lib/libraries/intentFactory.ts +++ b/src/lib/libraries/intentFactory.ts @@ -58,6 +58,7 @@ function toCoreCreateIntentOptions(opts: AppCreateIntentOptions): CreateIntentOp outputTokens: opts.outputTokens.map(toCoreTokenContext), verifier: opts.verifier, account, + outputRecipient: opts.outputRecipient, lock: { type: "compact", resetPeriod: opts.lock.resetPeriod, @@ -72,6 +73,7 @@ function toCoreCreateIntentOptions(opts: AppCreateIntentOptions): CreateIntentOp outputTokens: opts.outputTokens.map(toCoreTokenContext), verifier: opts.verifier, account, + outputRecipient: opts.outputRecipient, lock: { type: "escrow" } 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/solanaFinaliseLib.ts b/src/lib/libraries/solanaFinaliseLib.ts index 6b05a65..b02d48b 100644 --- a/src/lib/libraries/solanaFinaliseLib.ts +++ b/src/lib/libraries/solanaFinaliseLib.ts @@ -1,93 +1,32 @@ -import { keccak256 } from "viem"; import idl from "../abi/input_settler_escrow.json"; -import { - SOLANA_INPUT_SETTLER_ESCROW, - SOLANA_INTENTS_PROTOCOL, - SOLANA_POLYMER_ORACLE -} from "../config"; -import type { MandateOutput, SolanaStandardOrder } from "@lifi/intent"; - -/** 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 (or string/number coercible to bigint) to a 32-byte big-endian number[] */ -function bigintToBeBytes32(n: bigint | string | number): number[] { - return Array.from(Buffer.from(BigInt(n).toString(16).padStart(64, "0"), "hex")); +import { SOLANA_INPUT_SETTLER_ESCROW, SOLANA_INTENTS_PROTOCOL } from "../config"; +import type { SolanaStandardOrder } from "@lifi/intent"; +import { borshEncodeSolanaOrder, computeSolanaStandardOrderId } from "@lifi/intent"; + +/** Borsh-encode a Vec where SolveParams = { solver: [u8;32], timestamp: u32 } */ +function encodeSolveParamsVec(params: { solver: number[]; timestamp: number }[]): Uint8Array { + const buf = Buffer.alloc(4 + params.length * 36); + buf.writeUInt32LE(params.length, 0); + let offset = 4; + for (const sp of params) { + for (let i = 0; i < 32; i++) buf[offset + i] = sp.solver[i]; + offset += 32; + buf.writeUInt32LE(sp.timestamp, offset); + offset += 4; + } + return buf; } /** * Derive the order_context PDA for a SolanaStandardOrder. - * orderId = keccak256(borsh(order)); seeds = [b"order_context", orderId] - * This can be used to check if the order has been finalised (PDA closed = finalised). + * Uses the canonical borsh encoding (matching the on-chain program). + * seeds = [b"order_context", keccak256(borsh(order))] */ -export async function deriveOrderContextPda( - order: SolanaStandardOrder, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - connection: any -): Promise { - const { AnchorProvider, BN, Program } = await import("@coral-xyz/anchor"); +export async function deriveOrderContextPda(order: SolanaStandardOrder): Promise { const { PublicKey } = await import("@solana/web3.js"); const inputSettlerProgramId = new PublicKey(SOLANA_INPUT_SETTLER_ESCROW); - const polymerOracleProgram = new PublicKey(SOLANA_POLYMER_ORACLE); - - const [polymerOraclePda] = PublicKey.findProgramAddressSync( - [Buffer.from("polymer")], - polymerOracleProgram - ); - const inputMint = new PublicKey(Buffer.from(order.input.token.slice(2), "hex")); - const userPubkey = new PublicKey(Buffer.from(order.user.slice(2), "hex")); - - // Dummy provider for encoding only (no wallet needed) - const dummyWallet = { - publicKey: userPubkey, - signTransaction: async (tx: unknown) => tx, - signAllTransactions: async (txs: unknown[]) => txs - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const typedIdl = idl as any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const provider = new AnchorProvider(connection, dummyWallet as any, { commitment: "confirmed" }); - const program = new Program(typedIdl, provider); - - 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: new BN(order.input.amount.toString()) }, - 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") - })) - }; - - const valueForEncoding = { - ...anchorOrder, - outputs: anchorOrder.outputs.map((o) => ({ - ...o, - callbackData: o.callbackData ?? Buffer.alloc(0), - context: o.context ?? Buffer.alloc(0) - })) - }; - let encoded: Uint8Array; - try { - encoded = program.coder.types.encode("standardOrder", valueForEncoding); - } catch { - encoded = program.coder.types.encode("StandardOrder", valueForEncoding); - } - const orderIdHex = keccak256(encoded); + const orderIdHex = computeSolanaStandardOrderId(order); const orderId = Buffer.from(orderIdHex.slice(2), "hex"); const [orderContextPda] = PublicKey.findProgramAddressSync( @@ -99,15 +38,11 @@ export async function deriveOrderContextPda( } /** - * Call input_settler_escrow.finalise() on Solana devnet. + * Call input_settler_escrow.finalise() on Solana. * - * @param order The SolanaStandardOrder that was previously opened - * @param solveParams One entry per output: solver = 32-byte Solana pubkey of the filler, - * timestamp = EVM fill block timestamp - * @param attestationPdas Base58 pubkeys of attestation PDAs (one per output) - * @param solanaPublicKey Base58-encoded Solana solver public key (signer + token destination) - * @param walletAdapter Connected Solana wallet adapter - * @param connection Solana Connection instance + * Encodes the instruction data using the borsh library (matching the encoding + * used by the `open` instruction) rather than Anchor's TypeScript encoder, + * which produces different bytes and a different PDA. */ export async function finaliseSolanaEscrow(params: { order: SolanaStandardOrder; @@ -119,8 +54,10 @@ export async function finaliseSolanaEscrow(params: { // eslint-disable-next-line @typescript-eslint/no-explicit-any connection: any; }): Promise { - const { AnchorProvider, BN, Program } = await import("@coral-xyz/anchor"); - const { PublicKey, SystemProgram, Transaction } = await import("@solana/web3.js"); + const { AnchorProvider } = await import("@coral-xyz/anchor"); + const { PublicKey, SystemProgram, Transaction, TransactionInstruction } = await import( + "@solana/web3.js" + ); const { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync } = await import("@solana/spl-token"); @@ -129,7 +66,6 @@ export async function finaliseSolanaEscrow(params: { const solverPubkey = new PublicKey(solanaPublicKey); const inputSettlerProgramId = new PublicKey(SOLANA_INPUT_SETTLER_ESCROW); - const polymerOracleProgram = new PublicKey(SOLANA_POLYMER_ORACLE); const intentsProtocolId = new PublicKey(SOLANA_INTENTS_PROTOCOL); const anchorWallet = { @@ -138,59 +74,33 @@ export async function finaliseSolanaEscrow(params: { signAllTransactions: (txs: unknown[]) => walletAdapter.signAllTransactions(txs) }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const typedIdl = idl as any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any const provider = new AnchorProvider(connection, anchorWallet as any, { commitment: "confirmed" }); - const program = new Program(typedIdl, provider); - - const [polymerOraclePda] = PublicKey.findProgramAddressSync( - [Buffer.from("polymer")], - polymerOracleProgram - ); const inputMint = new PublicKey(Buffer.from(order.input.token.slice(2), "hex")); const userPubkey = new PublicKey(Buffer.from(order.user.slice(2), "hex")); - 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: new BN(order.input.amount.toString()) }, - 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") - })) - }; + // Encode order using the canonical borsh encoding (matches the `open` instruction) + const orderBytes = borshEncodeSolanaOrder(order); - // Compute orderId = keccak256(borsh(anchorOrder)) - const valueForEncoding = { - ...anchorOrder, - outputs: anchorOrder.outputs.map((o) => ({ - ...o, - callbackData: o.callbackData ?? Buffer.alloc(0), - context: o.context ?? Buffer.alloc(0) - })) - }; - let encoded: Uint8Array; - try { - encoded = program.coder.types.encode("standardOrder", valueForEncoding); - } catch { - encoded = program.coder.types.encode("StandardOrder", valueForEncoding); - } - const orderIdHex = keccak256(encoded); + // Encode solve_params using the same borsh format + const solveParamsBytes = encodeSolveParamsVec(solveParams); + + // Instruction discriminator from the IDL + const discriminator = new Uint8Array( + idl.instructions.find((ix) => ix.name === "finalise")!.discriminator + ); + + // Assemble instruction data: discriminator + order + solve_params + const data = Buffer.concat([ + Buffer.from(discriminator), + Buffer.from(orderBytes), + Buffer.from(solveParamsBytes) + ]); + + // Derive PDAs using the canonical orderId + const orderIdHex = computeSolanaStandardOrderId(order); const orderId = Buffer.from(orderIdHex.slice(2), "hex"); - // Derive PDAs const [inputSettlerEscrowPda] = PublicKey.findProgramAddressSync( [Buffer.from("input_settler_escrow")], inputSettlerProgramId @@ -200,40 +110,37 @@ export async function finaliseSolanaEscrow(params: { inputSettlerProgramId ); - // ATAs: destination = solver, orderContext = escrow + // ATAs const destinationTokenAccount = getAssociatedTokenAddressSync(inputMint, solverPubkey, false); const orderPdaTokenAccount = getAssociatedTokenAddressSync(inputMint, orderContext, true); - const remainingAccounts = attestationPdas.map((pda) => ({ - pubkey: new PublicKey(pda), - isWritable: false, - isSigner: false - })); - - const ix = await program.methods - .finalise(anchorOrder as any, solveParams as any) - .accounts({ - solver: solverPubkey, - inputSettlerEscrow: inputSettlerEscrowPda, - user: userPubkey, - destination: solverPubkey, - destinationTokenAccount, - orderContext, - orderPdaTokenAccount, - mint: inputMint, - intentsProtocolProgram: intentsProtocolId, - tokenProgram: TOKEN_PROGRAM_ID, - associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, - systemProgram: SystemProgram.programId - } as any) - .remainingAccounts(remainingAccounts) - .instruction(); - - // The on-chain finalise flow closes escrow/state accounts to `user`, so `user` - // must be writable even though the generated IDL marks it readonly. - for (const key of ix.keys) { - if (key.pubkey.equals(userPubkey)) key.isWritable = true; - } + // Build accounts list matching the IDL's finalise instruction + const keys = [ + { pubkey: solverPubkey, isWritable: true, isSigner: true }, + { pubkey: inputSettlerEscrowPda, isWritable: false, isSigner: false }, + { pubkey: userPubkey, isWritable: true, isSigner: false }, + { pubkey: solverPubkey, isWritable: false, isSigner: false }, // destination + { pubkey: destinationTokenAccount, isWritable: true, isSigner: false }, + { pubkey: orderContext, isWritable: true, isSigner: false }, + { pubkey: orderPdaTokenAccount, isWritable: true, isSigner: false }, + { pubkey: inputMint, isWritable: false, isSigner: false }, + { pubkey: intentsProtocolId, isWritable: false, isSigner: false }, + { pubkey: TOKEN_PROGRAM_ID, isWritable: false, isSigner: false }, + { pubkey: ASSOCIATED_TOKEN_PROGRAM_ID, isWritable: false, isSigner: false }, + { pubkey: SystemProgram.programId, isWritable: false, isSigner: false }, + // Remaining accounts: attestation PDAs + ...attestationPdas.map((pda) => ({ + pubkey: new PublicKey(pda), + isWritable: false, + isSigner: false + })) + ]; + + const ix = new TransactionInstruction({ + programId: inputSettlerProgramId, + keys, + data + }); const tx = new Transaction().add(ix); tx.feePayer = solverPubkey; diff --git a/src/lib/libraries/solanaValidateLib.ts b/src/lib/libraries/solanaValidateLib.ts index f31d998..60a883a 100644 --- a/src/lib/libraries/solanaValidateLib.ts +++ b/src/lib/libraries/solanaValidateLib.ts @@ -6,12 +6,12 @@ import type { MandateOutput } from "@lifi/intent"; const POLYMER_PROVER_PROGRAM = "CdvSq48QUukYuMczgZAVNZrwcHNshBdtqrjW26sQiGPs"; -/** Convert a bigint (or number) to a 16-byte little-endian Buffer (u128 LE) */ -function u128ToLeBytes(n: bigint | number): Buffer { - const v = BigInt(n); +/** Convert a bigint to a 16-byte little-endian Buffer (u128 LE) */ +function u128ToLeBytes(n: bigint | string | number): Buffer { + const value = BigInt(n); const buf = Buffer.alloc(16); - buf.writeBigUInt64LE(v & 0xffffffffffffffffn, 0); - buf.writeBigUInt64LE(v >> 64n, 8); + buf.writeBigUInt64LE(value & 0xffffffffffffffffn, 0); + buf.writeBigUInt64LE(value >> 64n, 8); return buf; } @@ -240,7 +240,7 @@ export async function submitProofToSolanaOracle(params: { // Increase compute budget for chained CPI into IntentsProtocol const computeIx = ComputeBudgetProgram.setComputeUnitLimit({ units: 1_000_000 }); - return await program.methods + const methodBuilder = program.methods .receive([proofBytes] as any) .accounts({ signer: signerPubkey, @@ -254,6 +254,76 @@ export async function submitProofToSolanaOracle(params: { systemProgram: SystemProgram.programId } as any) .remainingAccounts([{ pubkey: attestationPda, isWritable: true, isSigner: false }]) - .preInstructions([computeIx]) - .rpc({ commitment: "confirmed" }); + .preInstructions([computeIx]); + + // Pre-simulate without wallet signing to verify the proof is accepted on-chain. + // The Polymer prover on Solana can take a few minutes after the proof is "complete" + // in the API before it's finalised on-chain. Simulating first avoids showing the + // Phantom dialog for a transaction that would fail, causing "Unexpected error". + const loopStartMs = Date.now(); + for (const delayMs of [0, 10_000, 20_000, 40_000, 60_000]) { + if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs)); + const tx = await methodBuilder.transaction(); + tx.feePayer = signerPubkey; + tx.recentBlockhash = (await params.connection.getLatestBlockhash()).blockhash; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const simResult = await (params.connection as any).simulateTransaction(tx); + if (!simResult.value.err) break; + if (delayMs === 60_000) { + throw new Error( + `Polymer proof not yet accepted by the Solana prover. Please try again in a few minutes. (${JSON.stringify(simResult.value.err)})` + ); + } + console.warn( + `Proof simulation failed, retrying in ${(delayMs + 10_000) / 1000}s…`, + simResult.value.err + ); + } + + // Wait until at least FINALIZATION_WAIT_MS have elapsed since the simulation loop + // started. Solana's "finalized" commitment requires ~32 slots (~13–16 s) after + // "confirmed", so once this threshold is met the Polymer prover state is present on + // ALL RPC nodes — including the one Phantom uses for its pre-signing simulation. + // Without this wait, Phantom's pre-signing simulation sees stale state and shows + // "Unexpected error" immediately after fill, even though our RPC simulation passed. + const FINALIZATION_WAIT_MS = 5_000; + const elapsedMs = Date.now() - loopStartMs; + if (elapsedMs < FINALIZATION_WAIT_MS) { + await new Promise((r) => setTimeout(r, FINALIZATION_WAIT_MS - elapsedMs)); + } + + // Send manually with skipPreflight to avoid state drift between our + // simulation, Phantom's internal simulation, and the node's preflight. + for (let attempt = 0; attempt < 3; attempt++) { + try { + const finalTx = await methodBuilder.transaction(); + finalTx.feePayer = signerPubkey; + const { blockhash, lastValidBlockHeight } = + await params.connection.getLatestBlockhash("confirmed"); + finalTx.recentBlockhash = blockhash; + + const signedTx = await anchorWallet.signTransaction(finalTx); + const txId = await params.connection.sendRawTransaction(signedTx.serialize(), { + skipPreflight: true, + maxRetries: 3 + }); + await params.connection.confirmTransaction( + { signature: txId, blockhash, lastValidBlockHeight }, + "confirmed" + ); + return txId; + } catch (err) { + if (attempt === 2) throw err; + console.warn(`Proof submission attempt ${attempt + 1} failed, retrying…`, err); + await new Promise((r) => setTimeout(r, 3000)); + const retryTx = await methodBuilder.transaction(); + retryTx.feePayer = signerPubkey; + retryTx.recentBlockhash = (await params.connection.getLatestBlockhash()).blockhash; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sim = await (params.connection as any).simulateTransaction(retryTx); + if (sim.value.err) throw err; + } + } + + throw new Error("Proof submission failed after all retry attempts."); } 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..25471e8 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} {#snippet action()} + {@const orderId = orderToIntent(orderContainer).orderId()} {@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], + orderId, + 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 f266cf5..1a4aad1 100644 --- a/src/lib/screens/Finalise.svelte +++ b/src/lib/screens/Finalise.svelte @@ -82,11 +82,17 @@ primaryType: "MandateOutput" }); - const fillTransactionHashesFor = (container: OrderContainer) => - container.order.outputs.map((output) => store.fillTransactions[outputKey(output)]); + const fillTransactionHashesFor = (container: OrderContainer) => { + const orderId = orderToIntent(container).orderId(); + return container.order.outputs.map( + (output) => store.fillTransactions[`${orderId}:${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; @@ -97,13 +103,14 @@ async function isClaimed(chainId: bigint, container: OrderContainer, _: any) { const { order, inputSettler } = container; - // Solana→EVM: order_context PDA is closed after finalise + // Solana→EVM: order_context PDA is closed after finalise. + // Derive the PDA via the canonical borsh encoding (matching the open instruction). if (isSolanaChain(chainId)) { const { PublicKey } = await import("@solana/web3.js"); const conn = getSolanaConnection(chainId); - const orderContextPda = await deriveOrderContextPda(order as SolanaStandardOrder, conn); + const orderContextPda = await deriveOrderContextPda(order as SolanaStandardOrder); const info = await conn.getAccountInfo(new PublicKey(orderContextPda)); - return info === null; // null = closed = finalised + return info === null; } const inputChainClient = getClient(chainId); @@ -167,10 +174,7 @@ // Collect fill tx hashes for all outputs const fillTxHashes = outputs.map( - (output) => - store.fillTransactions[ - hashStruct({ data: output, types: compactTypes, primaryType: "MandateOutput" }) - ] + (output) => store.fillTransactions[`${orderId}:${outputKey(output)}`] ); if (fillTxHashes.some((h) => !h || !h.startsWith("0x") || h.length !== 66)) { throw new Error("Missing fill transaction hashes"); @@ -322,7 +326,9 @@ {:else} {@const fillTransactionHashes = fillTransactionHashesFor(orderContainer)} - {@const canClaim = fillTransactionHashes.every((hash) => isValidFillTxHash(hash))} + {@const canClaim = fillTransactionHashes.every((hash) => + hasClaimFillReference(inputChain, hash) + )} {#if !canClaim}