forked from tornadocash/classic-ui
706 lines
20 KiB
JavaScript
706 lines
20 KiB
JavaScript
/* eslint-disable no-console */
|
|
import Web3 from 'web3'
|
|
import BN from 'bignumber.js'
|
|
import namehash from 'eth-ens-namehash'
|
|
|
|
import { httpConfig } from '@/constants'
|
|
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,
|
|
address: null,
|
|
ethPrices: {
|
|
torn: '1'
|
|
}
|
|
},
|
|
isLoadingRelayers: false,
|
|
validRelayers: [],
|
|
jobs: initialJobsState,
|
|
jobWatchers: {}
|
|
}
|
|
}
|
|
|
|
export const getters = {
|
|
ethProvider: (state, getters, rootState) => {
|
|
const { url } = rootState.settings.netId1.rpc
|
|
const httpProvider = new Web3.providers.HttpProvider(url, httpConfig)
|
|
|
|
return new Web3(httpProvider)
|
|
},
|
|
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 reqConfig = {
|
|
headers: {
|
|
'Content-Type': 'application/json, application/x-www-form-urlencoded'
|
|
},
|
|
timeout: 10000
|
|
}
|
|
const response = await axios.get(`${url}status`, reqConfig).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 relayerVersion = response.data.version
|
|
|
|
if (relayerVersion === '5.0.0') {
|
|
return true
|
|
}
|
|
|
|
const requiredMajor = hasEnabledLightProxy ? '5' : '4'
|
|
const { major, patch, prerelease } = parseSemanticVersion(relayerVersion)
|
|
|
|
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,
|
|
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
|
|
} = pickWeightedRandomRelayer(statuses, netId)
|
|
|
|
console.log('Selected relayer', name, tornadoServiceFee)
|
|
commit('SET_SELECTED_RELAYER', {
|
|
name,
|
|
address,
|
|
ethPrices,
|
|
url: realUrl,
|
|
stakeBalance,
|
|
tornadoServiceFee
|
|
})
|
|
} catch {
|
|
console.error('Method pickRandomRelayer has not picked relayer')
|
|
dispatch(
|
|
'notice/addNotice',
|
|
{
|
|
notice: {
|
|
untranslatedTitle: 'Failed to fetch relayers',
|
|
type: 'warning'
|
|
},
|
|
interval: 1500
|
|
},
|
|
{ root: true }
|
|
)
|
|
}
|
|
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
|
|
|
|
const PROTOCOL_REGEXP = /^(http(s?))/
|
|
if (!PROTOCOL_REGEXP.test(url)) {
|
|
if (url.endsWith('.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, tornadoServiceFee } = await dispatch(
|
|
'askRelayerStatus',
|
|
relayerData
|
|
)
|
|
|
|
if (!isValid) {
|
|
return { error, isValid: false }
|
|
}
|
|
|
|
return {
|
|
isValid,
|
|
name,
|
|
url: realUrl || '',
|
|
address: address || '',
|
|
tornadoServiceFee: tornadoServiceFee || 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|