Repository navigation
feat(payment-router): partial settlement with pull-based claiming - #85
Merged
joelpeace48-cell merged 1 commit intoAug 24, 2026
Merged
Conversation
All-or-nothing escrow release is a poor fit for continuous inference usage. A buyer will not lock a month of spend up front and a provider will not serve a month before being paid, so the standoff blocks adoption. Settlement is now incremental: the metered portion is released as it accrues, the remainder stays escrowed, and the payee pulls what has been released. Release does not push. release_partial only moves funds from escrowed to claimable; the payee calls claim to take them. That keeps the transfer fee on the party receiving the value, and means a payee whose destination is broken cannot make settlement fail for anyone else. claim stays available after the escrow is closed, so a refund of the remainder never strands funds the payee had already earned. Disputes freeze only the disputed portion. dispute takes its amount from the unreleased remainder and cannot reach `released` at all: funds released earlier were settled against usage already metered, and a later disagreement about subsequent usage is not grounds to claw them back. Concretely — with 3,000 released and 2,000 disputed out of 10,000, the payee can still claim the 3,000 while arbitration runs, and the remaining 5,000 keeps settling normally. Resolution in the payer's favour returns the frozen amount to the releasable remainder rather than refunding it, so one resolved disagreement does not force the escrow to be torn down and reopened. Closing is refused while a dispute is open, since it would refund the frozen amount out from under the arbiter. Partial releases are capped at 32 per escrow. Each release is a ledger write and a slot in permanently stored state, so an unbounded count is unbounded state growth. A payee wanting finer granularity opens a second escrow. Arbitrated resolution does not consume a slot — the cap bounds how often the payer voluntarily settles, and an arbitrated outcome is not that. The invariant, enforced on every path and asserted directly: releases can never sum past the deposit, and can never draw on a frozen portion. The invariant test settles repeatedly with interleaved claims, keeps pushing after the deposit is exhausted, and checks released <= total, claimed <= released and releasable >= 0 at every step, finishing on the contract's own balance to confirm it never pays out more than it was given. Streaming is deliberately not implemented. The issue raises it as a "consider", and the property the notes actually argue for — pull-based claiming, fees on the receiver, no contract-initiated push — is what this delivers. Continuous vesting can be layered on the same claimable/claimed split later without changing the settlement model. Authorization follows the pattern from FinesseStudioLab#74: the payer's signature is bound to the escrow id and the amount with require_auth_for_args, so a release the payer agreed to cannot be enlarged in transit, and the same for disputes. claim is the payee's alone; resolve_dispute is the arbiter's alone. Both are tested without mock_all_auths, which would approve an impostor's signature as readily as the real one. Also replaces the Symbol admin placeholder with a real Address, adds typed errors, TTL extension on every persistent write, and #[contractevent] types for the six observable state changes. Tests: 17. fmt, clippy -D warnings, cargo test --workspace and the wasm32v1-none release build all pass.
4 tasks
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 #73
Summary
All-or-nothing escrow release is a poor fit for continuous inference usage. A buyer will not lock a month of spend up front and a provider will not serve a month before being paid, and that standoff is the adoption blocker the issue describes.
Settlement is now incremental: the metered portion is released as it accrues, the remainder stays escrowed, and the payee pulls what has been released.
Release does not push
release_partialonly moves funds from escrowed to claimable. The payee callsclaimto take them.This is the pull-based model the issue's notes argue for, and it earns its keep twice: the transfer fee sits on the party receiving the value, and a payee whose destination is broken cannot make settlement fail for anyone else.
claimalso stays available after the escrow is closed, so refunding the remainder never strands funds the payee had already earned.Disputes freeze only the disputed portion
disputetakes its amount from the unreleased remainder and cannot reachreleasedat all. Funds released earlier were settled against usage already metered, and a later disagreement about subsequent usage is not grounds to claw them back.Concretely, with 3,000 released and 2,000 disputed out of 10,000: the payee can still claim the 3,000 while arbitration runs, and the remaining 5,000 keeps settling normally. Both are asserted.
Resolution in the payer's favour returns the frozen amount to the releasable remainder rather than refunding it, so one resolved disagreement does not force the escrow to be torn down and reopened. Closing is refused while a dispute is open, since it would refund the frozen amount out from under the arbiter.
Bounded partial releases
Capped at 32 per escrow. Each release is a ledger write and a slot in permanently stored state, so an unbounded count is unbounded state growth funded by whoever opened the escrow. A payee wanting finer granularity opens a second escrow.
Arbitrated resolution does not consume a slot — the cap bounds how often the payer voluntarily settles, and an arbitrated outcome is not that.
The invariant
Releases can never sum past the deposit, and can never draw on a frozen portion. Enforced on every path, and asserted directly rather than inferred: the invariant test settles repeatedly with interleaved claims, keeps pushing after the deposit is exhausted, and checks
released <= total,claimed <= releasedandreleasable >= 0at every step — finishing on the contract's own token balance to confirm it never pays out more than it was given.Streaming: deliberately not implemented
The issue raises it as a "consider", and the property its notes actually argue for — pull-based claiming, fees on the receiver, no contract-initiated push — is what this delivers. Continuous vesting can be layered on the same
released/claimedsplit later without changing the settlement model, so nothing here forecloses it. Flagging the omission explicitly rather than leaving it ambiguous.Authorization
Follows the pattern in #74: the payer's signature is bound to the escrow id and the amount with
require_auth_for_args, so a release the payer agreed to cannot be enlarged in transit. Same for disputes.claimis the payee's alone;resolve_disputeis the arbiter's alone.Both auth tests run without
mock_all_auths, which would approve an impostor's signature as readily as the real one.Also in this change
The scaffold's
Symboladmin placeholder becomes a realAddress(the arbiter, signing for its own installation), plus typed errors, TTL extension on every persistent write, and#[contractevent]types for the six observable state changes —events().publishis deprecated in SDK 27 and CI runs with-D warnings.Migration note
initialize(admin: Symbol)becomesinitialize(arbiter: Address), and the arbiter must sign.pingis gone;versionreturns 2.Acceptance criteria
Tests
Conflicts
One of three PRs. File sets are disjoint by crate: this one touches
payment-router/only, #83 touchesaudit-registry/plusdocs/, #84 touchesusage-meter/only. All six merge orders were tested locally againstmain— clean in every order, withfmt,clippy -D warnings,cargo test --workspace(45 tests) and thewasm32v1-nonerelease build passing on the combined result.