feat(kotlin): x402 upto (payment-channel) client#201
Conversation
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 SummaryAdds the x402
Confidence Score: 5/5Safe 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
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
%%{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
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.
|
Addressed the settlement-response finding: |
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.
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.
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.
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.
|
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.
Summary
Adds the x402
upto(payment-channel) client to the Kotlin SDK, completing thex402/uptoclient cell.uptoauthorizes a maximum while the server settlesthe actual usage (
actual <= max); on Solana this is realized with an on-chainpayment channel whose deposit is the ceiling.
Wire-compatible with the Rust spine (
rust/crates/kit/src/x402/.../upto) and theSVM
uptospec (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, andrent payer. It signs only its payer slot of the
opentransaction; the operatorco-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
exactclient layout):protocols/x402/upto/Types.ktwire types.facilitatorFeeis omitted when0; the payload carries noprofile/signature; the offered requirement isechoed verbatim in
acceptedviaraw.protocols/x402/client/upto/Payment.ktparseUptoChallenge/parseUptoAcceptsandbuildUptoPayload/encodeUptoHeader/buildUptoHeader.Tests and coverage
Adversarial unit tests:
assetTransferMethodmismatch, missingfacilitatorAddress/recentBlockhash, fee out of range, fee0JSONomission,
payTo == operatorempty recipients,payTo != operatorsplit withbps = 10000 - fee, nonce random and independent of the channel salt,channelId == findChannelPda, envelope base64/JSON round-trip,deposit == maxAmount, andvalidAfterdefault.gradle checkpasses; thejacoco 90% line gate holds (98.8% on the new upto code).
Interop
harness/kotlin-x402-upto-clientdrives the upto client; registered askotlin-x402-upto(intents[x402-upto], opt-in viaX402_HARNESS_CLIENTS).The
kotlin.ymlinterop leg runs it againstrust-x402-uptoon the embeddedsurfnet with the canonical payment-channels program (
x402-upto-basic,x402-upto-zero-actual), mirroring the go/python upto legs.Read order
kotlin/.../protocols/x402/upto/Types.kt, wire shapes and serde-tolerance annotationskotlin/.../client/upto/Payment.kt, challenge precedence and the open-transaction builder, the heartkotlin/.../client/upto/UptoPaymentTest.kt, byte-level instruction assertions and adversarial parse casesharness/kotlin-x402-upto-client/.../Main.kt, the e2e adapter and its env contractharness/src/implementations.ts+ gradle plumbing, opt-in registration.github/actions/build-payment-channels+kotlin.yml, SBF build + upto harness jobkotlin/examples/AndroidDemo/.../MainActivity.kt+OpenApi.kt, demo scheme-first routingGenerated 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_elsechain 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
signatureFromReceiptHeaderhelper 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 readsX402_HARNESS_SETTLEMENT_HEADER, and the fee-range check sits outside the split branch where rust has it inside, stricter but a parity wrinkle.