height 23717 of 23717 on the network. 9 peer(s) connected, 77 in its peer directory; 1013 block(s) followed and 842 pulled in by sync since this follower started. Caught up: this follower holds every block any peer has claimed, so every panel below is the whole chain. Genesis 1cf8655125cc1bbb…, producers genesis.
What this follower is, locally
Following 7 seed(s) — their addresses are not published here, for the reason the peer table gives. Its store is held under its own Cerberus lease — no other node’s store is opened, touched or stopped.
PaymentSplitter for your fork
- Status
- a draft, not deployed nothing here is on any chain; never send value to a draft
- Profile
- native STR splitter
- Holds
- native STR
- The property that matters
- the split cannot change after seal; nobody is paid twice for the same income
- On Asgard
- the same template in the catalogue
Edit the draft
Set the constants the template marks for editing, and name a deployer to see where it would land.
The draft as shipped
- Contract
PaymentSplitter- Compiler
areslang-1.3.0- Bytecode SHA-256
073e24434851c90d3f793222f1eed46676b2ab694820926faeda67137276d5f1- Bytecode
- 1,873 bytes
- Source SHA-256
ecb5c940b3f8ff650f2e125275200e85f258575f65d1699155e2efb9f16c37fb- The catalogue text?
- Yes, byte for byte: matches the published vector
- Ready to deploy?
- no catalogue examples left
- Would land at
- name a deployer to see it
Deploy it to your fork
Only the last step signs, and only it reaches a node.
- Save the draft. Download it above, or start from the catalogue text with
strblk contract template payment-splitter > payment-splitter.aresand edit the marked constants yourself. - Dry-run it on your machine.
strblk wallet deploy payment-splitter.ares --dry-run --address <your address on the fork>compiles it locally and prints its digest and address. They must match this page; if they do not, the file is not this draft. - Find your fork's RPC. It is the
rpc (Iris, wallets)line of the fork's banner, and therpcUrlof each node in<data root>/fork.json. - Deploy it.
strblk wallet deploy payment-splitter.ares --node <your fork's RPC>, from a wallet whose account holds a proved STR.domain on your fork. Before it signs, the wallet prints the network: the realm reads unregistered and the genesis is your fork's hash. Check that hash; it is what the signature binds.
Measured gas
Charged by the STARW VM under areslang-1.3.0, the same on your fork. Measured, not estimated.
| Entry point | Gas |
|---|---|
| release | 3,571 |
| addPayee | 1,094 |
| seal | 574 |
- Declare at least
- 35,710 ten times the most expensive call
Functions
| Kind | Name | Inputs | Outputs | Mutability |
|---|---|---|---|---|
| function | held | uint | view | |
| function | lifetimeIncome | uint | view | |
| function | sharesOf | account: address | uint | view |
| function | totalShares | uint | view | |
| function | payeeCount | uint | view | |
| function | releasedToDate | account: address | uint | view |
| function | totalReleased | uint | view | |
| function | releasable | account: address | uint | view |
| function | sealed | bool | view | |
| function | owner | address | view | |
| function | pendingOwnerIs | address | view | |
| function | addPayee | payee: address, shares: uint | nonpayable | |
| function | seal | nonpayable | ||
| function | release | uint | nonpayable | |
| function | proposeOwner | next: address | nonpayable | |
| function | acceptOwnership | nonpayable | ||
| event | PayeeAdded | payee: address, shares: uint, shareTotal: uint | ||
| event | RollSealed | payees: uint, shareTotal: uint | ||
| event | Released | payee: address, amount: uint, releasedToDate: uint | ||
| event | OwnerProposed | current: address, pending: address | ||
| event | OwnerChanged | previous: address, next: address |
Your draft's source
templates/payment-splitter.ares, as shipped
// =========================================================================================
// PaymentSplitter — native STR arriving in one address, owed out in fixed proportions.
//
// WHAT IT IS FOR
// Income that belongs to several parties in shares agreed in advance: a founding team, a
// revenue split, a royalty pool paid by hand, a rent roll. STR is sent to this contract by
// ordinary transfer, from anywhere, by anyone. Each payee then PULLS what the split says they
// are owed, whenever they like, as many times as income keeps arriving.
//
// WHY PULL AND NOT PUSH, AND WHY THAT IS NOT A STYLE CHOICE
// A push splitter has to pay every payee in one call, which means a loop whose length the
// roll controls, with a `TRANSFER` (2,100 gas, the most expensive instruction in the machine)
// inside it. At some roll size that call exceeds any gas limit and the contract becomes
// PERMANENTLY UNCALLABLE — and the code is immutable, so there is no fixing it afterwards.
// Pull costs one transfer per payee per call and cannot be grown into a dead end by anybody.
// `release()` pays only `caller`: there is no argument naming whom to pay.
//
// HOW IT SEES ITS INCOME, AND THE LIMIT THAT SHAPES THIS WHOLE CONTRACT
// It cannot see who paid it. A call record carries no value and there is no deposit hook, so
// a contract cannot learn its payer — which is why this template splits POOLED income by a
// DECLARED roll, and why no template in this catalogue can credit a depositor. The splitter
// works because it never needs to: everything that arrives belongs to the roll in fixed
// proportions, whoever sent it.
//
// `balance(self)` is what is here NOW. Lifetime income is `balance(self) + totalReleased()`,
// and that is the number every entitlement is computed from — which is what makes a payee's
// share correct across any number of deposits, in any order, with any pattern of releases by
// anybody else.
//
// WHAT IT GUARANTEES
// * The roll cannot change after `seal()`. `addPayee` refuses once sealed, one-way, and
// there is no function that removes a payee or alters a share. Before sealing, the owner
// can change anything — so DO NOT SEND VALUE TO AN UNSEALED SPLITTER, and read `sealed()`
// before you do send any.
// * A payee can never take another payee's entitlement. Every figure in `release()` is keyed
// by `caller`, and the amount released is checked against that caller's own arithmetic.
// * Nobody can be paid twice for the same income. `releasedOf` is a high-water mark per
// payee, subtracted from their computed entitlement, so a second call with no new income
// reverts with `nothing due`.
// * The owner has NO path to the money. There is no withdraw, no sweep, no emergency
// recovery, and the owner cannot add themselves to the roll after sealing.
// * The sum of everything releasable is never more than the contract holds. Every
// entitlement rounds DOWN, so the remainders stay in the contract instead of being promised
// twice.
// * A revert leaves the books and the balance byte-identical. Storage and value commit
// together, only on success.
//
// WHAT IT CANNOT DO
// * It cannot pay a recipient the ledger refuses. `ValueJournal` refuses Hnitbjorg reserved
// accounts; a `release()` to one of those reverts whole — nothing is lost, and nothing is
// paid, until the payee is an address that can receive.
// * It cannot pay a contract's own bytecode. Another contract may RECEIVE STR, but it can
// never spend it: a contract cannot be `caller` anywhere on this chain. A contract address
// on the roll is a permanent loss of that share.
// * It cannot distribute a ZK13 token. Token balances live in the token's own contract and
// there are no cross-contract calls, so a splitter over a ZK13 token would have to be
// written INSIDE that token. Native STR only.
//
// HOW IT IS TYPICALLY MISUSED
// * Sending value before sealing, then changing the roll. The income is already here and the
// new roll applies to all of it, retroactively. Seal first.
// * Expecting the contract to end up empty. It does not. Integer division leaves a remainder
// of at most (number of payees − 1) minor units unclaimed at any moment; it is not lost,
// and it is released to whoever it rounds to once further income arrives, but the balance
// will normally sit a few units above zero forever. MEASURED: with shares 1/1/1 and 100
// minor units of income, the three payees release 33 each and 1 unit stays; after a further
// 200 arrives, the first payee to call releases 67 — the remainder came back to them, not
// to nobody.
// * Losing the owner key before sealing. The roll is then frozen unsealed, which still works
// — `release()` does not require a seal — but nothing can ever be added, and any payee who
// was not on the roll at that moment never will be.
// * Under-declaring the gas limit on the call record. Post-mining covers only
// min(gasUsed, gasLimit / 10), so the network silently absorbs the rest, forever, on every
// call. MEASURED, on the v2 gas table, with 53-character addresses: addPayee 1,094,
// seal 574, release 3,571 — of which 2,100 is the `TRANSFER` itself and 400 the balance
// read — proposeOwner 412, acceptOwnership 612. Declare a gas limit of at least 36,000.
//
// TO USE IT
// Deploy, then `addPayee(address, shares)` for each party, then `seal()`, and only then send
// STR to the contract's address. Shares are arbitrary integers — 50/30/20 and 5/3/2 split
// identically, because every entitlement is a proportion of `totalShares()`.
// =========================================================================================
contract PaymentSplitter {
// `caller` inside a state initializer is the DEPLOYER: Sindri runs the compiler-generated
// initializer with the authenticated deployer as caller, so there is no claim race on the
// key that writes the roll.
state contractOwner: address = caller;
state pendingOwner: address = "";
// One-way. After this there is no function in the contract that changes a share.
state rollSealed: bool = false;
state shareTotal: uint = 0;
state payeeTotal: uint = 0;
// Everything this contract has ever paid out, across all payees. Added back to the current
// balance to recover lifetime income, which is what entitlements are computed from.
state releasedTotal: uint = 0;
state shareOf: map<address, uint>;
state releasedOf: map<address, uint>;
event PayeeAdded(payee: address, shares: uint, shareTotal: uint);
event RollSealed(payees: uint, shareTotal: uint);
event Released(payee: address, amount: uint, releasedToDate: uint);
event OwnerProposed(current: address, pending: address);
event OwnerChanged(previous: address, next: address);
// ---- views ----------------------------------------------------------------------------
// What the contract holds right now, read from the native ledger rather than from a mirror
// in state. A contract that cached its own balance would have two copies of the truth, and
// they diverge the moment anybody pays it without calling it.
view fn held() -> uint {
return balance(self);
}
// Everything that has ever arrived, whoever sent it. The denominator of the whole contract.
view fn lifetimeIncome() -> uint {
return balance(self) + releasedTotal;
}
view fn sharesOf(account: address) -> uint {
return shareOf[account];
}
view fn totalShares() -> uint {
return shareTotal;
}
view fn payeeCount() -> uint {
return payeeTotal;
}
view fn releasedToDate(account: address) -> uint {
return releasedOf[account];
}
view fn totalReleased() -> uint {
return releasedTotal;
}
// What this account could take right now. Identical arithmetic to `release()`, so a payee can
// check before spending gas on a call that would revert with `nothing due`.
//
// It rounds DOWN, like the payout does. Nothing here promises the sum over all payees equals
// the balance — it is at most the balance, and the difference is the rounding remainder.
view fn releasable(account: address) -> uint {
let earned = (balance(self) + releasedTotal) * shareOf[account] / shareTotal;
if (earned <= releasedOf[account]) {
return 0;
}
return earned - releasedOf[account];
}
view fn sealed() -> bool {
return rollSealed;
}
view fn owner() -> address {
return contractOwner;
}
view fn pendingOwnerIs() -> address {
return pendingOwner;
}
// ---- building the roll ------------------------------------------------------------------
fn addPayee(payee: address, shares: uint) {
require(caller == contractOwner, "not owner");
require(rollSealed == false, "roll is sealed");
// Unreachable through a call record — `validateArgs` checksum-validates every `address`
// argument before the first opcode — and kept for a future caller path that does not.
require(len(payee) > 0, "empty payee");
// A zero share is a payee who can never be paid and whose presence only inflates
// `payeeCount`. Refuse it rather than record it.
require(shares > 0, "shares must be positive");
// This contract cannot pay itself: a contract cannot be `caller`, so its share would be
// stranded in its own balance and every other payee's proportion would silently shrink.
require(payee != self, "self cannot be a payee");
// A second `addPayee` for the same address would overwrite the share while `payeeTotal`
// and `shareTotal` had already counted the first one — the roll would then not add up.
// Refusing keeps the invariant that `shareTotal` is the sum of the shares actually stored.
require(shareOf[payee] == 0, "already a payee");
shareOf[payee] = shares;
shareTotal = shareTotal + shares;
payeeTotal = payeeTotal + 1;
emit PayeeAdded(payee, shares, shareTotal);
}
// One-way, and the point of the whole contract: after this the split is a fact rather than
// something the owner is trusting themselves not to change.
fn seal() {
require(caller == contractOwner, "not owner");
require(rollSealed == false, "roll is sealed");
// An empty roll sealed forever would be a contract that can receive value and pay nobody.
require(shareTotal > 0, "empty roll");
rollSealed = true;
emit RollSealed(payeeTotal, shareTotal);
}
// ---- taking what you are owed -----------------------------------------------------------
// Pays the CALLER and nobody else. Checks, then effects, then the interaction, in that order:
// the books are written before the value moves. There is no reentrancy on this chain — no
// call opcode, and `TRANSFER` runs no recipient code — so today that ordering is defensive
// against a hazard that does not exist. It is written this way because the day a call opcode
// lands it is the difference between a contract that pays once and one that pays until it is
// empty, and the code is immutable: it cannot be reordered later.
fn release() -> uint {
require(shareOf[caller] > 0, "not a payee");
// Lifetime income times this payee's proportion, minus what they have already taken. This
// is the whole accounting model, and it is correct across any number of deposits in any
// order because it never looks at a single deposit — only at the running total.
let earned = (balance(self) + releasedTotal) * shareOf[caller] / shareTotal;
require(earned > releasedOf[caller], "nothing due");
let due = earned - releasedOf[caller];
releasedOf[caller] = releasedOf[caller] + due;
releasedTotal = releasedTotal + due;
emit Released(caller, due, releasedOf[caller]);
// Last. If the ledger refuses this recipient the whole call reverts — books, event and
// balance together — and nothing has been paid or recorded.
transfer(caller, due);
return due;
}
// ---- ownership --------------------------------------------------------------------------
// The owner's only power is the roll, and only before it is sealed. Handover is still two
// steps: a one-step handover to a mistyped address is permanent, and there is no
// administrator anywhere in SourceLess who can reach into a contract's slots.
fn proposeOwner(next: address) {
require(caller == contractOwner, "not owner");
pendingOwner = next;
emit OwnerProposed(contractOwner, next);
}
fn acceptOwnership() {
require(caller == pendingOwner, "not the pending owner");
emit OwnerChanged(contractOwner, caller);
contractOwner = caller;
pendingOwner = "";
}
}