From 7a6b6c69e331d9fc11d87ee98aae6963e8176d04 Mon Sep 17 00:00:00 2001 From: michael1011 Date: Thu, 13 Feb 2025 13:05:49 +0100 Subject: [PATCH] feat: sequential EVM signer --- lib/db/Migration.ts | 57 +++++++++++- lib/db/models/PendingEthereumTransaction.ts | 12 +++ .../PendingEthereumTransactionRepository.ts | 12 +++ lib/wallet/ethereum/EthereumManager.ts | 6 +- lib/wallet/ethereum/InjectedProvider.ts | 17 ++-- lib/wallet/ethereum/SequentialSigner.ts | 86 +++++++++++++++++++ run-int.js | 8 ++ ...ndingEthereumTransactionRepository.spec.ts | 34 +++++++- .../wallet/ethereum/EthereumManager.spec.ts | 1 + .../wallet/ethereum/InjectedProvider.spec.ts | 9 +- .../wallet/ethereum/SequentialSigner.spec.ts | 85 ++++++++++++++++++ 11 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 lib/wallet/ethereum/SequentialSigner.ts create mode 100644 test/integration/wallet/ethereum/SequentialSigner.spec.ts diff --git a/lib/db/Migration.ts b/lib/db/Migration.ts index d7e877be5..9d399b99a 100644 --- a/lib/db/Migration.ts +++ b/lib/db/Migration.ts @@ -1,6 +1,7 @@ import { Transaction } from 'bitcoinjs-lib'; import bolt11, { RoutingInfo } from 'bolt11'; import { detectSwap } from 'boltz-core'; +import { Transaction as EthersTransaction } from 'ethers'; import { DataTypes, Op, QueryTypes, Sequelize } from 'sequelize'; import Logger from '../Logger'; import { @@ -14,18 +15,21 @@ import { } from '../Utils'; import { SwapType, SwapVersion, swapTypeToPrettyString } from '../consts/Enums'; import { Currency } from '../wallet/WalletManager'; +import { Rsk } from '../wallet/ethereum/EvmNetworks'; import ChainSwap from './models/ChainSwap'; import ChannelCreation from './models/ChannelCreation'; import DatabaseVersion from './models/DatabaseVersion'; import LightningPayment, { LightningPaymentStatus, } from './models/LightningPayment'; +import PendingEthereumTransaction from './models/PendingEthereumTransaction'; import PendingLockupTransaction from './models/PendingLockupTransaction'; import Referral, { ReferralConfig } from './models/Referral'; import ReverseSwap, { NodeType } from './models/ReverseSwap'; import Swap from './models/Swap'; import DatabaseVersionRepository from './repositories/DatabaseVersionRepository'; import LightningPaymentRepository from './repositories/LightningPaymentRepository'; +import PendingEthereumTransactionRepository from './repositories/PendingEthereumTransactionRepository'; const coalesceInvoiceAmount = ( decoded: bolt11.PaymentRequestObject, @@ -93,7 +97,7 @@ const decodeInvoice = ( // TODO: integration tests for actual migrations class Migration { - private static latestSchemaVersion = 15; + private static latestSchemaVersion = 16; private toBackFill: number[] = []; @@ -669,6 +673,57 @@ class Migration { break; } + case 15: { + await this.sequelize + .getQueryInterface() + .addColumn(PendingEthereumTransaction.tableName, 'etherAmount', { + type: new DataTypes.DECIMAL(), + allowNull: true, + }); + await this.sequelize + .getQueryInterface() + .addColumn(PendingEthereumTransaction.tableName, 'hex', { + type: new DataTypes.TEXT(), + allowNull: true, + }); + + const txs = + await PendingEthereumTransactionRepository.getTransactions(); + for (const tx of txs) { + const fetchedTx = await currencies + .get(Rsk.symbol) + ?.provider!.getTransaction(tx.hash); + + if (fetchedTx === undefined || fetchedTx === null) { + this.logger.warn( + `Could not fetch pending EVM transaction ${tx.hash}`, + ); + continue; + } + + await tx.update({ + etherAmount: fetchedTx.value, + hex: EthersTransaction.from(fetchedTx).serialized, + }); + } + + await this.sequelize + .getQueryInterface() + .changeColumn(PendingEthereumTransaction.tableName, 'etherAmount', { + type: new DataTypes.DECIMAL(), + allowNull: false, + }); + await this.sequelize + .getQueryInterface() + .changeColumn(PendingEthereumTransaction.tableName, 'hex', { + type: new DataTypes.TEXT(), + allowNull: false, + }); + + await this.finishMigration(versionRow.version, currencies); + break; + } + default: throw `found unexpected database version ${versionRow.version}`; } diff --git a/lib/db/models/PendingEthereumTransaction.ts b/lib/db/models/PendingEthereumTransaction.ts index 69c4e123c..02353ae8a 100644 --- a/lib/db/models/PendingEthereumTransaction.ts +++ b/lib/db/models/PendingEthereumTransaction.ts @@ -3,6 +3,8 @@ import { DataTypes, Model, Sequelize } from 'sequelize'; type PendingEthereumTransactionType = { hash: string; nonce: number; + etherAmount: bigint; + hex: string; }; class PendingEthereumTransaction @@ -11,6 +13,8 @@ class PendingEthereumTransaction { public hash!: string; public nonce!: number; + public etherAmount!: bigint; + public hex!: string; public static load = (sequelize: Sequelize): void => { PendingEthereumTransaction.init( @@ -25,6 +29,14 @@ class PendingEthereumTransaction unique: true, allowNull: false, }, + etherAmount: { + type: new DataTypes.DECIMAL(), + allowNull: false, + }, + hex: { + type: new DataTypes.TEXT(), + allowNull: false, + }, }, { sequelize, diff --git a/lib/db/repositories/PendingEthereumTransactionRepository.ts b/lib/db/repositories/PendingEthereumTransactionRepository.ts index df663321a..f5f79ea2a 100644 --- a/lib/db/repositories/PendingEthereumTransactionRepository.ts +++ b/lib/db/repositories/PendingEthereumTransactionRepository.ts @@ -17,13 +17,25 @@ class PendingEthereumTransactionRepository { return nonce + 1; }; + public static getTotalSent = async (): Promise => { + return BigInt( + (await PendingEthereumTransaction.sum( + 'etherAmount', + )) ?? 0, + ); + }; + public static addTransaction = ( hash: string, nonce: number, + etherAmount: bigint, + hex: string, ): Promise => { return PendingEthereumTransaction.create({ hash, nonce, + etherAmount, + hex, }); }; } diff --git a/lib/wallet/ethereum/EthereumManager.ts b/lib/wallet/ethereum/EthereumManager.ts index a2f6816eb..2fd87dec1 100644 --- a/lib/wallet/ethereum/EthereumManager.ts +++ b/lib/wallet/ethereum/EthereumManager.ts @@ -20,6 +20,7 @@ import ConsolidatedEventHandler from './ConsolidatedEventHandler'; import EthereumTransactionTracker from './EthereumTransactionTracker'; import { Ethereum, NetworkDetails, Rsk } from './EvmNetworks'; import InjectedProvider from './InjectedProvider'; +import SequentialSigner from './SequentialSigner'; import Contracts from './contracts/Contracts'; type Network = { @@ -81,7 +82,10 @@ class EthereumManager { name: this.config.networkName || network.name, }; - this.signer = EthersWallet.fromPhrase(mnemonic).connect(this.provider); + this.signer = new SequentialSigner( + this.networkDetails.symbol, + EthersWallet.fromPhrase(mnemonic), + ).connect(this.provider); this.address = await this.signer.getAddress(); this.logger.verbose( diff --git a/lib/wallet/ethereum/InjectedProvider.ts b/lib/wallet/ethereum/InjectedProvider.ts index 6eb4f7613..522d2775b 100644 --- a/lib/wallet/ethereum/InjectedProvider.ts +++ b/lib/wallet/ethereum/InjectedProvider.ts @@ -285,8 +285,7 @@ class InjectedProvider implements Provider { public broadcastTransaction = async ( signedTransaction: string, ): Promise => { - const transaction = Transaction.from(signedTransaction); - await this.addToTransactionDatabase(transaction.hash!, transaction.nonce); + await this.addToTransactionDatabase(Transaction.from(signedTransaction)); // When sending a transaction, you want it to propagate on the network as quickly as possible // Therefore, we send it to all available providers @@ -316,7 +315,8 @@ class InjectedProvider implements Provider { 'sendTransaction', tx, ); - await this.addToTransactionDatabase(res.hash, res.nonce); + + await this.addToTransactionDatabase(Transaction.from(res)); return res; }; @@ -521,11 +521,16 @@ class InjectedProvider implements Provider { }); }; - private addToTransactionDatabase = async (hash: string, nonce: number) => { + private addToTransactionDatabase = async (tx: Transaction) => { this.logger.silly( - `Sending ${this.networkDetails.name} transaction: ${hash}`, + `Sending ${this.networkDetails.name} transaction: ${tx.hash}`, + ); + await PendingEthereumTransactionRepository.addTransaction( + tx.hash!, + tx.nonce, + tx.value, + tx.serialized, ); - await PendingEthereumTransactionRepository.addTransaction(hash, nonce); }; private hashCode = (value: string): number => { diff --git a/lib/wallet/ethereum/SequentialSigner.ts b/lib/wallet/ethereum/SequentialSigner.ts new file mode 100644 index 000000000..1bd8a783c --- /dev/null +++ b/lib/wallet/ethereum/SequentialSigner.ts @@ -0,0 +1,86 @@ +import { SpanKind, SpanStatusCode, context, trace } from '@opentelemetry/api'; +import AsyncLock from 'async-lock'; +import { + AbstractSigner, + Provider, + Signer, + TransactionRequest, + TypedDataDomain, + TypedDataField, +} from 'ethers'; +import Tracing from '../../Tracing'; +import { formatError } from '../../Utils'; +import PendingEthereumTransactionRepository from '../../db/repositories/PendingEthereumTransactionRepository'; + +class SequentialSigner extends AbstractSigner { + private static readonly txLock = 'txLock'; + + private readonly lock = new AsyncLock(); + + constructor( + private readonly symbol: string, + private signer: AbstractSigner, + ) { + super(signer.provider); + } + + public getAddress = (): Promise => this.signer.getAddress(); + + public connect = (provider: null | Provider): Signer => { + return new SequentialSigner(this.symbol, this.signer.connect(provider)); + }; + + public signTransaction = async (tx: TransactionRequest): Promise => { + const span = Tracing.tracer.startSpan( + `Signing ${this.symbol} transaction`, + { + kind: SpanKind.INTERNAL, + attributes: { + nonce: tx.nonce?.toString(), + value: tx.value?.toString(), + }, + }, + ); + const ctx = trace.setSpan(context.active(), span); + + try { + return await context.with(ctx, this.signTransactionInternal, this, tx); + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: formatError(error), + }); + throw error; + } finally { + span.end(); + } + }; + + public signMessage = (message: string | Uint8Array): Promise => + this.signer.signMessage(message); + + public signTypedData = ( + domain: TypedDataDomain, + types: Record>, + value: Record, + ): Promise => this.signer.signTypedData(domain, types, value); + + private signTransactionInternal = async (tx: TransactionRequest) => { + return await this.lock.acquire(SequentialSigner.txLock, async () => { + if (tx.value !== undefined && tx.value !== null) { + const [ourBalance, pendingTxsValue] = await Promise.all([ + this.signer.provider!.getBalance(await this.getAddress()), + PendingEthereumTransactionRepository.getTotalSent(), + ]); + + if (ourBalance - pendingTxsValue < BigInt(tx.value)) { + throw new Error('insufficient balance'); + } + } + + return await this.signer.signTransaction(tx); + }); + }; +} + +export default SequentialSigner; diff --git a/run-int.js b/run-int.js index 3dd37d68d..769f48d69 100644 --- a/run-int.js +++ b/run-int.js @@ -12,6 +12,8 @@ const runTest = (file) => { }; let testsRun = 0; +let skipUntil = process.argv[2]; +let skip = skipUntil !== undefined; const execDir = (dir) => { const content = fs.readdirSync(dir); @@ -19,6 +21,12 @@ const execDir = (dir) => { for (const entry of content) { if (entry.includes('.')) { if (entry.endsWith('.spec.ts')) { + if (skip && !entry.includes(skipUntil)) { + console.log(`Skipping ${path.join(dir, entry)}`); + continue; + } + skip = false; + runTest(path.join(dir, entry)); testsRun++; } diff --git a/test/integration/db/repositories/PendingEthereumTransactionRepository.spec.ts b/test/integration/db/repositories/PendingEthereumTransactionRepository.spec.ts index a55c6f5a2..75a8f07ba 100644 --- a/test/integration/db/repositories/PendingEthereumTransactionRepository.spec.ts +++ b/test/integration/db/repositories/PendingEthereumTransactionRepository.spec.ts @@ -27,11 +27,43 @@ describe('PendingEthereumTransactionRepository', () => { }); test('should get highest nonce when there are pending transactions', async () => { - await PendingEthereumTransactionRepository.addTransaction('txHash', 20); + await PendingEthereumTransactionRepository.addTransaction( + 'txHash', + 20, + 1n, + '', + ); await expect( PendingEthereumTransactionRepository.getHighestNonce(), ).resolves.toEqual(21); }); }); + + describe('getTotalSent', () => { + test('should get total sent when there are no pending transactions', async () => { + await expect( + PendingEthereumTransactionRepository.getTotalSent(), + ).resolves.toEqual(BigInt(0)); + }); + + test('should get total sent when there are pending transactions', async () => { + await PendingEthereumTransactionRepository.addTransaction( + 'txHash', + 20, + 21n, + '', + ); + await PendingEthereumTransactionRepository.addTransaction( + 'txHash2', + 21, + 21n, + '', + ); + + await expect( + PendingEthereumTransactionRepository.getTotalSent(), + ).resolves.toEqual(BigInt(42)); + }); + }); }); diff --git a/test/integration/wallet/ethereum/EthereumManager.spec.ts b/test/integration/wallet/ethereum/EthereumManager.spec.ts index 10630a969..5ad485a01 100644 --- a/test/integration/wallet/ethereum/EthereumManager.spec.ts +++ b/test/integration/wallet/ethereum/EthereumManager.spec.ts @@ -18,6 +18,7 @@ import { jest.mock( '../../../../lib/db/repositories/PendingEthereumTransactionRepository', () => ({ + getTotalSent: jest.fn().mockResolvedValue(0n), getTransactions: jest.fn().mockResolvedValue([]), addTransaction: jest.fn().mockResolvedValue(null), getHighestNonce: jest.fn().mockResolvedValue(undefined), diff --git a/test/integration/wallet/ethereum/InjectedProvider.spec.ts b/test/integration/wallet/ethereum/InjectedProvider.spec.ts index b63d81204..446ffbfe0 100644 --- a/test/integration/wallet/ethereum/InjectedProvider.spec.ts +++ b/test/integration/wallet/ethereum/InjectedProvider.spec.ts @@ -1,3 +1,4 @@ +import { Transaction } from 'ethers'; import { RskConfig } from '../../../../lib/Config'; import Logger from '../../../../lib/Logger'; import PendingEthereumTransactionRepository from '../../../../lib/db/repositories/PendingEthereumTransactionRepository'; @@ -89,6 +90,7 @@ describe('InjectedProvider', () => { const tx = await signer.sendTransaction({ to: await signer.getAddress(), + value: 321, }); expect( @@ -96,6 +98,11 @@ describe('InjectedProvider', () => { ).toHaveBeenCalledTimes(1); expect( PendingEthereumTransactionRepository.addTransaction, - ).toHaveBeenCalledWith(tx.hash, tx.nonce); + ).toHaveBeenCalledWith( + tx.hash, + tx.nonce, + tx.value, + Transaction.from(tx).serialized, + ); }); }); diff --git a/test/integration/wallet/ethereum/SequentialSigner.spec.ts b/test/integration/wallet/ethereum/SequentialSigner.spec.ts new file mode 100644 index 000000000..7f5a6c600 --- /dev/null +++ b/test/integration/wallet/ethereum/SequentialSigner.spec.ts @@ -0,0 +1,85 @@ +import PendingEthereumTransactionRepository from '../../../../lib/db/repositories/PendingEthereumTransactionRepository'; +import SequentialSigner from '../../../../lib/wallet/ethereum/SequentialSigner'; +import { EthereumSetup, fundSignerWallet, getSigner } from '../EthereumTools'; + +describe('SequentialSigner', () => { + let setup: EthereumSetup; + let signer: SequentialSigner; + + beforeAll(async () => { + setup = await getSigner(); + signer = new SequentialSigner('ETH', setup.signer); + + await fundSignerWallet(setup.signer, setup.etherBase); + + PendingEthereumTransactionRepository.getTotalSent = jest + .fn() + .mockResolvedValue(0n); + }); + + test('should get address', async () => { + expect(await signer.getAddress()).toBe(await setup.signer.getAddress()); + }); + + test('should connect to provider', async () => { + expect(signer.provider).toBe(setup.signer.provider); + }); + + describe('signTransaction', () => { + test('should sign transactions', async () => { + const tx = await signer.sendTransaction({ + to: await setup.etherBase.getAddress(), + value: 1_000, + }); + + expect(tx.hash).toBeDefined(); + await tx.wait(1); + }); + + test('should throw when we do not have enough balance', async () => { + const balance = await signer.provider!.getBalance( + await signer.getAddress(), + ); + + PendingEthereumTransactionRepository.getTotalSent = jest + .fn() + .mockResolvedValue(balance / 2n); + + await expect( + signer.sendTransaction({ + to: await setup.etherBase.getAddress(), + value: balance / 2n + 1n, + }), + ).rejects.toThrow('insufficient balance'); + }); + }); + + test('should sign messages', async () => { + const message = 'Hello, world!'; + const signature = await signer.signMessage(message); + expect(signature).toEqual(await setup.signer.signMessage(message)); + }); + + test('should sign typed data', async () => { + const domain = { + name: 'EtherSwap', + version: '1', + chainId: 1, + }; + const types = { + test: [ + { + name: 'test', + type: 'string', + }, + ], + }; + const value = { + test: 'test', + }; + + expect(await signer.signTypedData(domain, types, value)).toEqual( + await setup.signer.signTypedData(domain, types, value), + ); + }); +});