0xtrails
Version:
SDK for Trails
1,561 lines (1,438 loc) • 57.7 kB
text/typescript
import type {
AddressOverrides,
GetIntentCallsPayloadsArgs,
GetIntentConfigReturn,
IntentCallsPayload,
IntentPrecondition,
} from "@0xsequence/trails-api"
import type { SequenceAPIClient } from "@0xsequence/trails-api"
import type { Relayer } from "@0xsequence/wallet-core"
import { useMutation, useQuery } from "@tanstack/react-query"
import { Address } from "ox"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { Hex } from "viem"
import {
createPublicClient,
createWalletClient,
custom,
http,
isAddress,
isAddressEqual,
zeroAddress,
} from "viem"
import type { Connector } from "wagmi"
import {
useEstimateGas,
useSendTransaction,
useSwitchChain,
useWaitForTransactionReceipt,
} from "wagmi"
import { useAPIClient } from "./apiClient.js"
import { attemptSwitchChain } from "./chainSwitch.js"
import { getChainInfo } from "./chains.js"
import {
TRAILS_CCTP_SAPIENT_SIGNER_ADDRESS,
TRAILS_LIFI_SAPIENT_SIGNER_ADDRESS,
TRAILS_RELAY_SAPIENT_SIGNER_ADDRESS,
} from "./constants.js"
import { getERC20TransferData } from "./encoders.js"
import type {
GetIntentCallsPayloadsReturn,
OriginCallParams,
QuoteProvider,
TrailsFee,
} from "./intents.js"
import {
calculateIntentAddress,
calculateOriginAndDestinationIntentAddresses,
getIntentCallsPayloads as getIntentCallsPayloadsFromIntents,
} from "./intents.js"
import type { MetaTxn } from "./metaTxnMonitor.js"
import { useMetaTxnsMonitor } from "./metaTxnMonitor.js"
import { findPreconditionAddresses } from "./preconditions.js"
import { getBackupRelayer, useRelayers } from "./relayer.js"
import { queueCCTPTransfer } from "./cctpqueue.js"
import { logger } from "./logger.js"
export type WagmiAccount = {
address: `0x${string}`
isConnected: boolean
chainId: number
connector?: Connector
}
export type UseTrailsConfig = {
account: WagmiAccount
disableAutoExecute?: boolean
env: "local" | "cors-anywhere" | "dev" | "prod"
sequenceProjectAccessKey?: string
}
export type UseTrailsReturn = {
apiClient: SequenceAPIClient
metaTxns: GetIntentCallsPayloadsReturn["metaTxns"] | null
intentCallsPayloads: GetIntentCallsPayloadsReturn["calls"] | null
intentPreconditions: GetIntentCallsPayloadsReturn["preconditions"] | null
trailsFee: TrailsFee | null
txnHash: Hex | undefined
committedOriginIntentAddress: string | null
committedDestinationIntentAddress: string | null
verificationStatus: {
success: boolean
calculatedOriginAddress?: string
calculatedDestinationAddress?: string
receivedOriginAddress?: string
receivedDestinationAddress?: string
} | null
getRelayer: (chainId: number) => any // TODO: Add proper type
estimatedGas: bigint | undefined
isEstimateError: boolean
estimateError: Error | null
calculateIntentAddress: typeof calculateIntentAddress
calculateOriginAndDestinationIntentAddresses: typeof calculateOriginAndDestinationIntentAddresses
committedIntentConfig: GetIntentConfigReturn | undefined
isLoadingCommittedConfig: boolean
committedConfigError: Error | null
commitIntentConfig: (args: {
mainSignerAddress: string
calls: IntentCallsPayload[]
preconditions: IntentPrecondition[]
quoteProvider: QuoteProvider
addressOverrides?: AddressOverrides
}) => void
commitIntentConfigPending: boolean
commitIntentConfigSuccess: boolean
commitIntentConfigError: Error | null
commitIntentConfigArgs:
| {
mainSignerAddress: string
calls: IntentCallsPayload[]
preconditions: IntentPrecondition[]
quoteProvider: QuoteProvider
addressOverrides?: AddressOverrides
}
| undefined
getIntentCallsPayloads: (
args: GetIntentCallsPayloadsArgs,
) => Promise<GetIntentCallsPayloadsReturn>
operationHashes: { [key: string]: Hex }
callIntentCallsPayload: (args: GetIntentCallsPayloadsArgs) => void
sendOriginTransaction: () => Promise<void>
switchChain: any // TODO: Add proper type
isSwitchingChain: boolean
switchChainError: Error | null
isTransactionInProgress: boolean
isChainSwitchRequired: boolean
sendTransaction: any // TODO: Add proper type
isSendingTransaction: boolean
originCallStatus: {
txnHash?: string
status?: string
revertReason?: string | null
gasUsed?: number
effectiveGasPrice?: string
} | null
updateOriginCallStatus: (
hash: Hex | undefined,
status: "success" | "reverted" | "pending" | "sending",
gasUsed?: bigint,
effectiveGasPrice?: bigint,
revertReason?: string | null,
) => void
isEstimatingGas: boolean
isAutoExecute: boolean
updateAutoExecute: (enabled: boolean) => void
receipt: any // TODO: Add proper type
isWaitingForReceipt: boolean
receiptIsSuccess: boolean
receiptIsError: boolean
receiptError: Error | null
hasAutoExecuted: boolean
originCallSuccess: boolean
sentMetaTxns: { [key: string]: number }
sendMetaTxn: (selectedId: string | null) => void
sendMetaTxnPending: boolean
sendMetaTxnSuccess: boolean
sendMetaTxnError: Error | null
sendMetaTxnArgs: { selectedId: string | null } | undefined
clearIntent: () => void
metaTxnMonitorStatuses: { [key: string]: Relayer.OperationStatus }
createIntent: (args: GetIntentCallsPayloadsArgs) => void
createIntentPending: boolean
createIntentSuccess: boolean
createIntentError: Error | null
createIntentArgs: GetIntentCallsPayloadsArgs | undefined
originCallParams: OriginCallParams | null
updateOriginCallParams: (
args: { originChainId: number; tokenAddress: string } | null,
) => void
originBlockTimestamp: number | null
metaTxnBlockTimestamps: {
[key: string]: { timestamp: number | null; error?: string }
}
originIntentAddress: string | null
destinationIntentAddress: string | null
}
const RETRY_WINDOW_MS = 10_000
export function useTrails(config: UseTrailsConfig): UseTrailsReturn {
const {
account,
disableAutoExecute = false,
env,
sequenceProjectAccessKey,
} = config
const apiClient = useAPIClient({ projectAccessKey: sequenceProjectAccessKey })
const [isAutoExecute, setIsAutoExecute] = useState(!disableAutoExecute)
const [hasAutoExecuted, setHasAutoExecuted] = useState(false)
// Track timestamps of when each meta-transaction was last sent
const [sentMetaTxns, setSentMetaTxns] = useState<{ [key: string]: number }>(
{},
)
// State declarations
const [metaTxns, setMetaTxns] = useState<
GetIntentCallsPayloadsReturn["metaTxns"] | null
>(null)
const [intentCallsPayloads, setIntentCallsPayloads] = useState<
GetIntentCallsPayloadsReturn["calls"] | null
>(null)
const [intentPreconditions, setIntentPreconditions] = useState<
GetIntentCallsPayloadsReturn["preconditions"] | null
>(null)
const [trailsFee, setTrailsFee] = useState<TrailsFee | null>(null)
const [txnHash, setTxnHash] = useState<Hex | undefined>()
const [committedOriginIntentAddress, setCommittedOriginIntentAddress] =
useState<string | null>(null)
const [
committedDestinationIntentAddress,
setCommittedDestinationIntentAddress,
] = useState<string | null>(null)
const [originIntentAddress, setOriginIntentAddress] = useState<string | null>(
null,
)
const [destinationIntentAddress, setDestinationIntentAddress] = useState<
string | null
>(null)
// const [preconditionStatuses, setPreconditionStatuses] = useState<boolean[]>([])
const [originCallParams, setOriginCallParams] =
useState<OriginCallParams | null>(null)
const [operationHashes, setOperationHashes] = useState<{
[key: string]: Hex
}>({})
const [isTransactionInProgress, setIsTransactionInProgress] = useState(false)
const [isChainSwitchRequired, setIsChainSwitchRequired] = useState(false)
const {
switchChain,
isPending: isSwitchingChain,
error: switchChainError,
} = useSwitchChain()
const sendOriginTxn = useSendTransaction()
const [isEstimatingGas, setIsEstimatingGas] = useState(false)
const [originCallStatus, setOriginCallStatus] = useState<{
txnHash?: string
status?: string
revertReason?: string | null
gasUsed?: number
effectiveGasPrice?: string
} | null>(null)
const [originBlockTimestamp, setOriginBlockTimestamp] = useState<
number | null
>(null)
const [metaTxnBlockTimestamps, setMetaTxnBlockTimestamps] = useState<{
[key: string]: { timestamp: number | null; error?: string }
}>({})
const [verificationStatus, setVerificationStatus] = useState<{
success: boolean
calculatedOriginAddress?: string
calculatedDestinationAddress?: string
receivedOriginAddress?: string
receivedDestinationAddress?: string
} | null>(null)
const { getRelayer, relayers } = useRelayers({
env,
})
// Add gas estimation hook with proper types
const {
data: estimatedGas,
isError: isEstimateError,
error: estimateError,
} = useEstimateGas(
originCallParams?.to && originCallParams?.chainId && !originCallParams.error
? {
to: originCallParams.to || undefined,
data: originCallParams.data || undefined,
value: originCallParams.value || undefined,
chainId: originCallParams.chainId || undefined,
}
: undefined,
)
const commitIntentConfigMutation = useMutation({
mutationFn: async (args: {
mainSignerAddress: string
calls: IntentCallsPayload[]
preconditions: IntentPrecondition[]
quoteProvider: QuoteProvider
addressOverrides?: AddressOverrides
}) => {
logger.console.log(
"[useTrails] commitIntentConfigMutation started with args:",
args,
)
if (!apiClient) {
logger.console.error("[useTrails] API client not available")
throw new Error("API client not available")
}
if (!args.quoteProvider) {
logger.console.error("[useTrails] quoteProvider is required")
throw new Error("quoteProvider is required")
}
try {
logger.console.log("[useTrails] Calculating intent address...")
logger.console.log("[useTrails] Main signer:", args.mainSignerAddress)
logger.console.log("[useTrails] Calls:", args.calls)
const originChainId = createIntentMutation.variables?.originChainId
const destinationChainId =
createIntentMutation.variables?.destinationChainId
if (!originChainId || !destinationChainId) {
logger.console.error(
"[useTrails] Could not determine origin/destination chain IDs for verification.",
)
throw new Error(
"Could not determine origin/destination chain IDs for verification.",
)
}
const { originIntentAddress, destinationIntentAddress } =
calculateOriginAndDestinationIntentAddresses(
args.mainSignerAddress,
args.calls,
)
const {
originAddress: originPreconditionAddress,
destinationAddress: destinationPreconditionAddress,
} = findPreconditionAddresses(
args.preconditions,
originChainId,
destinationChainId,
)
logger.console.log("[useTrails] Verification addresses:", {
calculatedOrigin: originIntentAddress.toString(),
calculatedDestination: destinationIntentAddress.toString(),
preconditionOrigin: originPreconditionAddress,
preconditionDestination: destinationPreconditionAddress,
})
const isOriginVerified =
!!originPreconditionAddress &&
isAddressEqual(
Address.from(originPreconditionAddress),
originIntentAddress,
)
logger.console.log("[useTrails] Origin verified:", isOriginVerified)
// For single chain, destination address may not be in preconditions,
// but the destination intent address should be the zero address.
const isDestinationVerified =
(destinationPreconditionAddress &&
isAddressEqual(
Address.from(destinationPreconditionAddress),
destinationIntentAddress,
)) ||
(!destinationPreconditionAddress &&
originChainId === destinationChainId &&
isAddressEqual(destinationIntentAddress, zeroAddress))
logger.console.log(
"[useTrails] Destination verified:",
isDestinationVerified,
)
const isVerified = isOriginVerified && isDestinationVerified
setVerificationStatus({
success: isVerified,
receivedOriginAddress: originPreconditionAddress,
receivedDestinationAddress: destinationPreconditionAddress,
calculatedOriginAddress: originIntentAddress.toString(),
calculatedDestinationAddress: destinationIntentAddress.toString(),
})
if (!isVerified) {
logger.console.error("[useTrails] Address verification failed.", {
isOriginVerified,
isDestinationVerified,
})
throw new Error(
`Address verification failed. Origin verified: ${isOriginVerified}, Destination verified: ${isDestinationVerified}`,
)
}
// Commit the intent config
logger.console.log("[useTrails] Committing intent config to API...")
const response = await apiClient.commitIntentConfig({
originIntentAddress: originIntentAddress.toString(),
destinationIntentAddress: destinationIntentAddress.toString(),
mainSigner: args.mainSignerAddress,
calls: args.calls,
preconditions: args.preconditions,
addressOverrides: {
trailsLiFiSapientSignerAddress: TRAILS_LIFI_SAPIENT_SIGNER_ADDRESS,
trailsRelaySapientSignerAddress:
TRAILS_RELAY_SAPIENT_SIGNER_ADDRESS,
trailsCCTPV2SapientSignerAddress:
TRAILS_CCTP_SAPIENT_SIGNER_ADDRESS,
...args.addressOverrides,
},
})
logger.console.log("[useTrails] API Commit Response:", response)
return {
originIntentAddress: originIntentAddress.toString(),
destinationIntentAddress: destinationIntentAddress.toString(),
response,
}
} catch (error) {
console.error("[useTrails] Error during commit intent mutation:", error)
throw error
}
},
onSuccess: (data) => {
logger.console.log(
"[useTrails] Intent config committed successfully. Data:",
data,
)
logger.console.log(
"[useTrails] Setting committedOriginIntentAddress:",
data.originIntentAddress,
)
setCommittedOriginIntentAddress(data.originIntentAddress)
setCommittedDestinationIntentAddress(data.destinationIntentAddress)
},
onError: (error) => {
logger.console.error("[useTrails] Failed to commit intent config:", error)
setCommittedOriginIntentAddress(null)
setCommittedDestinationIntentAddress(null)
},
})
// New Query to fetch committed intent config
const {
data: committedIntentConfig,
isLoading: isLoadingCommittedConfig,
error: committedConfigError,
} = useQuery<GetIntentConfigReturn, Error>({
queryKey: ["getIntentConfig", committedOriginIntentAddress],
queryFn: async () => {
if (!apiClient || !committedOriginIntentAddress) {
throw new Error("API client or committed intent address not available")
}
logger.console.log(
"Fetching intent config for address:",
committedOriginIntentAddress,
)
return await apiClient.getIntentConfig({
intentAddress: committedOriginIntentAddress,
})
},
enabled:
!!committedOriginIntentAddress &&
!!apiClient &&
commitIntentConfigMutation.isSuccess,
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
})
async function getIntentCallsPayloads(args: GetIntentCallsPayloadsArgs) {
return getIntentCallsPayloadsFromIntents(apiClient, {
...args,
addressOverrides: {
trailsLiFiSapientSignerAddress: TRAILS_LIFI_SAPIENT_SIGNER_ADDRESS,
trailsRelaySapientSignerAddress: TRAILS_RELAY_SAPIENT_SIGNER_ADDRESS,
trailsCCTPV2SapientSignerAddress: TRAILS_CCTP_SAPIENT_SIGNER_ADDRESS,
...args.addressOverrides,
},
})
}
// TODO: Add type for args
const createIntentMutation = useMutation<
GetIntentCallsPayloadsReturn,
Error,
GetIntentCallsPayloadsArgs
>({
mutationFn: async (args: GetIntentCallsPayloadsArgs) => {
if (
args.originChainId === args.destinationChainId &&
isAddressEqual(
Address.from(args.originTokenAddress),
Address.from(args.destinationTokenAddress),
)
) {
throw new Error(
"The same token cannot be used as both the source and destination token.",
)
}
if (!account.address) {
throw new Error("Missing selected token or account address")
}
// Reset commit state when generating a new intent
setCommittedOriginIntentAddress(null)
setCommittedDestinationIntentAddress(null)
setVerificationStatus(null)
setTrailsFee(null)
setMetaTxns(null)
setIntentCallsPayloads(null)
setIntentPreconditions(null)
setOriginIntentAddress(null)
setDestinationIntentAddress(null)
const data = await getIntentCallsPayloads(args)
setMetaTxns(data.metaTxns)
setIntentCallsPayloads(data.calls)
setIntentPreconditions(data.preconditions)
setTrailsFee(data.trailsFee!)
setOriginIntentAddress(data.originIntentAddress)
setDestinationIntentAddress(data.destinationIntentAddress)
setCommittedOriginIntentAddress(null)
setCommittedDestinationIntentAddress(null)
setVerificationStatus(null)
return data
},
onSuccess: (data) => {
logger.console.log("Intent Config Success:", data)
setTrailsFee(data.trailsFee || null)
setOriginIntentAddress(data.originIntentAddress)
setDestinationIntentAddress(data.destinationIntentAddress)
if (
data?.calls &&
data.calls.length > 0 &&
data.preconditions &&
data.preconditions.length > 0 &&
data.metaTxns &&
data.metaTxns.length > 0
) {
setIntentCallsPayloads(data.calls)
setIntentPreconditions(data.preconditions)
setMetaTxns(data.metaTxns)
} else {
logger.console.warn("API returned success but no operations found.")
setIntentCallsPayloads(null)
setIntentPreconditions(null)
setMetaTxns(null)
}
},
onError: (error) => {
console.error("Intent Config Error:", error)
setIntentCallsPayloads(null)
setIntentPreconditions(null)
setMetaTxns(null)
setTrailsFee(null)
setOriginIntentAddress(null)
setDestinationIntentAddress(null)
},
})
function callIntentCallsPayload(args: GetIntentCallsPayloadsArgs) {
createIntentMutation.mutate(args)
}
const clearIntent = useCallback(() => {
logger.console.log("[Trails] Clearing intent state")
setIntentCallsPayloads(null)
setIntentPreconditions(null)
setMetaTxns(null)
setTrailsFee(null)
setCommittedOriginIntentAddress(null)
setCommittedDestinationIntentAddress(null)
setVerificationStatus(null)
setOriginIntentAddress(null)
setDestinationIntentAddress(null)
setOperationHashes({})
setHasAutoExecuted(false)
setMetaTxnBlockTimestamps({})
}, []) // Empty deps array since these setters are stable
const updateOriginCallStatus = useCallback(
(
hash: Hex | undefined,
status: "success" | "reverted" | "pending" | "sending",
gasUsed?: bigint,
effectiveGasPrice?: bigint,
revertReason?: string | null,
) => {
setOriginCallStatus({
txnHash: hash,
status:
status === "success"
? "Success"
: status === "reverted"
? "Failed"
: status === "sending"
? "Sending..."
: "Pending",
revertReason:
status === "reverted"
? revertReason || "Transaction reverted"
: undefined,
gasUsed: gasUsed ? Number(gasUsed) : undefined,
effectiveGasPrice: effectiveGasPrice?.toString(),
})
},
[],
)
const sendOriginTransaction = async () => {
logger.console.log("Sending origin transaction...")
logger.console.log(
isTransactionInProgress,
originCallParams,
originCallParams?.error,
originCallParams?.to,
originCallParams?.data,
originCallParams?.value,
originCallParams?.chainId,
)
if (
isTransactionInProgress || // Prevent duplicate transactions
!originCallParams ||
originCallParams.error ||
!originCallParams.to ||
originCallParams.data === null ||
originCallParams.value === null ||
originCallParams.chainId === null
) {
logger.console.error(
"Origin call parameters not available or invalid:",
originCallParams,
)
updateOriginCallStatus(
undefined,
"reverted",
undefined,
undefined,
"Origin call parameters not ready",
)
return
}
// Check if we need to switch chains
if (account.chainId !== originCallParams.chainId) {
setIsChainSwitchRequired(true)
updateOriginCallStatus(
undefined,
"pending",
undefined,
undefined,
`Switching to chain ${originCallParams.chainId}...`,
)
const walletClient = createWalletClient({
chain: getChainInfo(originCallParams.chainId)!,
transport: custom((await account.connector!.getProvider()) as any), // TODO: Add proper type
})
try {
await attemptSwitchChain({
walletClient,
desiredChainId: originCallParams.chainId,
})
setIsChainSwitchRequired(false)
} catch (error: unknown) {
logger.console.error("Chain switch failed:", error)
if (error instanceof Error && error.message.includes("User rejected")) {
setIsAutoExecute(false)
}
updateOriginCallStatus(
undefined,
"reverted",
undefined,
undefined,
error instanceof Error
? error.message
: "Unknown error switching chain",
)
setIsChainSwitchRequired(false)
return // Stop execution on switch failure
}
}
// Ensure only one transaction is sent at a time
if (!isTransactionInProgress) {
setIsTransactionInProgress(true) // Mark transaction as in progress
setTxnHash(undefined)
updateOriginCallStatus(undefined, "sending")
if (!estimatedGas && !isEstimateError) {
setIsEstimatingGas(true)
return // Wait for gas estimation
}
if (isEstimateError) {
logger.console.error("Gas estimation failed:", estimateError)
updateOriginCallStatus(
undefined,
"reverted",
undefined,
undefined,
`Gas estimation failed: ${estimateError?.message}`,
)
setIsTransactionInProgress(false)
return
}
// Add 20% buffer to estimated gas
const gasLimit = estimatedGas
? BigInt(Math.floor(Number(estimatedGas) * 1.2))
: undefined
sendOriginTxn.sendTransaction(
{
to: originCallParams.to,
data: originCallParams.data,
value: originCallParams.value,
chainId: originCallParams.chainId,
gas: gasLimit,
},
{
onSuccess: (hash: Hex) => {
logger.console.log("Transaction sent, hash:", hash)
setTxnHash(hash)
setIsTransactionInProgress(false) // Reset transaction state
},
onError: (error: unknown) => {
logger.console.error("Transaction failed:", error)
if (
error instanceof Error &&
(error.message.includes("User rejected") ||
error.message.includes("user rejected"))
) {
setIsAutoExecute(false)
}
updateOriginCallStatus(
undefined,
"reverted",
undefined,
undefined,
error instanceof Error ? error.message : "Unknown error",
)
setIsTransactionInProgress(false)
},
},
)
} else {
logger.console.warn(
"Transaction already in progress. Skipping duplicate request.",
)
}
}
// Remove the chain change effect that might be resetting state
useEffect(() => {
if (switchChainError) {
logger.console.error("Chain switch error:", switchChainError)
updateOriginCallStatus(
undefined,
"reverted",
undefined,
undefined,
`Chain switch failed: ${switchChainError.message || "Unknown error"}`,
)
setIsChainSwitchRequired(false)
}
}, [switchChainError, updateOriginCallStatus])
// Reset gas estimation state when parameters change
useEffect(() => {
setIsEstimatingGas(false)
}, [])
// Only update chain switch required state when needed
useEffect(() => {
if (
originCallParams?.chainId &&
account.chainId === originCallParams.chainId
) {
logger.console.log("No chain switch required")
setIsChainSwitchRequired(false)
}
}, [account.chainId, originCallParams?.chainId])
// Effect to handle chain switching
useEffect(() => {
if (
originCallParams?.chainId &&
account.chainId !== originCallParams.chainId
) {
async function check() {
try {
const chainId = originCallParams!.chainId!
const walletClient = createWalletClient({
chain: getChainInfo(chainId)!,
transport: custom((await account.connector!.getProvider()) as any), // TODO: Add proper type
})
await attemptSwitchChain({
walletClient,
desiredChainId: chainId,
})
} catch (error) {
logger.console.error("Chain switch failed:", error)
}
}
check().catch(logger.console.error)
}
}, [account, originCallParams])
// Hook to wait for transaction receipt
const {
data: receipt,
isLoading: isWaitingForReceipt,
isSuccess: receiptIsSuccess,
isError: receiptIsError,
error: receiptError,
} = useWaitForTransactionReceipt({
hash: txnHash,
confirmations: 1,
query: {
enabled: !!txnHash,
},
})
// Modify the effect that watches for transaction status
// biome-ignore lint/correctness/useExhaustiveDependencies: False positive
useEffect(() => {
if (!txnHash) {
// Only reset these when txnHash is cleared
if (originCallStatus?.txnHash) {
setOriginCallStatus(null)
}
setOriginBlockTimestamp(null)
if (Object.keys(sentMetaTxns).length > 0) {
setSentMetaTxns({})
}
return
}
if (
originCallStatus?.txnHash === txnHash &&
(originCallStatus?.status === "Success" ||
originCallStatus?.status === "Failed") &&
!isWaitingForReceipt
) {
return
}
if (isWaitingForReceipt) {
setOriginCallStatus((prevStatus) => ({
...(prevStatus?.txnHash === txnHash
? prevStatus
: {
gasUsed: undefined,
effectiveGasPrice: undefined,
revertReason: undefined,
}),
txnHash,
status: "Pending",
}))
return
}
if (receiptIsSuccess && receipt) {
const newStatus = receipt.status === "success" ? "Success" : "Failed"
setOriginCallStatus({
txnHash: receipt.transactionHash,
status: newStatus,
gasUsed: receipt.gasUsed ? Number(receipt.gasUsed) : undefined,
effectiveGasPrice: receipt.effectiveGasPrice?.toString(),
revertReason:
receipt.status === "reverted"
? ((receiptError as any)?.message as string | undefined) ||
"Transaction reverted by receipt"
: undefined,
})
if (newStatus === "Success" && receipt.blockNumber) {
const fetchTimestamp = async () => {
try {
if (!originCallParams?.chainId) {
logger.console.error(
"[Trails] Origin chainId not available for fetching origin block timestamp.",
)
setOriginBlockTimestamp(null)
return
}
const chainConfig = getChainInfo(originCallParams.chainId)!
const client = createPublicClient({
chain: chainConfig,
transport: http(),
})
const block = await client.getBlock({
blockNumber: BigInt(receipt.blockNumber),
})
setOriginBlockTimestamp(Number(block.timestamp))
} catch (error) {
logger.console.error(
"[Trails] Error fetching origin block timestamp:",
error,
)
setOriginBlockTimestamp(null)
}
}
fetchTimestamp()
} else if (newStatus !== "Success") {
setOriginBlockTimestamp(null)
}
if (
newStatus === "Success" &&
metaTxns &&
metaTxns.length > 0 &&
isAutoExecute &&
!metaTxns.some((tx: MetaTxn) => sentMetaTxns[`${tx.chainId}-${tx.id}`])
) {
logger.console.log(
"Origin transaction successful, auto-sending all meta transactions...",
)
sendMetaTxnMutation.mutate({ selectedId: null })
}
} else if (receiptIsError) {
setOriginCallStatus({
txnHash,
status: "Failed",
revertReason:
((receiptError as any)?.message as string | undefined) ||
"Failed to get receipt",
gasUsed: undefined,
effectiveGasPrice: undefined,
})
setOriginBlockTimestamp(null)
}
}, [
txnHash,
isWaitingForReceipt,
receiptIsSuccess,
receiptIsError,
receipt,
receiptError,
metaTxns,
sentMetaTxns,
isAutoExecute,
originCallParams?.chainId,
originCallStatus?.status,
originCallStatus?.txnHash,
])
// Modify the auto-execute effect
useEffect(() => {
const shouldAutoSend =
isAutoExecute &&
commitIntentConfigMutation.isSuccess &&
originCallParams?.chainId &&
account.chainId === originCallParams.chainId &&
!originCallParams.error &&
originCallParams.to &&
originCallParams.data !== null &&
originCallParams.value !== null &&
!sendOriginTxn.isPending &&
!isWaitingForReceipt &&
!txnHash &&
!isChainSwitchRequired &&
!originCallStatus &&
!hasAutoExecuted
if (shouldAutoSend) {
logger.console.log("Auto-executing transaction: All conditions met.")
setHasAutoExecuted(true)
// Set initial status
setOriginCallStatus({
status: "Sending...",
})
sendOriginTxn.sendTransaction(
{
to: originCallParams.to!,
data: originCallParams.data!,
value: originCallParams.value!,
chainId: originCallParams.chainId!,
},
{
onSuccess: (hash: Hex) => {
logger.console.log("Auto-executed transaction sent, hash:", hash)
setTxnHash(hash)
},
onError: (error: unknown) => {
logger.console.error("Auto-executed transaction failed:", error)
if (
error instanceof Error &&
(error.message.includes("User rejected") ||
error.message.includes("user rejected"))
) {
setIsAutoExecute(false)
}
setOriginCallStatus({
status: "Failed",
revertReason:
error instanceof Error ? error.message : "Unknown error",
})
setHasAutoExecuted(false)
},
},
)
}
}, [
isAutoExecute,
commitIntentConfigMutation.isSuccess,
originCallParams,
account.chainId,
sendOriginTxn.isPending,
isWaitingForReceipt,
txnHash,
isChainSwitchRequired,
originCallStatus,
hasAutoExecuted,
sendOriginTxn,
])
// Effect to auto-commit when intent calls payloads are ready
useEffect(() => {
if (
isAutoExecute &&
intentCallsPayloads &&
intentPreconditions &&
trailsFee &&
account.address &&
originIntentAddress &&
!commitIntentConfigMutation.isPending &&
!commitIntentConfigMutation.isSuccess
) {
logger.console.log("Auto-committing intent configuration...")
commitIntentConfigMutation.mutate({
mainSignerAddress: account.address,
calls: intentCallsPayloads,
preconditions: intentPreconditions,
quoteProvider: trailsFee.quoteProvider as QuoteProvider,
addressOverrides: createIntentMutation.variables?.addressOverrides,
})
}
}, [
isAutoExecute,
intentCallsPayloads,
intentPreconditions,
trailsFee,
account.address,
originIntentAddress,
commitIntentConfigMutation,
createIntentMutation.variables,
])
// Update the sendMetaTxn mutation
const sendMetaTxnMutation = useMutation({
mutationFn: async ({ selectedId }: { selectedId: string | null }) => {
logger.console.log("[trails-sdk] Starting sendMetaTxn mutation", {
selectedId,
hasIntentCallsPayloads: !!intentCallsPayloads,
hasIntentPreconditions: !!intentPreconditions,
hasMetaTxns: !!metaTxns,
hasAccountAddress: !!account.address,
quoteProvider: trailsFee?.quoteProvider,
})
if (
!intentCallsPayloads ||
!intentPreconditions ||
!metaTxns ||
!account.address
) {
throw new Error("Missing required data for meta-transaction")
}
if (!trailsFee?.quoteProvider) {
throw new Error("quoteProvider is required")
}
const intentAddress = calculateIntentAddress(
account.address,
intentCallsPayloads as any[],
)
// If no specific ID is selected, send all meta transactions
const txnsToSend = selectedId
? [metaTxns.find((tx: MetaTxn) => tx.id === selectedId)]
: metaTxns
logger.console.log("[trails-sdk] Selected transactions to send", {
selectedId,
totalMetaTxns: metaTxns.length,
txnsToSendCount: txnsToSend.filter((tx) => tx).length,
txnsToSend: txnsToSend
.filter((tx) => tx)
.map((tx) => ({
id: tx?.id,
chainId: tx?.chainId,
walletAddress: tx?.walletAddress,
contract: tx?.contract,
})),
})
if (!txnsToSend || (selectedId && !txnsToSend[0])) {
throw new Error("Meta transaction not found")
}
const results = []
for (const metaTxn of txnsToSend) {
if (!metaTxn) continue
const operationKey = `${metaTxn.chainId}-${metaTxn.id}`
const lastSentTime = sentMetaTxns[operationKey]
const now = Date.now()
logger.console.log(
`[trails-sdk] Processing meta transaction ${operationKey}`,
{
metaTxnId: metaTxn.id,
chainId: metaTxn.chainId,
walletAddress: metaTxn.walletAddress,
contract: metaTxn.contract,
intentAddress,
},
)
if (lastSentTime && now - lastSentTime < RETRY_WINDOW_MS) {
const timeLeft = Math.ceil(
(RETRY_WINDOW_MS - (now - lastSentTime)) / 1000,
)
logger.console.log(
`[trails-sdk] Meta transaction for ${operationKey} was sent recently. Wait ${timeLeft}s before retry`,
{
lastSentTime,
now,
timeLeft,
},
)
continue
}
try {
const chainId = parseInt(metaTxn.chainId, 10)
if (Number.isNaN(chainId) || chainId <= 0) {
logger.console.error(
`[trails-sdk] Invalid chainId for meta transaction`,
{
chainId,
operationKey,
metaTxn,
},
)
throw new Error(`Invalid chainId for meta transaction: ${chainId}`)
}
const chainRelayer = getRelayer(chainId)
if (!chainRelayer) {
logger.console.error(`[trails-sdk] No relayer found for chainId`, {
chainId,
operationKey,
availableRelayers: Array.from(relayers.keys()),
})
throw new Error(`No relayer found for chainId: ${chainId}`)
}
const relevantPreconditions = intentPreconditions.filter(
(p: IntentPrecondition) =>
p.chainId && parseInt(p.chainId, 10) === chainId,
)
logger.console.log(
`[trails-sdk] Relaying meta transaction ${operationKey} to intent ${intentAddress} via relayer`,
{
chainId,
operationKey,
intentAddress,
relayer: !!chainRelayer,
preconditionsCount: relevantPreconditions.length,
},
)
const { opHash } = await chainRelayer.sendMetaTxn(
metaTxn.walletAddress as Address.Address,
metaTxn.contract as Address.Address,
metaTxn.input as Hex,
Number(metaTxn.chainId),
undefined,
relevantPreconditions,
)
logger.console.log(
`[trails-sdk] Successfully sent meta transaction ${operationKey}`,
{
operationKey,
opHash,
chainId,
},
)
const useBackupRelayer = false // Disable backup relayer for now
if (useBackupRelayer) {
try {
// Fire and forget send tx to backup relayer
const backupRelayer = getBackupRelayer(chainId)
backupRelayer
?.sendMetaTxn(
metaTxn.walletAddress as Address.Address,
metaTxn.contract as Address.Address,
metaTxn.input as Hex,
Number(metaTxn.chainId),
undefined,
relevantPreconditions,
)
.then(() => {
logger.console.log(
`[trails-sdk] Backup relayer sent for ${operationKey}`,
)
})
.catch((backupError) => {
logger.console.warn(
`[trails-sdk] Backup relayer failed for ${operationKey}`,
backupError,
)
})
} catch {}
}
results.push({
operationKey,
opHash,
success: true,
})
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error"
logger.console.error(
`[trails-sdk] Failed to send meta transaction ${operationKey}`,
{
operationKey,
error: errorMessage,
chainId: metaTxn.chainId,
metaTxnId: metaTxn.id,
walletAddress: metaTxn.walletAddress,
contract: metaTxn.contract,
},
)
results.push({
operationKey,
error: errorMessage,
success: false,
})
}
}
return results
},
onSuccess: (results) => {
logger.console.log(
"[trails-sdk] sendMetaTxn mutation completed successfully",
{
totalResults: results.length,
successfulResults: results.filter((r) => r.success).length,
failedResults: results.filter((r) => !r.success).length,
results: results.map((r) => ({
operationKey: r.operationKey,
success: r.success,
opHash: r.opHash || null,
error: r.error || null,
})),
},
)
// Update states based on results
results.forEach(({ operationKey, opHash, success }) => {
if (success && opHash) {
setSentMetaTxns((prev) => ({
...prev,
[operationKey]: Date.now(),
}))
setOperationHashes((prev) => ({
...prev,
[operationKey]: opHash,
}))
} else {
logger.console.warn(
`[trails-sdk] Not updating state for failed meta transaction ${operationKey}`,
{
operationKey,
error: results.find((r) => r.operationKey === operationKey)
?.error,
},
)
}
})
},
onError: (error) => {
logger.console.error("[trails-sdk] Error in meta-transaction process", {
error: error instanceof Error ? error.message : String(error),
errorType:
error instanceof Error ? error.constructor.name : typeof error,
stack: error instanceof Error ? error.stack : undefined,
})
},
retry: 5, // Allow up to 2 retries
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
})
const [tokenAddress, setTokenAddress] = useState<string | null>(null)
const [originChainId, setOriginChainId] = useState<number | null>(null)
useEffect(() => {
if (
!originIntentAddress ||
!intentCallsPayloads?.[0]?.chainId ||
!tokenAddress ||
!originChainId ||
!intentPreconditions ||
!account.address
) {
setOriginCallParams(null)
return
}
try {
const intentAddressString = originIntentAddress as Address.Address
if (!intentAddressString || !isAddress(intentAddressString)) {
setOriginCallParams(null)
return
}
let calcTo: Address.Address
let calcData: Hex = "0x"
let calcValue: bigint = 0n
const recipientAddress = intentAddressString
const isNative = tokenAddress === zeroAddress
if (isNative) {
const nativePrecondition = intentPreconditions.find(
(p: IntentPrecondition) =>
(p.type === "transfer-native" || p.type === "native-balance") &&
p.chainId === originChainId.toString(),
)
const nativeMinAmount =
nativePrecondition?.data?.minAmount?.toString() ??
nativePrecondition?.data?.min?.toString()
if (nativeMinAmount === undefined) {
throw new Error(
"Could not find native precondition (transfer-native or native-balance) or min amount",
)
}
calcValue = BigInt(nativeMinAmount)
calcTo = recipientAddress
} else {
const erc20Precondition = intentPreconditions.find(
(p: IntentPrecondition) =>
p.type === "erc20-balance" &&
p.chainId === originChainId.toString() &&
p.data?.token &&
isAddressEqual(
Address.from(p.data.token),
Address.from(tokenAddress),
),
)
const erc20MinAmount = erc20Precondition?.data?.min?.toString()
if (erc20MinAmount === undefined) {
throw new Error(
"Could not find ERC20 balance precondition or min amount",
)
}
calcData = getERC20TransferData({
recipient: recipientAddress,
amount: BigInt(erc20MinAmount),
})
calcTo = tokenAddress as Address.Address
}
setOriginCallParams({
to: calcTo,
data: calcData,
value: calcValue,
chainId: originChainId,
error: undefined,
})
} catch (error: unknown) {
logger.console.error(
"[trails-sdk] Failed to calculate origin call params for UI:",
error,
)
setOriginCallParams({
to: null,
data: null,
value: null,
chainId: null,
error: error instanceof Error ? error.message : "Unknown error",
})
}
}, [
intentCallsPayloads,
tokenAddress,
originChainId,
intentPreconditions,
account.address,
originIntentAddress,
])
// const checkPreconditionStatuses = useCallback(async () => {
// if (!intentPreconditions) return
// const statuses = await Promise.all(
// intentPreconditions.map(async (precondition) => {
// try {
// const chainIdString = precondition.chainId
// if (!chainIdString) {
// console.warn('[trails-sdk] Precondition missing chainId:', precondition)
// return false
// }
// const chainId = parseInt(chainIdString)
// if (isNaN(chainId) || chainId <= 0) {
// console.warn('[trails-sdk] Precondition has invalid chainId:', chainIdString, precondition)
// return false
// }
// const chainRelayer = getRelayer(chainId)
// if (!chainRelayer) {
// console.error(`[trails-sdk] No relayer found for chainId: ${chainId}`)
// return false
// }
// return await chainRelayer.checkPrecondition(precondition)
// } catch (error) {
// console.error('[trails-sdk] Error checking precondition:', error, 'Precondition:', precondition)
// return false
// }
// }),
// )
// setPreconditionStatuses(statuses)
// }, [intentPreconditions, getRelayer])
// useEffect(() => {
// // TODO: Remove this once we have a way to check precondition statuses
// if (false) {
// checkPreconditionStatuses()
// }
// }, [intentPreconditions, checkPreconditionStatuses])
// Add monitoring for each meta transaction
const metaTxnMonitorStatuses = useMetaTxnsMonitor(
metaTxns as unknown as MetaTxn[] | undefined,
getRelayer,
)
// Create a stable dependency for the meta timestamp effect
const _stableMetaTxnStatusesKey = useMemo(() => {
if (!metaTxns || Object.keys(metaTxnMonitorStatuses).length === 0) {
return "no_statuses"
}
// Sort by a stable key (e.g., id) to ensure consistent order if metaTxns array order changes
// but content is the same, though metaTxns itself is a dependency, so this might be redundant if metaTxns order is stable.
const sortedTxnIds = metaTxns
.map((tx: MetaTxn) => `${tx.chainId}-${tx.id}`)
.sort()
return sortedTxnIds
.map((key: string) => {
const statusObj = metaTxnMonitorStatuses[key]
return `${key}:${statusObj ? statusObj.status : "loading"}`
})
.join(",")
}, [metaTxns, metaTxnMonitorStatuses])
const processedTxns = useRef(new Set<string>())
const lastQueuedCctpSourceTxHash = useRef<string | null>(null)
// Effect to fetch meta-transaction block timestamps
useEffect(() => {
logger.console.log(
"[trails-sdk] Running meta-transaction block timestamp effect:",
{
metaTxnsLength: metaTxns?.length,
monitorStatusesLength: Object.keys(metaTxnMonitorStatuses).length,
},
)
if (!metaTxns || metaTxns.length === 0) {
logger.console.log(
"[trails-sdk] No meta transactions, clearing timestamps",
)
processedTxns.current.clear()
if (Object.keys(metaTxnBlockTimestamps).length > 0) {
setMetaTxnBlockTimestamps({})
}
return
}
if (!Object.keys(metaTxnMonitorStatuses).length) {
logger.console.log("[trails-sdk] No monitor statuses yet, waiting...")
return
}
metaTxns.forEach(async (metaTxn: MetaTxn, index: number) => {
const operationKey = `${metaTxn.chainId}-${metaTxn.id}`
// Skip if already processed
if (processedTxns.current.has(operationKey)) {
logger.console.log(
`[trails-sdk] MetaTxn ${operationKey}: Already processed, skipping`,
)
return
}
const monitorStatus = metaTxnMonitorStatuses[operationKey]
if (!monitorStatus || monitorStatus.status !== "confirmed") {
logger.console.log(
`[trails-sdk] MetaTxn ${operationKey}: Status not confirmed, skipping`,
)
return
}
// Type assertion since we know it exists when status is "confirmed"
const transactionHash = monitorStatus.transactionHash as Hex | undefined
if (!transactionHash) {
logger.console.log(
`[trails-sdk] MetaTxn ${operationKey}: No transaction hash, skipping`,
)
return
}
logger.console.log(
`[trails-sdk] MetaTxn ${operationKey}: Processing transaction ${transactionHash}`,
)
processedTxns.current.add(operationKey)
try {
const chainId = parseInt(metaTxn.chainId, 10)
if (Number.isNaN(chainId) || chainId <= 0) {
throw new Error(
`Invalid chainId for meta transaction: ${metaTxn.chainId}`,
)
}
const chainConfig = getChainInfo(chainId)!
const client = createPublicClient({
chain: chainConfig,
transport: http(),
})
const receipt = await client.getTransactionReceipt({
hash: transactionHash,
})
if (receipt && typeof receipt.blockNumber === "bigint") {
const block = await client.getBlock({
blockNumber: receipt.blockNumber,
})
logger.console.log(
`[trails-sdk] MetaTxn ${operationKey}: Got block timestamp ${block.timestamp}`,
)
setMetaTxnBlockTime