fix: WaitTx and TransactionDetail use the network the transaction was sent on (closes #584)
WaitTx asked the active network's node for the receipt, so after a network switch a transaction sent on the other network was never found and was reported as timed out. It now asks the node of the network its chain id names, through reviewedNetworkProvider(), moved from ConfirmTx into the shared view helpers so both screens use one function. WaitTx and TransactionDetail build their explorer links for that network too. A stored wait or transaction whose chain id names no network is refused on restore, as a stored ConfirmTx transaction already is. Model: opus-5-5
This commit was merged in pull request #621.
This commit is contained in:
@@ -1891,14 +1891,17 @@ view would leave a wallet one click from deletion.
|
|||||||
no recipient." instead
|
no recipient." instead
|
||||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||||
- Count-up timer: "Waiting for confirmation... Ns"
|
- Count-up timer: "Waiting for confirmation... Ns"
|
||||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The screen
|
||||||
persisted: closing and reopening the popup resumes the poll, with the elapsed
|
describes the transaction on the network it was sent on, whichever network is
|
||||||
counter and the timeout deadline still measured from the original broadcast. A
|
active: the receipt is asked of that network's RPC endpoint, the one Settings
|
||||||
lookup that fails is retried on the next tick rather than counted as a missing
|
has for it, and the To and hash links open that network's explorer. The wait
|
||||||
receipt, because a failed lookup says nothing about the transaction; but six
|
is persisted: closing and reopening the popup resumes the poll, with the
|
||||||
failures in a row (60 seconds at the poll cadence) end the wait, so an RPC
|
elapsed counter and the timeout deadline still measured from the original
|
||||||
that never answers cannot leave it running indefinitely. Any lookup that
|
broadcast. A lookup that fails is retried on the next tick rather than counted
|
||||||
answers resets that count.
|
as a missing receipt, because a failed lookup says nothing about the
|
||||||
|
transaction; but six failures in a row (60 seconds at the poll cadence) end
|
||||||
|
the wait, so an RPC that never answers cannot leave it running indefinitely.
|
||||||
|
Any lookup that answers resets that count.
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Receipt found → **SuccessTx**
|
- Receipt found → **SuccessTx**
|
||||||
- A lookup that answers "no receipt" 60 seconds or more after broadcast →
|
- A lookup that answers "no receipt" 60 seconds or more after broadcast →
|
||||||
@@ -1963,7 +1966,8 @@ view would leave a wallet one click from deletion.
|
|||||||
#### TransactionDetail (`transaction`)
|
#### TransactionDetail (`transaction`)
|
||||||
|
|
||||||
- **When**: User tapped a transaction row on Home, AddressDetail, or
|
- **When**: User tapped a transaction row on Home, AddressDetail, or
|
||||||
AddressToken.
|
AddressToken. Its etherscan links open the explorer of the network the
|
||||||
|
transaction was sent on, whichever network is active.
|
||||||
- **Elements** (grouped into logical blocks using light well containers; field
|
- **Elements** (grouped into logical blocks using light well containers; field
|
||||||
labels are self-explanatory so groups have no headings):
|
labels are self-explanatory so groups have no headings):
|
||||||
- "Transaction" heading, "Back" button
|
- "Transaction" heading, "Back" button
|
||||||
|
|||||||
@@ -145,12 +145,15 @@ function isRenderablePendingTx(value) {
|
|||||||
|
|
||||||
// The stored transaction transactionDetail.render() shows. contractAddress is
|
// The stored transaction transactionDetail.render() shows. contractAddress is
|
||||||
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
|
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
|
||||||
|
// `chainId` picks the network whose explorer the links open, so when it is
|
||||||
|
// there it has to name one.
|
||||||
function isRenderableTx(value) {
|
function isRenderableTx(value) {
|
||||||
return (
|
return (
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
isAddressText(value.from) &&
|
isAddressText(value.from) &&
|
||||||
isAddressText(value.to) &&
|
isAddressText(value.to) &&
|
||||||
(!value.contractAddress || isAddressText(value.contractAddress))
|
(!value.contractAddress || isAddressText(value.contractAddress)) &&
|
||||||
|
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ const {
|
|||||||
goBack,
|
goBack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
formatFee,
|
formatFee,
|
||||||
|
reviewedNetworkProvider,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentNetwork } = require("../../shared/state");
|
const { state, currentNetwork } = require("../../shared/state");
|
||||||
const {
|
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||||
networkByChainId,
|
|
||||||
nativeCurrencyByChainId,
|
|
||||||
} = require("../../shared/networks");
|
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
const { walletDefect } = require("../../shared/walletDefects");
|
const { walletDefect } = require("../../shared/walletDefects");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
@@ -458,20 +456,6 @@ function setVisible(id, visible) {
|
|||||||
$(id).style.visibility = visible ? "visible" : "hidden";
|
$(id).style.visibility = visible ? "visible" : "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
// The node of the network this transaction was reviewed for, which need not be
|
|
||||||
// the active one: the popup can reopen onto this screen after Settings switched
|
|
||||||
// the network. Its RPC URL is the one Settings has for that network: the
|
|
||||||
// active network's is state.rpcUrl, any other's the one remembered for it (see
|
|
||||||
// applyChainSwitchFields()).
|
|
||||||
function reviewedNetworkProvider(txInfo) {
|
|
||||||
const net = networkByChainId(txInfo.chainId);
|
|
||||||
if (net.id === state.networkId) {
|
|
||||||
return getProvider(state.rpcUrl, net.id);
|
|
||||||
}
|
|
||||||
const remembered = state.networkEndpoints[net.id] || {};
|
|
||||||
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function estimateGas(txInfo) {
|
async function estimateGas(txInfo) {
|
||||||
try {
|
try {
|
||||||
const provider = reviewedNetworkProvider(txInfo);
|
const provider = reviewedNetworkProvider(txInfo);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const {
|
|||||||
isBelowOneMillionth,
|
isBelowOneMillionth,
|
||||||
} = require("../../shared/amountDisplay");
|
} = require("../../shared/amountDisplay");
|
||||||
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
||||||
|
const { getProvider } = require("../../shared/balances");
|
||||||
const { DEBUG } = require("../../shared/constants");
|
const { DEBUG } = require("../../shared/constants");
|
||||||
const { escapeHtml } = require("../../shared/html");
|
const { escapeHtml } = require("../../shared/html");
|
||||||
const { isDebug } = require("../../shared/log");
|
const { isDebug } = require("../../shared/log");
|
||||||
@@ -607,6 +608,20 @@ const EXT_ICON =
|
|||||||
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
||||||
`</svg></span>`;
|
`</svg></span>`;
|
||||||
|
|
||||||
|
// The node of the network a transaction was reviewed for, the one its chain id
|
||||||
|
// names, which need not be the active one: the popup can reopen onto ConfirmTx
|
||||||
|
// or WaitTx after Settings switched the network. Its RPC URL is the one
|
||||||
|
// Settings has for that network: the active network's is state.rpcUrl, any
|
||||||
|
// other's the one remembered for it (see applyChainSwitchFields()).
|
||||||
|
function reviewedNetworkProvider(txInfo) {
|
||||||
|
const net = networkByChainId(txInfo.chainId);
|
||||||
|
if (net.id === state.networkId) {
|
||||||
|
return getProvider(state.rpcUrl, net.id);
|
||||||
|
}
|
||||||
|
const remembered = state.networkEndpoints[net.id] || {};
|
||||||
|
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Block-explorer URLs. The origin is a per-network constant from
|
// Block-explorer URLs. The origin is a per-network constant from
|
||||||
// src/shared/networks.js, the explorer of the network with `chainId`, or of
|
// src/shared/networks.js, the explorer of the network with `chainId`, or of
|
||||||
// the active network when no chain id is given; only the path segment is
|
// the active network when no chain id is given; only the path segment is
|
||||||
@@ -762,6 +777,7 @@ module.exports = {
|
|||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
etherscanLinkHtml,
|
etherscanLinkHtml,
|
||||||
explorerUrl,
|
explorerUrl,
|
||||||
|
reviewedNetworkProvider,
|
||||||
EXT_ICON,
|
EXT_ICON,
|
||||||
truncateMiddle,
|
truncateMiddle,
|
||||||
isoDate,
|
isoDate,
|
||||||
|
|||||||
@@ -48,16 +48,16 @@ function getTransactionType(tx) {
|
|||||||
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
|
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
|
||||||
}
|
}
|
||||||
|
|
||||||
function txAddressHtml(address, ensName, title) {
|
function txAddressHtml(address, ensName, title, chainId) {
|
||||||
const blockie = blockieHtml(address);
|
const blockie = blockieHtml(address);
|
||||||
return (
|
return (
|
||||||
`<div class="mb-1">${blockie}</div>` +
|
`<div class="mb-1">${blockie}</div>` +
|
||||||
renderAddressHtml(address, { title, ensName })
|
renderAddressHtml(address, { title, ensName, chainId })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function txHashHtml(hash) {
|
function txHashHtml(hash, chainId) {
|
||||||
const link = explorerUrl("tx", hash);
|
const link = explorerUrl("tx", hash, chainId);
|
||||||
const extLink = etherscanLinkHtml(link);
|
const extLink = etherscanLinkHtml(link);
|
||||||
return copyableHtml(hash, "break-all") + extLink;
|
return copyableHtml(hash, "break-all") + extLink;
|
||||||
}
|
}
|
||||||
@@ -83,10 +83,10 @@ function show(tx) {
|
|||||||
method: tx.method || null,
|
method: tx.method || null,
|
||||||
contractAddress: tx.contractAddress || null,
|
contractAddress: tx.contractAddress || null,
|
||||||
// The network the history entry was read from. The type line and
|
// The network the history entry was read from. The type line and
|
||||||
// the fee are in its native currency, not the active network's:
|
// the fee are in its native currency, not the active network's,
|
||||||
// the active network can change, in Settings or when the user
|
// and the links open its explorer: the active network can change,
|
||||||
// approves a site's request, before a later popup shows this
|
// in Settings or when the user approves a site's request, before
|
||||||
// screen again.
|
// a later popup shows this screen again.
|
||||||
chainId: tx.chainId,
|
chainId: tx.chainId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -96,18 +96,24 @@ function show(tx) {
|
|||||||
function render() {
|
function render() {
|
||||||
const tx = state.viewData.tx;
|
const tx = state.viewData.tx;
|
||||||
if (!tx) return;
|
if (!tx) return;
|
||||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash, tx.chainId);
|
||||||
|
|
||||||
const fromTitle = addressTitle(tx.from, state.wallets);
|
const fromTitle = addressTitle(tx.from, state.wallets);
|
||||||
$("tx-detail-from").innerHTML = txAddressHtml(
|
$("tx-detail-from").innerHTML = txAddressHtml(
|
||||||
tx.from,
|
tx.from,
|
||||||
tx.fromEns,
|
tx.fromEns,
|
||||||
fromTitle,
|
fromTitle,
|
||||||
|
tx.chainId,
|
||||||
);
|
);
|
||||||
// A contract creation has no recipient: transactions.js gives it `to: ""`.
|
// A contract creation has no recipient: transactions.js gives it `to: ""`.
|
||||||
if (tx.to) {
|
if (tx.to) {
|
||||||
const toTitle = addressTitle(tx.to, state.wallets);
|
const toTitle = addressTitle(tx.to, state.wallets);
|
||||||
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
$("tx-detail-to").innerHTML = txAddressHtml(
|
||||||
|
tx.to,
|
||||||
|
tx.toEns,
|
||||||
|
toTitle,
|
||||||
|
tx.chainId,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
||||||
}
|
}
|
||||||
@@ -146,7 +152,7 @@ function render() {
|
|||||||
if (tokenContractSection && tokenContractEl) {
|
if (tokenContractSection && tokenContractEl) {
|
||||||
if (tx.contractAddress) {
|
if (tx.contractAddress) {
|
||||||
const dot = addressDotHtml(tx.contractAddress);
|
const dot = addressDotHtml(tx.contractAddress);
|
||||||
const link = explorerUrl("token", tx.contractAddress);
|
const link = explorerUrl("token", tx.contractAddress, tx.chainId);
|
||||||
// Hand-rolled rather than renderAddressHtml() because the
|
// Hand-rolled rather than renderAddressHtml() because the
|
||||||
// link goes to the explorer's /token/ page, not /address/.
|
// link goes to the explorer's /token/ page, not /address/.
|
||||||
// Same two-row shape though: dot and link on the strip, the
|
// Same two-row shape though: dot and link on the strip, the
|
||||||
@@ -204,7 +210,11 @@ function showDetailField(sectionId, contentId, value) {
|
|||||||
function populateOnChainDetails(txData, chainId) {
|
function populateOnChainDetails(txData, chainId) {
|
||||||
// Block number
|
// Block number
|
||||||
if (txData.block_number != null) {
|
if (txData.block_number != null) {
|
||||||
const blockLink = explorerUrl("block", String(txData.block_number));
|
const blockLink = explorerUrl(
|
||||||
|
"block",
|
||||||
|
String(txData.block_number),
|
||||||
|
chainId,
|
||||||
|
);
|
||||||
const blockSection = $("tx-detail-block-section");
|
const blockSection = $("tx-detail-block-section");
|
||||||
const blockEl = $("tx-detail-block");
|
const blockEl = $("tx-detail-block");
|
||||||
if (blockSection && blockEl) {
|
if (blockSection && blockEl) {
|
||||||
@@ -340,9 +350,9 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
if (tokenSymbol) {
|
if (tokenSymbol) {
|
||||||
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
||||||
}
|
}
|
||||||
detailsHtml += renderAddressHtml(d.address);
|
detailsHtml += renderAddressHtml(d.address, { chainId });
|
||||||
} else if (d.address) {
|
} else if (d.address) {
|
||||||
detailsHtml += renderAddressHtml(d.address);
|
detailsHtml += renderAddressHtml(d.address, { chainId });
|
||||||
} else {
|
} else {
|
||||||
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-17
@@ -14,10 +14,13 @@ const {
|
|||||||
displaySymbol,
|
displaySymbol,
|
||||||
clearViewStack,
|
clearViewStack,
|
||||||
tokenLabel,
|
tokenLabel,
|
||||||
|
reviewedNetworkProvider,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state } = require("../../shared/state");
|
const { state } = require("../../shared/state");
|
||||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
const {
|
||||||
const { getProvider } = require("../../shared/balances");
|
SUPPORTED_CHAIN_IDS,
|
||||||
|
nativeCurrencyByChainId,
|
||||||
|
} = require("../../shared/networks");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
// Receipt poll cadence and the deadline after which the wait is reported as
|
// Receipt poll cadence and the deadline after which the wait is reported as
|
||||||
@@ -61,15 +64,16 @@ function endWait() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A contract creation reaches these screens with `to` as "" (approval.js
|
// A contract creation reaches these screens with `to` as "" (approval.js
|
||||||
// writes `to: toAddr || ""`).
|
// writes `to: toAddr || ""`). The link opens the explorer of the network with
|
||||||
function toAddressHtml(address) {
|
// `chainId`, or of the active network when none is given.
|
||||||
|
function toAddressHtml(address, chainId) {
|
||||||
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
||||||
const title = addressTitle(address, state.wallets);
|
const title = addressTitle(address, state.wallets);
|
||||||
return renderAddressHtml(address, { title });
|
return renderAddressHtml(address, { title, chainId });
|
||||||
}
|
}
|
||||||
|
|
||||||
function txHashHtml(hash) {
|
function txHashHtml(hash, chainId) {
|
||||||
const link = explorerUrl("tx", hash);
|
const link = explorerUrl("tx", hash, chainId);
|
||||||
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
|
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,9 +95,11 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
// native currency of txInfo.chainId, the network the transaction was sent
|
// native currency of txInfo.chainId, the network the transaction was sent
|
||||||
// on, not the active network's: the active network can change, in
|
// on, not the active network's: the active network can change, in
|
||||||
// Settings or when the user approves a site's request, while this screen
|
// Settings or when the user approves a site's request, while this screen
|
||||||
// is open or before a later popup resumes it. A null token is a swap whose
|
// is open or before a later popup resumes it. This screen's links open
|
||||||
// amount was not decoded (see showTxApproval() in approval.js): the amount
|
// that network's explorer, and its receipt is asked of that network's
|
||||||
// says so, and no symbol follows it.
|
// node, for the same reason. A null token is a swap whose amount was not
|
||||||
|
// decoded (see showTxApproval() in approval.js): the amount says so, and
|
||||||
|
// no symbol follows it.
|
||||||
let symbol = "";
|
let symbol = "";
|
||||||
if (txInfo.token === "ETH") {
|
if (txInfo.token === "ETH") {
|
||||||
symbol = nativeCurrencyByChainId(txInfo.chainId);
|
symbol = nativeCurrencyByChainId(txInfo.chainId);
|
||||||
@@ -102,8 +108,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
}
|
}
|
||||||
$("wait-tx-summary").textContent =
|
$("wait-tx-summary").textContent =
|
||||||
txInfo.amount + (symbol ? " " + symbol : "");
|
txInfo.amount + (symbol ? " " + symbol : "");
|
||||||
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
|
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
|
||||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
|
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
|
||||||
attachCopyHandlers("view-wait-tx");
|
attachCopyHandlers("view-wait-tx");
|
||||||
|
|
||||||
// Persisted so closing and reopening the popup resumes this wait
|
// Persisted so closing and reopening the popup resumes this wait
|
||||||
@@ -128,7 +134,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
renderElapsed();
|
renderElapsed();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = reviewedNetworkProvider(txInfo);
|
||||||
let consecutiveFailures = 0;
|
let consecutiveFailures = 0;
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
@@ -207,10 +213,11 @@ function showWait(txInfo, txHash) {
|
|||||||
// an object merely missing one of them throws a TypeError out of
|
// an object merely missing one of them throws a TypeError out of
|
||||||
// restoreView() — which init() does not guard, skipping the rest of popup
|
// restoreView() — which init() does not guard, skipping the rest of popup
|
||||||
// init and leaving wait-tx on screen with no back control. A non-numeric
|
// init and leaving wait-tx on screen with no back control. A non-numeric
|
||||||
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
|
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
|
||||||
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
|
// the node the receipt is asked of and the explorer the links open, so it has
|
||||||
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
|
// to name a network. txInfo.token and txInfo.tokenSymbol are deliberately
|
||||||
// ETH.
|
// unchecked: they are compared and coalesced rather than dereferenced, and
|
||||||
|
// tokenSymbol is null for ETH.
|
||||||
function restoreWait() {
|
function restoreWait() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
if (!d || !d.pendingWait) return false;
|
if (!d || !d.pendingWait) return false;
|
||||||
@@ -226,6 +233,7 @@ function restoreWait() {
|
|||||||
// path itself created.
|
// path itself created.
|
||||||
if (typeof info.to !== "string") return false;
|
if (typeof info.to !== "string") return false;
|
||||||
if (typeof info.amount !== "string") return false;
|
if (typeof info.amount !== "string") return false;
|
||||||
|
if (!SUPPORTED_CHAIN_IDS.has(info.chainId)) return false;
|
||||||
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ describe("the wait, success and error screens", () => {
|
|||||||
amount: "0.0000",
|
amount: "0.0000",
|
||||||
token: "ETH",
|
token: "ETH",
|
||||||
tokenSymbol: null,
|
tokenSymbol: null,
|
||||||
|
chainId: "0x1",
|
||||||
};
|
};
|
||||||
const transfer = { ...creation, to: RECIPIENT };
|
const transfer = { ...creation, to: RECIPIENT };
|
||||||
|
|
||||||
|
|||||||
@@ -459,6 +459,19 @@ const CONTRACT = [
|
|||||||
views: ["transaction"],
|
views: ["transaction"],
|
||||||
restored: false,
|
restored: false,
|
||||||
},
|
},
|
||||||
|
// A chain id naming no network has no explorer to link to.
|
||||||
|
{
|
||||||
|
value: {
|
||||||
|
tx: {
|
||||||
|
hash: "0x1",
|
||||||
|
from: ADDRESS,
|
||||||
|
to: ADDRESS,
|
||||||
|
chainId: "0x5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
views: ["transaction"],
|
||||||
|
restored: false,
|
||||||
|
},
|
||||||
// confirm-tx passes on `data.pendingTx`.
|
// confirm-tx passes on `data.pendingTx`.
|
||||||
{
|
{
|
||||||
value: { pendingTx: { amount: "1" } },
|
value: { pendingTx: { amount: "1" } },
|
||||||
@@ -821,7 +834,7 @@ const WELL_FORMED_DATA = {
|
|||||||
},
|
},
|
||||||
pendingWait: {
|
pendingWait: {
|
||||||
hash: "0x1",
|
hash: "0x1",
|
||||||
txInfo: { to: ADDRESS, amount: "1" },
|
txInfo: { to: ADDRESS, amount: "1", chainId: "0x1" },
|
||||||
broadcastTime: BROADCAST_TIME,
|
broadcastTime: BROADCAST_TIME,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -225,7 +225,12 @@ describe("a popup already open when the stored profile becomes unreadable", () =
|
|||||||
viewData: {
|
viewData: {
|
||||||
pendingWait: {
|
pendingWait: {
|
||||||
hash: "0x1",
|
hash: "0x1",
|
||||||
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
|
txInfo: {
|
||||||
|
to: ADDRESS,
|
||||||
|
amount: "1",
|
||||||
|
token: "ETH",
|
||||||
|
chainId: "0x1",
|
||||||
|
},
|
||||||
broadcastTime: Date.now(),
|
broadcastTime: Date.now(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
// tracks. It decodes against the address the explorer reports the transaction
|
// tracks. It decodes against the address the explorer reports the transaction
|
||||||
// was sent to.
|
// was sent to.
|
||||||
//
|
//
|
||||||
|
// Its links open the explorer of the network the transaction was sent on,
|
||||||
|
// whichever network is active
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/584).
|
||||||
|
//
|
||||||
// The row is the one the real history parser makes from the explorer's answer,
|
// The row is the one the real history parser makes from the explorer's answer,
|
||||||
// opened through transactionDetail.show(), which is what a click on the row
|
// opened through transactionDetail.show(), which is what a click on the row
|
||||||
// calls. Driven against a minimal DOM stub in the shape
|
// calls. Driven against a minimal DOM stub in the shape
|
||||||
@@ -35,6 +39,7 @@ globalThis.chrome = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
|
const { NETWORKS } = require("../src/shared/networks");
|
||||||
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
||||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
const transactionDetail = require("../src/popup/views/transactionDetail");
|
||||||
|
|
||||||
@@ -77,7 +82,11 @@ function mockExplorerAnswer(url) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (url === EXPLORER + "/transactions/" + TX_HASH) {
|
if (url === EXPLORER + "/transactions/" + TX_HASH) {
|
||||||
return { to: { hash: TOKEN }, raw_input: TRANSFER_DATA };
|
return {
|
||||||
|
to: { hash: TOKEN },
|
||||||
|
raw_input: TRANSFER_DATA,
|
||||||
|
block_number: 100,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { items: [] };
|
return { items: [] };
|
||||||
}
|
}
|
||||||
@@ -154,7 +163,11 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
|
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
|
||||||
const [row] = await fetchRecentTransactions(HOLDER, EXPLORER, 1);
|
const [row] = await fetchRecentTransactions(
|
||||||
|
HOLDER,
|
||||||
|
EXPLORER,
|
||||||
|
NETWORKS.mainnet.chainId,
|
||||||
|
);
|
||||||
expect(row.to).toBe(RECIPIENT);
|
expect(row.to).toBe(RECIPIENT);
|
||||||
expect(row.contractAddress).toBe(TOKEN.toLowerCase());
|
expect(row.contractAddress).toBe(TOKEN.toLowerCase());
|
||||||
|
|
||||||
@@ -174,3 +187,33 @@ test("a token transfer's decoded details name the token contract and the amount
|
|||||||
'<div class="font-bold">1234.5000 TDOL</div>',
|
'<div class="font-bold">1234.5000 TDOL</div>',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mainnet is active and the row was read from Sepolia's explorer. The stub
|
||||||
|
// explorer answers whichever network asks, so every link on the screen is
|
||||||
|
// drawn.
|
||||||
|
test("with mainnet active, a Sepolia transaction's links open the Sepolia explorer", async () => {
|
||||||
|
state.networkId = "mainnet";
|
||||||
|
const [row] = await fetchRecentTransactions(
|
||||||
|
HOLDER,
|
||||||
|
EXPLORER,
|
||||||
|
NETWORKS.sepolia.chainId,
|
||||||
|
);
|
||||||
|
|
||||||
|
transactionDetail.show(row);
|
||||||
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const sepolia = "https://sepolia.etherscan.io";
|
||||||
|
const links = (id) =>
|
||||||
|
[...node(id).innerHTML.matchAll(/href="([^"]*)"/g)].map((m) => m[1]);
|
||||||
|
expect(links("tx-detail-hash")).toEqual([`${sepolia}/tx/${TX_HASH}`]);
|
||||||
|
expect(links("tx-detail-from")).toEqual([`${sepolia}/address/${HOLDER}`]);
|
||||||
|
expect(links("tx-detail-to")).toEqual([`${sepolia}/address/${RECIPIENT}`]);
|
||||||
|
expect(links("tx-detail-token-contract")).toEqual([
|
||||||
|
`${sepolia}/token/${TOKEN.toLowerCase()}`,
|
||||||
|
]);
|
||||||
|
expect(links("tx-detail-block")).toEqual([`${sepolia}/block/100`]);
|
||||||
|
expect(links("tx-detail-calldata-details")).toEqual([
|
||||||
|
`${sepolia}/address/${TOKEN}`,
|
||||||
|
`${sepolia}/address/${RECIPIENT}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
+70
-3
@@ -29,8 +29,13 @@ jest.mock("../src/shared/log", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mockReceiptLookup = jest.fn();
|
const mockReceiptLookup = jest.fn();
|
||||||
|
// The RPC URL and network of every node a wait was given.
|
||||||
|
const mockNodes = [];
|
||||||
jest.mock("../src/shared/balances", () => ({
|
jest.mock("../src/shared/balances", () => ({
|
||||||
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
|
getProvider: (rpcUrl, networkId) => {
|
||||||
|
mockNodes.push({ rpcUrl, networkId });
|
||||||
|
return { getTransactionReceipt: mockReceiptLookup };
|
||||||
|
},
|
||||||
refreshBalances: jest.fn(),
|
refreshBalances: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -107,6 +112,7 @@ global.chrome = { storage };
|
|||||||
|
|
||||||
const txStatus = require("../src/popup/views/txStatus");
|
const txStatus = require("../src/popup/views/txStatus");
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
|
const { NETWORKS } = require("../src/shared/networks");
|
||||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||||
const { log } = require("../src/shared/log");
|
const { log } = require("../src/shared/log");
|
||||||
|
|
||||||
@@ -118,6 +124,7 @@ const TX_INFO = {
|
|||||||
amount: "0.0050",
|
amount: "0.0050",
|
||||||
token: "ETH",
|
token: "ETH",
|
||||||
tokenSymbol: null,
|
tokenSymbol: null,
|
||||||
|
chainId: NETWORKS.mainnet.chainId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// True when a view element is not hidden.
|
// True when a view element is not hidden.
|
||||||
@@ -137,7 +144,11 @@ beforeEach(() => {
|
|||||||
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
|
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
|
||||||
elements.clear();
|
elements.clear();
|
||||||
mockReceiptLookup.mockReset();
|
mockReceiptLookup.mockReset();
|
||||||
|
mockNodes.length = 0;
|
||||||
log.errorf.mockClear();
|
log.errorf.mockClear();
|
||||||
|
state.networkId = "mainnet";
|
||||||
|
state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl;
|
||||||
|
state.networkEndpoints = {};
|
||||||
state.wallets = [];
|
state.wallets = [];
|
||||||
state.viewData = {};
|
state.viewData = {};
|
||||||
state.viewStack = [];
|
state.viewStack = [];
|
||||||
@@ -349,14 +360,14 @@ describe("WaitTx persistence across popup close", () => {
|
|||||||
// out of restoreView().
|
// out of restoreView().
|
||||||
{
|
{
|
||||||
hash: TX_HASH,
|
hash: TX_HASH,
|
||||||
txInfo: { to: 42, amount: "0.0050" },
|
txInfo: { ...TX_INFO, to: 42 },
|
||||||
broadcastTime: Date.now(),
|
broadcastTime: Date.now(),
|
||||||
},
|
},
|
||||||
// Otherwise complete but an array: only Array.isArray() rejects
|
// Otherwise complete but an array: only Array.isArray() rejects
|
||||||
// it, since typeof [] is "object" and the fields are present.
|
// it, since typeof [] is "object" and the fields are present.
|
||||||
{
|
{
|
||||||
hash: TX_HASH,
|
hash: TX_HASH,
|
||||||
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
|
txInfo: Object.assign([], TX_INFO),
|
||||||
broadcastTime: Date.now(),
|
broadcastTime: Date.now(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -364,6 +375,18 @@ describe("WaitTx persistence across popup close", () => {
|
|||||||
txInfo: { to: RECIPIENT },
|
txInfo: { to: RECIPIENT },
|
||||||
broadcastTime: Date.now(),
|
broadcastTime: Date.now(),
|
||||||
},
|
},
|
||||||
|
// Otherwise complete but for a chain id naming no network: there
|
||||||
|
// is no node to ask for the receipt, and no explorer to link to.
|
||||||
|
{
|
||||||
|
hash: TX_HASH,
|
||||||
|
txInfo: { ...TX_INFO, chainId: undefined },
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hash: TX_HASH,
|
||||||
|
txInfo: { ...TX_INFO, chainId: "0x5" },
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
]) {
|
]) {
|
||||||
state.viewData = { pendingWait: bad };
|
state.viewData = { pendingWait: bad };
|
||||||
expect(txStatus.restoreWait()).toBe(false);
|
expect(txStatus.restoreWait()).toBe(false);
|
||||||
@@ -499,6 +522,50 @@ describe("WaitTx when the popup closes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The popup can reopen onto WaitTx after Settings switched the network. The
|
||||||
|
// wait asked the active network's node for the receipt, never found it, and
|
||||||
|
// reported a timeout, and its links went to the active network's explorer
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/584).
|
||||||
|
describe("WaitTx for a Sepolia transaction with mainnet active", () => {
|
||||||
|
const SEPOLIA_NODE = "https://sepolia.node.example.invalid";
|
||||||
|
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.rpcUrl = "https://mainnet.node.example.invalid";
|
||||||
|
state.networkEndpoints = { sepolia: { rpcUrl: SEPOLIA_NODE } };
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the receipt is asked of the Sepolia node Settings has", async () => {
|
||||||
|
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
|
||||||
|
state.viewData = {
|
||||||
|
pendingWait: {
|
||||||
|
txInfo: sepoliaTx,
|
||||||
|
hash: TX_HASH,
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(txStatus.restoreWait()).toBe(true);
|
||||||
|
await jest.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(mockNodes).toEqual([
|
||||||
|
{ rpcUrl: SEPOLIA_NODE, networkId: "sepolia" },
|
||||||
|
]);
|
||||||
|
expect(state.currentView).toBe("success-tx");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the recipient and hash link to the Sepolia explorer", () => {
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
txStatus.showWait(sepoliaTx, TX_HASH);
|
||||||
|
|
||||||
|
expect(getElement("wait-tx-to").innerHTML).toContain(
|
||||||
|
`href="https://sepolia.etherscan.io/address/${RECIPIENT}"`,
|
||||||
|
);
|
||||||
|
expect(getElement("wait-tx-hash").innerHTML).toContain(
|
||||||
|
`href="https://sepolia.etherscan.io/tx/${TX_HASH}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("wait-tx is a view the popup may reopen onto", () => {
|
describe("wait-tx is a view the popup may reopen onto", () => {
|
||||||
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
|
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
|
||||||
// refuses any view not in the set, so dropping "wait-tx" from it kills
|
// refuses any view not in the set, so dropping "wait-tx" from it kills
|
||||||
|
|||||||
Reference in New Issue
Block a user