// ========================================================================================= // 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; state releasedOf: map; 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 = ""; } }