Skip to content

Commit 1802215

Browse files
committed
Fix uncatchable issue when sending transactions over JSON-RPC and provide some retry-recovery for missing v (#4513).
1 parent 6ee1a5f commit 1802215

File tree

3 files changed

+271
-21
lines changed

3 files changed

+271
-21
lines changed
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import assert from "assert";
2+
3+
import {
4+
id, isError, makeError, toUtf8Bytes, toUtf8String,
5+
FetchRequest,
6+
JsonRpcProvider, Transaction, Wallet
7+
} from "../index.js";
8+
9+
const StatusMessages: Record<number, string> = {
10+
200: "OK",
11+
400: "BAD REQUEST",
12+
500: "SERVER ERROR",
13+
};
14+
15+
16+
type ProcessRequest = (method: string, params: Array<string>, blockNumber: number) => any;
17+
18+
const wallet = new Wallet(id("test"));
19+
20+
function createProvider(testFunc: ProcessRequest): JsonRpcProvider {
21+
22+
let blockNumber = 1;
23+
const ticker = setInterval(() => { blockNumber++ }, 100);
24+
if (ticker.unref) { ticker.unref(); }
25+
26+
const processReq = (req: { method: string, params: Array<string>, id: any }) => {
27+
28+
let result = testFunc(req.method, req.params, blockNumber);
29+
if (result === undefined) {
30+
switch (req.method) {
31+
case "eth_blockNumber":
32+
result = blockNumber;
33+
break;
34+
case "eth_chainId":
35+
result = "0x1337";
36+
break;
37+
case "eth_accounts":
38+
result = [ wallet.address ];
39+
break;
40+
default:
41+
console.log("****", req);
42+
return { id, error: "unsupported", jsonrpc: "2.0" };
43+
}
44+
}
45+
46+
return { id: req.id, result, jsonrpc: "2.0" };
47+
};
48+
49+
const req = new FetchRequest("http:/\/localhost:8082/");
50+
req.getUrlFunc = async (_req, signal) => {
51+
const req = JSON.parse(_req.hasBody() ? toUtf8String(_req.body): "");
52+
53+
let statusCode = 200;
54+
const headers = { };
55+
56+
let resp: any;
57+
try {
58+
if (Array.isArray(req)) {
59+
resp = req.map((r) => processReq(r));
60+
} else {
61+
resp = processReq(req);
62+
}
63+
64+
} catch(error: any) {
65+
statusCode = 500;
66+
resp = error.message;
67+
}
68+
69+
const body = toUtf8Bytes(JSON.stringify(resp));
70+
71+
return {
72+
statusCode,
73+
statusMessage: StatusMessages[statusCode],
74+
headers, body
75+
};
76+
};
77+
78+
return new JsonRpcProvider(req, undefined, { cacheTimeout: -1 });
79+
}
80+
81+
describe("Ensure Catchable Errors", function() {
82+
it("Can catch bad broadcast replies", async function() {
83+
this.timeout(15000);
84+
85+
const txInfo = {
86+
chainId: 1337,
87+
gasLimit: 100000,
88+
maxFeePerGas: 2000000000,
89+
maxPriorityFeePerGas: 1000000000,
90+
to: wallet.address,
91+
value: 1,
92+
};
93+
const txSign = await wallet.signTransaction(txInfo);
94+
const txObj = Transaction.from(txSign);
95+
96+
let count = 0;
97+
98+
const provider = createProvider((method, params, blockNumber) => {
99+
100+
switch (method) {
101+
case "eth_sendTransaction":
102+
return txObj.hash;
103+
104+
case "eth_getTransactionByHash": {
105+
count++;
106+
107+
// First time; fail!
108+
if (count === 1) {
109+
throw makeError("Faux Error", "SERVER_ERROR", {
110+
request: <any>({ })
111+
});
112+
}
113+
114+
// Second time; return null
115+
if (count === 2) { return null; }
116+
117+
// Return a valid tx...
118+
const result = Object.assign({ },
119+
txObj.toJSON(),
120+
txObj.signature!.toJSON(),
121+
{ hash: txObj.hash, from: wallet.address });
122+
123+
// ...eventually mined
124+
if (count > 4) {
125+
result.blockNumber = blockNumber;
126+
result.blockHash = id("test");
127+
}
128+
129+
return result;
130+
}
131+
}
132+
133+
return undefined;
134+
});
135+
136+
const signer = await provider.getSigner();
137+
138+
const tx = await signer.sendTransaction(txInfo);
139+
assert(tx);
140+
});
141+
142+
143+
it("Missing v is recovered", async function() {
144+
this.timeout(15000);
145+
146+
const txInfo = {
147+
chainId: 1337,
148+
gasLimit: 100000,
149+
maxFeePerGas: 2000000000,
150+
maxPriorityFeePerGas: 1000000000,
151+
to: wallet.address,
152+
value: 1,
153+
};
154+
const txSign = await wallet.signTransaction(txInfo);
155+
const txObj = Transaction.from(txSign);
156+
157+
let count = 0;
158+
159+
// A provider which is mocked to return a "missing v"
160+
// in getTransaction
161+
162+
const provider = createProvider((method, params, blockNumber) => {
163+
164+
switch (method) {
165+
case "eth_sendTransaction":
166+
return txObj.hash;
167+
168+
case "eth_getTransactionByHash": {
169+
count++;
170+
171+
// The fully valid tx response
172+
const result = Object.assign({ },
173+
txObj.toJSON(),
174+
txObj.signature!.toJSON(),
175+
{ hash: txObj.hash, from: wallet.address, sig: null });
176+
177+
// First time; fail with a missing v!
178+
if (count < 2) { delete result.v; }
179+
180+
// Debug
181+
result._count = count;
182+
183+
return result;
184+
}
185+
}
186+
187+
return undefined;
188+
});
189+
190+
// Track any "missing v" error
191+
let missingV: Error | null = null;
192+
provider.on("error", (e) => {
193+
if (isError(e, "UNKNOWN_ERROR") && isError(e.error, "INVALID_ARGUMENT")) {
194+
if (e.error.argument === "signature" && e.error.shortMessage === "missing v") {
195+
missingV = e.error;
196+
}
197+
}
198+
});
199+
200+
const signer = await provider.getSigner();
201+
202+
const tx = await signer.sendTransaction(txInfo);
203+
assert.ok(!!tx, "we got a transaction");
204+
assert.ok(!!missingV, "missing v error present");
205+
});
206+
});
207+

src.ts/providers/abstract-provider.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -862,16 +862,18 @@ export class AbstractProvider implements Provider {
862862
if (this.#networkPromise == null) {
863863

864864
// Detect the current network (shared with all calls)
865-
const detectNetwork = this._detectNetwork().then((network) => {
866-
this.emit("network", network, null);
867-
return network;
868-
}, (error) => {
869-
// Reset the networkPromise on failure, so we will try again
870-
if (this.#networkPromise === detectNetwork) {
871-
this.#networkPromise = null;
865+
const detectNetwork = (async () => {
866+
try {
867+
const network = await this._detectNetwork();
868+
this.emit("network", network, null);
869+
return network;
870+
} catch (error) {
871+
if (this.#networkPromise === detectNetwork!) {
872+
this.#networkPromise = null;
873+
}
874+
throw error;
872875
}
873-
throw error;
874-
});
876+
})();
875877

876878
this.#networkPromise = detectNetwork;
877879
return (await detectNetwork).clone();

src.ts/providers/provider-jsonrpc.ts

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { TypedDataEncoder } from "../hash/index.js";
2121
import { accessListify } from "../transaction/index.js";
2222
import {
2323
defineProperties, getBigInt, hexlify, isHexString, toQuantity, toUtf8Bytes,
24-
makeError, assert, assertArgument,
24+
isError, makeError, assert, assertArgument,
2525
FetchRequest, resolveProperties
2626
} from "../utils/index.js";
2727

@@ -41,7 +41,6 @@ import type { Signer } from "./signer.js";
4141

4242
type Timer = ReturnType<typeof setTimeout>;
4343

44-
4544
const Primitive = "bigint,boolean,function,number,string,symbol".split(/,/g);
4645
//const Methods = "getAddress,then".split(/,/g);
4746
function deepCopy<T = any>(value: T): T {
@@ -363,12 +362,49 @@ export class JsonRpcSigner extends AbstractSigner<JsonRpcApiProvider> {
363362
// for it; it should show up very quickly
364363
return await (new Promise((resolve, reject) => {
365364
const timeouts = [ 1000, 100 ];
365+
let invalids = 0;
366+
366367
const checkTx = async () => {
367-
// Try getting the transaction
368-
const tx = await this.provider.getTransaction(hash);
369-
if (tx != null) {
370-
resolve(tx.replaceableTransaction(blockNumber));
371-
return;
368+
369+
try {
370+
// Try getting the transaction
371+
const tx = await this.provider.getTransaction(hash);
372+
373+
if (tx != null) {
374+
resolve(tx.replaceableTransaction(blockNumber));
375+
return;
376+
}
377+
378+
} catch (error) {
379+
380+
// If we were cancelled: stop polling.
381+
// If the data is bad: the node returns bad transactions
382+
// If the network changed: calling again will also fail
383+
// If unsupported: likely destroyed
384+
if (isError(error, "CANCELLED") || isError(error, "BAD_DATA") ||
385+
isError(error, "NETWORK_ERROR" || isError(error, "UNSUPPORTED_OPERATION"))) {
386+
387+
if (error.info == null) { error.info = { }; }
388+
error.info.sendTransactionHash = hash;
389+
390+
reject(error);
391+
return;
392+
}
393+
394+
// Stop-gap for misbehaving backends; see #4513
395+
if (isError(error, "INVALID_ARGUMENT")) {
396+
invalids++;
397+
if (error.info == null) { error.info = { }; }
398+
error.info.sendTransactionHash = hash;
399+
if (invalids > 10) {
400+
reject(error);
401+
return;
402+
}
403+
}
404+
405+
// Notify anyone that cares; but we will try again, since
406+
// it is likely an intermittent service error
407+
this.provider.emit("error", makeError("failed to fetch transation after sending (will try again)", "UNKNOWN_ERROR", { error }));
372408
}
373409

374410
// Wait another 4 seconds
@@ -468,7 +504,7 @@ export abstract class JsonRpcApiProvider extends AbstractProvider {
468504
#scheduleDrain(): void {
469505
if (this.#drainTimer) { return; }
470506

471-
// If we aren't using batching, no hard in sending it immeidately
507+
// If we aren't using batching, no harm in sending it immediately
472508
const stallTime = (this._getOption("batchMaxCount") === 1) ? 0: this._getOption("batchStallTime");
473509

474510
this.#drainTimer = setTimeout(() => {
@@ -613,6 +649,7 @@ export abstract class JsonRpcApiProvider extends AbstractProvider {
613649
* and should generally call ``super._perform`` as a fallback.
614650
*/
615651
async _perform(req: PerformActionRequest): Promise<any> {
652+
616653
// Legacy networks do not like the type field being passed along (which
617654
// is fair), so we delete type if it is 0 and a non-EIP-1559 network
618655
if (req.method === "call" || req.method === "estimateGas") {
@@ -664,9 +701,14 @@ export abstract class JsonRpcApiProvider extends AbstractProvider {
664701
// If we are ready, use ``send``, which enabled requests to be batched
665702
if (this.ready) {
666703
this.#pendingDetectNetwork = (async () => {
667-
const result = Network.from(getBigInt(await this.send("eth_chainId", [ ])));
668-
this.#pendingDetectNetwork = null;
669-
return result;
704+
try {
705+
const result = Network.from(getBigInt(await this.send("eth_chainId", [ ])));
706+
this.#pendingDetectNetwork = null;
707+
return result;
708+
} catch (error) {
709+
this.#pendingDetectNetwork = null;
710+
throw error;
711+
}
670712
})();
671713
return await this.#pendingDetectNetwork;
672714
}
@@ -1186,7 +1228,6 @@ export class JsonRpcProvider extends JsonRpcApiPollingProvider {
11861228
const request = this._getConnection();
11871229
request.body = JSON.stringify(payload);
11881230
request.setHeader("content-type", "application/json");
1189-
11901231
const response = await request.send();
11911232
response.assertOk();
11921233

0 commit comments

Comments
 (0)