UNPKG

0xtrails

Version:

SDK for Trails

779 lines (693 loc) 23.7 kB
import type { CommitIntentConfigArgs, CommitIntentConfigReturn, GetIntentCallsPayloadsArgs, GetIntentCallsPayloadsReturn, IntentCallsPayload, IntentPrecondition, SequenceAPIClient, } from "@0xsequence/trails-api" import { logger } from "./logger.js" import { bigintReplacer } from "./utils.js" export type { IntentCallsPayload, IntentPrecondition, } from "@0xsequence/trails-api" import { Config, type Context, Payload } from "@0xsequence/wallet-primitives" import { AbiParameters, Address, Bytes, ContractAddress, Hash, type Hex, } from "ox" import { type Account, type Chain, isAddressEqual, type WalletClient, http, createPublicClient, } from "viem" import { ATTESATION_SIGNER_ADDRESS, SEQUENCE_V3_CONTRACT_ADDRESSES, TRAILS_CCTP_SAPIENT_SIGNER_ADDRESS, TRAILS_LIFI_SAPIENT_SIGNER_ADDRESS, TRAILS_RELAY_SAPIENT_SIGNER_ADDRESS, } from "./constants.js" import { findPreconditionAddresses } from "./preconditions.js" import { getChainInfo } from "./chains.js" import { trackTransactionStarted, trackTransactionSubmitted, trackTransactionError, trackIntentQuoteRequested, trackIntentQuoteReceived, trackIntentQuoteError, trackIntentCommitStarted, trackIntentCommitCompleted, trackIntentCommitError, } from "./analytics.js" import { getQueryParam } from "./queryParams.js" import { getFullErrorMessage } from "./error.js" export type IntentRequestParams = { userAddress: string originChainId: number originTokenAddress: string originTokenAmount: string destinationChainId: number destinationToAddress: string destinationTokenAddress: string destinationTokenAmount: string destinationCallData: string destinationCallValue: string } export interface MetaTxnFeeDetail { metaTxnID: string estimatedGasLimit: string feeNative: string } export interface ChainExecuteQuote { chainId: string totalGasLimit: string gasPrice: string totalFeeAmount: string nativeTokenSymbol: string nativeTokenPrice?: string metaTxnFeeDetails: Array<MetaTxnFeeDetail> totalFeeUSD?: string } export interface ExecuteQuote { chainQuotes: Array<ChainExecuteQuote> } export interface CrossChainFee { providerFee: string trailsSwapFee: string providerFeeUSD: number trailsSwapFeeUSD: number totalFeeAmount: string totalFeeUSD: number } export interface TrailsFee { executeQuote: ExecuteQuote crossChainFee?: CrossChainFee trailsFixedFeeUSD?: number feeToken?: string originTokenTotalAmount?: string totalFeeAmount?: string totalFeeUSD?: string quoteProvider?: string } // QuoteProvider defines the possible liquidity providers. export type QuoteProvider = "lifi" | "relay" | "cctp" export type { GetIntentCallsPayloadsReturn } export type OriginCallParams = { to: `0x${string}` | null data: Hex.Hex | null value: bigint | null chainId: number | null error?: string } export type SendOriginCallTxArgs = { to: string data: Hex.Hex value: bigint chain: Chain } export async function getIntentCallsPayloads( apiClient: SequenceAPIClient, args: GetIntentCallsPayloadsArgs, additionalTrackingProps: Record<string, string> = {}, ): Promise<GetIntentCallsPayloadsReturn> { const localApiIntent = getQueryParam("localapiintent") === "true" if (localApiIntent) { // for testing local api changes const { getAPIClient } = await import("./apiClient.js") apiClient = getAPIClient({ apiUrl: "http://localhost:4422", }) } // Track intent quote request trackIntentQuoteRequested({ originChainId: args.originChainId || 0, destinationChainId: args.destinationChainId || 0, originTokenAddress: args.originTokenAddress, destinationTokenAddress: args.destinationTokenAddress, userAddress: args.userAddress, ...additionalTrackingProps, }) try { logger.console.log("[trails-sdk] getIntentCallsPayloads args:", args) const result = await apiClient.getIntentCallsPayloads(args) if (!result) { logger.console.error("[trails-sdk] No result from getIntentCallsPayloads") throw new Error("No result from getIntentCallsPayloads") } // Track successful intent quote received trackIntentQuoteReceived({ quoteId: result.originIntentAddress || "unknown", totalFeeUSD: result.trailsFee?.totalFeeUSD, trailsFixedFeeUSD: result.trailsFee?.trailsFixedFeeUSD, crossChainFeeTotalUSD: result.trailsFee?.crossChainFee?.totalFeeUSD, takerFeeUSD: result.trailsFee?.crossChainFee?.providerFeeUSD, providerFeeUSD: result.trailsFee?.crossChainFee?.providerFeeUSD, trailsSwapFeeUSD: result.trailsFee?.crossChainFee?.trailsSwapFeeUSD, gasFeesPerChainUSD: result.trailsFee?.executeQuote?.chainQuotes?.map((quote: any) => parseFloat(quote.totalFeeUSD || "0"), ) || [], originTokenTotalAmount: result.trailsFee?.originTokenTotalAmount, destinationTokenAmount: result.trailsFee?.totalFeeAmount, // Using available property provider: result.trailsFee?.quoteProvider, feeToken: result.trailsFee?.feeToken, userAddress: args.userAddress, intentAddress: result.originIntentAddress, ...additionalTrackingProps, }) return result } catch (error) { logger.console.error("[trails-sdk] Error in getIntentCallsPayloads", error) // Track intent quote error trackIntentQuoteError({ error: getFullErrorMessage(error), userAddress: args.userAddress, originChainId: args.originChainId || 0, destinationChainId: args.destinationChainId || 0, originTokenAddress: args.originTokenAddress, destinationTokenAddress: args.destinationTokenAddress, ...additionalTrackingProps, }) throw error } } export function calculateIntentAddress( mainSigner: string, calls: Array<IntentCallsPayload>, ): `0x${string}` { logger.console.log("[trails-sdk] calculateIntentAddress inputs:", { mainSigner, calls: JSON.stringify(calls, bigintReplacer, 2), }) const context = SEQUENCE_V3_CONTRACT_ADDRESSES const coreCalls = calls.map((call) => ({ type: "call" as const, chainId: call.chainId.toString(), space: call.space ? call.space.toString() : "0", nonce: call.nonce ? call.nonce.toString() : "0", calls: call.calls.map((call) => ({ to: Address.from(call.to) as `0x${string}`, value: call.value?.toString() || "0", data: Bytes.toHex(Bytes.from((call.data as Hex.Hex) || "0x")), gasLimit: call.gasLimit?.toString() || "0", delegateCall: !!call.delegateCall, onlyFallback: !!call.onlyFallback, behaviorOnError: call.behaviorOnError, })), })) const calculatedAddress = calculateIntentConfigurationAddress( Address.from(mainSigner), coreCalls, context, ) logger.console.log( "[trails-sdk] Final calculated address:", calculatedAddress.toString(), ) return calculatedAddress } export function calculateOriginAndDestinationIntentAddresses( mainSigner: string, calls: Array<IntentCallsPayload>, ) { const originChainId = calls[0]?.chainId const destinationChainId = calls[1]?.chainId || originChainId logger.console.log("[trails-sdk] originChainId:", originChainId) logger.console.log("[trails-sdk] destinationChainId:", destinationChainId) // Different origin and destination chains: cross-chain execution. const originCalls = calls.filter((c) => c.chainId === originChainId) const destinationCalls = calls.filter((c) => c.chainId === destinationChainId) logger.console.log("[trails-sdk] originCalls:", originCalls) logger.console.log("[trails-sdk] destinationCalls:", destinationCalls) const originIntentAddress = calculateIntentAddress(mainSigner, originCalls) let destinationIntentAddress = originIntentAddress if (originChainId !== destinationChainId) { destinationIntentAddress = calculateIntentAddress( mainSigner, destinationCalls, ) } logger.console.log("[trails-sdk] originIntentAddress:", originIntentAddress) logger.console.log( "[trails-sdk] destinationIntentAddress:", destinationIntentAddress, ) return { originIntentAddress, destinationIntentAddress } } export async function commitIntentConfig( apiClient: SequenceAPIClient, mainSignerAddress: string, calls: Array<IntentCallsPayload>, preconditions: Array<IntentPrecondition>, additionalTrackingProps: Record<string, string> = {}, requestParams?: IntentRequestParams, ): Promise<CommitIntentConfigReturn> { logger.console.log("[trails-sdk] commitIntentConfig inputs:", { mainSignerAddress, calls: JSON.stringify(calls, bigintReplacer, 2), preconditions: JSON.stringify(preconditions, bigintReplacer, 2), requestParams: JSON.stringify(requestParams, bigintReplacer, 2), }) // Additional detailed precondition logging logger.console.log("[trails-sdk] Detailed preconditions:", preconditions) // Log each precondition individually for easier reading preconditions.forEach((precondition, index) => { logger.console.log(`[trails-sdk] Precondition ${index}:`, { type: precondition.type, chainId: precondition.chainId, data: precondition.data, }) }) const { originIntentAddress, destinationIntentAddress } = calculateOriginAndDestinationIntentAddresses(mainSignerAddress, calls) logger.console.log( "[trails-sdk] originIntentAddress:", originIntentAddress.toString(), ) logger.console.log( "[trails-sdk] destinationIntentAddress:", destinationIntentAddress.toString(), ) const originChainIdStr = calls[0]?.chainId const destinationChainIdStr = calls[1]?.chainId // The executionInfos could be empty, so we need to handle the undefined case. const { originAddress: receivedAddress } = originChainIdStr && destinationChainIdStr ? findPreconditionAddresses( preconditions, Number(originChainIdStr), Number(destinationChainIdStr), ) : { originAddress: undefined } logger.console.log("[trails-sdk] Address comparison:", { receivedAddress, calculatedAddress: originIntentAddress.toString(), match: receivedAddress && isAddressEqual(Address.from(receivedAddress), originIntentAddress), }) const args: CommitIntentConfigArgs = { originIntentAddress: originIntentAddress.toString(), destinationIntentAddress: destinationIntentAddress.toString(), mainSigner: mainSignerAddress, calls: calls, preconditions: preconditions, } // Add request parameters if provided if (requestParams) { // Convert requestParams to the format expected by the backend const requestParamsForStorage = { version: "1.0", userAddress: requestParams.userAddress, originChainId: requestParams.originChainId, originTokenAddress: requestParams.originTokenAddress, originTokenAmount: requestParams.originTokenAmount, destinationChainId: requestParams.destinationChainId, destinationToAddress: requestParams.destinationToAddress, destinationTokenAddress: requestParams.destinationTokenAddress, destinationTokenAmount: requestParams.destinationTokenAmount, destinationCallData: requestParams.destinationCallData, destinationCallValue: requestParams.destinationCallValue, createdAt: new Date().toISOString(), } // Store request params in a way that can be passed to the API logger.console.log( "[trails-sdk] Request params for storage:", requestParamsForStorage, ) // Note: The current API client doesn't support request params yet // This will need to be updated when the API client is regenerated } const addressOverrides = { trailsLiFiSapientSignerAddress: TRAILS_LIFI_SAPIENT_SIGNER_ADDRESS, trailsRelaySapientSignerAddress: TRAILS_RELAY_SAPIENT_SIGNER_ADDRESS, trailsCCTPV2SapientSignerAddress: TRAILS_CCTP_SAPIENT_SIGNER_ADDRESS, ...args.addressOverrides, } try { // Track successful intent commit trackIntentCommitStarted({ intentAddress: originIntentAddress.toString(), userAddress: mainSignerAddress, originChainId: originChainIdStr ? Number(originChainIdStr) : undefined, destinationChainId: destinationChainIdStr ? Number(destinationChainIdStr) : undefined, ...additionalTrackingProps, }) const result = await apiClient.commitIntentConfig({ ...args, addressOverrides, // requestParams: requestParams, // TODO }) // Track successful intent commit trackIntentCommitCompleted({ intentAddress: originIntentAddress.toString(), userAddress: mainSignerAddress, originChainId: originChainIdStr ? Number(originChainIdStr) : undefined, destinationChainId: destinationChainIdStr ? Number(destinationChainIdStr) : undefined, ...additionalTrackingProps, }) return result } catch (error) { // Track intent commit error trackIntentCommitError({ error: getFullErrorMessage(error), userAddress: mainSignerAddress, intentAddress: originIntentAddress.toString(), originChainId: originChainIdStr ? Number(originChainIdStr) : undefined, destinationChainId: destinationChainIdStr ? Number(destinationChainIdStr) : undefined, ...additionalTrackingProps, }) throw error } } export async function sendOriginTransaction( account: Account, walletClient: WalletClient, originParams: SendOriginCallTxArgs, additionalTrackingProps: Record<string, string> = {}, ): Promise<`0x${string}`> { const chainId = await walletClient.getChainId() if (chainId.toString() !== originParams.chain.id.toString()) { logger.console.log( "[trails-sdk] sendOriginTransaction: switching chain", "want:", originParams.chain.id, "current:", chainId, ) await walletClient.switchChain({ id: originParams.chain.id }) logger.console.log( "[trails-sdk] sendOriginTransaction: switched chain to", originParams.chain.id, ) } // Track transaction start trackTransactionStarted({ transactionType: "origin_call", chainId: originParams.chain.id, userAddress: account.address, ...additionalTrackingProps, }) const publicClient = createPublicClient({ chain: getChainInfo(originParams.chain.id)!, transport: http(), }) const gasLimit = await publicClient.estimateGas({ account: account, to: originParams.to as `0x${string}`, data: originParams.data as `0x${string}`, value: BigInt(originParams.value), }) logger.console.log("[trails-sdk] estimated gasLimit:", gasLimit) logger.console.log( "[trails-sdk] sending origin tx with walletClient.sendTransaction()", ) const id = Date.now() logger.console.time(`[trails-sdk] walletClient.sendTransaction-${id}`) try { const hash = await walletClient.sendTransaction({ account: account, to: originParams.to as `0x${string}`, data: originParams.data as `0x${string}`, value: BigInt(originParams.value), chain: originParams.chain, gas: gasLimit, }) logger.console.timeEnd(`[trails-sdk] walletClient.sendTransaction-${id}`) logger.console.log("[trails-sdk] done sending, origin tx hash", hash) // Track successful transaction submission (pseudonymize() is called inside analytics) trackTransactionSubmitted({ transactionHash: hash, chainId: originParams.chain.id, userAddress: account.address, ...additionalTrackingProps, }) if (!hash) { throw new Error( "No transaction hash returned from walletClient.sendTransaction", ) } return hash } catch (error) { logger.console.timeEnd(`[trails-sdk] walletClient.sendTransaction-${id}`) // Track failed transaction trackTransactionError({ transactionHash: "", error: getFullErrorMessage(error), userAddress: account.address, ...additionalTrackingProps, }) throw error } } export interface OriginTokenParam { address: Address.Address chainId: bigint } export interface DestinationTokenParam { address: Address.Address chainId: bigint amount: bigint } export function hashIntentParams({ userAddress, nonce, originTokens, destinationCalls, destinationTokens, }: { userAddress: Address.Address nonce: bigint originTokens: OriginTokenParam[] destinationCalls: Array<IntentCallsPayload> destinationTokens: DestinationTokenParam[] }): string { if ( !userAddress || userAddress === "0x0000000000000000000000000000000000000000" ) throw new Error("UserAddress is zero") if (typeof nonce !== "bigint") throw new Error("Nonce is not a bigint") if (!originTokens || originTokens.length === 0) throw new Error("OriginTokens is empty") if (!destinationCalls || destinationCalls.length === 0) throw new Error("DestinationCalls is empty") if (!destinationTokens || destinationTokens.length === 0) throw new Error("DestinationTokens is empty") for (let i = 0; i < destinationCalls.length; i++) { const currentCall = destinationCalls[i] if (!currentCall) throw new Error(`DestinationCalls[${i}] is nil`) if (!currentCall.calls || currentCall.calls.length === 0) { throw new Error(`DestinationCalls[${i}] has no calls`) } } const originTokensForAbi = originTokens.map((token) => ({ address: token.address, chainId: token.chainId, })) let cumulativeCallsHashBytes: Bytes.Bytes = Bytes.from(new Uint8Array(32)) for (let i = 0; i < destinationCalls.length; i++) { const callPayload = destinationCalls[i] if (!callPayload) throw new Error(`DestinationCalls[${i}] is nil`) const currentDestCallPayloadHashBytes = Payload.hash( ATTESATION_SIGNER_ADDRESS, Number(callPayload.chainId), { type: "call", space: callPayload.space ? BigInt(callPayload.space) : 0n, nonce: callPayload.nonce ? BigInt(callPayload.nonce) : 0n, calls: callPayload.calls.map((call) => ({ type: "call", to: call.to as `0x${string}`, value: BigInt(call.value?.toString() || "0"), data: Bytes.toHex(Bytes.from((call.data as Hex.Hex) || "0x")), gasLimit: BigInt(call.gasLimit?.toString() || "0"), delegateCall: !!call.delegateCall, onlyFallback: !!call.onlyFallback, behaviorOnError: call.behaviorOnError === 0 ? "ignore" : call.behaviorOnError === 1 ? "revert" : "abort", })), }, ) cumulativeCallsHashBytes = Hash.keccak256( Bytes.concat(cumulativeCallsHashBytes, currentDestCallPayloadHashBytes), { as: "Bytes", }, ) } const cumulativeCallsHashHex = Bytes.toHex(cumulativeCallsHashBytes) const destinationTokensForAbi = destinationTokens.map((token) => ({ address: token.address, chainId: token.chainId, amount: token.amount, })) const abiSchema = [ { type: "address" }, { type: "uint256" }, { type: "tuple[]", components: [ { name: "address", type: "address" }, { name: "chainId", type: "uint256" }, ], }, { type: "tuple[]", components: [ { name: "address", type: "address" }, { name: "chainId", type: "uint256" }, { name: "amount", type: "uint256" }, ], }, { type: "bytes32" }, ] const encodedHex = AbiParameters.encode(abiSchema, [ userAddress, nonce, originTokensForAbi, destinationTokensForAbi, cumulativeCallsHashHex, ]) as Hex.Hex const encodedBytes = Bytes.fromHex(encodedHex) const hashBytes = Hash.keccak256(encodedBytes) const hashHex = Bytes.toHex(hashBytes) return hashHex } export function calculateIntentConfigurationAddress( mainSigner: Address.Address, calls: Array<IntentCallsPayload>, context: Context.Context, ): Address.Address { const config = createIntentConfiguration(mainSigner, calls) // Calculate the image hash of the configuration const imageHash = Config.hashConfiguration(config) // Calculate the counterfactual address using the image hash and context return ContractAddress.fromCreate2({ from: context.factory, bytecodeHash: Hash.keccak256( Bytes.concat( Bytes.from(context.creationCode), Bytes.padLeft(Bytes.from(context.stage1), 32), ), { as: "Bytes" }, ), salt: imageHash, }) } function createIntentConfiguration( mainSigner: Address.Address, calls: IntentCallsPayload[], ): Config.Config { const mainSignerLeaf: Config.SignerLeaf = { type: "signer", address: mainSigner, weight: 1n, } logger.console.log("[trails-sdk] mainSignerLeaf:", mainSignerLeaf) const subdigestLeaves: Config.AnyAddressSubdigestLeaf[] = calls.map( (call) => { const digest = Payload.hash( Address.from("0x0000000000000000000000000000000000000000"), Number(call.chainId), { type: "call", space: BigInt(call.space || 0), nonce: BigInt(call.nonce || 0), calls: call.calls.map((call) => ({ type: "call", to: call.to as `0x${string}`, value: BigInt(call.value?.toString() || "0"), data: Bytes.toHex(Bytes.from((call.data as Hex.Hex) || "0x")), gasLimit: BigInt(call.gasLimit?.toString() || "0"), delegateCall: !!call.delegateCall, onlyFallback: !!call.onlyFallback, behaviorOnError: call.behaviorOnError === 0 ? "ignore" : call.behaviorOnError === 1 ? "revert" : "abort", })), }, ) logger.console.log("[trails-sdk] digest:", Bytes.toHex(digest)) return { type: "any-address-subdigest", digest: Bytes.toHex(digest), } as Config.AnyAddressSubdigestLeaf }, ) logger.console.log("[trails-sdk] calls:", calls) logger.console.log("[trails-sdk] subdigestLeaves:", subdigestLeaves) const otherLeaves: Config.Topology[] = [...subdigestLeaves] if (otherLeaves.length === 0) { throw new Error( "Intent configuration must have at least one call or LiFi information.", ) } let secondaryTopologyNode: Config.Topology if (otherLeaves.length === 1) { secondaryTopologyNode = otherLeaves[0]! } else { secondaryTopologyNode = buildMerkleTreeFromMembers(otherLeaves) } // Print the topology logger.console.log( "[trails-sdk] Topology:", JSON.stringify([mainSignerLeaf, secondaryTopologyNode], bigintReplacer, 2), ) return { threshold: 1n, checkpoint: 0n, topology: [mainSignerLeaf, secondaryTopologyNode] as Config.Node, } } // Renamed and generalized from createSubdigestTree function buildMerkleTreeFromMembers( members: Config.Topology[], ): Config.Topology { if (members.length === 0) { throw new Error("Cannot create a tree from empty members") } if (members.length === 1) { return members[0]! // Returns a single Leaf or a Node } let currentLevel = [...members] while (currentLevel.length > 1) { const nextLevel: Config.Topology[] = [] for (let i = 0; i < currentLevel.length; i += 2) { const left = currentLevel[i]! if (i + 1 < currentLevel.length) { const right = currentLevel[i + 1]! nextLevel.push([left, right] as Config.Node) } else { // Odd one out, carries over to the next level nextLevel.push(left) } } currentLevel = nextLevel } return currentLevel[0]! }