Skip to content

fix: scope the Reservoir NFT host to MONAD_NETWORK, refuse get_nfts on mainnet - #72

Open
rajarshidattapy wants to merge 2 commits into
portdeveloper:mainfrom
rajarshidattapy:fix/reservoir-network-scoped-url
Open

fix: scope the Reservoir NFT host to MONAD_NETWORK, refuse get_nfts on mainnet#72
rajarshidattapy wants to merge 2 commits into
portdeveloper:mainfrom
rajarshidattapy:fix/reservoir-network-scoped-url

Conversation

@rajarshidattapy

@rajarshidattapy rajarshidattapy commented Aug 27, 2026

Copy link
Copy Markdown

Closes #63.

The bug

reservoirUrl was the one chain-dependent value that didn't come from the NETWORKS table:

// src/config.mjs:44
const reservoirUrl = process.env.RESERVOIR_API_URL || "https://api-monad-testnet.reservoir.tools";

.env.example ships RESERVOIR_API_URL commented out, so that constant is what a mainnet operator actually gets. Run with MONAD_NETWORK=mainnet and ask "what NFTs do I own?" and the agent lists testnet holdings with nothing marking them as another chain's. The wallet address is identical on both, so the answer looks right.

It doesn't end at a wrong read: the natural follow-up is send NFT #12 to alice, and transfer_nft then aims safeTransferFrom at a contract/tokenId pair sourced from testnet. Best case that reverts and burns a sponsored userOp; worst case the same address on mainnet is a different contract, and the ownerOf pre-check in wallet.transferNft is validating against something the operator never meant.

Which fix

I checked Reservoir's docs before picking. There is no Monad mainnet hostSupported Chains lists 33 mainnets and Monad is not among them; Monad appears only as Monad Testnet at api-monad-testnet.reservoir.tools. (Reservoir also sunset the NFT API on 2025-10-15 to focus on Relay — worth its own issue, but out of scope here.)

So there was no mainnet URL to put in the table, and the issue's second option is the honest one: refuse rather than answer from testnet.

The change

src/config.mjs — the host moves into NETWORKS so it follows the network like chainId, rpcUrl and explorerUrl, with RESERVOIR_API_URL overriding it exactly the way MONAD_RPC_URL overrides rpcUrl:

testnet: { , reservoirUrl: "https://api-monad-testnet.reservoir.tools" },
mainnet: { , reservoirUrl: "" },   // Reservoir doesn't index Monad mainnet

src/wallet.mjs — one guard in fetchReservoir, the single chokepoint every Reservoir read passes through, so it can't be reintroduced by a future caller:

Refused: no NFT indexer is configured for Monad Mainnet. Reservoir does not index
Monad mainnet, and reading testnet holdings here would be wrong. Set
RESERVOIR_API_URL in .env to an indexer for this network to enable NFT reads.

It's checked before the API-key check, so a mainnet operator isn't sent off to get a key for a host that doesn't exist. Setting RESERVOIR_API_URL to a real indexer for the network re-enables get_nfts unchanged.

transfer_nft is deliberately untouched — its ownerOf check already reads the configured network's RPC, so it was correct per-network. The read was the root cause.

Docs updated: .env.example and the README NFT bullet.

Tests

test/reservoir-network.test.mjs — 4 cases, no new dependencies. config.mjs reads env once at import time, so each case runs in a child process rather than fighting the module cache.

  • testnet resolves to the Monad testnet host
  • mainnet does not fall back to it (the regression this PR fixes)
  • RESERVOIR_API_URL wins on both networks
  • get_nfts on mainnet returns Refused: naming RESERVOIR_API_URL — with RESERVOIR_API_KEY set, so the refusal can only be the missing host

npm test: 249 pass / 21 fail. Those same 21 fail on main with an unmodified tree (GGUFDownloader/computeMD5 + one account-persistence case — pre-existing on this Windows box, unrelated to this change). Net effect of this branch is +4 passing, 0 new failures.

Rebased on the swap work

main moved (#60) and touched the same NETWORKS entries, so this branch carries a merge of it. Both sides only add a network-scoped key, so the resolution keeps both — and they turn out to agree on the rule: dex: null on mainnet because no vetted DEX is pinned, reservoirUrl: "" on mainnet because no indexer is. The mainnet comment now points at dex: null as the precedent rather than restating the reasoning.

`reservoirUrl` was a flat constant pinned to Reservoir's Monad *testnet* host,
so a run with MONAD_NETWORK=mainnet answered "what NFTs do I own?" from testnet.
The wallet address is identical on both chains, so the answer looked plausible —
and the natural follow-up (`send NFT portdeveloper#12 to alice`) would then aim
safeTransferFrom at a contract/tokenId pair that came from the wrong chain.

The host now lives in the NETWORKS table alongside chainId/rpcUrl/explorerUrl,
and RESERVOIR_API_URL still overrides it.

Reservoir publishes no Monad mainnet endpoint (only api-monad-testnet), so
mainnet gets `reservoirUrl: ""` rather than a guessed URL, and fetchReservoir —
the single chokepoint every NFT read goes through — refuses with a reason
instead of silently reading another chain. Setting RESERVOIR_API_URL to an
indexer for the network re-enables it. This matches how the rest of the
codebase treats ambiguity, and how every other mainnet path is loud.

Tests cover both defaults, the override on each network, and the refusal;
each runs in a child process because config.mjs reads env at import time.
Conflict in src/config.mjs: both sides add a network-scoped key to the same
NETWORKS entries. Kept both — `reservoirUrl` (this branch) alongside `dex`
(PuddleSwap, portdeveloper#60). They land on the same rule for mainnet: no vetted endpoint
pinned, so the action refuses rather than guessing, and the mainnet comment now
points at `dex: null` as the precedent instead of restating it.

@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

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.

get_nfts reads Monad testnet even when MONAD_NETWORK=mainnet

2 participants