diff --git a/.changeset/blue-kings-compare.md b/.changeset/blue-kings-compare.md new file mode 100644 index 0000000000..b0beccbd2b --- /dev/null +++ b/.changeset/blue-kings-compare.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/sdk': minor +--- + +Adds modular transaction submission support for SDK clients, e.g. CLI. diff --git a/typescript/sdk/package.json b/typescript/sdk/package.json index a1a60493e1..7d87b1d1fc 100644 --- a/typescript/sdk/package.json +++ b/typescript/sdk/package.json @@ -7,6 +7,8 @@ "@cosmjs/stargate": "^0.31.3", "@hyperlane-xyz/core": "3.10.0", "@hyperlane-xyz/utils": "3.10.0", + "@safe-global/api-kit": "1.3.0", + "@safe-global/protocol-kit": "1.3.0", "@solana/spl-token": "^0.3.8", "@solana/web3.js": "^1.78.0", "@types/coingecko-api": "^1.0.10", @@ -64,7 +66,8 @@ ], "license": "Apache-2.0", "scripts": { - "build": "tsc", + "build": "yarn build:fixSafeLib && tsc", + "build:fixSafeLib": "rm -rf ../../node_modules/@safe-global/protocol-kit/dist/typechain/", "dev": "tsc --watch", "check": "tsc --noEmit", "clean": "rm -rf ./dist ./cache", diff --git a/typescript/sdk/src/providers/MultiProvider.ts b/typescript/sdk/src/providers/MultiProvider.ts index ecaec67f8b..0df4567dca 100644 --- a/typescript/sdk/src/providers/MultiProvider.ts +++ b/typescript/sdk/src/providers/MultiProvider.ts @@ -348,7 +348,7 @@ export class MultiProvider extends ChainMetadataManager { tx: PopulatedTransaction, from?: string, ): Promise { - const txFrom = from ? from : await this.getSignerAddress(chainNameOrId); + const txFrom = from ?? (await this.getSignerAddress(chainNameOrId)); const overrides = this.getTransactionOverrides(chainNameOrId); return { ...tx, diff --git a/typescript/sdk/src/providers/transactions/submitter/TxSubmitterInterface.ts b/typescript/sdk/src/providers/transactions/submitter/TxSubmitterInterface.ts new file mode 100644 index 0000000000..b857bd990d --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/TxSubmitterInterface.ts @@ -0,0 +1,32 @@ +import { ProtocolType } from '@hyperlane-xyz/utils'; + +import { ChainName } from '../../../types.js'; +import { + ProtocolTypedProvider, + ProtocolTypedReceipt, + ProtocolTypedTransaction, +} from '../../ProviderType.js'; + +import { TxSubmitterType } from './TxSubmitterTypes.js'; + +export interface TxSubmitterInterface { + /** + * Defines the type of tx submitter. + */ + txSubmitterType: TxSubmitterType; + /** + * The chain to submit transactions on. + */ + chain: ChainName; + /** + * The provider to use for transaction submission. + */ + provider?: ProtocolTypedProvider['provider']; + /** + * Should execute all transactions and return their receipts. + * @param txs The array of transactions to execute + */ + submit( + ...txs: ProtocolTypedTransaction['transaction'][] + ): Promise['receipt'][] | void>; +} diff --git a/typescript/sdk/src/providers/transactions/submitter/TxSubmitterTypes.ts b/typescript/sdk/src/providers/transactions/submitter/TxSubmitterTypes.ts new file mode 100644 index 0000000000..4e38f9c25a --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/TxSubmitterTypes.ts @@ -0,0 +1,5 @@ +export enum TxSubmitterType { + JSON_RPC = 'JSON RPC', + IMPERSONATED_ACCOUNT = 'Impersonated Account', + GNOSIS_SAFE = 'Gnosis Safe', +} diff --git a/typescript/sdk/src/providers/transactions/submitter/builder/TxSubmitterBuilder.ts b/typescript/sdk/src/providers/transactions/submitter/builder/TxSubmitterBuilder.ts new file mode 100644 index 0000000000..628bda0352 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/builder/TxSubmitterBuilder.ts @@ -0,0 +1,100 @@ +import { Logger } from 'pino'; + +import { rootLogger } from '@hyperlane-xyz/utils'; +import { ProtocolType } from '@hyperlane-xyz/utils'; + +import { ChainName } from '../../../../types.js'; +import { + ProtocolTypedReceipt, + ProtocolTypedTransaction, +} from '../../../ProviderType.js'; +import { TxTransformerInterface } from '../../transformer/TxTransformerInterface.js'; +import { TxSubmitterInterface } from '../TxSubmitterInterface.js'; +import { TxSubmitterType } from '../TxSubmitterTypes.js'; + +/** + * Builds a TxSubmitterBuilder for batch transaction submission. + * + * Example use-cases: + * const eV5builder = new TxSubmitterBuilder(); + * let txReceipts = eV5builder.for( + * new EV5GnosisSafeTxSubmitter(chainA) + * ).transform( + * EV5InterchainAccountTxTransformer(chainB) + * ).submit( + * txs + * ); + * txReceipts = eV5builder.for( + * new EV5ImpersonatedAccountTxSubmitter(chainA) + * ).submit(txs); + * txReceipts = eV5builder.for( + * new EV5JsonRpcTxSubmitter(chainC) + * ).submit(txs); + */ +export class TxSubmitterBuilder + implements TxSubmitterInterface +{ + public readonly txSubmitterType: TxSubmitterType; + public readonly chain: ChainName; + + protected readonly logger: Logger = rootLogger.child({ + module: 'submitter-builder', + }); + + constructor( + private currentSubmitter: TxSubmitterInterface, + private currentTransformers: TxTransformerInterface[] = [], + ) { + this.txSubmitterType = this.currentSubmitter.txSubmitterType; + this.chain = this.currentSubmitter.chain; + } + + /** + * Sets the current submitter for the builder. + * @param txSubmitterOrType The submitter to add to the builder + */ + public for( + txSubmitter: TxSubmitterInterface, + ): TxSubmitterBuilder { + this.currentSubmitter = txSubmitter; + return this; + } + + /** + * Adds a transformer for the builder. + * @param txTransformerOrType The transformer to add to the builder + */ + public transform( + ...txTransformers: TxTransformerInterface[] + ): TxSubmitterBuilder { + this.currentTransformers = txTransformers; + return this; + } + + /** + * Submits a set of transactions to the builder. + * @param txs The transactions to submit + */ + public async submit( + ...txs: ProtocolTypedTransaction['transaction'][] + ): Promise['receipt'][] | void> { + this.logger.info( + `Submitting ${txs.length} transactions to the ${this.currentSubmitter.txSubmitterType} submitter...`, + ); + + let transformedTxs = txs; + for (const currentTransformer of this.currentTransformers) { + transformedTxs = await currentTransformer.transform(...transformedTxs); + this.logger.info( + `🔄 Transformed ${transformedTxs.length} transactions with the ${currentTransformer.txTransformerType} transformer...`, + ); + } + + const txReceipts = await this.currentSubmitter.submit(...transformedTxs); + this.logger.info( + `✅ Successfully submitted ${transformedTxs.length} transactions to the ${this.currentSubmitter.txSubmitterType} submitter.`, + ); + + return txReceipts; + } +} diff --git a/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5GnosisSafeTxSubmitter.ts b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5GnosisSafeTxSubmitter.ts new file mode 100644 index 0000000000..de7d3b18ad --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5GnosisSafeTxSubmitter.ts @@ -0,0 +1,86 @@ +import SafeApiKit from '@safe-global/api-kit'; +import Safe, { EthSafeSignature } from '@safe-global/protocol-kit'; +import { + MetaTransactionData, + SafeTransactionData, +} from '@safe-global/safe-core-sdk-types'; +import assert from 'assert'; +import { PopulatedTransaction } from 'ethers'; +import { Logger } from 'pino'; + +import { Address, rootLogger } from '@hyperlane-xyz/utils'; + +import { ChainName } from '../../../../types.js'; +import { getSafe, getSafeService } from '../../../../utils/gnosisSafe.js'; +import { MultiProvider } from '../../../MultiProvider.js'; +import { TxSubmitterType } from '../TxSubmitterTypes.js'; + +import { EV5TxSubmitterInterface } from './EV5TxSubmitterInterface.js'; + +interface EV5GnosisSafeTxSubmitterProps { + safeAddress: Address; +} + +export class EV5GnosisSafeTxSubmitter implements EV5TxSubmitterInterface { + public readonly txSubmitterType: TxSubmitterType = + TxSubmitterType.GNOSIS_SAFE; + + protected readonly logger: Logger = rootLogger.child({ + module: 'gnosis-safe-submitter', + }); + + constructor( + public readonly multiProvider: MultiProvider, + public readonly chain: ChainName, + public readonly props: EV5GnosisSafeTxSubmitterProps, + ) {} + + public async submit(...txs: PopulatedTransaction[]): Promise { + const safe: Safe.default = await getSafe( + this.chain, + this.multiProvider, + this.props.safeAddress, + ); + const safeService: SafeApiKit.default = getSafeService( + this.chain, + this.multiProvider, + ); + const nextNonce: number = await safeService.getNextNonce( + this.props.safeAddress, + ); + const safeTransactionBatch: MetaTransactionData[] = txs.map( + ({ to, data, value }: PopulatedTransaction) => { + assert( + to && data, + 'Invalid PopulatedTransaction: Missing required field to or data.', + ); + return { to, data, value: value?.toString() ?? '0' }; + }, + ); + const safeTransaction = await safe.createTransaction({ + safeTransactionData: safeTransactionBatch, + options: { nonce: nextNonce }, + }); + const safeTransactionData: SafeTransactionData = safeTransaction.data; + const safeTxHash: string = await safe.getTransactionHash(safeTransaction); + const senderAddress: Address = await this.multiProvider.getSignerAddress( + this.chain, + ); + const safeSignature: EthSafeSignature = await safe.signTransactionHash( + safeTxHash, + ); + const senderSignature: string = safeSignature.data; + + this.logger.debug( + `Submitting transaction proposal to ${this.props.safeAddress} on ${this.chain}: ${safeTxHash}`, + ); + + return safeService.proposeTransaction({ + safeAddress: this.props.safeAddress, + safeTransactionData, + safeTxHash, + senderAddress, + senderSignature, + }); + } +} diff --git a/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5ImpersonatedAccountTxSubmitter.ts b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5ImpersonatedAccountTxSubmitter.ts new file mode 100644 index 0000000000..3511d00b16 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5ImpersonatedAccountTxSubmitter.ts @@ -0,0 +1,43 @@ +import { TransactionReceipt } from '@ethersproject/providers'; +import { PopulatedTransaction } from 'ethers'; +import { Logger } from 'pino'; + +import { rootLogger } from '@hyperlane-xyz/utils'; +import { Address } from '@hyperlane-xyz/utils'; + +import { ChainName } from '../../../../types.js'; +import { impersonateAccount } from '../../../../utils/fork.js'; +import { MultiProvider } from '../../../MultiProvider.js'; +import { TxSubmitterType } from '../TxSubmitterTypes.js'; + +import { EV5JsonRpcTxSubmitter } from './EV5JsonRpcTxSubmitter.js'; + +interface EV5ImpersonatedAccountTxSubmitterProps { + address: Address; +} + +export class EV5ImpersonatedAccountTxSubmitter extends EV5JsonRpcTxSubmitter { + public readonly txSubmitterType: TxSubmitterType = + TxSubmitterType.IMPERSONATED_ACCOUNT; + + protected readonly logger: Logger = rootLogger.child({ + module: 'impersonated-account-submitter', + }); + + constructor( + public readonly multiProvider: MultiProvider, + public readonly chain: ChainName, + public readonly props: EV5ImpersonatedAccountTxSubmitterProps, + ) { + super(multiProvider, chain); + } + + public async submit( + ...txs: PopulatedTransaction[] + ): Promise { + const impersonatedAccount = await impersonateAccount(this.props.address); + this.multiProvider.setSigner(this.chain, impersonatedAccount); + super.multiProvider.setSigner(this.chain, impersonatedAccount); + return await super.submit(...txs); + } +} diff --git a/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.ts b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.ts new file mode 100644 index 0000000000..e1077d7c5c --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.ts @@ -0,0 +1,41 @@ +import { TransactionReceipt } from '@ethersproject/providers'; +import { ContractReceipt, PopulatedTransaction } from 'ethers'; +import { Logger } from 'pino'; + +import { rootLogger } from '@hyperlane-xyz/utils'; + +import { ChainName } from '../../../../types.js'; +import { MultiProvider } from '../../../MultiProvider.js'; +import { TxSubmitterType } from '../TxSubmitterTypes.js'; + +import { EV5TxSubmitterInterface } from './EV5TxSubmitterInterface.js'; + +export class EV5JsonRpcTxSubmitter implements EV5TxSubmitterInterface { + public readonly txSubmitterType: TxSubmitterType = TxSubmitterType.JSON_RPC; + + protected readonly logger: Logger = rootLogger.child({ + module: 'json-rpc-submitter', + }); + + constructor( + public readonly multiProvider: MultiProvider, + public readonly chain: ChainName, + ) {} + + public async submit( + ...txs: PopulatedTransaction[] + ): Promise { + const receipts: TransactionReceipt[] = []; + for (const tx of txs) { + const receipt: ContractReceipt = await this.multiProvider.sendTransaction( + this.chain, + tx, + ); + this.logger.debug( + `Submitted PopulatedTransaction on ${this.chain}: ${receipt.transactionHash}`, + ); + receipts.push(receipt); + } + return receipts; + } +} diff --git a/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5TxSubmitterInterface.ts b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5TxSubmitterInterface.ts new file mode 100644 index 0000000000..d1c452f7d1 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5TxSubmitterInterface.ts @@ -0,0 +1,12 @@ +import { ProtocolType } from '@hyperlane-xyz/utils'; + +import { MultiProvider } from '../../../MultiProvider.js'; +import { TxSubmitterInterface } from '../TxSubmitterInterface.js'; + +export interface EV5TxSubmitterInterface + extends TxSubmitterInterface { + /** + * The EV5 multi-provider to use for transaction submission. + */ + multiProvider: MultiProvider; +} diff --git a/typescript/sdk/src/providers/transactions/transformer/TxTransformerInterface.ts b/typescript/sdk/src/providers/transactions/transformer/TxTransformerInterface.ts new file mode 100644 index 0000000000..5f2476d9fa --- /dev/null +++ b/typescript/sdk/src/providers/transactions/transformer/TxTransformerInterface.ts @@ -0,0 +1,19 @@ +import { ProtocolType } from '@hyperlane-xyz/utils'; + +import { ProtocolTypedTransaction } from '../../ProviderType.js'; + +import { TxTransformerType } from './TxTransformerTypes.js'; + +export interface TxTransformerInterface { + /** + * Defines the type of tx transformer. + */ + txTransformerType: TxTransformerType; + /** + * Should transform all transactions of type TX into transactions of type TX. + * @param txs The array of transactions to transform + */ + transform( + ...txs: ProtocolTypedTransaction['transaction'][] + ): Promise['transaction'][]>; +} diff --git a/typescript/sdk/src/providers/transactions/transformer/TxTransformerTypes.ts b/typescript/sdk/src/providers/transactions/transformer/TxTransformerTypes.ts new file mode 100644 index 0000000000..b8e029b2c1 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/transformer/TxTransformerTypes.ts @@ -0,0 +1,3 @@ +export enum TxTransformerType { + ICA = 'Interchain Account', +} diff --git a/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts b/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts new file mode 100644 index 0000000000..2f5e138bc3 --- /dev/null +++ b/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts @@ -0,0 +1,64 @@ +import assert from 'assert'; +import { PopulatedTransaction } from 'ethers'; +import { Logger } from 'pino'; + +import { CallData, rootLogger } from '@hyperlane-xyz/utils'; + +import { InterchainAccount } from '../../../../middleware/account/InterchainAccount.js'; +import { AccountConfig } from '../../../../middleware/account/types.js'; +import { ChainName } from '../../../../types.js'; +import { MultiProvider } from '../../../MultiProvider.js'; +import { TxTransformerType } from '../TxTransformerTypes.js'; + +import { EV5TxTransformerInterface } from './EV5TxTransformerInterface.js'; + +interface EV5InterchainAccountTxTransformerProps { + interchainAccount: InterchainAccount; + accountConfig: AccountConfig; + hookMetadata?: string; +} + +export class EV5InterchainAccountTxTransformer + implements EV5TxTransformerInterface +{ + public readonly txTransformerType: TxTransformerType = TxTransformerType.ICA; + protected readonly logger: Logger = rootLogger.child({ + module: 'ica-transformer', + }); + + constructor( + public readonly multiProvider: MultiProvider, + public readonly chain: ChainName, + public readonly props: EV5InterchainAccountTxTransformerProps, + ) {} + + public async transform( + ...txs: PopulatedTransaction[] + ): Promise { + const destinationChainId = txs[0].chainId; + assert( + destinationChainId, + 'Missing destination chainId in PopulatedTransaction.', + ); + + const innerCalls: CallData[] = txs.map( + ({ to, data, value }: PopulatedTransaction) => { + assert( + to && data, + 'Invalid PopulatedTransaction: Missing required field to or data.', + ); + return { to, data, value }; + }, + ); + + return [ + await this.props.interchainAccount.getCallRemote( + this.chain, + this.multiProvider.getChainName(this.chain), //chainIdToMetadata[destinationChainId].name, + innerCalls, + this.props.accountConfig, + this.props.hookMetadata, + ), + ]; + } +} diff --git a/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5TxTransformerInterface.ts b/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5TxTransformerInterface.ts new file mode 100644 index 0000000000..32e3c23f2b --- /dev/null +++ b/typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5TxTransformerInterface.ts @@ -0,0 +1,6 @@ +import { ProtocolType } from '@hyperlane-xyz/utils'; + +import { TxTransformerInterface } from '../TxTransformerInterface.js'; + +export interface EV5TxTransformerInterface + extends TxTransformerInterface {} diff --git a/typescript/sdk/src/utils/gnosisSafe.ts b/typescript/sdk/src/utils/gnosisSafe.ts new file mode 100644 index 0000000000..803c53f655 --- /dev/null +++ b/typescript/sdk/src/utils/gnosisSafe.ts @@ -0,0 +1,58 @@ +import SafeApiKit from '@safe-global/api-kit'; +import Safe, { EthersAdapter } from '@safe-global/protocol-kit'; +import { ethers } from 'ethers'; + +import { MultiProvider } from '../providers/MultiProvider.js'; +import { ChainName } from '../types.js'; + +export function getSafeService( + chain: ChainName, + multiProvider: MultiProvider, +): SafeApiKit.default { + const signer = multiProvider.getSigner(chain); + const ethAdapter = new EthersAdapter({ ethers, signerOrProvider: signer }); + const txServiceUrl = + multiProvider.getChainMetadata(chain).gnosisSafeTransactionServiceUrl; + if (!txServiceUrl) + throw new Error(`must provide tx service url for ${chain}`); + return new SafeApiKit.default({ txServiceUrl, ethAdapter }); +} + +export function getSafe( + chain: ChainName, + multiProvider: MultiProvider, + safeAddress: string, +): Promise { + const signer = multiProvider.getSigner(chain); + const ethAdapter = new EthersAdapter({ ethers, signerOrProvider: signer }); + return Safe.default.create({ + ethAdapter, + safeAddress: safeAddress, + }); +} + +export async function getSafeDelegates( + service: SafeApiKit.default, + safeAddress: string, +) { + const delegateResponse = await service.getSafeDelegates({ safeAddress }); + return delegateResponse.results.map((r) => r.delegate); +} + +export async function canProposeSafeTransactions( + proposer: string, + chain: ChainName, + multiProvider: MultiProvider, + safeAddress: string, +): Promise { + let safeService; + try { + safeService = getSafeService(chain, multiProvider); + } catch (e) { + return false; + } + const safe = await getSafe(chain, multiProvider, safeAddress); + const delegates = await getSafeDelegates(safeService, safeAddress); + const owners = await safe.getOwners(); + return delegates.includes(proposer) || owners.includes(proposer); +} diff --git a/yarn.lock b/yarn.lock index 45eebca5ac..7f158b4261 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5160,6 +5160,8 @@ __metadata: "@hyperlane-xyz/utils": "npm:3.10.0" "@nomiclabs/hardhat-ethers": "npm:^2.2.3" "@nomiclabs/hardhat-waffle": "npm:^2.0.6" + "@safe-global/api-kit": "npm:1.3.0" + "@safe-global/protocol-kit": "npm:1.3.0" "@solana/spl-token": "npm:^0.3.8" "@solana/web3.js": "npm:^1.78.0" "@types/coingecko-api": "npm:^1.0.10" @@ -7233,7 +7235,7 @@ __metadata: languageName: node linkType: hard -"@safe-global/api-kit@npm:^1.3.0": +"@safe-global/api-kit@npm:1.3.0, @safe-global/api-kit@npm:^1.3.0": version: 1.3.0 resolution: "@safe-global/api-kit@npm:1.3.0" dependencies: @@ -7244,6 +7246,24 @@ __metadata: languageName: node linkType: hard +"@safe-global/protocol-kit@npm:1.3.0": + version: 1.3.0 + resolution: "@safe-global/protocol-kit@npm:1.3.0" + dependencies: + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/solidity": "npm:^5.7.0" + "@safe-global/safe-deployments": "npm:^1.26.0" + ethereumjs-util: "npm:^7.1.5" + semver: "npm:^7.5.4" + web3: "npm:^1.8.1" + web3-core: "npm:^1.8.1" + web3-utils: "npm:^1.8.1" + zksync-web3: "npm:^0.14.3" + checksum: e562f437c3682ddf395e13b26adb9f4e4d2970c66b78e8f8f4895862864ac5bdfac3bdcfda234a171a3eb79d262b75d48cac3ff248f4587654b7b8da9a1ba7f6 + languageName: node + linkType: hard + "@safe-global/protocol-kit@npm:^1.2.0": version: 1.2.0 resolution: "@safe-global/protocol-kit@npm:1.2.0" @@ -24671,6 +24691,15 @@ __metadata: languageName: node linkType: hard +"zksync-web3@npm:^0.14.3": + version: 0.14.4 + resolution: "zksync-web3@npm:0.14.4" + peerDependencies: + ethers: ^5.7.0 + checksum: a1566a2a2ba34a3026680f3b4000ffa02593e02d9c73a4dd143bde929b5e39b09544d429bccad0479070670cfdad5f6836cb686c4b8d7954b4d930826be91c92 + languageName: node + linkType: hard + "zod@npm:^3.21.2": version: 3.21.2 resolution: "zod@npm:3.21.2"