0xtrails
Version:
SDK for Trails
779 lines (693 loc) • 23.7 kB
text/typescript
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]!
}