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.

  1. 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 equal skrotListy (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 accepted

    The 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.

  2. The rules are the published ones. Rebuild the canonical rules text from the registry rules in force at that block (the latest ZmianaRegul before it, or wpis() if unchanged) plus the project wallets when pomijajWlasne is set, and compare its keccak with skrotRegul (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.

  3. 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.

  4. The cap held. The sum of suma over all batches with the same epoka must not exceed maksNaEpoke as read at that block, and consecutive epoch openings must be at least minOdstep apart 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:

  1. Reconstruct from Transfer logs, from the token's deployment block to the snapshot block. Sort by (blockNumber, logIndex); skip duplicates; ignore self-transfers; treat from = 0x0 as mint and to = 0x0 as burn.
  2. Track the holding streak. For each address, "held since" (odKiedy in the API) is the block (then its header time) since which the balance has stayed at or above the threshold (prog in the API, the coin's minSaldo). 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.
  3. 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.
  4. 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: false in the API) and must not be used to pay.
  5. Mark contracts with eth_getCode at the snapshot block, and take every timestamp from block headers.
  6. 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 no Retry-After). Cache the reconstructed history on disk so the next snapshot only scans new blocks.
  7. 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:

ChartSource
Drops over time, amount per dropKropla.suma by header time
Remaining supply in the dropperthe balance curve
Recipients per drop, qualifying vs total holdersKropla.odbiorcow vs snapshot size
Concentration: share of each drop going to the top 10 walletsdecoded amounts per batch
Per-wallet cumulative receivedTransfer from the dropper, grouped by to
Time to next drop across all coinsstan().doNastepnej + header time, per registry entry
Rule changesZmianaRegul per dropper
Creator withdrawals and top-upsWyplata, Zasilenie

RPC limits to design around#

Measured on the public Robinhood Chain endpoint while building plop:

  • eth_getLogs with 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_getLogs calls can answer 429 with no Retry-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_getCode and eth_call at 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 blockTimestamp is unreliable (0x0); read headers.