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 fieldFrom the structNotes
kwota: bigintkwotaamount to distribute in this drop, base units
podzial: 'proporcjonalnie' | 'porowno'porowno ? 'porowno' : 'proporcjonalnie'proportional to balance, or equal per wallet
minSaldo: bigintminSaldo
minCzasTrzymania: numberminCzasTrzymaniaseconds
sufitNaAdres: bigint | nullsufitNaAdres, with 0null0 on chain means no cap, never a cap of zero
pomijajKontrakty: booleanpomijajKontrakty
wykluczone: string[]not on chainmanual 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:

FieldMeaning
adresaddress, unique within the snapshot (duplicates are an input error)
saldobalance at the snapshot block, base units
odKiedyunix time since which the balance has been continuously at or above the snapshot threshold; 0 = no such streak
kontraktthe 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.

OrderRejected whenReason text
1the address is the dropper itself (context.kroplomierz)the dropper itself — it cannot pay itself
2the address is on the exclusion list (wykluczone)a project wallet — the owner or the executor / excluded by hand
3contracts are skipped (pomijajKontrakty) and the address has codecontract address
4the balance is zerozero balance
5the balance is below the minimum (minSaldo)balance below the minimum
6there is no holding streak (odKiedy is 0)balance never held above the snapshot threshold
7now − heldSince < minCzasTrzymaniaheld 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:

  1. Let free be the holders not yet capped, W the sum of their weights, and budget = amount − S × (number of capped holders).
  2. For every free holder, amount_i = floor(budget × w_i / W).
  3. Every free holder with amount_i > S is set to S and marked capped.
  4. 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#

CodeWhen
rowny-podzial-zaprasza-sybilepodzial == 'porowno': one wallet split into thirty takes thirty times more
brak-kwalifikujacychnobody qualifies
sufit-zatrzymuje-reszteevery qualifying holder is at the cap
kwota-zerowakwota == 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:

  1. Lines joined with a single \n, no trailing newline.
  2. First line is the version tag sdrop-regula/1.
  3. Then key=value pairs in exactly this order: kwota, podzial, minSaldo, minCzasTrzymania, sufitNaAdres, pomijajKontrakty, wykluczone.
  4. Amounts as decimal strings of base units, no separators, no sign, no leading zeros. No cap is written as 0.
  5. minCzasTrzymania as a decimal integer of seconds.
  6. pomijajKontrakty as 1 or 0.
  7. podzial literally: proporcjonalnie or porowno.
  8. wykluczone: addresses lowercased, deduplicated, sorted ascending as strings, joined with , and no spaces. An empty list gives wykluczone=.

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:

HolderBalanceHeld sinceContractResult
0xaaaa…00016001799990000no666.666666666666666666
0xaaaa…00023001799990000no333.333333333333333333
0xaaaa…00031001799999900norejected: held for less than the minimum time (100 s < 300 s)
0xaaaa…0004501799990000norejected: balance below the minimum
0xaaaa…00059 0001799990000yesrejected: contract address
0x1111…111190 0001799990000norejected: 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.