`;
- if (decoded.name) {
+ if (decoded.name && typeof decoded.name === "string") {
html += `
`;
}
- if (decoded.description) {
+ if (decoded.description && typeof decoded.description === "string") {
html += `
`;
}
- for (const d of decoded.details) {
+ for (const d of lines) {
html += `
`;
html += `
${escapeHtml(d.label)}
`;
if (d.address) {
- if (d.isToken) {
- const sym = tokenLabel(d.address, chainId) || "Unknown token";
- html += `
${escapeHtml(sym)}
`;
- html += blockscoutOffTokenNoteHtml(d.address, chainId);
- html += toAddressHtml(d.address, chainId);
- } else {
- html += toAddressHtml(d.address, chainId);
- }
+ html += decodedAddressHtml(d, chainId);
} else {
html += `
${escapeHtml(d.value)}
`;
}
@@ -329,13 +353,13 @@ function renderSuccess() {
const d = state.viewData;
if (!d || !d.hash) return;
- const hasDecoded = d.decoded && d.decoded.details;
+ const decodedHtml = decodedDetailsHtml(d.decoded, d.chainId);
// When decoded details are present, the Amount and To are already
// shown inside the decoded well — hide the top-level duplicates.
const summarySection = $("success-tx-summary").parentElement;
const toSection = $("success-tx-to").parentElement;
- if (hasDecoded) {
+ if (decodedHtml) {
summarySection.classList.add("hidden");
toSection.classList.add("hidden");
} else {
@@ -351,8 +375,8 @@ function renderSuccess() {
// Show decoded calldata details if present
const decodedEl = $("success-tx-decoded");
- if (decodedEl && hasDecoded) {
- decodedEl.innerHTML = decodedDetailsHtml(d.decoded, d.chainId);
+ if (decodedEl && decodedHtml) {
+ decodedEl.innerHTML = decodedHtml;
decodedEl.classList.remove("hidden");
} else if (decodedEl) {
decodedEl.classList.add("hidden");
@@ -392,18 +416,18 @@ function renderError() {
d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-token-note").innerHTML =
blockscoutOffTokenNoteHtml(d.token, d.chainId) +
- movedOrAllowedHtml(d.decoded);
+ movedOrAllowedHtml(d.decoded, d.chainId);
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-message").textContent = d.message;
if (d.hash) {
$("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
$("error-tx-hash-section").classList.remove("hidden");
- attachCopyHandlers("view-error-tx");
} else {
$("error-tx-hash-section").classList.add("hidden");
}
+ attachCopyHandlers("view-error-tx");
showView("error-tx");
}
diff --git a/tests/swapStatusAmount.test.js b/tests/swapStatusAmount.test.js
index a54ff5c..10a3159 100644
--- a/tests/swapStatusAmount.test.js
+++ b/tests/swapStatusAmount.test.js
@@ -458,6 +458,9 @@ describe("a swap that gives another spender an allowance", () => {
const html = node(id).innerHTML;
expect(html).toContain("Permit2 allowance");
expect(html).toContain("Unlimited");
+ expect(html).toContain("Allowance token");
+ expect(html).toContain('
USDC
');
+ expect(html).toContain(`data-copy="${USDC}"`);
}
test("the wait screen shows the allowance under the Amount figure", async () => {
diff --git a/tests/txStatus.test.js b/tests/txStatus.test.js
index 7c97782..3d3e4c8 100644
--- a/tests/txStatus.test.js
+++ b/tests/txStatus.test.js
@@ -617,8 +617,8 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
const decoded = {
name: "Transfer",
details: [
- { label: "Token", address: TOKEN, isToken: true },
- { label: "Recipient", address: RECIPIENT },
+ { label: "Token", value: TOKEN, address: TOKEN, isToken: true },
+ { label: "Recipient", value: RECIPIENT, address: RECIPIENT },
],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
@@ -644,7 +644,9 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
};
const decoded = {
name: "Transfer",
- details: [{ label: "Token", address: TOKEN, isToken: true }],
+ details: [
+ { label: "Token", value: TOKEN, address: TOKEN, isToken: true },
+ ],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
@@ -747,6 +749,21 @@ describe("the Moved from the sender lines of a swap on WaitTx and ErrorTx", () =
{ details: [moved(7)] },
{ details: [moved({ toString: 1 })] },
{ details: [{ label: "Permit2 allowance", value: 7 }] },
+ {
+ details: [
+ { label: "Permit2 allowance", value: "text", address: 7 },
+ ],
+ },
+ {
+ details: [
+ {
+ label: "Allowance token",
+ value: "text",
+ address: 7,
+ isToken: true,
+ },
+ ],
+ },
]) {
expect(noteOnReopen(saved)).toBe("");
}
@@ -757,11 +774,22 @@ 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 USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const allowance = (value) => ({ label: "Permit2 allowance", value });
const decoded = {
details: [
allowance("Unlimited"),
- { label: "Allowance token", value: "USDC" },
+ {
+ label: "Allowance token",
+ value: "USDC (" + USDC + ")",
+ address: USDC,
+ isToken: true,
+ },
+ {
+ label: "Allowance spender",
+ value: RECIPIENT,
+ address: RECIPIENT,
+ },
allowance("
400.0000 DAI"),
],
};
@@ -771,9 +799,53 @@ describe("the Permit2 allowance lines of a swap on WaitTx and ErrorTx", () => {
expect(html).toContain("Permit2 allowance");
expect(html).toContain("Unlimited");
expect(html).toContain("<b>400.0000</b> DAI");
- expect(html).not.toContain("Allowance token");
+ expect(html).not.toContain("Allowance spender");
+ expect(html).not.toContain(RECIPIENT);
}
+ // `Unlimited` alone does not say which token it covers. The address gets
+ // the row of its own that every address gets, in the transaction's network.
+ function expectTokenLine(id) {
+ const html = getElement(id).innerHTML;
+ expect(html.indexOf("Unlimited")).toBeLessThan(
+ html.indexOf("Allowance token"),
+ );
+ expect(html).toContain('
USDC
');
+ expect(html).toContain(`data-copy="${USDC}"`);
+ expect(html).toContain(`href="https://etherscan.io/address/${USDC}"`);
+ expect(html).toMatch(/
.*class="am-address"/);
+ }
+
+ test("WaitTx says which token an allowance covers", () => {
+ mockReceiptLookup.mockResolvedValue(null);
+ txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
+ expectTokenLine("wait-tx-token-note");
+ });
+
+ test("ErrorTx says which token an allowance covers", () => {
+ txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
+ expectTokenLine("error-tx-token-note");
+ });
+
+ test("the token's address links to the transaction's network", () => {
+ const sepoliaTx = {
+ ...TX_INFO,
+ chainId: NETWORKS.sepolia.chainId,
+ decoded,
+ };
+ txStatus.showError(sepoliaTx, TX_HASH, "It failed.");
+ expect(getElement("error-tx-token-note").innerHTML).toContain(
+ `href="https://sepolia.etherscan.io/address/${USDC}"`,
+ );
+ });
+
+ test("the token's address can be copied on an ErrorTx with no hash", () => {
+ const view = getElement("view-error-tx");
+ view.querySelectorAll = jest.fn(() => []);
+ txStatus.showError({ ...TX_INFO, decoded }, null, "It failed.");
+ expect(view.querySelectorAll).toHaveBeenCalledWith("[data-copy]");
+ });
+
test("WaitTx shows each line's amount, escaped", () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
@@ -795,7 +867,8 @@ describe("the Permit2 allowance lines of a swap on WaitTx and ErrorTx", () => {
});
// 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.
+// break-words class its last digits are cut off at the edge. That class keeps
+// the words of a line such as `(decimals unknown)` whole.
describe("a long amount on WaitTx and ErrorTx", () => {
test("each amount under the Amount figure wraps", () => {
const decoded = {
@@ -806,8 +879,10 @@ describe("a long amount on WaitTx and ErrorTx", () => {
};
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
const html = getElement("error-tx-token-note").innerHTML;
- expect(html).toContain('
1.0000 ETH<');
- expect(html).toContain('
Unlimited<');
+ expect(html).toContain(
+ '
1.0000 ETH<',
+ );
+ expect(html).toContain('
Unlimited<');
});
test("the Amount figure wraps", () => {
@@ -816,14 +891,87 @@ describe("a long amount on WaitTx and ErrorTx", () => {
"utf8",
);
expect(html).toMatch(
- /id="wait-tx-summary"\s+class="font-bold break-all"/,
+ /id="wait-tx-summary"\s+class="font-bold break-words"/,
);
expect(html).toMatch(
- /id="error-tx-summary"\s+class="font-bold break-all"/,
+ /id="error-tx-summary"\s+class="font-bold break-words"/,
);
});
});
+// A saved wait comes back from storage, in whatever shape it is in. A throw when
+// its receipt arrives would leave the popup on WaitTx with the counter stopped.
+describe("a saved wait in another shape when its receipt arrives", () => {
+ const LINE = { label: "Steps", value: "V3_SWAP_EXACT_IN" };
+
+ async function receiptForSavedWait(decoded) {
+ mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
+ state.viewData = {
+ pendingWait: {
+ hash: TX_HASH,
+ txInfo: { ...TX_INFO, decoded },
+ broadcastTime: Date.now(),
+ },
+ };
+ expect(txStatus.restoreWait()).toBe(true);
+ await jest.advanceTimersByTimeAsync(0);
+ }
+
+ test.each([
+ { details: 7 },
+ { details: "text" },
+ { details: { label: "Steps", value: "text" } },
+ { details: [null] },
+ { details: [7] },
+ { details: ["text"] },
+ { details: [{ value: "text" }] },
+ { details: [{ label: 7, value: "text" }] },
+ { details: [{ label: { toString: 1 }, value: "text" }] },
+ { details: [{ label: "Steps" }] },
+ { details: [{ label: "Steps", value: 7 }] },
+ { details: [{ label: "Steps", value: { toString: 1 } }] },
+ { details: [{ label: "Steps", address: RECIPIENT }] },
+ { details: [{ label: "Steps", value: "text", address: 7 }] },
+ {
+ details: [
+ { label: "Steps", value: "text", address: 7, isToken: true },
+ ],
+ },
+ { name: "Swap", description: "A swap", details: [null] },
+ ])("%j shows SuccessTx with the amount and recipient", async (decoded) => {
+ await receiptForSavedWait(decoded);
+ expect(state.currentView).toBe("success-tx");
+ expect(
+ getElement("success-tx-decoded").classList.contains("hidden"),
+ ).toBe(true);
+ expect(
+ getElement("success-tx-summary-parent").classList.contains(
+ "hidden",
+ ),
+ ).toBe(false);
+ expect(getElement("success-tx-summary").textContent).toBe("0.0050 ETH");
+ expect(
+ getElement("success-tx-to-parent").classList.contains("hidden"),
+ ).toBe(false);
+ expect(getElement("success-tx-to").innerHTML).toContain(RECIPIENT);
+ });
+
+ test.each([
+ { details: [null, LINE, 7] },
+ { name: 7, details: [LINE] },
+ { name: { toString: 1 }, details: [LINE] },
+ { description: 7, details: [LINE] },
+ { description: { toString: 1 }, details: [LINE] },
+ ])("%j shows SuccessTx with the line it can read", async (decoded) => {
+ await receiptForSavedWait(decoded);
+ expect(state.currentView).toBe("success-tx");
+ const html = getElement("success-tx-decoded").innerHTML;
+ expect(html).toContain("V3_SWAP_EXACT_IN");
+ expect(html).not.toContain("Action");
+ expect(html).not.toContain("Description");
+ });
+});
+
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