DocsGuides
Keeper and executor
The executor is the one address allowed to call Kroplomierz.kapnij. plop runs a keeper whose key is the default executor for droppers created from the web app. Anyone can run the same keeper for their own droppers, or write their own executor against the contracts and rules.
| Default executor on Robinhood Chain | 0x5e25b93C6454C10E4110B70DAFa76C6934cDee03 |
| Factory keeper | keeper/fabryka.mjs: all droppers in the registry that name this key as executor |
| Single-dropper CLI | keeper/harmonogram.mjs: one dropper, rules from a local file; also the library the factory keeper calls |
The executor key is a hot wallet that holds gas only. It never holds the token; the supply sits in the dropper behind a cap and an interval, and the owner can pause or withdraw at any time. See Trust model for what the executor can and cannot do.
What one pass of the factory keeper does#
Every --co seconds (default 15, minimum 3):
- Reads the whole registry:
ile()thenlista(od, 100)pages. - For every entry, reads the dropper (
token,wykonawca,wstrzymane,stan(), cap, interval) and skips it when:wykonawca()is not the keeper's address (someone else's dropper);- it is paused;
- the balance is below one drop (
saldo < reguly.kwota, or zero); - the interval has not elapsed (
doNastepnej > 0).
- Otherwise runs the same path as the single-dropper CLI,
wykonajKrople:- read
decimalsand the liveowner()from chain; - convert the registry rules to engine rules, adding owner and executor to
wykluczonewhenpomijajWlasneis set; - build or refresh the holder snapshot of the token (see below);
- refuse to send if the snapshot is incomplete (
pelna: false) or if any pre-flight check fails; - run the split engine;
- split the allocations into batches of at most 400 recipients (
--wsad, max 1000); - write the plan to the journal, then for each batch: sign a legacy transaction with
gasPricefrom the node and a 25 % gas margin, journal "batch started" with its hash, broadcast, wait for the receipt, journal "batch done". The first batch opens the epoch (epokaId = epoka + 1), the last one closes it (domkniecie = true).
- read
- Errors on one dropper are logged and the loop moves on to the next. Droppers are processed one at a time because they share one nonce and one RPC budget.
Pre-flight checks#
Before any transaction the keeper verifies, and reports all failures at once:
- the snapshot reconciles with
totalSupplyat its block; - the keeper's key is the dropper's executor;
- the dropper is not paused, the epoch is not closed, the interval has elapsed;
- the drop total does not exceed
maksNaEpoke(with the excess printed); - the balance covers the drop (with the shortfall printed);
- no address appears in two batches (the contract cannot see the other batch and would pay twice);
- no zero amount and no zero address;
- the first batch simulates successfully with
eth_call; - the executor has ETH for gas, with a warning below roughly three drops' worth.
Journal and restarts#
Every dropper gets its own JSON Lines file, keeper/dane/epoki-<chainId>-<dropper>.jsonl, appended and fsynced. Record types, in the order they appear:
typ | Written | Carries |
|---|---|---|
plan | before anything is sent | epoch, rules hash, total, token, decimals, snapshot block, every batch with recipients, amounts and skrotListy |
wsad-start | before broadcasting a batch | batch index, list hash, transaction hash, nonce, block before |
wsad-koniec | after the receipt | batch index, block, gas |
wsad-odnaleziony | on restart, when a started batch is found on chain | how it was found |
epoka-domknieta | when the closing batch is confirmed | |
stop | when the keeper refuses to continue | reason |
The order is deliberate: a record that says "I may have sent this" is safe, because it forces the next run to check the chain; a missing record for a sent transaction is not. On restart, every batch with wsad-start but no wsad-koniec is resolved in this order:
- receipt by transaction hash;
- a
Kroplaevent with the sameskrotListy(catches a replaced transaction); - the account nonce: if it was never consumed, the batch is resent.
If the nonce was consumed and no drop can be found, the keeper stops and asks for a human. An interrupted epoch is finished from the journal's plan, not recomputed, so the epoch gets exactly the list that was planned.
Running the factory keeper#
Requirements: Node 22.13 or newer, pnpm install done in web/ (the keeper imports web/lib/*.ts and viem from there), and an archive RPC endpoint (the snapshot scans Transfer logs from the token's deployment block and finds that block by binary search on eth_getCode).
# keeper/.env.keeper (chmod 600, ignored by git)
WYKONAWCA_KLUCZ=0x<private key of the executor>
WYKONAWCA_ADRES=0x<its address>
# web/.env.rh.local (server-side, ignored by git)
RPC_URL=https://<archive endpoint>
NEXT_PUBLIC_CHAIN_ID=4663
NEXT_PUBLIC_FABRYKA=0x1CE8f5785617eaf9B6BB3c77CE7d242D86Dd0502
# dry run: computes every plan, sends nothing
(set -a; . keeper/.env.keeper; . web/.env.rh.local; set +a; node keeper/fabryka.mjs --sucho --raz)
# live, looping every 15 s
(set -a; . keeper/.env.keeper; . web/.env.rh.local; set +a; \
nohup node keeper/fabryka.mjs --wyslij --co 15 >> keeper/dane/fabryka.log 2>&1 &)
| Flag | Meaning |
|---|---|
--wyslij | send transactions; without it the keeper refuses to start unless --sucho |
--sucho | compute everything, send nothing |
--raz | one pass, then exit |
--co <s> | seconds between passes (default 15, minimum 3) |
--fabryka <addr> | registry address (default NEXT_PUBLIC_FABRYKA) |
Environment: WYKONAWCA_KLUCZ (the only accepted source of the key; a value that looks like a file path or any key-like NEXT_PUBLIC_* variable aborts the start), RPC_URL, NEXT_PUBLIC_FABRYKA, optional NEXT_PUBLIC_CHAIN_ID, SDROP_DZIENNIK and SDROP_CACHE for the journal and snapshot cache directories, SDROP_DEBUG=1 for stack traces.
SIGINT/SIGTERM finish the batch in flight and stop; a second signal exits immediately.
The single-dropper CLI#
keeper/harmonogram.mjs drives one dropper described by web/dane/wdrozenie.json with rules from keeper/reguly.json (template: keeper/reguly.przyklad.json, amounts as strings in token units, times as 7d/36h/90m/30s). It predates the registry and is still the place to rehearse a drop:
node keeper/harmonogram.mjs <command> [flags]
pomoc help (default; the program never sends anything on its own)
stan what is on chain and in the journal
plan the whole path dry: snapshot, rules, split, batches, full recipient list, eth_call simulation
kropla one drop; without --wyslij behaves like plan
pilnuj wait for the interval and keep dropping; requires --wyslij
--wyslij --sucho --reguly <file> --wdrozenie <file> --rpc <url> --wsad <n> --ile <n>
--wykonawca <addr> --pozwol-niepelna --pelna-lista --juz --dziennik <dir> --cache <dir>
--pozwol-niepelna is the only way to send on a snapshot that does not reconcile with totalSupply; the factory keeper never does.
Being your own executor#
Set your own address as wykonawca (at creation or with ustaw) and call kapnij yourself. To stay compatible with the web app's audit view:
- compute the list with the split engine on a snapshot taken at one block, with
nowequal to that block's timestamp; - pass the canonical rules hash as
skrotRegul; - keep every recipient in exactly one batch of the epoch, open the epoch with
epokaId = epoka + 1, close it withdomkniecie = trueon the last batch; - send legacy transactions with
gasPricefrom the node; there is no priority-fee auction on this chain.
The web app shows the coin as Drops are not automated while the executor is neither the keeper nor the zero address, and offers the owner a one-click ustaw to hand the drops to the keeper.
Gas#
One batch of 400 recipients is a few million gas. Keep the executor funded for more than one epoch; a dropper that runs out of gas mid-epoch leaves the epoch open until the next pass. The keeper warns when the balance covers fewer than three drops.