diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 8007c5c..4ac7eeb 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/yeagerai/genlayer-wallet.git" }, "source": { - "shasum": "eY80XAM15OdmyAec7lzqcy7C3T6bQ611scgsLt6STec=", + "shasum": "juv32Btut0tfktPrqMa0kWMNU24SCROlArAiybo05KI=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -20,7 +20,11 @@ "initialPermissions": { "snap_dialog": {}, "endowment:transaction-insight": {}, - "snap_manageState": {} + "snap_manageState": {}, + "endowment:rpc": { + "dapps": true, + "snaps": false + } }, "platformVersion": "6.14.0", "manifestVersion": "0.1" diff --git a/packages/snap/src/components/AdvancedOptionsForm.tsx b/packages/snap/src/components/AdvancedOptionsForm.tsx index ca79073..ff8d2b3 100644 --- a/packages/snap/src/components/AdvancedOptionsForm.tsx +++ b/packages/snap/src/components/AdvancedOptionsForm.tsx @@ -11,24 +11,16 @@ import { Option, } from '@metamask/snaps-sdk/jsx'; -export type AdvancedOptionsFormState = { - 'leader-timeout-input': string; - 'validator-timeout-input': string; - 'genlayer-storage-input': string; - 'rollup-storage-input': string; - 'message-gas-input': string; - 'number-of-appeals': string; - [key: string]: string; -}; +import type { FeeConfigState } from '../types'; export type AdvancedOptionsFormProps = { - values: AdvancedOptionsFormState; + values: FeeConfigState; }; export const AdvancedOptionsForm: SnapComponent = ({ values, }) => { - const numberOfAppeals = parseInt(values['number-of-appeals'] || '1', 10); + const numberOfAppeals = parseInt(values['number-of-appeals'] ?? '1', 10); return (
@@ -37,7 +29,7 @@ export const AdvancedOptionsForm: SnapComponent = ({ @@ -72,7 +64,7 @@ export const AdvancedOptionsForm: SnapComponent = ({ type={'number'} min={1} placeholder="60 Sec" - value={values['leader-timeout-input'] || ''} + value={values['leader-timeout-input'] ?? ''} /> @@ -81,7 +73,7 @@ export const AdvancedOptionsForm: SnapComponent = ({ type={'number'} min={1} placeholder="30 Sec" - value={values['validator-timeout-input'] || ''} + value={values['validator-timeout-input'] ?? ''} /> @@ -89,7 +81,7 @@ export const AdvancedOptionsForm: SnapComponent = ({ name="genlayer-storage-input" type={'number'} placeholder="12 GEN" - value={values['genlayer-storage-input'] || ''} + value={values['genlayer-storage-input'] ?? ''} /> @@ -97,14 +89,14 @@ export const AdvancedOptionsForm: SnapComponent = ({ name="rollup-storage-input" type={'number'} placeholder="12 GEN" - value={values['rollup-storage-input'] || ''} + value={values['rollup-storage-input'] ?? ''} /> diff --git a/packages/snap/src/index.test.tsx b/packages/snap/src/index.test.tsx index 59c8471..95fcd4e 100644 --- a/packages/snap/src/index.test.tsx +++ b/packages/snap/src/index.test.tsx @@ -1,11 +1,12 @@ // Mock modules before importing import { installSnap } from '@metamask/snaps-jest'; -import { UserInputEventType } from '@metamask/snaps-sdk'; -import { onTransaction, onUserInput } from '.'; -import type { AdvancedOptionsFormState } from './components'; +import { onRpcRequest, onTransaction, onUserInput } from '.'; import { StateManager } from './libs/StateManager'; -import { getTransactionStorageKey } from './transactions/transaction'; +import { + setDefaultFeeConfig, + getTransactionStorageKey, +} from './transactions/transaction'; jest.mock('genlayer-js', () => ({ abi: { @@ -28,8 +29,31 @@ jest.mock('ethers', () => ({ getBytes: jest.fn(), })); -jest.mock('./libs/StateManager'); -jest.mock('./transactions/transaction'); +// Mock the transaction module +jest.mock('./transactions/transaction', () => ({ + ...jest.requireActual('./transactions/transaction'), + setDefaultFeeConfig: jest.fn(), + getTransactionStorageKey: jest.fn(), +})); + +// Mock StateManager +jest.mock('./libs/StateManager', () => ({ + StateManager: { + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + }, +})); + +const mockedSetDefaultFeeConfig = setDefaultFeeConfig as jest.MockedFunction< + typeof setDefaultFeeConfig +>; +const mockedGetTransactionStorageKey = + getTransactionStorageKey as jest.MockedFunction< + typeof getTransactionStorageKey + >; +const mockedStateManager = StateManager as jest.Mocked; describe('Snap Handlers', () => { let snap: any; @@ -45,6 +69,146 @@ describe('Snap Handlers', () => { delete (global as any).snap; }); + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('onRpcRequest handler', () => { + describe('setDefaultFeeConfig', () => { + it('should successfully set default fee config when no previous config exists', async () => { + const request = { + method: 'setDefaultFeeConfig', + params: { + contractAddress: '0x1234567890123456789012345678901234567890', + methodName: 'transfer', + config: { + 'leader-timeout-input': '60', + 'validator-timeout-input': '30', + 'number-of-appeals': '2', + }, + }, + }; + + mockedSetDefaultFeeConfig.mockResolvedValue(true); + + const result = await onRpcRequest({ origin: 'test', request } as any); + + expect(mockedSetDefaultFeeConfig).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890', + 'transfer', + { + 'leader-timeout-input': '60', + 'validator-timeout-input': '30', + 'number-of-appeals': '2', + }, + ); + expect(result).toEqual({ success: true }); + }); + + it('should return false when previous config already exists', async () => { + const request = { + method: 'setDefaultFeeConfig', + params: { + contractAddress: '0x1234567890123456789012345678901234567890', + methodName: 'transfer', + config: { + 'leader-timeout-input': '60', + }, + }, + }; + + mockedSetDefaultFeeConfig.mockResolvedValue(false); + + const result = await onRpcRequest({ origin: 'test', request } as any); + + expect(result).toEqual({ success: false }); + }); + + it('should throw error when contract address is missing', async () => { + const request = { + method: 'setDefaultFeeConfig', + params: { + methodName: 'transfer', + config: { + 'leader-timeout-input': '60', + }, + }, + }; + + await expect( + onRpcRequest({ origin: 'test', request } as any), + ).rejects.toThrow('Contract address and method name are required'); + }); + + it('should throw error when method name is missing', async () => { + const request = { + method: 'setDefaultFeeConfig', + params: { + contractAddress: '0x1234567890123456789012345678901234567890', + config: { + 'leader-timeout-input': '60', + }, + }, + }; + + await expect( + onRpcRequest({ origin: 'test', request } as any), + ).rejects.toThrow('Contract address and method name are required'); + }); + + it('should handle missing config by passing undefined', async () => { + const request = { + method: 'setDefaultFeeConfig', + params: { + contractAddress: '0x1234567890123456789012345678901234567890', + methodName: 'transfer', + }, + }; + + mockedSetDefaultFeeConfig.mockResolvedValue(false); + + const result = await onRpcRequest({ origin: 'test', request } as any); + + expect(mockedSetDefaultFeeConfig).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890', + 'transfer', + undefined, + ); + expect(result).toEqual({ success: false }); + }); + + it('should handle errors from setDefaultFeeConfig', async () => { + const request = { + method: 'setDefaultFeeConfig', + params: { + contractAddress: '0x1234567890123456789012345678901234567890', + methodName: 'transfer', + config: { + 'leader-timeout-input': '60', + }, + }, + }; + + mockedSetDefaultFeeConfig.mockRejectedValue(new Error('Storage error')); + + await expect( + onRpcRequest({ origin: 'test', request } as any), + ).rejects.toThrow('Storage error'); + }); + }); + + it('should throw error for unknown method', async () => { + const request = { + method: 'unknownMethod', + params: {}, + }; + + await expect( + onRpcRequest({ origin: 'test', request } as any), + ).rejects.toThrow('Method not found: unknownMethod'); + }); + }); + describe('onTransaction handler', () => { it('should set currentStorageKey and return the interface id', async () => { const transaction = { @@ -54,16 +218,16 @@ describe('Snap Handlers', () => { }; const mockStorageKey = '0x123456_a9059cbb'; - (getTransactionStorageKey as jest.Mock).mockReturnValue(mockStorageKey); - (StateManager.set as jest.Mock).mockResolvedValue(undefined); + mockedGetTransactionStorageKey.mockReturnValue(mockStorageKey); + mockedStateManager.set.mockResolvedValue(undefined); jest.spyOn(snap, 'request').mockResolvedValue('test-interface-id'); const result = await onTransaction({ transaction } as Parameters< typeof onTransaction >[0]); - expect(getTransactionStorageKey).toHaveBeenCalledWith(transaction); - expect(StateManager.set).toHaveBeenCalledWith( + expect(mockedGetTransactionStorageKey).toHaveBeenCalledWith(transaction); + expect(mockedStateManager.set).toHaveBeenCalledWith( 'currentStorageKey', mockStorageKey, ); @@ -72,89 +236,126 @@ describe('Snap Handlers', () => { }); describe('onUserInput handler', () => { - it('should handle an InputChangeEvent for "number-of-appeals"', async () => { - const interfaceId = 'interface-id-2'; - const mockStorageKey = '0xabcdef_a9059cbb'; + it('should handle number-of-appeals input change event', async () => { + const id = 'test-interface-id'; const event = { - type: UserInputEventType.InputChangeEvent, + type: 'InputChangeEvent', name: 'number-of-appeals', - value: '4', + value: '3', }; - const getMock = jest - .spyOn(StateManager, 'get') - .mockImplementation(async (key: string | undefined) => { - if (key === 'currentStorageKey') { - return mockStorageKey; - } - if (key === mockStorageKey) { - return { 'number-of-appeals': '2' }; - } - return {}; - }); - const requestMock = jest - .spyOn(snap, 'request') - .mockResolvedValue(undefined); - await onUserInput({ id: interfaceId, event } as Parameters< - typeof onUserInput - >[0]); - expect(getMock).toHaveBeenCalledWith('currentStorageKey'); - expect(getMock).toHaveBeenCalledWith(mockStorageKey); - expect(requestMock).toHaveBeenCalledWith({ + + mockedStateManager.get + .mockResolvedValueOnce('current-storage-key') // currentStorageKey + .mockResolvedValueOnce({ 'number-of-appeals': '2' }); // persistedData + + jest.spyOn(snap, 'request').mockResolvedValue(undefined); + + await onUserInput({ id, event } as any); + + expect(mockedStateManager.get).toHaveBeenCalledWith('currentStorageKey'); + expect(mockedStateManager.get).toHaveBeenCalledWith( + 'current-storage-key', + ); + expect(snap.request).toHaveBeenCalledWith({ method: 'snap_updateInterface', params: { - id: interfaceId, + id, ui: expect.any(Object), }, }); - getMock.mockRestore(); - requestMock.mockRestore(); }); - it('should handle a FormSubmitEvent for "advanced-options-form"', async () => { - const interfaceId = 'interface-id-3'; - const mockStorageKey = '0xabcdef_a9059cbb'; - const advancedOptionsData: AdvancedOptionsFormState = { - 'leader-timeout-input': '60', - 'validator-timeout-input': '30', - 'genlayer-storage-input': '12', - 'rollup-storage-input': '12', - 'message-gas-input': '{"gas": "value"}', - 'number-of-appeals': '2', + it('should handle cancel_config button click event', async () => { + const id = 'test-interface-id'; + const event = { + type: 'ButtonClickEvent', + name: 'cancel_config', }; + + jest.spyOn(snap, 'request').mockResolvedValue(undefined); + + await onUserInput({ id, event } as any); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_updateInterface', + params: { + id, + ui: expect.any(Object), + }, + }); + }); + + it('should handle advanced_options button click event', async () => { + const id = 'test-interface-id'; const event = { - type: UserInputEventType.FormSubmitEvent, + type: 'ButtonClickEvent', + name: 'advanced_options', + }; + + mockedStateManager.get + .mockResolvedValueOnce('current-storage-key') // currentStorageKey + .mockResolvedValueOnce({ 'number-of-appeals': '2' }); // persistedData + + jest.spyOn(snap, 'request').mockResolvedValue(undefined); + + await onUserInput({ id, event } as any); + + expect(mockedStateManager.get).toHaveBeenCalledWith('currentStorageKey'); + expect(mockedStateManager.get).toHaveBeenCalledWith( + 'current-storage-key', + ); + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_updateInterface', + params: { + id, + ui: expect.any(Object), + }, + }); + }); + + it('should handle advanced-options-form form submit event', async () => { + const id = 'test-interface-id'; + const event = { + type: 'FormSubmitEvent', name: 'advanced-options-form', - value: advancedOptionsData, + value: { + 'leader-timeout-input': '60', + 'number-of-appeals': '2', + }, }; - const getMock = jest - .spyOn(StateManager, 'get') - .mockImplementation(async (key: string | undefined) => { - if (key === 'currentStorageKey') { - return mockStorageKey; - } - return {}; - }); - const setMock = jest - .spyOn(StateManager, 'set') - .mockResolvedValue(undefined); - const requestMock = jest - .spyOn(snap, 'request') - .mockResolvedValue(undefined); - await onUserInput({ id: interfaceId, event } as unknown as Parameters< - typeof onUserInput - >[0]); - expect(getMock).toHaveBeenCalledWith('currentStorageKey'); - expect(setMock).toHaveBeenCalledWith(mockStorageKey, advancedOptionsData); - expect(requestMock).toHaveBeenCalledWith({ + + mockedStateManager.get.mockResolvedValue('current-storage-key'); + mockedStateManager.set.mockResolvedValue(undefined); + jest.spyOn(snap, 'request').mockResolvedValue(undefined); + + await onUserInput({ id, event } as any); + + expect(mockedStateManager.get).toHaveBeenCalledWith('currentStorageKey'); + expect(mockedStateManager.set).toHaveBeenCalledWith( + 'current-storage-key', + event.value, + ); + expect(snap.request).toHaveBeenCalledWith({ method: 'snap_updateInterface', params: { - id: interfaceId, + id, ui: expect.any(Object), }, }); - getMock.mockRestore(); - setMock.mockRestore(); - requestMock.mockRestore(); + }); + + it('should handle unknown button click event', async () => { + const id = 'test-interface-id'; + const event = { + type: 'ButtonClickEvent', + name: 'unknown_button', + }; + + jest.spyOn(snap, 'request').mockResolvedValue(undefined); + + await onUserInput({ id, event } as any); + + expect(snap.request).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/snap/src/index.tsx b/packages/snap/src/index.tsx index ecd6312..b7b7aa9 100644 --- a/packages/snap/src/index.tsx +++ b/packages/snap/src/index.tsx @@ -1,13 +1,43 @@ import type { + OnRpcRequestHandler, OnTransactionHandler, OnUserInputHandler, } from '@metamask/snaps-sdk'; import { UserInputEventType } from '@metamask/snaps-sdk'; -import type { AdvancedOptionsFormState } from './components'; import { AdvancedOptionsForm, TransactionConfig } from './components'; import { StateManager } from './libs/StateManager'; -import { getTransactionStorageKey } from './transactions/transaction'; +import { + getTransactionStorageKey, + setDefaultFeeConfig, +} from './transactions/transaction'; +import type { FeeConfig, FeeConfigState } from './types'; + +export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { + switch (request.method) { + case 'setDefaultFeeConfig': { + const { contractAddress, methodName, config } = request.params as { + contractAddress: string; + methodName: string; + config: Record; + }; + + if (!contractAddress || !methodName) { + throw new Error('Contract address and method name are required'); + } + + const wasSet = await setDefaultFeeConfig( + contractAddress, + methodName, + config as FeeConfig, + ); + return { success: wasSet }; + } + + default: + throw new Error(`Method not found: ${request.method}`); + } +}; export const onTransaction: OnTransactionHandler = async ({ transaction }) => { const storageKey = getTransactionStorageKey(transaction); @@ -60,7 +90,7 @@ export const onUserInput: OnUserInputHandler = async ({ id, event }) => { // eslint-disable-next-line no-case-declarations const persistedData = (await StateManager.get( currentStorageKey, - )) as AdvancedOptionsFormState; + )) as FeeConfigState; await snap.request({ method: 'snap_updateInterface', @@ -81,7 +111,7 @@ export const onUserInput: OnUserInputHandler = async ({ id, event }) => { event.name === 'advanced-options-form' ) { const currentStorageKey = await StateManager.get('currentStorageKey'); - const value = event.value as AdvancedOptionsFormState; + const value = event.value as FeeConfig; await StateManager.set(currentStorageKey, value); await snap.request({ diff --git a/packages/snap/src/transactions/transaction.test.ts b/packages/snap/src/transactions/transaction.test.ts index 73b7399..63644d7 100644 --- a/packages/snap/src/transactions/transaction.test.ts +++ b/packages/snap/src/transactions/transaction.test.ts @@ -1,13 +1,15 @@ +// Mock modules before importing import { decodeRlp, getBytes } from 'ethers'; +import { StateManager } from '../libs/StateManager'; import { extractMethodSelector, generateStorageKey, getTransactionStorageKey, parseGenLayerTransaction, + setDefaultFeeConfig, } from './transaction'; -// Mock the genlayer-js and ethers dependencies jest.mock('genlayer-js', () => ({ abi: { calldata: { @@ -29,13 +31,23 @@ jest.mock('ethers', () => ({ getBytes: jest.fn(), })); +jest.mock('../libs/StateManager', () => ({ + StateManager: { + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + }, +})); + // Import and cast the mocked modules const { Interface: MockedInterface } = jest.requireMock('ethers'); const { abi: mockedAbi } = jest.requireMock('genlayer-js'); -const mockedDecodeRlp = decodeRlp as jest.MockedFunction; -const mockedGetBytes = getBytes as jest.MockedFunction; +const mockedDecodeRlp = decodeRlp as jest.MockedFunction; +const mockedGetBytes = getBytes as jest.MockedFunction; +const mockedStateManager = StateManager as jest.Mocked; describe('Transaction Utilities', () => { beforeEach(() => { @@ -331,4 +343,127 @@ describe('Transaction Utilities', () => { expect(result).toBe('0xa0b86a33e6441d95a9c1a3b4e9b3b9b0d6b4c4b4_approve'); }); }); + + describe('setDefaultFeeConfig', () => { + const mockConfig = { + 'leader-timeout-input': '60', + 'validator-timeout-input': '30', + 'genlayer-storage-input': '0.01', + 'rollup-storage-input': '0.01', + 'message-gas-input': '0.9', + 'number-of-appeals': '2', + }; + + it('should set default fee config when no previous config exists', async () => { + const contractAddress = '0x1234567890123456789012345678901234567890'; + const methodName = 'transfer'; + + mockedStateManager.get.mockResolvedValue(null); + mockedStateManager.set.mockResolvedValue(undefined); + + const result = await setDefaultFeeConfig( + contractAddress, + methodName, + mockConfig, + ); + + expect(result).toBe(true); + expect(mockedStateManager.get).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890_transfer', + ); + expect(mockedStateManager.set).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890_transfer', + mockConfig, + ); + }); + + it('should not set default fee config when previous config exists', async () => { + const contractAddress = '0x1234567890123456789012345678901234567890'; + const methodName = 'transfer'; + + const existingConfig = { + 'leader-timeout-input': '120', + 'validator-timeout-input': '60', + }; + + mockedStateManager.get.mockResolvedValue(existingConfig); + + const result = await setDefaultFeeConfig( + contractAddress, + methodName, + mockConfig, + ); + + expect(result).toBe(false); + expect(mockedStateManager.get).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890_transfer', + ); + expect(mockedStateManager.set).not.toHaveBeenCalled(); + }); + + it('should normalize contract address to lowercase', async () => { + const contractAddress = '0X1234567890123456789012345678901234567890'; + const methodName = 'transfer'; + + mockedStateManager.get.mockResolvedValue(null); + mockedStateManager.set.mockResolvedValue(undefined); + + const result = await setDefaultFeeConfig( + contractAddress, + methodName, + mockConfig, + ); + + expect(result).toBe(true); + expect(mockedStateManager.get).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890_transfer', + ); + expect(mockedStateManager.set).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890_transfer', + mockConfig, + ); + }); + + it('should throw error when contract address is missing', async () => { + const contractAddress = ''; + const methodName = 'transfer'; + + await expect( + setDefaultFeeConfig(contractAddress, methodName, mockConfig), + ).rejects.toThrow('Contract address and method name are required'); + }); + + it('should throw error when method name is missing', async () => { + const contractAddress = '0x1234567890123456789012345678901234567890'; + const methodName = ''; + + await expect( + setDefaultFeeConfig(contractAddress, methodName, mockConfig), + ).rejects.toThrow('Contract address and method name are required'); + }); + + it('should handle partial fee config', async () => { + const contractAddress = '0x1234567890123456789012345678901234567890'; + const methodName = 'transfer'; + const partialConfig = { + 'leader-timeout-input': '60', + 'number-of-appeals': '3', + }; + + mockedStateManager.get.mockResolvedValue(null); + mockedStateManager.set.mockResolvedValue(undefined); + + const result = await setDefaultFeeConfig( + contractAddress, + methodName, + partialConfig, + ); + + expect(result).toBe(true); + expect(mockedStateManager.set).toHaveBeenCalledWith( + '0x1234567890123456789012345678901234567890_transfer', + partialConfig, + ); + }); + }); }); diff --git a/packages/snap/src/transactions/transaction.ts b/packages/snap/src/transactions/transaction.ts index a07d4cc..53f43e1 100644 --- a/packages/snap/src/transactions/transaction.ts +++ b/packages/snap/src/transactions/transaction.ts @@ -2,6 +2,9 @@ import type { BytesLike } from 'ethers'; import { decodeRlp, getBytes, Interface } from 'ethers'; import { abi, chains } from 'genlayer-js'; +import { StateManager } from '../libs/StateManager'; +import type { FeeConfig } from '../types'; + /** * Transaction handling and storage key generation. */ @@ -93,3 +96,33 @@ export function getTransactionStorageKey(transaction: { return storageKey; } + +/** + * Sets default fee configuration for a specific contract and method only if no previous config exists. + * @param contractAddress - The contract address. + * @param methodName - The method name. + * @param config - The default fee configuration. + * @returns True if config was set, false if a previous config already existed. + */ +export async function setDefaultFeeConfig( + contractAddress: string, + methodName: string, + config: FeeConfig, +): Promise { + if (!contractAddress || !methodName) { + throw new Error('Contract address and method name are required'); + } + const storageKey = generateStorageKey(contractAddress, methodName); + + const existingConfig = await StateManager.get(storageKey); + if (existingConfig) { + return false; + } + + const defaultConfig = { + ...config, + }; + + await StateManager.set(storageKey, defaultConfig); + return true; +} diff --git a/packages/snap/src/types/index.ts b/packages/snap/src/types/index.ts new file mode 100644 index 0000000..c1bb8a1 --- /dev/null +++ b/packages/snap/src/types/index.ts @@ -0,0 +1,23 @@ +/** + * Base fee configuration interface for contract methods. + * This type is shared across the snap for consistency. + */ +export type FeeConfig = { + 'leader-timeout-input'?: string; + 'validator-timeout-input'?: string; + 'genlayer-storage-input'?: string; + 'rollup-storage-input'?: string; + 'message-gas-input'?: string; + 'number-of-appeals'?: string; + [key: string]: string | undefined; +}; + +/** + * Fee configuration state for form components. + * JSON-compatible version of FeeConfig with all properties required. + */ +export type FeeConfigState = { + [K in keyof FeeConfig]: string; +} & { + [key: string]: string; +};