fix: the wait and error screens show a swap's Permit2 allowance amounts (closes #733)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A swap can give another spender an allowance that its Amount figure leaves
out, and these two screens did not show it. Both now show each Permit2
allowance amount under the figure, with the Moved from the sender amounts.
movedFromSenderHtml() is now movedOrAllowedHtml(), as it shows both.

An amount too long for one line now wraps on these screens, rather than losing
its last digits at the screen's edge.

Model: opus-5-5
This commit was merged in pull request #737.
This commit is contained in:
2026-11-03 09:14:23 +01:00
parent 4531e8bce5
commit 1ea0730a81
5 changed files with 165 additions and 13 deletions
+2
View File
@@ -787,6 +787,7 @@ Shown after a broadcast. The screen holds:
- the heading "Transaction Broadcast", with no "Back" button;
- the amount and symbol;
- each `Moved from the sender` amount of a swap;
- each `Permit2 allowance` amount of a swap;
- the recipient;
- the transaction hash, with an explorer link;
- the counter "Waiting for confirmation... Ns".
@@ -826,6 +827,7 @@ The screen holds:
- the heading "Transaction Failed";
- the amount;
- each `Moved from the sender` amount of a swap;
- each `Permit2 allowance` amount of a swap;
- the recipient;
- the error message, in a dashed box;
- the transaction hash, when one exists;
+5 -2
View File
@@ -810,7 +810,7 @@
<h2 class="font-bold mb-2">Transaction Broadcast</h2>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="wait-tx-summary" class="font-bold"></div>
<div id="wait-tx-summary" class="font-bold break-all"></div>
<div id="wait-tx-token-note"></div>
</div>
<div class="mb-3">
@@ -857,7 +857,10 @@
<h2 class="font-bold mb-2">Transaction Failed</h2>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="error-tx-summary" class="font-bold"></div>
<div
id="error-tx-summary"
class="font-bold break-all"
></div>
<div id="error-tx-token-note"></div>
</div>
<div class="mb-3">
+12 -11
View File
@@ -73,24 +73,25 @@ function toAddressHtml(address, chainId) {
return renderAddressHtml(address, { title, chainId });
}
// The amount of each `Moved from the sender` line of a decoded swap, for the
// wait and error screens to show under the Amount figure. That figure can leave
// these amounts out, so alone it can read as less than can have left the
// wallet. `decoded` can come back from storage in any shape, so this shows only
// the lines whose value is text.
function movedFromSenderHtml(decoded) {
// The amount of each `Moved from the sender` and `Permit2 allowance` line of a
// decoded swap, for the wait and error screens to show under the Amount figure.
// That figure can leave these amounts out, so alone it can understate what can
// leave the wallet. `decoded` can come back from storage in any shape, so this
// shows only the lines whose value is text.
function movedOrAllowedHtml(decoded) {
if (!decoded || !Array.isArray(decoded.details)) return "";
let html = "";
for (const d of decoded.details) {
if (
!d ||
d.label !== "Moved from the sender" ||
(d.label !== "Moved from the sender" &&
d.label !== "Permit2 allowance") ||
typeof d.value !== "string"
)
continue;
html += `<div class="mt-2">`;
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
html += `<div class="font-bold break-all">${escapeHtml(d.value)}</div>`;
html += `</div>`;
}
return html;
@@ -134,7 +135,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-token-note").innerHTML =
blockscoutOffTokenNoteHtml(txInfo.token, txInfo.chainId) +
movedFromSenderHtml(txInfo.decoded);
movedOrAllowedHtml(txInfo.decoded);
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
attachCopyHandlers("view-wait-tx");
@@ -245,7 +246,7 @@ function showWait(txInfo, txHash) {
// to name a network. txInfo.token and txInfo.tokenSymbol are not checked here.
// blockscoutOffTokenNoteHtml() shows no line for a token that is not text.
// Everything else only compares and coalesces them, and tokenSymbol is null for
// ETH. txInfo.decoded is not checked either: see movedFromSenderHtml().
// ETH. txInfo.decoded is not checked either: see movedOrAllowedHtml().
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -391,7 +392,7 @@ function renderError() {
d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-token-note").innerHTML =
blockscoutOffTokenNoteHtml(d.token, d.chainId) +
movedFromSenderHtml(d.decoded);
movedOrAllowedHtml(d.decoded);
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-message").textContent = d.message;
+74
View File
@@ -412,3 +412,77 @@ describe("a swap with a Moved from the sender line of its own", () => {
expectMovedLine("error-tx-token-note");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/733: a PERMIT2_PERMIT that gives
// another spender an unlimited USDC allowance, then a V4 swap of 1 ETH. The
// Amount line reads 1.0000 ETH and says nothing of the allowance.
describe("a swap that gives another spender an allowance", () => {
const OTHER_SPENDER = "0x1111111111111111111111111111111111111111";
const MAX_UINT160 = (1n << 160n) - 1n;
const SEND_FAILS = {
error: "The transaction could not be sent.",
retryable: false,
};
const data = routerIface.encodeFunctionData("execute", [
"0x0a10",
[
coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
[
[[USDC, MAX_UINT160, 0, 0], OTHER_SPENDER, 9999999999n],
"0x1234",
],
),
coder.encode(
["bytes", "bytes[]"],
[
new Uint8Array([SWAP_EXACT_IN_SINGLE, SETTLE, TAKE]),
[
coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[ETH_USDC_POOL, true, ONE_ETH, 990000n, "0x"]],
),
settleEth,
takeUsdc,
],
],
),
],
9999999999n,
]);
function expectAllowanceLine(id) {
const html = node(id).innerHTML;
expect(html).toContain("Permit2 allowance");
expect(html).toContain("Unlimited");
}
test("the wait screen shows the allowance under the Amount figure", async () => {
await approveSwap(data, { txHash: TX_HASH }, ONE_ETH);
expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH");
expectAllowanceLine("wait-tx-token-note");
});
test("the error screen shows the allowance under the Amount figure", async () => {
await approveSwap(data, SEND_FAILS, ONE_ETH);
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
expectAllowanceLine("error-tx-token-note");
});
test("a reopened popup's wait screen still shows it", async () => {
await approveSwap(data, { txHash: TX_HASH }, ONE_ETH);
node("wait-tx-token-note").innerHTML = "";
txStatus.restoreWait();
expectAllowanceLine("wait-tx-token-note");
});
test("a reopened popup's error screen still shows it", async () => {
await approveSwap(data, SEND_FAILS, ONE_ETH);
node("error-tx-token-note").innerHTML = "";
txStatus.renderError();
expectAllowanceLine("error-tx-token-note");
});
});
+72
View File
@@ -101,6 +101,9 @@ global.document = {
global.window = { location: { search: "" } };
const fs = require("fs");
const path = require("path");
// Clones in both directions, as the real chrome.storage.local does; the stub
// here used to hand back the live stored object, so an in-memory mutation
// looked like a write that had reached storage. See
@@ -743,6 +746,7 @@ describe("the Moved from the sender lines of a swap on WaitTx and ErrorTx", () =
{ details: [{ label: "Moved from the sender" }] },
{ details: [moved(7)] },
{ details: [moved({ toString: 1 })] },
{ details: [{ label: "Permit2 allowance", value: 7 }] },
]) {
expect(noteOnReopen(saved)).toBe("");
}
@@ -752,6 +756,74 @@ describe("the Moved from the sender lines of a swap on WaitTx and ErrorTx", () =
});
});
describe("the Permit2 allowance lines of a swap on WaitTx and ErrorTx", () => {
const allowance = (value) => ({ label: "Permit2 allowance", value });
const decoded = {
details: [
allowance("Unlimited"),
{ label: "Allowance token", value: "USDC" },
allowance("<b>400.0000</b> DAI"),
],
};
function expectEveryLine(id) {
const html = getElement(id).innerHTML;
expect(html).toContain("Permit2 allowance");
expect(html).toContain("Unlimited");
expect(html).toContain("&lt;b&gt;400.0000&lt;/b&gt; DAI");
expect(html).not.toContain("Allowance token");
}
test("WaitTx shows each line's amount, escaped", () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
expectEveryLine("wait-tx-token-note");
});
test("ErrorTx shows each line's amount, escaped", () => {
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
expectEveryLine("error-tx-token-note");
});
test("a wait that ends in ErrorTx keeps them", async () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(60000);
expect(state.currentView).toBe("error-tx");
expectEveryLine("error-tx-token-note");
});
});
// An amount of about 47 digits or more is wider than the screen. Without the
// break-all class its last digits are cut off at the edge.
describe("a long amount on WaitTx and ErrorTx", () => {
test("each amount under the Amount figure wraps", () => {
const decoded = {
details: [
{ label: "Moved from the sender", value: "1.0000 ETH" },
{ label: "Permit2 allowance", value: "Unlimited" },
],
};
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
const html = getElement("error-tx-token-note").innerHTML;
expect(html).toContain('<div class="font-bold break-all">1.0000 ETH<');
expect(html).toContain('<div class="font-bold break-all">Unlimited<');
});
test("the Amount figure wraps", () => {
const html = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
expect(html).toMatch(
/id="wait-tx-summary"\s+class="font-bold break-all"/,
);
expect(html).toMatch(
/id="error-tx-summary"\s+class="font-bold break-all"/,
);
});
});
describe("wait-tx is a view the popup may reopen onto", () => {
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
// refuses any view not in the set, so dropping "wait-tx" from it kills