diff --git a/README.md b/README.md
index eccafec..92a35af 100644
--- a/README.md
+++ b/README.md
@@ -786,6 +786,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;
- the recipient;
- the transaction hash, with an explorer link;
- the counter "Waiting for confirmation... Ns".
@@ -823,7 +824,9 @@ Shown when signing or a broadcast fails, or when a wait ends without a receipt.
The screen holds:
- the heading "Transaction Failed";
-- the amount and the recipient;
+- the amount;
+- each `Moved from the sender` amount of a swap;
+- the recipient;
- the error message, in a dashed box;
- the transaction hash, when one exists;
- a "Done" button that behaves as on SuccessTx.
diff --git a/src/popup/views/txStatus.js b/src/popup/views/txStatus.js
index 3653b32..1b591b0 100644
--- a/src/popup/views/txStatus.js
+++ b/src/popup/views/txStatus.js
@@ -73,6 +73,24 @@ 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, and a shape the
+// decoder does not write shows no line here.
+function movedFromSenderHtml(decoded) {
+ if (!decoded || !Array.isArray(decoded.details)) return "";
+ let html = "";
+ for (const d of decoded.details) {
+ if (!d || d.label !== "Moved from the sender") continue;
+ html += `
`;
+ html += `
${escapeHtml(d.label)}
`;
+ html += `
${escapeHtml(d.value)}
`;
+ html += `
`;
+ }
+ return html;
+}
+
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
@@ -109,10 +127,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
- $("wait-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
- txInfo.token,
- txInfo.chainId,
- );
+ $("wait-tx-token-note").innerHTML =
+ blockscoutOffTokenNoteHtml(txInfo.token, txInfo.chainId) +
+ movedFromSenderHtml(txInfo.decoded);
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
attachCopyHandlers("view-wait-tx");
@@ -223,7 +240,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.
+// ETH. txInfo.decoded is not checked either: see movedFromSenderHtml().
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -356,6 +373,7 @@ function showError(txInfo, txHash, message) {
to: txInfo.to,
hash: txHash || null,
message: message,
+ decoded: txInfo.decoded || null,
chainId: txInfo.chainId,
};
renderError();
@@ -366,10 +384,9 @@ function renderError() {
if (!d || !d.message) return;
$("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
- $("error-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
- d.token,
- d.chainId,
- );
+ $("error-tx-token-note").innerHTML =
+ blockscoutOffTokenNoteHtml(d.token, d.chainId) +
+ movedFromSenderHtml(d.decoded);
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-message").textContent = d.message;
diff --git a/tests/swapStatusAmount.test.js b/tests/swapStatusAmount.test.js
index 3dd8d19..dfac9a3 100644
--- a/tests/swapStatusAmount.test.js
+++ b/tests/swapStatusAmount.test.js
@@ -340,3 +340,75 @@ describe.each([
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
});
});
+
+// https://git.eeqj.de/sneak/AutistMask/issues/722: a WRAP_ETH of 1 ETH into the
+// router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH the user
+// settles. The Amount line reads 1.0000 ETH, though up to 2 ETH can leave. The
+// rest is on the swap's own Moved from the sender line.
+describe("a swap with a Moved from the sender line of its own", () => {
+ const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
+ const TWO_ETH = 2n * ONE_ETH;
+ const SEND_FAILS = {
+ error: "The transaction could not be sent.",
+ retryable: false,
+ };
+ const data = routerIface.encodeFunctionData("execute", [
+ "0x0b0c10",
+ [
+ // WRAP_ETH and UNWRAP_WETH have the same layout.
+ coder.encode(["address", "uint256"], [ADDRESS_THIS, ONE_ETH]),
+ coder.encode(["address", "uint256"], [ADDRESS_THIS, 0n]),
+ coder.encode(
+ ["bytes", "bytes[]"],
+ [
+ new Uint8Array([SWAP_EXACT_IN_SINGLE, SETTLE, TAKE_ALL]),
+ [
+ coder.encode(
+ [`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
+ [[ETH_USDC_POOL, true, ONE_ETH, 990000n, "0x"]],
+ ),
+ // The user pays this one, not the router.
+ coder.encode(
+ ["address", "uint256", "bool"],
+ [ZERO, 0n, true],
+ ),
+ takeAllUsdc,
+ ],
+ ],
+ ),
+ ],
+ 9999999999n,
+ ]);
+
+ function expectMovedLine(id) {
+ const html = node(id).innerHTML;
+ expect(html).toContain("Moved from the sender");
+ expect(html).toContain("Up to 1.0000 ETH");
+ }
+
+ test("the wait screen shows that line's amount under the Amount figure", async () => {
+ await approveSwap(data, { txHash: TX_HASH }, TWO_ETH);
+ expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH");
+ expectMovedLine("wait-tx-token-note");
+ });
+
+ test("the error screen shows that line's amount under the Amount figure", async () => {
+ await approveSwap(data, SEND_FAILS, TWO_ETH);
+ expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
+ expectMovedLine("error-tx-token-note");
+ });
+
+ test("a reopened popup's wait screen still shows it", async () => {
+ await approveSwap(data, { txHash: TX_HASH }, TWO_ETH);
+ node("wait-tx-token-note").innerHTML = "";
+ txStatus.restoreWait();
+ expectMovedLine("wait-tx-token-note");
+ });
+
+ test("a reopened popup's error screen still shows it", async () => {
+ await approveSwap(data, SEND_FAILS, TWO_ETH);
+ node("error-tx-token-note").innerHTML = "";
+ txStatus.renderError();
+ expectMovedLine("error-tx-token-note");
+ });
+});
diff --git a/tests/txStatus.test.js b/tests/txStatus.test.js
index f2ce665..69c46fb 100644
--- a/tests/txStatus.test.js
+++ b/tests/txStatus.test.js
@@ -672,6 +672,78 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
});
});
+describe("the Moved from the sender lines of a swap on WaitTx and ErrorTx", () => {
+ const moved = (value) => ({ label: "Moved from the sender", value });
+ const decoded = {
+ details: [
+ moved("Up to 1.0000 ETH"),
+ { label: "Token moved", value: "ETH" },
+ moved("400.0000 DAI"),
+ ],
+ };
+
+ function expectEveryLine(id) {
+ const html = getElement(id).innerHTML;
+ expect(html).toContain("Up to 1.0000 ETH");
+ expect(html).toContain("<b>400.0000</b> DAI");
+ expect(html).not.toContain("Token moved");
+ }
+
+ 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");
+ });
+
+ // A saved record comes back from storage, in whatever shape it is in. A
+ // throw here would leave the popup's restore unfinished.
+ test("a saved record in another shape shows only the lines it can read", () => {
+ mockReceiptLookup.mockResolvedValue(null);
+ function noteOnReopen(saved) {
+ state.viewData = {
+ pendingWait: {
+ hash: TX_HASH,
+ txInfo: { ...TX_INFO, decoded: saved },
+ broadcastTime: Date.now(),
+ },
+ };
+ expect(txStatus.restoreWait()).toBe(true);
+ const waitNote = getElement("wait-tx-token-note").innerHTML;
+ txStatus.endWait();
+
+ state.viewData = {
+ message: "It failed.",
+ to: RECIPIENT,
+ chainId: TX_INFO.chainId,
+ decoded: saved,
+ };
+ txStatus.renderError();
+ expect(getElement("error-tx-token-note").innerHTML).toBe(waitNote);
+ return waitNote;
+ }
+
+ for (const saved of [7, "text", { details: 7 }, { details: [null] }]) {
+ expect(noteOnReopen(saved)).toBe("");
+ }
+ expect(
+ noteOnReopen({ details: [null, moved("Up to 1.0000 ETH")] }),
+ ).toContain("Up to 1.0000 ETH");
+ });
+});
+
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