Skip to content

feat(kotlin): mpp/session client#179

Open
EfeDurmaz16 wants to merge 14 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/kotlin-mpp-session
Open

feat(kotlin): mpp/session client#179
EfeDurmaz16 wants to merge 14 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/kotlin-mpp-session

Conversation

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator

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

  • paycore PaymentChannels: the 48-byte Ed25519 voucher preimage, channel + event-authority PDA derivation, and the open instruction 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.
  • Session wire types (kotlinx.serialization): SessionRequest, OpenPayload (salt as a decimal string via SaltStringSerializer), SignedVoucher/VoucherData (cumulativeAmount with a cumulative read-alias), commit/topUp/close payloads, MeteringDirective/MeteringUsage, CommitReceipt, and the SessionAction sealed union with the SessionActionCodec flatten/unflatten codec (the action key, the topUp tag).
  • ActiveSession: monotonic cumulative watermark, nonce accounting, retry-safe prepare/record voucher, the reconcileSettled lost-response clamp (Go parity), and the action builders. recordVoucher binds the voucher to the active channel and advances the nonce to at least nonce + 1, matching Go RecordVoucher.
  • 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.
  • Session credential framing (serializeSessionCredential, reusing the existing JCS canonicalization) and a solana/session challenge dispatch.

Verification

  • gradle check is green (tests, jacoco coverage gate, ktlint).
  • Adds a cross-SDK conformance runner: harness/kotlin-conformance (a small gradle application that path-includes the SDK, mirroring harness/kotlin-client) reads a vector on stdin and drives the real PaymentChannels.voucherMessageBytes, registered via harness/runners/kotlin.json with intents=["session"]. Both frozen session-voucher vectors pass through the cross-SDK conformance driver (byte-identical to Swift and Go), now enforced in CI in the harness-kotlin job.

Notes

  • Client-only. The session reuses the MPP charge mint resolver (localnet USDC resolves to the mainnet mint); server-side operability caveats are not applicable.

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).
Voucher preimage pinned against the frozen cross-SDK session-voucher vector
(channelId(32)||cumulative LE u64||expiresAt LE i64, incl. near-u64-max with no
precision loss) + signature verification, channel PDA stability, ActiveSession
watermark/nonce/expiry + reconcile clamp, SessionConsumer commit + replay
reconcile-and-clamp, the SessionAction wire codec, and the opener guards (pull +
clientVoucher only).
… advance

recordVoucher now rejects a voucher whose channelId does not match the active
session and advances the nonce to at least nonce+1 (or the voucher nonce when
higher), matching Go RecordVoucher. Mirrors the same fix applied to the swift
port; the kotlin session client is Go-flavoured (carries Go's ReconcileSettled).
…tent

Add harness/kotlin-conformance, a small gradle application (path-included SDK
build, like harness/kotlin-client) that reads a conformance vector on stdin and
emits a RunnerResult, driving the real PaymentChannels.voucherMessageBytes
encoder for the canonical-bytes voucherPreimage branch. Register it via
harness/runners/kotlin.json with intents=["session"] so the frozen
session-voucher vector now runs against the Kotlin SDK through the cross-SDK
driver (verified: both session vectors -> accept, byte-identical to swift/go).
…in job

Pre-warm the kotlin-conformance project and run the conformance driver for the
kotlin runner, so the session-voucher byte invariant is enforced in CI.
@greptile-apps

greptile-apps Bot commented Jun 19, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces the Kotlin MPP payment-channel session client, adding PaymentChannels (48-byte voucher preimage, PDA derivation, open instruction/transaction), ActiveSession (monotonic watermark, nonce accounting, two-phase prepare/record), SessionConsumer (validate-sign-commit-advance), the full session wire type set, SessionActionCodec, and a cross-SDK conformance harness. All new code is backed by unit tests and a pinned conformance vector.

  • PaymentChannels implements voucher preimage serialization, channel PDA derivation, and the payer-partial-signed open transaction — all faithfully mirroring the Rust/Go reference.
  • ActiveSession / SessionConsumer implement the monotonic watermark with retry-safe prepare/record and the reconcileSettled lost-response clamp (Go fix(rust): do not advance session watermark on a replayed commit #162 parity).
  • SessionActionCodec provides a custom flatten/unflatten codec for the internally-tagged SessionAction union, including the camelCase topUp tag and the salt decimal-string serializer.

Confidence Score: 5/5

Safe to merge. Core session logic is correct, well-tested, and faithfully mirrors the Go reference including the reconcileSettled lost-response clamp.

The implementation is clean with comprehensive unit tests covering the two-phase prepare/record flow, replay deduplication, watermark non-regression, and cross-SDK conformance vectors. The one finding is a future-proofing gap in the exhaustiveness of a when statement that has no impact on the current two-value enum.

No files require special attention. SessionConsumer.kt has a non-exhaustive when nit but it has no current behavioral impact.

Important Files Changed

Filename Overview
kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/SessionConsumer.kt Core metered-delivery consumer; non-exhaustive when over CommitStatus in commitDirective could silently skip watermark advance for future status values
kotlin/src/main/kotlin/com/solana/paykit/paycore/PaymentChannels.kt New file: PDA derivation, voucher preimage, and open transaction builder. Correct little-endian encoding and overflow/nonce guards.
kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/SessionClient.kt New file: ActiveSession (two-phase voucher, watermark, overflow guard), PaymentChannelSession.open, credential serialization. Logic aligns with Go reference.
kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/SessionTypes.kt New file: full wire-type set. SaltStringSerializer correctly uses element.content.toULongOrNull() to handle the full u64 range.
kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/SessionActionCodec.kt Flatten/unflatten codec for internally-tagged SessionAction union; camelCase topUp tag handled correctly.
kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/SessionStream.kt Demo end-to-end session flow. HttpCommitTransport throws IllegalStateException on failure rather than MppException; correctness preserved but exception type diverges from SDK contract.
harness/kotlin-conformance/src/main/kotlin/com/solana/paykit/conformance/Main.kt Cross-SDK conformance harness correctly drives PaymentChannels.voucherMessageBytes for byte-exact vector verification.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant PaymentChannelSession
    participant ActiveSession
    participant SessionConsumer
    participant Transport
    participant Operator

    Client->>PaymentChannelSession: open(request, payerSigner, sessionSigner, blockhash)
    PaymentChannelSession->>PaymentChannelSession: deriveOpen() → channelPDA, salt, deposit
    PaymentChannelSession->>PaymentChannelSession: buildOpenTransaction() → payer-partial-signed tx
    PaymentChannelSession-->>Client: "PaymentChannelSessionOpen { open, session, action }"

    Client->>PaymentChannelSession: serializeSessionCredential(challenge, action)
    PaymentChannelSession-->>Client: Authorization: Payment base64url(JCS)

    Client->>Operator: credential → server co-signs + broadcasts open tx
    Operator-->>Client: 200 SSE stream with MeteringDirective per delivery

    loop Per metered delivery
        Client->>SessionConsumer: commitDirective(directive)
        SessionConsumer->>SessionConsumer: validateDirective()
        SessionConsumer->>ActiveSession: prepareIncrement(amount) → SignedVoucher
        SessionConsumer->>Transport: commit(directive, CommitPayload)
        Transport-->>SessionConsumer: "CommitReceipt { status: COMMITTED | REPLAYED }"
        alt COMMITTED
            SessionConsumer->>ActiveSession: recordVoucher(voucher) → advance watermark + nonce
        else REPLAYED
            SessionConsumer->>ActiveSession: reconcileSettled(min(settled, prepared))
        end
        SessionConsumer-->>Client: CommitReceipt
    end
Loading
%%{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 Client
    participant PaymentChannelSession
    participant ActiveSession
    participant SessionConsumer
    participant Transport
    participant Operator

    Client->>PaymentChannelSession: open(request, payerSigner, sessionSigner, blockhash)
    PaymentChannelSession->>PaymentChannelSession: deriveOpen() → channelPDA, salt, deposit
    PaymentChannelSession->>PaymentChannelSession: buildOpenTransaction() → payer-partial-signed tx
    PaymentChannelSession-->>Client: "PaymentChannelSessionOpen { open, session, action }"

    Client->>PaymentChannelSession: serializeSessionCredential(challenge, action)
    PaymentChannelSession-->>Client: Authorization: Payment base64url(JCS)

    Client->>Operator: credential → server co-signs + broadcasts open tx
    Operator-->>Client: 200 SSE stream with MeteringDirective per delivery

    loop Per metered delivery
        Client->>SessionConsumer: commitDirective(directive)
        SessionConsumer->>SessionConsumer: validateDirective()
        SessionConsumer->>ActiveSession: prepareIncrement(amount) → SignedVoucher
        SessionConsumer->>Transport: commit(directive, CommitPayload)
        Transport-->>SessionConsumer: "CommitReceipt { status: COMMITTED | REPLAYED }"
        alt COMMITTED
            SessionConsumer->>ActiveSession: recordVoucher(voucher) → advance watermark + nonce
        else REPLAYED
            SessionConsumer->>ActiveSession: reconcileSettled(min(settled, prepared))
        end
        SessionConsumer-->>Client: CommitReceipt
    end
Loading

Reviews (8): Last reviewed commit: "feat(kotlin/example): consume the sessio..." | Re-trigger Greptile

Comment on lines +70 to +72
} else {
element.longOrNull?.toULong() ?: throw MppException.InvalidJson()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
} else {
element.longOrNull?.toULong() ?: throw MppException.InvalidJson()
}
} else {
element.content.toULongOrNull() ?: throw MppException.InvalidJson()
}

Mirror the swift solana-foundation#178 review fix: guard the signature-slot index so a payer
outside the required-signer prefix throws instead of going out of bounds.
…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).
… binary

The runner manifest cwd resolves against the repo root (runners.ts), so it must
be harness/kotlin-conformance, not kotlin-conformance (which pointed at a
nonexistent <repo>/kotlin-conformance and failed the spawn). Invoke the
self-contained installDist binary via sh -c so it resolves relative to that cwd
and runs through $JAVA_HOME/bin/java (no gradle on the spawn PATH needed); the
CI step now builds it with gradle installDist.
…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.
…xactly

The iOS demo picks the card glyph by payment intent (every charge endpoint shows
the credit-card icon); the Android demo was keying off path/label and showed a
different icon per card. Mirror systemImage(intent:scheme:method:) so the cards
carry the same glyph as Swift, and refresh the screenshot.
…onsume by intent

Mirror the swift fix: read every offer's method (mpp/x402) and the intent, show
them on each card (e.g. 'x402 · mpp', 'mpp', 'mpp · session'), and only settle
one-shot charge endpoints over MPP. Non-charge intents (session streaming,
subscription, x402 upto) show a clear message about their dedicated multi-step
API instead of firing a charge the server's non-charge 402 rejects. Re-aligned
to the current OpenAPI offers and refreshed the screenshot.
…tream + voucher + settle)

Mirror the swift session flow: the stream card now opens a payment channel
(PaymentChannelSession.open, server co-signs + broadcasts), reads the SSE
metered deliveries, reserves a delivery + signs and commits a cumulative voucher
via SessionConsumer, and polls the receipt for the on-chain settle signature.
Adds SessionStream.kt (OkHttp SSE + side-channel transport, mirroring TS
SessionFetch) and routes session intent to it. Verified e2e on the emulator
against the playground + Surfpool sandbox: streamed 7 chunks, settled.

Also surfaces the selected protocol per card: the accepted methods (x402/mpp)
render with the one the demo settles over (mpp) emphasized.
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.

1 participant