npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

robinhood-chain

v0.1.0

Published

Chain SDK for Robinhood Chain (Arbitrum Orbit L2, 4663/46630): viem chain definitions with Multicall3, verified token constants, decimals-safe formatting, address verification against ticker collisions, and adaptive eth_getLogs scanning.

Readme

robinhood-chain

The chain SDK for Robinhood Chain: viem chain definitions, verified token constants, decimals-safe formatting, on-chain address verification, and log scanning that survives mainnet.

Robinhood Chain is an Arbitrum Orbit L2 (mainnet 4663, testnet 46630) that went to permissionless public mainnet on 2026-07-01. Deploying to it is easy. Reading from it correctly is where people lose a day.

Every constant in this package was read from the live chain, not copied from documentation. Every helper exists because of a specific trap that is expensive to rediscover.

npm install robinhood-chain viem

ESM only, Node 20+. viem is a peer dependency and the only dependency of any kind.


The two traps this package exists for

1. USDG has 6 decimals, not 18

USDG (Global Dollar) is the settlement asset for Stock Tokens. It is a dollar stablecoin, and it uses 6 decimals. WETH on the same chain uses 18.

An 18 default does not throw. It returns a number that looks fine:

formatToken(1_500_000n, 6)   // '1.5'          <- correct
formatToken(1_500_000n, 18)  // '0.0000000000015'  <- wrong by 10^12, still renders

In the parse direction the same mistake attempts to move a trillion times the intended amount.

So nothing in this package ever defaults decimals. formatToken and parseToken require an explicit value and throw MissingDecimalsError without one. readDecimals reads from the contract at call time and throws if the read fails rather than falling back.

2. Two live tokens on mainnet answer to "USDG"

| Address | Name | Decimals | |---|---|---| | 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 | Global Dollar | 6 | | 0x8218d73C00567A01481495Ad6c5143e00D5BB5b4 | Useless Stupid Degen Gamblers | 18 |

Both are live right now. Both have pools. Both return from a symbol search on DexScreener. The second is a memecoin deliberately squatting the ticker of the first, and it carries 18 decimals, so a codebase that resolves by symbol and defaults decimals gets both failures at once.

Symbols are attacker-controlled strings with no uniqueness guarantee at any layer of this stack. Resolve by contract address, always. assertCanonicalToken proves an address is what it claims by reading name/symbol/decimals on-chain:

await assertCanonicalToken(client, USDG.address, USDG)  // resolves
await assertCanonicalToken(client, FAKE, USDG)          // throws NotCanonicalTokenError

Two further traps, less dramatic but just as costly:

  • viem's multicall() throws without contracts.multicall3 on the chain definition. Not a degraded read, not a fall back to individual eth_calls: ChainDoesNotSupportContract, thrown before anything is sent. The address is deployed on both networks; viem just refuses without the declaration. Both chain definitions here declare it.
  • eth_getLogs on mainnet caps how many logs a query may MATCH, not how wide its block range is, and it has reported that rejection under two different error messages on the same day. See Log scanning.

Quick start

import { createPublicClient, http } from 'viem'
import { robinhoodChain, USDG, assertCanonicalToken, readBalance } from 'robinhood-chain'

const client = createPublicClient({
  chain: robinhoodChain,
  transport: http(),
})

// Prove the token is the real Global Dollar before touching it.
const meta = await assertCanonicalToken(client, USDG.address, USDG)
console.log(meta.name, meta.decimals)  // 'Global Dollar' 6

// Balance formatted with decimals read from the same contract.
const balance = await readBalance(client, {
  token: USDG.address,
  account: '0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73',
})
console.log(balance.formatted, balance.decimals)

The chain definition supplies the RPC URL, so bare http() works. Pass your own endpoint for anything production-shaped.


Runnable example: catch the collision yourself

Save as collision.mjs and run it. No key, no funded account, read-only.

import { createPublicClient, http } from 'viem'
import {
  robinhoodChain, USDG, formatToken, readTokenMetadata,
  assertCanonicalToken, NotCanonicalTokenError,
} from 'robinhood-chain'

const client = createPublicClient({ chain: robinhoodChain, transport: http() })
const IMPOSTOR = '0x8218d73C00567A01481495Ad6c5143e00D5BB5b4'

for (const address of [USDG.address, IMPOSTOR]) {
  const t = await readTokenMetadata(client, address)
  console.log(`${t.symbol}  ${t.address}  ${JSON.stringify(t.name)}  ${t.decimals} decimals`)
}

// Identical raw amount, wildly different meaning.
const raw = 1_500_000n
console.log(`\n1500000n as real USDG:     ${formatToken(raw, 6)}`)
console.log(`1500000n at 18 decimals:  ${formatToken(raw, 18)}`)

try {
  await assertCanonicalToken(client, IMPOSTOR, USDG)
  console.log('\nthis line never runs')
} catch (error) {
  if (!(error instanceof NotCanonicalTokenError)) throw error
  console.log(`\nrejected: ${error.mismatches.map((m) => m.field).join(', ')} mismatch`)
}

Actual output, run against https://rpc.mainnet.chain.robinhood.com:

USDG  0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168  "Global Dollar"  6 decimals
USDG  0x8218d73C00567A01481495Ad6c5143e00D5BB5b4  "Useless Stupid Degen Gamblers"  18 decimals

1500000n as real USDG:     1.5
1500000n at 18 decimals:  0.0000000000015

rejected: address mismatch

The package ships this as scripts/smoke.mjs. Run npm run smoke from the package directory.


API

Chains

| Export | Description | |---|---| | robinhoodChain | viem Chain for mainnet (4663), Multicall3 declared | | robinhoodTestnet | viem Chain for testnet (46630), Multicall3 declared | | CHAINS | both, mainnet first | | chainsById | { 4663: …, 46630: … } | | getChain(id) | resolve by ID; throws UnsupportedChainError rather than returning undefined | | isRobinhoodChain(id) | boolean | | MULTICALL3_ADDRESS | 0xcA11bde05977b3631167028862bE2a173976CA11 | | hasMulticall3(client) | confirms the bytecode is still deployed | | addChainParams | EIP-3085 wallet_addEthereumChain payloads | | ROBINHOOD_MAINNET_ID / ROBINHOOD_TESTNET_ID | 4663 / 46630 |

Both definitions carry rpcUrls, blockExplorers (Blockscout, with apiUrl), nativeCurrency ETH, and contracts.multicall3.

addChainParams uses hex string chain IDs ('0x1237', '0xb626'). EIP-3085 requires this; passing the decimal 4663 fails with an opaque wallet error.

await window.ethereum.request({
  method: 'wallet_addEthereumChain',
  params: [addChainParams.mainnet],
})

Tokens

| Export | Description | |---|---| | USDG | Global Dollar. decimals: 6 | | WETH | Wrapped Ether. decimals: 18 | | KNOWN_TOKENS | { USDG, WETH }, for compile-time use in your own source | | KNOWN_IMPOSTORS | documented ticker squatters observed live | | knownTokenAt(address) | resolve by address, or null | | isKnownImpostor(address) | advisory flag |

Both real tokens are proxies. Interact through the addresses above and never cache an implementation address; it can change on upgrade.

KNOWN_IMPOSTORS is a convenience for surfacing a warning in a UI, not a security boundary. A new impostor costs one deploy, so the list can never be complete. isKnownImpostor returning false means "not on our list", never "safe". Verify by address.

Do not feed user input into KNOWN_TOKENS: keying a lookup on a symbol is the exact bug the collision exploits.

Formatting

| Export | Description | |---|---| | formatToken(amount, decimals) | raw bigint to decimal string. decimals required | | parseToken(value, decimals) | decimal string to raw bigint. decimals required | | readDecimals(client, address, { cache }) | read decimals() on-chain; throws rather than defaulting | | readBalance(client, { token, account, cache }) | { raw, decimals, formatted } | | assertDecimals(decimals, context) | validate; throws MissingDecimalsError when absent |

readDecimals caches only when you pass a Map, so it never quietly serves a stale value you did not ask it to keep.

const cache = new Map()
const decimals = await readDecimals(client, USDG.address, { cache })
const amount = parseToken('250.75', decimals)  // 250750000n

Verification

| Export | Description | |---|---| | assertCanonicalToken(client, address, expected, opts?) | prove identity on-chain; throws NotCanonicalTokenError | | verifyToken(client, address, expected, opts?) | non-throwing: { ok, metadata, error } | | readTokenMetadata(client, address) | { address, chainId, name, symbol, decimals, readAt } |

expected is a token constant or any subset of { address, name, symbol, decimals }. Supply at least one field; verifying against nothing verifies nothing, and the function throws if you try.

Checks run in order of trustworthiness. Address is compared first because it is the only field an attacker cannot choose, and a mismatch there short-circuits without a network round trip. readTokenMetadata rejects an address with no bytecode before reading anything, and uses Multicall3 with a genuine sequential fallback.

opts.caseInsensitive relaxes name/symbol comparison. Off by default.

Use it at every boundary that accepts an address from a user, a URL parameter, a config file, a search result, or another service:

const result = await verifyToken(client, userSuppliedAddress, USDG)
if (!result.ok) {
  showWarning(`Not the canonical USDG: ${result.error.message}`)
}

name and symbol in the returned metadata are attacker-controlled strings. Render them as data, escape them in HTML, never route logic on them, and never interpolate them into a prompt or a shell command.

Log scanning

| Export | Description | |---|---| | scanLogs(options) | collect logs over a range: { logs, cursor, done, stats } | | streamLogs(options) | async generator, one batch per successful chunk | | createCursor / serializeCursor / deserializeCursor | resumable cursor, JSON-safe | | DEFAULT_CHUNK (1000n) / MIN_CHUNK (10n) | tuned to measured mainnet limits | | BLOCK_TIME_MS (101) / blocksToMs(n) | convert block counts to real time | | classifyScanError(error) | advisory cap classifier, for reporting only |

Mainnet eth_getLogs caps the number of logs a query may MATCH. It does not cap block span. Measured live 2026-07-20:

| Block span | Matched-log allowance | |---|---| | 1001 blocks or fewer | 50,000 | | 1002 blocks or more | 10,000 |

There is no hard block-span cap. A 500,000-block range is accepted without complaint when its filter matches little enough: filtered to an address with no WETH transfers, that range returns []. Span only selects which allowance applies. On the busy WETH contract the boundary is one block wide: a 1001-block span returned 14,517 logs successfully, while 1002 blocks of the same contract was rejected at the 10,000 limit. Returning 14,517 logs from the narrower query is the proof that the higher tier is real, not a rounding artifact. That count moves with live activity (11,508 on a re-run minutes later); the boundary does not.

A second, independent cap is on response size, reported as HTTP response body exceeded the size limit. A high-volume query can trip it while staying under the matched-log allowance.

The error strings are not a stable contract, and this is the important lesson. The matched-log rejection currently reads logs matched by query exceeds limit of N. Earlier the same day, on the same RPC, the identical condition reported Missing or invalid parameters, a message that names the wrong problem entirely and costs an hour chasing a parameter bug that does not exist. The server changed its wording with no warning.

The scanner halves on any error rather than matching an error string, so that change required no code change here: a string-matching scanner would have silently stopped retrying the moment the server reworded. classifyScanError recognizes both wordings, but it is for reporting only. Never branch on it.

Testnet is materially more permissive; a 1501-block span succeeds there. Constants tuned on testnet fail immediately on mainnet. DEFAULT_CHUNK is 1000n because a chunk of 1000 blocks queries an inclusive span of 1001, exactly the widest chunk that still earns the 50,000-log allowance. One more block would drop the allowance to 10,000, so 1001 is a strictly worse chunk size than 1000 despite being wider.

At approximately 101 ms blocks, mainnet produces roughly 850,000 blocks per day. DEFAULT_CHUNK is about 101 seconds of chain history, not hours. Convert block counts to time in your own comments and UI copy; every range intuition from a 12-second L1 is off by two orders of magnitude here.

import { parseAbiItem } from 'viem'
import { scanLogs, WETH } from 'robinhood-chain'

const head = await client.getBlockNumber()
const { logs, stats } = await scanLogs({
  client,
  address: WETH.address,
  event: parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)'),
  fromBlock: head - 2000n,
  toBlock: head,
})

console.log(logs.length, stats)
// 23906 { chunksScanned: 3, halvings: 0, finalChunkSize: 1000n, logsFound: 23906, elapsedMs: 1524 }

That is a real run against mainnet on 2026-07-20. The log count moves with live activity; chunksScanned: 3 and halvings: 0 are the parts that should not.

halvings: 0 is what a correctly tuned scan looks like. Halvings in the stats usually mean you set chunkSize past the tier boundary and are paying a failed request per chunk. On an unfiltered scan of a very busy contract they can also mean a genuine volume spike tripped a cap at the default chunk, which is exactly the case the adaptive path exists for.

Bounded, resumable backfill, for keeping work inside a cron window or a request timeout:

let cursor = savedCursor ? deserializeCursor(savedCursor) : undefined

const { logs, cursor: next, done } = await scanLogs({
  client,
  address: WETH.address,
  fromBlock: 15_000_000n,
  toBlock: 15_100_000n,
  cursor,
  maxChunks: 20,
})

await persist(serializeCursor(next))
if (!done) scheduleNextRun()

LogScanError carries the cursor too, so a scan that dies at the floor can be resumed rather than restarted.

Errors

All extend RobinhoodChainError, so one catch covers the package.

| Error | Thrown when | |---|---| | UnsupportedChainError | a chain ID is not 4663 or 46630. Carries supported | | MissingDecimalsError | decimals were needed and not supplied. Never a silent 18 | | NotCanonicalTokenError | an address is not the token it claims. Carries mismatches and actual | | LogScanError | a scan cannot progress at the minimum chunk. Carries cursor |


Testing

npm test        # 42 offline tests, no network
npm run test:live   # 12 additional tests against mainnet, read-only
npm run smoke       # the live smoke script

Live tests are gated behind RH_LIVE_TESTS=1 so the default run works offline and in CI. They are read-only: no key, no funded account, no spend. Override the endpoint with RH_MAINNET_RPC.

The offline suite includes a stub RPC that reproduces the eth_getLogs caps as the endpoint actually implements them: a matched-log allowance tiered by span, plus an independent response-size cap. The stub can reject with either observed wording, and one test asserts the scan result is byte-identical under both, which is the property that let the library survive the live message change untouched. The live suite re-confirms every shipped constant against the chain, probes the 1001/1002 tier boundary directly, and proves the collision is caught for real rather than against a fixture.


Verified facts and their dates

Everything below was read from the live chain, most recently 2026-07-20.

| Fact | Value | |---|---| | Mainnet chain ID | 4663 (0x1237) | | Testnet chain ID | 46630 (0xb626) | | Client version | nitro/v3.11.3-rc.4-4bed0c5, both networks | | Mainnet cadence | approximately 101 ms at approximately 0.056 gwei | | Testnet cadence | approximately 432 ms at a flat 0.01 gwei | | Multicall3 | deployed at 0xcA11bde05977b3631167028862bE2a173976CA11 on both, bytecode confirmed | | USDG | 0x5fc5…d168, name Global Dollar, 6 decimals, proxy | | WETH | 0x0Bd7…AD73, name WETH, 18 decimals, proxy | | USDG impostor | 0x8218…B5b4, name Useless Stupid Degen Gamblers, symbol USDG, 18 decimals | | eth_getLogs block span cap | none; a 500,000-block range is accepted when its filter matches nothing | | eth_getLogs matched-log cap | 50,000 within a span of 1001 blocks or fewer, 10,000 beyond it | | eth_getLogs cap error text | UNSTABLE. logs matched by query exceeds limit of N and Missing or invalid parameters both observed for the same condition on the same day | | eth_getLogs size cap | separate from the above; reported as HTTP response body exceeded the size limit |

Public RPC rate limits are UNVERIFIED. Measure under your own load or use a keyed provider where the limit is contractual. Build backoff on 429 from day one.

USDG's testnet deployment is UNVERIFIED. The address above is confirmed on mainnet only. Do not copy it to testnet and assume it works.

Related

Disclaimer

Not affiliated with, endorsed by, or sponsored by Robinhood Markets, Inc. or any of its subsidiaries. "Robinhood" is used nominatively to identify the network this package targets.

Robinhood Chain is centralized today: Robinhood operates both the sequencer and the proposer. Canonical bridge withdrawals take about 7 days. On-chain balances are not brokerage balances. Nothing here is financial advice.

License

MIT © 2026 nirholas