feat(kotlin): mpp/session client#179
Conversation
Greptile SummaryThis PR adds the client-side MPP payment-channel session intent to the Kotlin SDK: a 48-byte Ed25519 voucher preimage encoder, channel and event-authority PDA derivation, a payer-partial-signed
Confidence Score: 5/5The core session accounting — watermark monotonicity, nonce tracking, retry-safe prepare/record, and the REPLAYED clamp — is correct and well-tested. No data-loss or incorrect-state paths were found in the changed code. The voucher preimage byte layout matches the frozen cross-SDK conformance vectors. The reconcileSettled clamp and retry-safe prepareIncrement patterns mirror the Go reference correctly. The two noted issues are API-contract quality concerns (inconsistent exception types from fromBase58 and deferred zero-amount validation in accept) that do not cause incorrect watermark state or on-chain mis-behavior. The deriveOpen / open functions in SessionClient.kt deserve a second look for the PublicKey.fromBase58 error wrapping; everything else is straightforward. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant App
participant PaymentChannelSession
participant ActiveSession
participant SessionConsumer
participant CommitTransport
participant Operator
App->>PaymentChannelSession: open(request, payerSigner, sessionSigner, blockhash)
PaymentChannelSession->>PaymentChannelSession: ensureClientVoucherPull(request)
PaymentChannelSession->>PaymentChannelSession: deriveOpen() — PDA + salt
PaymentChannelSession->>PaymentChannelSession: buildOpenTransaction() — payer partial-signs
PaymentChannelSession-->>App: PaymentChannelSessionOpen(open, session, action)
App->>Operator: POST credential (serializeSessionCredential)
Operator-->>App: session established
loop Per metered delivery
App->>SessionConsumer: commitDirective(directive)
SessionConsumer->>ActiveSession: prepareIncrement(amount)
ActiveSession-->>SessionConsumer: SignedVoucher (watermark unchanged)
SessionConsumer->>CommitTransport: commit(directive, CommitPayload)
CommitTransport-->>SessionConsumer: CommitReceipt(status)
alt COMMITTED
SessionConsumer->>ActiveSession: recordVoucher(voucher)
ActiveSession-->>SessionConsumer: watermark advanced
else REPLAYED
SessionConsumer->>ActiveSession: reconcileSettled(min(settled, prepared))
ActiveSession-->>SessionConsumer: watermark clamped (never regresses)
end
SessionConsumer-->>App: CommitReceipt
end
App->>ActiveSession: closeAction(finalIncrement?)
ActiveSession-->>App: SessionAction.Close(voucher?)
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant App
participant PaymentChannelSession
participant ActiveSession
participant SessionConsumer
participant CommitTransport
participant Operator
App->>PaymentChannelSession: open(request, payerSigner, sessionSigner, blockhash)
PaymentChannelSession->>PaymentChannelSession: ensureClientVoucherPull(request)
PaymentChannelSession->>PaymentChannelSession: deriveOpen() — PDA + salt
PaymentChannelSession->>PaymentChannelSession: buildOpenTransaction() — payer partial-signs
PaymentChannelSession-->>App: PaymentChannelSessionOpen(open, session, action)
App->>Operator: POST credential (serializeSessionCredential)
Operator-->>App: session established
loop Per metered delivery
App->>SessionConsumer: commitDirective(directive)
SessionConsumer->>ActiveSession: prepareIncrement(amount)
ActiveSession-->>SessionConsumer: SignedVoucher (watermark unchanged)
SessionConsumer->>CommitTransport: commit(directive, CommitPayload)
CommitTransport-->>SessionConsumer: CommitReceipt(status)
alt COMMITTED
SessionConsumer->>ActiveSession: recordVoucher(voucher)
ActiveSession-->>SessionConsumer: watermark advanced
else REPLAYED
SessionConsumer->>ActiveSession: reconcileSettled(min(settled, prepared))
ActiveSession-->>SessionConsumer: watermark clamped (never regresses)
end
SessionConsumer-->>App: CommitReceipt
end
App->>ActiveSession: closeAction(finalIncrement?)
ActiveSession-->>App: SessionAction.Close(voucher?)
Reviews (17): Last reviewed commit: "feat(kotlin): mpp/session client" | Re-trigger Greptile |
| } else { | ||
| element.longOrNull?.toULong() ?: throw MppException.InvalidJson() | ||
| } |
There was a problem hiding this comment.
The number branch of
SaltStringSerializer uses JsonPrimitive.longOrNull to read the salt. Any salt value greater than Long.MAX_VALUE (~9.2 × 10¹⁸) will cause longOrNull to return null, which throws InvalidJson. Since salt is a random u64, about half of all valid salts would silently break deserialization if a peer sends them as a JSON number rather than a string. Using content.toULongOrNull() on the primitive's raw string avoids the Long range limit entirely.
| } else { | |
| element.longOrNull?.toULong() ?: throw MppException.InvalidJson() | |
| } | |
| } else { | |
| element.content.toULongOrNull() ?: throw MppException.InvalidJson() | |
| } |
There was a problem hiding this comment.
Already fixed in cefd1cf, reads raw content via toULongOrNull now.
…penAPI Address Ludo's review (solana-foundation#179): rewrite the Compose demo to mirror the iOS PayKitDemo. It fetches the playground's /openapi.json (over 10.0.2.2:3000), renders every priced operation (from each route's x-payment-info offers) as a tappable card collection, generates a local signer, tops up over Surfpool cheatcodes, and consumes one over MPP. iOS-styled grouped UI (bold title, inset rounded cards, log). Adds OpenApi.kt, embeds the screenshot in kotlin/README.md, and drops the stale Mobile-Wallet-Adapter e2e assets. Two real fixes surfaced while capturing the screenshot on the emulator: consume() now runs OkHttp on Dispatchers.IO (was NetworkOnMainThreadException), and the Section card lays its children in a Column (rows previously overlapped).
…Null Address solana-foundation#179 greptile P1: SaltStringSerializer's number branch used JsonPrimitive.longOrNull, so a salt above Long.MAX_VALUE (about half of all random u64 salts) sent as a JSON number deserialized to null and threw. Parse the raw primitive content as ULong for both the string and number forms.
Address Ludo's review (solana-foundation#179): the Compose UI now mirrors the SwiftUI demo's iOS inset-grouped design. Thin uppercase gray section headers, white 10dp rounded section cards on a #F2F2F7 grouped background with hairline row separators; an Account section with a green dollar Balance + monospaced values; endpoint cards (150x130, 14dp radius) with a vertical tint gradient, a per- endpoint Material icon (chart/quote/sparkles/credit-card, not a single $ glyph) matching the iOS SF Symbols, and a white capsule GET/POST badge; and a log row with the green check-seal / red / info status icons, a monospaced timestamp + signature + body, and a blue 'View receipt on pay.sh' link. Adds material-icons-extended and refreshes the screenshot.
lgalabru
left a comment
There was a problem hiding this comment.
LGTM thanks @EfeDurmaz16.
Let's get #181 merged first - solving our CI issues.
…penAPI Address Ludo's review (solana-foundation#179): rewrite the Compose demo to mirror the iOS PayKitDemo. It fetches the playground's /openapi.json (over 10.0.2.2:3000), renders every priced operation (from each route's x-payment-info offers) as a tappable card collection, generates a local signer, tops up over Surfpool cheatcodes, and consumes one over MPP. iOS-styled grouped UI (bold title, inset rounded cards, log). Adds OpenApi.kt, embeds the screenshot in kotlin/README.md, and drops the stale Mobile-Wallet-Adapter e2e assets. Two real fixes surfaced while capturing the screenshot on the emulator: consume() now runs OkHttp on Dispatchers.IO (was NetworkOnMainThreadException), and the Section card lays its children in a Column (rows previously overlapped).
…Null Address solana-foundation#179 greptile P1: SaltStringSerializer's number branch used JsonPrimitive.longOrNull, so a salt above Long.MAX_VALUE (about half of all random u64 salts) sent as a JSON number deserialized to null and threw. Parse the raw primitive content as ULong for both the string and number forms.
Address Ludo's review (solana-foundation#179): the Compose UI now mirrors the SwiftUI demo's iOS inset-grouped design. Thin uppercase gray section headers, white 10dp rounded section cards on a #F2F2F7 grouped background with hairline row separators; an Account section with a green dollar Balance + monospaced values; endpoint cards (150x130, 14dp radius) with a vertical tint gradient, a per- endpoint Material icon (chart/quote/sparkles/credit-card, not a single $ glyph) matching the iOS SF Symbols, and a white capsule GET/POST badge; and a log row with the green check-seal / red / info status icons, a monospaced timestamp + signature + body, and a blue 'View receipt on pay.sh' link. Adds material-icons-extended and refreshes the screenshot.
d7ab2ac to
eb07047
Compare
ee56652 to
988b321
Compare
|
openPullAction passes channelId as tokenAccount on purpose, matching Go and Rust. Documented in 478dfe2. |
Implements the client-side MPP payment-channel session intent for the Kotlin SDK, mirroring the Go reference and Rust spine: - 48-byte Ed25519 voucher preimage, channel/event-authority PDA derivation - open instruction with payer-partial-signed transaction - ActiveSession with monotonic watermark accounting and overflow guards - SessionConsumer with retry-safe commit/reconcile (lost-response clamp) - Full session wire types with SessionActionCodec (internally-tagged union) - Cross-SDK conformance runner validating byte-identical voucher vectors - AndroidDemo consuming the session endpoint end-to-end Reviewed by Greptile; both flagged concerns resolved: - SaltStringSerializer reads raw content via toULongOrNull (full u64 range) - openPullAction channelId-as-tokenAccount documented, matching Go/Rust
478dfe2 to
2ba6d8f
Compare
Adds the client-side MPP payment-channel session intent to the Kotlin SDK (
com.solana.paykit), mirroring the Rust reference spine and the Go reference.What
PaymentChannels: the 48-byte Ed25519 voucher preimage, channel + event-authority PDA derivation, and theopeninstruction plus the payer-signed (operator-fee-payer-unsigned) open transaction. Client subset only; the server-side settle/finalize/distribute, the Ed25519 precompile, and the BLAKE3 distribution hash are out of scope for this client-only SDK.SessionRequest,OpenPayload(salt as a decimal string viaSaltStringSerializer),SignedVoucher/VoucherData(cumulativeAmountwith acumulativeread-alias), commit/topUp/close payloads,MeteringDirective/MeteringUsage,CommitReceipt, and theSessionActionsealed union with theSessionActionCodecflatten/unflatten codec (theactionkey, thetopUptag).ActiveSession: monotonic cumulative watermark, nonce accounting, retry-safe prepare/record voucher, thereconcileSettledlost-response clamp (Go parity), and the action builders.recordVoucherbinds the voucher to the active channel and advances the nonce to at leastnonce + 1, matching GoRecordVoucher.SessionConsumer: validate, sign, commit, advance. The local watermark advances only on a committed receipt and reconciles (clamped to the prepared voucher, never regressing) on a replayed one.serializeSessionCredential, reusing the existing JCS canonicalization) and asolana/sessionchallenge dispatch.Verification
gradle checkis green (tests, jacoco coverage gate, ktlint).harness/kotlin-conformance(a small gradle application that path-includes the SDK, mirroringharness/kotlin-client) reads a vector on stdin and drives the realPaymentChannels.voucherMessageBytes, registered viaharness/runners/kotlin.jsonwithintents=["session"]. Both frozensession-vouchervectors pass through the cross-SDK conformance driver (byte-identical to Swift and Go), now enforced in CI in theharness-kotlinjob.Notes