From 730777f732a0c05d2a9b7e2ffbd3ea0b8d21fedb Mon Sep 17 00:00:00 2001 From: bartosz-lipinski <264380+bartosz-lipinski@users.noreply.github.com> Date: Tue, 9 Mar 2021 20:34:48 -0600 Subject: [PATCH] fix: transfer --- .../bridge/src/components/Input/input.tsx | 74 +--- .../bridge/src/components/Transfer/index.tsx | 117 ++++-- .../{transfer.ts => transfer/fromSolana.ts} | 61 +--- .../src/models/bridge/transfer/index.ts | 3 + .../src/models/bridge/transfer/interface.ts | 39 ++ .../src/models/bridge/transfer/toSolana.ts | 345 ++++++++++++++++++ 6 files changed, 485 insertions(+), 154 deletions(-) rename packages/bridge/src/models/bridge/{transfer.ts => transfer/fromSolana.ts} (88%) create mode 100644 packages/bridge/src/models/bridge/transfer/index.ts create mode 100644 packages/bridge/src/models/bridge/transfer/interface.ts create mode 100644 packages/bridge/src/models/bridge/transfer/toSolana.ts diff --git a/packages/bridge/src/components/Input/input.tsx b/packages/bridge/src/components/Input/input.tsx index 1802e6c..71c3a9d 100644 --- a/packages/bridge/src/components/Input/input.tsx +++ b/packages/bridge/src/components/Input/input.tsx @@ -1,32 +1,24 @@ import React, { useState } from 'react'; -import { NumericInput, programIds } from '@oyster/common'; +import { NumericInput } from '@oyster/common'; import { Card, Select } from 'antd'; import './style.less'; import { useEthereum } from '../../contexts'; -import { WrappedAssetFactory } from '../../contracts/WrappedAssetFactory'; -import { WormholeFactory } from '../../contracts/WormholeFactory'; -import { TransferRequestInfo } from '../../models/bridge'; import { TokenDisplay } from '../TokenDisplay'; -import BN from 'bn.js'; import { ASSET_CHAIN } from '../../models/bridge/constants'; const { Option } = Select; export function EthereumInput(props: { title: string; - hideBalance?: boolean; - + balance?: number; asset?: string; chain?: ASSET_CHAIN; setAsset: (asset: string) => void; - - setInfo: (info: TransferRequestInfo) => void; amount?: number | null; - onInputChange: (value: number | null) => void; + onInputChange: (value: number | undefined) => void; }) { - const [balance, setBalance] = useState(0); const [lastAmount, setLastAmount] = useState(''); - const { tokens, provider } = useEthereum(); + const { tokens } = useEthereum(); const renderReserveAccounts = tokens .filter(t => (t.tags?.indexOf('longList') || -1) < 0) @@ -54,54 +46,6 @@ export function EthereumInput(props: { ); }); - const updateBalance = async (fromAddress: string) => { - props.setAsset(fromAddress); - - if (!provider) { - return; - } - - const bridgeAddress = programIds().wormhole.bridge; - - let signer = provider.getSigner(); - let e = WrappedAssetFactory.connect(fromAddress, provider); - let addr = await signer.getAddress(); - let balance = await e.balanceOf(addr); - let decimals = await e.decimals(); - let symbol = await e.symbol(); - - let allowance = await e.allowance(addr, bridgeAddress); - - let info = { - address: fromAddress, - name: symbol, - balance: balance, - allowance: allowance, - decimals: decimals, - isWrapped: false, - chainID: ASSET_CHAIN.Ethereum, - assetAddress: Buffer.from(fromAddress.slice(2), 'hex'), - mint: '', - }; - - setBalance( - new BN(info.balance.toString()) - .div(new BN(10).pow(new BN(info.decimals))) - .toNumber(), - ); - - let b = WormholeFactory.connect(bridgeAddress, provider); - - let isWrapped = await b.isWrappedAsset(fromAddress); - if (isWrapped) { - info.chainID = await e.assetChain(); - info.assetAddress = Buffer.from((await e.assetAddress()).slice(2), 'hex'); - info.isWrapped = true; - } - - props.setInfo(info); - }; - return (
{props.title}
- {!props.hideBalance && ( + {!!props.balance && (
props.onInputChange && props.onInputChange(balance)} + onClick={() => props.onInputChange && props.onInputChange(props.balance)} > - Balance: {balance.toFixed(6)} + Balance: {props.balance.toFixed(6)}
)} @@ -132,7 +76,7 @@ export function EthereumInput(props: { } onChange={(val: string) => { if (props.onInputChange && parseFloat(val) !== props.amount) { - if (!val || !parseFloat(val)) props.onInputChange(null); + if (!val || !parseFloat(val)) props.onInputChange(undefined); else props.onInputChange(parseFloat(val)); } setLastAmount(val); @@ -153,7 +97,7 @@ export function EthereumInput(props: { placeholder="CCY" value={props.asset} onChange={(item: string) => { - updateBalance(item); + props.setAsset(item); }} filterOption={(input, option) => option?.name?.toLowerCase().indexOf(input.toLowerCase()) >= 0 diff --git a/packages/bridge/src/components/Transfer/index.tsx b/packages/bridge/src/components/Transfer/index.tsx index c20ea68..94e3382 100644 --- a/packages/bridge/src/components/Transfer/index.tsx +++ b/packages/bridge/src/components/Transfer/index.tsx @@ -1,26 +1,21 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Card, notification, Spin, Button } from 'antd'; -import { TokenInfo } from '@uniswap/token-lists'; -import { LAMPORTS_PER_SOL } from '@solana/web3.js'; -import { LABELS } from '../../constants'; +import React, { useEffect, useState } from 'react'; +import { notification, Spin, Button } from 'antd'; import { contexts, - utils, ConnectButton, programIds, formatAmount, } from '@oyster/common'; -import { useHistory, useLocation } from 'react-router-dom'; import { EthereumInput } from './../Input'; import './style.less'; -import { ethers } from 'ethers'; import { ASSET_CHAIN, chainToName } from '../../utils/assets'; -import { BigNumber } from 'ethers/utils'; -import { Erc20Factory } from '../../contracts/Erc20Factory'; -import { ProgressUpdate, transfer, TransferRequest } from '../../models/bridge'; +import { ProgressUpdate, toSolana, TransferRequest } from '../../models/bridge'; import { useEthereum } from '../../contexts'; import { TokenDisplay } from './../TokenDisplay'; +import { WrappedAssetFactory } from '../../contracts/WrappedAssetFactory'; +import { WormholeFactory } from '../../contracts/WormholeFactory'; +import BN from 'bn.js'; const { useConnection } = contexts.Connection; const { useWallet } = contexts.Wallet; @@ -51,23 +46,75 @@ export const Transfer = () => { toChain: ASSET_CHAIN.Solana, }); - const setAssetInformation = (asset: string) => { + const setAssetInformation = async (asset: string) => { setRequest({ ...request, asset, }); }; + useEffect(() => { + const asset = request.asset; + if(!asset || asset === request?.info?.address) { + return; + } + + (async () => { + if (!provider) { + return; + } + + const bridgeAddress = programIds().wormhole.bridge; + + let signer = provider.getSigner(); + let e = WrappedAssetFactory.connect(asset, provider); + let addr = await signer.getAddress(); + let balance = await e.balanceOf(addr); + let decimals = await e.decimals(); + let symbol = await e.symbol(); + + let allowance = await e.allowance(addr, bridgeAddress); + + let info = { + address: asset, + name: symbol, + balance: balance, + balanceAsNumber: (new BN(balance.toString()) + .div(new BN(10).pow(new BN(decimals - 2))) + .toNumber()) / 100, + allowance: allowance, + decimals: decimals, + isWrapped: false, + chainID: ASSET_CHAIN.Ethereum, + assetAddress: Buffer.from(asset.slice(2), 'hex'), + mint: '', + }; + + let b = WormholeFactory.connect(bridgeAddress, provider); + + let isWrapped = await b.isWrappedAsset(asset); + if (isWrapped) { + info.chainID = await e.assetChain(); + info.assetAddress = Buffer.from((await e.assetAddress()).slice(2), 'hex'); + info.isWrapped = true; + } + + setRequest({ + ...request, + asset, + info, + }); + })(); + }, [request, provider]) + return ( <>
{ - request.info = info; - }} + title={`From ${chainToName(request.from)}`} asset={request.asset} chain={request.from} + balance={request.info?.balanceAsNumber || 0 } setAsset={asset => setAssetInformation(asset)} amount={request.amount} onInputChange={amount => { @@ -94,13 +141,9 @@ export const Transfer = () => { ⇅ { - request.info = info; - }} + title={`To ${chainToName(request.toChain)}`} asset={request.asset} chain={request.toChain} - hideBalance={true} setAsset={asset => setAssetInformation(asset)} amount={request.amount} onInputChange={amount => { @@ -126,22 +169,24 @@ export const Transfer = () => { (async () => { let steps: ProgressUpdate[] = []; try { - await transfer( - connection, - wallet, - request, - provider, - update => { - if (update.replace) { - steps.pop(); - steps = [...steps, update]; - } else { - steps = [...steps, update]; - } + if(request.toChain === ASSET_CHAIN.Solana) { + await toSolana( + connection, + wallet, + request, + provider, + update => { + if (update.replace) { + steps.pop(); + steps = [...steps, update]; + } else { + steps = [...steps, update]; + } - setActiveSteps(steps); - }, - ); + setActiveSteps(steps); + }, + ); + } } catch { // TODO... } diff --git a/packages/bridge/src/models/bridge/transfer.ts b/packages/bridge/src/models/bridge/transfer/fromSolana.ts similarity index 88% rename from packages/bridge/src/models/bridge/transfer.ts rename to packages/bridge/src/models/bridge/transfer/fromSolana.ts index a0e3ff5..74422e5 100644 --- a/packages/bridge/src/models/bridge/transfer.ts +++ b/packages/bridge/src/models/bridge/transfer/fromSolana.ts @@ -1,6 +1,4 @@ import { - contexts, - utils, programIds, WalletAdapter, getMultipleAccounts, @@ -8,66 +6,26 @@ import { cache, TokenAccountParser, ParsedAccount, - formatNumber, formatAmount, createAssociatedTokenAccountInstruction, } from '@oyster/common'; - import { ethers } from 'ethers'; -import { ASSET_CHAIN } from '../../utils/assets'; +import { ASSET_CHAIN } from '../../../utils/assets'; import { BigNumber } from 'ethers/utils'; -import { Erc20Factory } from '../../contracts/Erc20Factory'; -import { WormholeFactory } from '../../contracts/WormholeFactory'; -import { AssetMeta, createWrappedAssetInstruction } from './meta'; -import { bridgeAuthorityKey, wrappedAssetMintKey } from './helpers'; +import { Erc20Factory } from '../../../contracts/Erc20Factory'; +import { WormholeFactory } from '../../../contracts/WormholeFactory'; +import { AssetMeta, createWrappedAssetInstruction } from './../meta'; +import { bridgeAuthorityKey, wrappedAssetMintKey } from './../helpers'; import { Account, Connection, PublicKey, TransactionInstruction, } from '@solana/web3.js'; -import { AccountInfo, AccountLayout } from '@solana/spl-token'; +import { AccountInfo } from '@solana/spl-token'; +import { ProgressUpdate, TransferRequest } from './interface'; -export interface ProgressUpdate { - message: string; - type: string; - step: number; - group: string; - replace?: boolean; -} - -export interface TransferRequestInfo { - name: string; - balance: BigNumber; - decimals: number; - allowance: BigNumber; - isWrapped: boolean; - chainID: number; - assetAddress: Buffer; - mint: string; -} - -export interface TransferRequest { - nonce?: number; - signer?: ethers.Signer; - asset?: string; - amount?: number; - amountBN?: BigNumber; - - recipient?: Buffer; - - info?: TransferRequestInfo; - - from?: ASSET_CHAIN; - toChain?: ASSET_CHAIN; -} - -// type of updates -// 1. info -// 2. user -// 3. wait (progress bar) - -export const transfer = async ( +export const fromSolana = async ( connection: Connection, wallet: WalletAdapter, request: TransferRequest, @@ -77,7 +35,6 @@ export const transfer = async ( if (!request.asset) { return; } - const walletName = 'MetaMask'; request.signer = provider?.getSigner(); @@ -384,8 +341,6 @@ export const transfer = async ( ); }); }, - // - vaa: async (request: TransferRequest) => {}, }; return steps.transfer(request); diff --git a/packages/bridge/src/models/bridge/transfer/index.ts b/packages/bridge/src/models/bridge/transfer/index.ts new file mode 100644 index 0000000..f0fec90 --- /dev/null +++ b/packages/bridge/src/models/bridge/transfer/index.ts @@ -0,0 +1,3 @@ +export * from './toSolana'; +export * from './fromSolana'; +export * from './interface'; diff --git a/packages/bridge/src/models/bridge/transfer/interface.ts b/packages/bridge/src/models/bridge/transfer/interface.ts new file mode 100644 index 0000000..726dc2d --- /dev/null +++ b/packages/bridge/src/models/bridge/transfer/interface.ts @@ -0,0 +1,39 @@ +import { ethers } from 'ethers'; +import { BigNumber } from 'ethers/utils'; +import { ASSET_CHAIN } from '../constants'; + +export interface ProgressUpdate { + message: string; + type: string; + step: number; + group: string; + replace?: boolean; +} + +export interface TransferRequestInfo { + address: string; + name: string; + balance: BigNumber; + balanceAsNumber: number; + decimals: number; + allowance: BigNumber; + isWrapped: boolean; + chainID: number; + assetAddress: Buffer; + mint: string; +} + +export interface TransferRequest { + nonce?: number; + signer?: ethers.Signer; + asset?: string; + amount?: number; + amountBN?: BigNumber; + + recipient?: Buffer; + + info?: TransferRequestInfo; + + from?: ASSET_CHAIN; + toChain?: ASSET_CHAIN; +} diff --git a/packages/bridge/src/models/bridge/transfer/toSolana.ts b/packages/bridge/src/models/bridge/transfer/toSolana.ts new file mode 100644 index 0000000..d124e35 --- /dev/null +++ b/packages/bridge/src/models/bridge/transfer/toSolana.ts @@ -0,0 +1,345 @@ +import { + programIds, + WalletAdapter, + getMultipleAccounts, + sendTransaction, + cache, + TokenAccountParser, + ParsedAccount, + formatAmount, + createAssociatedTokenAccountInstruction, +} from '@oyster/common'; +import { ethers } from 'ethers'; +import { Erc20Factory } from '../../../contracts/Erc20Factory'; +import { WormholeFactory } from '../../../contracts/WormholeFactory'; +import { AssetMeta, createWrappedAssetInstruction } from './../meta'; +import { bridgeAuthorityKey, wrappedAssetMintKey } from './../helpers'; +import { + Account, + Connection, + PublicKey, + TransactionInstruction, +} from '@solana/web3.js'; +import { AccountInfo } from '@solana/spl-token'; +import { TransferRequest, ProgressUpdate } from './interface'; + +export const toSolana = async ( + connection: Connection, + wallet: WalletAdapter, + request: TransferRequest, + provider: ethers.providers.Web3Provider, + setProgress: (update: ProgressUpdate) => void, +) => { + if (!request.asset) { + return; + } + const walletName = 'MetaMask'; + request.signer = provider?.getSigner(); + + request.nonce = await provider.getTransactionCount( + request.signer.getAddress(), + 'pending', + ); + + let counter = 0; + // check difference between lock/approve (invoke lock if allowance < amount) + const steps = { + transfer: async (request: TransferRequest) => { + if (!request.info || !request.amount) { + return; + } + + request.amountBN = ethers.utils.parseUnits( + formatAmount(request.amount, 9), + request.info.decimals, + ); + + return steps.prepare(request); + }, + + // creates wrapped account on solana + prepare: async (request: TransferRequest) => { + if (!request.info || !request.from || !wallet.publicKey) { + return; + } + + const group = 'Initiate transfer'; + try { + const bridgeId = programIds().wormhole.pubkey; + const authority = await bridgeAuthorityKey(bridgeId); + const meta: AssetMeta = { + decimals: Math.min(request.info?.decimals, 9), + address: request.info?.assetAddress, + chain: request.from, + }; + const mintKey = await wrappedAssetMintKey(bridgeId, authority, meta); + + const recipientKey = + cache + .byParser(TokenAccountParser) + .map(key => { + let account = cache.get(key) as ParsedAccount; + if (account?.info.mint.toBase58() === mintKey.toBase58()) { + return key; + } + + return; + }) + .find(_ => _) || ''; + const recipient: PublicKey = recipientKey + ? new PublicKey(recipientKey) + : ( + await PublicKey.findProgramAddress( + [ + wallet.publicKey.toBuffer(), + programIds().token.toBuffer(), + mintKey.toBuffer(), + ], + programIds().associatedToken, + ) + )[0]; + + request.recipient = recipient.toBuffer(); + + const accounts = await getMultipleAccounts( + connection, + [mintKey.toBase58(), recipient.toBase58()], + 'single', + ); + const instructions: TransactionInstruction[] = []; + const signers: Account[] = []; + + if (!accounts.array[0]) { + // create mint using wormhole instruction + instructions.push( + await createWrappedAssetInstruction( + meta, + bridgeId, + authority, + mintKey, + wallet.publicKey, + ), + ); + } + + if (!accounts.array[1]) { + createAssociatedTokenAccountInstruction( + instructions, + recipient, + wallet.publicKey, + wallet.publicKey, + mintKey, + ); + } + + if (instructions.length > 0) { + setProgress({ + message: 'Waiting for Solana approval...', + type: 'user', + group, + step: counter++, + }); + + const tx = await sendTransaction( + connection, + wallet, + instructions, + signers, + true, + ); + } + } catch (err) { + setProgress({ + message: `Couldn't create Solana account!`, + type: 'error', + group, + step: counter++, + }); + throw err; + } + + return steps.approve(request); + }, + // approves assets for transfer + approve: async (request: TransferRequest) => { + if (!request.amountBN || !request.asset || !request.signer) { + return; + } + + const group = 'Approve assets'; + try { + if (request.info?.allowance.lt(request.amountBN)) { + let e = Erc20Factory.connect(request.asset, request.signer); + setProgress({ + message: `Waiting for ${walletName} approval`, + type: 'user', + group, + step: counter++, + }); + let res = await e.approve( + programIds().wormhole.bridge, + request.amountBN, + ); + setProgress({ + message: 'Waiting for ETH transaction to be minted...', + type: 'wait', + group, + step: counter++, + }); + await res.wait(1); + setProgress({ + message: 'Approval on ETH succeeded!', + type: 'done', + group, + step: counter++, + }); + } else { + setProgress({ + message: 'Already approved on ETH!', + type: 'done', + group, + step: counter++, + }); + } + } catch (err) { + setProgress({ + message: 'Approval failed!', + type: 'error', + group, + step: counter++, + }); + throw err; + } + + return steps.lock(request); + }, + // locks assets in the bridge + lock: async (request: TransferRequest) => { + if ( + !request.amountBN || + !request.asset || + !request.signer || + !request.recipient || + !request.toChain || + !request.info || + !request.nonce + ) { + return; + } + + let group = 'Lock assets'; + + try { + let wh = WormholeFactory.connect( + programIds().wormhole.bridge, + request.signer, + ); + setProgress({ + message: `Waiting for ${walletName} transfer approval`, + type: 'user', + group, + step: counter++, + }); + let res = await wh.lockAssets( + request.asset, + request.amountBN, + request.recipient, + request.toChain, + request.nonce, + false, + ); + setProgress({ + message: 'Waiting for ETH transaction to be minted...', + type: 'wait', + group, + step: counter++, + }); + await res.wait(1); + setProgress({ + message: 'Transfer on ETH succeeded!', + type: 'done', + group, + step: counter++, + }); + } catch (err) { + setProgress({ + message: 'Transfer failed!', + type: 'error', + group, + step: counter++, + }); + throw err; + } + + return steps.wait(request); + }, + wait: async (request: TransferRequest) => { + let startBlock = provider.blockNumber; + let completed = false; + let group = 'Finalizing transfer'; + + const ethConfirmationMessage = (current: number) => + `Awaiting ETH confirmations: ${current} out of 15`; + + setProgress({ + message: ethConfirmationMessage(0), + type: 'wait', + step: counter++, + group, + }); + + let blockHandler = (blockNumber: number) => { + let passedBlocks = blockNumber - startBlock; + const isLast = passedBlocks === 14; + if (passedBlocks < 15) { + setProgress({ + message: ethConfirmationMessage(passedBlocks), + type: isLast ? 'done' : 'wait', + step: counter++, + group, + replace: passedBlocks > 0, + }); + + if (isLast) { + setProgress({ + message: 'Awaiting completion on Solana...', + type: 'wait', + group, + step: counter++, + }); + } + } else if (!completed) { + provider.removeListener('block', blockHandler); + } + }; + provider.on('block', blockHandler); + + return new Promise((resolve, reject) => { + if (!request.recipient) { + return; + } + + let accountChangeListener = connection.onAccountChange( + new PublicKey(request.recipient), + () => { + if (completed) return; + + completed = true; + provider.removeListener('block', blockHandler); + connection.removeAccountChangeListener(accountChangeListener); + setProgress({ + message: 'Transfer completed on Solana', + type: 'info', + group, + step: counter++, + }); + resolve(); + }, + 'single', + ); + }); + }, + }; + + return steps.transfer(request); +};