height 23774 of 23774 on the network. 9 peer(s) connected, 77 in its peer directory; 1070 block(s) followed and 893 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
- Status
- template — not deployed A template is a draft, not a deployed contract. Nothing here is on the chain: it has no address until somebody deploys it, and where it lands depends on who deploys it and with which salt. Never send value to a template.
- File
templates/payment-splitter.ares- 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
- Compiler in force
areslang-1.3.0- Bytecode
- 1,873 bytes
- Bytecode SHA-256
073e24434851c90d3f793222f1eed46676b2ab694820926faeda67137276d5f1- Source SHA-256
ecb5c940b3f8ff650f2e125275200e85f258575f65d1699155e2efb9f16c37fb- Published vector
- matches the published vector spec/conformance-vectors.json pins 073e24434851c90d… under areslang-1.3.0
- Catalogue
- every template
Before you deploy
This template’s header marks no constant to edit: its parties and amounts are given to its functions after deployment.
Where your deployment would land
Name a deployer to see where its deployment of this template, unedited, would land.
The chain derives a contract’s address from its name, its bytecode, the deployer and the salt. This is that function over the bytecode above, so it holds only for the template exactly as shown and only while the chain runs the same compiler. Edit one constant and the address moves, which is correct: different code is a different contract. Nothing is at this address until somebody deploys there.
Measured gas
What the STARW VM charged for each call on a real chain, under areslang-1.3.0, and asserted exactly by the catalogue test: a figure that moved would fail the build. Measured, not estimated. A call that stores a string costs more for a longer one; those were measured with ipfs://QmExampleTokenMetadataDocument01.
| Entry point | Gas |
|---|---|
| release | 3,571 |
| addPayee | 1,094 |
| seal | 574 |
- Declare at least
- 35,710 ten times the most expensive call; post-mining covers only a tenth of the declared limit
ABI
| 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 |
From the command line
Print the source to a file, edit the marked constants, dry-run it to see the digest and your address, then deploy. Only the last command signs, and it is the only one that needs your key and a node.
strblk contract template payment-splitter > payment-splitter.ares strblk contract template payment-splitter --info strblk wallet deploy payment-splitter.ares --dry-run --address <your address> strblk wallet deploy payment-splitter.ares --node <your node's RPC>
Source
templates/payment-splitter.ares, as embedded in this build
// =========================================================================================
// 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 = "";
}
}