import { calculateSafeTransactionHash } from "@safe-global/protocol-kit/dist/src/utils" import { Address, createPublicClient, createWalletClient, encodeAbiParameters, encodeFunctionData, Hash, Hex, http, parseAbiParameters, PublicClient, SendTransactionReturnType, zeroAddress, } from "viem" import { privateKeyToAccount } from "viem/accounts" import { getEthereumContracts } from "./ethereum" import { OrangeKitContracts } from "./contracts" import { BitcoinAddressHelper } from "./bitcoin" import MezoTransactionSender from "./utils/MezoTransactionSender" import normalizeSafeSignature from "./utils/normalizeSafeSignature" import { TransactionSender, type TransactionSenderRequest, } from "./utils/TransactionSender" import { getChainById, mezoTestnet, mezoMainnet } from "./utils/chains" import predictOrangeKitAddress from "./utils/predictOrangeKitAddress" import { SafeDeploymentTransaction } from "./contracts/safe-factory" export * from "./bitcoin" const SAFE_VERSION = "1.4.1" type EnsureContractResult = { address: string transactionHash?: string } /** * Represents the Safe transaction data payload for calculating the hash used * as a message to sign with Bitcoin wallet. */ export type SafeTransactionData = { /** * Destination address of Safe transaction. */ to: string /** * The amount of ether (in wei) of Safe transaction. */ value: string /** * Data payload of Safe transaction. */ data: string /** * Operation type of Safe transaction. */ operation: number /** * Gas that should be used for the Safe transaction. */ safeTxGas: string /** * Gas costs for that are independent of the transaction execution(e.g. base * transaction fee, signature check, payment of the refund) */ baseGas: string /** * Gas price that should be used for the payment calculation. */ gasPrice: string /** * Token address (or 0 if ETH) that is used for the payment. */ gasToken: string /** * Address of receiver of gas payment (or 0 if tx.origin). */ refundReceiver: string /** * Transaction nonce. */ nonce: number } /** * Function for signing a Bitcoin transaction message. The message is a hash of * the Safe transaction with details specified by the `transactionData`. * Handling the `transactionData` is optional and can be used to provide the * clear signing process, depending on the wallet implementation. * * @param message - Message to sign. * @param transactionData - Safe transaction data serves as the payload for the * message to sign calculation. * * @returns A promise that resolves to a string representing the signed message. */ export type BitcoinSignSafeTransactionMessageFn = ( message: string, transactionData: SafeTransactionData, ) => Promise // eslint-disable-next-line import/prefer-default-export export class OrangeKitSmartAccount { static async init( chainId: number, rpcUrl: string, transactionSender: TransactionSender, ) { const client = createPublicClient({ chain: getChainById(chainId), transport: http(rpcUrl), }) const contracts = getEthereumContracts(client, chainId) return Promise.resolve( new OrangeKitSmartAccount(client, chainId, contracts, transactionSender), ) } #chainId: number #contracts: OrangeKitContracts #client: PublicClient #transactionSender: TransactionSender constructor( client: PublicClient, _chainId: number, contracts: OrangeKitContracts, transactionSender: TransactionSender, ) { // TODO: chain validation this.#chainId = _chainId // TODO: initialize contracts this.#contracts = contracts this.#client = client this.#transactionSender = transactionSender } get chainId(): number { return this.#chainId } /** * Predicts the Ethereum address of a safe for a given Bitcoin address. * @param {string} bitcoinAddress - The Bitcoin address to predict the safe for. * @param {string} publicKey - The public key associated with the Bitcoin address. * @returns {Promise} A promise that resolves to the predicted Ethereum address of the safe. */ // eslint-disable-next-line class-methods-use-this async predictAddress( bitcoinAddress: string, publicKey?: string, ): Promise
{ const predictedAddress = predictOrangeKitAddress(bitcoinAddress, publicKey) return predictedAddress } private async checkIfContractExists(address: Address): Promise { const existingCode = await this.#client.getCode({ address }) return existingCode !== undefined } async checkIfSafeExists( bitcoinAddress: string, publicKey?: string, ): Promise { const ethereumAddress = await this.predictAddress(bitcoinAddress, publicKey) return this.checkIfContractExists(ethereumAddress) } /** * Populates a transaction to deploy a safe for a given Bitcoin address. * @param {string} bitcoinAddress - The Bitcoin address to deploy the safe for. * @param {string} [publicKey] - The public key associated with the Bitcoin address. * @returns {Promise} A promise that resolves to the populated transaction. */ async populateSafeDeploymentTransaction( bitcoinAddress: string, publicKey?: string, ): Promise { const truncatedBitcoinAddress = BitcoinAddressHelper.recoverTruncatedBitcoinAddress( bitcoinAddress, publicKey, ) as Hex return this.#contracts.safeFactory.populateSafeDeploymentTransaction( truncatedBitcoinAddress, ) } /* * Deploys a safe corresponding to the given bitcoinAddress. Note that this function is currently only used for * demonstrating integration testing capabilities. */ async deploySafe( bitcoinAddress: string, relayerPrivateKey: Hex, rpcUrl: string, ): Promise { // TODO: This will fail for the nested segwit case, we need to extract and // pass the public key as well. const unsignedTx = await this.populateSafeDeploymentTransaction(bitcoinAddress) const account = privateKeyToAccount(relayerPrivateKey) const transport = http(rpcUrl) const walletClient = createWalletClient({ account, transport, }) const truncatedBitcoinAddress = BitcoinAddressHelper.recoverTruncatedBitcoinAddress(bitcoinAddress) // Preflight check to prevent burning gas on failed transactions. const safeDeploymentSimulationResult = await this.#contracts.safeFactory.simulateSafeDeploymentTransaction( truncatedBitcoinAddress, account.address, ) if (!safeDeploymentSimulationResult) { throw new Error("Deployment simulation failed") } return walletClient.sendTransaction({ account, chain: getChainById(this.#chainId), ...unsignedTx, }) } // Check if a contract is already deployed at the given address and deploy it if not. // Returns the address with a transactionHash if the contract needed to be deployed. // Otherwise, returns just the address. private async ensureContract( address: Address, deploymentTransactionData: TransactionSenderRequest, ): Promise { const existingCode = await this.#client.getCode({ address }) if (!existingCode) { let result if (this.#transactionSender.refundReceiver) { const { chainId } = deploymentTransactionData let transactionSender: MezoTransactionSender switch (chainId) { case mezoMainnet.id: transactionSender = new MezoTransactionSender( chainId, "https://mezo.org/api/v2/relay/deploy-safe", this.#transactionSender.refundReceiver, ) break case mezoTestnet.id: transactionSender = new MezoTransactionSender( chainId, "https://test.mezo.org/api/v2/relay/deploy-safe", this.#transactionSender.refundReceiver, ) break default: throw new Error(`Unsupported chain id: ${chainId}`) } result = await transactionSender.sendTransaction( deploymentTransactionData, ) await this.#client.waitForTransactionReceipt({ hash: result.hash, }) } else { result = await this.#transactionSender.sendTransaction( deploymentTransactionData, ) } return { address, transactionHash: result.hash } } return { address } } private async ensureSafeForBtcWallet( bitcoinAddress: string, publicKey?: string, ): Promise> { const deploymentTransactionData = await this.populateSafeDeploymentTransaction(bitcoinAddress, publicKey) const predictedAddress = await this.predictAddress( bitcoinAddress, publicKey, ) await this.ensureContract(predictedAddress, { chainId: this.#chainId, ...deploymentTransactionData, }) return this.#contracts.getSafeContract(predictedAddress) } /** * Sends a transaction from a safe associated with a given Bitcoin address. * @param {string} to - The Ethereum address to send the transaction to. * @param {string} value - The amount of Ether to send. * @param {string} data - The data to include in the transaction. * @param {string} bitcoinAddress - The Bitcoin address associated with the safe. * @param {string} publicKey - The public key associated with the Bitcoin address. * @param {BitcoinSignSafeTransactionMessageFn} bitcoinSignMessageFn - A * function to sign a message with the Bitcoin private key. * @returns {Promise} A promise that resolves to the hash of the sent transaction. */ async sendTransaction( to: Address, value: string, data: Hex, bitcoinAddress: string, publicKey: string, bitcoinSignMessageFn: BitcoinSignSafeTransactionMessageFn, ): Promise { const safe = await this.ensureSafeForBtcWallet(bitcoinAddress, publicKey) const safeAddress = safe.getAddress() const safeOwnerAddress = await safe.getSafeOwnerAddress() const nonce = Number(await safe.nonce()) let gasParameters = { safeTxGas: "0x0", baseGas: "0x0", gasPrice: "0x0", gasToken: zeroAddress, refundReceiver: zeroAddress as string, } // If the transaction sender specifies a refund receiver, we estimate the // gas costs of the transaction, add a base gas that is enough to verify // the base Bitcoin transaction signature, and use the estimated gas price // from the client. Note that the use of gas estimation implies that the // `from` address must have enough native token balance to pay for the // transaction execution. // // If no receiver is specified, we don't use refunds. if ( "refundReceiver" in this.#transactionSender && this.#transactionSender.refundReceiver ) { const baseGas = "0x3035F" const estimatedSafeTxGas = await this.#client.estimateGas({ account: safeAddress, to, value: BigInt(value), data, }) const gasPrice = await this.#client.getGasPrice() const gasLimit = BigInt(baseGas) + estimatedSafeTxGas const minimumNativeTokenBalanceRequired = gasLimit * gasPrice const balance = await this.#client.getBalance({ address: safeAddress, }) // Check if there is enough native token balance on underlying evm account // assigned to the currently connected btc account, to cover transaction // gas. if (balance < minimumNativeTokenBalanceRequired) { throw new Error( `Not enough native token balance to cover transaction gas. Required: ${minimumNativeTokenBalanceRequired} sats. Current balance: ${balance} sats.`, ) } gasParameters = { safeTxGas: estimatedSafeTxGas.toString(), baseGas, gasPrice: gasPrice.toString() ?? "0x0", gasToken: zeroAddress, // Refund in the base asset. refundReceiver: this.#transactionSender.refundReceiver, } } const safeTx = { to, value, data, operation: 0, ...gasParameters, nonce, } // Calculate entry for the first signature slot as per how Safe handles // ERC-1271 signatures: // For ERC-1271 signature type is 0 and the signature is assembled as: // {32-bytes signature verifier}{32-bytes data position}{1-byte signature type} // // Reference: // https://docs.safe.global/advanced/smart-account-signatures#contract-signature-eip-1271 const safeERC1271ConstantSignature = encodeAbiParameters( parseAbiParameters("address, uint256, uint8"), [safeOwnerAddress, 65n, 0], ) const safeTxHash = calculateSafeTransactionHash( safeAddress, safeTx, SAFE_VERSION, BigInt(this.#chainId), ) const signature = await bitcoinSignMessageFn( safeTxHash.slice(2), // drop the leading 0x safeTx, ) const safeBitcoinMessageSignature = await normalizeSafeSignature( signature, publicKey, ) const safeTxWithBitcoinSignature = encodeFunctionData({ abi: safe.getAbi(), functionName: "execTransaction", args: [ safeTx.to, safeTx.value, safeTx.data, safeTx.operation, safeTx.safeTxGas, safeTx.baseGas, safeTx.gasPrice, safeTx.gasToken, safeTx.refundReceiver, safeERC1271ConstantSignature + safeBitcoinMessageSignature, ], }) const txParams: TransactionSenderRequest = { to: safeAddress, data: safeTxWithBitcoinSignature, chainId: this.#chainId, } return (await this.#transactionSender.sendTransaction(txParams)).hash } }