Skip to content
This repository has been archived by the owner on Feb 26, 2024. It is now read-only.

fix: reject transactions with insufficient funds #1842

Merged
merged 9 commits into from
Dec 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/chains/ethereum/block/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe("@ganache/ethereum-block", async () => {
describe("baseFeePerGas calculations", () => {
let blockchain: Blockchain;
before(async function () {
this.timeout(5000);
this.timeout(10000);
const privKey = `0x${"46".repeat(32)}`;
const privKeyData = Data.from(privKey);
const options = EthereumOptionsConfig.normalize({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ export default class AccountManager {
return balance.length === 0 ? RPCQUANTITY_ZERO : Quantity.from(balance);
}

public async getNonceAndBalance(
address: Address,
blockNumber: QUANTITY | Buffer | Tag = Tag.LATEST
) {
const data = await this.getRaw(address, blockNumber);

if (data == null)
return { nonce: RPCQUANTITY_ZERO, balance: RPCQUANTITY_ZERO };

const [nonce, balance] = decode<EthereumRawAccount>(data);
return {
nonce: nonce.length === 0 ? RPCQUANTITY_ZERO : Quantity.from(nonce),
balance: balance.length === 0 ? RPCQUANTITY_ZERO : Quantity.from(balance)
};
}

public async getCode(
address: Address,
blockNumber: QUANTITY | Buffer | Tag = Tag.LATEST
Expand Down
49 changes: 32 additions & 17 deletions src/chains/ethereum/ethereum/src/transaction-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
CodedError,
UNDERPRICED,
REPLACED,
TRANSACTION_LOCKED
TRANSACTION_LOCKED,
INSUFFICIENT_FUNDS
} from "@ganache/ethereum-utils";
import { EthereumInternalOptions } from "@ganache/ethereum-options";
import { Executables } from "./miner/executables";
Expand Down Expand Up @@ -133,7 +134,10 @@ export default class TransactionPool extends Emittery.Typed<{}, "drain"> {
pending: new Map()
};
readonly #origins: Map<string, Heap<TypedTransaction>>;
readonly #accountPromises = new Map<string, Promise<Quantity>>();
readonly #accountPromises = new Map<
string,
Promise<{ balance: Quantity; nonce: Quantity }>
>();

/**
* Inserts a transaction into the pending queue, if executable, or future pool
Expand Down Expand Up @@ -170,9 +174,9 @@ export default class TransactionPool extends Emittery.Typed<{}, "drain"> {
// account's pending executable transactions, not the account...
// But another transaction might currently be getting the nonce from the
// account, if it is, we need to wait for it to be done doing that. Hence:
let transactorNoncePromise = this.#accountPromises.get(origin);
if (transactorNoncePromise) {
await transactorNoncePromise;
let transactorPromise = this.#accountPromises.get(origin);
if (transactorPromise) {
await transactorPromise;
}
// if the user called sendTransaction or sendRawTransaction, effectiveGasPrice
// hasn't been set yet on the tx. calculating the effectiveGasPrice requires
Expand Down Expand Up @@ -204,6 +208,25 @@ export default class TransactionPool extends Emittery.Typed<{}, "drain"> {
// `executableOriginTransactions` map, always taking the highest of the two.
let highestNonce = 0n;

if (!transactorPromise) {
transactorPromise = this.#blockchain.accounts.getNonceAndBalance(from);
this.#accountPromises.set(origin, transactorPromise);
transactorPromise.then(() => {
this.#accountPromises.delete(origin);
});
}
const transactor = await transactorPromise;

const cost =
transaction.gas.toBigInt() * transaction.maxGasPrice().toBigInt() +
transaction.value.toBigInt();
if (transactor.balance.toBigInt() < cost) {
throw new CodedError(
INSUFFICIENT_FUNDS,
JsonRpcErrorCode.TRANSACTION_REJECTED
);
}

const origins = this.#origins;
const queuedOriginTransactions = origins.get(origin);

Expand Down Expand Up @@ -260,16 +283,7 @@ export default class TransactionPool extends Emittery.Typed<{}, "drain"> {
} else {
// since we don't have any executable transactions at the moment, we need
// to find our nonce from the account itself...
if (!transactorNoncePromise) {
transactorNoncePromise = this.#blockchain.accounts.getNonce(from);
this.#accountPromises.set(origin, transactorNoncePromise);
transactorNoncePromise.then(() => {
this.#accountPromises.delete(origin);
});
}
const transactor = await transactorNoncePromise;

const transactorNonce = transactor ? transactor.toBigInt() : 0n;
const transactorNonce = transactor.nonce.toBigInt();
if (txNonce === void 0) {
// if we don't have a transactionNonce, just use the account's next
// nonce and mark as executable
Expand All @@ -278,8 +292,9 @@ export default class TransactionPool extends Emittery.Typed<{}, "drain"> {
transactionPlacement = TriageOption.Executable;
} else if (txNonce < transactorNonce) {
// it's an error if the transaction's nonce is <= the persisted nonce
throw new Error(
`the tx doesn't have the correct nonce. account has nonce of: ${transactorNonce} tx has nonce of: ${txNonce}`
throw new CodedError(
`the tx doesn't have the correct nonce. account has nonce of: ${transactorNonce} tx has nonce of: ${txNonce}`,
JsonRpcErrorCode.INVALID_INPUT
);
} else if (txNonce === transactorNonce) {
transactionPlacement = TriageOption.Executable;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe("api", () => {
result.nextKey,
"0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6"
);
}).timeout(0);
});

it("should return only the filled storage slots", async () => {
const result = await provider.send("debug_storageRangeAt", [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,37 @@ describe("api", () => {
miner: { legacyInstamine: true },
chain: { vmErrorsOnRPCResponse: true }
}).then(async provider => {
const accounts = await provider.send("eth_accounts");
const [from, to] = await provider.send("eth_accounts");
const balance = parseInt(
await provider.send("eth_getBalance", [from]),
16
);
const gasCost = 99967968750001;
// send a transaction that will spend some of the balance
provider.request({
method: "eth_sendTransaction",
params: [
{
from,
to
}
]
});

// send another transaction while the previous transaction is still
// pending. this transaction appears to have enough balance to run,
// so the transaction pool will accept it, but when it runs in the VM
// it won't have enough balance to run.
provider.send(
{
jsonrpc: "2.0",
id: "1",
method: "eth_sendTransaction",
params: [
{
from: accounts[0],
to: accounts[1],
value: "0x76bc75e2d631000000"
from,
to,
value: `0x${(balance - gasCost).toString(16)}`
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,25 +47,59 @@ describe("api", () => {
});

describe("insufficient funds", () => {
it("returns an error when account has insufficient funds to send the transaction", async () => {
const p = await getProvider({
const types = ["0x0", "0x1", "0x2"] as const;
it("returns a VM error when the account has insufficient funds to transfer the value at runtime", async () => {
const approximateGasCost = 99967968750001;
const provider = await getProvider({
miner: { legacyInstamine: true },
chain: { vmErrorsOnRPCResponse: true }
});
const [from, to] = await p.send("eth_accounts");
const balance = await p.send("eth_getBalance", [from]);
const types = ["0x0", "0x1", "0x2"] as const;
const accounts = await provider.send("eth_accounts");
const to = accounts.pop();
const getBalance = acct => provider.send("eth_getBalance", [acct]);
const sendTx = tx => provider.send("eth_sendTransaction", [tx]);
for (let i = 0; i < types.length; i++) {
const snapshot = await provider.send("evm_snapshot");
try {
const from = accounts[i];
const balance = parseInt(await getBalance(from), 16);
// fire a transaction without awaiting it in order to spend some
// gas
const tx = { type: types[i], from, to };
sendTx(tx);
await assert.rejects(
sendTx({
...tx,
// attempt to zero out the account. this tx will fail because
// the previous (pending transaction) will spend some of its
// balance, not leaving enough left over for this transaction.
value: `0x${(balance - approximateGasCost).toString(16)}`
}),
new RegExp(
`VM Exception while processing transaction: sender doesn't have enough funds to send tx\\. The upfront cost is: \\d+ and the sender's account \\(${from}\\) only has: \\d+ \\(vm hf=london -> block -> tx\\)`
)
);
} finally {
await provider.send("evm_revert", [snapshot]);
}
}
});

it("returns an `insufficient funds` error when the account doesn't have enough funds to send the transaction", async () => {
const provider = await getProvider();
const [from, to] = await provider.send("eth_accounts");
const getBalance = acct => provider.send("eth_getBalance", [acct]);
for (let i = 0; i < types.length; i++) {
await assert.rejects(
p.send("eth_sendTransaction", [
{ type: types[i], from, to, value: balance }
]),
new RegExp(
`VM Exception while processing transaction: sender doesn't have enough funds to send tx\\. The upfront cost is: \\d+ and the sender's account \\(${from}\\) only has: ${BigInt(
balance
)} \\(vm hf=london -> block -> tx\\)`
)
);
const tx = {
type: types[i],
from,
to,
value: await getBalance(from)
};
await assert.rejects(provider.send("eth_sendTransaction", [tx]), {
message: "insufficient funds for gas * price + value",
code: -32003
});
}
});
});
Expand Down
11 changes: 10 additions & 1 deletion src/chains/ethereum/ethereum/tests/api/evm/evm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ describe("api", () => {

it("should add an account to the personal namespace", async () => {
const address = "0x742d35cc6634c0532925a3b844bc454e4438f44e";
// fund the account
const [from] = await provider.request({
method: "eth_accounts",
params: []
});
await provider.send("eth_subscribe", ["newHeads"]);
await provider.send("eth_sendTransaction", [
{ from, to: address, value: "0xffffffffffffffff" }
]);
await provider.once("message");
const tx: TypedRpcTransaction = { from: address };
// account is unknown on startup
await assert.rejects(provider.send("eth_sendTransaction", [tx]), {
Expand All @@ -173,7 +183,6 @@ describe("api", () => {
address,
passphrase
]);

assert.strictEqual(result, true);

// account is known but locked
Expand Down
4 changes: 2 additions & 2 deletions src/chains/ethereum/ethereum/tests/api/miner/miner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@ describe("api", () => {
it("should stop mining, then mine when started", async () => {
const provider = await getProvider();
await testStopStartMining(provider);
}).timeout(3000);
}).timeout(6000);

it("should stop mining, then mine when started", async () => {
const provider = await getProvider({ miner: { blockTime: 1 } });
await testStopStartMining(provider);
}).timeout(4000);
}).timeout(8000);

it("should not throw an error when miner was already started when calling miner_start", async () => {
const provider = await getProvider({
Expand Down
2 changes: 1 addition & 1 deletion src/chains/ethereum/ethereum/tests/connector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,5 @@ describe("connector", () => {
}
const result = JSON.parse(str);
assert.deepStrictEqual(result, expected);
});
}).timeout(10000);
});
2 changes: 1 addition & 1 deletion src/chains/ethereum/ethereum/tests/forking/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,6 @@ describe("forking", () => {
endOnFailure: true
}
);
}).timeout(30000);
}).timeout(60000);
});
});
34 changes: 30 additions & 4 deletions src/chains/ethereum/ethereum/tests/transaction-pool.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "assert";
import Common from "@ethereumjs/common";
import {
EIP1559FeeMarketRpcTransaction,
TransactionFactory,
TypedRpcTransaction,
TypedTransaction
Expand Down Expand Up @@ -81,8 +82,12 @@ describe("transaction pool", async () => {
// returns an account's nonce
blockchain = {
davidmurdoch marked this conversation as resolved.
Show resolved Hide resolved
accounts: {
getNonce: async () => {
return Quantity.from("0x0");
getNonceAndBalance: async () => {
return {
nonce: Quantity.from("0x0"),
// 1000 ether
balance: Quantity.from("0x3635c9adc5dea00000")
};
}
},
common,
Expand Down Expand Up @@ -137,8 +142,8 @@ describe("transaction pool", async () => {
// so if we send a tx with nonce 0, it should reject
const fakeNonceChain = {
accounts: {
getNonce: async () => {
return Quantity.from(1);
getNonceAndBalance: async () => {
return { nonce: Quantity.from(1), balance: Quantity.from(1e15) };
}
},
common,
Expand Down Expand Up @@ -247,6 +252,27 @@ describe("transaction pool", async () => {
);
});

it("rejects transactions whose potential cost is more than the account's balance", async () => {
const expensiveRpc: EIP1559FeeMarketRpcTransaction = {
from,
type: "0x2",
value: "0xfffffffffffffffffff",
maxFeePerGas: "0xffffff",
maxPriorityFeePerGas: "0xff",
gasLimit: "0xffff"
};
const txPool = new TransactionPool(options.miner, blockchain, origins);
const expensiveTx = TransactionFactory.fromRpc(expensiveRpc, common);
await assert.rejects(
txPool.prepareTransaction(expensiveTx, secretKey),
{
code: -32003,
message: "insufficient funds for gas * price + value"
},
"transaction whose potential cost is more than the account's balance should have been rejected"
);
});

it("adds immediately executable transactions to the pending queue", async () => {
const txPool = new TransactionPool(options.miner, blockchain, origins);
const executableTx = TransactionFactory.fromRpc(executableRpc, common);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ export class EIP1559FeeMarketTransaction extends RuntimeTransaction {
}
}

public maxGasPrice() {
return this.maxFeePerGas;
}

public toJSON(_common?: Common): EIP1559FeeMarketTransactionJSON {
return {
type: this.type,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ export class EIP2930AccessListTransaction extends RuntimeTransaction {
}
}

public maxGasPrice() {
return this.gasPrice;
}

public toJSON(_common?: Common): EIP2930AccessListTransactionJSON {
return {
hash: this.hash,
Expand Down
4 changes: 4 additions & 0 deletions src/chains/ethereum/transaction/src/legacy-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export class LegacyTransaction extends RuntimeTransaction {
}
}

public maxGasPrice() {
return this.gasPrice;
}

public toJSON(common?: Common): LegacyTransactionJSON {
const json: LegacyTransactionJSON = {
hash: this.hash,
Expand Down
Loading