UNPKG

0xtrails

Version:

SDK for Trails

597 lines (521 loc) 16.8 kB
import { Account } from "@0xsequence/account" import { commons } from "@0xsequence/core" import { allNetworks } from "@0xsequence/network" import { trackers } from "@0xsequence/sessions" import { Orchestrator, type signers } from "@0xsequence/signhub" import type { Payload } from "@0xsequence/wallet-primitives" import { ethers } from "ethers" import { Abi, AbiFunction } from "ox" import { bytesToHex, type Chain, hexToBytes, type PublicClient, toHex, type WalletClient, } from "viem" import type { Relayer, RelayerEnvConfig, RelayerEnv } from "./relayer.js" import { getRelayerUrl } from "./relayer.js" import { getSequenceProjectAccessKey, getSequenceEnv } from "./config.js" import { logger } from "./logger.js" export type FlatTransaction = { to: string value?: string data?: string gasLimit?: string delegateCall?: boolean revertOnError?: boolean } export type TransactionsEntry = { subdigest?: string wallet: string space: string nonce: string chainId: string transactions: FlatTransaction[] } export const TRACKER = new trackers.remote.RemoteConfigTracker( "https://sessions.sequence.app", ) type GetAccountNetworksInput = { relayerConfig: RelayerEnvConfig sequenceProjectAccessKey: string } export function getAccountNetworks(input: GetAccountNetworksInput): any[] { return allNetworks.map((network) => { try { const relayerUrl = getRelayerUrl(input.relayerConfig, network.chainId) if (relayerUrl) { const relayer: any = { provider: new ethers.JsonRpcProvider(network.rpcUrl), url: relayerUrl, projectAccessKey: input.sequenceProjectAccessKey, } return { ...network, relayer, } } } catch (_err: unknown) { // noop } return network }) } export async function createSequenceWallet( threshold: number, signers: { address: string; weight: number }[], relayerConfig: RelayerEnvConfig, sequenceProjectAccessKey: string, ): Promise<Account> { const account = await Account.new({ config: { threshold, // By default a random checkpoint is generated every second checkpoint: Math.floor(Date.now() / 1000), signers: signers, }, tracker: TRACKER, contexts: commons.context.defaultContexts, orchestrator: new Orchestrator([]), networks: getAccountNetworks({ relayerConfig, sequenceProjectAccessKey }), }) // Try to fetch the config from the tracker const reverse1 = await TRACKER.imageHashOfCounterfactualWallet({ wallet: account.address, }) if (!reverse1) { throw new Error("Failed to fetch imageHash from the tracker") } // Try to fetch the imageHash from the tracker const reverse2 = await TRACKER.configOfImageHash({ imageHash: reverse1.imageHash, }) if (!reverse2) { throw new Error("Failed to fetch config from the tracker") } return account } export function toSequenceTransactions( txs: FlatTransaction[], ): commons.transaction.Transaction[] { return txs.map(toSequenceTransaction) } export function toSequenceTransaction( tx: FlatTransaction, ): commons.transaction.Transaction { return { to: tx.to, value: tx.value ? BigInt(tx.value) : 0n, data: tx.data, gasLimit: tx.gasLimit ? BigInt(tx.gasLimit) : 100000n, delegateCall: tx.delegateCall || false, revertOnError: tx.revertOnError || false, } } export function accountFor(args: { address: string signatures?: { signer: string; signature: string }[] relayerConfig: RelayerEnvConfig sequenceProjectAccessKey: string }): Account { const signers: signers.SapientSigner[] = [] if (args.signatures) { for (const { signer, signature } of args.signatures) { // Some ECDSA libraries may return the signature with `v` as 0x00 or 0x01 // but the Sequence protocol expects it to be 0x1b or 0x1c. We need to // adjust the signature to match the protocol. const signatureArr = hexToBytes(signature as `0x${string}`) if ( signatureArr.length === 66 && (signatureArr[64] === 0 || signatureArr[64] === 1) ) { signatureArr[64] = signatureArr[64] + 27 } signers.push(new StaticSigner(signer, bytesToHex(signatureArr))) } } logger.console.log("[trails-sdk] signers", signers) return new Account({ address: args.address, tracker: TRACKER, contexts: commons.context.defaultContexts, orchestrator: new Orchestrator(signers), networks: getAccountNetworks({ relayerConfig: args.relayerConfig, sequenceProjectAccessKey: args.sequenceProjectAccessKey, }), }) } export function digestOf(tx: TransactionsEntry): string { return commons.transaction.digestOfTransactions( commons.transaction.encodeNonce(tx.space, tx.nonce), toSequenceTransactions(tx.transactions), ) } export function subdigestOf(tx: TransactionsEntry): string { const digest = digestOf(tx) return commons.signature.subdigestOf({ digest, chainId: tx.chainId, address: tx.wallet, }) } export function fromSequenceTransactions( wallet: string, txs: commons.transaction.Transactionish, ): FlatTransaction[] { const sequenceTxs = commons.transaction.fromTransactionish(wallet, txs) return sequenceTxs.map((stx: any) => ({ to: stx.to, value: stx.value?.toString(), data: stx.data?.toString(), gasLimit: stx.gasLimit?.toString(), delegateCall: stx.delegateCall, revertOnError: stx.revertOnError, })) } export function recoverSigner( signatures: string[], subdigest: string, ): { signer: string; signature: string }[] { const res: { signer: string; signature: string }[] = [] for (const signature of signatures) { try { const r = commons.signer.recoverSigner(subdigest, signature) res.push({ signer: r, signature: signature }) } catch (e) { logger.console.error("[trails-sdk] Failed to recover signature", e) } } return res } export async function simpleCreateSequenceWallet( account: Account, relayerConfig: RelayerEnvConfig = { env: getSequenceEnv() as RelayerEnv, }, sequenceProjectAccessKey: string = getSequenceProjectAccessKey(), ): Promise<`0x${string}`> { const signer = account.address const threshold = 1 const weight = 1 const wallet = await createSequenceWallet( threshold, [{ address: signer, weight: weight }], relayerConfig, sequenceProjectAccessKey, ) return wallet.address as `0x${string}` } export async function getIsWalletDeployed( wallet: `0x${string}`, publicClient: PublicClient, ): Promise<boolean> { const hasCode = await publicClient?.getCode({ address: wallet as `0x${string}`, }) const isDeployed = hasCode !== undefined && hasCode !== "0x" return isDeployed } export async function waitForWalletDeployment( wallet: `0x${string}`, publicClient: PublicClient, ): Promise<void> { while (true) { const isDeployed = await getIsWalletDeployed(wallet, publicClient) if (isDeployed) { break } logger.console.log("[trails-sdk] waiting for wallet deployment") await new Promise((resolve) => setTimeout(resolve, 500)) } } export async function sequenceSendTransaction( sequenceWalletAddress: string, accountClient: WalletClient, publicClient: PublicClient, calls: any[], chain: Chain, relayerConfig: RelayerEnvConfig = { env: getSequenceEnv() as RelayerEnv, }, sequenceProjectAccessKey: string = getSequenceProjectAccessKey(), ): Promise<string | null> { const chainId = chain.id if (!accountClient?.account?.address || !sequenceWalletAddress) { throw new Error("Signer or sequence wallet address not available") } const txsToExecute = calls.map((call: any) => { return { to: call.to, data: call.data, value: call.value || "0", revertOnError: true, } }) const txe: TransactionsEntry = { wallet: sequenceWalletAddress as `0x${string}`, space: Math.floor(Date.now()).toString(), nonce: "0", chainId: chainId.toString(), transactions: txsToExecute, } // Calculate the tx subdigest const subdigest = subdigestOf(txe) const digestBytes = hexToBytes(subdigest as `0x${string}`) // Sign the tx subdigest const signature = await accountClient.signMessage({ account: accountClient.account, message: { raw: digestBytes }, }) const suffixed = `${signature}02` // Get the account for the Sequence Wallet with signatures const sequenceAccount = accountFor({ address: sequenceWalletAddress as `0x${string}`, signatures: [ { signer: accountClient.account?.address as `0x${string}`, signature: suffixed, }, ], relayerConfig, sequenceProjectAccessKey, }) const sequenceTxs = toSequenceTransactions(txsToExecute) const status = await sequenceAccount.status(chainId) const wallet = sequenceAccount.walletForStatus(chainId, status) logger.console.log("[trails-sdk] sequence wallet", wallet) const isDeployed = await getIsWalletDeployed( wallet.address as `0x${string}`, publicClient, ) if (!isDeployed) { logger.console.log("[trails-sdk] deploying sequence wallet") const deployTx = await wallet.buildDeployTransaction() if (!wallet.relayer) throw new Error("Wallet deploy requires a relayer") logger.console.log("[trails-sdk] deploy Tx", deployTx) logger.console.log( "[trails-sdk] deployTx entrypoint:", deployTx!.entrypoint, ) logger.console.log( "[trails-sdk] deployTx transactions:", deployTx!.transactions, ) logger.console.log("[trails-sdk] getting fee options 0") const feeOptions = await wallet.relayer.getFeeOptions( wallet.address as `0x${string}`, ...deployTx!.transactions, ) const quote = feeOptions?.quote logger.console.log("[trails-sdk] feeOptions", feeOptions) // Check if deployment is whitelisted (no fees required) if (feeOptions?.options.length === 0) { logger.console.log( "[trails-sdk] Deployment is whitelisted - no fees required", ) const bytes = new Uint8Array(32) crypto.getRandomValues(bytes) wallet.relayer.relay( { entrypoint: deployTx!.entrypoint as `0x${string}`, transactions: deployTx!.transactions, chainId: wallet.chainId, intent: { id: toHex(bytes), wallet: wallet.address, }, }, quote, ) logger.console.log("[trails-sdk] Deployment relayed") await waitForWalletDeployment( wallet.address as `0x${string}`, publicClient, ) logger.console.log("[trails-sdk] sequence wallet deployed") } else { const option = feeOptions?.options[0] if (!option) { throw new Error("fee option not found") } logger.console.log("[trails-sdk] option", option) if (option) { logger.console.log("[trails-sdk] Using native token for deployment fee") // Use encodeGasRefundTransaction to create the fee transaction const feeTransactions = encodeGasRefundTransaction(option) logger.console.log("[trails-sdk] Fee transactions:", feeTransactions) // Include both deployment and fee transactions const allTransactions = [...deployTx!.transactions] logger.console.log( "[trails-sdk] All transactions (deployment + fees):", allTransactions, ) const predecorated = await sequenceAccount.predecorateTransactions( allTransactions, status, chainId, ) const signed = await sequenceAccount.signTransactions( predecorated, chainId, ) logger.console.log( "[trails-sdk] signed transactions with fees:", signed.transactions, ) logger.console.log( "[trails-sdk] signed entrypoint with fees:", signed.entrypoint, ) const bytes = new Uint8Array(32) crypto.getRandomValues(bytes) wallet.relayer.relay( { entrypoint: deployTx!.entrypoint as `0x${string}`, transactions: deployTx!.transactions, chainId: wallet.chainId, intent: { id: toHex(bytes), wallet: wallet.address, }, }, quote, ) logger.console.log("[trails-sdk] relayed deployment") await waitForWalletDeployment( wallet.address as `0x${string}`, publicClient, ) logger.console.log("[trails-sdk] sequence wallet deployed") } else { throw new Error( "ERC20 fee payment for deployment is not supported yet. Please use native token or a relayer with free wallet deployments.", ) } } } logger.console.log("[trails-sdk] sequenceTxs", sequenceTxs) logger.console.log("[trails-sdk] getting fee options 1") const feeOptions = await wallet.relayer!.getFeeOptions( wallet.address as `0x${string}`, ...sequenceTxs, ) logger.console.log("[trails-sdk] feeOptions 1", feeOptions) // Find the USDC option for fee payment const option = feeOptions?.options.find( (option) => option.token.symbol === "USDC", ) const quote = feeOptions?.quote // Use encodeGasRefundTransaction to create the fee transaction const feeTransactions = encodeGasRefundTransaction(option) logger.console.log("[trails-sdk] Fee transactions:", feeTransactions) // Sign the txs with the Sequence Wallet const signed = await wallet.signTransactions( [...feeTransactions, ...sequenceTxs], commons.transaction.encodeNonce(txe.space, txe.nonce), ) // Relay the txs to sponsor them const relayer = sequenceAccount.relayer(chainId) const relayed = await relayer.relay(signed, quote) return relayed?.hash || null } type TransactionBundle = commons.transaction.TransactionBundle type SignedTransactionBundle = commons.transaction.SignedTransactionBundle type IntendedTransactionBundle = commons.transaction.IntendedTransactionBundle type BytesLike = `0x${string}` | Uint8Array export class StaticSigner implements signers.SapientSigner { private readonly signatureBytes: Uint8Array private readonly savedSuffix: Uint8Array constructor( private readonly address: string, private readonly signature: string, ) { const raw = hexToBytes(this.signature as `0x${string}`) // Separate last byte as suffix this.savedSuffix = raw.slice(-1) this.signatureBytes = raw.slice(0, -1) } async buildDeployTransaction(): Promise<TransactionBundle | undefined> { return undefined } async predecorateSignedTransactions(): Promise<SignedTransactionBundle[]> { return [] } async decorateTransactions( og: IntendedTransactionBundle, ): Promise<IntendedTransactionBundle> { return og } async sign(): Promise<BytesLike> { return this.signatureBytes } notifyStatusChange(): void {} suffix(): BytesLike { return this.savedSuffix } async getAddress() { return this.address } } export async function getFeeOptions( relayer: Relayer.Standard.Rpc.RpcRelayer, wallet: string, chainId: number, calls: Payload.Call[], ) { const feeOptions = await relayer.feeOptions( wallet as `0x${string}`, chainId, calls, ) return feeOptions } // Import the encodeGasRefundTransaction function function encodeGasRefundTransaction(option?: any): FlatTransaction[] { if (!option) return [] const value = BigInt(option.value) switch (option.token.type) { case "UNKNOWN": return [ { delegateCall: false, revertOnError: true, gasLimit: option.gasLimit, to: option.to, value: value.toString(), data: "0x", }, ] case "ERC20_TOKEN": { if (!option.token.contractAddress) { throw new Error(`No contract address for ERC-20 fee option`) } const [transfer] = Abi.from([ { inputs: [{ type: "address" }, { type: "uint256" }], name: "transfer", outputs: [{ type: "bool" }], stateMutability: "nonpayable", type: "function", }, ]) return [ { delegateCall: false, revertOnError: true, gasLimit: option.gasLimit, to: option.token.contractAddress, value: "0", data: AbiFunction.encodeData(transfer, [ option.to as `0x${string}`, value, ]), }, ] } default: throw new Error(`Unhandled fee token type ${option.token.type}`) } }