fix: the wait and error screens show a swap's Moved from the sender amounts (closes #722)
A swap's Amount figure can leave out what its Moved from the sender lines take, so these screens could read as if less left the wallet than can have left. Both now show each of those amounts under the figure, using the decoded details they already receive. The error screen saves those details too, so a reopened popup keeps the lines. A saved record in another shape shows only the lines it can read. Model: opus-5-5
This commit was merged in pull request #732.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 += `<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>`;
|
||||
}
|
||||
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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("<b>400.0000</b> 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
|
||||
|
||||
Reference in New Issue
Block a user