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
Noah Bayindirli 🥂 7 months ago committed by GitHub
parent cf727db3d4
commit d37cbab724
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/blue-kings-compare.md
  2. 5
      typescript/sdk/package.json
  3. 2
      typescript/sdk/src/providers/MultiProvider.ts
  4. 32
      typescript/sdk/src/providers/transactions/submitter/TxSubmitterInterface.ts
  5. 5
      typescript/sdk/src/providers/transactions/submitter/TxSubmitterTypes.ts
  6. 100
      typescript/sdk/src/providers/transactions/submitter/builder/TxSubmitterBuilder.ts
  7. 86
      typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5GnosisSafeTxSubmitter.ts
  8. 43
      typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5ImpersonatedAccountTxSubmitter.ts
  9. 41
      typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5JsonRpcTxSubmitter.ts
  10. 12
      typescript/sdk/src/providers/transactions/submitter/ethersV5/EV5TxSubmitterInterface.ts
  11. 19
      typescript/sdk/src/providers/transactions/transformer/TxTransformerInterface.ts
  12. 3
      typescript/sdk/src/providers/transactions/transformer/TxTransformerTypes.ts
  13. 64
      typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5InterchainAccountTxTransformer.ts
  14. 6
      typescript/sdk/src/providers/transactions/transformer/ethersV5/EV5TxTransformerInterface.ts
  15. 58
      typescript/sdk/src/utils/gnosisSafe.ts
  16. 31
      yarn.lock

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/sdk': minor
---
Adds modular transaction submission support for SDK clients, e.g. CLI.

@ -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",

@ -348,7 +348,7 @@ export class MultiProvider<MetaExt = {}> extends ChainMetadataManager<MetaExt> {
tx: PopulatedTransaction,
from?: string,
): Promise<providers.TransactionRequest> {
const txFrom = from ? from : await this.getSignerAddress(chainNameOrId);
const txFrom = from ?? (await this.getSignerAddress(chainNameOrId));
const overrides = this.getTransactionOverrides(chainNameOrId);
return {
...tx,

@ -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);
}

@ -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"

Loading…
Cancel
Save