fix: scope the Reservoir NFT host to MONAD_NETWORK, refuse get_nfts on mainnet - #72
Open
rajarshidattapy wants to merge 2 commits into
Open
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #63.
The bug
reservoirUrlwas the one chain-dependent value that didn't come from theNETWORKStable:.env.exampleshipsRESERVOIR_API_URLcommented out, so that constant is what a mainnet operator actually gets. Run withMONAD_NETWORK=mainnetand 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, andtransfer_nftthen aimssafeTransferFromat 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 theownerOfpre-check inwallet.transferNftis validating against something the operator never meant.Which fix
I checked Reservoir's docs before picking. There is no Monad mainnet host — Supported 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 intoNETWORKSso it follows the network likechainId,rpcUrlandexplorerUrl, withRESERVOIR_API_URLoverriding it exactly the wayMONAD_RPC_URLoverridesrpcUrl:src/wallet.mjs— one guard infetchReservoir, the single chokepoint every Reservoir read passes through, so it can't be reintroduced by a future caller: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_URLto a real indexer for the network re-enablesget_nftsunchanged.transfer_nftis deliberately untouched — itsownerOfcheck already reads the configured network's RPC, so it was correct per-network. The read was the root cause.Docs updated:
.env.exampleand the README NFT bullet.Tests
test/reservoir-network.test.mjs— 4 cases, no new dependencies.config.mjsreads env once at import time, so each case runs in a child process rather than fighting the module cache.RESERVOIR_API_URLwins on both networksget_nftson mainnet returnsRefused:namingRESERVOIR_API_URL— withRESERVOIR_API_KEYset, so the refusal can only be the missing hostnpm test: 249 pass / 21 fail. Those same 21 fail onmainwith 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
mainmoved (#60) and touched the sameNETWORKSentries, 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: nullon mainnet because no vetted DEX is pinned,reservoirUrl: ""on mainnet because no indexer is. The mainnet comment now points atdex: nullas the precedent rather than restating the reasoning.