Stop copying payee addresses from wallet history: a small pattern for repeat crypto payments
If you pay the same person in USDC or USDT every month, there is a good chance you do it like this: open the wallet, find last month's transfer, copy the address, paste, send. That one step, copying the address from his
If you pay the same person in USDC or USDT every month, there is a good chance you do it like this: open the wallet, find last month's transfer, copy the address, paste, send.
That one step, copying the address from history, is exactly what address poisoning is built for.
The threat in one paragraph
An attacker watches the chain for your transfers. Right after you pay someone, they send you a tiny or zero-value transfer from an address that starts and ends with the same characters as your real payee. Now your history has two entries that look the same at a glance. Next month you copy the wrong one.
This is not rare. The USENIX Security '25 paper "Blockchain Address Poisoning" from Carnegie Mellon counted 270 million attack attempts aimed at 17 million victims on Ethereum and BSC alone, between 1 July 2022 and 30 June 2024. 6,633 of those incidents caused at least $83.8M in losses.
A test transfer doesn't protect you either. On 19 December 2025 (UTC), someone sent 49,999,950 USDT on Ethereum to a lookalike address about 26 minutes after a 50 USDT test transfer to the real one (CoinDesk). The test worked. The address for the big payment came from history.
A manual routine anyone can use (no tools needed)
- Get the address once, from outside the chain. An invoice, a contract or a message from the payee. Not from a block explorer and not from your history.
- Save it as a named payee record. Your wallet's address book, a password manager entry, a text file you control. Write down the network and the asset next to it (USDT on Solana and USDT on Ethereum are different addresses).
- Every payment starts from that record. Never from "recent", never from the history list, never from a search box that shows past addresses.
- Compare the whole address, in groups of four. Lookalikes are generated to match the first and last 4 to 6 characters. Read it group by group against the record.
- Check again on the wallet's confirm screen. That is the last place the address can still change, for example through a swapped clipboard or a forged link.
- If the payee says their address changed, confirm through a second channel before you update the record.
Most of the protection comes from steps 1 to 3: one source of truth, and history is never used as an address book.
The same pattern in code
We wrote an open-source example of this routine with send21 (crypto pay links and API for people and AI agents): repeat-payer-verified-address. It is a small Node.js app. It's one way to automate the routine above, not the only one.
One stored record. The payee lives in payee.json (label, network, asset, address, monthly amount). The app refuses to start until you replace the placeholder, and the error tells you to copy the address "from the invoice or contract they sent you, never from wallet history". It also refuses an asset on a network where send21 does not offer it (USDT, for example, only runs on Solana and Ethereum).
If your send21 key can read the address book, the app also checks whether the address is an entry you verified in the send21 app (with a passkey or an authenticator code; no API key can verify one). Only then does the UI say approved payee. Otherwise it says saved payee and that the record is your local file.
The draft is built from that record, never from a paste:
// lib/monthly.js
const idempotencyKey = `monthly-${payee.id}-${month}`;
let draft = await api.post("/drafts", draftBody(payee, month), { idempotencyKey });
The Idempotency-Key is per payee and month, so a double click or a retry returns the same draft instead of a second one.
The full address is checked in three places. After creating the draft, the app compares the saved address character by character with receiverAddress in the draft, with the output in the wallet instructions (GET /drafts/{id}/tx-template) and with the address inside the QR link (Solana Pay or EIP-681 URI):
// lib/address.js: exact, case-sensitive, no first-4/last-4 shortcut
export function compareAddresses(approved, actual) {
const a = String(approved ?? ""), b = String(actual ?? "");
const match = a.length > 0 && a === b;
// ...also reports where the first difference is
}
The test suite includes a lookalike that shares the first and last four characters and fails at position 4. If any check fails, the UI shows Stop. The address does not match and no wallet button.
Monthly without retyping. npm run monthly -- --schedule creates a recurring template (POST /recurring-templates, interval: "Monthly") from the same record, so send21 creates each month's draft from the stored address. The payer still signs every one.
Price lock. A price in EUR (or any of 161 currencies) is locked for 15 minutes. If this month's draft is still valid but the lock ran out, the app re-quotes the same draft (POST /drafts/{id}/requote) instead of making a twin.
Your wallet signs. The page ends at Waiting for your signature with a QR code and an Open in your wallet button. send21 prepares the payment; it never holds keys, never signs and never broadcasts. A signed draft.confirmed webhook (checked with X-Send21-Signature-V2) moves it to Paid.
What this does not do
It removes one risky step: copying an address from history. It does not stop scams in general. A phished or forged pay link, a changed invoice or a tampered payee record is still a risk, and a wrong address saved in step 1 stays wrong every month. So keep step 5: compare the full address on your wallet's confirm screen before you sign.
Try it
git clone https://github.com/send21io/send21-examples
cd send21-examples/examples/repeat-payer-verified-address
npm install && npm test # tests need no key and no network
cp .env.example .env # add a scoped key from https://send21.io/api-keys
npm start # http://localhost:3021
Account keys use live networks, and a draft moves no money until someone signs it, so test with a small draft to your own address and cancel it afterwards (npm run monthly -- --cancel <id>). If you just want to look around, the testnet demo runs in the browser with no account.
How do you keep payee addresses today? We're curious what routines people use. Questions about the example: [email protected]
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.