SourceLessExplorer · SourceLess AsgardSourceLess Asgard · chain 1313
LiveHeight23,745Head age5 minLast interval4 minFinality23,734Behind−11Pending0 / 8,192Tx in head0
XPlorer following the chain as its own read-only node

height 23745 of 23745 on the network. 9 peer(s) connected, 77 in its peer directory; 1041 block(s) followed and 865 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

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 pointGas
release3,571
addPayee1,094
seal574
Declare at least
35,710 ten times the most expensive call; post-mining covers only a tenth of the declared limit

ABI

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

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 = "";
  }
}