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..d0675fc 100644 --- a/src/popup/views/txStatus.js +++ b/src/popup/views/txStatus.js @@ -73,6 +73,29 @@ 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) { + if (!decoded || !Array.isArray(decoded.details)) return ""; + let html = ""; + for (const d of decoded.details) { + if ( + !d || + d.label !== "Moved from the sender" || + typeof d.value !== "string" + ) + 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 +132,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 +245,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 +378,7 @@ function showError(txInfo, txHash, message) { to: txInfo.to, hash: txHash || null, message: message, + decoded: txInfo.decoded || null, chainId: txInfo.chainId, }; renderError(); @@ -366,10 +389,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..d7a02ea 100644 --- a/tests/txStatus.test.js +++ b/tests/txStatus.test.js @@ -672,6 +672,86 @@ 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] }, + { details: [{ label: "Moved from the sender" }] }, + { details: [moved(7)] }, + { details: [moved({ toString: 1 })] }, + ]) { + 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