Context
distribute pushes tokens to every member inside one invocation. That means the payer funds N cross-contract transfer calls, one bad member address aborts the entire payroll run (a frozen or clawback-enabled trustline is enough), and the payer must be online with the full balance at exactly the right moment. Real payroll systems use pull payments.
Scope
deposit(id, from, amount) — pull amount into the contract address in a single transfer, then credit each member's claimable balance using the same basis-point floor-plus-dust math as base::utils::distribute_amounts.
claim(id, member) — member.require_auth(), transfers the full accrued balance out, zeroes the entry. Add claim_to(id, member, to) for members paying out to a different address.
- Views —
claimable_balance(id, member) -> i128 and total_escrowed(id) -> i128.
- Snapshot semantics — a deposit credits the member set as of that deposit. Later member changes must never retroactively move already-credited funds. Document it in rustdoc and prove it in a test: deposit, swap the whole member list, and confirm the original members can still claim their exact amounts.
- Invariant — for every group, the sum of all claimable balances equals
total_escrowed, and the sum across groups never exceeds the contract's token balance. Expose an internal accounting helper the tests can assert on.
- Storage and archival — new keys
DataKey::Claimable(BytesN<32>, Address) and DataKey::Escrowed(BytesN<32>). Every write must extend_ttl with named threshold/extend-to constants, because a member who claims eleven months later must not find an archived entry. Document the chosen TTL policy and its rent cost.
distribute keeps working unchanged — escrow is additive, not a replacement.
Acceptance criteria
- Claiming with a zero balance returns a new
NothingToClaim error; double-claim returns the same on the second call.
- Claim by a non-member returns
MemberNotFound and moves nothing.
deposit on a group with no members returns EmptyMembers and does not take custody of the tokens.
- Dust: after 100 deposits of an amount that divides unevenly across 7 members,
sum(claimable) == sum(deposits) exactly — no leaked stroops.
- Fuzz a random sequence of deposits and claims and assert the accounting invariant after every step.
- TTL test: advance the ledger past the default persistent-entry TTL with
env.ledger().with_mut(...), then claim successfully.
- Reentrancy note in the PR: state must be zeroed before the token transfer, and a test must assert the write ordering.
Context
distributepushes tokens to every member inside one invocation. That means the payer funds N cross-contract transfer calls, one bad member address aborts the entire payroll run (a frozen or clawback-enabled trustline is enough), and the payer must be online with the full balance at exactly the right moment. Real payroll systems use pull payments.Scope
deposit(id, from, amount)— pullamountinto the contract address in a single transfer, then credit each member's claimable balance using the same basis-point floor-plus-dust math asbase::utils::distribute_amounts.claim(id, member)—member.require_auth(), transfers the full accrued balance out, zeroes the entry. Addclaim_to(id, member, to)for members paying out to a different address.claimable_balance(id, member) -> i128andtotal_escrowed(id) -> i128.total_escrowed, and the sum across groups never exceeds the contract's token balance. Expose an internal accounting helper the tests can assert on.DataKey::Claimable(BytesN<32>, Address)andDataKey::Escrowed(BytesN<32>). Every write mustextend_ttlwith named threshold/extend-to constants, because a member who claims eleven months later must not find an archived entry. Document the chosen TTL policy and its rent cost.distributekeeps working unchanged — escrow is additive, not a replacement.Acceptance criteria
NothingToClaimerror; double-claim returns the same on the second call.MemberNotFoundand moves nothing.depositon a group with no members returnsEmptyMembersand does not take custody of the tokens.sum(claimable) == sum(deposits)exactly — no leaked stroops.env.ledger().with_mut(...), then claim successfully.