Skip to content

Refuse an unreadable NFT token id instead of throwing or encoding zero - #105

Merged
portdeveloper merged 1 commit into
portdeveloper:mainfrom
BeeHiveTeam:fix/token-id-guard
Sep 24, 2026
Merged

portdeveloper merged 1 commit into
portdeveloper:mainfrom
BeeHiveTeam:fix/token-id-guard

Conversation

@BeeHiveTeam

@BeeHiveTeam BeeHiveTeam commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #103.

The token id reached BigInt twice with nothing in front of it — once in the ownership check, once in the calldata encoder — so the field carried both halves of the failure, with the CLI guard at src/tools.mjs:996 standing between the model and one of them.

Through the CLI, an id that is non-empty goes to the wallet as String(a.tokenId) and is unexamined from there: "abc", "1.5", "-1" and a 79-digit number come back as a raw SyntaxError or ethers value out-of-bounds, where every neighbouring field on this path answers with a readable Refused: line. That is the inconsistency #79 fixed for fromAddress.

Called directly, the exported helpers are looser. src/tools.mjs:996 refuses an id that is empty, blank, undefined, null or [] before the wallet sees it; buildNftTransferCalldata and transferNft have no check of their own, and BigInt("") is 0n, which is a real token. So buildNftTransferCalldata(from, to, "") returns a complete, signable safeTransferFrom of token #0. The two conversions were independent, which is why the ownership check did not contradict it — ownerOf(BigInt("")) asks about token #0 too, so both sides agreed on a token nobody named.

The change

One guarded conversion, requireTokenId, used by both call sites:

  • accepts a string, a number or a bigint; an empty or blank string is not a value
  • requires the result to fit the uint256 the ABI declares — 0 through 2^256-1
  • hex keeps working ("0x2a" → 42), and "0" is still a token id, which is exactly what "" had to stop being

It throws rather than returning null: both refusals already in this function throw, and an existing test requires a throw for a bad address. Two refusal mechanisms in one function would be worse than either.

The offending value is deliberately not echoed in the message. transferNft already prints a raw tokenId in its wrong-owner refusal, and a second unescaped echo is not something to add here.

transferNft resolves the id before the session check. A malformed argument is the caller's to fix whether or not a wallet is open, and it is what makes the refusal reachable from a test at all — every other statement in that function needs an initialised account. Without the reorder the guard was unobservable, which a mutation confirmed by surviving.

Coverage

518 tests, up from 494. The new table lists both directions in one place on purpose — a shape that encodes and a shape that is refused are statements about the same guard, and listing them apart is how one half drifts.

Encoded: "730", "0", "0x2a", " 42 ", 42, 42n, max uint256.
Refused: "", " ", "abc", "1.5", "1e3", "-1", -1, max+1, null, undefined, true, false, [], ["5"], {}, { toString: () => "7" }.

The refusals assert the message, not only the throw: err.message.startsWith("Refused:"). The throw alone was already there before this PR — it just came from ethers and said something else.

A second test drives the same ids through transferNft with no wallet open, so the ownership check is pinned separately from the encoder rather than assumed to follow it.

The four pre-existing buildNftTransferCalldata tests are untouched, including the one that requires a 78-digit max-uint256 id to keep encoding.

Mutations

Each guard reverted on its own, full suite each time, plus a no-op control so a red result means the mutant and not the harness:

reverted tests red
control — no-op rename, nothing else 0 (518 pass)
type check removed (anything goes to BigInt) 8
empty-string refusal removed 3
lower bound removed 3
upper bound removed 1
ceiling moved to 1n << 256n 1
encoder bypasses the guard (BigInt(tokenId)) 16
ownership check bypasses the guard 1
Refused: prefix dropped from the message 17

The ownership-check row is the one that began as a survivor, before the id was resolved ahead of the session check.

Scope

No change to the request, the ABI, the calldata for ids that were already valid, or any message other than the new refusal.

For direct callers, shapes that used to convert by coercion are now refused — ["5"], { toString: () => "7" }. Through the CLI they are unaffected: String(a.tokenId) turns them into "5" and "7" before the wallet is called.

Rebased onto 7e0ad55, so the counts above are against current main including the native tool-calling work. npm run build clean; suite run ten consecutive times with no failures.

The token id went to a bare BigInt twice: in the ownership check and in the
calldata encoder. Neither asked whether the value was a token id, so the field
had both halves of the failure. An empty or blank id, an empty array, false —
all reach BigInt as 0, which is a real token, so the result was a complete,
signable transfer of token #0 that nobody asked for. Anything else BigInt or
ethers disliked came back as a raw converter error rather than a refusal.

The two conversions were also independent, which is why the ownership check did
not catch the fabrication: ownerOf(BigInt("")) asks about token #0 too, so the
two agreed on a token nobody named.

requireTokenId now resolves both. It takes a string, a number or a bigint,
refuses an empty or blank string, and requires the result to fit the uint256 the
ABI declares. Hex still works and "0" is still a token id — which is exactly what
"" had to stop being.

It throws rather than returning null because both refusals already in this file
throw, and an existing test requires a throw for a bad address; two mechanisms in
one function would be worse than either. The offending value is deliberately not
echoed: transferNft already prints a raw tokenId in its wrong-owner refusal, and
a second unescaped echo is not something to add here.

transferNft resolves the id before the session check. A malformed argument is the
caller's to fix whether or not a wallet is open, and it is what makes that refusal
reachable from a test at all — every other statement in the function needs an
initialised account. Without the reorder the guard was unobservable, which a
mutation confirmed by surviving.
@BeeHiveTeam
BeeHiveTeam force-pushed the fix/token-id-guard branch 2 times, most recently from 97bfae4 to 9cacb12 Compare September 22, 2026 17:53

@portdeveloper portdeveloper left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, thanks

@portdeveloper
portdeveloper merged commit 3b32d68 into portdeveloper:main Sep 24, 2026
3 checks passed
ColinkaMir added a commit to ColinkaMir/nad-agent that referenced this pull request Sep 24, 2026
The precision is gone before any line of this repo runs. JSON.parse turns
9007199254740993 into 9007199254740992, so parseAction never sees the id the
model wrote, String() then makes it an ordinary decimal, and requireTokenId from
portdeveloper#105 accepts it because it is a legal uint256. The transfer is complete,
signable, and for a different token.

Nothing downstream can recover the digit, so this refuses rather than repairs,
at the two places a number can enter. At the action boundary the check sits
before the String() coercion, because after it the value is indistinguishable
from an id somebody meant. In requireTokenId only the number branch changes:
strings and bigints keep the whole uint256 range, so 9007199254740993 remains a
usable id when it is written as a string, which is what the refusal tells the
operator to do.

runAction gained a transferNft seam beside the sendToken one it already had.
Without it the ordering is unobservable: a test can see a refusal but not
whether the wallet was reached first, and ordering is the entire fix here.

Tests: the model output from the issue, asserting both the refusal and that the
stubbed wallet recorded no call; the safe-integer boundary accepted as a number
and the next value refused; the same id accepted as a string; the exported
helpers refusing the number; an exact large string decoded back out of the
calldata unchanged; and uint256 max still legal. Five of the eight fail on the
code before this commit.
ColinkaMir added a commit to ColinkaMir/nad-agent that referenced this pull request Sep 24, 2026
The precision is gone before any line of this repo runs. JSON.parse turns
9007199254740993 into 9007199254740992, so parseAction never sees the id the
model wrote, String() then makes it an ordinary decimal, and requireTokenId from
portdeveloper#105 accepts it because it is a legal uint256. The transfer is complete,
signable, and for a different token.

Nothing downstream can recover the digit, so this refuses rather than repairs,
at the two places a number can enter. At the action boundary the check sits
before the String() coercion, because after it the value is indistinguishable
from an id somebody meant. In requireTokenId only the number branch changes:
strings and bigints keep the whole uint256 range, so 9007199254740993 remains a
usable id when it is written as a string, which is what the refusal tells the
operator to do.

runAction gained a transferNft seam beside the sendToken one it already had.
Without it the ordering is unobservable: a test can see a refusal but not
whether the wallet was reached first, and ordering is the entire fix here.

Tests: the model output from the issue and the same number arriving as a native
tool argument, both asserting the refusal and that the stubbed wallet recorded
no call; the safe-integer boundary accepted as a number and the next value
refused; the same id accepted as a string; the exported helpers refusing the
number; an exact large string decoded back out of the calldata unchanged; and
uint256 max still legal. Six of the nine fail with the sources checked out at
the previous commit.
portdeveloper pushed a commit that referenced this pull request Sep 25, 2026
The precision is gone before any line of this repo runs. JSON.parse turns
9007199254740993 into 9007199254740992, so parseAction never sees the id the
model wrote, String() then makes it an ordinary decimal, and requireTokenId from
#105 accepts it because it is a legal uint256. The transfer is complete,
signable, and for a different token.

Nothing downstream can recover the digit, so this refuses rather than repairs,
at the two places a number can enter. At the action boundary the check sits
before the String() coercion, because after it the value is indistinguishable
from an id somebody meant. In requireTokenId only the number branch changes:
strings and bigints keep the whole uint256 range, so 9007199254740993 remains a
usable id when it is written as a string, which is what the refusal tells the
operator to do.

runAction gained a transferNft seam beside the sendToken one it already had.
Without it the ordering is unobservable: a test can see a refusal but not
whether the wallet was reached first, and ordering is the entire fix here.

Tests: the model output from the issue and the same number arriving as a native
tool argument, both asserting the refusal and that the stubbed wallet recorded
no call; the safe-integer boundary accepted as a number and the next value
refused; the same id accepted as a string; the exported helpers refusing the
number; an exact large string decoded back out of the calldata unchanged; and
uint256 max still legal. Six of the nine fail with the sources checked out at
the previous commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An unreadable NFT token id throws where every neighbouring field refuses

2 participants