Skip to content

feat(kotlin): add x402 exact client#126

Closed
EfeDurmaz16 wants to merge 12 commits into
solana-foundation:mainfrom
EfeDurmaz16:pr/kotlin-x402-port
Closed

feat(kotlin): add x402 exact client#126
EfeDurmaz16 wants to merge 12 commits into
solana-foundation:mainfrom
EfeDurmaz16:pr/kotlin-x402-port

Conversation

@EfeDurmaz16

@EfeDurmaz16 EfeDurmaz16 commented May 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ports the x402 exact client to Kotlin as a new top-level kotlin/ module, following the rust/crates/x402/ canonical spine. Client-only by design (no Kotlin server runtime, no MWA dependency).

Scope

  • New Kotlin/Gradle module at kotlin/ with build.gradle.kts and settings.gradle.kts
  • Implementation at kotlin/src/main/kotlin/org/solana/x402/exact/: ExactChallenge.kt, ExactPaymentClient.kt, InteropClient.kt, SolanaTransaction.kt
  • Mainnet CAIP-2 alignment to Rust spine SOLANA_MAINNET 32-char canonical prefix (339c647)
  • maxAmountRequired fallback for the TS reference fixture (8fd19f0) so cross-fixture pairing is robust to the stub envelope
  • ALLOWED_TOKEN_PROGRAMS triple-validation at the challenge parser, transaction builder, and RPC mint-owner check (same three layers as client/exact/payment.rs)

Files changed

  • SDK: kotlin/src/main/kotlin/org/solana/x402/exact/**
  • Tests: kotlin/src/test/kotlin/org/solana/x402/exact/** (27 JUnit cases)
  • Harness: harness/src/implementations.ts registers kotlin-x402-client with intents: ["x402-exact"]

Security highlights

  • payTo != payer self-transfer guard fails fast before any RPC or Base58 work
  • currencyMatches wrapped in runCatching for safe parse-failure handling
  • compileV0Message cross-set account-key dedup with role promotion
  • RFC 8032 §7.1 TEST 1 regression test locks JCA seed-handling parity

Test evidence

  • gradle --project-dir kotlin test --rerun-tasks: 27/27 pass
  • pnpm typecheck (harness/): clean for changed files
  • Codex r8: 0 P1, confidence 4/5 (notes/codex-review/pr-126-r8.md)
  • Cross-spine pair kotlin-x402-client ↔ rust-x402-server registered

Closes / supersedes

None.

Reviewer notes

@EfeDurmaz16 EfeDurmaz16 changed the title feat(kotlin): port x402 exact client — from x402-sdk #27 feat(kotlin): add x402 exact client May 25, 2026
Adds the canonical x402 `exact` intent to the cross-language interop
harness, plus TypeScript reference client and server fixtures and
matrix wiring that registers the Rust spine adapters already shipped
under `rust/crates/x402/src/bin/`. Language adapters can now target
the harness contract (X402_INTEROP_* env vars, ready/result JSON
shapes) to validate against the Rust spine cell.

The TS reference fixture carries a stub credential payload (challenge
id + resource) so the harness wiring, negative-code classification,
cross-server portability, and idempotent-resubmit flows can run
without a full Solana signer. Pair restriction in the matrix gates
TS↔TS and Rust↔Rust by default; full TS↔Rust on-chain settlement
parity lands with a follow-up SDK port.

The legacy MPP charge runner hard-skips the new intent so default
`pnpm test` behaviour is unchanged.
…n#27

Ports the Kotlin x402 exact client adapter from
solana-foundation/x402-sdk PR solana-foundation#27 (tip cab2f21) into a new top-level
`kotlin/` module under mpp-sdk, following Ludo's `rust/crates/x402/`
pattern. Client-only — no Kotlin server runtime in this milestone.

Layout: `kotlin/{build.gradle.kts,settings.gradle.kts,src/main,src/test}`
with package namespace rewritten from `org.x402.sdk.interop` to
`org.solana.x402.exact`.

Wires `tests/interop/src/implementations.ts` with the
`kotlin-x402-client` entry (gated, opt-in via MPP_INTEROP_CLIENTS).

Codex Round 4 evidence (notes/codex-review-kotlin-x402-r4.md):
- 0 real P1, Confidence 4/5
- 27 JUnit tests pass (`gradle --project-dir kotlin test`)
- carries payTo!=payer self-transfer guard, currencyMatches
  runCatching wrap, stablecoin mainnet-leak fix, cross-set account-key
  dedup, real Long.MAX_VALUE guard, ALLOWED_TOKEN_PROGRAMS triple
  validation, RFC 8032 §7.1 TEST 1 regression
@EfeDurmaz16 EfeDurmaz16 force-pushed the pr/kotlin-x402-port branch from 235507f to 343419a Compare May 25, 2026 21:16
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 25, 2026
Three fixes so the x402-exact matrix actually executes once the
language adapter PRs (solana-foundation#124, solana-foundation#126, solana-foundation#127, solana-foundation#128, solana-foundation#129, solana-foundation#130) rebase on top
of this branch:

1. Pair filter is data-driven. Previously only ts-x402 self-pair and
   rust-x402 self-pair were accepted. Now the filter walks the
   registered adapters and accepts any pair where: both sides are the
   TS reference (stub-payload), both sides are the Rust spine, the two
   sides share a base language id (same-language self-pair, e.g.
   go-x402-client <-> go-x402-server), or one side is the Rust spine
   (cross-spine pair in either direction). TS reference is locked to
   its self-pair only because its stub payload would fail real
   signature verification on any other server.

2. rust-x402 cargo --manifest-path corrected from ../../rust/Cargo.toml
   to ../rust/Cargo.toml. The path was stale after the
   tests/interop -> harness rename; the existing rust (charge) entries
   already used the correct relative path, the x402 entries did not.

3. Pair selector docstring rewritten to spell out the data-driven
   matrix policy so future language ports don't need to touch the test.
The Mainnet network identifier used the full 44-char base58 genesis hash
which broke interop with every spine-compliant mainnet challenge. The
Rust spine constant SOLANA_MAINNET (rust/crates/x402/src/protocol/schemes/exact/types.rs)
uses the canonical 32-char truncated prefix; all other ports already match.

Updates the affected unit test (it previously relied on the 44-char form
to assert network-mismatch rejection) and adds a regression test pinning
SolanaNetwork.Mainnet.caip2 to the spine constant.
ExactChallenge.accepts() previously read only `amount`, silently
dropping every spine-shaped challenge that uses the canonical
`maxAmountRequired` wire field (TS fixture, Rust spine output,
Go/Python/PHP ports). Mirror the Rust spine fallback in
rust/crates/x402/src/protocol/schemes/exact/types.rs by accepting
either field, preferring `amount` when both are present for
back-compat.

Adds two regression tests covering the new wire field and the
both-present precedence.
Codex r8 P2 finding. The Rust spine parses x402 exact amounts as u64
(rust/crates/x402/src/protocol/schemes/exact/verify.rs), so the full
unsigned 64-bit range is valid on the wire. The Kotlin builder
previously narrowed the amount to a signed Long and rejected anything
above Long.MAX_VALUE, breaking spine parity for valid challenges that
exercise the upper half of u64.

Pass the parsed ULong directly into the instruction encoder, which
already serialises the value as 8 little-endian bytes — no signed
narrowing happens anywhere now.

Replace the dead Long.MAX_VALUE rejection regression with a positive
test that builds a transaction at u64::MAX and asserts the encoded
transferChecked instruction contains 0xFF * 8 + decimals.
Codex r9 P1 finding. The Rust spine's `find_matching_requirement`
(rust/crates/x402/src/server/exact.rs) round-trips the credential's
`accepted` through the typed `PaymentRequirements` serialiser and
structurally compares the result against the route's offered
requirement. Echoing the raw offered object verbatim leaks deprecated
wire aliases (`maxAmountRequired`, `currency`, `recipient`) into the
credential and the structural equality match fails even when the
underlying values agree.

Mirror `PaymentRequirements::to_accepted_value` in
rust/crates/x402/src/protocol/schemes/exact/types.rs by stripping the
deprecated aliases before re-asserting canonical `scheme`, `network`,
`asset`, `amount`, `payTo`, `maxTimeoutSeconds` and `extra`.

Adds an `ExactPaymentClientTest` regression covering a requirement
whose raw envelope carries both alias forms and confirming the
emitted `accepted` only contains the canonical fields.
Codex r9 P1 finding. The Rust spine
(rust/crates/x402/src/client/exact/payment.rs) supports native SOL
challenges by emitting a System Program transfer for `asset: "SOL"`.
This Kotlin client is SPL-only — the builder decodes `asset` as a
base58 mint and emits transferChecked. A Rust-spine SOL offer would
crash deep inside Base58.decode("SOL") with an opaque error.

Filter native SOL offers out at selection time so the resolver either
picks a supported SPL candidate or returns null. The kotlin client
falls through cleanly when no SPL offer is available rather than
constructing an invalid transferChecked.

Adds two ExactChallengeTest regressions covering (a) SOL skipped in
favour of an SPL alternative and (b) selection returning null when
only a SOL offer is on the wire.
Codex r10 P1 finding. The Rust spine client at
rust/crates/x402/src/client/exact/payment.rs treats
`requirements.fee_payer_key` as optional and falls back to the signer
as the actual fee payer:

    let actual_fee_payer = fee_payer_pubkey.unwrap_or(signer_pubkey);

The Kotlin client previously required `extra.feePayer` and rejected
every spine-compliant challenge that omitted it. Mirror the Rust
fallback: when no managed fee payer is supplied, the transfer
authority (payer) pays its own network fees. The drain-attack and
self-pay-loop guards still fire when a feePayer *is* present.

`SolanaExactPaymentRequest.feePayer` is now nullable.
`DefaultSolanaExactTransactionBuilder` substitutes the payer when the
field is absent, producing a v0 message that requires a single
signature (signer == feePayer).

Two regressions:
- `falls back to payer as fee payer when feePayer is absent` (client)
  confirms the request flows through with feePayer = null.
- `builder uses signer as fee payer when challenge omits feePayer`
  (builder) asserts num_required_signatures == 1 and signerIndex == 0.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 26, 2026
Three fixes so the x402-exact matrix actually executes once the
language adapter PRs (solana-foundation#124, solana-foundation#126, solana-foundation#127, solana-foundation#128, solana-foundation#129, solana-foundation#130) rebase on top
of this branch:

1. Pair filter is data-driven. Previously only ts-x402 self-pair and
   rust-x402 self-pair were accepted. Now the filter walks the
   registered adapters and accepts any pair where: both sides are the
   TS reference (stub-payload), both sides are the Rust spine, the two
   sides share a base language id (same-language self-pair, e.g.
   go-x402-client <-> go-x402-server), or one side is the Rust spine
   (cross-spine pair in either direction). TS reference is locked to
   its self-pair only because its stub payload would fail real
   signature verification on any other server.

2. rust-x402 cargo --manifest-path corrected from ../../rust/Cargo.toml
   to ../rust/Cargo.toml. The path was stale after the
   tests/interop -> harness rename; the existing rust (charge) entries
   already used the correct relative path, the x402 entries did not.

3. Pair selector docstring rewritten to spell out the data-driven
   matrix policy so future language ports don't need to touch the test.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 26, 2026
Three fixes so the x402-exact matrix actually executes once the
language adapter PRs (solana-foundation#124, solana-foundation#126, solana-foundation#127, solana-foundation#128, solana-foundation#129, solana-foundation#130) rebase on top
of this branch:

1. Pair filter is data-driven. Previously only ts-x402 self-pair and
   rust-x402 self-pair were accepted. Now the filter walks the
   registered adapters and accepts any pair where: both sides are the
   TS reference (stub-payload), both sides are the Rust spine, the two
   sides share a base language id (same-language self-pair, e.g.
   go-x402-client <-> go-x402-server), or one side is the Rust spine
   (cross-spine pair in either direction). TS reference is locked to
   its self-pair only because its stub payload would fail real
   signature verification on any other server.

2. rust-x402 cargo --manifest-path corrected from ../../rust/Cargo.toml
   to ../rust/Cargo.toml. The path was stale after the
   tests/interop -> harness rename; the existing rust (charge) entries
   already used the correct relative path, the x402 entries did not.

3. Pair selector docstring rewritten to spell out the data-driven
   matrix policy so future language ports don't need to touch the test.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 26, 2026
Three fixes so the x402-exact matrix actually executes once the
language adapter PRs (solana-foundation#124, solana-foundation#126, solana-foundation#127, solana-foundation#128, solana-foundation#129, solana-foundation#130) rebase on top
of this branch:

1. Pair filter is data-driven. Previously only ts-x402 self-pair and
   rust-x402 self-pair were accepted. Now the filter walks the
   registered adapters and accepts any pair where: both sides are the
   TS reference (stub-payload), both sides are the Rust spine, the two
   sides share a base language id (same-language self-pair, e.g.
   go-x402-client <-> go-x402-server), or one side is the Rust spine
   (cross-spine pair in either direction). TS reference is locked to
   its self-pair only because its stub payload would fail real
   signature verification on any other server.

2. rust-x402 cargo --manifest-path corrected from ../../rust/Cargo.toml
   to ../rust/Cargo.toml. The path was stale after the
   tests/interop -> harness rename; the existing rust (charge) entries
   already used the correct relative path, the x402 entries did not.

3. Pair selector docstring rewritten to spell out the data-driven
   matrix policy so future language ports don't need to touch the test.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 26, 2026
Three fixes so the x402-exact matrix actually executes once the
language adapter PRs (solana-foundation#124, solana-foundation#126, solana-foundation#127, solana-foundation#128, solana-foundation#129, solana-foundation#130) rebase on top
of this branch:

1. Pair filter is data-driven. Previously only ts-x402 self-pair and
   rust-x402 self-pair were accepted. Now the filter walks the
   registered adapters and accepts any pair where: both sides are the
   TS reference (stub-payload), both sides are the Rust spine, the two
   sides share a base language id (same-language self-pair, e.g.
   go-x402-client <-> go-x402-server), or one side is the Rust spine
   (cross-spine pair in either direction). TS reference is locked to
   its self-pair only because its stub payload would fail real
   signature verification on any other server.

2. rust-x402 cargo --manifest-path corrected from ../../rust/Cargo.toml
   to ../rust/Cargo.toml. The path was stale after the
   tests/interop -> harness rename; the existing rust (charge) entries
   already used the correct relative path, the x402 entries did not.

3. Pair selector docstring rewritten to spell out the data-driven
   matrix policy so future language ports don't need to touch the test.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Closing per maintainer's focus-on-Ruby guidance. The branch stays available on the fork so the work can be cherry-picked once the Ruby pattern is set and we replay the same shape across languages.

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