tovarish-relayer/src/services/router.ts

446 lines
14 KiB
TypeScript

import path from 'path';
import { createReadStream } from 'fs';
import type { Logger } from 'winston';
import { fastify, FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fastifyCors } from '@fastify/cors';
import {
NetIdType,
getConfig,
DepositsEvents,
WithdrawalsEvents,
EchoEvents,
EncryptedNotesEvents,
AllGovernanceEvents,
TornadoWithdrawParams,
RelayerTornadoWithdraw,
getActiveTokenInstances,
TovarishEventsStatus,
MAX_TOVARISH_EVENTS,
TovarishStatus,
TovarishEventsQuery,
BaseTovarishEvents,
AllRelayerRegistryEvents,
StakeBurnedEvents,
} from 'tornado-scripts';
import { isAddress, BigNumberish } from 'ethers';
import { RelayerConfig, version } from '../config';
import { getLogger } from './logger';
import { resolveMessages, sendMessage, SentMsg } from './routerMsg';
import { SyncManagerStatus } from './sync';
import { existsAsync, loadSavedEvents } from './data';
import { RelayerTornadoQueue } from './worker';
import {
getTreeNameKeyword,
getAllEventsKeyword,
getAllWithdrawKeyword,
getEventsSchema,
getWithdrawSchema,
idParamsSchema,
treeNameSchema,
} from './schema';
import { ErrorMessages } from './error';
export function getHealthStatus(netId: NetIdType, syncManagerStatus: SyncManagerStatus) {
const { events, tokenPrice, gasPrice } = syncManagerStatus.syncStatus[netId];
return String(Boolean(events && tokenPrice && gasPrice));
}
export function getGasPrices(netId: NetIdType, syncManagerStatus: SyncManagerStatus) {
const { gasPrice, l1Fee } = syncManagerStatus.cachedGasPrices[netId];
return {
fast: Number(gasPrice),
additionalProperties: l1Fee ? Number(l1Fee) : undefined,
};
}
export function formatStatus({
netId,
relayerConfig,
syncManagerStatus,
pendingWorks,
}: {
netId: NetIdType;
relayerConfig: RelayerConfig;
syncManagerStatus: SyncManagerStatus;
pendingWorks: number;
}): Omit<TovarishStatus, 'url'> {
const config = getConfig(netId);
return {
rewardAccount: relayerConfig.rewardAccount,
instances: getActiveTokenInstances(config),
events: syncManagerStatus.cachedEvents[netId],
gasPrices: getGasPrices(netId, syncManagerStatus),
netId,
ethPrices: syncManagerStatus.cachedPrices[netId],
tornadoServiceFee: relayerConfig.serviceFee,
latestBlock: syncManagerStatus.latestBlocks[netId],
latestBalance: syncManagerStatus.latestBalances[netId],
version,
health: {
status: getHealthStatus(netId, syncManagerStatus),
error: '',
errorsLog: [...syncManagerStatus.errors.filter((e) => e.netId === netId)],
},
syncStatus: syncManagerStatus.syncStatus[netId],
onSyncEvents: syncManagerStatus.onSyncEvents,
currentQueue: pendingWorks,
};
}
export function handleIndex(enabledNetworks: NetIdType[]) {
return (
'This is <a href=https://tornado.ws>Tornado Cash</a> Relayer service. Check the ' +
enabledNetworks.map((netId) => `<a href=/${netId}/v1/status>/${netId}/v1/status</a> `).join(', ') +
'for settings'
);
}
export async function handleStatus(router: Router, netId: NetIdType | NetIdType[], reply: FastifyReply) {
const { relayerConfig } = router;
const { syncManagerStatus, pendingWorks } = await sendMessage<{
syncManagerStatus: SyncManagerStatus;
pendingWorks: number;
}>(router, { type: 'status' });
if (Array.isArray(netId)) {
reply.send(
netId.map((n) =>
formatStatus({
netId: n,
relayerConfig,
syncManagerStatus,
pendingWorks,
}),
),
);
return;
}
reply.send(
formatStatus({
netId,
relayerConfig,
syncManagerStatus,
pendingWorks,
}),
);
}
/**
* Since we check gasLimit and fees, should extend timeout at any proxy more than 60s
*/
export async function handleTornadoWithdraw(
router: Router,
netId: NetIdType,
req: FastifyRequest,
reply: FastifyReply,
) {
const { contract, proof, args } = req.body as unknown as TornadoWithdrawParams;
const { id, error } = await sendMessage<RelayerTornadoWithdraw>(router, {
type: 'tornadoWithdraw',
netId,
contract,
proof,
args,
});
if (error) {
reply.send({ error });
return;
}
reply.send({ id });
}
export async function handleGetJob(router: Router, req: FastifyRequest, reply: FastifyReply) {
const { id } = req.params as unknown as { id: string };
const job = await sendMessage<{ error: string } | RelayerTornadoQueue>(router, { type: 'job', id });
if (job.error) {
reply.send(job);
return;
}
reply.send(job as RelayerTornadoQueue);
}
export type AllTovarishEvents =
| DepositsEvents
| WithdrawalsEvents
| EchoEvents
| EncryptedNotesEvents
| AllGovernanceEvents
| AllRelayerRegistryEvents
| StakeBurnedEvents;
export async function handleEvents(router: Router, netId: NetIdType, req: FastifyRequest, reply: FastifyReply) {
const {
relayerConfig: { userEventsDir: userDirectory },
} = router;
const { type, currency, amount, fromBlock, recent } = req.body as unknown as TovarishEventsQuery;
const name = ['deposit', 'withdrawal'].includes(type)
? `${type}s_${netId}_${currency}_${amount}`
: `${type}_${netId}`;
// Return 0 length events if not exist (likely 0 events, can be checked by lastSyncBlock === fromBlock)
if (!(await existsAsync(path.join(userDirectory, `${name}.json`)))) {
reply.send({
events: [],
lastSyncBlock: fromBlock,
} as BaseTovarishEvents<AllTovarishEvents>);
return;
}
const { syncManagerStatus } = await sendMessage<{
syncManagerStatus: SyncManagerStatus;
}>(router, { type: 'status' });
const lastSyncBlock = Number(
['deposit', 'withdrawal'].includes(type)
? syncManagerStatus.cachedEvents[netId]?.tornado?.lastBlock
: syncManagerStatus.cachedEvents[netId]?.[String(type) as keyof TovarishEventsStatus]?.lastBlock,
);
if (type === 'deposit' && recent) {
const { events } = await loadSavedEvents<AllTovarishEvents>({
name: 'recent_' + name,
userDirectory,
});
reply.send({
events,
lastSyncBlock,
} as BaseTovarishEvents<AllTovarishEvents>);
return;
}
const { events } = await loadSavedEvents<AllTovarishEvents>({
name,
userDirectory,
});
if (recent) {
reply.send({
events: events.slice(-10).reverse(),
lastSyncBlock,
} as BaseTovarishEvents<AllTovarishEvents>);
return;
}
reply.send({
events: events.filter((e) => e.blockNumber >= (fromBlock || 0)).slice(0, MAX_TOVARISH_EVENTS),
lastSyncBlock,
} as BaseTovarishEvents<AllTovarishEvents>);
}
export async function handleTrees(router: Router, req: FastifyRequest, reply: FastifyReply) {
const treeRegex = /deposits_(?<netId>\d+)_(?<currency>\w+)_(?<amount>[\d.]+)_(?<part>\w+).json.zip/g;
const { netId, currency, amount, part } =
treeRegex.exec((req.params as unknown as { treeName: string }).treeName)?.groups || {};
const treeName = `deposits_${netId}_${currency}_${amount}_${part}.json.zip`;
const treePath = path.join(router.relayerConfig.userTreeDir, treeName);
if (!(await existsAsync(treePath))) {
reply.status(404).send(`Tree ${treeName} not found!`);
return;
}
reply.send(createReadStream(treePath));
}
export function listenRouter(router: Router) {
const { relayerConfig, logger, app, admin, forkId } = router;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app.register(fastifyCors, () => (req: FastifyRequest, callback: any) => {
callback(null, {
origin: req.headers.origin || '*',
credentials: true,
methods: ['GET, POST, OPTIONS'],
headers: [
'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type',
],
maxAge: 1728000,
});
});
app.get('/', (_, reply) => {
reply.type('text/html').send(handleIndex(relayerConfig.enabledNetworks));
});
app.get('/relayer', (_, reply) => {
reply.type('text/html').send(handleIndex(relayerConfig.enabledNetworks));
});
app.get('/status', (req, reply) => {
handleStatus(router, relayerConfig.enabledNetworks, reply);
});
app.get('/enabledNetworks', (_, reply) => {
reply.send(relayerConfig.enabledNetworks);
});
if (forkId === 0) {
logger.info('Router listening on /, /status, /enabledNetworks');
}
for (const netId of relayerConfig.enabledNetworks) {
app.get(`/${netId}`, (_, reply) => {
reply.type('text/html').send(handleIndex([netId]));
});
app.get(`/${netId}/status`, (req, reply) => {
handleStatus(router, netId, reply);
});
const withdrawSchema = getWithdrawSchema(netId);
app.post(`/${netId}/relay`, { schema: withdrawSchema }, (req, reply) => {
handleTornadoWithdraw(router, netId, req, reply);
});
app.get(`/${netId}/v1/status`, (req, reply) => {
handleStatus(router, netId, reply);
});
app.post(`/${netId}/v1/tornadoWithdraw`, { schema: withdrawSchema }, (req, reply) => {
handleTornadoWithdraw(router, netId, req, reply);
});
app.get(`/${netId}/v1/jobs/:id`, { schema: idParamsSchema }, (req, reply) => {
handleGetJob(router, req, reply);
});
const eventSchema = getEventsSchema(netId);
app.post(`/${netId}/events`, { schema: eventSchema }, (req, reply) => {
handleEvents(router, netId, req, reply);
});
if (relayerConfig.enableTrees) {
app.get(`/${netId}/trees/:treeName`, { schema: treeNameSchema }, (req, reply) => {
handleTrees(router, req, reply);
});
}
if (forkId === 0) {
logger.info(
`Router listening on /${netId}, /${netId}/status, /${netId}/relay, /${netId}/v1/status, /${netId}/v1/tornadoWithdraw, /${netId}/v1/jobs/:id, /${netId}/events, /${netId}/trees/:treeName`,
);
}
}
const { port, host } = relayerConfig;
app.listen({ port, host }, (err, address) => {
if (err) {
logger.error('Router Error');
console.log(err);
throw err;
} else {
logger.debug(`Router listening on ${address}`);
}
});
admin.get('/errors', (_, reply) => {
(async () => {
const { errors } = await sendMessage<{
errors: ErrorMessages[];
}>(router, { type: 'errors' });
reply.header('Content-Type', 'application/json').send(JSON.stringify(errors, null, 2));
})();
});
admin.listen({ port: port + 100, host }, (err, address) => {
if (err) {
logger.error('Admin Router Error');
console.log(err);
throw err;
} else {
if (forkId === 0) {
logger.debug(`Admin Router listening on ${address}`);
}
}
});
resolveMessages(router);
}
export class Router {
relayerConfig: RelayerConfig;
logger: Logger;
forkId: number;
app: FastifyInstance;
// For viewing error logs
admin: FastifyInstance;
messages: SentMsg[];
constructor(relayerConfig: RelayerConfig, forkId = 0) {
this.relayerConfig = relayerConfig;
this.logger = getLogger(`[Router ${forkId}]`, relayerConfig.logLevel);
this.forkId = forkId;
const app = fastify({
ajv: {
customOptions: {
keywords: [
{
keyword: 'isAddress',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
validate: (schema: any, data: string) => {
try {
return isAddress(data);
} catch {
return false;
}
},
errors: true,
},
{
keyword: 'BN',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
validate: (schema: any, data: BigNumberish) => {
try {
BigInt(data);
return true;
} catch {
return false;
}
},
errors: true,
},
getTreeNameKeyword(),
...getAllWithdrawKeyword(relayerConfig.rewardAccount),
...getAllEventsKeyword(),
],
},
},
trustProxy: relayerConfig.reverseProxy ? 1 : false,
ignoreTrailingSlash: true,
});
const admin = fastify();
this.app = app;
this.admin = admin;
this.messages = [];
listenRouter(this);
}
}