diff --git a/packages/bridge-ui/src/App.svelte b/packages/bridge-ui/src/App.svelte index 5342eb9e00..9aa4c28752 100644 --- a/packages/bridge-ui/src/App.svelte +++ b/packages/bridge-ui/src/App.svelte @@ -5,25 +5,22 @@ import Navbar from "./components/Navbar.svelte"; import { SvelteToast } from "@zerodevx/svelte-toast"; - import { onMount } from "svelte"; import Home from "./pages/home/Home.svelte"; import { setupI18n } from "./i18n"; import { BridgeType } from "./domain/bridge"; import ETHBridge from "./eth/bridge"; import { bridges, chainIdToBridgeAddress } from "./store/bridge"; import { CHAIN_MAINNET, CHAIN_TKO } from "./domain/chain"; - - const { chains, provider } = configureChains( - [mainnet, taiko], - [publicProvider()] - ); + import ERC20Bridge from "./erc20/bridge"; setupI18n({ withLocale: "en" }); const ethBridge = new ETHBridge(); + const erc20Bridge = new ERC20Bridge(); bridges.update((store) => { store.set(BridgeType.ETH, ethBridge); + store.set(BridgeType.ERC20, erc20Bridge); return store; }); @@ -46,7 +43,6 @@
-
diff --git a/packages/bridge-ui/src/components/buttons/SelectToken.svelte b/packages/bridge-ui/src/components/buttons/SelectToken.svelte index 889f078ad1..0f7a5bb1b0 100644 --- a/packages/bridge-ui/src/components/buttons/SelectToken.svelte +++ b/packages/bridge-ui/src/components/buttons/SelectToken.svelte @@ -1,13 +1,20 @@ diff --git a/packages/bridge-ui/src/components/form/BridgeForm.svelte b/packages/bridge-ui/src/components/form/BridgeForm.svelte index 8a1411d259..620fbc11d7 100644 --- a/packages/bridge-ui/src/components/form/BridgeForm.svelte +++ b/packages/bridge-ui/src/components/form/BridgeForm.svelte @@ -2,21 +2,53 @@ import { _ } from "svelte-i18n"; import { token } from "../../store/token"; import { fromChain, toChain } from "../../store/chain"; - import { activeBridge, chainIdToBridgeAddress } from "../../store/bridge"; + import { + activeBridge, + chainIdToBridgeAddress, + bridgeType, + } from "../../store/bridge"; import { signer } from "../../store/signer"; import { BigNumber, ethers, Signer } from "ethers"; import { toast } from "@zerodevx/svelte-toast"; + import type { Token } from "../../domain/token"; + import type { BridgeType } from "../../domain/bridge"; + import type { Chain } from "../../domain/chain"; let amount: string; + let requiresAllowance: boolean = true; let btnDisabled: boolean = true; $: isBtnDisabled($signer, amount) .then((d) => (btnDisabled = d)) .catch((e) => console.log(e)); + $: checkAllowance(amount, $token, $bridgeType, $fromChain, $signer) + .then((a) => (requiresAllowance = a)) + .catch((e) => console.log(e)); + + async function checkAllowance( + amt: string, + token: Token, + bridgeType: BridgeType, + fromChain: Chain, + signer: Signer + ) { + if (!signer || !amt || !token || !fromChain) return true; + + return await $activeBridge.RequiresAllowance({ + amountInWei: amt + ? ethers.utils.parseUnits(amt, token.decimals) + : BigNumber.from(0), + signer: signer, + contractAddress: token.address, + spenderAddress: $chainIdToBridgeAddress.get(fromChain.id), + }); + } + async function isBtnDisabled(signer: Signer, amount: string) { if (!signer) return true; if (!amount) return true; + if (requiresAllowance) return true; const balance = await signer.getBalance("latest"); if (balance.lt(ethers.utils.parseUnits(amount, $token.decimals))) return true; @@ -24,8 +56,33 @@ return false; } + async function approve() { + try { + if (!requiresAllowance) + throw Error("does not require additional allowance"); + + const tx = await $activeBridge.Approve({ + amountInWei: ethers.utils.parseUnits(amount, $token.decimals), + signer: $signer, + contractAddress: $token.address, + spenderAddress: $chainIdToBridgeAddress.get($fromChain.id), + }); + console.log("approved, waiting for confirmations ", tx); + await $signer.provider.waitForTransaction(tx.hash, 3); + + requiresAllowance = false; + + toast.push($_("toast.transactionSent")); + } catch (e) { + console.log(e); + toast.push($_("toast.errorSendingTransaction")); + } + } + async function bridge() { try { + if (requiresAllowance) throw Error("requires additional allowance"); + const tx = await $activeBridge.Bridge({ amountInWei: ethers.utils.parseUnits(amount, $token.decimals), signer: $signer, @@ -76,10 +133,20 @@ - +{#if !requiresAllowance} + +{:else} + +{/if} diff --git a/packages/bridge-ui/src/constants/abi/ERC20.ts b/packages/bridge-ui/src/constants/abi/ERC20.ts new file mode 100644 index 0000000000..f8bd8a6651 --- /dev/null +++ b/packages/bridge-ui/src/constants/abi/ERC20.ts @@ -0,0 +1,222 @@ +export default [ + { + constant: true, + inputs: [], + name: "name", + outputs: [ + { + name: "", + type: "string", + }, + ], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: false, + inputs: [ + { + name: "_spender", + type: "address", + }, + { + name: "_value", + type: "uint256", + }, + ], + name: "approve", + outputs: [ + { + name: "", + type: "bool", + }, + ], + payable: false, + stateMutability: "nonpayable", + type: "function", + }, + { + constant: true, + inputs: [], + name: "totalSupply", + outputs: [ + { + name: "", + type: "uint256", + }, + ], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: false, + inputs: [ + { + name: "_from", + type: "address", + }, + { + name: "_to", + type: "address", + }, + { + name: "_value", + type: "uint256", + }, + ], + name: "transferFrom", + outputs: [ + { + name: "", + type: "bool", + }, + ], + payable: false, + stateMutability: "nonpayable", + type: "function", + }, + { + constant: true, + inputs: [], + name: "decimals", + outputs: [ + { + name: "", + type: "uint8", + }, + ], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: true, + inputs: [ + { + name: "_owner", + type: "address", + }, + ], + name: "balanceOf", + outputs: [ + { + name: "balance", + type: "uint256", + }, + ], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: true, + inputs: [], + name: "symbol", + outputs: [ + { + name: "", + type: "string", + }, + ], + payable: false, + stateMutability: "view", + type: "function", + }, + { + constant: false, + inputs: [ + { + name: "_to", + type: "address", + }, + { + name: "_value", + type: "uint256", + }, + ], + name: "transfer", + outputs: [ + { + name: "", + type: "bool", + }, + ], + payable: false, + stateMutability: "nonpayable", + type: "function", + }, + { + constant: true, + inputs: [ + { + name: "_owner", + type: "address", + }, + { + name: "_spender", + type: "address", + }, + ], + name: "allowance", + outputs: [ + { + name: "", + type: "uint256", + }, + ], + payable: false, + stateMutability: "view", + type: "function", + }, + { + payable: true, + stateMutability: "payable", + type: "fallback", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + name: "owner", + type: "address", + }, + { + indexed: true, + name: "spender", + type: "address", + }, + { + indexed: false, + name: "value", + type: "uint256", + }, + ], + name: "Approval", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + name: "from", + type: "address", + }, + { + indexed: true, + name: "to", + type: "address", + }, + { + indexed: false, + name: "value", + type: "uint256", + }, + ], + name: "Transfer", + type: "event", + }, +]; diff --git a/packages/bridge-ui/src/domain/bridge.ts b/packages/bridge-ui/src/domain/bridge.ts index 63eeb9caff..9767f95b38 100644 --- a/packages/bridge-ui/src/domain/bridge.ts +++ b/packages/bridge-ui/src/domain/bridge.ts @@ -11,6 +11,7 @@ type ApproveOpts = { amountInWei: BigNumber; contractAddress: string; signer: ethers.Signer; + spenderAddress: string; }; type BridgeOpts = { @@ -26,6 +27,7 @@ type BridgeOpts = { }; interface Bridge { + RequiresAllowance(opts: ApproveOpts): Promise; Approve(opts: ApproveOpts): Promise; Bridge(opts: BridgeOpts): Promise; } diff --git a/packages/bridge-ui/src/erc20/bridge.spec.ts b/packages/bridge-ui/src/erc20/bridge.spec.ts new file mode 100644 index 0000000000..8b329410da --- /dev/null +++ b/packages/bridge-ui/src/erc20/bridge.spec.ts @@ -0,0 +1,228 @@ +import { BigNumber, Wallet } from "ethers"; +import { mainnet, taiko } from "../domain/chain"; +import type { ApproveOpts, Bridge, BridgeOpts } from "../domain/bridge"; +import ERC20Bridge from "./bridge"; + +const mockSigner = { + getAddress: jest.fn(), +}; + +const mockContract = { + sendERC20: jest.fn(), + allowance: jest.fn(), + approve: jest.fn(), +}; + +jest.mock("ethers", () => ({ + /* eslint-disable-next-line */ + ...(jest.requireActual("ethers") as object), + Wallet: function () { + return mockSigner; + }, + Signer: function () { + return mockSigner; + }, + Contract: function () { + return mockContract; + }, +})); + +const wallet = new Wallet("0x"); + +const opts: BridgeOpts = { + amountInWei: BigNumber.from(1), + signer: wallet, + tokenAddress: "0xtoken", + fromChainId: mainnet.id, + toChainId: taiko.id, + bridgeAddress: "0x456", + processingFeeInWei: BigNumber.from(2), + memo: "memo", +}; + +const approveOpts: ApproveOpts = { + amountInWei: BigNumber.from(1), + signer: wallet, + contractAddress: "0x456", + spenderAddress: "0x789", +}; + +describe("bridge tests", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("requires allowance returns true when allowance has not been set", async () => { + mockContract.allowance.mockImplementationOnce(() => + opts.amountInWei.sub(1) + ); + + mockSigner.getAddress.mockImplementationOnce(() => "0xfake"); + + const bridge: Bridge = new ERC20Bridge(); + + expect(mockContract.allowance).not.toHaveBeenCalled(); + const requires = await bridge.RequiresAllowance(approveOpts); + + expect(mockSigner.getAddress).toHaveBeenCalled(); + expect(mockContract.allowance).toHaveBeenCalledWith( + "0xfake", + approveOpts.spenderAddress + ); + expect(requires).toBe(true); + }); + + it("requires allowance returns true when allowance is > than amount", async () => { + mockContract.allowance.mockImplementationOnce(() => + opts.amountInWei.add(1) + ); + mockSigner.getAddress.mockImplementationOnce(() => "0xfake"); + + const bridge: Bridge = new ERC20Bridge(); + + expect(mockContract.allowance).not.toHaveBeenCalled(); + const requires = await bridge.RequiresAllowance(approveOpts); + + expect(mockSigner.getAddress).toHaveBeenCalled(); + expect(mockContract.allowance).toHaveBeenCalledWith( + "0xfake", + approveOpts.spenderAddress + ); + expect(requires).toBe(false); + }); + + it("requires allowance returns true when allowance is === amount", async () => { + mockContract.allowance.mockImplementationOnce(() => opts.amountInWei); + mockSigner.getAddress.mockImplementationOnce(() => "0xfake"); + + const bridge: Bridge = new ERC20Bridge(); + + expect(mockContract.allowance).not.toHaveBeenCalled(); + const requires = await bridge.RequiresAllowance(approveOpts); + + expect(mockSigner.getAddress).toHaveBeenCalled(); + expect(mockContract.allowance).toHaveBeenCalledWith( + "0xfake", + approveOpts.spenderAddress + ); + expect(requires).toBe(false); + }); + + it("approve throws when amount is already greater than whats set", async () => { + mockContract.allowance.mockImplementationOnce(() => + opts.amountInWei.add(1) + ); + + mockSigner.getAddress.mockImplementationOnce(() => "0xfake"); + + const bridge: Bridge = new ERC20Bridge(); + + expect(mockContract.allowance).not.toHaveBeenCalled(); + await expect(bridge.Approve(approveOpts)).rejects.toThrowError( + "token vault already has required allowance" + ); + + expect(mockSigner.getAddress).toHaveBeenCalled(); + expect(mockContract.allowance).toHaveBeenCalledWith( + "0xfake", + approveOpts.spenderAddress + ); + }); + + it("approve succeeds when allowance is less than what is being requested", async () => { + mockContract.allowance.mockImplementationOnce(() => + opts.amountInWei.sub(1) + ); + + mockSigner.getAddress.mockImplementationOnce(() => "0xfake"); + + const bridge: Bridge = new ERC20Bridge(); + + expect(mockContract.allowance).not.toHaveBeenCalled(); + await bridge.Approve(approveOpts); + + expect(mockSigner.getAddress).toHaveBeenCalled(); + expect(mockContract.allowance).toHaveBeenCalledWith( + "0xfake", + approveOpts.spenderAddress + ); + expect(mockContract.approve).toHaveBeenCalledWith( + approveOpts.spenderAddress, + approveOpts.amountInWei + ); + }); + + it("bridge throws when requires approval", async () => { + mockContract.allowance.mockImplementationOnce(() => + opts.amountInWei.sub(1) + ); + + const bridge: Bridge = new ERC20Bridge(); + + expect(mockContract.sendERC20).not.toHaveBeenCalled(); + + await expect(bridge.Bridge(opts)).rejects.toThrowError( + "token vault does not have required allowance" + ); + + expect(mockContract.sendERC20).not.toHaveBeenCalled(); + }); + + it("bridge calls senderc20 when doesnt requires approval", async () => { + mockContract.allowance.mockImplementationOnce(() => + opts.amountInWei.add(1) + ); + mockSigner.getAddress.mockImplementation(() => "0xfake"); + + const bridge: Bridge = new ERC20Bridge(); + + expect(mockContract.sendERC20).not.toHaveBeenCalled(); + + await bridge.Bridge(opts); + + expect(mockContract.sendERC20).toHaveBeenCalled(); + expect(mockContract.sendERC20).toHaveBeenCalledWith( + opts.toChainId, + "0xfake", + opts.tokenAddress, + opts.amountInWei, + BigNumber.from(100000), + opts.processingFeeInWei, + "0xfake", + opts.memo + ); + }); + + it("bridge calls senderc20 when doesnt requires approval, with no processing fee and memo", async () => { + mockContract.allowance.mockImplementationOnce(() => + opts.amountInWei.add(1) + ); + mockSigner.getAddress.mockImplementation(() => "0xfake"); + + const bridge: Bridge = new ERC20Bridge(); + + expect(mockContract.sendERC20).not.toHaveBeenCalled(); + + const opts: BridgeOpts = { + amountInWei: BigNumber.from(1), + signer: wallet, + tokenAddress: "0xtoken", + fromChainId: mainnet.id, + toChainId: taiko.id, + bridgeAddress: "0x456", + }; + + await bridge.Bridge(opts); + + expect(mockContract.sendERC20).toHaveBeenCalledWith( + opts.toChainId, + "0xfake", + opts.tokenAddress, + opts.amountInWei, + BigNumber.from(0), + BigNumber.from(0), + "0xfake", + "" + ); + }); +}); diff --git a/packages/bridge-ui/src/erc20/bridge.ts b/packages/bridge-ui/src/erc20/bridge.ts new file mode 100644 index 0000000000..ad59c5e002 --- /dev/null +++ b/packages/bridge-ui/src/erc20/bridge.ts @@ -0,0 +1,102 @@ +import { BigNumber, Contract, Signer } from "ethers"; +import type { Transaction } from "ethers"; +import type { ApproveOpts, Bridge, BridgeOpts } from "../domain/bridge"; +import TokenVault from "../constants/abi/TokenVault"; +import ERC20 from "../constants/abi/ERC20"; + +class ERC20Bridge implements Bridge { + private async spenderRequiresAllowance( + tokenAddress: string, + signer: Signer, + amount: BigNumber, + bridgeAddress: string + ): Promise { + const contract: Contract = new Contract(tokenAddress, ERC20, signer); + const owner = await signer.getAddress(); + const allowance: BigNumber = await contract.allowance(owner, bridgeAddress); + + return allowance.lt(amount); + } + + async RequiresAllowance(opts: ApproveOpts): Promise { + return await this.spenderRequiresAllowance( + opts.contractAddress, + opts.signer, + opts.amountInWei, + opts.spenderAddress + ); + } + + async Approve(opts: ApproveOpts): Promise { + if ( + !(await this.spenderRequiresAllowance( + opts.contractAddress, + opts.signer, + opts.amountInWei, + opts.spenderAddress + )) + ) { + throw Error("token vault already has required allowance"); + } + + const contract: Contract = new Contract( + opts.contractAddress, + ERC20, + opts.signer + ); + + const tx = await contract.approve(opts.spenderAddress, opts.amountInWei); + return tx; + } + + async Bridge(opts: BridgeOpts): Promise { + if ( + await this.spenderRequiresAllowance( + opts.tokenAddress, + opts.signer, + opts.amountInWei, + opts.bridgeAddress + ) + ) { + throw Error("token vault does not have required allowance"); + } + + const contract: Contract = new Contract( + opts.bridgeAddress, + TokenVault, + opts.signer + ); + + const owner = await opts.signer.getAddress(); + const message = { + sender: owner, + srcChainId: opts.fromChainId, + destChainId: opts.toChainId, + owner: owner, + to: owner, + refundAddress: owner, + depositValue: opts.amountInWei, + callValue: 0, + processingFee: opts.processingFeeInWei ?? BigNumber.from(0), + gasLimit: opts.processingFeeInWei + ? BigNumber.from(100000) + : BigNumber.from(0), + memo: opts.memo ?? "", + }; + + const tx = await contract.sendERC20( + message.destChainId, + owner, + opts.tokenAddress, + opts.amountInWei, + message.gasLimit, + message.processingFee, + message.refundAddress, + message.memo + ); + + return tx; + } +} + +export default ERC20Bridge; diff --git a/packages/bridge-ui/src/eth/bridge.spec.ts b/packages/bridge-ui/src/eth/bridge.spec.ts index 4d247f0303..10e45728a2 100644 --- a/packages/bridge-ui/src/eth/bridge.spec.ts +++ b/packages/bridge-ui/src/eth/bridge.spec.ts @@ -30,6 +30,19 @@ describe("bridge tests", () => { jest.resetAllMocks(); }); + it("requires allowance returns false", async () => { + const bridge: Bridge = new ETHBridge(); + const wallet = new Wallet("0x"); + + const requires = await bridge.RequiresAllowance({ + amountInWei: BigNumber.from(1), + signer: new Wallet("0x"), + contractAddress: "0x1234", + spenderAddress: "0x", + }); + expect(requires).toBe(false); + }); + it("approve returns empty transaction", async () => { const bridge: Bridge = new ETHBridge(); @@ -37,6 +50,7 @@ describe("bridge tests", () => { amountInWei: BigNumber.from(1), signer: new Wallet("0x"), contractAddress: "0x1234", + spenderAddress: "0x", }); }); diff --git a/packages/bridge-ui/src/eth/bridge.ts b/packages/bridge-ui/src/eth/bridge.ts index e56f89bf80..b3809bff0c 100644 --- a/packages/bridge-ui/src/eth/bridge.ts +++ b/packages/bridge-ui/src/eth/bridge.ts @@ -4,6 +4,10 @@ import type { ApproveOpts, Bridge, BridgeOpts } from "../domain/bridge"; import TokenVault from "../constants/abi/TokenVault"; class ETHBridge implements Bridge { + RequiresAllowance(opts: ApproveOpts): Promise { + return Promise.resolve(false); + } + // ETH does not need to be approved for transacting Approve(opts: ApproveOpts): Promise { return new Promise((resolve) => resolve({} as unknown as Transaction)); @@ -18,7 +22,7 @@ class ETHBridge implements Bridge { const owner = await opts.signer.getAddress(); const message = { - sender: opts.signer.getAddress(), + sender: owner, srcChainId: opts.fromChainId, destChainId: opts.toChainId, owner: owner, diff --git a/packages/bridge-ui/src/i18n.js b/packages/bridge-ui/src/i18n.js index 8adccadf3c..f4ac2eb5e2 100644 --- a/packages/bridge-ui/src/i18n.js +++ b/packages/bridge-ui/src/i18n.js @@ -9,7 +9,8 @@ function setupI18n({ withLocale: _locale } = { withLocale: "en" }) { selectToken: "Select Token", from: "From", to: "To", - bridge: "Bridge" + bridge: "Bridge", + approve: "Approve" }, nav: { connect: "Connect Wallet"