From 6292803bc5946f29ce288674ae169815750b01fe Mon Sep 17 00:00:00 2001 From: Pierre-Alain Date: Wed, 12 Jun 2024 10:20:23 +0900 Subject: [PATCH] feat: Add native NEP-141 bridge to Ethereum. (#96) * fix: Poll getTransaction. Fixes error when sending tx from different provider (WalletConnect). * feat: Add native NEP-141 bridge to Ethereum. * chore: Fix lint. * docs: Add mainnet deployment. * fix: Increase finalization on NEAR gas limit. * feat: Pay storage balance before NEP-141 unlock. * fix: Use new contract abi. * fix: Handle any NEP-141 metadata log receipt order. --- .yarn/versions/cf0fe5ae.yml | 10 + .../src/bridged-ether/sendToEthereum/index.ts | 10 +- .../src/bridged-ether/sendToNear/index.ts | 10 +- .../src/natural-nep141/sendToAurora/index.ts | 56 +- packages/client/README.md | 36 +- packages/client/src/index.ts | 4 + .../nep141-erc20/src/bridged-erc20/deploy.ts | 156 +++ .../src/bridged-erc20/getAddress.ts | 34 + .../src/bridged-erc20/getMetadata.ts | 64 ++ .../nep141-erc20/src/bridged-erc20/index.ts | 9 + .../src/bridged-erc20/sendToNear/index.ts | 815 ++++++++++++++++ packages/nep141-erc20/src/index.ts | 2 + .../src/natural-erc20/sendToNear/index.ts | 5 +- .../src/natural-nep141/getMetadata.ts | 26 + .../nep141-erc20/src/natural-nep141/index.ts | 7 + .../natural-nep141/sendToEthereum/index.ts | 905 ++++++++++++++++++ packages/utils/src/findProof.ts | 61 ++ packages/utils/src/index.ts | 3 +- packages/utils/src/nep141.ts | 49 + 19 files changed, 2188 insertions(+), 74 deletions(-) create mode 100644 .yarn/versions/cf0fe5ae.yml create mode 100644 packages/nep141-erc20/src/bridged-erc20/deploy.ts create mode 100644 packages/nep141-erc20/src/bridged-erc20/getAddress.ts create mode 100644 packages/nep141-erc20/src/bridged-erc20/getMetadata.ts create mode 100644 packages/nep141-erc20/src/bridged-erc20/index.ts create mode 100644 packages/nep141-erc20/src/bridged-erc20/sendToNear/index.ts create mode 100644 packages/nep141-erc20/src/natural-nep141/getMetadata.ts create mode 100644 packages/nep141-erc20/src/natural-nep141/index.ts create mode 100644 packages/nep141-erc20/src/natural-nep141/sendToEthereum/index.ts diff --git a/.yarn/versions/cf0fe5ae.yml b/.yarn/versions/cf0fe5ae.yml new file mode 100644 index 00000000..791b479d --- /dev/null +++ b/.yarn/versions/cf0fe5ae.yml @@ -0,0 +1,10 @@ +releases: + "@near-eth/aurora-erc20": patch + "@near-eth/aurora-ether": patch + "@near-eth/aurora-nep141": patch + "@near-eth/client": minor + "@near-eth/near-ether": patch + "@near-eth/nep141-erc20": minor + "@near-eth/rainbow": minor + "@near-eth/utils": minor + rainbow-bridge-client-monorepo: minor diff --git a/packages/aurora-ether/src/bridged-ether/sendToEthereum/index.ts b/packages/aurora-ether/src/bridged-ether/sendToEthereum/index.ts index 317cb307..7162b846 100644 --- a/packages/aurora-ether/src/bridged-ether/sendToEthereum/index.ts +++ b/packages/aurora-ether/src/bridged-ether/sendToEthereum/index.ts @@ -457,7 +457,15 @@ export async function burn ( }) txHash = transaction!.hash } - const pendingBurnTx = await provider.getTransaction(txHash) + let pendingBurnTx = null + while (!pendingBurnTx) { + try { + await new Promise(resolve => setTimeout(resolve, 1000)) + pendingBurnTx = await provider.getTransaction(txHash) + } catch (error) { + console.log(error) + } + } return { ...transfer, diff --git a/packages/aurora-nep141/src/bridged-ether/sendToNear/index.ts b/packages/aurora-nep141/src/bridged-ether/sendToNear/index.ts index 240dc083..da2d73be 100644 --- a/packages/aurora-nep141/src/bridged-ether/sendToNear/index.ts +++ b/packages/aurora-nep141/src/bridged-ether/sendToNear/index.ts @@ -354,7 +354,15 @@ export async function burn ( data: exitToNearData, gas: ethers.BigNumber.from(121000).toHexString() }]) - const tx = await provider.getTransaction(txHash) + let tx = null + while (!tx) { + try { + await new Promise(resolve => setTimeout(resolve, 1000)) + tx = await provider.getTransaction(txHash) + } catch (error) { + console.log(error) + } + } return { ...transfer, diff --git a/packages/aurora-nep141/src/natural-nep141/sendToAurora/index.ts b/packages/aurora-nep141/src/natural-nep141/sendToAurora/index.ts index cd5880ef..48c21e8f 100644 --- a/packages/aurora-nep141/src/natural-nep141/sendToAurora/index.ts +++ b/packages/aurora-nep141/src/natural-nep141/sendToAurora/index.ts @@ -1,12 +1,11 @@ import BN from 'bn.js' import { ethers } from 'ethers' import { transactions, Account, utils, providers as najProviders } from 'near-api-js' -import { CodeResult } from 'near-api-js/lib/providers/provider' import { getBridgeParams, track, untrack } from '@near-eth/client' import { TransactionInfo, TransferStatus } from '@near-eth/client/dist/types' import * as status from '@near-eth/client/dist/statuses' import { getNearWallet, getNearAccountId, getNearProvider } from '@near-eth/client/dist/utils' -import { urlParams, buildIndexerTxQuery } from '@near-eth/utils' +import { urlParams, buildIndexerTxQuery, nep141 } from '@near-eth/utils' import getMetadata from '../getMetadata' export const SOURCE_NETWORK = 'near' @@ -564,10 +563,10 @@ export async function lockNear ( const auroraEvmAccount = options.auroraEvmAccount ?? bridgeParams.auroraEvmAccount const actions = [] - const minStorageBalance = await getMinStorageBalance({ + const minStorageBalance = await nep141.getMinStorageBalance({ nep141Address: wNearNep141, nearProvider }) - const userStorageBalance = await getStorageBalance({ + const userStorageBalance = await nep141.getStorageBalance({ nep141Address: wNearNep141, accountId: transfer.sender, nearProvider @@ -664,53 +663,4 @@ export async function lockNear ( } } -export async function getMinStorageBalance ( - { nep141Address, nearProvider }: { - nep141Address: string - nearProvider: najProviders.Provider - } -): Promise { - try { - const result = await nearProvider.query({ - request_type: 'call_function', - account_id: nep141Address, - method_name: 'storage_balance_bounds', - args_base64: '', - finality: 'optimistic' - }) - return JSON.parse(Buffer.from(result.result).toString()).min - } catch (e) { - const result = await nearProvider.query({ - request_type: 'call_function', - account_id: nep141Address, - method_name: 'storage_minimum_balance', - args_base64: '', - finality: 'optimistic' - }) - return JSON.parse(Buffer.from(result.result).toString()) - } -} - -export async function getStorageBalance ( - { nep141Address, accountId, nearProvider }: { - nep141Address: string - accountId: string - nearProvider: najProviders.Provider - } -): Promise { - try { - const result = await nearProvider.query({ - request_type: 'call_function', - account_id: nep141Address, - method_name: 'storage_balance_of', - args_base64: Buffer.from(JSON.stringify({ account_id: accountId })).toString('base64'), - finality: 'optimistic' - }) - return JSON.parse(Buffer.from(result.result).toString()) - } catch (e) { - console.warn(e, nep141Address) - return null - } -} - const last = (arr: any[]): any => arr[arr.length - 1] diff --git a/packages/client/README.md b/packages/client/README.md index 8b65b427..a6b18d1b 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -441,7 +441,7 @@ import { | from Ethereum | to NEAR | to Aurora | | :------------ | :---------------------------------------------- | :------------------------------------------------------ | | ERC-20 | nep141Erc20/naturalErc20/sendToNear | auroraErc20/naturalErc20/sendToAurora | -| | nep141Erc20/bridgedErc20/sendToNear (TODO) | auroraErc20/bridgedErc20/sendToAurora (TODO) | +| | nep141Erc20/bridgedErc20/sendToNear | auroraErc20/bridgedErc20/sendToAurora (TODO) | | ETH | nearEther/naturalETH/sendToNear | auroraEther/naturalEther/sendToAurora | | NEAR | nearEther.bridgedNEAR.sendToNear | (TODO) | | ERC-721 | (TODO) | (TODO) | @@ -449,9 +449,9 @@ import { | from NEAR | to Ethereum | to Aurora | | :------------ | :---------------------------------------------- | :------------------------------------------------------ | | NEP-141 | nep141Erc20/bridgedNep141/sendToEthereum | auroraNep141/naturalNep141/sendToAurora | -| | nep141Erc20/naturalNep141/sendToEthereum (TODO) | auroraNep141/bridgedNep141/sendToAurora (TODO) | +| | nep141Erc20/naturalNep141/sendToEthereum | auroraNep141/bridgedNep141/sendToAurora (TODO) | | ETH | nearEther/bridgedETH/sendToEthereum | auroraNep141/naturalNep141/sendToAurora | -| NEAR | nearEther/naturalNEAR/sendToEthereum | auroraNep141/naturalNep141/wrapAndSendNearToAurora \*\* | +| NEAR | nearEther/naturalNEAR/sendToEthereum | auroraNep141/naturalNep141/wrapAndSendNearToAurora | | ERC-721 | (TODO) | (TODO) | | from Aurora | to Ethereum | to NEAR | @@ -459,14 +459,11 @@ import { | ERC-20 | auroraErc20/bridgedErc20/sendToEthereum | auroraNep141/bridgedErc20/sendToNear \* | | | auroraErc20/naturalErc20/sendToEthereum (TODO) | auroraNep141/naturalErc20/sendToNear (TODO) \* | | ETH | auroraEther/bridgedEther/sendToEthereum | auroraNep141/bridgedEther/sendToNear \* | -| NEAR | (TODO) | auroraNep141/bridgedErc20/sendToNear \* \*\* | +| NEAR | auroraErc20/wNEAR/sendToEthereum | auroraNep141/bridgedErc20/sendToNear \* | | ERC-721 | (TODO) | (TODO) | \* WARNING: The recipient of transfers from Aurora to NEAR must have paid NEAR storage fees otherwise tokens may be lost. -\*\* Received as wNEAR - - Author a custom connector library ================================= @@ -517,10 +514,18 @@ setBridgeParams({ nativeNEARLockerAddress: 'e-near.near', wNearNep141: 'wrap.near', eventRelayerAccount: 'event-relayer.near', + // https://github.com/Near-One/near-erc20-connector/blob/main/aurora/contracts/NearBridge.sol + wNearBridgeAddress: '0x5D5a9D3fB8BD3959B0C9266f90e126427E83872d', + wNearBridgeAbi: process.env.wNearBridgeAbi, + // https://github.com/Near-One/rainbow-token-connector/tree/master/token-locker + nep141LockerAccount: 'ft-locker.bridge.near', + // https://github.com/Near-One/rainbow-token-connector/tree/master/erc20-bridge-token + erc20FactoryAddress: '0x252e87862A3A720287E7fd527cE6e8d0738427A2', + erc20FactoryAbi: process.env.erc20FactoryAbi, }) ``` -Goerli Testnet Bridge addresses and parameters +Sepolia Testnet Bridge addresses and parameters ============================================== ```js import { setBridgeParams } from '@near-eth/client' @@ -561,12 +566,13 @@ setBridgeParams({ nativeNEARLockerAddress: 'enear.goerli.testnet', wNearNep141: 'wrap.testnet', eventRelayerAccount: 'event-relayer.goerli.testnet', + // https://github.com/Near-One/near-erc20-connector/blob/main/aurora/contracts/NearBridge.sol + wNearBridgeAddress: '0x329242C003Df320166F5b198dCcb22b0CFF1d91B', + wNearBridgeAbi: process.env.wNearBridgeAbi, + // https://github.com/Near-One/rainbow-token-connector/tree/master/token-locker + nep141LockerAccount: 'ft-locker.sepolia.testnet', + // https://github.com/Near-One/rainbow-token-connector/tree/master/erc20-bridge-token + erc20FactoryAddress: '0xa9108f7F83Fb661e611991116D526fCa1a9585ab', + erc20FactoryAbi: process.env.erc20FactoryAbi, }) ``` - -Getting testnet tokens: - -- https://erc20faucet.com -- https://goerlifaucet.com -- https://goerli-faucet.mudit.blog -- https://usdcfaucet.com diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 9fc8da77..ea38bb97 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -61,6 +61,10 @@ export function getTransferType (transfer: Transfer): ConnectorLib { return require('@near-eth/nep141-erc20/dist/natural-erc20/sendToNear') case '@near-eth/nep141-erc20/bridged-nep141/sendToEthereum': return require('@near-eth/nep141-erc20/dist/bridged-nep141/sendToEthereum') + case '@near-eth/nep141-erc20/natural-nep141/sendToEthereum': + return require('@near-eth/nep141-erc20/dist/natural-nep141/sendToEthereum') + case '@near-eth/nep141-erc20/bridged-erc20/sendToNear': + return require('@near-eth/nep141-erc20/dist/bridged-erc20/sendToNear') case '@near-eth/near-ether/natural-near/sendToEthereum': return require('@near-eth/near-ether/dist/natural-near/sendToEthereum') case '@near-eth/near-ether/bridged-near/sendToNear': diff --git a/packages/nep141-erc20/src/bridged-erc20/deploy.ts b/packages/nep141-erc20/src/bridged-erc20/deploy.ts new file mode 100644 index 00000000..3f39f6aa --- /dev/null +++ b/packages/nep141-erc20/src/bridged-erc20/deploy.ts @@ -0,0 +1,156 @@ +import { Account, providers as najProviders } from 'near-api-js' +import { FinalExecutionOutcome } from 'near-api-js/lib/providers' +import { deserialize as deserializeBorsh } from 'near-api-js/lib/utils/serialize' +import { + getNearWallet, + getNearProvider, + getSignerProvider, + getBridgeParams +} from '@near-eth/client/dist/utils' +import { providers, Signer, Contract } from 'ethers' +import { + borshifyOutcomeProof, + nearOnEthSyncHeight, + findNearProof +} from '@near-eth/utils' + +export async function logNep141Metadata ( + { nep141Address, options }: { + nep141Address: string + options?: { + nearAccount?: Account + nep141LockerAccount?: string + } + } +): Promise { + options = options ?? {} + const bridgeParams = getBridgeParams() + const nep141LockerAccount: string = options.nep141LockerAccount ?? bridgeParams.nep141LockerAccount + const nearWallet = options.nearAccount ?? getNearWallet() + const isNajAccount = nearWallet instanceof Account + + let tx: FinalExecutionOutcome + if (isNajAccount) { + tx = await nearWallet.functionCall({ + contractId: nep141LockerAccount, + methodName: 'log_metadata', + args: { token_id: nep141Address }, + gas: '100' + '0'.repeat(12) + }) + } else { + tx = await nearWallet.signAndSendTransaction({ + receiverId: nep141LockerAccount, + actions: [ + { + type: 'FunctionCall', + params: { + methodName: 'log_metadata', + args: { token_id: nep141Address }, + gas: '100' + '0'.repeat(12) + } + } + ] + }) + } + return tx +} + +export async function deploy ( + { nep141MetadataLogTx, options }: { + nep141MetadataLogTx: string + options?: { + erc20FactoryAddress?: string + nep141LockerAccount?: string + erc20FactoryAbi?: string + signer?: Signer + provider?: providers.JsonRpcProvider + ethChainId?: number + nearProvider?: najProviders.Provider + ethClientAddress?: string + ethClientAbi?: string + } + } +): Promise { + options = options ?? {} + const bridgeParams = getBridgeParams() + const erc20FactoryAddress: string = options.erc20FactoryAddress ?? bridgeParams.erc20FactoryAddress + const nep141LockerAccount: string = options.nep141LockerAccount ?? bridgeParams.nep141LockerAccount + const provider = options.provider ?? getSignerProvider() + const ethChainId: number = (await provider.getNetwork()).chainId + const expectedChainId: number = options.ethChainId ?? bridgeParams.ethChainId + if (ethChainId !== expectedChainId) { + throw new Error( + `Wrong network for deploying token, expected: ${expectedChainId}, got: ${ethChainId}` + ) + } + const nearProvider = options.nearProvider ?? getNearProvider() + const logTx = await nearProvider.txStatus(nep141MetadataLogTx, nep141LockerAccount) + let logReceipt: any + logTx.receipts_outcome.some((receipt) => { + // @ts-expect-error + if (receipt.outcome.executor_id !== nep141LockerAccount) return false + try { + // @ts-expect-error + const successValue = receipt.outcome.status.SuccessValue + // eslint-disable-next-line @typescript-eslint/no-extraneous-class + class LogEvent { + constructor (args: any) { + Object.assign(this, args) + } + } + const SCHEMA = new Map([ + [LogEvent, { + kind: 'struct', + fields: [ + ['prefix', [32]], + ['token', 'String'], + ['name', 'String'], + ['symbol', 'String'], + ['decimals', 'u8'], + ['block_height', 'u64'] + ] + }] + ]) + deserializeBorsh( + SCHEMA, LogEvent, Buffer.from(successValue, 'base64') + ) + logReceipt = receipt + return true + } catch (error) { + console.log(error) + } + return false + }) + if (!logReceipt) { + console.log(logReceipt) + throw new Error('Failed to parse NEP-141 log metadata receipt.') + } + const receiptBlock = await nearProvider.block({ blockId: logReceipt.block_hash }) + const logBlockHeight = Number(receiptBlock.header.height) + const nearOnEthClientBlockHeight = await nearOnEthSyncHeight( + provider, + options.ethClientAddress ?? bridgeParams.ethClientAddress, + options.ethClientAbi ?? bridgeParams.ethClientAbi + ) + if (logBlockHeight > nearOnEthClientBlockHeight) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Wait for the light client sync: NEP-141 metadata log block height: ${logBlockHeight}, light client block height: ${nearOnEthClientBlockHeight}`) + } + const proof = await findNearProof( + logReceipt.id, + nep141LockerAccount, + nearOnEthClientBlockHeight, + nearProvider, + provider, + options.ethClientAddress ?? bridgeParams.ethClientAddress, + options.ethClientAbi ?? bridgeParams.ethClientAbi + ) + const borshProof = borshifyOutcomeProof(proof) + const erc20Factory = new Contract( + erc20FactoryAddress, + options.erc20FactoryAbi ?? bridgeParams.erc20FactoryAbi, + provider.getSigner() + ) + const tx = await erc20Factory.newBridgeToken(borshProof, nearOnEthClientBlockHeight) + return tx +} diff --git a/packages/nep141-erc20/src/bridged-erc20/getAddress.ts b/packages/nep141-erc20/src/bridged-erc20/getAddress.ts new file mode 100644 index 00000000..cc91e4b5 --- /dev/null +++ b/packages/nep141-erc20/src/bridged-erc20/getAddress.ts @@ -0,0 +1,34 @@ +import { Contract, providers } from 'ethers' +import { getEthProvider, getBridgeParams } from '@near-eth/client/dist/utils' + +/** + * Given a bridged ERC-20 contract address, get the NEAR native NEP-141. + * + * @param params Uses Named Arguments pattern, please pass arguments as object + * @param params.erc20Address Contract address of an ERC20 token on Ethereum + * @param params.options Optional arguments. + * @param params.options.erc20FactoryAddress Bridge token factory account on Ethereum. + * @param params.options.erc20FactoryAbi Bridge token factory abi on Ethereum. + * @param params.options.provider Ethereum provider. + * @returns string Contract address of NEP-141 token bridged from NEAR + */ +export default async function getNep141Address ( + { erc20Address, options }: { + erc20Address: string + options?: { + erc20FactoryAddress?: string + erc20FactoryAbi?: string + provider?: providers.Provider + } + } +): Promise { + options = options ?? {} + const bridgeParams = getBridgeParams() + const erc20Factory = new Contract( + options.erc20FactoryAddress ?? bridgeParams.erc20FactoryAddress, + options.erc20FactoryAbi ?? bridgeParams.erc20FactoryAbi, + options.provider ?? getEthProvider() + ) + const nep141Address = await erc20Factory.ethToNearToken(erc20Address) + return nep141Address +} diff --git a/packages/nep141-erc20/src/bridged-erc20/getMetadata.ts b/packages/nep141-erc20/src/bridged-erc20/getMetadata.ts new file mode 100644 index 00000000..3523e2e2 --- /dev/null +++ b/packages/nep141-erc20/src/bridged-erc20/getMetadata.ts @@ -0,0 +1,64 @@ +import { ethers } from 'ethers' +import { getEthProvider, getBridgeParams } from '@near-eth/client/dist/utils' +import { erc20 } from '@near-eth/utils' + +const erc20Decimals: {[key: string]: number} = {} +export async function getDecimals ( + { erc20Address, options }: { + erc20Address: string + options?: { + provider?: ethers.providers.Provider + erc20Abi?: string + } + } +): Promise { + if (erc20Decimals[erc20Address] !== undefined) return erc20Decimals[erc20Address]! + + options = options ?? {} + const bridgeParams = getBridgeParams() + const provider = options.provider ?? getEthProvider() + const erc20Abi = options.erc20Abi ?? bridgeParams.erc20Abi + + let decimals + try { + decimals = await erc20.getDecimals({ erc20Address, provider, erc20Abi }) + // Only record decimals if it was success + erc20Decimals[erc20Address] = decimals + } catch { + console.log(`Failed to read token decimals for: ${erc20Address}`) + decimals = 0 + } + return decimals +} + +const erc20Symbols: {[key: string]: string} = {} +export async function getSymbol ( + { erc20Address, options }: { + erc20Address: string + options?: { + provider?: ethers.providers.Provider + erc20Abi?: string + } + } +): Promise { + if (erc20Symbols[erc20Address]) return erc20Symbols[erc20Address]! + options = options ?? {} + const bridgeParams = getBridgeParams() + const provider = options.provider ?? getEthProvider() + const erc20Abi = options.erc20Abi ?? bridgeParams.erc20Abi + + let symbol + try { + symbol = await erc20.getSymbol({ erc20Address, provider, erc20Abi }) + // Only record symbol if it was success + erc20Symbols[erc20Address] = symbol + } catch { + console.log(`Failed to read token symbol for: ${erc20Address}`) + if (erc20Address.toLowerCase() === '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2') { + symbol = 'MKR' + } else { + symbol = erc20Address.slice(0, 5) + '…' + } + } + return symbol +} diff --git a/packages/nep141-erc20/src/bridged-erc20/index.ts b/packages/nep141-erc20/src/bridged-erc20/index.ts new file mode 100644 index 00000000..fdf74206 --- /dev/null +++ b/packages/nep141-erc20/src/bridged-erc20/index.ts @@ -0,0 +1,9 @@ +export { getDecimals, getSymbol } from './getMetadata' +export { logNep141Metadata, deploy } from './deploy' +export { default as getAddress } from './getAddress' +export { + initiate as sendToNear, + recover, + findAllTransactions, + findAllTransfers +} from './sendToNear' diff --git a/packages/nep141-erc20/src/bridged-erc20/sendToNear/index.ts b/packages/nep141-erc20/src/bridged-erc20/sendToNear/index.ts new file mode 100644 index 00000000..8929e546 --- /dev/null +++ b/packages/nep141-erc20/src/bridged-erc20/sendToNear/index.ts @@ -0,0 +1,815 @@ +import BN from 'bn.js' +import { ethers } from 'ethers' +import { track } from '@near-eth/client' +import { utils, Account, providers as najProviders } from 'near-api-js' +import { CodeResult } from 'near-api-js/lib/providers/provider' +import { stepsFor } from '@near-eth/client/dist/i18nHelpers' +import * as status from '@near-eth/client/dist/statuses' +import { + getEthProvider, + getNearWallet, + getNearProvider, + formatLargeNum, + getSignerProvider, + getBridgeParams +} from '@near-eth/client/dist/utils' +import { TransferStatus, TransactionInfo } from '@near-eth/client/dist/types' +import { + urlParams, + ethOnNearSyncHeight, + findEthProof, + findFinalizationTxOnNear, + ExplorerIndexerResult, + nep141 +} from '@near-eth/utils' +import { findReplacementTx, TxValidationError } from 'find-replacement-tx' +import { getDecimals, getSymbol } from '../getMetadata' +import getNep141Address from '../getAddress' + +export const SOURCE_NETWORK = 'ethereum' +export const DESTINATION_NETWORK = 'near' +export const TRANSFER_TYPE = '@near-eth/nep141-erc20/bridged-erc20/sendToNear' + +const BURN = 'burn-bridged-erc20-to-nep141' +const SYNC = 'sync-bridged-erc20-to-nep141' +const UNLOCK = 'unlock-bridged-erc20-to-nep141' + +const steps = [ + BURN, + SYNC, + UNLOCK +] + +export interface TransferDraft extends TransferStatus { + type: string + burnHashes: string[] + burnReceipts: ethers.providers.TransactionReceipt[] + unlockHashes: string[] + completedConfirmations: number + neededConfirmations: number +} + +export interface Transfer extends TransferDraft, TransactionInfo { + id: string + startTime: string + finishTime?: string + decimals: number + destinationTokenName: string + recipient: string + sender: string + symbol: string + sourceTokenName: string + checkSyncInterval?: number + nextCheckSyncTimestamp?: Date + proof?: Uint8Array +} + +export interface TransferOptions { + provider?: ethers.providers.JsonRpcProvider + erc20FactoryAddress?: string + erc20FactoryAbi?: string + erc20Abi?: string + sendToNearSyncInterval?: number + nep141LockerAccount?: string + nearEventRelayerMargin?: number + nearAccount?: Account + nearProvider?: najProviders.Provider + nearClientAccount?: string + callIndexer?: (query: string) => Promise + eventRelayerAccount?: string +} + +const transferDraft: TransferDraft = { + // Attributes common to all transfer types + // amount, + completedStep: null, + // destinationTokenName, + errors: [], + // recipient, + // sender, + // sourceToken: erc20Address, + // sourceTokenName, + // decimals, + status: status.ACTION_NEEDED, + type: TRANSFER_TYPE, + // Cache eth tx information used for finding a replaced (speedup/cancel) tx. + // ethCache: { + // from, // tx.from of last broadcasted eth tx + // to, // tx.to of last broadcasted eth tx (can be multisig contract) + // safeReorgHeight, // Lower boundary for replacement tx search + // nonce // tx.nonce of last broadcasted eth tx + // } + + // Attributes specific to natural-erc20-to-nep141 transfers + completedConfirmations: 0, + burnHashes: [], + burnReceipts: [], + neededConfirmations: 20, // hard-coding until connector contract is updated with this information + unlockHashes: [] +} + +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +export const i18n = { + en_US: { + steps: (transfer: Transfer) => stepsFor(transfer, steps, { + [BURN]: `Start transfer of ${formatLargeNum(transfer.amount, transfer.decimals).toString()} ${transfer.sourceTokenName} to NEAR`, + [SYNC]: `Wait for ${transfer.neededConfirmations + Number(getBridgeParams().nearEventRelayerMargin)} transfer confirmations for security`, + [UNLOCK]: `Deposit ${formatLargeNum(transfer.amount, transfer.decimals).toString()} ${transfer.destinationTokenName} in NEAR` + }), + statusMessage: (transfer: Transfer) => { + if (transfer.status === status.FAILED) return 'Failed' + if (transfer.status === status.ACTION_NEEDED) { + switch (transfer.completedStep) { + case null: return 'Ready to transfer from Ethereum' + case SYNC: return 'Ready to deposit in NEAR' + default: throw new Error(`Transfer in unexpected state, transfer with ID=${transfer.id} & status=${transfer.status} has completedStep=${transfer.completedStep}`) + } + } + switch (transfer.completedStep) { + case null: return 'Transferring to NEAR' + case BURN: return `Confirming transfer ${transfer.completedConfirmations + 1} of ${transfer.neededConfirmations + Number(getBridgeParams().nearEventRelayerMargin)}` + case SYNC: return 'Depositing in NEAR' + case UNLOCK: return 'Transfer complete' + default: throw new Error(`Transfer in unexpected state, transfer with ID=${transfer.id} & status=${transfer.status} has completedStep=${transfer.completedStep}`) + } + }, + callToAction: (transfer: Transfer) => { + if (transfer.status === status.FAILED) return 'Retry' + if (transfer.status !== status.ACTION_NEEDED) return null + switch (transfer.completedStep) { + case null: return 'Transfer' + case SYNC: return 'Deposit' + default: throw new Error(`Transfer in unexpected state, transfer with ID=${transfer.id} & status=${transfer.status} has completedStep=${transfer.completedStep}`) + } + } + } +} +/* eslint-enable @typescript-eslint/restrict-template-expressions */ + +/** + * Called when status is ACTION_NEEDED or FAILED + * @param transfer Transfer object to act on. + */ +export async function act (transfer: Transfer): Promise { + switch (transfer.completedStep) { + case null: return await burn(transfer) + case BURN: return await checkSync(transfer) + case SYNC: + try { + return await unlock(transfer) + } catch (error) { + console.error(error) + if (error.message.includes('Failed to redirect to sign transaction')) { + // Increase time to redirect to wallet before recording an error + await new Promise(resolve => setTimeout(resolve, 10000)) + } + if (typeof window !== 'undefined') urlParams.clear('unlocking') + throw error + } + default: throw new Error(`Don't know how to act on transfer: ${transfer.id}`) + } +} + +/** + * Called when status is IN_PROGRESS + * @param transfer Transfer object to check status on. + */ +export async function checkStatus (transfer: Transfer): Promise { + switch (transfer.completedStep) { + case null: return await checkBurn(transfer) + case BURN: return await checkSync(transfer) + case SYNC: return await checkUnlock(transfer) + default: throw new Error(`Don't know how to checkStatus for transfer ${transfer.id}`) + } +} + +/** + * Find all burn transactions sending `erc20Address` tokens to NEAR. + * @param params Uses Named Arguments pattern, please pass arguments as object + * @param params.fromBlock Ethereum block number. + * @param params.toBlock 'latest' | Ethereum block number. + * @param params.sender Ethereum address. + * @param params.erc20Address Token address. + * @param params.options Optional arguments. + * @param params.options.provider Ethereum provider to use. + * @param params.options.erc20FactoryAddress Rainbow bridge ERC-20 token factory address. + * @param params.options.erc20FactoryAbi Rainbow bridge ERC-20 token factory abi. + * @returns Array of Ethereum transaction hashes. + */ +export async function findAllTransactions ( + { fromBlock, toBlock, sender, erc20Address, options }: { + fromBlock: number | string + toBlock: number | string + sender: string + erc20Address: string + options?: { + provider?: ethers.providers.Provider + erc20FactoryAddress?: string + erc20FactoryAbi?: string + } + } +): Promise { + options = options ?? {} + const bridgeParams = getBridgeParams() + const provider = options.provider ?? getEthProvider() + const erc20Factory = new ethers.Contract( + options.erc20FactoryAddress ?? bridgeParams.erc20FactoryAddress, + options.erc20FactoryAbi ?? bridgeParams.erc20FactoryAbi, + provider + ) + const filter = erc20Factory.filters.Withdraw!(null, sender, null, null, erc20Address) + const events = await erc20Factory.queryFilter(filter, fromBlock, toBlock) + return events.filter(event => !event.args!.recipient.startsWith('aurora:')).map(event => event.transactionHash) +} + +/** + * Recover all transfers sending `erc20Address` tokens to Near. + * @param params Uses Named Arguments pattern, please pass arguments as object + * @param params.fromBlock Ethereum block number. + * @param params.toBlock 'latest' | Ethereum block number. + * @param params.sender Ethereum address. + * @param params.erc20Address Token address. + * @param params.options TransferOptions. + * @returns Array of recovered transfers. + */ +export async function findAllTransfers ( + { fromBlock, toBlock, sender, erc20Address, options }: { + fromBlock: number | string + toBlock: number | string + sender: string + erc20Address: string + options?: TransferOptions & { + decimals?: number + symbol?: string + } + } +): Promise { + const lockTransactions = await findAllTransactions({ fromBlock, toBlock, sender, erc20Address, options }) + const transfers = await Promise.all(lockTransactions.map(async (tx) => await recover(tx, options))) + return transfers +} + +/** + * Recover transfer from a burn tx hash. + * @param burnTxHash Ethereum transaction hash which initiated the transfer. + * @param options TransferOptions optional arguments. + * @returns The recovered transfer object + */ +export async function recover ( + burnTxHash: string, + options?: TransferOptions & { + decimals?: number + symbol?: string + } +): Promise { + options = options ?? {} + const bridgeParams = getBridgeParams() + const provider = options.provider ?? getEthProvider() + + const receipt = await provider.getTransactionReceipt(burnTxHash) + const erc20Factory = new ethers.Contract( + options.erc20FactoryAddress ?? bridgeParams.erc20FactoryAddress, + options.erc20FactoryAbi ?? bridgeParams.erc20FactoryAbi, + provider + ) + const filter = erc20Factory.filters.Withdraw!() + const events = await erc20Factory.queryFilter(filter, receipt.blockNumber, receipt.blockNumber) + const burnEvent = events.find(event => event.transactionHash === burnTxHash) + if (!burnEvent) { + throw new Error('Unable to process burn transaction event.') + } + const erc20Address = burnEvent.args!.tokenEthAddress + const amount = burnEvent.args!.amount.toString() + const recipient = burnEvent.args!.recipient + const sender = burnEvent.args!.sender + const symbol: string = options.symbol ?? await getSymbol({ erc20Address, options }) + const sourceTokenName = symbol + const destinationTokenName = symbol + const decimals = options.decimals ?? await getDecimals({ erc20Address, options }) + + const txBlock = await burnEvent.getBlock() + + const transfer = { + ...transferDraft, + + id: Math.random().toString().slice(2), + startTime: new Date(txBlock.timestamp * 1000).toISOString(), + amount, + completedStep: BURN, + destinationTokenName, + recipient, + sender, + sourceToken: erc20Address, + sourceTokenName, + symbol, + decimals, + status: status.IN_PROGRESS, + + burnHashes: [burnTxHash], + burnReceipts: [receipt] + } + + // Check transfer status + return await checkSync(transfer, options) +} + +/** + * Initiate a transfer from Ethereum to NEAR by burning tokens. + * Broadcasts the burn transaction and creates a transfer object. + * The receipt will be fetched by checkStatus. + * @param params Uses Named Arguments pattern, please pass arguments as object + * @param params.erc20Address ERC-20 address of token to transfer. + * @param params.amount Number of tokens to transfer. + * @param params.recipient NEAR address to receive tokens on the other side of the bridge. + * @param params.options Optional arguments. + * @param params.options.symbol ERC-20 symbol (queried if not provided). + * @param params.options.decimals ERC-20 decimals (queried if not provided). + * @param params.options.nep141Address NEP-141 address of token to transfer. + * @param params.options.sender Sender of tokens (defaults to the connected wallet address). + * @param params.options.ethChainId Ethereum chain id of the bridge. + * @param params.options.provider Ethereum provider to use. + * @param params.options.erc20FactoryAddress Rainbow bridge ERC-20 token factory address. + * @param params.options.erc20FactoryAbi Rainbow bridge ERC-20 token factory abi. + * @param params.options.erc20Abi ERC-20 token abi. + * @param params.options.signer Ethers signer to use. + * @returns The created transfer object. + */ +export async function initiate ( + { erc20Address, amount, recipient, options }: { + erc20Address: string + amount: string | ethers.BigNumber + recipient: string + options?: { + symbol?: string + decimals?: number + nep141Address?: string + sender?: string + ethChainId?: number + provider?: ethers.providers.JsonRpcProvider + erc20FactoryAddress?: string + erc20FactoryAbi?: string + erc20Abi?: string + signer?: ethers.Signer + } + } +): Promise { + options = options ?? {} + const provider = options.provider ?? getSignerProvider() + const symbol: string = options.symbol ?? await getSymbol({ erc20Address, options }) + const sourceTokenName = symbol + const destinationTokenName = symbol + const decimals = options.decimals ?? await getDecimals({ erc20Address, options }) + const signer = options.signer ?? provider.getSigner() + const sender = options.sender ?? (await signer.getAddress()).toLowerCase() + + // various attributes stored as arrays, to keep history of retries + let transfer = { + ...transferDraft, + + id: Math.random().toString().slice(2), + startTime: new Date().toISOString(), + amount: amount.toString(), + destinationTokenName, + recipient, + sender, + sourceToken: erc20Address, + sourceTokenName, + symbol, + decimals + } + + transfer = await burn(transfer, options) + + if (typeof window !== 'undefined') transfer = await track(transfer) as Transfer + + return transfer +} + +export async function burn ( + transfer: Transfer, + options?: { + nep141Address?: string + provider?: ethers.providers.JsonRpcProvider + ethChainId?: number + erc20FactoryAddress?: string + erc20FactoryAbi?: string + signer?: ethers.Signer + } +): Promise { + options = options ?? {} + const bridgeParams = getBridgeParams() + const provider = options.provider ?? getSignerProvider() + + const ethChainId: number = (await provider.getNetwork()).chainId + const expectedChainId: number = options.ethChainId ?? bridgeParams.ethChainId + if (ethChainId !== expectedChainId) { + // Webapp should prevent the user from confirming if the wrong network is selected + throw new Error( + `Wrong eth network for burn, expected: ${expectedChainId}, got: ${ethChainId}` + ) + } + + const erc20Factory = new ethers.Contract( + options.erc20FactoryAddress ?? bridgeParams.erc20FactoryAddress, + options.erc20FactoryAbi ?? bridgeParams.erc20FactoryAbi, + options.signer ?? provider.getSigner() + ) + const nep141Address = options.nep141Address ?? await erc20Factory.ethToNearToken(transfer.sourceToken) + + // If this tx is dropped and replaced, lower the search boundary + // in case there was a reorg. + const safeReorgHeight = await provider.getBlockNumber() - 20 + const pendingBurnTx = await erc20Factory.withdraw(nep141Address, transfer.amount, transfer.recipient) + + return { + ...transfer, + status: status.IN_PROGRESS, + ethCache: { + from: pendingBurnTx.from, + to: pendingBurnTx.to, + safeReorgHeight, + data: pendingBurnTx.data, + nonce: pendingBurnTx.nonce + }, + burnHashes: [...transfer.burnHashes, pendingBurnTx.hash] + } +} + +export async function checkBurn ( + transfer: Transfer, + options?: { + provider?: ethers.providers.Provider + ethChainId?: number + } +): Promise { + options = options ?? {} + const bridgeParams = getBridgeParams() + const provider = options.provider ?? getEthProvider() + + const burnHash = last(transfer.burnHashes) + const ethChainId: number = (await provider.getNetwork()).chainId + const expectedChainId: number = options.ethChainId ?? bridgeParams.ethChainId + if (ethChainId !== expectedChainId) { + throw new Error( + `Wrong eth network for checkBurn, expected: ${expectedChainId}, got: ${ethChainId}` + ) + } + let burnReceipt = await provider.getTransactionReceipt(burnHash) + + // If no receipt, check that the transaction hasn't been replaced (speedup or canceled) + if (!burnReceipt) { + // don't break old transfers in case they were made before this functionality is released + if (!transfer.ethCache) return transfer + try { + const tx = { + nonce: transfer.ethCache.nonce, + from: transfer.ethCache.from, + to: transfer.ethCache.to, + data: transfer.ethCache.data + } + const foundTx = await findReplacementTx(provider, transfer.ethCache.safeReorgHeight, tx) + if (!foundTx) return transfer + burnReceipt = await provider.getTransactionReceipt(foundTx.hash) + } catch (error) { + console.error(error) + if (error instanceof TxValidationError) { + return { + ...transfer, + errors: [...transfer.errors, error.message], + status: status.FAILED + } + } + throw error + } + } + + if (!burnReceipt) return transfer + + if (!burnReceipt.status) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + const error = `Transaction failed: ${burnReceipt.transactionHash}` + return { + ...transfer, + status: status.FAILED, + errors: [...transfer.errors, error], + burnReceipts: [...transfer.burnReceipts, burnReceipt] + } + } + + if (burnReceipt.transactionHash !== burnHash) { + // Record the replacement tx burnHash + transfer = { + ...transfer, + burnHashes: [...transfer.burnHashes, burnReceipt.transactionHash] + } + } + + const txBlock = await provider.getBlock(burnReceipt.blockHash) + + return { + ...transfer, + status: status.IN_PROGRESS, + completedStep: BURN, + startTime: new Date(txBlock.timestamp * 1000).toISOString(), + burnReceipts: [...transfer.burnReceipts, burnReceipt] + } +} + +export async function checkSync ( + transfer: Transfer | string, + options?: TransferOptions +): Promise { + if (typeof transfer === 'string') { + return await recover(transfer, options) + } + options = options ?? {} + const bridgeParams = getBridgeParams() + const provider = options.provider ?? getEthProvider() + const nearProvider = + options.nearProvider ?? + options.nearAccount?.connection.provider ?? + getNearProvider() + + if (!transfer.checkSyncInterval) { + // checkSync every 20s: reasonable value to show the confirmation counter x/30 + transfer = { + ...transfer, + checkSyncInterval: options.sendToNearSyncInterval ?? bridgeParams.sendToNearSyncInterval + } + } + if (transfer.nextCheckSyncTimestamp && new Date() < new Date(transfer.nextCheckSyncTimestamp)) { + return transfer + } + const burnReceipt = last(transfer.burnReceipts) + const eventEmittedAt = burnReceipt.blockNumber + const syncedTo = await ethOnNearSyncHeight( + options.nearClientAccount ?? bridgeParams.nearClientAccount, + nearProvider + ) + const completedConfirmations = Math.max(0, syncedTo - eventEmittedAt) + let proof + + if (completedConfirmations > transfer.neededConfirmations) { + // Check if relayer already minted + proof = await findEthProof( + 'Withdraw', + burnReceipt.transactionHash, + options.erc20FactoryAddress ?? bridgeParams.erc20FactoryAddress, + options.erc20FactoryAbi ?? bridgeParams.erc20FactoryAbi, + provider + ) + const result = await nearProvider.query({ + request_type: 'call_function', + account_id: options.nep141LockerAccount ?? bridgeParams.nep141LockerAccount, + method_name: 'is_used_proof', + args_base64: Buffer.from(proof).toString('base64'), + finality: 'optimistic' + }) + const proofAlreadyUsed = JSON.parse(Buffer.from(result.result).toString()) + if (proofAlreadyUsed) { + if (options.callIndexer) { + try { + const { transactions, timestamps } = await findFinalizationTxOnNear({ + proof: Buffer.from(proof).toString('base64'), + connectorAccount: options.nep141LockerAccount ?? bridgeParams.nep141LockerAccount, + eventRelayerAccount: options.eventRelayerAccount ?? bridgeParams.eventRelayerAccount, + finalizationMethod: 'withdraw', + ethTxHash: burnReceipt.transactionHash, + callIndexer: options.callIndexer + }) + let finishTime: string | undefined + if (timestamps.length > 0) { + finishTime = new Date(timestamps[0]! / 10 ** 6).toISOString() + } + transfer = { + ...transfer, + finishTime, + unlockHashes: [...transfer.unlockHashes, ...transactions] + } + } catch (error) { + // Not finding the finalization tx should not prevent processing/recovering the transfer. + console.error(error) + } + } + return { + ...transfer, + completedStep: UNLOCK, + completedConfirmations, + status: status.COMPLETE, + errors: [...transfer.errors, 'Transfer already finalized.'] + } + } + } + + const nearEventRelayerMargin: number = options.nearEventRelayerMargin ?? bridgeParams.nearEventRelayerMargin + if (completedConfirmations < transfer.neededConfirmations + nearEventRelayerMargin) { + // Leave some time for the relayer to finalize + return { + ...transfer, + nextCheckSyncTimestamp: new Date(Date.now() + transfer.checkSyncInterval!), + completedConfirmations, + status: status.IN_PROGRESS + } + } + + return { + ...transfer, + completedConfirmations, + completedStep: SYNC, + status: status.ACTION_NEEDED, + proof // used when checkSync() is called by unlock() + } +} + +export async function unlock ( + transfer: Transfer | string, + options?: TransferOptions +): Promise { + options = options ?? {} + const bridgeParams = getBridgeParams() + const nearWallet = options.nearAccount ?? getNearWallet() + const nearProvider = options.nearProvider ?? getNearProvider() + const isNajAccount = nearWallet instanceof Account + const browserRedirect = typeof window !== 'undefined' && (isNajAccount || nearWallet.type === 'browser') + + // Check if the transfer is finalized and get the proof if not + transfer = await checkSync(transfer, options) + if (transfer.status !== status.ACTION_NEEDED) return transfer + const proof = transfer.proof + + const nep141Address = await getNep141Address({ + erc20Address: transfer.sourceToken, + options + }) + const minStorageBalance = await nep141.getMinStorageBalance({ + nep141Address: nep141Address, nearProvider + }) + const userStorageBalance = await nep141.getStorageBalance({ + nep141Address: nep141Address, + accountId: transfer.recipient, + nearProvider + }) + const transactions = [] + if (!userStorageBalance || new BN(userStorageBalance.total).lt(new BN(minStorageBalance))) { + transactions.push({ + receiverId: nep141Address, + actions: [{ + type: 'FunctionCall', + params: { + methodName: 'storage_deposit', + args: { + account_id: transfer.recipient, + registration_only: true + }, + gas: '50' + '0'.repeat(12), + deposit: minStorageBalance + } + }] + }) + } + transactions.push({ + receiverId: options.nep141LockerAccount ?? bridgeParams.nep141LockerAccount, + actions: [{ + type: 'FunctionCall', + params: { + methodName: 'withdraw', + args: Buffer.from(proof!), + gas: '250' + '0'.repeat(12), + deposit: '6' + '0'.repeat(22) + } + }] + }) + + if (browserRedirect) urlParams.set({ unlocking: transfer.id }) + if (browserRedirect) transfer = await track({ ...transfer, status: status.IN_PROGRESS }) as Transfer + + // @ts-expect-error + const txs = await nearWallet.signAndSendTransactions({ transactions }) + const tx = last(txs) + + return { + ...transfer, + status: status.IN_PROGRESS, + unlockHashes: [...transfer.unlockHashes, tx.transaction.hash] + } +} + +export async function checkUnlock ( + transfer: Transfer, + options?: { + nearAccount?: Account + nearProvider?: najProviders.Provider + } +): Promise { + options = options ?? {} + let txHash: string + let clearParams + if (transfer.unlockHashes.length === 0) { + const id = urlParams.get('unlocking') as string | null + // NOTE: when a single tx is executed, transactionHashes is equal to that hash + const transactionHashes = urlParams.get('transactionHashes') as string | null + const errorCode = urlParams.get('errorCode') as string | null + clearParams = ['unlocking', 'transactionHashes', 'errorCode', 'errorMessage'] + if (!id) { + // The user closed the tab and never rejected or approved the tx from Near wallet. + // This doesn't protect agains the user broadcasting a tx and closing the tab before + // redirect. So the dapp has no way of knowing the status of that transaction. + // Set status to FAILED so that it can be retried + const newError = `A finalization transaction was initiated but could not be verified. + Click 'Retry' to make sure the transfer is finalized.` + console.error(newError) + return { + ...transfer, + status: status.FAILED, + errors: [...transfer.errors, newError] + } + } + if (id !== transfer.id) { + // Another unlocking transaction cannot be in progress, ie if checkUnlock is called on + // an in progess unlock then the transfer ids must be equal or the url callback is invalid. + const newError = `Couldn't determine transaction outcome. + Got transfer id '${id} in URL, expected '${transfer.id}` + console.error(newError) + return { + ...transfer, + status: status.FAILED, + errors: [...transfer.errors, newError] + } + } + if (errorCode) { + // If errorCode, then the redirect succeded but the tx was rejected/failed + // so clear url params + urlParams.clear(...clearParams) + const newError = 'Error from wallet: ' + errorCode + console.error(newError) + return { + ...transfer, + status: status.FAILED, + errors: [...transfer.errors, newError] + } + } + if (!transactionHashes) { + // If checkUnlock is called before unlock sig wallet redirect, + // log the error but don't mark as FAILED and don't clear url params + // as the wallet redirect has not happened yet + const newError = 'Tx hash not received: pending redirect or wallet error' + console.log(newError) + return transfer + } + if (transactionHashes.includes(',')) { + urlParams.clear(...clearParams) + const newError = 'Error from wallet: expected single txHash, got: ' + transactionHashes + console.error(newError) + return { + ...transfer, + status: status.FAILED, + errors: [...transfer.errors, newError] + } + } + txHash = transactionHashes + } else { + txHash = last(transfer.unlockHashes) + } + + const decodedTxHash = utils.serialize.base_decode(txHash) + const nearProvider = + options.nearProvider ?? + options.nearAccount?.connection.provider ?? + getNearProvider() + const mintTx = await nearProvider.txStatus( + decodedTxHash, options?.nearAccount?.accountId ?? 'todo' + ) + + // @ts-expect-error : wallet returns errorCode + if (mintTx.status.Unknown) { + // Transaction or receipt not processed yet + return transfer + } + + // Check status of tx broadcasted by wallet + // @ts-expect-error : wallet returns errorCode + if (mintTx.status.Failure) { + if (clearParams) urlParams.clear(...clearParams) + const error = `NEAR transaction failed: ${txHash}` + console.error(error) + return { + ...transfer, + errors: [...transfer.errors, error], + status: status.FAILED, + unlockHashes: [...transfer.unlockHashes, txHash] + } + } + + // Clear urlParams at the end so that if the provider connection throws, + // checkStatus will be able to process it again in the next loop. + if (clearParams) urlParams.clear(...clearParams) + + return { + ...transfer, + completedStep: UNLOCK, + status: status.COMPLETE, + unlockHashes: [...transfer.unlockHashes, txHash] + } +} + +const last = (arr: any[]): any => arr[arr.length - 1] diff --git a/packages/nep141-erc20/src/index.ts b/packages/nep141-erc20/src/index.ts index 037c0e76..22870cd0 100644 --- a/packages/nep141-erc20/src/index.ts +++ b/packages/nep141-erc20/src/index.ts @@ -1,2 +1,4 @@ export * as bridgedNep141 from './bridged-nep141' export * as naturalErc20 from './natural-erc20' +export * as naturalNep141 from './natural-nep141' +export * as bridgedErc20 from './bridged-erc20' diff --git a/packages/nep141-erc20/src/natural-erc20/sendToNear/index.ts b/packages/nep141-erc20/src/natural-erc20/sendToNear/index.ts index 0128ab21..7e9bf614 100644 --- a/packages/nep141-erc20/src/natural-erc20/sendToNear/index.ts +++ b/packages/nep141-erc20/src/natural-erc20/sendToNear/index.ts @@ -802,8 +802,7 @@ export async function mint ( contractId: options.nep141Factory ?? bridgeParams.nep141Factory, methodName: 'deposit', args: proof!, - // 200Tgas: enough for execution, not too much so that a 2fa tx is within 300Tgas - gas: new BN('200' + '0'.repeat(12)), + gas: new BN('250' + '0'.repeat(12)), // We need to attach tokens because minting increases the contract state, by <600 bytes, which // requires an additional 0.06 NEAR to be deposited to the account for state staking. // Note technically 0.0537 NEAR should be enough, but we round it up to stay on the safe side. @@ -818,7 +817,7 @@ export async function mint ( params: { methodName: 'deposit', args: proof!, - gas: '200' + '0'.repeat(12), + gas: '250' + '0'.repeat(12), deposit: '6' + '0'.repeat(22) } } diff --git a/packages/nep141-erc20/src/natural-nep141/getMetadata.ts b/packages/nep141-erc20/src/natural-nep141/getMetadata.ts new file mode 100644 index 00000000..138d298d --- /dev/null +++ b/packages/nep141-erc20/src/natural-nep141/getMetadata.ts @@ -0,0 +1,26 @@ +import { Account, providers as najProviders } from 'near-api-js' +import { nep141 } from '@near-eth/utils' +import { getNearProvider } from '@near-eth/client/dist/utils' + +const tokenMetadata: {[key: string]: {symbol: string, decimals: number, name: string, icon: string}} = {} + +export default async function getMetadata ( + { nep141Address, options }: { + nep141Address: string + options?: { + nearAccount?: Account + nearProvider?: najProviders.Provider + } + } +): Promise<{symbol: string, decimals: number, name: string, icon: string}> { + options = options ?? {} + const nearProvider = + options.nearProvider ?? + options.nearAccount?.connection.provider ?? + getNearProvider() + if (tokenMetadata[nep141Address]) return tokenMetadata[nep141Address]! + + const metadata = await nep141.getMetadata({ nep141Address, nearProvider }) + tokenMetadata[nep141Address] = metadata + return metadata +} diff --git a/packages/nep141-erc20/src/natural-nep141/index.ts b/packages/nep141-erc20/src/natural-nep141/index.ts new file mode 100644 index 00000000..72d51b4e --- /dev/null +++ b/packages/nep141-erc20/src/natural-nep141/index.ts @@ -0,0 +1,7 @@ +export { default as getMetadata } from './getMetadata' +export { + initiate as sendToEthereum, + recover, + findAllTransactions, + findAllTransfers +} from './sendToEthereum' diff --git a/packages/nep141-erc20/src/natural-nep141/sendToEthereum/index.ts b/packages/nep141-erc20/src/natural-nep141/sendToEthereum/index.ts new file mode 100644 index 00000000..933719a0 --- /dev/null +++ b/packages/nep141-erc20/src/natural-nep141/sendToEthereum/index.ts @@ -0,0 +1,905 @@ +import BN from 'bn.js' +import bs58 from 'bs58' +import { ethers } from 'ethers' +import { Account, utils, providers as najProviders } from 'near-api-js' +import * as status from '@near-eth/client/dist/statuses' +import { stepsFor } from '@near-eth/client/dist/i18nHelpers' +import { TransferStatus, TransactionInfo } from '@near-eth/client/dist/types' +import { track, untrack } from '@near-eth/client' +import { + borshifyOutcomeProof, + urlParams, + nearOnEthSyncHeight, + findNearProof, + buildIndexerTxQuery, + findFinalizationTxOnEthereum, + parseNep141LockReceipt, + nep141 +} from '@near-eth/utils' +import { findReplacementTx, TxValidationError } from 'find-replacement-tx' +import { + getEthProvider, + getNearWallet, + getNearAccountId, + getNearProvider, + formatLargeNum, + getSignerProvider, + getBridgeParams +} from '@near-eth/client/dist/utils' +import getMetadata from '../getMetadata' + +export const SOURCE_NETWORK = 'near' +export const DESTINATION_NETWORK = 'ethereum' +export const TRANSFER_TYPE = '@near-eth/nep141-erc20/natural-nep141/sendToEthereum' + +const LOCK = 'lock-natural-nep141-to-erc20' +const AWAIT_FINALITY = 'await-finality-natural-nep141-to-erc20' +const SYNC = 'sync-natural-nep141-to-erc20' +const MINT = 'mint-natural-nep141-to-erc20' + +const steps = [ + LOCK, + AWAIT_FINALITY, + SYNC, + MINT +] + +export interface TransferDraft extends TransferStatus { + type: string + finalityBlockHeights: number[] + nearOnEthClientBlockHeight: null | number + mintHashes: string[] + mintReceipts: ethers.providers.TransactionReceipt[] + lockHashes: string[] + lockReceiptBlockHeights: number[] + lockReceiptIds: string[] +} + +export interface Transfer extends TransferDraft, TransactionInfo { + id: string + startTime: string + finishTime?: string + decimals: number + destinationTokenName: string + recipient: string + sender: string + symbol: string + sourceTokenName: string + checkSyncInterval?: number + nextCheckSyncTimestamp?: Date + proof?: Uint8Array +} + +export interface TransferOptions { + provider?: ethers.providers.Provider + sendToEthereumSyncInterval?: number + ethChainId?: number + nearAccount?: Account + nearProvider?: najProviders.Provider + ethClientAddress?: string + ethClientAbi?: string + nep141LockerAccount?: string + erc20FactoryAbi?: string + erc20FactoryAddress?: string +} + +class TransferError extends Error {} + +const transferDraft: TransferDraft = { + // Attributes common to all transfer types + // amount, + completedStep: null, + // destinationTokenName, + errors: [], + // recipient, + // sender, + // sourceToken, + // sourceTokenName, + // decimals, + status: status.IN_PROGRESS, + type: TRANSFER_TYPE, + // Cache eth tx information used for finding a replaced (speedup/cancel) tx. + // ethCache: { + // from, // tx.from of last broadcasted eth tx + // to, // tx.to of last broadcasted eth tx (can be multisig contract) + // safeReorgHeight, // Lower boundary for replacement tx search + // nonce // tx.nonce of last broadcasted eth tx + // } + + // Attributes specific to natural-nep141-to-erc20 transfers + finalityBlockHeights: [], + nearOnEthClientBlockHeight: null, // calculated & set to a number during checkSync + mintHashes: [], + mintReceipts: [], + lockReceiptBlockHeights: [], + lockReceiptIds: [], + lockHashes: [] +} + +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +export const i18n = { + en_US: { + steps: (transfer: Transfer) => stepsFor(transfer, steps, { + [LOCK]: `Start transfer of ${formatLargeNum(transfer.amount, transfer.decimals).toString()} ${transfer.sourceTokenName} from NEAR`, + [AWAIT_FINALITY]: 'Confirm in NEAR', + [SYNC]: 'Confirm in Ethereum. This can take around 16 hours. Feel free to return to this window later, to complete the final step of the transfer.', + [MINT]: `Deposit ${formatLargeNum(transfer.amount, transfer.decimals).toString()} ${transfer.destinationTokenName} in Ethereum` + }), + statusMessage: (transfer: Transfer) => { + if (transfer.status === status.FAILED) return 'Failed' + if (transfer.status === status.ACTION_NEEDED) { + switch (transfer.completedStep) { + case null: return 'Ready to transfer from NEAR' + case SYNC: return 'Ready to deposit in Ethereum' + default: throw new Error(`Transfer in unexpected state, transfer with ID=${transfer.id} & status=${transfer.status} has completedStep=${transfer.completedStep}`) + } + } + switch (transfer.completedStep) { + case null: return 'Transferring from NEAR' + case LOCK: return 'Confirming transfer' + case AWAIT_FINALITY: return 'Confirming transfer' + case SYNC: return 'Depositing in Ethereum' + case MINT: return 'Transfer complete' + default: throw new Error(`Transfer in unexpected state, transfer with ID=${transfer.id} & status=${transfer.status} has completedStep=${transfer.completedStep}`) + } + }, + callToAction: (transfer: Transfer) => { + if (transfer.status === status.FAILED) return 'Retry' + if (transfer.status !== status.ACTION_NEEDED) return null + switch (transfer.completedStep) { + case null: return 'Transfer' + case SYNC: return 'Deposit' + default: throw new Error(`Transfer in unexpected state, transfer with ID=${transfer.id} & status=${transfer.status} has completedStep=${transfer.completedStep}`) + } + } + } +} +/* eslint-enable @typescript-eslint/restrict-template-expressions */ + +/** + * Called when status is ACTION_NEEDED or FAILED + * @param transfer Transfer object to act on. + */ +export async function act (transfer: Transfer): Promise { + switch (transfer.completedStep) { + case null: + try { + return await lock(transfer) + } catch (error) { + console.error(error) + if (error.message.includes('Failed to redirect to sign transaction')) { + // Increase time to redirect to wallet before recording an error + await new Promise(resolve => setTimeout(resolve, 10000)) + } + if (typeof window !== 'undefined') urlParams.clear('locking') + throw error + } + case AWAIT_FINALITY: return await checkSync(transfer) + case SYNC: return await mint(transfer) + default: throw new Error(`Don't know how to act on transfer: ${JSON.stringify(transfer)}`) + } +} + +/** + * Called when status is IN_PROGRESS + * @param transfer Transfer object to check status on. + */ +export async function checkStatus (transfer: Transfer): Promise { + switch (transfer.completedStep) { + case null: return await checkLock(transfer) + case LOCK: return await checkFinality(transfer) + case AWAIT_FINALITY: return await checkSync(transfer) + case SYNC: return await checkMint(transfer) + default: throw new Error(`Don't know how to checkStatus for transfer ${transfer.id}`) + } +} + +/** + * Find all lock transactions sending nep141Address tokens from NEAR to Ethereum. + * Any WAMP library can be used to query the indexer or near explorer backend via the `callIndexer` callback. + * @param params Uses Named Arguments pattern, please pass arguments as object + * @param params.fromBlock NEAR block timestamp. + * @param params.toBlock 'latest' | NEAR block timestamp. + * @param params.sender NEAR account id. + * @param params.erc20Address Token address on Ethereum. + * @param params.callIndexer Function making the query to indexer. + * @param params.options Optional arguments. + * @param params.options.nep141Address Token address on NEAR. + * @returns Array of NEAR transaction hashes. + */ +export async function findAllTransactions ( + { fromBlock, toBlock, sender, nep141Address, callIndexer, options }: { + fromBlock: string + toBlock: string + sender: string + nep141Address: string + callIndexer: (query: string) => Promise<[{ originated_from_transaction_hash: string, args: { method_name: string } }]> + options?: { + } + } +): Promise { + options = options ?? {} + const transactions = await callIndexer(buildIndexerTxQuery( + { fromBlock, toBlock, predecessorAccountId: sender, receiverAccountId: nep141Address } + )) + return transactions.filter(tx => tx.args.method_name === 'withdraw').map(tx => tx.originated_from_transaction_hash) +} + +/** + * Recover all transfers sending nep141Address tokens from NEAR to Ethereum. + * Any WAMP library can be used to query the indexer or near explorer backend via the `callIndexer` callback. + * @param params Uses Named Arguments pattern, please pass arguments as object + * @param params.fromBlock NEAR block timestamp. + * @param params.toBlock 'latest' | NEAR block timestamp. + * @param params.sender NEAR account id. + * @param params.erc20Address Token address on Ethereum. + * @param params.callIndexer Function making the query to indexer. + * @param params.options TransferOptions. + * @returns Array of recovered transfers. + */ +export async function findAllTransfers ( + { fromBlock, toBlock, sender, nep141Address, callIndexer, options }: { + fromBlock: string + toBlock: string + sender: string + nep141Address: string + callIndexer: (query: string) => Promise<[{ originated_from_transaction_hash: string, args: { method_name: string } }]> + options?: TransferOptions & { + decimals?: number + symbol?: string + } + } +): Promise { + const burnTransactions = await findAllTransactions({ fromBlock, toBlock, sender, nep141Address, callIndexer, options }) + const transfers = await Promise.all(burnTransactions.map(async (tx) => { + try { + return await recover(tx, sender, options) + } catch (error) { + // Unlike with Ethereum events, the transaction exists even if it failed. + // So ignore the transfer if it cannot be recovered + console.log('Failed to recover transfer (transaction failed ?): ', tx, error) + return null + } + })) + return transfers.filter((transfer: Transfer | null): transfer is Transfer => transfer !== null) +} + +/** + * Recover transfer from a lock tx hash + * @param lockTxHash Near tx hash containing the token lock + * @param sender Near account sender of lockTxHash + * @param options TransferOptions optional arguments. + * @returns The recovered transfer object + */ +export async function recover ( + lockTxHash: string, + sender: string = 'todo', + options?: TransferOptions & { + decimals?: number + symbol?: string + } +): Promise { + options = options ?? {} + const nearProvider = + options.nearProvider ?? + options.nearAccount?.connection.provider ?? + getNearProvider() + const decodedTxHash = utils.serialize.base_decode(lockTxHash) + const lockTx = await nearProvider.txStatus( + decodedTxHash, sender + ) + sender = lockTx.transaction.signer_id + + // @ts-expect-error TODO + if (lockTx.status.Unknown) { + // Transaction or receipt not processed yet + throw new Error(`Lock transaction pending: ${lockTxHash}`) + } + + // @ts-expect-error TODO + if (lockTx.status.Failure) { + throw new Error(`Lock transaction failed: ${lockTxHash}`) + } + + const nep141LockerAccount = options.nep141LockerAccount ?? getBridgeParams().nep141LockerAccount + const lockReceipt = await parseNep141LockReceipt(lockTx, nep141LockerAccount, nearProvider) + + const { amount, recipient, token: nep141Address } = lockReceipt.event + let metadata = { symbol: '', decimals: 0 } + if (!options.symbol || !options.decimals) { + metadata = await getMetadata({ nep141Address, options }) + } + const symbol = options.symbol ?? metadata.symbol + const decimals = options.decimals ?? metadata.decimals + const destinationTokenName = symbol + const sourceTokenName = symbol + const sourceToken = nep141Address + + // various attributes stored as arrays, to keep history of retries + const transfer = { + ...transferDraft, + + id: Math.random().toString().slice(2), + startTime: new Date(lockReceipt.blockTimestamp / 10 ** 6).toISOString(), + amount, + completedStep: LOCK, + destinationTokenName, + recipient, + sender, + sourceToken, + sourceTokenName, + symbol, + decimals, + + lockHashes: [lockTxHash], + lockReceiptBlockHeights: [lockReceipt.blockHeight], + lockReceiptIds: [lockReceipt.id] + } + + // Check transfer status + return await checkSync(transfer, options) +} + +/** + * Initiate a transfer from NEAR to Ethereum by locking tokens. + * @param params Uses Named Arguments pattern, please pass arguments as object + * @param params.nep141Address NEP-141 address of the NEAR token to transfer. + * @param params.amount Number of tokens to transfer. + * @param params.recipient Ethereum address to receive tokens on the other side of the bridge. + * @param params.options Optional arguments. + * @param params.options.symbol ERC-20 symbol (queried if not provided). + * @param params.options.decimals ERC-20 decimals (queried if not provided). + * @param params.options.sender Sender of tokens (defaults to the connected NEAR wallet address). + * @param params.options.nearAccount Connected NEAR wallet account to use. + * @param params.options.nearProvider NEAR provider. + * @returns The created transfer object. + */ +export async function initiate ( + { nep141Address, amount, recipient, options }: { + nep141Address: string + amount: string | ethers.BigNumber + recipient: string + options?: { + symbol?: string + decimals?: number + sender?: string + nearAccount?: Account + nearProvider?: najProviders.Provider + } + } +): Promise { + options = options ?? {} + let metadata = { symbol: '', decimals: 0 } + if (!options.symbol || !options.decimals) { + const nearProvider = + options.nearProvider ?? + options.nearAccount?.connection.provider ?? + getNearProvider() + metadata = await nep141.getMetadata({ nep141Address, nearProvider }) + } + const symbol: string = options.symbol ?? metadata.symbol + const sourceTokenName = symbol + const destinationTokenName = symbol + const sourceToken = nep141Address + const decimals = options.decimals ?? metadata.decimals + const sender = options.sender ?? await getNearAccountId() + + // various attributes stored as arrays, to keep history of retries + let transfer = { + ...transferDraft, + + id: Math.random().toString().slice(2), + startTime: new Date().toISOString(), + amount: amount.toString(), + destinationTokenName, + recipient, + sender, + sourceToken, + sourceTokenName, + symbol, + decimals + } + + try { + transfer = await lock(transfer, options) + // Track for injected NEAR wallet (Sender) + if (typeof window !== 'undefined') transfer = await track(transfer) as Transfer + } catch (error) { + if (error.message.includes('Failed to redirect to sign transaction')) { + // Increase time to redirect to wallet before alerting an error + await new Promise(resolve => setTimeout(resolve, 10000)) + } + if (typeof window !== 'undefined' && urlParams.get('locking')) { + // If the urlParam is set then the transfer was tracked so delete it. + await untrack(urlParams.get('locking') as string) + urlParams.clear('locking') + } + // Throw the error to be handled by frontend + throw error + } + + return transfer +} + +export async function lock ( + transfer: Transfer, + options?: { + nearAccount?: Account + nep141LockerAccount?: Account + } +): Promise { + options = options ?? {} + const nearWallet = options.nearAccount ?? getNearWallet() + const isNajAccount = nearWallet instanceof Account + const browserRedirect = typeof window !== 'undefined' && (isNajAccount || nearWallet.type === 'browser') + + // NOTE: + // checkStatus should wait for NEAR wallet redirect if it didn't happen yet. + // On page load the dapp should clear urlParams if transactionHashes or errorCode are not present: + // this will allow checkStatus to handle the transfer as failed because the NEAR transaction could not be processed. + if (browserRedirect) urlParams.set({ locking: transfer.id }) + if (browserRedirect) transfer = await track({ ...transfer, status: status.IN_PROGRESS }) as Transfer + + let tx + if (isNajAccount) { + tx = await nearWallet.functionCall({ + contractId: transfer.sourceToken, + methodName: 'ft_transfer_call', + args: { + receiver_id: options.nep141LockerAccount ?? getBridgeParams().nep141LockerAccount, + amount: transfer.amount, + memo: null, + msg: transfer.recipient.toLowerCase().slice(2) + }, + // 100Tgas: enough for execution, not too much so that a 2fa tx is within 300Tgas + gas: new BN('100' + '0'.repeat(12)), + attachedDeposit: new BN('1') + }) + } else { + tx = await nearWallet.signAndSendTransaction({ + receiverId: transfer.sourceToken, + actions: [ + { + type: 'FunctionCall', + params: { + methodName: 'ft_transfer_call', + args: { + receiver_id: options.nep141LockerAccount ?? getBridgeParams().nep141LockerAccount, + amount: transfer.amount, + memo: null, + msg: transfer.recipient.toLowerCase().slice(2) + }, + gas: '100' + '0'.repeat(12), + deposit: '1' + } + } + ] + }) + } + + return { + ...transfer, + status: status.IN_PROGRESS, + lockHashes: [...transfer.lockHashes, tx.transaction.hash] + } +} + +export async function checkLock ( + transfer: Transfer, + options?: { + nearAccount?: Account + nearProvider?: najProviders.Provider + nep141LockerAccount?: string + } +): Promise { + options = options ?? {} + let txHash: string + let clearParams + if (transfer.lockHashes.length === 0) { + const id = urlParams.get('locking') as string | null + // NOTE: when a single tx is executed, transactionHashes is equal to that hash + const transactionHashes = urlParams.get('transactionHashes') as string | null + const errorCode = urlParams.get('errorCode') as string | null + clearParams = ['locking', 'transactionHashes', 'errorCode', 'errorMessage'] + if (!id) { + // The user closed the tab and never rejected or approved the tx from Near wallet. + // This doesn't protect agains the user broadcasting a tx and closing the tab before + // redirect. So the dapp has no way of knowing the status of that transaction. + // Set status to FAILED so that it can be retried + const newError = `A transaction was initiated but could not be verified. + Click 'Rescan the blockchain' to check if a transfer was created.` + console.error(newError) + return { + ...transfer, + status: status.FAILED, + errors: [...transfer.errors, newError] + } + } + if (id !== transfer.id) { + // Another withdraw transaction cannot be in progress, ie if checkLock is called on + // an in process withdraw then the transfer ids must be equal or the url callback is invalid. + const newError = `Couldn't determine transaction outcome. + Got transfer id '${id} in URL, expected '${transfer.id}` + console.error(newError) + return { + ...transfer, + status: status.FAILED, + errors: [...transfer.errors, newError] + } + } + if (errorCode) { + // If errorCode, then the redirect succeded but the tx was rejected/failed + // so clear url params + urlParams.clear(...clearParams) + const newError = 'Error from wallet: ' + errorCode + console.error(newError) + return { + ...transfer, + status: status.FAILED, + errors: [...transfer.errors, newError] + } + } + if (!transactionHashes) { + // If checkLock is called before withdraw sig wallet redirect + // log the error but don't mark as FAILED and don't clear url params + // as the wallet redirect has not happened yet + const newError = 'Withdraw tx hash not received: pending redirect or wallet error' + console.log(newError) + return transfer + } + if (transactionHashes.includes(',')) { + urlParams.clear(...clearParams) + const newError = 'Error from wallet: expected single txHash, got: ' + transactionHashes + console.error(newError) + return { + ...transfer, + status: status.FAILED, + errors: [...transfer.errors, newError] + } + } + txHash = transactionHashes + } else { + txHash = last(transfer.lockHashes) + } + + const decodedTxHash = utils.serialize.base_decode(txHash) + const nearProvider = + options.nearProvider ?? + options.nearAccount?.connection.provider ?? + getNearProvider() + const lockTx = await nearProvider.txStatus( + // use transfer.sender instead of nearAccount.accountId so that a withdraw + // tx hash can be recovered even if it is not made by the logged in account + decodedTxHash, transfer.sender + ) + + // @ts-expect-error : wallet returns errorCode + if (lockTx.status.Unknown) { + // Transaction or receipt not processed yet + return transfer + } + + // Check status of tx broadcasted by wallet + // @ts-expect-error : wallet returns errorCode + if (lockTx.status.Failure) { + if (clearParams) urlParams.clear(...clearParams) + const error = `NEAR transaction failed: ${txHash}` + console.error(error) + return { + ...transfer, + errors: [...transfer.errors, error], + status: status.FAILED, + lockHashes: [...transfer.lockHashes, txHash] + } + } + + let lockReceipt + const nep141LockerAccount = options.nep141LockerAccount ?? getBridgeParams().nep141LockerAccount + try { + lockReceipt = await parseNep141LockReceipt(lockTx, nep141LockerAccount, nearProvider) + } catch (e) { + if (e instanceof TransferError) { + if (clearParams) urlParams.clear(...clearParams) + return { + ...transfer, + errors: [...transfer.errors, e.message], + status: status.FAILED, + lockHashes: [...transfer.lockHashes, txHash] + } + } + // Any other error like provider connection error should throw + // so that the transfer stays in progress and checkLock will be called again. + throw e + } + + const startTime = new Date(lockReceipt.blockTimestamp / 10 ** 6).toISOString() + + // Clear urlParams at the end so that if the provider connection throws, + // checkStatus will be able to process it again in the next loop. + if (clearParams) urlParams.clear(...clearParams) + + return { + ...transfer, + status: status.IN_PROGRESS, + completedStep: LOCK, + startTime, + lockReceiptIds: [...transfer.lockReceiptIds, lockReceipt.id], + lockReceiptBlockHeights: [...transfer.lockReceiptBlockHeights, lockReceipt.blockHeight], + lockHashes: [...transfer.lockHashes, txHash] + } +} + +export async function checkFinality ( + transfer: Transfer, + options?: { + nearAccount?: Account + nearProvider?: najProviders.Provider + } +): Promise { + options = options ?? {} + const nearProvider = + options.nearProvider ?? + options.nearAccount?.connection.provider ?? + getNearProvider() + + const withdrawReceiptBlockHeight = last(transfer.lockReceiptBlockHeights) + const latestFinalizedBlock = Number(( + await nearProvider.block({ finality: 'final' }) + ).header.height) + + if (latestFinalizedBlock <= withdrawReceiptBlockHeight) { + return transfer + } + + return { + ...transfer, + completedStep: AWAIT_FINALITY, + status: status.IN_PROGRESS, + finalityBlockHeights: [...transfer.finalityBlockHeights, latestFinalizedBlock] + } +} + +export async function checkSync ( + transfer: Transfer | string, + options?: TransferOptions +): Promise { + if (typeof transfer === 'string') { + return await recover(transfer, 'todo', options) + } + options = options ?? {} + const bridgeParams = getBridgeParams() + const provider = options.provider ?? getEthProvider() + if (!transfer.checkSyncInterval) { + // checkSync every 60s: reasonable value to detect transfer is ready to be finalized + transfer = { + ...transfer, + checkSyncInterval: options.sendToEthereumSyncInterval ?? bridgeParams.sendToEthereumSyncInterval + } + } + if (transfer.nextCheckSyncTimestamp && new Date() < new Date(transfer.nextCheckSyncTimestamp)) { + return transfer + } + + const ethChainId: number = (await provider.getNetwork()).chainId + const expectedChainId: number = options.ethChainId ?? bridgeParams.ethChainId + if (ethChainId !== expectedChainId) { + throw new Error( + `Wrong eth network for checkSync, expected: ${expectedChainId}, got: ${ethChainId}` + ) + } + + const lockBlockHeight = last(transfer.lockReceiptBlockHeights) + const nearOnEthClientBlockHeight = await nearOnEthSyncHeight( + provider, + options.ethClientAddress ?? bridgeParams.ethClientAddress, + options.ethClientAbi ?? bridgeParams.ethClientAbi + ) + let proof + + const nearProvider = + options.nearProvider ?? + options.nearAccount?.connection.provider ?? + getNearProvider() + if (nearOnEthClientBlockHeight > lockBlockHeight) { + proof = await findNearProof( + last(transfer.lockReceiptIds), + options.nep141LockerAccount ?? bridgeParams.nep141LockerAccount, + nearOnEthClientBlockHeight, + nearProvider, + provider, + options.ethClientAddress ?? bridgeParams.ethClientAddress, + options.ethClientAbi ?? bridgeParams.ethClientAbi + ) + if (await proofAlreadyUsed( + provider, + proof, + options.erc20FactoryAddress ?? bridgeParams.erc20FactoryAddress, + options.erc20FactoryAbi ?? bridgeParams.erc20FactoryAbi + )) { + try { + const { transactions, block } = await findFinalizationTxOnEthereum({ + usedProofPosition: '3', + proof, + connectorAddress: options.erc20FactoryAddress ?? bridgeParams.erc20FactoryAddress, + connectorAbi: options.erc20FactoryAbi ?? bridgeParams.erc20FactoryAbi, + finalizationEvent: 'Deposit', + recipient: transfer.recipient, + amount: transfer.amount, + provider + }) + transfer = { + ...transfer, + finishTime: new Date(block.timestamp * 1000).toISOString(), + mintHashes: [...transfer.mintHashes, ...transactions] + } + } catch (error) { + // Not finding the finalization tx should not prevent processing/recovering the transfer. + console.error(error) + } + return { + ...transfer, + completedStep: MINT, + nearOnEthClientBlockHeight, + status: status.COMPLETE, + errors: [...transfer.errors, 'Mint proof already used.'] + } + } + } else { + return { + ...transfer, + nextCheckSyncTimestamp: new Date(Date.now() + transfer.checkSyncInterval!), + nearOnEthClientBlockHeight, + status: status.IN_PROGRESS + } + } + + return { + ...transfer, + completedStep: SYNC, + nearOnEthClientBlockHeight, + status: status.ACTION_NEEDED, + proof // used when checkSync() is called by mint() + } +} + +export async function proofAlreadyUsed (provider: ethers.providers.Provider, proof: any, erc20FactoryAddress: string, erc20FactoryAbi: string): Promise { + const erc20Factory = new ethers.Contract( + erc20FactoryAddress, + erc20FactoryAbi, + provider + ) + const proofIsUsed = await erc20Factory.usedProofs('0x' + bs58.decode(proof.outcome_proof.outcome.receipt_ids[0]).toString('hex')) + return proofIsUsed +} + +export async function mint ( + transfer: Transfer | string, + options?: Omit & { + provider?: ethers.providers.JsonRpcProvider + signer?: ethers.Signer + } +): Promise { + options = options ?? {} + const bridgeParams = getBridgeParams() + const provider = options.provider ?? getSignerProvider() + + const ethChainId: number = (await provider.getNetwork()).chainId + const expectedChainId: number = options.ethChainId ?? bridgeParams.ethChainId + if (ethChainId !== expectedChainId) { + throw new Error( + `Wrong eth network for checkSync, expected: ${expectedChainId}, got: ${ethChainId}` + ) + } + + // Build lock proof + transfer = await checkSync(transfer, options) + if (transfer.status !== status.ACTION_NEEDED) return transfer + const proof = transfer.proof + + const borshProof = borshifyOutcomeProof(proof) + + const erc20Factory = new ethers.Contract( + options.erc20FactoryAddress ?? bridgeParams.erc20FactoryAddress, + options.erc20FactoryAbi ?? bridgeParams.erc20FactoryAbi, + options.signer ?? provider.getSigner() + ) + // If this tx is dropped and replaced, lower the search boundary + // in case there was a reorg. + const safeReorgHeight = await provider.getBlockNumber() - 20 + const pendingMintTx = await erc20Factory.deposit(borshProof, transfer.nearOnEthClientBlockHeight) + + return { + ...transfer, + status: status.IN_PROGRESS, + ethCache: { + from: pendingMintTx.from, + to: pendingMintTx.to, + safeReorgHeight, + data: pendingMintTx.data, + nonce: pendingMintTx.nonce + }, + mintHashes: [...transfer.mintHashes, pendingMintTx.hash] + } +} + +export async function checkMint ( + transfer: Transfer, + options?: { + provider?: ethers.providers.Provider + ethChainId?: number + } +): Promise { + options = options ?? {} + const bridgeParams = getBridgeParams() + const provider = options.provider ?? getEthProvider() + + const ethChainId: number = (await provider.getNetwork()).chainId + const expectedChainId: number = options.ethChainId ?? bridgeParams.ethChainId + if (ethChainId !== expectedChainId) { + throw new Error( + `Wrong eth network for checkMint, expected: ${expectedChainId}, got: ${ethChainId}` + ) + } + + const mintHash = last(transfer.mintHashes) + let mintReceipt: ethers.providers.TransactionReceipt = await provider.getTransactionReceipt(mintHash) + + // If no receipt, check that the transaction hasn't been replaced (speedup or canceled) + if (!mintReceipt) { + // don't break old transfers in case they were made before this functionality is released + if (!transfer.ethCache) return transfer + try { + const tx = { + nonce: transfer.ethCache.nonce, + from: transfer.ethCache.from, + to: transfer.ethCache.to, + data: transfer.ethCache.data + } + const foundTx = await findReplacementTx(provider, transfer.ethCache.safeReorgHeight, tx) + if (!foundTx) return transfer + mintReceipt = await provider.getTransactionReceipt(foundTx.hash) + } catch (error) { + console.error(error) + if (error instanceof TxValidationError) { + return { + ...transfer, + errors: [...transfer.errors, error.message], + status: status.FAILED + } + } + throw error + } + } + + if (!mintReceipt) return transfer + + if (!mintReceipt.status) { + const error = `Transaction failed: ${mintReceipt.transactionHash}` + return { + ...transfer, + status: status.FAILED, + errors: [...transfer.errors, error], + mintReceipts: [...transfer.mintReceipts, mintReceipt] + } + } + + if (mintReceipt.transactionHash !== mintHash) { + // Record the replacement tx mintHash + transfer = { + ...transfer, + mintHashes: [...transfer.mintHashes, mintReceipt.transactionHash] + } + } + + const block = await provider.getBlock(mintReceipt.blockNumber) + + return { + ...transfer, + status: status.COMPLETE, + completedStep: LOCK, + finishTime: new Date(block.timestamp * 1000).toISOString(), + mintReceipts: [...transfer.mintReceipts, mintReceipt] + } +} + +const last = (arr: any[]): any => arr[arr.length - 1] diff --git a/packages/utils/src/findProof.ts b/packages/utils/src/findProof.ts index a780e424..e4be8e80 100644 --- a/packages/utils/src/findProof.ts +++ b/packages/utils/src/findProof.ts @@ -367,3 +367,64 @@ export async function parseNep141BurnReceipt ( const blockTimestamp = Number(receiptBlock.header.timestamp) return { id: bridgeReceipt.id, blockHeight, blockTimestamp, event } } + +/** + * Parse the lock receipt id and block height needed to build a proof. + * @param lockTx + * @param nep141LockerAccount + * @param nearProvider + */ +export async function parseNep141LockReceipt ( + lockTx: FinalExecutionOutcome, + nep141LockerAccount: string, + nearProvider: najProviders.Provider +): Promise<{id: string, blockHeight: number, blockTimestamp: number, event: { amount: string, token: string, recipient: string }}> { + let event: any + let bridgeReceipt: any + lockTx.receipts_outcome.some((receipt) => { + // @ts-expect-error + if (receipt.outcome.executor_id !== nep141LockerAccount) return false + try { + // @ts-expect-error + const successValue = receipt.outcome.status.SuccessValue + // eslint-disable-next-line @typescript-eslint/no-extraneous-class + class LockEvent { + constructor (args: any) { + Object.assign(this, args) + } + } + const SCHEMA = new Map([ + [LockEvent, { + kind: 'struct', + fields: [ + ['prefix', [32]], + ['token', 'String'], + ['amount', 'u128'], + ['recipient', [20]] + ] + }] + ]) + // const prefix = ethers.utils.keccak256('ResultType.Withdraw') + const rawEvent = deserializeBorsh( + SCHEMA, LockEvent, Buffer.from(successValue, 'base64') + ) as { prefix: Uint8Array, amount: BN, token: string, recipient: Uint8Array} + event = { + amount: rawEvent.amount.toString(), + token: rawEvent.token, + recipient: '0x' + Buffer.from(rawEvent.recipient).toString('hex') + } + bridgeReceipt = receipt + return true + } catch (error) { + console.log(error) + } + return false + }) + if (!bridgeReceipt || !event) { + throw new Error(`Failed to parse bridge receipt for ${JSON.stringify(nep141LockerAccount)}`) + } + const receiptBlock = await nearProvider.block({ blockId: bridgeReceipt.block_hash }) + const blockHeight = Number(receiptBlock.header.height) + const blockTimestamp = Number(receiptBlock.header.timestamp) + return { id: bridgeReceipt.id, blockHeight, blockTimestamp, event } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 9d161c3c..0839d347 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -11,6 +11,7 @@ export { findNearProof, parseETHBurnReceipt, parseNEARLockReceipt, - parseNep141BurnReceipt + parseNep141BurnReceipt, + parseNep141LockReceipt } from './findProof' export { buildIndexerTxQuery } from './indexer' diff --git a/packages/utils/src/nep141.ts b/packages/utils/src/nep141.ts index 245d5196..8cadba26 100644 --- a/packages/utils/src/nep141.ts +++ b/packages/utils/src/nep141.ts @@ -33,3 +33,52 @@ export async function getBalance ( }) return JSON.parse(Buffer.from(result.result).toString()) } + +export async function getMinStorageBalance ( + { nep141Address, nearProvider }: { + nep141Address: string + nearProvider: najProviders.Provider + } +): Promise { + try { + const result = await nearProvider.query({ + request_type: 'call_function', + account_id: nep141Address, + method_name: 'storage_balance_bounds', + args_base64: '', + finality: 'optimistic' + }) + return JSON.parse(Buffer.from(result.result).toString()).min + } catch (e) { + const result = await nearProvider.query({ + request_type: 'call_function', + account_id: nep141Address, + method_name: 'storage_minimum_balance', + args_base64: '', + finality: 'optimistic' + }) + return JSON.parse(Buffer.from(result.result).toString()) + } +} + +export async function getStorageBalance ( + { nep141Address, accountId, nearProvider }: { + nep141Address: string + accountId: string + nearProvider: najProviders.Provider + } +): Promise { + try { + const result = await nearProvider.query({ + request_type: 'call_function', + account_id: nep141Address, + method_name: 'storage_balance_of', + args_base64: Buffer.from(JSON.stringify({ account_id: accountId })).toString('base64'), + finality: 'optimistic' + }) + return JSON.parse(Buffer.from(result.result).toString()) + } catch (e) { + console.warn(e, nep141Address) + return null + } +}