feat: Modular Tx Submission – Create Transformer + Submitter + Builder Abstraction (#3627)
## Description
- Adds modular transaction submission support for SDK clients, e.g. CLI.
To-do:
- [ ] Failing CI build due to linting Gnosis Safe package import
- [ ] Export to `sdk/src/index.ts`
Note:
- Built to eventually expand to [Sealevel/CW
support](dc62847615/typescript/sdk/src/providers/ProviderType.ts (L51-L85)
)
### Transformers
- Input: `PopulatedTx[]`
- Output: `HTX[]` (where `HTX extends HyperlaneTx`)
- Purpose: Given a set of populated transactions, transform those
transactions into a set a HyperlaneTxs (for the corresponding
submitter), e.g.
```
...
const somePopulatedTxs = ...
const transformer = new InterchainAccountTxTransformer(mp,o,d,p);
const populatedTxs = transformer.transformTxs(somePopulatedTxs);
```
### Submitters
- Input: `HTX[]` (where `HTX extends HyperlaneTx`)
- Output: `TxReceipt[] | ResponseData[]`
- Purpose: Given a set of Hyperlane transactions, execute those
transactions for the specified submitter (submitter of type HTX should
enforce the transactions being passed are of type HTX), e.g.
```
...
const submitter = new GnosisSafeTxSubmitter(mp,c,p);
const txReceipts = submitter.submitTxs(populatedTxs);
---
Client-side example: for each gnosisTxReceipt display transactionHash
```
### Builder (Utilizes both Submitters & Transformer)
- Input: `(TxTransformer<HTX> | TxTransformerType & Chain) &
(TxSubmitter<HTX,HTR> | TxSubmitterType & Chain) & HTX[]` (where `HTX
extends HyperlaneTx`)
- Output: `TxReceipt[] | *response data*`
- Purpose: Given a submitter, an optional transformer, and a set of
PopulatedTransactions, transform and submit all transactions, e.g.
```
...
const eV5builder = new TxSubmitterBuilder<EV5Transaction, EV5TransactionReceipt>();
let txReceipts = eV5builder.for(
new GnosisSafeTxSubmitter(chainA)
).transform(
InterchainAccountTxTransformer(chainB)
).submit(
txs
);
txReceipts = eV5builder.for(
new ImpersonatedAccountTxSubmitter(chainA)
).submit(txs);
txReceipts = eV5builder.for(
new JsonRpcTxSubmitter(chainC)
).submit(txs);
---
Client-side example: for each txReceipt display transactionHash | response data
```
### Drive-by changes
* None
### Related issues
- Fixes https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3547
### Backward compatibility
- Yes
### Testing
- Testing through CLI unit testing
pull/3696/head
parent
cf727db3d4
commit
d37cbab724
@ -0,0 +1,5 @@ |
||||
--- |
||||
'@hyperlane-xyz/sdk': minor |
||||
--- |
||||
|
||||
Adds modular transaction submission support for SDK clients, e.g. CLI. |
@ -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<TProtocol extends ProtocolType> { |
||||
/** |
||||
* 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<TProtocol>['provider']; |
||||
/** |
||||
* Should execute all transactions and return their receipts. |
||||
* @param txs The array of transactions to execute |
||||
*/ |
||||
submit( |
||||
...txs: ProtocolTypedTransaction<TProtocol>['transaction'][] |
||||
): Promise<ProtocolTypedReceipt<TProtocol>['receipt'][] | void>; |
||||
} |
@ -0,0 +1,5 @@ |
||||
export enum TxSubmitterType { |
||||
JSON_RPC = 'JSON RPC', |
||||
IMPERSONATED_ACCOUNT = 'Impersonated Account', |
||||
GNOSIS_SAFE = 'Gnosis Safe', |
||||
} |
@ -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<EV5Transaction, EV5TransactionReceipt>(); |
||||
* 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<TProtocol extends ProtocolType> |
||||
implements TxSubmitterInterface<TProtocol> |
||||
{ |
||||
public readonly txSubmitterType: TxSubmitterType; |
||||
public readonly chain: ChainName; |
||||
|
||||
protected readonly logger: Logger = rootLogger.child({ |
||||
module: 'submitter-builder', |
||||
}); |
||||
|
||||
constructor( |
||||
private currentSubmitter: TxSubmitterInterface<TProtocol>, |
||||
private currentTransformers: TxTransformerInterface<TProtocol>[] = [], |
||||
) { |
||||
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<TProtocol>, |
||||
): TxSubmitterBuilder<TProtocol> { |
||||
this.currentSubmitter = txSubmitter; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Adds a transformer for the builder. |
||||
* @param txTransformerOrType The transformer to add to the builder |
||||
*/ |
||||
public transform( |
||||
...txTransformers: TxTransformerInterface<TProtocol>[] |
||||
): TxSubmitterBuilder<TProtocol> { |
||||
this.currentTransformers = txTransformers; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Submits a set of transactions to the builder. |
||||
* @param txs The transactions to submit |
||||
*/ |
||||
public async submit( |
||||
...txs: ProtocolTypedTransaction<TProtocol>['transaction'][] |
||||
): Promise<ProtocolTypedReceipt<TProtocol>['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; |
||||
} |
||||
} |
@ -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<void> { |
||||
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, |
||||
}); |
||||
} |
||||
} |
@ -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<TransactionReceipt[]> { |
||||
const impersonatedAccount = await impersonateAccount(this.props.address); |
||||
this.multiProvider.setSigner(this.chain, impersonatedAccount); |
||||
super.multiProvider.setSigner(this.chain, impersonatedAccount); |
||||
return await super.submit(...txs); |
||||
} |
||||
} |
@ -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<TransactionReceipt[]> { |
||||
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; |
||||
} |
||||
} |
@ -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<ProtocolType.Ethereum> { |
||||
/** |
||||
* The EV5 multi-provider to use for transaction submission. |
||||
*/ |
||||
multiProvider: MultiProvider; |
||||
} |
@ -0,0 +1,19 @@ |
||||
import { ProtocolType } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { ProtocolTypedTransaction } from '../../ProviderType.js'; |
||||
|
||||
import { TxTransformerType } from './TxTransformerTypes.js'; |
||||
|
||||
export interface TxTransformerInterface<TProtocol extends ProtocolType> { |
||||
/** |
||||
* 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<TProtocol>['transaction'][] |
||||
): Promise<ProtocolTypedTransaction<TProtocol>['transaction'][]>; |
||||
} |
@ -0,0 +1,3 @@ |
||||
export enum TxTransformerType { |
||||
ICA = 'Interchain Account', |
||||
} |
@ -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<PopulatedTransaction[]> { |
||||
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, |
||||
), |
||||
]; |
||||
} |
||||
} |
@ -0,0 +1,6 @@ |
||||
import { ProtocolType } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { TxTransformerInterface } from '../TxTransformerInterface.js'; |
||||
|
||||
export interface EV5TxTransformerInterface |
||||
extends TxTransformerInterface<ProtocolType.Ethereum> {} |
@ -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<Safe.default> { |
||||
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<boolean> { |
||||
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); |
||||
} |
Loading…
Reference in new issue