diff --git a/web/src/components/Dropdown/Dropdown.test.tsx b/web/src/components/Dropdown/Dropdown.test.tsx index 34ff0fa..427fb02 100644 --- a/web/src/components/Dropdown/Dropdown.test.tsx +++ b/web/src/components/Dropdown/Dropdown.test.tsx @@ -57,7 +57,8 @@ describe("Dropdown Component", () => { const selectedOption = screen.getByText("Option 3"); expect( - selectedOption?.parentElement?.parentElement?.parentElement?.parentElement, + selectedOption?.parentElement?.parentElement?.parentElement + ?.parentElement, ).toHaveClass("is-active"); }); diff --git a/web/src/components/WithdrawCard/WithdrawCard.tsx b/web/src/components/WithdrawCard/WithdrawCard.tsx index 77e0bb9..4c8b589 100644 --- a/web/src/components/WithdrawCard/WithdrawCard.tsx +++ b/web/src/components/WithdrawCard/WithdrawCard.tsx @@ -175,7 +175,8 @@ export default function WithdrawCard(): React.ReactElement { !selectedWallet || !selectedEvmCurrency || !isAmountValid || - !toAddress + !toAddress || + !selectedEvmCurrency?.evmWithdrawerContractAddress ) { console.warn( "Withdrawal cannot proceed: missing required fields or fields are invalid", @@ -194,6 +195,7 @@ export default function WithdrawCard(): React.ReactElement { const withdrawerSvc = getAstriaWithdrawerService( selectedWallet.provider, selectedEvmCurrency.evmWithdrawerContractAddress, + true, // FIXME - how to determine when erc20? just add flag to config? ); await withdrawerSvc.withdrawToIbcChain( fromAddress, diff --git a/web/src/features/EthWallet/services/AstriaWithdrawerService/AstriaWithdrawerService.test.ts b/web/src/features/EthWallet/services/AstriaWithdrawerService/AstriaWithdrawerService.test.ts index fa4cd24..e117516 100644 --- a/web/src/features/EthWallet/services/AstriaWithdrawerService/AstriaWithdrawerService.test.ts +++ b/web/src/features/EthWallet/services/AstriaWithdrawerService/AstriaWithdrawerService.test.ts @@ -1,9 +1,13 @@ import { ethers } from "ethers"; -import { getAstriaWithdrawerService } from "./AstriaWithdrawerService"; +import { + getAstriaWithdrawerService, + AstriaWithdrawerService, + AstriaErc20WithdrawerService, +} from "./AstriaWithdrawerService"; jest.mock("ethers"); -describe("AstriaWithdrawerService", () => { +describe("AstriaWithdrawerService and AstriaErc20WithdrawerService", () => { const mockContractAddress = "0x1234567890123456789012345678901234567890"; const mockFromAddress = "0x9876543210987654321098765432109876543210"; const mockDestinationAddress = @@ -18,57 +22,202 @@ describe("AstriaWithdrawerService", () => { beforeEach(() => { jest.resetAllMocks(); - // mock ethers.BrowserProvider mockProvider = { getSigner: jest.fn(), } as unknown as jest.Mocked; - // mock ethers.JsonRpcSigner mockSigner = {} as jest.Mocked; - // mock ethers.Contract mockContract = { withdrawToSequencer: jest.fn(), withdrawToIbcChain: jest.fn(), } as unknown as jest.Mocked; - // setup mocks (ethers.BrowserProvider as jest.Mock).mockReturnValue(mockProvider); mockProvider.getSigner.mockResolvedValue(mockSigner); - (ethers.Contract as unknown as jest.Mock).mockReturnValue(mockContract); - (ethers.parseEther as jest.Mock).mockReturnValue(mockAmount); - }); - - it("should throw an error if provider is not supplied on first instantiation", () => { - expect(() => getAstriaWithdrawerService()).toThrow( - "Provider must be supplied when creating the service instance", + (ethers.Contract as jest.Mock).mockReturnValue(mockContract); + (ethers.parseEther as jest.Mock).mockReturnValue( + ethers.parseUnits(mockAmount, 18), ); }); - it("should throw an error if contract address is not supplied on first instantiation", () => { - expect(() => - getAstriaWithdrawerService({} as ethers.Eip1193Provider), - ).toThrow( - "Contract address must be supplied when creating the service instance", - ); + describe("AstriaWithdrawerService", () => { + it("should create a singleton instance", () => { + const service1 = getAstriaWithdrawerService( + {} as ethers.Eip1193Provider, + mockContractAddress, + ); + const service2 = getAstriaWithdrawerService( + {} as ethers.Eip1193Provider, + mockContractAddress, + ); + + expect(service1).toBe(service2); + expect(service1).toBeInstanceOf(AstriaWithdrawerService); + }); + + it("should update provider when a new one is supplied", () => { + const initialProvider = {} as ethers.Eip1193Provider; + const newProvider = {} as ethers.Eip1193Provider; + + const service1 = getAstriaWithdrawerService( + initialProvider, + mockContractAddress, + ); + const service2 = getAstriaWithdrawerService( + newProvider, + mockContractAddress, + ); + + expect(service1).toBe(service2); + expect(ethers.BrowserProvider).toHaveBeenCalledTimes(2); + expect(ethers.BrowserProvider).toHaveBeenNthCalledWith( + 1, + initialProvider, + ); + expect(ethers.BrowserProvider).toHaveBeenNthCalledWith(2, newProvider); + }); + + it("should call withdrawToSequencer with correct parameters", async () => { + const service = getAstriaWithdrawerService( + {} as ethers.Eip1193Provider, + mockContractAddress, + ) as AstriaWithdrawerService; + + await service.withdrawToSequencer( + mockFromAddress, + mockDestinationAddress, + mockAmount, + ); + + expect(mockContract.withdrawToSequencer).toHaveBeenCalledWith( + mockDestinationAddress, + { value: ethers.parseUnits(mockAmount, 18) }, + ); + }); + + it("should call withdrawToIbcChain with correct parameters", async () => { + const service = getAstriaWithdrawerService( + {} as ethers.Eip1193Provider, + mockContractAddress, + ) as AstriaWithdrawerService; + + await service.withdrawToIbcChain( + mockFromAddress, + mockDestinationAddress, + mockAmount, + mockMemo, + ); + + expect(mockContract.withdrawToIbcChain).toHaveBeenCalledWith( + mockDestinationAddress, + mockMemo, + { value: ethers.parseUnits(mockAmount, 18) }, + ); + }); }); - it("should create a singleton instance", () => { - const service1 = getAstriaWithdrawerService( - {} as ethers.Eip1193Provider, - mockContractAddress, - ); - const service2 = getAstriaWithdrawerService(); + describe("AstriaErc20WithdrawerService", () => { + it("should create a singleton instance", () => { + const service1 = getAstriaWithdrawerService( + {} as ethers.Eip1193Provider, + mockContractAddress, + true, + ); + const service2 = getAstriaWithdrawerService( + {} as ethers.Eip1193Provider, + mockContractAddress, + true, + ); + + expect(service1).toBe(service2); + expect(service1).toBeInstanceOf(AstriaErc20WithdrawerService); + }); + + it("should update provider when a new one is supplied", () => { + const initialProvider = {} as ethers.Eip1193Provider; + const newProvider = {} as ethers.Eip1193Provider; + + const service1 = getAstriaWithdrawerService( + initialProvider, + mockContractAddress, + true, + ); + const service2 = getAstriaWithdrawerService( + newProvider, + mockContractAddress, + true, + ); + + expect(service1).toBe(service2); + expect(ethers.BrowserProvider).toHaveBeenCalledTimes(2); + expect(ethers.BrowserProvider).toHaveBeenNthCalledWith( + 1, + initialProvider, + ); + expect(ethers.BrowserProvider).toHaveBeenNthCalledWith(2, newProvider); + }); + + it("should call withdrawToSequencer with correct parameters", async () => { + const service = getAstriaWithdrawerService( + {} as ethers.Eip1193Provider, + mockContractAddress, + true, + ) as AstriaErc20WithdrawerService; + + await service.withdrawToSequencer( + mockFromAddress, + mockDestinationAddress, + mockAmount, + ); + + expect(mockContract.withdrawToSequencer).toHaveBeenCalledWith( + ethers.parseUnits(mockAmount, 18), + mockDestinationAddress, + { value: ethers.parseUnits(mockAmount, 18) }, + ); + }); + + it("should call withdrawToIbcChain with correct parameters", async () => { + const service = getAstriaWithdrawerService( + {} as ethers.Eip1193Provider, + mockContractAddress, + true, + ) as AstriaErc20WithdrawerService; + + await service.withdrawToIbcChain( + mockFromAddress, + mockDestinationAddress, + mockAmount, + mockMemo, + ); - expect(service1).toBe(service2); + expect(mockContract.withdrawToIbcChain).toHaveBeenCalledWith( + ethers.parseUnits(mockAmount, 18), + mockDestinationAddress, + mockMemo, + { value: ethers.parseUnits(mockAmount, 18) }, + ); + }); }); - it("should update provider when a new one is supplied", () => { - const service = getAstriaWithdrawerService({} as ethers.Eip1193Provider); - const newProvider = {} as ethers.Eip1193Provider; - const updatedService = getAstriaWithdrawerService(newProvider); + describe("getAstriaWithdrawerService", () => { + it("should return AstriaWithdrawerService when isErc20 is false", () => { + const service = getAstriaWithdrawerService( + {} as ethers.Eip1193Provider, + mockContractAddress, + false, + ); + expect(service).toBeInstanceOf(AstriaWithdrawerService); + }); - expect(service).toBe(updatedService); - expect(ethers.BrowserProvider).toHaveBeenCalledTimes(2); + it("should return AstriaErc20WithdrawerService when isErc20 is true", () => { + const service = getAstriaWithdrawerService( + {} as ethers.Eip1193Provider, + mockContractAddress, + true, + ); + expect(service).toBeInstanceOf(AstriaErc20WithdrawerService); + }); }); }); diff --git a/web/src/features/EthWallet/services/AstriaWithdrawerService/AstriaWithdrawerService.ts b/web/src/features/EthWallet/services/AstriaWithdrawerService/AstriaWithdrawerService.ts index 3173474..a867ba2 100644 --- a/web/src/features/EthWallet/services/AstriaWithdrawerService/AstriaWithdrawerService.ts +++ b/web/src/features/EthWallet/services/AstriaWithdrawerService/AstriaWithdrawerService.ts @@ -1,79 +1,59 @@ import { ethers } from "ethers"; -const ABI = [ - "function withdrawToSequencer(string destinationChainAddress) payable", - "function withdrawToIbcChain(string destinationChainAddress, string memo) payable", -]; - -/** - * Service for interacting with the Astria withdrawer contract. - * - * This service is used to withdraw funds from the Astria EVM to the Sequencer or to an IBC chain. - */ -export class AstriaWithdrawerService { - private static instance: AstriaWithdrawerService | null = null; - private walletProvider: ethers.BrowserProvider; - private readonly contractAddress: string; - private contractPromise: Promise | null = null; - - private constructor( +export class GenericContractService { + protected static instances: Map = new Map(); + protected static ABI: ethers.InterfaceAbi; + protected walletProvider: ethers.BrowserProvider; + protected readonly contractAddress: string; + protected readonly abi: ethers.InterfaceAbi; + protected contractPromise: Promise | null = null; + + protected constructor( walletProvider: ethers.Eip1193Provider, contractAddress: string, ) { this.walletProvider = new ethers.BrowserProvider(walletProvider); this.contractAddress = contractAddress; + this.abi = (this.constructor as typeof GenericContractService).ABI; + } + + protected static getInstanceKey(contractAddress: string): string { + /* biome-ignore lint/complexity/noThisInStatic: */ + return `${this.name}-${contractAddress}`; } - /** - * Get the singleton instance of the AstriaWithdrawerService. - * If a provider is supplied, it will be used to create a new instance. - * @param provider - * @param contractAddress - */ public static getInstance( - provider?: ethers.Eip1193Provider, - contractAddress?: string, - ): AstriaWithdrawerService { - if (!AstriaWithdrawerService.instance) { - if (!provider) { - throw new Error( - "Provider must be supplied when creating the service instance", - ); - } - if (!contractAddress) { - throw new Error( - "Contract address must be supplied when creating the service instance", - ); - } - AstriaWithdrawerService.instance = new AstriaWithdrawerService( - provider, - contractAddress, - ); - } else if (provider) { - // update the provider if one is supplied - AstriaWithdrawerService.instance.updateProvider(provider); + provider: ethers.Eip1193Provider, + contractAddress: string, + ): GenericContractService { + /* biome-ignore lint/complexity/noThisInStatic: */ + const key = this.getInstanceKey(contractAddress); + /* biome-ignore lint/complexity/noThisInStatic: */ + let instance = this.instances.get(key); + + if (!instance) { + /* biome-ignore lint/complexity/noThisInStatic: */ + instance = new this(provider, contractAddress); + /* biome-ignore lint/complexity/noThisInStatic: */ + this.instances.set(key, instance); + } else { + instance.updateProvider(provider); } - return AstriaWithdrawerService.instance; + + return instance; } - private updateProvider(provider: ethers.Eip1193Provider): void { + protected updateProvider(provider: ethers.Eip1193Provider): void { this.walletProvider = new ethers.BrowserProvider(provider); - // reset the contract promise to force a new contract creation this.contractPromise = null; } - /** - * Get the withdrawer contract instance. - * Caches the contract instance to avoid unnecessary contract creation. - * @param address - * @private - */ - private async getContract(address: string): Promise { + protected async getContract(address: string): Promise { if (!this.contractPromise) { this.contractPromise = (async () => { try { const signer = await this.walletProvider.getSigner(address); - return new ethers.Contract(this.contractAddress, ABI, signer); + return new ethers.Contract(this.contractAddress, this.abi, signer); } catch (error) { this.contractPromise = null; throw error; @@ -83,22 +63,56 @@ export class AstriaWithdrawerService { return this.contractPromise; } - async withdrawToSequencer( + protected async callContractMethod( + methodName: string, fromAddress: string, - destinationChainAddress: string, - amount: string, + args: unknown[], + value?: ethers.BigNumberish, ): Promise { try { - const amountWei = ethers.parseEther(amount); const contract = await this.getContract(fromAddress); - return contract.withdrawToSequencer(destinationChainAddress, { - value: amountWei, - }); + const method = contract[methodName]; + if (!method) { + throw new Error(`Method ${methodName} not found in contract`); + } + return method(...args, { value }); } catch (error) { - console.error("Error in withdrawToSequencer:", error); + console.error(`Error in ${methodName}:`, error); throw error; } } +} + +export class AstriaWithdrawerService extends GenericContractService { + protected static override ABI: ethers.InterfaceAbi = [ + "function withdrawToSequencer(string destinationChainAddress) payable", + "function withdrawToIbcChain(string destinationChainAddress, string memo) payable", + ]; + + public static override getInstance( + provider: ethers.Eip1193Provider, + contractAddress: string, + ): AstriaWithdrawerService { + /* biome-ignore lint/complexity/noThisInStatic: */ + return super.getInstance( + provider, + contractAddress, + ) as AstriaWithdrawerService; + } + + async withdrawToSequencer( + fromAddress: string, + destinationChainAddress: string, + amount: string, + ): Promise { + const amountWei = ethers.parseEther(amount); + return this.callContractMethod( + "withdrawToSequencer", + fromAddress, + [destinationChainAddress], + amountWei, + ); + } async withdrawToIbcChain( fromAddress: string, @@ -106,27 +120,73 @@ export class AstriaWithdrawerService { amount: string, memo: string, ): Promise { - try { - const amountWei = ethers.parseEther(amount); - const contract = await this.getContract(fromAddress); - return contract.withdrawToIbcChain(destinationChainAddress, memo, { - value: amountWei, - }); - } catch (error) { - console.error("Error in withdrawToIbcChain:", error); - throw error; - } + const amountWei = ethers.parseEther(amount); + return this.callContractMethod( + "withdrawToIbcChain", + fromAddress, + [destinationChainAddress, memo], + amountWei, + ); } } -/** - * Get the singleton instance of the AstriaWithdrawerService. - * @param provider - * @param contractAddress - */ +export class AstriaErc20WithdrawerService extends GenericContractService { + protected static override ABI: ethers.InterfaceAbi = [ + "function withdrawToSequencer(uint256 amount, string destinationChainAddress) payable", + "function withdrawToIbcChain(uint256 amount, string destinationChainAddress, string memo) payable", + ]; + + public static override getInstance( + provider: ethers.Eip1193Provider, + contractAddress: string, + ): AstriaErc20WithdrawerService { + /* biome-ignore lint/complexity/noThisInStatic: */ + return super.getInstance( + provider, + contractAddress, + ) as AstriaErc20WithdrawerService; + } + async withdrawToSequencer( + fromAddress: string, + destinationChainAddress: string, + amount: string, + ): Promise { + const amountWei = ethers.parseEther(amount); + return this.callContractMethod("withdrawToSequencer", fromAddress, [ + amountWei, + destinationChainAddress, + ]); + } + + async withdrawToIbcChain( + fromAddress: string, + destinationChainAddress: string, + amount: string, + memo: string, + ): Promise { + const amountWei = ethers.parseEther(amount); + return this.callContractMethod("withdrawToIbcChain", fromAddress, [ + amountWei, + destinationChainAddress, + memo, + ]); + } +} + +// Helper function to get AstriaWithdrawerService instance export const getAstriaWithdrawerService = ( - provider?: ethers.Eip1193Provider, - contractAddress?: string, -): AstriaWithdrawerService => { - return AstriaWithdrawerService.getInstance(provider, contractAddress); + provider: ethers.Eip1193Provider, + contractAddress: string, + isErc20 = false, +): AstriaWithdrawerService | AstriaErc20WithdrawerService => { + if (isErc20) { + return AstriaErc20WithdrawerService.getInstance( + provider, + contractAddress, + ) as AstriaErc20WithdrawerService; + } + return AstriaWithdrawerService.getInstance( + provider, + contractAddress, + ) as AstriaWithdrawerService; };