DocsReference
Reading the data
Everything the web app shows can be rebuilt from chain data with a public RPC and no plop code. This page is for indexers, dashboards, bots and auditors. Where plop already exposes the result as JSON, the HTTP API is the shortcut; the on-chain path is the ground truth.
Every snippet below was run against Robinhood Chain with viem 2.x. Replace the client with any EVM library. Function, event and field names in the ABI are Polish (they are the deployed contract's); the glossary translates them.
import { createPublicClient, http, parseAbi, parseAbiItem } from 'viem'
const RPC = 'https://rpc.mainnet.chain.robinhood.com'
const REGISTRY = '0x1CE8f5785617eaf9B6BB3c77CE7d242D86Dd0502'
const REGISTRY_DEPLOYED_AT = 63_645_852n // nothing to scan before this block
const MULTICALL3 = '0xcA11bde05977b3631167028862bE2a173976CA11'
const client = createPublicClient({ transport: http(RPC) })
Listing the registry#
One ile() (count) and one lista() (list) per hundred entries. No log scan is needed to enumerate droppers.
const registryAbi = parseAbi([
'struct Reguly { uint256 kwota; uint256 minSaldo; uint256 sufitNaAdres; uint64 minCzasTrzymania; uint64 okresBezruchu; bool porowno; bool pomijajKontrakty; bool pomijajWlasne; }',
'struct Wpis { address kroplomierz; address token; address wlasciciel; uint64 utworzono; string logo; Reguly reguly; }',
'function ile() view returns (uint256)',
'function lista(uint256 od, uint256 ile) view returns (Wpis[])',
'function wpis(address kroplomierz) view returns (Wpis)',
'function poTokenie(address token) view returns (address[])',
])
const count = await client.readContract({ address: REGISTRY, abi: registryAbi, functionName: 'ile' })
const entries = []
for (let offset = 0n; offset < count; offset += 100n) {
entries.push(...(await client.readContract({ address: REGISTRY, abi: registryAbi, functionName: 'lista', args: [offset, 100n] })))
}
// entries[i].kroplomierz (dropper), .token, .wlasciciel (owner at registration, display only),
// .utworzono (registered at), .logo, .reguly (rules: kwota = amount per drop, minSaldo = minimum balance, …)
wlasciciel is the owner at registration; read owner() on the dropper for the current one. logo is untrusted text: render only https:// and ipfs://, never inline it as HTML.
Dropper state, pinned to one block#
Blocks are ~100 ms apart, so reads "at latest" of several droppers describe several moments. Read the header first and pin every call to its number; the countdown is then relative to that header's timestamp.
const dropperAbi = parseAbi([
'function stan() view returns (uint256 saldo, uint256 doNastepnej, uint256 epokaTeraz)', // state: balance, seconds to next epoch, epoch counter
'function wstrzymane() view returns (bool)', // paused
'function wykonawca() view returns (address)', // executor
'function owner() view returns (address)',
'function maksNaEpoke() view returns (uint256)', // cap per epoch
'function minOdstep() view returns (uint256)', // minimum interval, seconds
'function token() view returns (address)',
])
const erc20Abi = parseAbi([
'function symbol() view returns (string)',
'function decimals() view returns (uint8)',
'function totalSupply() view returns (uint256)',
])
const entry = entries[0]
const block = await client.getBlock() // number + timestamp
const [state, paused, executor, cap, interval, symbol, decimals, totalSupply] = await client.multicall({
multicallAddress: MULTICALL3,
blockNumber: block.number,
allowFailure: false,
contracts: [
{ address: entry.kroplomierz, abi: dropperAbi, functionName: 'stan' },
{ address: entry.kroplomierz, abi: dropperAbi, functionName: 'wstrzymane' },
{ address: entry.kroplomierz, abi: dropperAbi, functionName: 'wykonawca' },
{ address: entry.kroplomierz, abi: dropperAbi, functionName: 'maksNaEpoke' },
{ address: entry.kroplomierz, abi: dropperAbi, functionName: 'minOdstep' },
{ address: entry.token, abi: erc20Abi, functionName: 'symbol' },
{ address: entry.token, abi: erc20Abi, functionName: 'decimals' },
{ address: entry.token, abi: erc20Abi, functionName: 'totalSupply' },
],
})
const [balance, secondsToNext, epoch] = state
// the same status rule the dashboard uses
const amountPerDrop = entry.reguly.kwota
const empty = amountPerDrop > 0n ? balance < amountPerDrop : balance === 0n
const status = !empty && !paused ? 'dripping' : paused ? 'paused' : 'empty'
const nextDropAt = secondsToNext === 0n ? 'now' : block.timestamp + secondsToNext // unix seconds
const dropsLeft = amountPerDrop > 0n ? balance / amountPerDrop : null
Use allowFailure: true in production: the registry is permissionless and a "token" can be a contract that reverts on symbol().
Drop history#
Kropla (drop) events on the dropper address, from its deployment block. Recipients are not in the event (they are in the calldata), but the recipient count, the sum and the two hashes are.
The deployment block#
For a dropper created through the registry, the Utworzono (created) event with przezFabryke = true is emitted in the deployment transaction, so its block is the deployment block:
const createdEvent = parseAbiItem('event Utworzono(address indexed kroplomierz, address indexed token, address indexed wlasciciel, uint64 utworzono, bool przezFabryke)')
const [created] = await client.getLogs({
address: REGISTRY,
event: createdEvent,
args: { kroplomierz: entry.kroplomierz },
fromBlock: REGISTRY_DEPLOYED_AT,
toBlock: block.number,
})
const deployedAt = created.args.przezFabryke ? created.blockNumber : null
When przezFabryke is false the dropper existed before registration and the event block is only an upper bound. Find the real deployment block by binary search on eth_getCode (needs an archive node), or scan from the token's first Transfer.
Kropla events#
const dropEvent = parseAbiItem('event Kropla(uint256 indexed epoka, uint256 odbiorcow, uint256 suma, bytes32 skrotListy, bytes32 skrotRegul, bool domkniecie)')
// epoka = epoch, odbiorcow = recipient count, suma = sum, skrotListy = list hash, skrotRegul = rules hash, domkniecie = closes the epoch
const CHUNK = 50_000n
const drops = []
for (let from = deployedAt; from <= block.number; from += CHUNK) {
const to = from + CHUNK - 1n < block.number ? from + CHUNK - 1n : block.number
drops.push(...(await client.getLogs({ address: entry.kroplomierz, event: dropEvent, fromBlock: from, toBlock: to })))
}
// one element per batch; several batches of one epoch share args.epoka
Timestamps come from headers. On this chain the blockTimestamp field of a log is often 0x0:
const header = await client.getBlock({ blockNumber: drops[0].blockNumber })
const droppedAt = Number(header.timestamp) // never Number(drops[0].blockTimestamp)
Fetch one header per distinct block and cache them; drops of one dropper rarely share a block.
The balance curve#
The dropper's balance after each drop can be reconstructed backwards from the live balance, because only three dropper events move it: Kropla (down by suma), Zasilenie (funding, up by ile), Wyplata (withdrawal, down by ile). Fetch all three with one filter (events: [dropEvent, fundedEvent, withdrawnEvent]), sort by (blockNumber, logIndex), start from stan().saldo at the head block and undo each move from newest to oldest.
Two things make the curve an approximation before the latest drop: funding done with a plain token.transfer (no Zasilenie) and fee-on-transfer tokens (the event records the intended amount). Floor the curve at zero; the balance never went below it.
Who received what#
The token's Transfer events with from = dropper, grouped by recipient. This counts what was delivered, which for a fee-on-transfer token is less than what the dropper intended.
const transferEvent = parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)')
const transfers = []
for (let from = deployedAt; from <= block.number; from += CHUNK) {
const to = from + CHUNK - 1n < block.number ? from + CHUNK - 1n : block.number
transfers.push(...(await client.getLogs({ address: entry.token, event: transferEvent, args: { from: entry.kroplomierz }, fromBlock: from, toBlock: to })))
}
const perWallet = new Map<string, bigint>()
for (const t of transfers) perWallet.set(t.args.to, (perWallet.get(t.args.to) ?? 0n) + t.args.value)
// per drop: group transfers by transactionHash instead
Filtering by to as well gives one wallet's history; the API route /api/dostalem does exactly this.
Verifying a drop#
Every batch can be checked against what the keeper claims it did.
The list is what was paid. Decode the
kapnij(drop) calldata of the transaction and hash the arrays the way the contract does; it must equalskrotListy(list hash) in the event.import { decodeFunctionData, encodeAbiParameters, keccak256 } from 'viem' const dropFunctionAbi = parseAbi(['function kapnij(uint256 epokaId, address[] odbiorcy, uint256[] kwoty, bytes32 skrotRegul, bool domkniecie)']) const drop = drops[0] const tx = await client.getTransaction({ hash: drop.transactionHash }) const { args: [epochId, recipients, amounts, rulesHash, closesEpoch] } = decodeFunctionData({ abi: dropFunctionAbi, data: tx.input }) const listHash = keccak256(encodeAbiParameters([{ type: 'address[]' }, { type: 'uint256[]' }], [recipients, amounts])) listHash === drop.args.skrotListy // true for every batch the contract acceptedThe contract computed the hash from the same calldata, so this always holds; the point is to obtain the list itself from a source that cannot be edited afterwards.
The rules are the published ones. Rebuild the canonical rules text from the registry rules in force at that block (the latest
ZmianaRegulbefore it, orwpis()if unchanged) plus the project wallets whenpomijajWlasneis set, and compare its keccak withskrotRegul(rules hash). A mismatch means the executor used different rules, most often a keeper-side manual exclusion list, which is not on chain. Compare the lists in that case.The list follows from the rules. Build a holder snapshot at the block just before the drop, run the split engine, and compare the allocations with the decoded
recipients/amounts. Amounts must match exactly; the engine is deterministic, including ordering.The cap held. The sum of
sumaover all batches with the sameepokamust not exceedmaksNaEpokeas read at that block, and consecutive epoch openings must be at leastminOdstepapart in header time.
Building a holder snapshot#
The snapshot is the list of every address with its balance at a block, plus how long it has held continuously, plus whether it is a contract. It is the input to the split and the most expensive thing in plop. The reference implementation is web/lib/holdery.ts; the rules it follows:
- Reconstruct from
Transferlogs, from the token's deployment block to the snapshot block. Sort by(blockNumber, logIndex); skip duplicates; ignore self-transfers; treatfrom = 0x0as mint andto = 0x0as burn. - Track the holding streak. For each address, "held since" (
odKiedyin the API) is the block (then its header time) since which the balance has stayed at or above the threshold (progin the API, the coin'sminSaldo). Any dip below the threshold resets it. A zero balance never qualifies, even with a threshold of zero. Check the threshold after both legs of a transfer, so an address that sends to itself does not lose its streak. - Never go below zero. A negative running balance means the scan started after the token's deployment; clamp to zero and mark the index incomplete. Errors must go against paying, never towards it.
- Reconcile. The sum of all balances at the snapshot block must equal
totalSupply()at that block. If it does not, the snapshot is incomplete (pelna: falsein the API) and must not be used to pay. - Mark contracts with
eth_getCodeat the snapshot block, and take every timestamp from block headers. - Chunk the scan in 50 000-block windows with the token as the address filter, halve the window when the node refuses or when a response hits the 10 000-log ceiling, and back off on
429(about 0.7 s on the public node, which sends noRetry-After). Cache the reconstructed history on disk so the next snapshot only scans new blocks. - Compute qualification at the snapshot time, never at a later clock: a wallet whose holding period completes after the snapshot may have sold since.
The web app serves the result as /api/migawka, including the threshold it used and whether it is complete.
Ideas for charts#
All of these come from the queries above with no extra data source:
| Chart | Source |
|---|---|
| Drops over time, amount per drop | Kropla.suma by header time |
| Remaining supply in the dropper | the balance curve |
| Recipients per drop, qualifying vs total holders | Kropla.odbiorcow vs snapshot size |
| Concentration: share of each drop going to the top 10 wallets | decoded amounts per batch |
| Per-wallet cumulative received | Transfer from the dropper, grouped by to |
| Time to next drop across all coins | stan().doNastepnej + header time, per registry entry |
| Rule changes | ZmianaRegul per dropper |
| Creator withdrawals and top-ups | Wyplata, Zasilenie |
RPC limits to design around#
Measured on the public Robinhood Chain endpoint while building plop:
eth_getLogswith a single address filter accepts wide ranges when few logs match (hundreds of thousands of blocks worked for one dropper), but busy addresses hit the 10 000-log ceiling; plop scans 50 000-block windows and halves on refusal. Without an address filter the practical window is about 40 000 blocks.- Two or three consecutive
eth_getLogscalls can answer429with noRetry-After; wait about a second and retry rather than shrinking the window, which only means more calls. - The public endpoint keeps no archive: historical
eth_getCodeandeth_callat old blocks fail. Use an archive provider for snapshots and deployment-block searches. - Batch JSON-RPC requests are accepted; Multicall3 is deployed at the canonical address.
- Log
blockTimestampis unreliable (0x0); read headers.