Skip to content

feat(kotlin): x402 upto (payment-channel) client#201

Open
EfeDurmaz16 wants to merge 17 commits into
mainfrom
feat/kotlin-x402-upto
Open

feat(kotlin): x402 upto (payment-channel) client#201
EfeDurmaz16 wants to merge 17 commits into
mainfrom
feat/kotlin-x402-upto

Conversation

@EfeDurmaz16

@EfeDurmaz16 EfeDurmaz16 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the x402 upto (payment-channel) client to the Kotlin SDK, completing the
x402/upto client cell. upto authorizes a maximum while the server settles
the actual usage (actual <= max); on Solana this is realized with an on-chain
payment channel whose deposit is the ceiling.

Wire-compatible with the Rust spine (rust/crates/kit/src/x402/.../upto) and the
SVM upto spec (x402-foundation/x402#2697),
on the wire shape introduced by #199 (assetTransferMethod, facilitatorAddress,
facilitatorFee, channelProgram, tokenProgram).

Client behaviour

The client opens a channel depositing maxAmount, with the operator
(extra.facilitatorAddress) as channel payee, authorized signer, fee payer, and
rent payer. It signs only its payer slot of the open transaction; the operator
co-signs (fee payer + rentPayer), broadcasts, and later settles the metered
amount with a single voucher, refunding the remainder. The client never signs a
voucher and never needs SOL. The channel open reuses the existing
PaymentChannels.buildOpenTransaction.

New surface (mirrors the exact client layout):

  • protocols/x402/upto/Types.kt wire types. facilitatorFee is omitted when
    0; the payload carries no profile/signature; the offered requirement is
    echoed verbatim in accepted via raw.
  • protocols/x402/client/upto/Payment.kt parseUptoChallenge /
    parseUptoAccepts and buildUptoPayload / encodeUptoHeader /
    buildUptoHeader.

Tests and coverage

Adversarial unit tests: assetTransferMethod mismatch, missing
facilitatorAddress / recentBlockhash, fee out of range, fee 0 JSON
omission, payTo == operator empty recipients, payTo != operator split with
bps = 10000 - fee, nonce random and independent of the channel salt,
channelId == findChannelPda, envelope base64/JSON round-trip,
deposit == maxAmount, and validAfter default. gradle check passes; the
jacoco 90% line gate holds (98.8% on the new upto code).

Interop

harness/kotlin-x402-upto-client drives the upto client; registered as
kotlin-x402-upto (intents [x402-upto], opt-in via X402_HARNESS_CLIENTS).
The kotlin.yml interop leg runs it against rust-x402-upto on the embedded
surfnet with the canonical payment-channels program (x402-upto-basic,
x402-upto-zero-actual), mirroring the go/python upto legs.

Read order

  1. kotlin/.../protocols/x402/upto/Types.kt, wire shapes and serde-tolerance annotations
  2. kotlin/.../client/upto/Payment.kt, challenge precedence and the open-transaction builder, the heart
  3. kotlin/.../client/upto/UptoPaymentTest.kt, byte-level instruction assertions and adversarial parse cases
  4. harness/kotlin-x402-upto-client/.../Main.kt, the e2e adapter and its env contract
  5. harness/src/implementations.ts + gradle plumbing, opt-in registration
  6. .github/actions/build-payment-channels + kotlin.yml, SBF build + upto harness job
  7. kotlin/examples/AndroidDemo/.../MainActivity.kt + OpenApi.kt, demo scheme-first routing
  8. READMEs + screenshot, docs

Generated vs handwritten

Only the demo screenshot is an asset; ~1,390 added lines are handwritten (Payment.kt 274, Types.kt 139, tests 537, harness 166, CI/gradle ~160, demo ~85).

Reviewer note

Every byte-level invariant checks out against the rust spine: borsh layout, 14-account codama order with writable/signer flags, PDA seeds, deposit == maxAmount, zero-filled unsigned slots, salt independent of nonce, the intentional 0-bps entry at fee=10000. Header-precedence semantics match the rust or_else chain case by case. The upto harness job runs green against the rust upto server.

Fable 5 self-review

The SDK layer asserts parity at the instruction-byte level instead of hand-waving it, which is the right bar. The Android demo was written faster than the SDK: it logged the raw settlement envelope instead of running it through the signatureFromReceiptHeader helper three functions below, classic copy-paste drift from the exact path; fixed in 0cab25b. Two open polish items I would still flag: the harness adapter hardcodes the settlement header where rust reads X402_HARNESS_SETTLEMENT_HEADER, and the fee-range check sits outside the split branch where rust has it inside, stricter but a parity wrinkle.

Port the x402 `upto` scheme client to Kotlin, matching the Rust spine and the
SVM upto spec. The client authorizes a maximum by opening a payment channel
whose deposit is the ceiling, with the operator (extra.facilitatorAddress) as
channel payee, authorized signer, fee payer, and rent payer; the operator
co-signs and settles the metered amount with a single voucher. The client signs
only its payer slot of the open transaction and never needs SOL.

Adds the wire types (UptoExtra/Requirements/RequiredEnvelope/Payload/
SignatureEnvelope/SettlementResponse, facilitatorFee omitted when zero, opaque
nonce independent of the channel salt, verbatim accepted echo via raw), and the
builder (parseUptoChallenge/parseUptoAccepts, buildUptoPayload/encodeUptoHeader/
buildUptoHeader reusing PaymentChannels.buildOpenTransaction). Adversarial tests
cover field omission, operator binding, recipient split, channelId PDA
derivation, and envelope round-trips.
Add the kotlin-x402-upto harness client (parse the upto challenge, build a
partially-signed channel open plus Payment-Signature, retry) and register it in
implementations.ts (intents [x402-upto], opt-in via X402_HARNESS_CLIENTS,
reportsAs kotlin). Add the kotlin.yml interop leg driving kotlin-x402-upto
against rust-x402-upto on the embedded surfnet with the canonical
payment-channels program.
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown

Greptile Summary

Adds the x402 upto (payment-channel) client to the Kotlin SDK, completing the wire-compatible counterpart to the Rust spine and SVM upto spec. The change introduces wire types (Types.kt), the challenge-parse/payload-build API (client/upto/Payment.kt), a cross-language harness adapter, CI for the upto e2e leg, and an Android demo update that routes metered endpoints by scheme.

  • UptoSettlementResponse now matches the exact settlement type: transaction, network, and amount are all nullable so generic failure bodies that omit those fields deserialize without a MissingFieldException.
  • X402_VERSION is promoted from a private constant inside exact/Payment.kt to a shared internal constant in protocols/x402/X402.kt, keeping the version stamp consistent across both payment methods.
  • The self-reported parity wrinkles (hardcoded settlement header in the harness, fee-range check placement relative to the split branch) are explicitly documented in the PR description and do not affect the client's functional correctness.

Confidence Score: 5/5

Safe to merge; the previously reported nullability gaps in UptoSettlementResponse are fixed and all new code carries byte-level test coverage.

All three previously flagged non-nullable fields in UptoSettlementResponse (transaction, network, amount) are now nullable with null defaults, matching the exact settlement type. The new upto client and types are covered at 98.8% line coverage with adversarial tests for every error path, byte-level instruction assertions, and explicit round-trip checks. The self-reported parity wrinkles are documented and do not affect functional correctness. No unaddressed defects found.

No files require special attention.

Important Files Changed

Filename Overview
kotlin/src/main/kotlin/com/solana/paykit/protocols/x402/client/upto/Payment.kt Core upto client: challenge parsing with header-first/body-fallback, payload builder (channel open, fee validation, recipients split), and envelope encoding. Logic is well-tested; header fallback semantics match the Rust spine.
kotlin/src/main/kotlin/com/solana/paykit/protocols/x402/upto/Types.kt Wire types for upto protocol. Previously reported nullability gaps in UptoSettlementResponse (transaction, network, amount) have been fixed; all three are now nullable with null defaults.
kotlin/src/test/kotlin/com/solana/paykit/protocols/x402/client/upto/UptoPaymentTest.kt Adversarial unit tests covering byte-level instruction assertions, parse fallback paths, nonce/salt independence, PDA derivation, recipients branching, and envelope round-trips. Settlement failure without optional fields is also covered.
harness/kotlin-x402-upto-client/src/main/kotlin/com/solana/paykit/x402uptoharness/Main.kt Harness adapter for the upto client. Settlement header is hardcoded (acknowledged self-reported item); env contract and result JSON format are consistent with other harness adapters.
kotlin/src/main/kotlin/com/solana/paykit/paycore/SolanaNetwork.kt New sealed class for typed CAIP-2 Solana network identifiers with a custom serializer. Unknown networks are preserved verbatim in Other, so unrecognized CAIP-2 ids never throw during deserialization.
kotlin/examples/AndroidDemo/app/src/main/java/com/solana/paykit/demo/MainActivity.kt Android demo updated with consumeUpto flow and scheme-first routing. Uses signatureFromReceiptHeader correctly. The settlement header read from the playground server differs from the harness constant, which is expected since they target different servers.
kotlin/src/main/kotlin/com/solana/paykit/protocols/x402/X402.kt Promotes X402_VERSION from a private constant in exact/Payment.kt to a shared internal constant, used by both exact and upto builders.
harness/src/implementations.ts Registers kotlin-x402-upto as an opt-in harness client with intents ["x402-upto"]. Pattern is consistent with the existing kotlin-x402 entry.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client as Kotlin Client
    participant Server as x402 Server (Operator)
    participant Chain as Solana Chain

    Client->>Server: GET /resource (no payment)
    Server-->>Client: 402 + Payment-Required header

    Note over Client: parseUptoChallenge()
    Note over Client: buildUptoPayload()
    Note over Client: encodeUptoHeader()

    Client->>Server: GET /resource + Payment-Signature
    Server->>Chain: "broadcast open tx (deposit = maxAmount)"
    Chain-->>Server: confirmed
    Note over Server: Meters actual, settles voucher
    Server->>Chain: settle(actual) + refund(max - actual)
    Server-->>Client: 200 + settlement receipt
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 as Kotlin Client
    participant Server as x402 Server (Operator)
    participant Chain as Solana Chain

    Client->>Server: GET /resource (no payment)
    Server-->>Client: 402 + Payment-Required header

    Note over Client: parseUptoChallenge()
    Note over Client: buildUptoPayload()
    Note over Client: encodeUptoHeader()

    Client->>Server: GET /resource + Payment-Signature
    Server->>Chain: "broadcast open tx (deposit = maxAmount)"
    Chain-->>Server: confirmed
    Note over Server: Meters actual, settles voucher
    Server->>Chain: settle(actual) + refund(max - actual)
    Server-->>Client: 200 + settlement receipt
Loading

Reviews (14): Last reviewed commit: "refactor(kotlin): type the upto network ..." | Re-trigger Greptile

A failure PAYMENT-RESPONSE omits transaction, so decoding it into a
non-nullable field threw SerializationException. Match the exact settlement
type (String? = null) and cover the failure-body path with a test.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Addressed the settlement-response finding: UptoSettlementResponse.transaction is now nullable (String? = null) to match the exact settlement type, so decoding a failure body that omits it no longer throws SerializationException. Added a failure-path test covering it. Fixed in 04c0140.

The rust spine marks UptoRequiredEnvelope.accepts serde(default), so a 402
header that decodes to a valid envelope (x402Version present) without an accepts
array resolves to no offers and the body is not consulted. The parser treated a
missing accepts key as unparseable and fell back to the body, so it could pay a
body offer rust would ignore. Require x402Version to count as an envelope and
return an empty result when accepts is absent.
The android demo build compiles the SDK with a stricter Kotlin compiler that
rejects a plain if-return inside an expression-bodied function. Use the
?: return idiom (as the surrounding code already does) so acceptsFrom stays an
expression body and compiles under both toolchains.
…iler

The android demo build's Kotlin compiler prohibits any return (including the
elvis ?: return idiom) inside an expression-bodied function. Convert acceptsFrom
to a block body using when-expressions so it compiles under both the SDK and the
android demo toolchains; behavior is unchanged.
Tapping the metered (x402 upto) playground endpoint now authorizes a ceiling via
buildUptoHeader and surfaces the billed amount, instead of the 'demo doesn't
drive' fallback. Mirrors the iOS demo's upto path.
A metered x402 upto route advertises the generic `charge` discovery intent, so
the demo now routes the usage flow by the offer `scheme` (`upto`) before the
intent `when`. Adds the scheme to the parsed Endpoint and refreshes the
screenshot with a settled metered upto payment captured on the emulator.
Comment thread kotlin/src/main/kotlin/com/solana/paykit/protocols/x402/upto/Types.kt Outdated
A generic failure PAYMENT-RESPONSE (e.g. {"success":false,"errorReason":...})
can omit network and amount, so decoding it into non-nullable fields threw
MissingFieldException. Match the exact settlement type (all String? = null) and
extend the failure-body test to a response carrying only success + errorReason.
The fixed-height endpoint card clipped its price/protocol row when a long title
wrapped to two lines. Cap the title at one line with ellipsis (matching the iOS
demo) so the price always renders, and refresh the screenshot with a clean
carousel and a settled metered upto payment.
- Demo routes upto by the UPTO_SCHEME constant, not a string literal.
- CI upto leg checks out solana-foundation/payment-channels (the org repo).
The upto interop leg's checkout + treasury-sentinel patch + SBF build is now the
shared .github/actions/build-payment-channels composite action, called in one
step.
@EfeDurmaz16 EfeDurmaz16 requested a review from lgalabru July 1, 2026 21:00
consumeUpto logged the raw x-payment-response header (a base64 settlement
envelope) as the signature; run it through signatureFromReceiptHeader like the
exact path so the demo shows the on-chain transaction signature.
Comment thread kotlin/src/main/kotlin/com/solana/paykit/protocols/x402/client/upto/Payment.kt Outdated
Comment thread kotlin/src/main/kotlin/com/solana/paykit/protocols/x402/upto/Types.kt Outdated
Comment thread kotlin/src/main/kotlin/com/solana/paykit/protocols/x402/upto/Types.kt Outdated
The exact and upto builders each declared a private X402_VERSION; hoist it to a
single internal constant in the x402 package and reuse it in both.
consumeUpto derived expiresAt straight from maxTimeoutSeconds, so a 0 (or
negative) value from the challenge produced an already-expired authorization.
Fall back to 300s for a non-positive timeout, matching the harness adapter.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Addressed the consumeUpto finding: the demo now falls back to 300s when maxTimeoutSeconds is non-positive (matching the harness adapter), so the authorization can no longer expire immediately. 1596dc3.

Add a wire-compatible sealed SolanaNetwork in paycore (Mainnet/Devnet/Testnet
plus Other preserving unrecognized CAIP-2 ids verbatim) whose serializer emits
the bare CAIP-2 string, and adopt it for the upto requirement and settlement
network fields. The wire bytes are unchanged and an unknown network still
parses; tests cover the Other round-trip.
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