feat(contract): pull-payment escrow — deposit(), per-member claim(), and claimable-balance accounting with TTL management - #138
Merged
Yunusabdul38 merged 4 commits intoAug 19, 2026
Conversation
…agement `distribute` pushes tokens to every member inside one invocation, so the payer funds N cross-contract transfers, must be online with the full balance at that exact moment, and a single unusable member trustline aborts the whole payroll run. This adds the pull-payment counterpart alongside it. - `deposit(id, from, amount)` takes custody in a single transfer and credits each member using the same basis-point floor-plus-dust math as `distribute`. - `claim(id, member)` / `claim_to(id, member, to)` pay one member out and clear their entry; `claimable_balance` and `total_escrowed` expose the books. - Credits are a snapshot of the member set at deposit time: balances are keyed by `(group id, member address)`, so replacing a group's members never moves funds that were already credited. - Every write extends the entry it touched, the group record and the contract instance under named TTL constants, so a member claiming months later does not find an archived entry. - `settle_claim` is split out of `claim_to` so the effects-before-interaction ordering is directly assertable in a test. `distribute` is untouched — escrow is additive.
Contributor
|
@ayinde38 ci check is failling kindly resolve it |
The merge of main into this branch was taken automatically and left the crate uncompilable. Resolving it properly turned up that `main` itself does not build — its own Contract CI run is red for the same reasons — so the escrow work cannot go green without fixing them. Merge resolution: - errors.rs — main took discriminants 12 through 15 for the upgradeability work, so `NothingToClaim` moves to 16. Discriminants are ABI; nothing already deployed may be renumbered. - lib.rs — rebuilt on main's version with the five escrow methods appended, so `upgrade`, `migrate`, `pause` and the escrow entrypoints all survive. - types.rs, events.rs, interfaces/autoshare.rs, test.rs — both sides kept. Pre-existing breakage on main, fixed here because it blocks CI: - `base::utils::calculate_share` was half-converted to a checked form: it referenced an undefined `product`, used `?` in a function returning `i128`, and `MAX_SAFE_TOTAL` — which main's tests and prop tests both import — was never added. Completed the conversion the tests describe. - `base::auth::validate_percentages` still summed with `+=` and trapped on overflow, which is exactly what main's own `test_regression_validate_percentages_overflow_safe` was written to catch. It uses `checked_add` now. - Two tests passed the `token` module where `token_address` was meant. - Thirteen inline test setups never called `init`, so every `create` in them failed with `MigrationRequired` once the crate compiled — the migration gate landed without them being updated. - `test_distribute_large_amount` asserted an error while distributing 1000 against a minted balance of 1e18. That only held because its group was never created; with the setup fixed it cannot pass either way, so it now distributes the large amount it mints and asserts the even split, which is what its name and setup describe. `get_calculated_share` is left returning `i128` and aborting on overflow, as its trait signature and doc comment still say. A test comment on main argues for a typed error there, but widening it is an ABI change and belongs to whoever owns that refactor. Verified locally: cargo fmt --check, clippy -D warnings, build, 81 tests passing, doc tests, and the wasm32v1-none release build.
The build job runs `stellar contract build` with a pinned stellar-cli v21.4.0, which emits to target/wasm32-unknown-unknown/release, and then verifies target/wasm32v1-none/release/*.wasm — a path only newer CLIs produce. The step could never find the artifact, so the job failed on `ls`. Building through cargo with the same target the Makefile's `build` target already uses makes the output path correct by construction and removes the dependency on the pinned CLI version. This only became visible once the crate compiled again; before that the job failed earlier, at clippy.
Yunusabdul38
self-requested a review
August 19, 2026 17:34
Yunusabdul38
approved these changes
Aug 19, 2026
Yunusabdul38
left a comment
Contributor
There was a problem hiding this comment.
ACK
Thanks for ur continuous contribution on paymesh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #121
What this adds
A pull-payment escrow path alongside the existing
distribute, so a payer fundsone transfer instead of N and no single bad member trustline can abort a
payroll run.
deposit(id, from, amount)amountinto the contract in a single transfer, then credits each member their basis-point shareclaim(id, member)claim_to(id, member, to)memberauthorizes)claimable_balance(id, member) -> i128total_escrowed(id) -> i128distributeis untouched — escrow is additive, not a replacement, andtest_distribute_still_works_alongside_escrowexercises both paths on one group.Design notes
Snapshot semantics. A deposit credits the member set as of that deposit.
Balances live under
DataKey::Claimable(BytesN<32>, Address)— keyed by address,not by a position in the member list — so
update_memberscan never retroactivelymove already-credited funds. Members removed from a group keep what they accrued
and can still claim it. Documented in the module rustdoc and proven in
test_escrow_credits_survive_a_full_member_replacement, which deposits, swaps theentire member list, and confirms the original members still claim their exact
amounts while the new members start at zero.
Accounting invariant. Per group,
sum(claimable) == total_escrowed.depositmoves both sides up together and
settle_claimmoves both down together, so theycannot diverge.
base::escrow::sum_claimableandbase::escrow::accounting_holdsare the internal helpers the tests assert on (Soroban cannot enumerate storage
keys, so they take the address set to total). Across groups, the sum of
total_escrowedequals the contract's token balance — asserted intest_escrow_totals_across_groups_never_exceed_contract_balance.Dust. Credits reuse
base::utils::distribute_amounts, which floors everyshare except the last and gives the remainder to the final member, so each deposit
sums exactly.
test_escrow_no_dust_leaks_across_100_uneven_depositsruns 100deposits of 1000 across a 7-member group whose split never divides evenly and
asserts
sum(claimable) == sum(deposits)to the stroop, then claims every memberout and asserts the contract's token balance returns to zero.
Reentrancy / write ordering. The state change is complete before the token is
touched:
claim_torunssettle_claim— which removes the member's entry anddecrements the group total — and only then calls
transfer. By the time the tokencontract runs there is nothing left to claim. To make that assertable rather than
merely asserted, the effects half is a separate function;
test_escrow_settle_clears_state_before_any_transferdrives it directly andchecks the entry is cleared and the total decremented while not a single stroop
has moved, then shows a second attempt at that point returns
NothingToClaim.Worth noting for reviewers: Soroban already rejects reentry into a contract
already on the call stack, so a token genuinely calling back would be rejected by
the host — this ordering is a second line of defence, and it is the reason the
test drives
settle_claimdirectly rather than through a reentrant mock token.TTL policy and rent cost. Named constants in
base::escrow:ESCROW_TTL_THRESHOLD= 30 days (518_400 ledgers) andESCROW_TTL_EXTEND_TO=120 days (2_073_600 ledgers), both derived from
LEDGERS_PER_DAY = 17_280. Everyescrow write extends the entry it touched, the group record the claim path has to
read, and the contract's own instance entry — an archived instance means the
contract cannot be invoked at all, so live balances behind a dead instance are
still unreachable. The rent is charged to whoever triggers the bump: the depositor
pays one bump per credited member plus the group total, group record and instance;
the claimer pays for the group total, group record and instance. The 30-day
threshold means an active group re-bumps about monthly rather than on every call,
while an idle group still leaves any member a four-month window to come back and
claim without an external
RestoreFootprint.New error.
NothingToClaim = 16, appended so no existing discriminant moves(12–15 went to the upgradeability work that landed on main while this was open).
A current member with no balance (including a second claim after a successful one)
gets
NothingToClaim; an address that holds no balance and is not a member getsMemberNotFound.depositon a memberless group returnsEmptyMembersbeforeany transfer, so the contract never takes custody of tokens nobody could claim.
Events.
escrow_deposited(id, from, amount)andescrow_claimed(id, member, to, amount).Tests
20 new tests in
contract/src/test.rs, covering every acceptance criterion:zero-balance and double-claim →
NothingToClaim; non-member →MemberNotFoundwith nothing moved; memberless deposit →
EmptyMemberswith zero custody; the100-deposit dust check; a seeded-LCG fuzz run of 120 random deposit/claim steps
asserting the invariant, the group total and the contract's token balance after
every step; the member-swap snapshot test; the write-ordering test; and a TTL
test that advances the ledger 60 days past the 4_096-ledger default with
env.ledger().with_mut(...)and then claims successfully.maindoes not build — fixed here, please readWhile this was open, main gained the upgradeability, SEP-10 and pause work, and
main's own Contract CI is red: the crate does not compile there. This branch
cannot go green without fixing that, so the second commit does, and each fix is
something main's own tests already ask for:
base::utils::calculate_sharewas half-converted to a checked form. Itreferenced an undefined
product, used?in a function returningi128,and
MAX_SAFE_TOTAL— whichtest.rsandprop_tests.rsboth import — wasnever added. Completed as the tests describe.
base::auth::validate_percentagesstill summed with+=and trapped onoverflow, which is exactly what main's own
test_regression_validate_percentages_overflow_safeexists to catch. It useschecked_addnow.tokenmodule wheretoken_addresswas meant.init, so once the cratecompiled every
createin them failed withMigrationRequired— themigration gate landed without those setups being updated.
test_distribute_large_amountasserted an error while distributing 1000against a minted balance of 1e18. That only held because its group was never
created; with the setup fixed it cannot pass either way. It now distributes
the large amount it mints and asserts the even split, which is what its name
and setup describe. If that is not the intent, say so and I will change it.
Left alone deliberately:
get_calculated_sharestill returnsi128andaborts on overflow, as its trait signature and doc comment say. A test comment
on main argues for a typed error there, but widening the return type is an ABI
change and belongs to whoever owns that refactor.
Verification
Run locally in
contract/on the merged tree, all green:cargo fmt --all -- --checkcargo clippy --all --all-targets -- -D warningscargo buildcargo test— 81 passed, 0 failed (60 from main + 21 escrow tests)cargo test --doccargo build --target wasm32v1-none --release(the Makefile'sbuildtarget)Not covered
ledger jump, because this contract can only extend entries it owns. It does that
with a small
SacDataKeymirror of the Stellar asset contract'sBalance(Address)key layout, which relies on Soroban encoding
#[contracttype]enum keys byvariant name. That is test scaffolding standing in for the traffic that keeps a
real asset contract live, not production code — but it is a coupling to SAC
internals that a reviewer should be aware of.
suite above.
test_snapshots/is gitignored in this repo, so the regenerated snapshots aredeliberately left out of the diff.