SourceLessExplorer · SourceLess AsgardSourceLess Asgard · chain 1313
LiveHeight23,717Head age48 sLast interval1 minFinality23,706Behind−11Pending0 / 8,192Tx in head0
XPlorer following the chain as its own read-only node

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

Edit the draft

Set the constants the template marks for editing, and name a deployer to see where it would land.

This template marks no constant to edit: its parties and amounts are given to its functions after it is deployed. You can still name a deployer to see where it would land.

The form asks this explorer to compile your draft and show it back. It signs nothing, stores nothing and reaches no node.

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.

  1. Save the draft. Download it above, or start from the catalogue text with strblk contract template payment-splitter > payment-splitter.ares and edit the marked constants yourself.
  2. 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.
  3. Find your fork's RPC. It is the rpc (Iris, wallets) line of the fork's banner, and the rpcUrl of each node in <data root>/fork.json.
  4. 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 pointGas
release3,571
addPayee1,094
seal574
Declare at least
35,710 ten times the most expensive call

Functions

KindNameInputsOutputsMutability
functionhelduintview
functionlifetimeIncomeuintview
functionsharesOfaccount: addressuintview
functiontotalSharesuintview
functionpayeeCountuintview
functionreleasedToDateaccount: addressuintview
functiontotalReleaseduintview
functionreleasableaccount: addressuintview
functionsealedboolview
functionowneraddressview
functionpendingOwnerIsaddressview
functionaddPayeepayee: address, shares: uintnonpayable
functionsealnonpayable
functionreleaseuintnonpayable
functionproposeOwnernext: addressnonpayable
functionacceptOwnershipnonpayable
eventPayeeAddedpayee: address, shares: uint, shareTotal: uint
eventRollSealedpayees: uint, shareTotal: uint
eventReleasedpayee: address, amount: uint, releasedToDate: uint
eventOwnerProposedcurrent: address, pending: address
eventOwnerChangedprevious: 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 = "";
  }
}