From b801419dc58f89768e809c3de22b1311819aecd8 Mon Sep 17 00:00:00 2001 From: natureloved Date: Thu, 1 Oct 2026 08:26:18 +0100 Subject: [PATCH] Refuse bad address checksum on read actions instead of throwing On get_token_balance and get_nfts, passing a mixed-case 0x address whose EIP-55 checksum failed bypassed regex address checks and reached ethers unchecked, throwing a raw INVALID_ARGUMENT error instead of returning a refusal string. Validate the address checksum on both read paths: - get_token_balance catches checksum errors from resolveToken and returns a refusal matching prepareTokenSend - get_nfts validates the owner address checksum with toChecksumAddress and returns a refusal matching resolveSend Closes #111 --- src/tools.mjs | 23 ++++++++++++++++++++--- test/nft.test.mjs | 11 ++++++++++- test/tools.test.mjs | 11 ++++++++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/tools.mjs b/src/tools.mjs index c6fa45b..420a8f7 100644 --- a/src/tools.mjs +++ b/src/tools.mjs @@ -947,7 +947,16 @@ export async function runAction(a, resolved, opts = {}) { const bal = await wallet.getBalance(); return `${formatMon(bal)} ${SYMBOL()}`; } - const token = resolveToken(input); + let token; + try { + token = resolveToken(input); + } catch (err) { + const detail = err?.shortMessage || err?.message || "invalid token identifier"; + if (/checksum/i.test(detail)) { + return `Refused: invalid token address "${safeEcho(input)}" (checksum failed)`; + } + return `Refused: invalid token "${safeEcho(input)}": ${safeEcho(detail)}`; + } if (!token) { return unknownTokenMessage(input); } @@ -1012,8 +1021,16 @@ export async function runAction(a, resolved, opts = {}) { case "get_nfts": { const owner = a.address ?? a.owner; - if (owner && !isAddress(owner)) { - return `Refused: "${String(owner).trim()}" is not a valid address.`; + if (owner) { + const trimmed = String(owner).trim(); + if (!isAddress(trimmed)) { + return `Refused: "${safeEcho(trimmed)}" is not a valid address.`; + } + try { + toChecksumAddress(trimmed); + } catch { + return `Refused: "${safeEcho(trimmed)}" is not a valid address (checksum failed)`; + } } const { tokens, skipped, truncated } = await wallet.getNfts(owner); // Notes, not silence: an empty list caused by unusable rows must not read as "you own diff --git a/test/nft.test.mjs b/test/nft.test.mjs index cab5485..e68fa8f 100644 --- a/test/nft.test.mjs +++ b/test/nft.test.mjs @@ -10,7 +10,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { parseAction, describeAction, runAction, isWrite, ACTIONS, systemPrompt } from "../src/tools.mjs"; +import { parseAction, describeAction, runAction, isWrite, ACTIONS, systemPrompt, isRefusal } from "../src/tools.mjs"; import { buildNftTransferCalldata, ERC721_ABI, normalizeNftPage, transferNft } from "../src/wallet.mjs"; import { Interface, getAddress } from "ethers"; import { config } from "../src/config.mjs"; @@ -184,6 +184,15 @@ describe("runAction — get_nfts / transfer_nft guards", () => { assert.match(String(res), /refused/i); }); + it("get_nfts with bad address checksum returns refusal instead of throwing", async () => { + const BAD = "0x534B2f3A21130D7A60830C2Df862319E593943A3"; + const res = await runAction({ action: "get_nfts", address: BAD }); + assert.equal(isRefusal(res), true); + assert.match(String(res), /^Refused:/); + assert.match(String(res), /checksum failed/); + assert.match(String(res), new RegExp(BAD)); + }); + it("transfer_nft with invalid to returns refusal", async () => { const res = await runAction({ action: "transfer_nft", contractAddress: "0xabc", tokenId: "1", to: "not-an-address" }); assert.match(String(res), /refused/i); diff --git a/test/tools.test.mjs b/test/tools.test.mjs index bddbcd7..3a6adfa 100644 --- a/test/tools.test.mjs +++ b/test/tools.test.mjs @@ -7,7 +7,7 @@ import { describe, it, test } from "node:test"; import assert from "node:assert/strict"; -import { ACTIONS, parseAction, isWrite, describeAction, systemPrompt, runAction } from "../src/tools.mjs"; +import { ACTIONS, parseAction, isWrite, describeAction, systemPrompt, runAction, isRefusal } from "../src/tools.mjs"; import { config } from "../src/config.mjs"; // --------------------------------------------------------------------------- @@ -342,6 +342,15 @@ test("unknown token balance errors explain when the catalog is empty", async () } }); +test("get_token_balance refuses a token address with a bad checksum instead of throwing", async () => { + const BAD = "0x534B2f3A21130D7A60830C2Df862319E593943A3"; + const result = await runAction({ action: "get_token_balance", token: BAD }); + assert.equal(isRefusal(result), true); + assert.match(result, /^Refused:/); + assert.match(result, /checksum failed/); + assert.match(result, new RegExp(BAD)); +}); + // ─── Native tool-calling tests ─────────────────────────────────────────────── import { getToolDefinitions, dispatchToolCall } from "../src/tools.mjs";