DocsReference

Contracts

Two contracts, both in kontrakty/src, Solidity 0.8.24, EVM cancun, optimizer 1 000 000 runs, OpenZeppelin Ownable2Step, ReentrancyGuard and SafeERC20.

Address on Robinhood Chain (4663)
Fabryka (registry)0x1CE8f5785617eaf9B6BB3c77CE7d242D86Dd0502, deployed at block 63645852
Kroplomierz (dropper)one per airdrop; addresses come from Fabryka.lista() or the Utworzono event

Both contracts use block.timestamp only. On this chain block.number is the Ethereum L1 block number, so nothing here depends on it.

The full ABI is exported in web/lib/abi.ts (ABI_FABRYKA, ABI_KROPLOMIERZ) and regenerated from Foundry artifacts with pnpm --dir web abi. Human-readable signatures are listed at the end of this page.

Fabryka (registry)#

A permissionless list of droppers plus each dropper's split rules and logo. It never holds or moves tokens: no transferFrom, no receive, no fallback. The worst a bug here can do is show a wrong list.

Structs#

struct Reguly {
    uint256 kwota;             // amount per drop, base units; 0 = not set yet
    uint256 minSaldo;          // minimum balance to qualify, base units
    uint256 sufitNaAdres;      // per-wallet cap per drop, base units; 0 = no cap
    uint64  minCzasTrzymania;  // minimum continuous holding time, seconds
    uint64  okresBezruchu;     // declared quiet period, seconds; 0 = not declared
    bool    porowno;           // true = equal split; false = proportional to balance
    bool    pomijajKontrakty;  // skip addresses with code
    bool    pomijajWlasne;     // skip the dropper's owner and executor
}

struct Wpis {
    address kroplomierz;   // dropper
    address token;         // the ERC-20 it pays out
    address wlasciciel;    // owner AT REGISTRATION TIME; display only, never use for authorisation
    uint64  utworzono;     // block.timestamp of registration
    string  logo;          // "" or https:// or ipfs://, at most 256 bytes
    Reguly  reguly;
}

The registry stores the rules but does not interpret them. Qualification and amounts are computed off chain by the split engine. okresBezruchu in particular is a declaration by the creator, not a lock: the dropper's wyplac stays unconditional.

Functions#

FunctionAccessWhat it does
utworz(address token, address wykonawca, uint256 maksNaEpoke, uint256 minOdstep, string logo, Reguly reguly) returns (address kroplomierz)anyoneValidates the logo, deploys new Kroplomierz(token, msg.sender, wykonawca, maksNaEpoke, minOdstep), records the entry, emits Utworzono (with przezFabryke = true), ZmianaRegul and ZmianaLogo. Constructor reverts of the dropper propagate unchanged (ZlyAdres for a token without code, ZerowyOdstep for a zero interval).
zglos(address kroplomierz, string logo, Reguly reguly)live owner() of that dropperRegisters a dropper deployed outside the registry. The address must have code, answer owner() and token(), and the token must have code. Emits the same three events with przezFabryke = false.
ustawReguly(address kroplomierz, Reguly reguly)live owner()Replaces the rules. No validation beyond types; kwota == 0 is legal and means "not set". Emits ZmianaRegul.
ustawLogo(address kroplomierz, string logo)live owner()Replaces the logo ("" removes it). Emits ZmianaLogo.
ile() returns (uint256)viewNumber of entries.
lista(uint256 od, uint256 ile) returns (Wpis[])viewEntries od .. od + ile - 1 in registration order. ile must be 1..100 and od < ile(), otherwise ZlyZakres. A range past the end is clamped, not rejected.
wpis(address kroplomierz) returns (Wpis)viewOne entry. Reverts NieWpisany for an unknown address rather than returning zeros.
czyWpisany(address kroplomierz) returns (bool)viewMembership test.
poTokenie(address token) returns (address[])viewAll droppers of a token, in registration order. A token may have several.

Authorisation always reads Kroplomierz(kroplomierz).owner() live. The stored Wpis.wlasciciel is a snapshot for display: after a two-step ownership transfer the old owner loses ustawReguly/ustawLogo immediately and the new one gains them without any registry transaction.

Events#

EventEmitted when
Utworzono(address indexed kroplomierz, address indexed token, address indexed wlasciciel, uint64 utworzono, bool przezFabryke)a dropper is created (utworz) or registered (zglos)
ZmianaRegul(address indexed kroplomierz, Reguly reguly)at registration and on every ustawReguly
ZmianaLogo(address indexed kroplomierz, string logo)at registration and on every ustawLogo

Rules and logo are emitted at registration too, so an indexer gets the complete history from one event stream and never needs the initial state from a call.

Errors#

ErrorMeaning
JuzWpisany(address)already registered
NieWpisany(address)not registered
NieWlasciciel(address kto, address wlasciciel)caller is not the dropper's live owner
ZlyAdres()zero address or no code where code is required
ZaDlugieLogo(uint256 dlugosc)logo longer than 256 bytes
ZlySchemat()logo does not start with https:// or ipfs://
ZlyZakres()lista arguments out of range

Logo rules#

Empty, or https://…, or ipfs://…; at most 256 bytes; checked before the dropper is deployed so a bad logo does not burn the deployment gas. The web app validates the string a second time before rendering, rewrites ipfs:// to its own CID-only proxy, and falls back to a generated mark when the logo is missing or fails to load.

Kroplomierz (dropper)#

Holds one token's supply and pays it out in batches. It does not trust the executor; it limits the damage a hostile executor can do to a cap per epoch and a minimum interval, and gives the owner a pause and an unconditional exit.

Constructor#

constructor(address token_, address wlasciciel_, address wykonawca_, uint256 maks_, uint256 odstep_)
  • token_ must have code (ZlyAdres otherwise): a typo in the token address would otherwise create a dropper that can never pay anything.
  • wykonawca_ may be the zero address; then nobody can drop until the owner calls ustaw.
  • maks_ may be zero, which blocks drops more firmly than a pause.
  • odstep_ must be positive (ZerowyOdstep). With a zero interval the executor could open unlimited epochs in one block and the cap would mean nothing.
  • ostatniaKropla starts at zero, so the first epoch may open immediately after deployment.

Fabryka.utworz calls this constructor with wlasciciel_ = msg.sender.

State#

FieldTypeMeaning
tokenIERC20 immutablethe token paid out
wykonawcaaddressthe only address allowed to call kapnij
wstrzymaneboolpaused: kapnij reverts; wyplac still works
domknietaboolcurrent epoch closed: further batches rejected
maksNaEpokeuint256cap on the sum of all batches in one epoch
minOdstepuint256minimum seconds between epoch openings
epokauint256epoch counter; 0 = nothing dropped yet
ostatniaKroplauint256block.timestamp when the current epoch opened
wydaneWEpoceuint256intended amounts sent so far in the current epoch
owner(), pendingOwner()from Ownable2Stepowner and pending owner

The balance is not stored: it is always token.balanceOf(address(this)). A token that charges a fee on transfer therefore does not corrupt any ledger; less simply arrives, and that is what the balance shows.

Functions#

FunctionAccessWhat it does
zasil(uint256 ile)anyonesafeTransferFrom(msg.sender, this, ile) after an approve. Emits Zasilenie. Zero reverts NicDoWyslania. A plain token.transfer to the dropper also works but emits no dropper event.
kapnij(uint256 epokaId, address[] odbiorcy, uint256[] kwoty, bytes32 skrotRegul, bool domkniecie)wykonawca onlyPays one batch. See the epoch rules below. Emits Kropla before transferring.
wyplac(address doKogo, uint256 ile)ownerEmergency exit. Works always, including while paused and mid-epoch. Emits Wyplata.
ustaw(address wykonawca_, uint256 maks_, uint256 odstep_)ownerSets executor, cap and interval in one call. odstep_ == 0 reverts. Lowering the cap below wydaneWEpoce simply stops the current epoch from accepting more batches. Emits Ustawienia.
ustawPauze(bool v)ownerPause or resume drops. Emits Wstrzymanie.
transferOwnership(address) / acceptOwnership()owner / pending ownerTwo-step ownership transfer from OpenZeppelin.
renounceOwnership()ownerAlways reverts (ZrzeczenieZabronione). An ownerless dropper with a live executor would drain itself at the cap rate with nobody able to pause or withdraw. To hand off control instead: ustaw(address(0), 0, odstep).
stan() returns (uint256 saldo, uint256 doNastepnej, uint256 epokaTeraz)viewBalance, seconds until a new epoch may open (0 = now), epoch counter. Arithmetic saturates so an absurd interval cannot make the read revert.

Epoch rules in kapnij#

Let epoka be the current counter.

  1. msg.sender must be wykonawca (NieWykonawca), and the dropper must not be paused (Wstrzymano).
  2. odbiorcy.length == kwoty.length (NiezgodneDlugosci), non-empty (PustyWsad), no zero amount (ZerowaKwota(i)), no zero address (ZlyAdres).
  3. Epoch selection:
    • epokaId == epoka + 1 opens a new epoch. Requires block.timestamp >= ostatniaKropla + minOdstep (ZaWczesnie(now, earliest)). Resets wydaneWEpoce to the batch sum and sets ostatniaKropla = block.timestamp.
    • epokaId == epoka continues the current epoch. No interval check. Reverts if epoka == 0 (nothing to continue) or if the epoch is closed (EpokaJuzDomknieta).
    • anything else reverts ZlaEpoka(given, current).
  4. Cap: wydaneWEpoce (or 0 for a new epoch) + sum(kwoty) <= maksNaEpoke, otherwise PrzekroczonySufit(afterBatch, cap). Checked arithmetic; overflow reverts.
  5. State is written before any transfer; then Kropla is emitted with skrotListy = keccak256(abi.encode(odbiorcy, kwoty)) and the caller-supplied skrotRegul; then the transfers run in order with safeTransfer.
  6. domkniecie = true marks the batch as the last one of the epoch.

The contract records intended amounts. It does not know what a fee-on-transfer token actually delivered and does not pretend to.

Events#

EventEmitted when
Zasilenie(address indexed od, uint256 ile)zasil
Kropla(uint256 indexed epoka, uint256 odbiorcow, uint256 suma, bytes32 skrotListy, bytes32 skrotRegul, bool domkniecie)every batch
Ustawienia(address wykonawca, uint256 maksNaEpoke, uint256 minOdstep)constructor and ustaw
Wstrzymanie(bool wstrzymane)ustawPauze
Wyplata(address indexed doKogo, uint256 ile)wyplac
OwnershipTransferStarted, OwnershipTransferredOpenZeppelin two-step ownership

The recipients and amounts are not in the event; they are in the transaction calldata of kapnij, and skrotListy lets anyone check that the calldata they read is the list that was paid. See Reading the data.

Errors#

ErrorMeaning
ZlyAdres()zero address, or token without code
NieWykonawca(address kto)caller is not the executor
Wstrzymano()paused
NiezgodneDlugosci(uint256 odbiorcow, uint256 kwot)array length mismatch
PustyWsad()empty batch
ZerowaKwota(uint256 indeks)zero amount at index
ZlaEpoka(uint256 podana, uint256 biezaca)epoch id is neither current nor next
EpokaJuzDomknieta(uint256 epoka)batch sent to a closed epoch
ZaWczesnie(uint256 teraz, uint256 najwczesniej)interval not elapsed
PrzekroczonySufit(uint256 poWsadzie, uint256 sufit)epoch cap exceeded
ZerowyOdstep()interval of zero
NicDoWyslania()zero amount in zasil/wyplac
ZrzeczenieZabronione()renounceOwnership is disabled

Trust boundaries in one table#

ActorCanCannot
Executorsend any list of recipients and amounts, up to maksNaEpoke per epoch, one epoch per minOdstepexceed the cap by batching, open epochs faster than the interval, drop while paused, touch another token, change its own permissions, block the owner
Ownerwithdraw everything at any time, pause, change executor/cap/interval, change rules and logo in the registry, transfer ownership in two stepsrenounce ownership, make the dropper pay out without an executor
Anyonefund a dropper, create and register droppers, read everythingchange anything on someone else's dropper

For the consequences of this model see Trust model.

Human-readable ABI#

Suitable for viem's parseAbi or cast.

Fabryka#

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 utworz(address token, address wykonawca, uint256 maksNaEpoke, uint256 minOdstep, string logo, Reguly reguly) returns (address kroplomierz)
function zglos(address kroplomierz, string logo, Reguly reguly)
function ustawReguly(address kroplomierz, Reguly reguly)
function ustawLogo(address kroplomierz, string logo)
function ile() view returns (uint256)
function lista(uint256 od, uint256 ile) view returns (Wpis[])
function wpis(address kroplomierz) view returns (Wpis)
function czyWpisany(address kroplomierz) view returns (bool)
function poTokenie(address token) view returns (address[])

event Utworzono(address indexed kroplomierz, address indexed token, address indexed wlasciciel, uint64 utworzono, bool przezFabryke)
event ZmianaRegul(address indexed kroplomierz, Reguly reguly)
event ZmianaLogo(address indexed kroplomierz, string logo)

error JuzWpisany(address kroplomierz)
error NieWpisany(address kroplomierz)
error NieWlasciciel(address kto, address wlasciciel)
error ZlyAdres()
error ZaDlugieLogo(uint256 dlugosc)
error ZlySchemat()
error ZlyZakres()

Kroplomierz#

constructor(address token_, address wlasciciel_, address wykonawca_, uint256 maks_, uint256 odstep_)

function token() view returns (address)
function wykonawca() view returns (address)
function wstrzymane() view returns (bool)
function domknieta() view returns (bool)
function maksNaEpoke() view returns (uint256)
function minOdstep() view returns (uint256)
function epoka() view returns (uint256)
function ostatniaKropla() view returns (uint256)
function wydaneWEpoce() view returns (uint256)
function stan() view returns (uint256 saldo, uint256 doNastepnej, uint256 epokaTeraz)
function owner() view returns (address)
function pendingOwner() view returns (address)

function zasil(uint256 ile)
function kapnij(uint256 epokaId, address[] odbiorcy, uint256[] kwoty, bytes32 skrotRegul, bool domkniecie)
function wyplac(address doKogo, uint256 ile)
function ustaw(address wykonawca_, uint256 maks_, uint256 odstep_)
function ustawPauze(bool v)
function transferOwnership(address newOwner)
function acceptOwnership()
function renounceOwnership()

event Zasilenie(address indexed od, uint256 ile)
event Kropla(uint256 indexed epoka, uint256 odbiorcow, uint256 suma, bytes32 skrotListy, bytes32 skrotRegul, bool domkniecie)
event Ustawienia(address wykonawca, uint256 maksNaEpoke, uint256 minOdstep)
event Wstrzymanie(bool wstrzymane)
event Wyplata(address indexed doKogo, uint256 ile)
event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner)
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner)

error ZlyAdres()
error NieWykonawca(address kto)
error Wstrzymano()
error NiezgodneDlugosci(uint256 odbiorcow, uint256 kwot)
error PustyWsad()
error ZerowaKwota(uint256 indeks)
error ZlaEpoka(uint256 podana, uint256 biezaca)
error EpokaJuzDomknieta(uint256 epoka)
error ZaWczesnie(uint256 teraz, uint256 najwczesniej)
error PrzekroczonySufit(uint256 poWsadzie, uint256 sufit)
error ZerowyOdstep()
error NicDoWyslania()
error ZrzeczenieZabronione()

Event topics and selectors#

Signaturetopics[0] / selector
Utworzono(address,address,address,uint64,bool)0x75348e0d23f86823d7cd12353f869acbf731b5e25ac95482cfaeb942c11b244c
ZmianaRegul(address,(uint256,uint256,uint256,uint64,uint64,bool,bool,bool))0x19c025e33dc943814c1ae5443deb712510d0eb8929ceafa9c9a71e8fb41ed2d4
ZmianaLogo(address,string)0xc506a67dc1cd36eaa48d49a9b3f9893c176b818dbed3ce6a163057df48d77b27
Zasilenie(address,uint256)0x01d97b10ae37514d188ef9680fab5dbb0cbb7e58df4be1bf35042cfa405b4af7
Kropla(uint256,uint256,uint256,bytes32,bytes32,bool)0xffb1f0315c51a4f756dbace04c0f615e11acf808cef89efd5c3ae45f35bacb69
Ustawienia(address,uint256,uint256)0x3a97864a5135d5c628b21bb372484cc231779708127f78f339b5d6b053080261
Wstrzymanie(bool)0x756d12a2233d390bfdd29c7223eb2210e93ae18cd2fcd3b45a41f3d380f5911c
Wyplata(address,uint256)0xec93ef6b2cb2d9cfa164f1f52786d03dffba8cfc273f0f5cc4216987a6802617
ERC-20 Transfer(address,address,uint256)0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
utworz(...)0x06a9d8f1
zglos(...)0x5a6ec1df
kapnij(...)0x5ebd5b0a
zasil(uint256)0x1f181a16
stan()0xb801a804
lista(uint256,uint256)0xecb53825

Quick reads with cast#

RPC=https://rpc.mainnet.chain.robinhood.com
FABRYKA=0x1CE8f5785617eaf9B6BB3c77CE7d242D86Dd0502

# how many droppers are listed
cast call $FABRYKA "ile()(uint256)" --rpc-url $RPC

# the first page of entries, rules included
cast call $FABRYKA \
  "lista(uint256,uint256)((address,address,address,uint64,string,(uint256,uint256,uint256,uint64,uint64,bool,bool,bool))[])" \
  0 100 --rpc-url $RPC

# state of one dropper: balance, seconds to next epoch, epoch counter
cast call <DROPPER> "stan()(uint256,uint256,uint256)" --rpc-url $RPC