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
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.