Skip to content

Commit 058d2d3

Browse files
committed
feat: RAI-2581 - wallet nonce cache
1 parent f5372a7 commit 058d2d3

11 files changed

Lines changed: 256 additions & 38 deletions

File tree

‎.github/workflows/deploy-preview.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ jobs:
9595
echo "TX_GAS=${{ vars.PREVIEW_TX_GAS }}" >> .env
9696
echo "DISPAIR=${{ vars.PREVIEW_DISPAIR }}" >> .env
9797
echo "MAX_CONCURRENCY=${{ vars.MAX_CONCURRENCY }}" >> .env
98+
echo "BLOCK_TIME=${{ vars.PREVIEW_BLOCK_TIME || '2000' }}" >> .env
9899
99100
- name: Run Rain Solver Docker Image
100101
run: docker run --rm --env-file .env $TAG_BASE:$CHANNEL

‎config.example.yaml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ gasPriceMultiplier: 107
121121

122122
# Time threshold (in ms) for a transaction mine time before it counts as a trigger to increase gas price multiplier for future transactions, default is 15000ms (15 seconds)
123123
# A timed out receipt wait does not count, only a mined transaction does. Set this to at least two block times, one missed block is normal.
124+
# A receipt wait times out after 3 block times, so set this below that, otherwise no transaction can count as slow.
124125
txTimeThreshold: 15000
125126

126127
# Points added to the gas price multiplier on each step up, default is 10
@@ -135,7 +136,7 @@ gasIncreaseStepTime: 6
135136
# maxGasPriceMultiplier: 200
136137

137138
# The average block time (in ms) of the operating chain, used as the polling interval of the block number watcher
138-
# and the transaction receipt wait, required
139+
# and the transaction receipt wait, a receipt wait times out after 3 block times, required
139140
blockTime: 2000
140141

141142
# Optional block tag used for the order quote calls (eth_call) and the gas estimate calls (eth_estimateGas),
@@ -217,8 +218,8 @@ oracleMarketHours: 08:00-23:59
217218
# Option to multiply the gas limit estimation from the rpc as percentage, default is 100, ie no change
218219
gasLimitMultiplier: 100
219220

220-
# Optional seconds to wait for the transaction to mine before disregarding it, default is 15
221-
timeout: 15
221+
# Optional timeout (in ms) of the rpc and subgraph requests, default is 15000
222+
timeout: 15000
222223

223224
# Option to maximize maxIORatio, default true
224225
maxRatio: true

‎src/config/yaml.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export type AppOptions = {
8282
sleep: number;
8383
/** Gas coverage percentage for each transaction to be considered profitable to be submitted, default is 100 */
8484
gasCoveragePercentage: string;
85-
/** Optional seconds to wait for the transaction to mine before disregarding it, default is 15 */
85+
/** Optional timeout (in ms) of the rpc and subgraph requests, default is 15000 */
8686
timeout: number;
8787
/** Option to specify time (in minutes) between pools data resets, default is 0 minutes */
8888
poolUpdateInterval: number;
@@ -122,15 +122,15 @@ export type AppOptions = {
122122
convertToGasTime: number;
123123
/** Determines if multi wallets should be rotated at runtime, meaning new ones to replace older ones once they runs out of gas, default is false */
124124
rotateMultiWallet: boolean;
125-
/** Time threshold (in ms) for a transaction mine time before it counts as a trigger to increase gas price multiplier for future transactions, default is 15 seconds */
125+
/** Time threshold (in ms) for a transaction mine time before it counts as a trigger to increase gas price multiplier for future transactions, default is 15 seconds, must be below the receipt wait (3 block times) for a transaction to count as slow */
126126
txTimeThreshold: number;
127127
/** Points added to the gas price multiplier on each step up, default is 10 */
128128
gasIncreasePointsPerStep: number;
129129
/** Time (in minutes) of one gas price multiplier step, at most one step up per this time and one step down per this time once the last step up is that old, default is 6 */
130130
gasIncreaseStepTime: number;
131131
/** Optional ceiling of the gas price multiplier, never below gasPriceMultiplier, defaults to gasPriceMultiplier plus 1000 points (ten times the rpc gas price) when unset */
132132
maxGasPriceMultiplier?: number;
133-
/** The average block time (in ms) of the operating chain, used as the polling interval of the block number watcher and the transaction receipt wait, required */
133+
/** The average block time (in ms) of the operating chain, used as the polling interval of the block number watcher and the transaction receipt wait, a receipt wait times out after 3 block times, required */
134134
blockTime: number;
135135
/** Subscribes the block number watcher to flashblocks heads instead of new heads over the ws rpc, only supported on Base chain, requires wsRpc, default is false */
136136
flashblocks: boolean;

‎src/signer/actions.test.ts‎

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest";
99
import {
1010
sendTx,
1111
getTxGas,
12+
resetNonce,
1213
broadcastTx,
14+
getNextNonce,
1315
hasExplicitGasParams,
1416
tryGetReceipt,
1517
waitUntilFree,
@@ -71,6 +73,7 @@ describe("Test sendTx", () => {
7173
isSpecialL2: false,
7274
},
7375
l1GasPrice: undefined,
76+
nonceCache: new Map(),
7477
},
7578
waitUntilFree: vi.fn().mockResolvedValue(undefined),
7679
getTransactionCount: vi.fn().mockResolvedValue(5),
@@ -378,6 +381,96 @@ describe("Test sendTx", () => {
378381
expect.anything(),
379382
);
380383
});
384+
385+
describe("nonce cache", () => {
386+
it("should cache the next nonce and send the next tx without reading it over rpc", async () => {
387+
(mockSigner.account as any).address = "0xSender";
388+
389+
await sendTx(mockSigner, { ...mockTx });
390+
expect(mockSigner.state.nonceCache.get("0xsender")).toBe(6);
391+
392+
mockSigner.busy = false;
393+
await sendTx(mockSigner, { ...mockTx });
394+
395+
// read over rpc only for the first tx
396+
expect(mockSigner.getTransactionCount).toHaveBeenCalledTimes(1);
397+
expect((mockSigner.account.signTransaction as Mock).mock.calls[0][0].nonce).toBe(5);
398+
expect((mockSigner.account.signTransaction as Mock).mock.calls[1][0].nonce).toBe(6);
399+
expect(mockSigner.state.nonceCache.get("0xsender")).toBe(7);
400+
});
401+
402+
it("should cache the next nonce for a tx sent through viem sendTransaction", async () => {
403+
(mockSigner.sendTransaction as Mock).mockResolvedValue("0xviemhash");
404+
mockSigner.state.nonceCache.set("0xsender", 9);
405+
406+
await sendTx(mockSigner, { to: mockTx.to, value: 1n } as any);
407+
408+
expect(mockSigner.getTransactionCount).not.toHaveBeenCalled();
409+
expect(mockSigner.sendTransaction).toHaveBeenCalledWith(
410+
expect.objectContaining({ nonce: 9 }),
411+
);
412+
expect(mockSigner.state.nonceCache.get("0xsender")).toBe(10);
413+
});
414+
415+
it("should drop a stale cached nonce on a failed send and retry with the rpc nonce", async () => {
416+
mockSigner.state.nonceCache.set("0xsender", 3);
417+
(mockSigner.sendRawTransaction as Mock)
418+
.mockRejectedValueOnce(new Error("nonce too low"))
419+
.mockResolvedValueOnce("0xhash");
420+
421+
const { hash } = await sendTx(mockSigner, mockTx, 10);
422+
423+
expect(hash).toBe("0xhash");
424+
expect(mockSigner.getTransactionCount).toHaveBeenCalledTimes(1);
425+
expect((mockSigner.account.signTransaction as Mock).mock.calls[0][0].nonce).toBe(3);
426+
expect((mockSigner.account.signTransaction as Mock).mock.calls[1][0].nonce).toBe(5);
427+
expect(mockSigner.state.nonceCache.get("0xsender")).toBe(6);
428+
});
429+
430+
it("should leave no cached nonce when both attempts fail", async () => {
431+
mockSigner.state.nonceCache.set("0xsender", 3);
432+
mockSigner.sendRawTransaction = vi.fn().mockRejectedValue(new Error("failed"));
433+
434+
await expect(sendTx(mockSigner, mockTx, 10)).rejects.toThrow("failed");
435+
436+
expect(mockSigner.state.nonceCache.has("0xsender")).toBe(false);
437+
expect(mockSigner.busy).toBe(false);
438+
});
439+
});
440+
});
441+
442+
describe("Test getNextNonce and resetNonce", () => {
443+
let mockSigner: RainSolverSigner;
444+
445+
beforeEach(() => {
446+
mockSigner = {
447+
account: { address: "0xSender" },
448+
state: { nonceCache: new Map() },
449+
getTransactionCount: vi.fn().mockResolvedValue(5),
450+
} as unknown as RainSolverSigner;
451+
});
452+
453+
it("should read the nonce over rpc when the cache does not hold one", async () => {
454+
expect(await getNextNonce(mockSigner)).toBe(5);
455+
expect(mockSigner.getTransactionCount).toHaveBeenCalledWith({
456+
address: "0xSender",
457+
blockTag: "latest",
458+
});
459+
});
460+
461+
it("should return the cached nonce by lowercase address without reading it over rpc", async () => {
462+
mockSigner.state.nonceCache.set("0xsender", 0);
463+
expect(await getNextNonce(mockSigner)).toBe(0);
464+
expect(mockSigner.getTransactionCount).not.toHaveBeenCalled();
465+
});
466+
467+
it("should drop only the signer's cached nonce", () => {
468+
mockSigner.state.nonceCache.set("0xsender", 8);
469+
mockSigner.state.nonceCache.set("0xother", 2);
470+
resetNonce(mockSigner);
471+
expect(mockSigner.state.nonceCache.has("0xsender")).toBe(false);
472+
expect(mockSigner.state.nonceCache.get("0xother")).toBe(2);
473+
});
381474
});
382475

383476
describe("Test isAlreadyKnownTxError", () => {
@@ -978,6 +1071,63 @@ describe("Test getWriteSignerFrom", () => {
9781071

9791072
spySigner.mockRestore();
9801073
});
1074+
1075+
describe("shared busy state", () => {
1076+
let mockState: SharedState;
1077+
let signer: RainSolverSigner;
1078+
1079+
beforeEach(() => {
1080+
mockState = new SharedState({
1081+
rpcState: new RpcState([{ url: "https://example.com" }]),
1082+
writeRpcState: new RpcState([{ url: "https://example-write.com" }]),
1083+
chainConfig: {
1084+
id: 1,
1085+
isSpecialL2: false,
1086+
},
1087+
} as any);
1088+
signer = RainSolverSigner.create(account, mockState);
1089+
});
1090+
1091+
it("should share the busy state of the signer with each of its write signers", () => {
1092+
const writeSigner1 = getWriteSignerFrom(signer);
1093+
const writeSigner2 = getWriteSignerFrom(signer);
1094+
expect(writeSigner1).not.toBe(signer);
1095+
1096+
// a send through a write signer marks the wallet busy
1097+
writeSigner1.busy = true;
1098+
expect(signer.busy).toBe(true);
1099+
expect(writeSigner2.busy).toBe(true);
1100+
1101+
// the receipt wait frees the wallet through the signer
1102+
signer.busy = false;
1103+
expect(writeSigner1.busy).toBe(false);
1104+
expect(writeSigner2.busy).toBe(false);
1105+
});
1106+
1107+
it("should make a send through another write signer of the wallet wait until the wallet is free", async () => {
1108+
mockState.nonceCache.set(account.address.toLowerCase(), 5);
1109+
const sendTransaction = vi.fn().mockResolvedValue("0xhash");
1110+
const writeSigner1 = getWriteSignerFrom(signer);
1111+
const writeSigner2 = getWriteSignerFrom(signer);
1112+
(writeSigner1 as any).sendTransaction = sendTransaction;
1113+
(writeSigner2 as any).sendTransaction = sendTransaction;
1114+
1115+
await sendTx(writeSigner1, { to: account.address, value: 1n } as any);
1116+
expect(signer.busy).toBe(true);
1117+
1118+
// the second send waits while the first tx is not settled
1119+
const second = sendTx(writeSigner2, { to: account.address, value: 1n } as any);
1120+
await new Promise((resolve) => setTimeout(resolve, 100));
1121+
expect(sendTransaction).toHaveBeenCalledTimes(1);
1122+
1123+
// the receipt wait of the first tx frees the wallet
1124+
signer.busy = false;
1125+
await second;
1126+
expect(sendTransaction).toHaveBeenCalledTimes(2);
1127+
expect(sendTransaction.mock.calls[0][0].nonce).toBe(5);
1128+
expect(sendTransaction.mock.calls[1][0].nonce).toBe(6);
1129+
});
1130+
});
9811131
});
9821132

9831133
describe("Test tryGetReceipt", () => {
@@ -988,6 +1138,7 @@ describe("Test tryGetReceipt", () => {
9881138
beforeEach(() => {
9891139
mockSigner = {
9901140
busy: true,
1141+
account: { address: "0xSender" },
9911142
state: {
9921143
appOptions: { blockTime: 150 },
9931144
blockNumber: 100n,
@@ -997,6 +1148,7 @@ describe("Test tryGetReceipt", () => {
9971148
gasManager: {
9981149
onTransactionMine: vi.fn(),
9991150
},
1151+
nonceCache: new Map([["0xsender", 7]]),
10001152
},
10011153
} as unknown as RainSolverSigner;
10021154

@@ -1042,7 +1194,7 @@ describe("Test tryGetReceipt", () => {
10421194
expect(mockSigner.state.client.getTransactionReceipt).not.toHaveBeenCalled();
10431195
});
10441196

1045-
it("should default the polling interval to the configured block time", async () => {
1197+
it("should default the polling interval to the block time and the timeout to 3 block times", async () => {
10461198
(mockSigner.state.appOptions as any).blockTime = 500;
10471199
(mockSigner.state.client.getTransactionReceipt as Mock).mockResolvedValue({
10481200
status: "success",
@@ -1052,8 +1204,13 @@ describe("Test tryGetReceipt", () => {
10521204
const start = Date.now();
10531205
await tryGetReceipt(mockSigner, "0xhash");
10541206

1055-
// the tick is a fifth of the block time
1207+
// the tick is a fifth of the block time and the timeout is 3 block times
10561208
expect(sleepSpy).toHaveBeenCalledWith(100);
1209+
expect(promiseTimeoutSpy).toHaveBeenCalledWith(
1210+
expect.any(Promise),
1211+
1_500,
1212+
expect.any(Object),
1213+
);
10571214
expect(mockSigner.state.client.getTransactionReceipt).toHaveBeenCalledTimes(1);
10581215
expect(Date.now() - start).toBeLessThan(400);
10591216
});
@@ -1122,6 +1279,8 @@ describe("Test tryGetReceipt", () => {
11221279
length: expect.any(Number),
11231280
});
11241281
expect(mockSigner.busy).toBe(false);
1282+
// a mined tx keeps the cached nonce
1283+
expect(mockSigner.state.nonceCache.get("0xsender")).toBe(7);
11251284
});
11261285

11271286
it("should hit timeout", async () => {
@@ -1151,5 +1310,7 @@ describe("Test tryGetReceipt", () => {
11511310
length: expect.any(Number),
11521311
});
11531312
expect(mockSigner.busy).toBe(false);
1313+
// the tx may have been dropped, so the cached nonce is dropped too
1314+
expect(mockSigner.state.nonceCache.has("0xsender")).toBe(false);
11541315
});
11551316
});

0 commit comments

Comments
 (0)