classic-ui/store/relayer.js
2022-05-31 00:36:41 +10:00

690 lines
19 KiB
JavaScript

/* eslint-disable no-console */
import Web3 from 'web3'
import BN from 'bignumber.js'
import namehash from 'eth-ens-namehash'
import { schema, relayerRegisterService } from '@/services'
import { createChainIdState, parseNote, parseSemanticVersion } from '@/utils'
import ENSABI from '@/abis/ENS.abi.json'
import networkConfig from '@/networkConfig'
const getAxios = () => {
return import('axios')
}
const calculateScore = ({ stakeBalance, tornadoServiceFee }, minFee = 0.33, maxFee = 0.53) => {
if (tornadoServiceFee < minFee) {
tornadoServiceFee = minFee
} else if (tornadoServiceFee >= maxFee) {
return new BN(0)
}
const serviceFeeCoefficient = (tornadoServiceFee - minFee) ** 2
const feeDiffCoefficient = 1 / (maxFee - minFee) ** 2
const coefficientsMultiplier = 1 - feeDiffCoefficient * serviceFeeCoefficient
return new BN(stakeBalance).multipliedBy(coefficientsMultiplier)
}
const getWeightRandom = (weightsScores, random) => {
for (let i = 0; i < weightsScores.length; i++) {
if (random.isLessThan(weightsScores[i])) {
return i
}
random = random.minus(weightsScores[i])
}
return Math.floor(Math.random() * weightsScores.length)
}
const pickWeightedRandomRelayer = (items, netId) => {
let minFee, maxFee
if (netId !== 1) {
minFee = 0.01
maxFee = 0.3
}
const weightsScores = items.map((el) => calculateScore(el, minFee, maxFee))
const totalWeight = weightsScores.reduce((acc, curr) => {
return (acc = acc.plus(curr))
}, new BN('0'))
const random = totalWeight.multipliedBy(Math.random())
const weightRandomIndex = getWeightRandom(weightsScores, random)
return items[weightRandomIndex]
}
const initialJobsState = createChainIdState({
tornado: {}
})
export const state = () => {
return {
prices: {
dai: '6700000000000000'
},
selectedRelayer: {
url: '',
name: '',
stakeBalance: 0,
tornadoServiceFee: 0.05,
miningServiceFee: 0.05,
address: null,
ethPrices: {
torn: '1'
}
},
isLoadingRelayers: false,
validRelayers: [],
jobs: initialJobsState,
jobWatchers: {}
}
}
export const getters = {
ethProvider: (state, getters, rootState) => {
const { url } = rootState.settings.netId1.rpc
return new Web3(url)
},
jobs: (state, getters, rootState, rootGetters) => (type) => {
const netId = rootGetters['metamask/netId']
const jobsToRender = Object.entries(state.jobs[`netId${netId}`][type])
.reverse()
.map(
([
id,
{
action,
relayerUrl,
amount,
currency,
fee,
timestamp,
txHash,
confirmations,
status,
failedReason
}
]) => {
return {
id,
action,
relayerUrl,
amount,
currency,
fee,
timestamp,
txHash,
confirmations,
status,
failedReason
}
}
)
return jobsToRender
}
}
export const mutations = {
SET_SELECTED_RELAYER(state, payload) {
this._vm.$set(state, 'selectedRelayer', payload)
},
SAVE_VALIDATED_RELAYERS(state, relayers) {
state.validRelayers = relayers
},
SAVE_JOB(
state,
{
id,
netId,
type,
action,
relayerUrl,
amount,
currency,
fee,
commitmentHex,
timestamp,
note,
accountAfter,
account
}
) {
this._vm.$set(state.jobs[`netId${netId}`][type], id, {
action,
relayerUrl,
amount,
currency,
fee,
commitmentHex,
timestamp,
note,
accountAfter,
account
})
},
UPDATE_JOB(state, { id, netId, type, txHash, confirmations, status, failedReason }) {
const job = state.jobs[`netId${netId}`][type][id]
this._vm.$set(state.jobs[`netId${netId}`][type], id, {
...job,
txHash,
confirmations,
status,
failedReason
})
},
DELETE_JOB(state, { id, netId, type }) {
this._vm.$delete(state.jobs[`netId${netId}`][type], id)
},
ADD_JOB_WATCHER(state, { id, timerId }) {
this._vm.$set(state.jobWatchers, id, {
timerId
})
},
DELETE_JOB_WATCHER(state, { id }) {
this._vm.$delete(state.jobWatchers, id)
},
SET_IS_LOADING_RELAYERS(state, isLoadingRelayers) {
state.isLoadingRelayers = isLoadingRelayers
}
}
export const actions = {
async askRelayerStatus(
{ rootState, dispatch, rootGetters },
{ hostname, relayerAddress, stakeBalance, ensName }
) {
try {
const axios = await getAxios()
if (!hostname.endsWith('/')) {
hostname += '/'
}
const url = `${window.location.protocol}//${hostname}`
const response = await axios.get(`${url}status`, { timeout: 5000 }).catch(() => {
throw new Error(this.app.i18n.t('canNotFetchStatusFromTheRelayer'))
})
if (Number(response.data.currentQueue) > 5) {
throw new Error(this.app.i18n.t('withdrawalQueueIsOverloaded'))
}
const netId = Number(rootGetters['metamask/netId'])
if (Number(response.data.netId) !== netId) {
throw new Error(this.app.i18n.t('thisRelayerServesADifferentNetwork'))
}
const validate = schema.getRelayerValidateFunction(netId)
// check rewardAccount === relayerAddress for TORN burn, custom relayer - exception
if (netId === 1 && relayerAddress && response.data.rewardAccount !== relayerAddress) {
throw new Error('The Relayer reward address must match registered address')
}
const isValid = validate(response.data)
if (!isValid) {
console.error('askRelayerStatus', ensName, validate?.errors)
throw new Error(this.app.i18n.t('canNotFetchStatusFromTheRelayer'))
}
const hasEnabledLightProxy = rootGetters['application/hasEnabledLightProxy']
const getIsUpdated = () => {
const requiredMajor = hasEnabledLightProxy ? '5' : '4'
const { major, patch, prerelease } = parseSemanticVersion(response.data.version)
const isUpdatedMajor = major === requiredMajor
if (isUpdatedMajor && prerelease) {
const minimalBeta = 11
const [betaVersion] = prerelease.split('.').slice(-1)
return Number(betaVersion) >= minimalBeta
}
const minimalPatch = 4
return isUpdatedMajor && Number(patch) >= minimalPatch
}
if (!getIsUpdated()) {
throw new Error('Outdated version.')
}
return {
isValid,
realUrl: url,
stakeBalance,
name: ensName,
relayerAddress,
netId: response.data.netId,
ethPrices: response.data.ethPrices,
address: response.data.rewardAccount,
currentQueue: response.data.currentQueue,
miningServiceFee: response.data.miningServiceFee,
tornadoServiceFee: response.data.tornadoServiceFee
}
} catch (e) {
console.error('askRelayerStatus', ensName, e.message)
return { isValid: false, error: e.message }
}
},
async observeRelayer({ dispatch }, { relayer }) {
const result = await dispatch('askRelayerStatus', relayer)
return result
},
async pickRandomRelayer({ rootGetters, commit, dispatch, getters }) {
const netId = rootGetters['metamask/netId']
const { ensSubdomainKey } = rootGetters['metamask/networkConfig']
commit('SET_IS_LOADING_RELAYERS', true)
const registeredRelayers = await relayerRegisterService(getters.ethProvider).getRelayers(ensSubdomainKey)
const requests = []
for (const registeredRelayer of registeredRelayers) {
requests.push(dispatch('observeRelayer', { relayer: registeredRelayer }))
}
let statuses = await Promise.all(requests)
statuses = statuses.filter((status) => status.isValid)
// const validRelayerENSnames = statuses.map((relayer) => relayer.name)
commit('SAVE_VALIDATED_RELAYERS', statuses)
console.log('filtered statuses ', statuses)
try {
const {
name,
realUrl,
address,
ethPrices,
stakeBalance,
tornadoServiceFee,
miningServiceFee
} = pickWeightedRandomRelayer(statuses, netId)
console.log('Selected relayer', name, tornadoServiceFee)
commit('SET_SELECTED_RELAYER', {
name,
address,
ethPrices,
url: realUrl,
stakeBalance,
tornadoServiceFee,
miningServiceFee
})
} catch {
console.error('Method pickRandomRelayer has not picked relayer')
}
commit('SET_IS_LOADING_RELAYERS', false)
},
async getKnownRelayerData({ rootGetters, getters }, { relayerAddress, name }) {
const { ensSubdomainKey } = rootGetters['metamask/networkConfig']
const [validRelayer] = await relayerRegisterService(getters.ethProvider).getValidRelayers(
[{ relayerAddress, ensName: name.replace(`${ensSubdomainKey}.`, '') }],
ensSubdomainKey
)
console.warn('validRelayer', validRelayer)
return validRelayer
},
async getCustomRelayerData({ rootState, state, getters, rootGetters, dispatch }, { url, name }) {
const provider = getters.ethProvider.eth
if (!url.startsWith('https:') && !url.startsWith('http:')) {
if (url.includes('.onion')) {
url = `http://${url}`
} else {
url = `https://${url}`
}
}
const urlParser = new URL(url)
urlParser.href = url
let ensName = name
if (urlParser.hostname.endsWith('.eth')) {
ensName = urlParser.hostname
let resolverInstance = await provider.ens.getResolver(ensName)
if (new BN(resolverInstance._address).isZero()) {
throw new Error('missingENSSubdomain')
}
resolverInstance = new provider.Contract(ENSABI, resolverInstance._address)
const ensNameHash = namehash.hash(ensName)
const hostname = await resolverInstance.methods.text(ensNameHash, 'url').call()
if (!hostname) {
throw new Error('canNotFetchStatusFromTheRelayer')
}
urlParser.host = hostname
}
const hostname = urlParser.host
return { hostname, ensName, stakeBalance: 0 }
},
async getRelayerData({ state, dispatch }, { url, name }) {
const knownRelayer = state.validRelayers.find((el) => el.name === name)
if (knownRelayer) {
const knownRelayerData = await dispatch('getKnownRelayerData', knownRelayer)
return knownRelayerData
}
const customRelayerData = await dispatch('getCustomRelayerData', { url, name })
return customRelayerData
},
async setupRelayer({ commit, rootState, dispatch }, { url, name }) {
try {
const relayerData = await dispatch('getRelayerData', { url, name })
const {
error,
isValid,
realUrl,
address,
ethPrices,
miningServiceFee,
tornadoServiceFee
} = await dispatch('askRelayerStatus', relayerData)
if (!isValid) {
return { error, isValid: false }
}
return {
isValid,
name,
url: realUrl || '',
address: address || '',
tornadoServiceFee: tornadoServiceFee || 0.0,
miningServiceFee: miningServiceFee || 0.0,
ethPrices: ethPrices || { torn: '1' }
}
} catch (err) {
return {
isValid: false,
error: this.app.i18n.t(err.message)
}
}
},
async relayTornadoWithdraw({ state, commit, dispatch, rootState }, { note }) {
const { currency, netId, amount, commitmentHex } = parseNote(note)
const config = networkConfig[`netId${netId}`]
const contract = config.tokens[currency].instanceAddress[amount]
try {
const { proof, args } = rootState.application.notes[note]
const message = {
args,
proof,
contract
}
dispatch(
'loading/changeText',
{ message: this.app.i18n.t('relayerIsNowSendingYourTransaction') },
{ root: true }
)
const response = await fetch(state.selectedRelayer.url + 'v1/tornadoWithdraw', {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json'
},
redirect: 'error',
body: JSON.stringify(message)
})
if (response.status === 400) {
const { error } = await response.json()
throw new Error(error)
}
if (response.status === 200) {
const { id } = await response.json()
const timestamp = Math.round(new Date().getTime() / 1000)
commit('SAVE_JOB', {
id,
netId,
type: 'tornado',
action: 'Deposit',
relayerUrl: state.selectedRelayer.url,
commitmentHex,
amount,
currency,
timestamp,
note
})
dispatch('runJobWatcherWithNotifications', { id, type: 'tornado', netId })
} else {
throw new Error(this.app.i18n.t('unknownError'))
}
} catch (e) {
console.error('relayTornadoWithdraw', e)
const { name, url } = state.selectedRelayer
throw new Error(this.app.i18n.t('relayRequestFailed', { relayerName: name === 'custom' ? url : name }))
}
},
async runJobWatcherWithNotifications({ dispatch, state }, { routerLink, id, netId, type }) {
const { amount, currency } = state.jobs[`netId${netId}`][type][id]
const noticeId = await dispatch(
'notice/addNotice',
{
notice: {
title: {
path: 'withdrawing',
amount,
currency
},
type: 'loading',
routerLink
}
},
{ root: true }
)
try {
await dispatch('runJobWatcher', { id, netId, type, noticeId })
dispatch('deleteJob', { id, netId, type })
} catch (err) {
dispatch(
'notice/updateNotice',
{
id: noticeId,
notice: {
title: 'transactionFailed',
type: 'danger',
routerLink: undefined
}
},
{ root: true }
)
dispatch(
'notice/addNoticeWithInterval',
{
notice: {
title: 'relayerError',
type: 'danger'
}
},
{ root: true }
)
}
},
deleteJob({ state, dispatch, commit }, { id, netId, type }) {
dispatch('stopFinishJobWatcher', { id })
const { amount, currency, action, fee, txHash, note } = state.jobs[`netId${netId}`][type][id]
commit('DELETE_JOB', { id, netId, type })
dispatch(
'txHashKeeper/updateDeposit',
{ amount, currency, netId, type, action, note, txHash, fee },
{ root: true }
)
},
runJobWatcher({ state, dispatch }, { id, netId, type, noticeId }) {
console.log('runJobWatcher started for job', id)
return new Promise((resolve, reject) => {
const getConfirmations = async ({ id, netId, type, noticeId, retryAttempt = 0, noticeCalls = 0 }) => {
try {
const job = state.jobs[`netId${netId}`][type][id]
if (job.status === 'FAILED') {
retryAttempt = 6
throw new Error('Relayer is not responding')
}
const response = await fetch(`${job.relayerUrl}v1/jobs/${id}`, {
method: 'GET',
mode: 'cors',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json'
},
redirect: 'error'
})
if (response.status === 400) {
const { error } = await response.json()
console.error('runJobWatcher', error)
throw new Error(this.app.i18n.t('relayerError'))
}
if (response.status === 200) {
await dispatch('handleResponse', {
id,
response,
job,
type,
netId,
retryAttempt,
noticeId,
noticeCalls,
resolve,
getConfirmations
})
} else {
throw new Error(this.app.i18n.t('unknownError'))
}
} catch (e) {
if (retryAttempt < 5) {
retryAttempt++
setTimeout(
() =>
getConfirmations({
id,
netId,
type,
noticeId,
retryAttempt,
noticeCalls
}),
3000
)
}
reject(e.message)
}
}
getConfirmations({ id, netId, type, noticeId })
dispatch('finishJobWatcher', { id, netId, type })
})
},
async handleResponse(
{ state, rootGetters, commit, dispatch, getters, rootState },
{ response, id, job, type, netId, retryAttempt, resolve, getConfirmations, noticeId, noticeCalls }
) {
const { amount, currency } = job
const { txHash, confirmations, status, failedReason } = await response.json()
console.log('txHash, confirmations, status, failedReason', txHash, confirmations, status, failedReason)
commit('UPDATE_JOB', { id, netId, type, txHash, confirmations, status, failedReason })
if (status === 'FAILED') {
dispatch('stopFinishJobWatcher', { id })
commit('DELETE_JOB', { id, netId, type })
retryAttempt = 6
console.error('runJobWatcher.handleResponse', failedReason)
throw new Error(this.app.i18n.t('relayerError'))
}
if (txHash && noticeCalls === 0 && (Number(confirmations) > 0 || status === 'CONFIRMED')) {
noticeCalls++
dispatch(
'notice/updateNotice',
{
id: noticeId,
notice: {
title: {
path: 'withdrawnValue',
amount,
currency
},
type: 'success',
txHash
},
interval: 10000
},
{ root: true }
)
}
if (status === 'CONFIRMED') {
console.log(`Job ${id} has enough confirmations`)
resolve(txHash)
} else {
setTimeout(() => getConfirmations({ id, netId, type, noticeId, retryAttempt, noticeCalls }), 3000)
}
},
finishJobWatcher({ state, rootGetters, commit, dispatch, getters, rootState }, { id, netId, type }) {
const timerId = setTimeout(() => {
const { txHash, confirmations } = state.jobs[`netId${netId}`][type][id]
commit('UPDATE_JOB', {
id,
netId,
type,
txHash,
confirmations,
status: 'FAILED',
failedReason: this.app.i18n.t('relayerIsNotResponding')
})
commit('DELETE_JOB_WATCHER', { id })
}, 15 * 60 * 1000)
commit('ADD_JOB_WATCHER', { id, timerId })
},
stopFinishJobWatcher({ state, rootGetters, commit, dispatch, getters, rootState }, { id }) {
console.log(`Stop finishJobWatcher ${id}`)
const { timerId } = state.jobWatchers[id]
clearTimeout(timerId)
commit('DELETE_JOB_WATCHER', { id })
},
runAllJobs({ state, commit, dispatch, rootState }) {
const netId = rootState.metamask.netId
const jobs = state.jobs[`netId${netId}`]
for (const type in jobs) {
for (const [id, { status }] of Object.entries(jobs[type])) {
const job = { id, netId, type }
if (status === 'FAILED') {
commit('DELETE_JOB', job)
} else {
dispatch('runJobWatcherWithNotifications', job)
}
}
}
}
}