fix: one transaction approval at a time, and honest copy for a nonce collision (closes #271)
All checks were successful
check / check (push) Successful in 40s
All checks were successful
check / check (push) Successful in 40s
Populating the transaction in the background before the approval window opens is what makes the displayed object the verified object. It also fixes the nonce before the user has answered anything, so two eth_sendTransaction calls populated concurrently took the same nonce from a node that had seen neither of them broadcast, and the second could never be sent: its approved nonce is spent, and the only way to give it a fresh one is to populate it again after the user has read the old one off the screen. A second transaction approval is now refused while one is unanswered, with EIP-1193 code -32002. The refusal happens before anything is populated — no second nonce is allocated, no window opens — and the slot is released when the requesting page has its answer. Signature approvals are not gated; a signature consumes no nonce. A collision that does happen is now reported for what it is. A broadcast the node refused for the nonce, and an approval carrying a nonce this worker has already broadcast (caught before the node is asked at all), both report that the transaction did not reach the network and to send it again, instead of the standing broadcast wording that warns it may have sent. "already known" keeps that ambiguous wording deliberately: a node that says it has the transaction has it. Nothing about verification is weakened. The approval still carries the transaction the screen displayed, and the artifact is still compared against that object field for field.
This commit is contained in:
@@ -14,8 +14,10 @@ const {
|
||||
assertWithinCeilings,
|
||||
sameAddress,
|
||||
failureIsRetryable,
|
||||
isNonceCollision,
|
||||
describeTxFailure,
|
||||
describeSigningFailure,
|
||||
NONCE_COLLISION_MESSAGE,
|
||||
ALLOWED_TX_TYPES,
|
||||
SERIALIZED_FIELDS,
|
||||
FORBIDDEN_FIELDS,
|
||||
@@ -23,6 +25,7 @@ const {
|
||||
TX_STAGE_SIGN,
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
TX_STAGE_NONCE,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
} = require("../src/shared/approvalVerify");
|
||||
@@ -1191,7 +1194,6 @@ describe("signing failure and retry", () => {
|
||||
"already known",
|
||||
"timeout of 30000ms exceeded",
|
||||
"could not coalesce error",
|
||||
"replacement transaction underpriced",
|
||||
]) {
|
||||
const outcome = describeTxFailure(
|
||||
TX_STAGE_BROADCAST,
|
||||
@@ -1199,10 +1201,76 @@ describe("signing failure and retry", () => {
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.spendApproval).toBe(true);
|
||||
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
|
||||
expect(outcome.error).toBe(message);
|
||||
}
|
||||
});
|
||||
|
||||
// The one broadcast failure that is not ambiguous. The node answered, and
|
||||
// its answer was that the nonce was already spoken for, so this
|
||||
// transaction is not in a mempool anywhere.
|
||||
test("a nonce the node refused is classified however it was worded", () => {
|
||||
for (const err of [
|
||||
new Error("nonce too low"),
|
||||
new Error("replacement transaction underpriced"),
|
||||
Object.assign(new Error("could not coalesce error"), {
|
||||
code: "NONCE_EXPIRED",
|
||||
}),
|
||||
Object.assign(new Error("could not coalesce error"), {
|
||||
code: "REPLACEMENT_UNDERPRICED",
|
||||
}),
|
||||
// The shape ethers hands up when it could not classify the node's
|
||||
// error itself: the node's own words are nested underneath.
|
||||
Object.assign(new Error("could not coalesce error"), {
|
||||
info: { error: { code: -32000, message: "OldNonce" } },
|
||||
}),
|
||||
]) {
|
||||
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
|
||||
expect(
|
||||
describeSigningFailure(
|
||||
outcome,
|
||||
"The transaction could not be sent.",
|
||||
).message,
|
||||
).toMatch(/did not reach the network/);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.spendApproval).toBe(true);
|
||||
expect(outcome.error).toBe(NONCE_COLLISION_MESSAGE);
|
||||
expect(outcome.stage).toBe(TX_STAGE_NONCE);
|
||||
expect(isNonceCollision(err)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// A node that says it knows the transaction has it, so it did reach the
|
||||
// network and the ambiguous wording is the correct one.
|
||||
test("already known is not a nonce collision", () => {
|
||||
const err = new Error("already known");
|
||||
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
|
||||
expect(
|
||||
describeSigningFailure(
|
||||
outcome,
|
||||
"The transaction could not be sent.",
|
||||
).message,
|
||||
).toMatch(/may still have reached the network/);
|
||||
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
|
||||
expect(isNonceCollision(err)).toBe(false);
|
||||
});
|
||||
|
||||
test("a nonce collision says the transaction did not reach the network", () => {
|
||||
const outcome = describeTxFailure(
|
||||
TX_STAGE_BROADCAST,
|
||||
new Error("nonce too low"),
|
||||
);
|
||||
const copy = describeSigningFailure(
|
||||
outcome,
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(copy.retryable).toBe(false);
|
||||
expect(copy.message).toMatch(/did not reach the network/);
|
||||
expect(copy.message).not.toMatch(/may still have reached the network/);
|
||||
expect(copy.message).toMatch(/Please send it again from the site\.$/);
|
||||
expect(copy.message).toMatch(/^[A-Z].*\.$/);
|
||||
});
|
||||
|
||||
test("a failed broadcast does not tell the user to send it again", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{
|
||||
|
||||
@@ -206,6 +206,10 @@ function loadBackground(options) {
|
||||
// approval id back out of the popup URL the background opened.
|
||||
function requestTx(txParams) {
|
||||
let rpcResult = null;
|
||||
// The window this request opens, if it opens one. A request refused
|
||||
// before an approval is raised opens none, and the window belonging to
|
||||
// some other request must not be handed back as this one's.
|
||||
const windowIndex = created.length;
|
||||
const sendResponse = jest.fn((r) => {
|
||||
rpcResult = r;
|
||||
});
|
||||
@@ -219,7 +223,12 @@ function loadBackground(options) {
|
||||
sendResponse,
|
||||
);
|
||||
return {
|
||||
id: () => new URL(created[0].url).searchParams.get("approval"),
|
||||
id: () =>
|
||||
created.length > windowIndex
|
||||
? new URL(created[windowIndex].url).searchParams.get(
|
||||
"approval",
|
||||
)
|
||||
: null,
|
||||
result: () => rpcResult,
|
||||
};
|
||||
}
|
||||
@@ -444,6 +453,176 @@ describe("one approval, one broadcast", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Populating the transaction before the approval window opens is what makes
|
||||
// the displayed object the verified object. It also fixes the nonce before the
|
||||
// user has answered anything: two requests populated concurrently take the
|
||||
// same nonce from a node that has seen neither of them broadcast, and the
|
||||
// second can then never be sent, because the only way to give it a fresh nonce
|
||||
// is to populate it again after the user has read the old one off the screen.
|
||||
// So the second request is refused while the first is unanswered.
|
||||
describe("one transaction approval at a time", () => {
|
||||
test("a second eth_sendTransaction while one is pending is refused before it takes a nonce", async () => {
|
||||
const getTransactionCount = jest.fn(async () => NONCE);
|
||||
const bg = loadBackground({ provider: { getTransactionCount } });
|
||||
|
||||
const first = bg.requestTx();
|
||||
await settle();
|
||||
expect(first.id()).toBeTruthy();
|
||||
expect(getTransactionCount).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
expect(second.result()).toEqual({
|
||||
error: {
|
||||
code: -32002,
|
||||
message: expect.stringMatching(
|
||||
/already waiting to be approved/,
|
||||
),
|
||||
},
|
||||
});
|
||||
// Where the refusal happened matters as much as that it happened: no
|
||||
// second window, and the node was never asked for a second nonce.
|
||||
expect(bg.created).toHaveLength(1);
|
||||
expect(getTransactionCount).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The refusal leaves the pending approval untouched, and it still
|
||||
// sends.
|
||||
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: first.id(),
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(NONCE),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(first.result()).toEqual({ result: "0xfeed" });
|
||||
});
|
||||
|
||||
test("an answered approval frees the next request", async () => {
|
||||
const bg = loadBackground();
|
||||
const first = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
// The user closes the approval window, which rejects it.
|
||||
bg.closeWindow(1);
|
||||
await settle();
|
||||
expect(first.result()).toEqual({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
|
||||
const second = bg.requestTx();
|
||||
await settle();
|
||||
expect(second.id()).toBeTruthy();
|
||||
expect(bg.created).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a signature request is not held up by a pending transaction", async () => {
|
||||
const bg = loadBackground();
|
||||
bg.requestTx();
|
||||
await settle();
|
||||
|
||||
// A signature consumes no nonce, so it has nothing to collide with.
|
||||
const signing = bg.requestSign();
|
||||
await settle();
|
||||
expect(signing.id()).toBeTruthy();
|
||||
expect(signing.result()).toBeNull();
|
||||
expect(bg.created).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// A nonce collision found before the transaction reaches the network is the
|
||||
// one send failure the wallet can speak about with certainty. The user is told
|
||||
// it did not go out and to send it again, rather than being warned it might
|
||||
// already be on the chain — which would send them looking for a transaction
|
||||
// that does not exist, and stop them retrying the one that never went.
|
||||
describe("a nonce collision is reported as a transaction that did not go out", () => {
|
||||
test("a broadcast the node refused for the nonce is not reported as possibly sent", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
bg.broadcastTransaction.mockRejectedValue(
|
||||
Object.assign(new Error("nonce too low"), {
|
||||
code: "NONCE_EXPIRED",
|
||||
}),
|
||||
);
|
||||
const answer = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: pending.id(),
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(NONCE),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(answer.sendResponse).toHaveBeenCalledWith({
|
||||
error: expect.stringMatching(/nonce had already been used/),
|
||||
retryable: false,
|
||||
stage: "nonce",
|
||||
});
|
||||
expect(pending.result()).toEqual({
|
||||
error: {
|
||||
message: expect.stringMatching(
|
||||
/transaction was not sent, because its nonce/,
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("a nonce this wallet already broadcast is refused without asking the node again", async () => {
|
||||
const bg = loadBackground();
|
||||
const first = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: first.id(),
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(NONCE),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(first.result()).toEqual({ result: "0xfeed" });
|
||||
|
||||
// The stubbed node still reports NONCE as the next nonce — a pending
|
||||
// count that lags a broadcast the node has already taken — so this
|
||||
// second approval is populated at a nonce this worker has spent.
|
||||
const second = bg.requestTx();
|
||||
await settle();
|
||||
const answer = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: second.id(),
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(NONCE),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(answer.sendResponse).toHaveBeenCalledWith({
|
||||
error: expect.stringMatching(/nonce had already been used/),
|
||||
retryable: false,
|
||||
stage: "nonce",
|
||||
});
|
||||
expect(second.result()).toEqual({
|
||||
error: {
|
||||
message: expect.stringMatching(/nonce had already been used/),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The approval carries the transaction the user was shown and the address it
|
||||
// was raised for, and the artifact is checked against both. Every case here is
|
||||
// one the old comparison — against the dApp's request, for the address that is
|
||||
|
||||
Reference in New Issue
Block a user