Skip to content

Commit

Permalink
feat: sequential EVM signer
Browse files Browse the repository at this point in the history
  • Loading branch information
michael1011 committed Feb 13, 2025
1 parent e76e74c commit 7a6b6c6
Show file tree
Hide file tree
Showing 11 changed files with 317 additions and 10 deletions.
57 changes: 56 additions & 1 deletion lib/db/Migration.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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}`;
}
Expand Down
12 changes: 12 additions & 0 deletions lib/db/models/PendingEthereumTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { DataTypes, Model, Sequelize } from 'sequelize';
type PendingEthereumTransactionType = {
hash: string;
nonce: number;
etherAmount: bigint;
hex: string;
};

class PendingEthereumTransaction
Expand All @@ -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(
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions lib/db/repositories/PendingEthereumTransactionRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,25 @@ class PendingEthereumTransactionRepository {
return nonce + 1;
};

public static getTotalSent = async (): Promise<bigint> => {
return BigInt(
(await PendingEthereumTransaction.sum<number, PendingEthereumTransaction>(
'etherAmount',
)) ?? 0,
);
};

public static addTransaction = (
hash: string,
nonce: number,
etherAmount: bigint,
hex: string,
): Promise<PendingEthereumTransaction> => {
return PendingEthereumTransaction.create({
hash,
nonce,
etherAmount,
hex,
});
};
}
Expand Down
6 changes: 5 additions & 1 deletion lib/wallet/ethereum/EthereumManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 11 additions & 6 deletions lib/wallet/ethereum/InjectedProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,7 @@ class InjectedProvider implements Provider {
public broadcastTransaction = async (
signedTransaction: string,
): Promise<TransactionResponse> => {
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
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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 => {
Expand Down
86 changes: 86 additions & 0 deletions lib/wallet/ethereum/SequentialSigner.ts
Original file line number Diff line number Diff line change
@@ -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<string> => this.signer.getAddress();

public connect = (provider: null | Provider): Signer => {
return new SequentialSigner(this.symbol, this.signer.connect(provider));
};

public signTransaction = async (tx: TransactionRequest): Promise<string> => {
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<string> =>
this.signer.signMessage(message);

public signTypedData = (
domain: TypedDataDomain,
types: Record<string, Array<TypedDataField>>,
value: Record<string, any>,
): Promise<string> => 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;
8 changes: 8 additions & 0 deletions run-int.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,21 @@ const runTest = (file) => {
};

let testsRun = 0;
let skipUntil = process.argv[2];
let skip = skipUntil !== undefined;

const execDir = (dir) => {
const content = fs.readdirSync(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++;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
});
1 change: 1 addition & 0 deletions test/integration/wallet/ethereum/EthereumManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading

0 comments on commit 7a6b6c6

Please sign in to comment.