fix(rust): do not advance session watermark on a replayed commit#162
fix(rust): do not advance session watermark on a replayed commit#162EfeDurmaz16 wants to merge 3 commits into
Conversation
Greptile SummaryThis PR fixes a watermark over-advancement bug in
Confidence Score: 5/5Safe to merge — changes are surgical and self-contained, both paths in commit_directive are fully exercised by the new tests, and the clamp invariant prevents a misbehaving server from over-authorizing. The logic in reconcile_settled correctly mirrors record_voucher nonce+1 accounting for the single-threaded lost-response scenario. The clamp settled.min(prepared) is the critical security boundary and is verified by a dedicated test. The channel-id guard in record_voucher is a pure tightening with no callers affected. No regressions introduced. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant C as Client (SessionConsumer)
participant S as Server (CommitTransport)
participant W as ActiveSession (watermark)
Note over C,W: Fresh commit path (normal)
C->>W: prepare_increment(amount)
C->>S: commit(directive, payload)
S-->>C: CommitReceipt status Committed
C->>W: "record_voucher - cumulative=X nonce=N+1"
Note over C,W: Lost-response replay path (new fix)
C->>W: prepare_increment(amount)
C->>S: commit(directive, payload)
Note over S: Server already settled at Y
S-->>C: "CommitReceipt status Replayed cumulative=Y"
Note over C: clamp = min(Y, X)
C->>W: reconcile_settled(clamp) - advances if above watermark
Note over C,W: Server-inflation attack (clamped)
C->>W: prepare_increment(amount)
C->>S: commit(directive, payload)
S-->>C: "CommitReceipt status Replayed cumulative=1000000"
Note over C: 1000000 clamped to prepared X
C->>W: reconcile_settled(X)
Reviews (6): Last reviewed commit: "fix(rust): clamp replayed-receipt reconc..." | Re-trigger Greptile |
| if receipt.status != CommitStatus::Replayed { | ||
| self.session.record_voucher(&payload.voucher)?; | ||
| } |
There was a problem hiding this comment.
Using a negative comparison (
!= Replayed) means any future CommitStatus variant that is not Replayed will silently advance the local watermark. The comment above already states the intent is to record only on a fresh committed receipt, so a positive equality check is more defensive and self-documenting — it will safely default to not recording if a new status is ever added.
| if receipt.status != CommitStatus::Replayed { | |
| self.session.record_voucher(&payload.voucher)?; | |
| } | |
| if receipt.status == CommitStatus::Committed { | |
| self.session.record_voucher(&payload.voucher)?; | |
| } |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…ign vouchers On a replayed CommitReceipt the server has already settled the delivery, so its Cumulative is authoritative. CommitDirective now reconciles the local watermark to that cumulative (advancing when behind, e.g. a lost response, and never regressing) instead of recording the freshly prepared higher voucher, which would let a later close sign for more than was settled. RecordVoucher also rejects a voucher whose channel does not match the active session. Surfaced by Greptile and Codex on solana-foundation#160. Mirrors the rust spine fix (solana-foundation#162).
…foreign vouchers On a replayed CommitReceipt the server has already settled the delivery, so its cumulative is authoritative. commit_directive now reconciles the local watermark to that cumulative (advancing when behind, e.g. a lost response, and never regressing) instead of recording the freshly prepared higher voucher, which would let a later close sign for more than was settled. record_voucher also rejects a voucher whose channel does not match the active session, and VoucherData.from_dict coerces a numeric cumulativeAmount to str. Surfaced by Greptile and Codex on solana-foundation#161. Mirrors the rust spine fix (solana-foundation#162).
51cb702 to
f887a9f
Compare
| pub fn reconcile_settled(&mut self, settled: u64) { | ||
| if settled > self.cumulative { | ||
| self.cumulative = settled; | ||
| } | ||
| } |
There was a problem hiding this comment.
reconcile_settled leaves the nonce counter behind
record_voucher (the fresh-commit path) advances self.nonce to max(current, voucher.nonce). reconcile_settled advances only self.cumulative. In the canonical lost-response scenario this creates an immediate nonce collision: the client starts at nonce=0, prepare_increment(X) emits a voucher with nonce=1, the server settles it at nonce=1, the network drops the response, the client retries, gets Replayed at the correct cumulative, calls reconcile_settled — and is now at cumulative=X, nonce=0. The very next prepare_increment produces another voucher with nonce=1, which the server has already accepted. If the channel program or the session server validates nonce monotonicity (which is the whole point of tracking it client-side), that delivery will be rejected.
reconcile_settled should also advance the nonce, either by accepting it as a second parameter or by having commit_directive call a separate reconcile_nonce(payload.voucher.data.nonce.unwrap_or(0)) helper on the replay branch. The existing regression tests do not exercise a second delivery after a replay, so the failure mode is invisible today.
Addresses Greptile + Codex review of solana-foundation#160: - Reconcile the local watermark to a replayed receipt's cumulative (advance when behind, e.g. a lost response; never regress) instead of recording the freshly prepared higher voucher, which could let a later close sign for more than was settled. - RecordVoucher rejects a voucher whose channel does not match the session. - CommitDirective records only on an explicit committed receipt and rejects unknown statuses, so a malformed/misrouted receipt never advances local state. Mirrors the rust spine fix (solana-foundation#162).
Addresses Greptile + Codex review of solana-foundation#161: - Reconcile the local watermark to a replayed receipt's cumulative (advance when behind, e.g. a lost response; never regress) instead of recording the freshly prepared higher voucher, which could let a later close sign for more than was settled. - record_voucher rejects a voucher whose channel does not match the session; VoucherData.from_dict coerces a numeric cumulativeAmount to str. - commit_directive records only on an explicit committed receipt and rejects unknown statuses. - Base-unit accessors (deposit_amount, amount_base_units, voucher cumulative) parse strict unsigned u64 decimals, rejecting negative/fractional/over-range values like the rust/Go typed parsers. Mirrors the rust spine fix (solana-foundation#162).
…cumulative Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the nonce unchanged, so the first delivery after a lost-response replay reused the nonce the server already settled. Bump the nonce by one whenever reconcile advances (mirroring record_voucher's accounting for that delivery). The nonce is client request-counter metadata (not in the signed 48-byte preimage), so this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay regression test.
…mulative Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the nonce unchanged, so the first delivery after a lost-response replay reused the nonce the server already settled. Bump the nonce by one whenever reconcile advances (mirroring record_voucher's accounting for that delivery). The nonce is client request-counter metadata (not in the signed 48-byte preimage), so this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay regression test.
…d cumulative Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the nonce unchanged, so the first delivery after a lost-response replay reused the nonce the server already settled. Bump the nonce by one whenever reconcile advances (mirroring record_voucher's accounting for that delivery). The nonce is client request-counter metadata (not in the signed 48-byte preimage), so this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay regression test.
…cumulative Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the nonce unchanged, so the first delivery after a lost-response replay reused the nonce the server already settled. Bump the nonce by one whenever reconcile advances (mirroring record_voucher's accounting for that delivery). The nonce is client request-counter metadata (not in the signed 48-byte preimage), so this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay regression test.
8521d0f to
c1baba6
Compare
…reign vouchers On a replayed CommitReceipt the server has already settled the delivery, so its cumulative is authoritative. SessionConsumer::commit_directive now reconciles the local watermark to that cumulative (advancing when behind, e.g. a lost response, and never regressing) instead of recording the freshly prepared higher voucher, which would let a later channel close sign for more than was settled. ActiveSession::record_voucher also rejects a voucher whose channel does not match the active session. Adds reconcile_settled plus regression tests for reconcile, no-regress, and the foreign-channel guard. Surfaced by Greptile/Codex on the Go and Python session ports (solana-foundation#160, solana-foundation#161).
…cumulative Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the nonce unchanged, so the first delivery after a lost-response replay reused the nonce the server already settled. Bump the nonce by one whenever reconcile advances (mirroring record_voucher's accounting for that delivery). The nonce is client request-counter metadata (not in the signed 48-byte preimage), so this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay regression test.
…umulative The replayed-receipt path reconciled the local watermark to the server-reported cumulative with only a never-regress lower bound and no upper bound. The server is untrusted, so a malicious or buggy server could report a replay settled far above the voucher the client just signed, pushing the watermark up; the next voucher would then over-authorize (capped only by the on-chain deposit). Clamp the reported cumulative to the just-prepared voucher's cumulative: an honest lost-response replay settles at or below it (single-threaded session), so this preserves recovery while closing the over-authorization. Adds a regression test.
Addresses Greptile + Codex review of solana-foundation#160: - Reconcile the local watermark to a replayed receipt's cumulative (advance when behind, e.g. a lost response; never regress) instead of recording the freshly prepared higher voucher, which could let a later close sign for more than was settled. - RecordVoucher rejects a voucher whose channel does not match the session. - CommitDirective records only on an explicit committed receipt and rejects unknown statuses, so a malformed/misrouted receipt never advances local state. Mirrors the rust spine fix (solana-foundation#162).
…mulative Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the nonce unchanged, so the first delivery after a lost-response replay reused the nonce the server already settled. Bump the nonce by one whenever reconcile advances (mirroring record_voucher's accounting for that delivery). The nonce is client request-counter metadata (not in the signed 48-byte preimage), so this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay regression test.
Addresses Greptile + Codex review of solana-foundation#161: - Reconcile the local watermark to a replayed receipt's cumulative (advance when behind, e.g. a lost response; never regress) instead of recording the freshly prepared higher voucher, which could let a later close sign for more than was settled. - record_voucher rejects a voucher whose channel does not match the session; VoucherData.from_dict coerces a numeric cumulativeAmount to str. - commit_directive records only on an explicit committed receipt and rejects unknown statuses. - Base-unit accessors (deposit_amount, amount_base_units, voucher cumulative) parse strict unsigned u64 decimals, rejecting negative/fractional/over-range values like the rust/Go typed parsers. Mirrors the rust spine fix (solana-foundation#162).
…d cumulative Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the nonce unchanged, so the first delivery after a lost-response replay reused the nonce the server already settled. Bump the nonce by one whenever reconcile advances (mirroring record_voucher's accounting for that delivery). The nonce is client request-counter metadata (not in the signed 48-byte preimage), so this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay regression test.
6a3561a to
d19638d
Compare
Addresses Greptile + Codex review of solana-foundation#161: - Reconcile the local watermark to a replayed receipt's cumulative (advance when behind, e.g. a lost response; never regress) instead of recording the freshly prepared higher voucher, which could let a later close sign for more than was settled. - record_voucher rejects a voucher whose channel does not match the session; VoucherData.from_dict coerces a numeric cumulativeAmount to str. - commit_directive records only on an explicit committed receipt and rejects unknown statuses. - Base-unit accessors (deposit_amount, amount_base_units, voucher cumulative) parse strict unsigned u64 decimals, rejecting negative/fractional/over-range values like the rust/Go typed parsers. Mirrors the rust spine fix (solana-foundation#162).
…d cumulative Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the nonce unchanged, so the first delivery after a lost-response replay reused the nonce the server already settled. Bump the nonce by one whenever reconcile advances (mirroring record_voucher's accounting for that delivery). The nonce is client request-counter metadata (not in the signed 48-byte preimage), so this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay regression test.
Match the Rust/Go (solana-foundation#162) consumer: on a replayed commit receipt the client reconciles its watermark to the server-settled cumulative, clamped to the voucher just prepared (settled.min(prepared)) since the server is untrusted, and never regresses (nonce advances by one when it does). Previously Python recorded the prepared voucher for replayed receipts too, double-counting a duplicate delivery and trusting the server's reported cumulative. ActiveSession.reconcile_settled mirrors the Rust/Go method. Updated the two replay tests to the clamp behavior and added never-regress + inflated-server clamp cases.
Addresses Greptile + Codex review of solana-foundation#161: - Reconcile the local watermark to a replayed receipt's cumulative (advance when behind, e.g. a lost response; never regress) instead of recording the freshly prepared higher voucher, which could let a later close sign for more than was settled. - record_voucher rejects a voucher whose channel does not match the session; VoucherData.from_dict coerces a numeric cumulativeAmount to str. - commit_directive records only on an explicit committed receipt and rejects unknown statuses. - Base-unit accessors (deposit_amount, amount_base_units, voucher cumulative) parse strict unsigned u64 decimals, rejecting negative/fractional/over-range values like the rust/Go typed parsers. Mirrors the rust spine fix (solana-foundation#162).
…d cumulative Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the nonce unchanged, so the first delivery after a lost-response replay reused the nonce the server already settled. Bump the nonce by one whenever reconcile advances (mirroring record_voucher's accounting for that delivery). The nonce is client request-counter metadata (not in the signed 48-byte preimage), so this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay regression test.
Port the client-side MPP session surface to Kotlin (com.solana.paykit), mirroring the Rust spine and the Go reference: - paycore PaymentChannels: 48-byte Ed25519 voucher preimage, channel + event-authority PDA derivation, and the open instruction + payer-signed (operator-fee-payer-unsigned) open transaction. Client subset only; the server-side settle/finalize/distribute + ed25519 precompile + BLAKE3 distribution hash are out of scope for this client-only SDK. - Session wire types (kotlinx.serialization): SessionRequest, OpenPayload (salt as decimal string via SaltStringSerializer), SignedVoucher/VoucherData (cumulativeAmount with cumulative read-alias), commit/topUp/close payloads, MeteringDirective/Usage, CommitReceipt, and the SessionAction sealed union with the SessionActionCodec flatten/unflatten codec (action key, topUp tag). - ActiveSession: monotonic cumulative watermark, nonce accounting, retry-safe prepare/record voucher, the ReconcileSettled lost-response clamp (Go solana-foundation#162 parity), and the open/voucher/topUp/close action builders. - SessionConsumer: validate -> sign -> commit -> advance, advancing only on a committed receipt and reconciling (clamped, never regressing) on a replayed one. - Session credential framing (serializeSessionCredential) + solana/session challenge dispatch (sessionRequest / requireSolanaSession).
Port the client-side MPP session surface to Swift, mirroring the Rust spine (rust/crates/mpp + crates/core) and the Go reference: - PayCore PaymentChannels: 48-byte Ed25519 voucher preimage, channel + event-authority PDA derivation, and the open instruction + payer-signed (operator-fee-payer-unsigned) open transaction. Client subset only; the server-side settle/finalize/distribute + ed25519 precompile + BLAKE3 distribution hash are out of scope for this client-only SDK. - Session wire types: SessionRequest, OpenPayload, SignedVoucher/VoucherData (cumulativeAmount with cumulative read-alias), commit/topUp/close payloads, MeteringDirective/Usage, CommitReceipt, and the internally-tagged SessionAction codec (action key, salt-as-decimal-string, topUp tag). - ActiveSession: monotonic cumulative watermark, nonce accounting, prepare/record voucher (sign-without-advance is retry-safe), the ReconcileSettled lost-response clamp (Go solana-foundation#162 parity), and the open/voucher/topUp/close action builders. - SessionConsumer: validate -> sign -> commit -> advance, advancing the local watermark only on a committed receipt and reconciling (clamped, never regressing) on a replayed one. - Session credential framing (serializeSessionCredential) and a solana/session challenge dispatch accessor.
Match the Rust/Go (solana-foundation#162) consumer: on a replayed commit receipt the client reconciles its watermark to the server-settled cumulative, clamped to the voucher just prepared (settled.min(prepared)) since the server is untrusted, and never regresses (nonce advances by one when it does). Previously Python recorded the prepared voucher for replayed receipts too, double-counting a duplicate delivery and trusting the server's reported cumulative. ActiveSession.reconcile_settled mirrors the Rust/Go method. Updated the two replay tests to the clamp behavior and added never-regress + inflated-server clamp cases.
Addresses Greptile + Codex review of solana-foundation#161: - Reconcile the local watermark to a replayed receipt's cumulative (advance when behind, e.g. a lost response; never regress) instead of recording the freshly prepared higher voucher, which could let a later close sign for more than was settled. - record_voucher rejects a voucher whose channel does not match the session; VoucherData.from_dict coerces a numeric cumulativeAmount to str. - commit_directive records only on an explicit committed receipt and rejects unknown statuses. - Base-unit accessors (deposit_amount, amount_base_units, voucher cumulative) parse strict unsigned u64 decimals, rejecting negative/fractional/over-range values like the rust/Go typed parsers. Mirrors the rust spine fix (solana-foundation#162).
…d cumulative Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the nonce unchanged, so the first delivery after a lost-response replay reused the nonce the server already settled. Bump the nonce by one whenever reconcile advances (mirroring record_voucher's accounting for that delivery). The nonce is client request-counter metadata (not in the signed 48-byte preimage), so this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay regression test.
Match the Rust/Go (solana-foundation#162) consumer: on a replayed commit receipt the client reconciles its watermark to the server-settled cumulative, clamped to the voucher just prepared (settled.min(prepared)) since the server is untrusted, and never regresses (nonce advances by one when it does). Previously Python recorded the prepared voucher for replayed receipts too, double-counting a duplicate delivery and trusting the server's reported cumulative. ActiveSession.reconcile_settled mirrors the Rust/Go method. Updated the two replay tests to the clamp behavior and added never-regress + inflated-server clamp cases.
fix(rust): reconcile session watermark on a replayed commit
Suggested PR title (replaces the stale "do not advance session watermark on a replayed commit"): the final semantics do advance the watermark on replay, by reconciling it to the server-settled cumulative, clamped and never regressing.
Problem
SessionConsumer::commit_directiverecorded the freshly prepared voucher unconditionally after every commit, including commits the server answered withstatus: replayed. Per the replay rule inskills/pay-sdk-implementation/references/intents/mpp-session.md(lines 221, 294, 355-357), a commit isreplayedonly when the retried delivery carries the same cumulative AND the same signature as the cached committed delivery, with the signature re-verified, and the server returns the cached receipt without advancing its watermark. Both reference servers implement exactly that:rust/crates/mpp/src/server/session.rs:495(replay condition),:555(replay check inside the atomic store closure),:782(CommitStatus::Replayedwith the cached receipt)typescript/packages/mpp/src/server/Session.ts:893-901(idempotent replay window insideupdateChannel: samecumulativeand samevoucherSignature, signature re-verified viaassertVoucherSignature, cached outcome returned andreturn currentleaves channel state untouched)The client, however, treated a replayed receipt like a fresh acceptance and recorded the voucher it had just prepared. Against a non-conforming or malicious server that reports
replayedwith an arbitrary cumulative, this over-advanced the local watermark, so the next signed voucher could over-authorize up to the on-chain deposit. Skipping the record entirely is also wrong: in the lost-response case (the original commit succeeded but the receipt never arrived) the local watermark is behind the server, and the next delivery would sign a non-monotonic cumulative the server must reject.Fix
ActiveSession::reconcile_settled(settled): advances the local watermark to a server-settled cumulative when it is ahead, never regresses, and bumps the request nonce on advance so the next prepared voucher does not reuse the already-settled nonce (mirrors therecord_vouchernonce accounting).SessionConsumer::commit_directive: onCommitStatus::Replayed, parse the receipt cumulative and reconcile tomin(settled, prepared)instead of recording the prepared voucher; fresh commits record the voucher as before. The clamp treats the server as untrusted: an honest lost-response replay settles at or below the voucher just signed (single-threaded session), so a server-reported cumulative above it cannot push the watermark past what the client actually authorized.ActiveSession::record_voucher: rejects vouchers for a different channel. This is parity catch-up with the TypeScript client, which already throws on a channel mismatch intypescript/packages/mpp/src/client/Session.ts:466.Against a conforming server the change is unobservable: replay requires the identical cumulative and signature, Ed25519 signing is deterministic, so settled equals prepared and
reconcile_settled(min(settled, prepared))advances exactly asrecord_voucherdid. The fix is defensive hardening on the client, not a wire change.Note for review: TypeScript client has the pre-fix behavior
typescript/packages/mpp/src/client/SessionConsumer.ts:76(commitDirective) still callsthis.#session.recordVoucher(voucher)unconditionally, with no branch onreceipt.status === 'replayed'. After this PR the Rust and TypeScript reference clients diverge on the replayed branch. The divergence is latent against conforming servers (per above), but ports mirror both references, so a matching TypeScript follow-up is needed. Happy to open it once the semantics here are agreed.Tests
Unit tests cover: lost-response reconcile to the settled cumulative, no regression from a stale replayed receipt, clamp against an inflated server-reported cumulative, foreign-channel voucher rejection, and nonce freshness after a replay reconcile.
cargo fmt --check: cleancargo clippy -p solana-mpp --all-targets: no warnings in the touched files (the remaining warnings pre-exist on main in files this PR does not modify)cargo test -p solana-mpp: 641 lib + 9 integration tests pass, 0 failedCoverage is unit-level (transport fakes). The harness has no session scenario yet; when the session harness cell lands, a duplicate-commit scenario should exercise this replayed-client path cross-SDK. Tracked as a follow-up.
Read order
rust/crates/mpp/src/client/session.rs,reconcile_settled(strict greater-than, nonce bump) and the channel guard inrecord_voucherrust/crates/mpp/src/client/session_consumer.rs, theReplayedbranch with thesettled.min(prepared)clampReplayTransport+ the three replay scenariosGenerated vs handwritten
The load-bearing change is ~25-30 lines (
reconcile_settledbody, theReplayedbranch, the channel guard); the other ~150 added lines are test scaffolding and rationale comments. Small diff, small blast radius.Reviewer note
Grade this as hardening, not a live-fund bug: both canonical servers emit
Replayedonly for a byte-identical voucher, and Ed25519 re-signing is deterministic, so on every reachable honest path the old code already landed on the same watermark. The fix's value is against non-canonical or lying servers (the clamp blocks over-authorization, strict greater-than blocks regression) and interop with looser replay-idempotency servers. Status: the bug still exists on main and nothing superseded it, but the branch conflicts with thecrates/mpptocrates/kit/src/mpprestructure and needs a rebase that re-targets the moved files before merge. TS has the same gap inSessionConsumer.commitDirective; this is the template for that follow-up.Fable 5 self-review
The logic is correct at every boundary I could attack (equality no-op, reject at less-or-equal, clamp against inflated settled) and the adversarial tests are genuinely good. What a stranger would fairly flag: the PR sat unrebased through a crate restructure and now conflicts; the body originally described the first commit's semantics rather than the final reconcile-with-clamp; and the replay branch has the settled voucher's nonce in hand but bumps by a bare
+1instead of reconciling it. All 641 solana-mpp lib tests pass on this head.