Skip to content

feat(payment-router): partial settlement with pull-based claiming - #85

Merged
joelpeace48-cell merged 1 commit into
FinesseStudioLab:mainfrom
Alheriii:feat/partial-settlement-73
Aug 24, 2026
Merged

joelpeace48-cell merged 1 commit into
FinesseStudioLab:mainfrom
Alheriii:feat/partial-settlement-73

Conversation

@Alheriii

Copy link
Copy Markdown
Contributor

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_partial only moves funds from escrowed to claimable. The payee calls claim to 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. claim also 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

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. 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 <= released and releasable >= 0 at 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/claimed split 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. claim is the payee's alone; resolve_dispute is 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 Symbol admin placeholder becomes a real Address (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().publish is deprecated in SDK 27 and CI runs with -D warnings.

Migration note

initialize(admin: Symbol) becomes initialize(arbiter: Address), and the arbiter must sign. ping is gone; version returns 2.

Acceptance criteria

  • Partial release implemented against metered usage
  • Dispute freezes only the disputed portion
  • Partial-release count bounded
  • Invariant tests: partial releases never exceed the escrowed total

Tests

Conflicts

One of three PRs. File sets are disjoint by crate: this one touches payment-router/ only, #83 touches audit-registry/ plus docs/, #84 touches usage-meter/ only. All six merge orders were tested locally against main — clean in every order, with fmt, clippy -D warnings, cargo test --workspace (45 tests) and the wasm32v1-none release build passing on the combined result.

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.
@joelpeace48-cell
joelpeace48-cell merged commit 30ccd81 into FinesseStudioLab:main Aug 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

payment-router: partial and streaming settlement

2 participants