Skip to content

feat(kotlin): mpp/session client#179

Merged
lgalabru merged 1 commit into
solana-foundation:mainfrom
EfeDurmaz16:feat/kotlin-mpp-session
Jun 23, 2026
Merged

feat(kotlin): mpp/session client#179
lgalabru merged 1 commit 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.

@greptile-apps

greptile-apps Bot commented Jun 19, 2026

Copy link
Copy Markdown

Greptile Summary

This 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 open transaction builder, session wire types (with SaltStringSerializer correctly using toULongOrNull for the full u64 range), the SessionActionCodec flatten/unflatten codec, ActiveSession (monotonic watermark, retry-safe prepare/record voucher, reconcileSettled clamp), SessionConsumer, the PaymentChannelSession opener, a cross-SDK conformance runner, and an expanded Android demo.

  • PaymentChannels.kt — new file implementing PDA derivation, voucher preimage (48-byte channelId ‖ cumulativeAmount_LE ‖ expiresAt_LE), and the payer-partial-signed legacy transaction builder.
  • SessionClient.kt / SessionConsumer.ktActiveSession drives monotonic watermark + nonce accounting with Go-parity ReconcileSettled; SessionConsumer signs vouchers, commits via CommitTransport, and advances the watermark only on committed or reconciled receipts.
  • Conformance harnessharness/kotlin-conformance drives PaymentChannels.voucherMessageBytes against frozen cross-SDK vectors, enforced in CI via the harness-kotlin job.

Confidence Score: 5/5

The 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

Filename Overview
kotlin/src/main/kotlin/com/solana/paykit/paycore/PaymentChannels.kt New file: 48-byte voucher preimage, channel/event-authority PDA derivation, open instruction and payer-partial-signed transaction builder. Little-endian serialization helpers are correct; the double PDA derivation (once in buildOpenTransaction directly, once inside buildOpenInstruction) is redundant but not incorrect.
kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/SessionClient.kt New file: ActiveSession watermark/nonce logic and PaymentChannelSession opener. Core session accounting is correct and retry-safe. Error contract inconsistency: PublicKey.fromBase58 calls for request.recipient, request.operator, and request.programId are not wrapped in try-catch unlike the splits path, leaking raw IllegalArgumentException.
kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/client/SessionConsumer.kt New file: commit-with-watermark-advance logic including REPLAYED clamp. Session ID validation is split between accept() and commitDirective(), causing accept() to silently pass zero-amount directives that will always fail at ack() time.
kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/SessionTypes.kt New file: wire types, SaltStringSerializer (reads raw content via toULongOrNull — correctly handles u64 > Long.MAX_VALUE), VoucherData with cumulativeAmount/@JsonNames cumulative alias, SessionAction sealed union. Serialization looks correct.
kotlin/src/main/kotlin/com/solana/paykit/protocols/mpp/core/SessionActionCodec.kt New file: flatten/unflatten codec for the internally-tagged SessionAction union. Tag injection and extraction look correct; unknown tag throws MppException.
harness/kotlin-conformance/src/main/kotlin/com/solana/paykit/conformance/Main.kt New conformance runner: reads one vector from stdin, drives PaymentChannels.voucherMessageBytes, emits RunnerResult JSON on stdout. Correctly uses toULongOrNull for cumulativeAmount parsing; unsupported modes return reject, not crash.
.github/workflows/kotlin.yml Adds two CI steps: build the conformance runner with Gradle installDist and run conformance vectors via vitest with MPP_CONFORMANCE_LANGUAGES=kotlin.
kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/SessionStream.kt New demo file: drives the full MPP payment-channel session flow (402 challenge → open → SSE streaming → commit) using OkHttp and the SDK's SessionConsumer.

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?)
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 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?)
Loading

Reviews (17): Last reviewed commit: "feat(kotlin): mpp/session client" | 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()
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Already fixed in cefd1cf, reads raw content via toULongOrNull now.

EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 19, 2026
…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).
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 19, 2026
…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.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 19, 2026
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
lgalabru previously approved these changes Jun 22, 2026

@lgalabru lgalabru left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM thanks @EfeDurmaz16.
Let's get #181 merged first - solving our CI issues.

EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 22, 2026
…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).
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 22, 2026
…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.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 22, 2026
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.
@EfeDurmaz16
EfeDurmaz16 force-pushed the feat/kotlin-mpp-session branch from d7ab2ac to eb07047 Compare June 22, 2026 17:56
@EfeDurmaz16
EfeDurmaz16 force-pushed the feat/kotlin-mpp-session branch from ee56652 to 988b321 Compare June 22, 2026 21:59
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

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
@EfeDurmaz16
EfeDurmaz16 force-pushed the feat/kotlin-mpp-session branch from 478dfe2 to 2ba6d8f Compare June 23, 2026 10:04
@lgalabru
lgalabru merged commit 0aa56e4 into solana-foundation:main Jun 23, 2026
30 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.

2 participants