refactor: spread across services & added docker

This commit is contained in:
nikdementev 2021-07-19 15:50:34 +03:00
parent 268dd097b7
commit 79af9c7ce0
No known key found for this signature in database
GPG Key ID: 769B05D57CF16FE2
32 changed files with 266 additions and 248 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
.env
.git

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
.env
# OS # OS
.DS_Store .DS_Store

View File

@ -1,4 +1,5 @@
{ {
"singleQuote": true, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"printWidth": 140
} }

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM node:12
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn && yarn cache clean --force
COPY . .
EXPOSE 8000
ENTRYPOINT ["yarn"]

63
docker-compose.yml Normal file
View File

@ -0,0 +1,63 @@
version: '2'
services:
server:
image: tornadocash/relayer
restart: always
command: start:prod
env_file: .env
environment:
REDIS_URL: redis://redis/0
nginx_proxy_read_timeout: 600
depends_on: [redis]
links:
- redis
redis:
image: redis
restart: always
command: [redis-server, --appendonly, 'yes']
volumes:
- redis:/data
nginx:
image: nginx:alpine
container_name: nginx
restart: always
ports:
- 80:80
- 443:443
volumes:
- conf:/etc/nginx/conf.d
- vhost:/etc/nginx/vhost.d
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs
logging:
driver: none
dockergen:
image: poma/docker-gen
container_name: dockergen
restart: always
command: -notify-sighup nginx -watch /etc/docker-gen/templates/nginx.tmpl /etc/nginx/conf.d/default.conf
volumes_from:
- nginx
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
letsencrypt:
image: jrcs/letsencrypt-nginx-proxy-companion
container_name: letsencrypt
restart: always
environment:
NGINX_DOCKER_GEN_CONTAINER: dockergen
volumes_from:
- nginx
- dockergen
volumes:
conf:
vhost:
html:
certs:
redis:

View File

@ -1,3 +1,17 @@
RPC_URL= # DNS settings
PRIVATE_KEY= VIRTUAL_HOST=
LETSENCRYPT_HOST=
# server settings
PORT=8000
#commision for service
SERVICE_FEE=0.05 SERVICE_FEE=0.05
# bull settings
REDIS_URL=redis://127.0.0.1:6379
# tx-manager settings
RPC_URL=
CHAIN_ID=1
PRIVATE_KEY=

View File

@ -13,7 +13,7 @@
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "yarn prebuild; yarn build; node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",

View File

@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { baseConfig } from '@/config'; import { baseConfig } from '@/config';
import { QueueModule, StatusModule } from '@/modules'; import { QueueModule, ApiModule } from '@/modules';
@Module({ @Module({
imports: [ imports: [
@ -10,8 +10,8 @@ import { QueueModule, StatusModule } from '@/modules';
load: [baseConfig], load: [baseConfig],
isGlobal: true, isGlobal: true,
}), }),
ApiModule,
QueueModule, QueueModule,
StatusModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,6 +1,7 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
export default registerAs('bull', () => ({ export default registerAs('bull', () => ({
name: 'withdrawal',
redis: { redis: {
host: 'localhost', host: 'localhost',
port: 6379, port: 6379,

View File

@ -1,5 +1,6 @@
export const baseConfig = () => ({ export const baseConfig = () => ({
gasLimit: 600000,
serviceFee: process.env.SERVICE_FEE,
chainId: process.env.CHAIN_ID,
port: parseInt(process.env.PORT, 10) || 8080, port: parseInt(process.env.PORT, 10) || 8080,
gasLimit: 400000,
fee: process.env.SERVICE_FEE,
}); });

View File

@ -3,7 +3,7 @@ import { ChainId } from '@/types';
export const CONTRACT_NETWORKS: { [chainId in ChainId]: string } = { export const CONTRACT_NETWORKS: { [chainId in ChainId]: string } = {
[ChainId.MAINNET]: '0x8Bfac9EF3d73cE08C7CEC339C0fE3B2e57814c1E', [ChainId.MAINNET]: '0x8Bfac9EF3d73cE08C7CEC339C0fE3B2e57814c1E',
[ChainId.GOERLI]: '0x20a2D506cf52453D681F9E8E814A3437c6242B9e', [ChainId.GOERLI]: '0x20a2D506cf52453D681F9E8E814A3437c6242B9e',
[ChainId.OPTIMISM]: '0xc436071dE853A4421c57ddD0CDDC116C735aa8b5', [ChainId.OPTIMISM]: '0x1Ed4dcDB4b78985008199f451E88C6448C4EDd94',
}; };
export const RPC_LIST: { [chainId in ChainId]: string } = { export const RPC_LIST: { [chainId in ChainId]: string } = {

View File

@ -1,10 +0,0 @@
import { ChainId } from '@/types';
import { CONTRACT_NETWORKS } from '@/constants';
import { getProviderWithSigner } from '@/services';
import { TornadoPool__factory as TornadoPoolFactory } from '@/artifacts';
export function getTornadoPool(chainId: ChainId) {
const provider = getProviderWithSigner(chainId);
return TornadoPoolFactory.connect(CONTRACT_NETWORKS[chainId], provider);
}

View File

@ -0,0 +1,31 @@
import { Controller, Body, Param, Get, Post } from '@nestjs/common';
import { Job } from 'bull';
import { ApiService } from './api.service';
@Controller()
export class ApiController {
constructor(private readonly service: ApiService) {}
@Get('/api')
async status(): Promise<Health> {
return await this.service.status();
}
@Get('/')
async main(): Promise<string> {
return this.service.main();
}
@Get('/job/:jobId')
async getJob(@Param('jobId') jobId: string): Promise<Job> {
return await this.service.getJob(jobId);
}
@Post('/withdrawal')
async withdrawal(_, @Body() { body }: any): Promise<string> {
console.log('body', body);
return await this.service.withdrawal(JSON.parse(body));
}
}

View File

@ -1,15 +1,15 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { StatusService } from './stat.service'; import { ApiService } from './api.service';
import { StatusController } from './stat.controller'; import { ApiController } from './api.controller';
import { QueueModule } from '@/modules'; import { QueueModule } from '@/modules';
@Module({ @Module({
imports: [ConfigModule, QueueModule], imports: [ConfigModule, QueueModule],
providers: [StatusService], providers: [ApiService],
controllers: [StatusController], controllers: [ApiController],
exports: [], exports: [],
}) })
export class StatusModule {} export class ApiModule {}

View File

@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Queue, Job } from 'bull';
import { Queue } from 'bull';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
class StatusService { class ApiService {
constructor(@InjectQueue('withdrawal') private withdrawalQueue: Queue) {} constructor(@InjectQueue('withdrawal') private withdrawalQueue: Queue) {}
async status(): Promise<Health> { async status(): Promise<Health> {
@ -17,11 +17,15 @@ class StatusService {
return `This is <a href=https://tornado.cash>tornado.cash</a> Relayer service. Check the <a href=/status>/status</a> for settings`; return `This is <a href=https://tornado.cash>tornado.cash</a> Relayer service. Check the <a href=/status>/status</a> for settings`;
} }
async withdrawal(data): Promise<string> { async withdrawal(data: any): Promise<string> {
const job = await this.withdrawalQueue.add(data) const job = await this.withdrawalQueue.add(data);
return String(job.id); return String(job.id);
} }
async getJob(id: string): Promise<Job> {
return await this.withdrawalQueue.getJob(id);
}
} }
export { StatusService }; export { ApiService };

View File

@ -1,4 +1,4 @@
export class CreateStatusDto { export class CreateApiDto {
error: boolean; error: boolean;
status: string; status: string;
} }

1
src/modules/api/index.ts Normal file
View File

@ -0,0 +1 @@
export { ApiModule } from './api.module';

View File

@ -1,2 +1,2 @@
export * from './queue'; export * from './queue';
export * from './status'; export * from './api';

View File

@ -10,7 +10,6 @@ import {
} from '@nestjs/bull'; } from '@nestjs/bull';
import { Injectable, OnModuleDestroy } from '@nestjs/common'; import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { Job, Queue } from 'bull'; import { Job, Queue } from 'bull';
import { v4 as uuid } from 'uuid';
@Injectable() @Injectable()
@Processor() @Processor()
@ -54,17 +53,11 @@ export class BaseProcessor<T = object> implements OnModuleDestroy {
return this.updateTask(job); return this.updateTask(job);
} }
async updateTask(job: Job<T>) { private async updateTask(job: Job<T>) {
const currentJob = await this.queue.getJob(job.id); const currentJob = await this.queue.getJob(job.id);
await currentJob.update(job.data); await currentJob.update(job.data);
} }
private async createTask({ request }) {
const id = uuid();
await this.queue.add({ ...request, id });
return id;
}
async onModuleDestroy() { async onModuleDestroy() {
if (this.queue) { if (this.queue) {
await this.queue.close(); await this.queue.close();

View File

@ -1,18 +1,15 @@
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { GasPriceService, ProviderService } from '@/services';
import { WithdrawalProcessor } from './withdrawal.processor'; import { WithdrawalProcessor } from './withdrawal.processor';
import bullConfig from '@/config/bull.config'; import bullConfig from '@/config/bull.config';
@Module({ @Module({
imports: [ imports: [BullModule.registerQueue(bullConfig())],
BullModule.registerQueue({ providers: [GasPriceService, ProviderService, WithdrawalProcessor],
...bullConfig(),
name: 'withdrawal',
}),
],
providers: [WithdrawalProcessor],
exports: [BullModule], exports: [BullModule],
}) })
export class QueueModule {} export class QueueModule {}

View File

@ -7,11 +7,11 @@ import { ConfigService } from '@nestjs/config';
import { InjectQueue, Process, Processor } from '@nestjs/bull'; import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { toWei } from '@/utilities'; import { toWei } from '@/utilities';
import { getGasPrice } from '@/services'; import { GasPriceService, ProviderService } from '@/services';
import { getTornadoPool } from '@/contracts';
import txMangerConfig from '@/config/txManager.config'; import txMangerConfig from '@/config/txManager.config';
import { BaseProcessor } from './base.processor'; import { BaseProcessor } from './base.processor';
import { ChainId } from '@/types';
export interface Withdrawal { export interface Withdrawal {
args: string[]; args: string[];
@ -27,6 +27,8 @@ export interface Withdrawal {
export class WithdrawalProcessor extends BaseProcessor<Withdrawal> { export class WithdrawalProcessor extends BaseProcessor<Withdrawal> {
constructor( constructor(
@InjectQueue('withdrawal') public withdrawalQueue: Queue, @InjectQueue('withdrawal') public withdrawalQueue: Queue,
private gasPriceService: GasPriceService,
private providerService: ProviderService,
private configService: ConfigService, private configService: ConfigService,
) { ) {
super(); super();
@ -49,12 +51,12 @@ export class WithdrawalProcessor extends BaseProcessor<Withdrawal> {
} }
async submitTx(job: Job<Withdrawal>) { async submitTx(job: Job<Withdrawal>) {
try {
const txManager = new TxManager(txMangerConfig()); const txManager = new TxManager(txMangerConfig());
const prepareTx = await this.prepareTransaction(job.data); const prepareTx = await this.prepareTransaction(job.data);
const tx = await txManager.createTx(prepareTx); const tx = await txManager.createTx(prepareTx);
try {
const receipt = await tx const receipt = await tx
.send() .send()
.on('transactionHash', async (txHash: string) => { .on('transactionHash', async (txHash: string) => {
@ -88,44 +90,52 @@ export class WithdrawalProcessor extends BaseProcessor<Withdrawal> {
} }
} }
async prepareTransaction({ proof, args, amount }) { async prepareTransaction({ proof, args }) {
const contract = getTornadoPool(5); const chainId = this.configService.get<number>('chainId');
const contract = this.providerService.getTornadoPool();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
const data = contract.interface.encodeFunctionData('transaction', [ const data = contract.interface.encodeFunctionData('transaction', [proof, ...args]);
proof,
...args,
]);
const gasLimit = this.configService.get<number>('gasLimit'); let gasLimit = this.configService.get<BigNumber>('gasLimit');
// need because optimism has dynamic gas limit
if (chainId === ChainId.OPTIMISM) {
// @ts-ignore
gasLimit = await contract.estimateGas.transaction(proof, ...args, {
value: BigNumber.from(0)._hex,
from: '0x1a5245ea5210C3B57B7Cfdf965990e63534A7b52',
gasPrice: toWei('0.015', 'gwei'),
});
}
const { fast } = await this.gasPriceService.getGasPrice();
return { return {
data, data,
gasLimit, gasLimit,
value: BigNumber.from(0)._hex,
to: contract.address, to: contract.address,
gasPrice: fast.toString(),
value: BigNumber.from(0)._hex,
}; };
} }
async checkFee({ fee, amount }) { async checkFee({ fee, amount }) {
const gasLimit = this.configService.get<number>('gasLimit'); const { gasLimit, serviceFee } = this.configService.get('');
const { fast } = await getGasPrice(5); const { fast } = await this.gasPriceService.getGasPrice();
const expense = BigNumber.from(toWei(fast.toString(), 'gwei')).mul( const expense = BigNumber.from(toWei(fast.toString(), 'gwei')).mul(gasLimit);
gasLimit,
);
const serviceFee = this.configService.get<number>('fee'); const feePercent = BigNumber.from(amount)
const feePercent = BigNumber.from(amount).mul(serviceFee * 1e10).div(100 * 1e10) .mul(serviceFee * 1e10)
.div(100 * 1e10);
const desiredFee = expense.add(feePercent); const desiredFee = expense.add(feePercent);
if (BigNumber.from(fee).lt(desiredFee)) { if (BigNumber.from(fee).lt(desiredFee)) {
throw new Error( throw new Error('Provided fee is not enough. Probably it is a Gas Price spike, try to resubmit.');
'Provided fee is not enough. Probably it is a Gas Price spike, try to resubmit.',
);
} }
} }
} }

View File

@ -1 +0,0 @@
export { StatusModule } from './stat.module';

View File

@ -1,25 +0,0 @@
import { Controller, Body, Get, Post } from '@nestjs/common';
import { StatusService } from './stat.service';
@Controller()
export class StatusController {
constructor(private readonly service: StatusService) {}
@Get('/status')
async status(): Promise<Health> {
return await this.service.status();
}
@Get('/')
async main(): Promise<string> {
return this.service.main();
}
@Post('/withdrawal')
async withdrawal(_, @Body() { body }: any): Promise<string> {
console.log('body', body)
return await this.service.withdrawal(JSON.parse(body))
}
}

View File

@ -1,26 +0,0 @@
import { ethers } from 'ethers';
import { ChainId } from '@/types';
import { RPC_LIST } from '@/constants';
interface Options {
url: string;
}
export class Provider {
public provider: ethers.providers.JsonRpcProvider;
constructor(options: Options) {
this.provider = new ethers.providers.JsonRpcProvider(options.url);
}
}
export function getProvider(chainId: ChainId): Provider {
return new Provider({ url: RPC_LIST[chainId] });
}
export function getProviderWithSigner(
chainId: ChainId,
): ethers.providers.BaseProvider {
return ethers.providers.getDefaultProvider(RPC_LIST[chainId]);
}

View File

@ -1,91 +0,0 @@
import { Wallet, PopulatedTransaction } from 'ethers';
import {
FlashbotsBundleProvider,
FlashbotsBundleResolution,
} from '@flashbots/ethers-provider-bundle';
import { ChainId } from '@/types';
import { numbers } from '@/constants';
import { getProviderWithSigner } from '@/services';
const authSigner = Wallet.createRandom();
const FLASH_BOT_RPC: { [key in ChainId]: { name: string; url: string } } = {
[ChainId.GOERLI]: {
url: 'https://relay-goerli.flashbots.net/',
name: 'goerli',
},
[ChainId.MAINNET]: {
url: '',
name: '',
},
};
async function sendFlashBotTransaction(
transaction: PopulatedTransaction,
chainId: ChainId,
) {
const provider = getProviderWithSigner(chainId);
const { url, name } = FLASH_BOT_RPC[chainId];
const flashBotsProvider = await FlashbotsBundleProvider.create(
provider,
authSigner,
url,
name,
);
const nonce = await provider.getTransactionCount(authSigner.address);
const mergedTx = {
...transaction,
nonce,
from: authSigner.address,
};
const signedTransaction = await authSigner.signTransaction(mergedTx);
const TIME_10_BLOCK = 130;
const blockNumber = await provider.getBlockNumber();
const minTimestamp = (await provider.getBlock(blockNumber)).timestamp;
const maxTimestamp = minTimestamp + TIME_10_BLOCK;
const targetBlockNumber = blockNumber + numbers.TWO;
const simulation = await flashBotsProvider.simulate(
[signedTransaction],
targetBlockNumber,
);
if ('error' in simulation) {
console.log(`Simulation Error: ${simulation.error.message}`);
} else {
console.log(
`Simulation Success: ${JSON.stringify(simulation, null, numbers.TWO)}`,
);
}
const bundleSubmission = await flashBotsProvider.sendBundle(
[{ signedTransaction }],
targetBlockNumber,
{
minTimestamp,
maxTimestamp,
},
);
if ('error' in bundleSubmission) {
throw new Error(bundleSubmission.error.message);
}
const waitResponse = await bundleSubmission.wait();
const bundleSubmissionSimulation = await bundleSubmission.simulate();
console.log({
bundleSubmissionSimulation,
waitResponse: FlashbotsBundleResolution[waitResponse],
});
}
export { sendFlashBotTransaction };

View File

@ -1,3 +1,2 @@
export * from './ether'; export * from './oracle.service';
export * from './oracle'; export * from './provider.service';
export * from './flashbot';

View File

@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GasPriceOracle } from 'gas-price-oracle';
import { GasPrice } from 'gas-price-oracle/lib/types';
import { ChainId } from '@/types';
import { toWei } from '@/utilities';
import { RPC_LIST, numbers } from '@/constants';
@Injectable()
export class GasPriceService {
private readonly chainId: number;
constructor(private configService: ConfigService) {
this.chainId = this.configService.get<number>('chainId');
}
async getGasPrice(): Promise<GasPrice> {
if (this.chainId === ChainId.OPTIMISM) {
return GasPriceService.getOptimismPrice();
}
const TIMER = 10;
const INTERVAL = TIMER * numbers.SECOND;
const instance = new GasPriceOracle({
timeout: INTERVAL,
defaultRpc: RPC_LIST[ChainId.MAINNET],
});
return await instance.gasPrices();
}
private static getOptimismPrice() {
const OPTIMISM_GAS = toWei('0.015', 'gwei').toNumber();
return {
fast: OPTIMISM_GAS,
low: OPTIMISM_GAS,
instant: OPTIMISM_GAS,
standard: OPTIMISM_GAS,
};
}
}

View File

@ -1,29 +0,0 @@
import { GasPriceOracle } from 'gas-price-oracle';
import { GasPrice } from 'gas-price-oracle/lib/types';
import { ChainId } from '@/types';
import { RPC_LIST, numbers } from '@/constants';
const SECONDS = 10;
const TEN_SECOND = SECONDS * numbers.SECOND;
const OPTIMISM_GAS_PRICE = {
fast: 0.015,
low: 0.015,
instant: 0.015,
standard: 0.015,
};
const getGasPrice = async (chainId: ChainId): Promise<GasPrice> => {
if (chainId === ChainId.OPTIMISM) {
return OPTIMISM_GAS_PRICE;
}
const instance = new GasPriceOracle({
timeout: TEN_SECOND,
defaultRpc: RPC_LIST[ChainId.MAINNET],
});
return await instance.gasPrices();
};
export { getGasPrice };

View File

@ -0,0 +1,27 @@
import { ethers } from 'ethers';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CONTRACT_NETWORKS, RPC_LIST } from '@/constants';
import { TornadoPool__factory as TornadoPoolFactory } from '@/artifacts';
@Injectable()
export class ProviderService {
private readonly chainId: number;
constructor(private configService: ConfigService) {
this.chainId = this.configService.get<number>('chainId');
}
getProvider() {
return new ethers.providers.JsonRpcProvider(RPC_LIST[this.chainId]);
}
getProviderWithSigner() {
return ethers.providers.getDefaultProvider(RPC_LIST[this.chainId]);
}
getTornadoPool() {
return TornadoPoolFactory.connect(CONTRACT_NETWORKS[this.chainId], this.getProviderWithSigner());
}
}