diff --git a/package-lock.json b/package-lock.json index 42869dc..06a3594 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@tetherto/wdk-failover-provider": "1.0.0-beta.2", - "@tetherto/wdk-wallet": "1.0.0-beta.9", + "@tetherto/wdk-wallet": "1.0.0-beta.10", "@tetherto/wdk-wallet-evm": "1.0.0-beta.13", "abstractionkit": "0.3.8", "bare-node-runtime": "^1.1.4", @@ -4942,12 +4942,12 @@ "license": "Apache-2.0" }, "node_modules/@tetherto/wdk-wallet": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/@tetherto/wdk-wallet/-/wdk-wallet-1.0.0-beta.9.tgz", - "integrity": "sha512-sycU5+3fcgnJNR2vftGYykwrcqIzy9WKef6Yzwn7luS4S+MHDz+nfhhI0t9Qh3GMC+1m52LSMGBro3BPSHszNg==", + "version": "1.0.0-beta.10", + "resolved": "https://registry.npmjs.org/@tetherto/wdk-wallet/-/wdk-wallet-1.0.0-beta.10.tgz", + "integrity": "sha512-qCOxvNIltfwwMhOjWAgk2DN/0TZ+RjNOk10BXKQM2XMmq6KR8yJZV3Yc5FoJ+AFsCHZBhVJR6k6eF0/ZMI1pIA==", "license": "Apache-2.0", "dependencies": { - "bare-node-runtime": "^1.1.4", + "bare-node-runtime": "^1.4.0", "bip39": "3.1.0" } }, diff --git a/package.json b/package.json index 9a0f8e5..eb9532f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ }, "dependencies": { "@tetherto/wdk-failover-provider": "1.0.0-beta.2", - "@tetherto/wdk-wallet": "1.0.0-beta.9", + "@tetherto/wdk-wallet": "1.0.0-beta.10", "@tetherto/wdk-wallet-evm": "1.0.0-beta.13", "abstractionkit": "0.3.8", "bare-node-runtime": "^1.1.4", diff --git a/src/wallet-account-evm-erc-4337.js b/src/wallet-account-evm-erc-4337.js index 4a4d494..beb29da 100644 --- a/src/wallet-account-evm-erc-4337.js +++ b/src/wallet-account-evm-erc-4337.js @@ -156,6 +156,13 @@ export default class WalletAccountEvmErc4337 extends WalletAccountReadOnlyEvmErc const cached = await this._resolveQuote(tx, config) + const fee = cached.fee + + const { isSponsored, transactionMaxFee } = mergedConfig + if (!isSponsored && transactionMaxFee !== undefined && fee > transactionMaxFee) { + throw new Error('Exceeded maximum fee cost for transaction operation.') + } + const { userOp } = await this._signUserOperation([tx], { config: mergedConfig, cachedBuild: cached }) this._quoteCache.clear() @@ -263,6 +270,11 @@ export default class WalletAccountEvmErc4337 extends WalletAccountReadOnlyEvmErc const fee = cached.fee + const { isSponsored, transactionMaxFee } = mergedConfig + if (!isSponsored && transactionMaxFee !== undefined && fee > transactionMaxFee) { + throw new Error('Exceeded maximum fee cost for transaction operation.') + } + const hash = await this._sendUserOperation([tx].flat(), { config: mergedConfig, cachedBuild: cached }) return { hash, fee } @@ -290,7 +302,7 @@ export default class WalletAccountEvmErc4337 extends WalletAccountReadOnlyEvmErc const fee = cached.fee - if (!isSponsored && transferMaxFee !== undefined && fee >= transferMaxFee) { + if (!isSponsored && transferMaxFee !== undefined && fee > transferMaxFee) { throw new Error('Exceeded maximum fee cost for transfer operation.') } diff --git a/src/wallet-account-read-only-evm-erc-4337.js b/src/wallet-account-read-only-evm-erc-4337.js index 9dc207a..e7d37f4 100644 --- a/src/wallet-account-read-only-evm-erc-4337.js +++ b/src/wallet-account-read-only-evm-erc-4337.js @@ -125,6 +125,7 @@ export const FEE_TOLERANCE_COEFFICIENT = 120n * @property {Object} paymasterToken - The paymaster token configuration. * @property {string} paymasterToken.address - The address of the paymaster token. * @property {number | bigint} [transferMaxFee] - The maximum fee amount for transfer operations. + * @property {number | bigint} [transactionMaxFee] - The maximum fee amount for sendTransaction and signTransaction operations. */ /** @@ -140,6 +141,7 @@ export const FEE_TOLERANCE_COEFFICIENT = 120n * @property {false} [isSponsored] - Whether the paymaster is sponsoring the account. * @property {true} useNativeCoins - Whether to use native coins instead of a paymaster to pay for gas fees. * @property {number | bigint} [transferMaxFee] - The maximum fee amount for transfer operations. + * @property {number | bigint} [transactionMaxFee] - The maximum fee amount for sendTransaction and signTransaction operations. */ /** @@ -160,7 +162,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * Creates a new read-only evm [erc-4337](https://www.erc4337.io/docs) wallet account. * * @param {string} address - The evm account's address. - * @param {Omit} config - The configuration object. + * @param {Omit} config - The configuration object. * @throws {ConfigurationError} If `config.safeModulesVersion` is not in the supported set. */ constructor (address, config) { @@ -176,7 +178,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * The read-only evm erc-4337 wallet account configuration. * * @protected - * @type {Omit} + * @type {Omit} */ this._config = config @@ -415,7 +417,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * Validates the configuration to ensure all required fields are present. * * @protected - * @param {Omit} config - The configuration to validate. + * @param {Omit} config - The configuration to validate. * @throws {ConfigurationError} If the configuration is invalid or has missing required fields. * @returns {void} */ @@ -456,7 +458,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * Builds a safe account instance for the current owner. * * @protected - * @param {Omit} [config] - The wallet configuration. Defaults to the instance configuration. + * @param {Omit} [config] - The wallet configuration. Defaults to the instance configuration. * @returns {Promise} The safe account instance. */ async _getSmartAccount (config = this._config) { @@ -533,7 +535,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * Creates a FailoverProvider from the configured providers. If only one provider is supplied, it is wrapped and returned. * * @protected - * @param {Omit} [config] - The configuration object. + * @param {Omit} [config] - The configuration object. * @returns {Eip1193Provider} A wrapped Eip1193Provider instance. * @throws {Error} If the `provider` option is set to an empty array. */ @@ -615,7 +617,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * * @protected * @param {MetaTransaction[]} calls - The meta-transactions to include in the UserOperation. - * @param {Omit} config - The wallet configuration. + * @param {Omit} config - The wallet configuration. * @param {EvmErc4337GasOverrides} [txOverrides] - Optional UserOperationV7 gas overrides extracted from the input transaction(s). * @returns {Promise} The built operation, signing context, and (in token mode) the paymaster quote. */ @@ -682,7 +684,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * * @protected * @param {EvmErc4337Transaction[]} txs - The EVM transactions to include in the UserOperation. - * @param {Omit} config - The wallet configuration to use for the build. + * @param {Omit} config - The wallet configuration to use for the build. * @returns {Promise>} The built operation plus its raw fee (no tolerance buffer applied). * @throws {Error} If the token paymaster reports AA50 (account does not hold the paymaster token). */ diff --git a/tests/integration/module.test.js b/tests/integration/module.test.js index 1da5e4a..9f6c6c5 100644 --- a/tests/integration/module.test.js +++ b/tests/integration/module.test.js @@ -443,6 +443,95 @@ describe('@wdk/wallet-evm-erc-4337', () => { .rejects.toThrow('Exceeded maximum fee cost for transfer operation.') }, TIMEOUT) + test('should create a wallet with a low transaction max fee, derive an account, try to send a transaction and gracefully fail', async () => { + const config = { + chainId: 1, + provider: 'http://localhost:8545', + bundlerUrl: 'http://localhost:4337', + paymasterUrl: 'http://localhost:3000?pimlico', + paymasterAddress: paymasterAddress, + safeModulesVersion: '0.3.0', + paymasterToken: { + address: MOCK_PAYMASTER_TOKEN_ADDRESS + }, + transactionMaxFee: 100 + } + + const wallet = new WalletManagerEvmErc4337(SEED_PHRASE, config) + + const account = await wallet.getAccount(0) + + const TX = { + to: '0xa460AEbce0d3A4BecAd8ccf9D6D4861296c503Bd', + value: 0 + } + + await expect(account.sendTransaction(TX)) + .rejects.toThrow('Exceeded maximum fee cost for transaction operation.') + }, TIMEOUT) + + test('should allow a fee exactly equal to transactionMaxFee', async () => { + const TX = { + to: '0xa460AEbce0d3A4BecAd8ccf9D6D4861296c503Bd', + value: 0 + } + + const account0 = await wallet.getAccount(0) + const { fee } = await account0.quoteSendTransaction(TX) + + const config = { + chainId: 1, + provider: 'http://localhost:8545', + bundlerUrl: 'http://localhost:4337', + paymasterUrl: 'http://localhost:3000?pimlico', + paymasterAddress: paymasterAddress, + safeModulesVersion: '0.3.0', + paymasterToken: { + address: MOCK_PAYMASTER_TOKEN_ADDRESS + }, + transactionMaxFee: fee + } + + const limitedWallet = new WalletManagerEvmErc4337(SEED_PHRASE, config) + const limitedAccount = await limitedWallet.getAccount(0) + + const { hash } = await limitedAccount.sendTransaction(TX) + await waitForTx(hash, limitedAccount) + + expect(hash).toBeTruthy() + }, TIMEOUT) + + test('should allow a fee below transactionMaxFee', async () => { + const TX = { + to: '0xa460AEbce0d3A4BecAd8ccf9D6D4861296c503Bd', + value: 0 + } + + const account0 = await wallet.getAccount(0) + const { fee } = await account0.quoteSendTransaction(TX) + + const config = { + chainId: 1, + provider: 'http://localhost:8545', + bundlerUrl: 'http://localhost:4337', + paymasterUrl: 'http://localhost:3000?pimlico', + paymasterAddress: paymasterAddress, + safeModulesVersion: '0.3.0', + paymasterToken: { + address: MOCK_PAYMASTER_TOKEN_ADDRESS + }, + transactionMaxFee: fee + 1n + } + + const limitedWallet = new WalletManagerEvmErc4337(SEED_PHRASE, config) + const limitedAccount = await limitedWallet.getAccount(0) + + const { hash } = await limitedAccount.sendTransaction(TX) + await waitForTx(hash, limitedAccount) + + expect(hash).toBeTruthy() + }, TIMEOUT) + test('should use cached fee when sendTransaction is called with the same quoted params', async () => { const account0 = await wallet.getAccountByPath("0'/0/0") account0._quoteCache.clear() diff --git a/types/src/wallet-account-read-only-evm-erc-4337.d.ts b/types/src/wallet-account-read-only-evm-erc-4337.d.ts index e88f448..7c07052 100644 --- a/types/src/wallet-account-read-only-evm-erc-4337.d.ts +++ b/types/src/wallet-account-read-only-evm-erc-4337.d.ts @@ -24,14 +24,14 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * @param {Omit} config - The configuration object. * @throws {ConfigurationError} If `config.safeModulesVersion` is not in the supported set. */ - constructor(address: string, config: Omit); + constructor(address: string, config: Omit); /** * The read-only evm erc-4337 wallet account configuration. * * @protected * @type {Omit} */ - protected _config: Omit; + protected _config: Omit; /** * An EIP-1193–compatible provider used to interact with the blockchain. * @@ -162,7 +162,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * @throws {ConfigurationError} If the configuration is invalid or has missing required fields. * @returns {void} */ - protected _validateConfig(config: Omit): void; + protected _validateConfig(config: Omit): void; /** * Builds a safe account instance for the current owner. * @@ -170,7 +170,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * @param {Omit} [config] - The wallet configuration. Defaults to the instance configuration. * @returns {Promise} The safe account instance. */ - protected _getSmartAccount(config?: Omit): Promise; + protected _getSmartAccount(config?: Omit): Promise; /** * Returns an AbstractionKit Bundler for querying UserOperations. * @@ -201,7 +201,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * @returns {Eip1193Provider} A wrapped Eip1193Provider instance. * @throws {Error} If the `provider` option is set to an empty array. */ - protected _createFailoverProvider (config?: Omit): Eip1193Provider + protected _createFailoverProvider (config?: Omit): Eip1193Provider /** @private */ private _getEvmReadOnlyAccount; /** @@ -213,7 +213,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * @param {EvmErc4337GasOverrides} [txOverrides] - Optional UserOperationV7 gas overrides extracted from the input transaction(s). * @returns {Promise} The built operation, signing context, and (in token mode) the paymaster quote. */ - protected _buildUserOperation(calls: import('abstractionkit').MetaTransaction[], config: Omit, txOverrides?: EvmErc4337GasOverrides): Promise; + protected _buildUserOperation(calls: import('abstractionkit').MetaTransaction[], config: Omit, txOverrides?: EvmErc4337GasOverrides): Promise; /** * Extracts the optional UserOperationV7 gas overrides from a single transaction. * @@ -240,7 +240,7 @@ export default class WalletAccountReadOnlyEvmErc4337 extends WalletAccountReadOn * @returns {Promise>} The built operation plus its raw fee (no tolerance buffer applied). * @throws {Error} If the token paymaster reports AA50 (account does not hold the paymaster token). */ - protected _getUserOperationGasCost(txs: EvmErc4337Transaction[], config: Omit): Promise>; + protected _getUserOperationGasCost(txs: EvmErc4337Transaction[], config: Omit): Promise>; } export type Eip1193Provider = import("ethers").Eip1193Provider; export type TransactionResult = import("@tetherto/wdk-wallet-evm").TransactionResult; @@ -405,6 +405,10 @@ export type EvmErc4337WalletPaymasterTokenConfig = { * - The maximum fee amount for transfer operations. */ transferMaxFee?: number | bigint; + /** + * - The maximum fee amount for sendTransaction and signTransaction operations. + */ + transactionMaxFee?: number | bigint; }; export type EvmErc4337WalletSponsorshipPolicyConfig = { /** @@ -437,6 +441,10 @@ export type EvmErc4337WalletNativeCoinsConfig = { * - The maximum fee amount for transfer operations. */ transferMaxFee?: number | bigint; + /** + * - The maximum fee amount for sendTransaction and signTransaction operations. + */ + transactionMaxFee?: number | bigint; }; export type EvmErc4337WalletConfig = EvmErc4337WalletCommonConfig & (EvmErc4337WalletPaymasterTokenConfig | EvmErc4337WalletSponsorshipPolicyConfig | EvmErc4337WalletNativeCoinsConfig); import { WalletAccountReadOnly } from '@tetherto/wdk-wallet';