fix: the recipient warning says what it checked and skips addresses that have received ETH (closes #528)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

The transaction count the node reports is what an address has sent, so
an address that only receives, such as a savings address, got the "ZERO
transaction history" box on every send. The check now also asks the node
for the recipient's balance and warns only when both are zero. The box
says the address has never sent a transaction and holds no ETH, naming
the active network's native token as the contract warning does.

Model: opus-5-5
This commit is contained in:
2026-10-18 23:05:28 +00:00
parent fc042c3191
commit 7f259e501e
7 changed files with 86 additions and 26 deletions
+5 -3
View File
@@ -1020,7 +1020,7 @@ on the approval screen, the amount on the wait, success and error screens, the
transaction history and the transaction detail screen, and the refusal of a fee
above 1 ETH. A chain id that names no network reads `ETH`. Wherever this
document shows ETH as the label of a native balance, value or fee, in a "Native
ETH transfer" type line, in the contract-recipient warning or in that refusal,
ETH transfer" type line, in the two recipient warnings or in that refusal,
Sepolia shows `SepoliaETH`. The swap lines keep `ETH`, the router's own name for
the native currency, and the ETH/USD price line, shown on mainnet only, keeps
its fixed wording.
@@ -1730,8 +1730,10 @@ view would leave a wallet one click from deletion.
reserved either way
- Warnings: inline warnings from the local checks (scam address, self-send)
plus four reserved warning boxes made visible by the async checks —
recipient with no transaction history, recipient is a contract, burn
address, and an Etherscan phishing/scam label
recipient that has never sent a transaction and holds no ETH, recipient is
a contract, burn address, and an Etherscan phishing/scam label. An address
that has received ETH but never sent a transaction still holds that ETH,
so it gets no warning
- Errors (insufficient balance), plus three reserved error boxes — the
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
+6 -11
View File
@@ -634,17 +634,12 @@
</div>
</div>
<div id="confirm-warnings" class="mb-2 invisible"></div>
<div id="confirm-recipient-warning" class="mb-2 invisible">
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient address has ZERO transaction
history. This may indicate a fresh or unused address.
Double-check the address before sending.
</div>
</div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
<!-- These two sentences name the network's native token, so
show() in confirmTx.js sets them. -->
<div
id="confirm-recipient-warning"
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
></div>
<div
id="confirm-contract-warning"
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
+8 -1
View File
@@ -217,7 +217,14 @@ function show(txInfo) {
nativeCurrency() +
" to this address and try again.";
// Shown later, once checkRecipientHistory() finds a contract.
// Both shown later, by checkRecipientHistory(). The first sentence says
// exactly what getFullWarnings() checks.
$("confirm-recipient-warning").textContent =
"WARNING: The recipient address has never sent a transaction and" +
" holds no " +
nativeCurrency() +
". This may indicate a fresh or unused address. Double-check the" +
" address before sending.";
$("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() +
+20 -10
View File
@@ -51,7 +51,8 @@ function getLocalWarnings(address, options = {}) {
/**
* Check an address against local lists AND via RPC queries.
* Async — performs network calls to check contract status and tx history.
* Async — performs network calls to check contract status, tx count and
* balance.
*
* @param {string} address - The target address to check.
* @param {object} provider - An ethers.js provider instance.
@@ -78,21 +79,30 @@ async function getFullWarnings(address, provider, options = {}) {
log.errorf("contract check failed:", e.shortMessage || e.message);
}
// Skip tx count check for contracts — they may legitimately have
// zero inbound EOA transactions.
// The transaction count is the number of transactions the address has
// sent, so an address that has only ever received has a count of zero.
// Native token it received stays in its balance until it sends a
// transaction, so the warning also needs a zero balance. A contract is
// skipped: it has its own warning above.
if (!isContract) {
try {
const txCount = await provider.getTransactionCount(address);
if (txCount === 0) {
warnings.push({
type: "new-address",
message:
"This address has never sent a transaction. Double-check it is correct.",
severity: "info",
});
const balance = await provider.getBalance(address);
if (balance === 0n) {
warnings.push({
type: "new-address",
message:
"This address has never sent a transaction and holds none of the network's native token. Double-check it is correct.",
severity: "info",
});
}
}
} catch (e) {
log.errorf("tx count check failed:", e.shortMessage || e.message);
log.errorf(
"tx count or balance check failed:",
e.shortMessage || e.message,
);
}
}
+39
View File
@@ -0,0 +1,39 @@
// The confirmation screen's warning for a recipient that has never sent a
// transaction (https://git.eeqj.de/sneak/AutistMask/issues/528).
//
// The node's transaction count for an address is the number of transactions
// it has sent, so an address that only ever receives, such as a savings
// address or an exchange deposit address, has a count of zero. The warning
// used to fire on that count alone. It now also needs a zero balance: ETH an
// address received stays in its balance until it sends a transaction.
"use strict";
const { getFullWarnings } = require("../src/shared/addressWarnings");
global.fetch = jest.fn(async () => {
throw new Error("tests must not perform network requests");
});
const RECIPIENT = "0x" + "b".repeat(40);
// The warning types getFullWarnings() returns for RECIPIENT when the node
// reports it as a regular address with `txCount` sent transactions and a
// balance of `balance` wei.
async function warningTypes(txCount, balance) {
const provider = {
getCode: async () => "0x",
getTransactionCount: async () => txCount,
getBalance: async () => balance,
};
const warnings = await getFullWarnings(RECIPIENT, provider);
return warnings.map((w) => w.type);
}
test("a recipient that has received 0.5 ETH and never sent a transaction gets no warning", async () => {
expect(await warningTypes(0, 500000000000000000n)).toEqual([]);
});
test("a recipient that has never sent a transaction and holds no ETH gets the warning", async () => {
expect(await warningTypes(0, 0n)).toEqual(["new-address"]);
});
+7
View File
@@ -267,6 +267,13 @@ describe.each([
);
});
test("the warning for a recipient that has never sent a transaction", async () => {
await confirmSend("0.1");
expect(text("confirm-recipient-warning")).toContain(
"has never sent a transaction and holds no " + symbol + ".",
);
});
// A token reports whatever symbol it likes. One reporting the label the
// wallet shows its native token under, on this network or any other, is
// a fake, exactly as one reporting `ETH` always was.
+1 -1
View File
@@ -74,7 +74,7 @@ test("ethers puts the URL in the error message, not in the short message", async
test("the recipient checks before a send", async () => {
await getFullWarnings(ADDRESS, getProvider(RPC_URL, "mainnet"));
expectFailureLoggedWithoutKey("contract check failed");
expectFailureLoggedWithoutKey("tx count check failed");
expectFailureLoggedWithoutKey("tx count or balance check failed");
});
test("the ENS reverse lookup", async () => {