DocsReference
Split rules
This page specifies how a drop is divided among holders. It is a specification of web/lib/regula.ts, the pure function policz(snapshot, rules, now, context) ("compute") that both the web app and the keeper run. Implementing it in another language gives byte-identical results, including the rules hash the dropper records on chain. Field names are the contract's and the engine's, i.e. Polish; each is explained where it appears and in the glossary.
Inputs#
Rules#
The on-chain Reguly struct, converted as follows:
| Engine field | From the struct | Notes |
|---|---|---|
kwota: bigint | kwota | amount to distribute in this drop, base units |
podzial: 'proporcjonalnie' | 'porowno' | porowno ? 'porowno' : 'proporcjonalnie' | proportional to balance, or equal per wallet |
minSaldo: bigint | minSaldo | |
minCzasTrzymania: number | minCzasTrzymania | seconds |
sufitNaAdres: bigint | null | sufitNaAdres, with 0 → null | 0 on chain means no cap, never a cap of zero |
pomijajKontrakty: boolean | pomijajKontrakty | |
wykluczone: string[] | not on chain | manual exclusions, plus the project wallets when pomijajWlasne is set |
okresBezruchu does not enter the engine or the hash. It is a declaration shown next to the measured last transfer, not a rule of the split.
Project wallets. When pomijajWlasne is true, three addresses are appended to wykluczone: the owner recorded in the registry entry (Wpis.wlasciciel), the live owner() of the dropper, and wykonawca. Owner and executor are often the same wallet; duplicates are removed. The web app labels such rejections "a project wallet — the owner or the executor" rather than "excluded by hand", but the effect is the same.
Snapshot#
A list of holders at one block, each with:
| Field | Meaning |
|---|---|
adres | address, unique within the snapshot (duplicates are an input error) |
saldo | balance at the snapshot block, base units |
odKiedy | unix time since which the balance has been continuously at or above the snapshot threshold; 0 = no such streak |
kontrakt | the address had code at the snapshot block |
plus czas, the header timestamp of the snapshot block. How the snapshot is built is described in Reading the data.
now#
The time against which holding periods are measured. It must not be later than czas. The engine rejects a later now: balances are frozen at the snapshot block, so a moving clock would qualify a wallet that reached the minimum holding time after the snapshot without checking whether it still holds anything. The keeper and the web app both pass now = czas.
Algorithm#
1. Filter#
Each holder is tested in this fixed order; the first failing test is the reason shown in the UI.
| Order | Rejected when | Reason text |
|---|---|---|
| 1 | the address is the dropper itself (context.kroplomierz) | the dropper itself — it cannot pay itself |
| 2 | the address is on the exclusion list (wykluczone) | a project wallet — the owner or the executor / excluded by hand |
| 3 | contracts are skipped (pomijajKontrakty) and the address has code | contract address |
| 4 | the balance is zero | zero balance |
| 5 | the balance is below the minimum (minSaldo) | balance below the minimum |
| 6 | there is no holding streak (odKiedy is 0) | balance never held above the snapshot threshold |
| 7 | now − heldSince < minCzasTrzymania | held for less than the minimum time |
The dropper is excluded unconditionally, even with pomijajKontrakty off: a transfer to itself reaches nobody but still consumes the epoch cap.
The holding-time boundary is closed: exactly minCzasTrzymania seconds qualifies.
Filtering happens before any weight is computed, so an excluded whale does not take a share away from the others.
2. Weights#
w_i = balance_i for a proportional split, w_i = 1 for an equal split.
3. Allocation with an iterative cap#
Let amount be the amount per drop (kwota). With no cap: amount_i = floor(amount × w_i / Σw).
With a cap S (sufitNaAdres), repeat:
- Let
freebe the holders not yet capped,Wthe sum of their weights, andbudget = amount − S × (number of capped holders). - For every free holder,
amount_i = floor(budget × w_i / W). - Every free holder with
amount_i > Sis set toSand marked capped. - Stop when a pass caps nobody.
Each pass caps at least one more holder and capping is permanent, so the loop runs at most n passes. If every qualifying holder ends up capped, the warning sufit-zatrzymuje-reszte is raised: the cap times the number of holders is smaller than the drop, and the rest stays in the dropper.
4. Drop zero allocations#
A holder whose rounded amount is 0 is moved to the rejected list with the reason "share rounds down to zero". The dropper reverts on zero amounts, and the dust is not redistributed; it stays for the next drop.
5. Order#
Allocations are sorted by amount descending, ties by address ascending (lowercase string comparison). The rejected list is sorted by address. The order matters: skrotListy recorded on chain is the hash of the ordered arrays, so two implementations must sort identically to produce the same hash.
6. Invariants#
Σ amount_i ≤ amount, remainder = amount − Σ amount_i ≥ 0, and no amount_i > S. The engine asserts all three and throws rather than produce a list that violates them.
Rounding#
Every division rounds down. The remainder stays in the dropper and rolls into the next drop. It is never given to "the last address on the list", because that would create an advantage based on list position.
Warnings#
| Code | When |
|---|---|
rowny-podzial-zaprasza-sybile | podzial == 'porowno': one wallet split into thirty takes thirty times more |
brak-kwalifikujacych | nobody qualifies |
sufit-zatrzymuje-reszte | every qualifying holder is at the cap |
kwota-zerowa | kwota == 0 |
The rules hash#
kapnij takes a bytes32 skrotRegul and records it in Kropla. plop computes it as keccak256 of the UTF-8 bytes of this canonical text:
- Lines joined with a single
\n, no trailing newline. - First line is the version tag
sdrop-regula/1. - Then
key=valuepairs in exactly this order:kwota,podzial,minSaldo,minCzasTrzymania,sufitNaAdres,pomijajKontrakty,wykluczone. - Amounts as decimal strings of base units, no separators, no sign, no leading zeros. No cap is written as
0. minCzasTrzymaniaas a decimal integer of seconds.pomijajKontraktyas1or0.podzialliterally:proporcjonalnieorporowno.wykluczone: addresses lowercased, deduplicated, sorted ascending as strings, joined with,and no spaces. An empty list giveswykluczone=.
Token, snapshot block and now are deliberately not part of the hash: it describes the rules. The recipient list is covered by skrotListy, which the contract computes itself.
Worked example#
Rules: 1 000 tokens per drop (18 decimals), proportional, minimum balance 100, minimum holding time 300 s, no cap, skip contracts, project wallets 0x1111…1111 (owner) and 0x5e25…ee03 (executor).
sdrop-regula/1
kwota=1000000000000000000000
podzial=proporcjonalnie
minSaldo=100000000000000000000
minCzasTrzymania=300
sufitNaAdres=0
pomijajKontrakty=1
wykluczone=0x1111111111111111111111111111111111111111,0x5e25b93c6454c10e4110b70dafa76c6934cdee03
keccak256 of that text is 0x3792f962f6d695dc4dd6efc4866951d1bb115196f91051b9111908a51f9a9c33.
Snapshot at czas = 1800000000:
| Holder | Balance | Held since | Contract | Result |
|---|---|---|---|---|
0xaaaa…0001 | 600 | 1799990000 | no | 666.666666666666666666 |
0xaaaa…0002 | 300 | 1799990000 | no | 333.333333333333333333 |
0xaaaa…0003 | 100 | 1799999900 | no | rejected: held for less than the minimum time (100 s < 300 s) |
0xaaaa…0004 | 50 | 1799990000 | no | rejected: balance below the minimum |
0xaaaa…0005 | 9 000 | 1799990000 | yes | rejected: contract address |
0x1111…1111 | 90 000 | 1799990000 | no | rejected: a project wallet |
Sum sent 999.999999999999999999, remainder 1 base unit stays in the dropper.
With a per-wallet cap of 400 on the same snapshot: pass one gives 666.6 and 333.3, the first is cut to 400; pass two gives the second holder the whole remaining budget of 600, which is also cut to 400; both are capped, 800 go out, 200 remain, and the warning sufit-zatrzymuje-reszte is raised.
Reference implementation#
import { policz, skrotRegul } from './web/lib/regula' // policz = compute, skrotRegul = rules hash; pure TypeScript, only dependency: keccak256 from viem
const result = policz(
{ czas: snapshotTime, holdery: holders }, // snapshot: header time + holders
{ kwota, podzial, minSaldo, minCzasTrzymania, sufitNaAdres, pomijajKontrakty, wykluczone }, // rules, see the table above
snapshotTime, // now = snapshot time
{ kroplomierz: dropper, wlasne: [owner, executor] }, // context: the dropper and the project wallets
)
result.przydzialy // allocations: [{ adres, kwota, udzial }] = address, amount, share; sorted as the contract will hash them
result.suma // total sent, ≤ amount per drop
result.reszta // remainder, stays in the dropper
result.odrzuceni // rejected: [{ adres, powod }] = address, reason
result.skrotRegul // rules hash, bytes32 for kapnij
result.ostrzezenia // warnings: [{ kod, tekst }] = code, text
The test suite in web/lib/regula.test.mjs covers rounding, the cap loop, ordering and the hash; run it with pnpm --dir web test.