Skip to content

feat(swift): x402 upto (payment-channel) client#200

Open
EfeDurmaz16 wants to merge 14 commits into
mainfrom
feat/swift-x402-upto
Open

feat(swift): x402 upto (payment-channel) client#200
EfeDurmaz16 wants to merge 14 commits into
mainfrom
feat/swift-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 Swift 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/UptoTypes.swift wire types. facilitatorFee is omitted
    when 0; the payload carries no profile/signature; the offered requirement
    is echoed verbatim in accepted.
  • Protocols/X402/Client/Upto/UptoPayment.swift parseUptoChallenge /
    parseUptoAccepts and buildUptoPayload / encodeUptoHeader /
    buildUptoHeader.
  • UptoTransport.swift the X402UptoInterceptor 402 retry path and the
    PayKit.HttpClient.x402Upto factory.

Tests and coverage

36 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 (decoded from the signed open tx), nonce random and
independent of the channel salt, channelId == findChannelPda, envelope
base64/JSON round-trip, deposit == maxAmount, validAfter default, and the
full interceptor retry. swift test passes 200/200; coverage on the new upto
code is 95.6% line.

Interop

harness/swift-x402-upto-client drives the upto client; registered as
swift-x402-upto (intents [x402-upto], opt-in via X402_HARNESS_CLIENTS). The
swift.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. swift/.../X402/Upto/UptoTypes.swift, the four wire types, field-for-field against rust schemes/upto/types.rs
  2. swift/.../Client/Upto/UptoPayment.swift, challenge parse, payload build, header encode
  3. swift/.../Client/Upto/UptoTransport.swift, the 402 interceptor (now with the bounded expiry math)
  4. swift/.../Client/HeaderPairs.swift, extracted shared helper
  5. swift/.../Client/Exact/Transport.swift, X402 namespace refactor, behavior-neutral
  6. swift/.../PayCore/Errors.swift onward, mechanical MppError to PayKitError rename churn, skim
  7. swift/Tests/.../X402UptoTests.swift, decodes the actual signed transaction bytes
  8. harness swift-x402-upto-client + .github/actions/build-payment-channels + swift.yml, e2e wiring

Generated vs handwritten

No generated code in the diff. Handwritten: ~1,850 lines (types 374, payment 217, tests 838+, transport, harness, CI), plus ~200 lines of mechanical rename churn across ~30 files that can be skimmed.

Reviewer note

Wire parity with the rust spine holds byte-for-byte: borsh open-args layout, the 14-account order, channel PDA seeds, deposit == maxAmount, payer-signed / fee-payer-empty signature slots. The 0-bps distribution entry at fee=10000 is intentional and matches the spine. The place to be skeptical was arithmetic on decoded ints, and that is where the one real bug lived.

Fable 5 self-review

The protocol core is tight and the tests decode real transaction bytes rather than trusting the builder. But the first cut did now + maxTimeoutSeconds unchecked on a server-controlled Int in the one path every payment traverses; Swift + traps, so a hostile 402 could crash the paying app. Exactly the class of bug this SDK's threat model exists for, and three review rounds missed it. Fixed in a3099e5 with a range guard and boundary tests (202 tests green). Bundling the ~30-file error rename into this PR inflated the review surface for zero wire benefit; next time that ships as a separate stack layer.

Port the x402 `upto` scheme client to Swift, 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), the builder
(parseUptoChallenge/parseUptoAccepts, buildUptoPayload/encodeUptoHeader/
buildUptoHeader reusing PaymentChannels.buildOpenTransaction), and the
X402UptoInterceptor retry path. 36 adversarial tests cover field omission,
operator binding, recipient split, channelId PDA derivation, and envelope
round-trips.
Add the swift-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 swift). Add the swift.yml interop leg driving swift-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

This PR adds the x402 upto (payment-channel) client to the Swift SDK, wire-compatible with the Rust spine. It adds four new source files (UptoTypes.swift, UptoPayment.swift, UptoTransport.swift, HeaderPairs.swift), 855 lines of tests, and CI/harness wiring; the remaining ~30 files are mechanical MppError → PayKitError rename churn.

  • Core protocol path (UptoPayment.swift + UptoTransport.swift): parses the upto challenge, builds a partially-signed channel open transaction with the operator as fee payer, wraps it in the PAYMENT-SIGNATURE envelope, and replays the request. The previously reported maxTimeoutSeconds integer-overflow trap has been fixed with a (0...31_536_000) range guard and boundary tests.
  • Shared HeaderPairs.swift extension: the duplicate allHeaders(from:) helper that caused the earlier divergence risk has been consolidated into a single HTTPURLResponse.headerPairs() extension shared by both interceptors.
  • Tests: 36+ adversarial unit tests decode real transaction bytes, check fee-distribution arithmetic, verify nonce independence from channel salt, and exercise the hostile-402 expiry boundary.

Confidence Score: 5/5

Safe to merge — the new upto payment-channel client is well-scoped, all three previously reported issues have been addressed, and the one real bug (unchecked server-controlled integer addition) was caught and fixed with a range guard before this review.

The core protocol path (parse challenge → build open tx → encode envelope) is thoroughly covered by 36+ adversarial unit tests that decode real transaction bytes. The fee-distribution arithmetic, nonce independence from channel salt, blockhash validation, and overflow-safe expiry math all have dedicated test cases. The MppError → PayKitError rename churn is mechanical and behavior-neutral. No new unsafe patterns are introduced.

No files require special attention. The build-payment-channels action installs the Anza CLI via curl-pipe-sh which is standard for this toolchain.

Important Files Changed

Filename Overview
swift/Sources/SolanaPayKit/Protocols/X402/Upto/UptoTypes.swift Wire types for the upto scheme; codable structs are correct with appropriate use of decodeIfPresent, zero-fee omission, and verbatim-raw capture for accepted echo.
swift/Sources/SolanaPayKit/Protocols/X402/Client/Upto/UptoPayment.swift Challenge parsing and buildUptoPayload; all inputs validated (assetTransferMethod, facilitatorAddress, fee range, blockhash length); fee-distribution arithmetic is correct for both payTo==operator and payTo!=operator cases.
swift/Sources/SolanaPayKit/Protocols/X402/Client/Upto/UptoTransport.swift UptoInterceptor 402 retry path with bounded uptoExpiresAt guard; overflow-trap fix is in place and tested; mirrors X402.Interceptor shape correctly.
swift/Sources/SolanaPayKit/Protocols/X402/Client/HeaderPairs.swift New shared HTTPURLResponse.headerPairs() extension; consolidates the previously duplicated allHeaders helper from both interceptors.
swift/Tests/SolanaPayKitTests/X402UptoTests.swift 855-line test file; decodes real transaction bytes, covers fee arithmetic, nonce independence, envelope round-trip, all error paths, and the hostile-timeout boundary.
harness/swift-x402-upto-client/Sources/SwiftX402UptoClient/main.swift Harness adapter; nil-Optional JSON serialization bug fixed (settlement ?? NSNull()); result line is now always valid JSON.
.github/actions/build-payment-channels/action.yml New composite action that builds the payment-channels SBF artifact; installs Anza CLI via curl-pipe-sh; program-id guard will fail loudly on a future ref bump.
.github/workflows/swift.yml Adds harness-swift-x402-upto CI job; scoping via X402_HARNESS_CLIENTS/SERVERS is correct.
swift/Sources/SolanaPayKit/Protocols/X402/Client/Exact/Transport.swift Refactored into X402.Interceptor namespace and migrated to shared headerPairs() helper; behavior-neutral change.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App as Client App
    participant IC as X402.UptoInterceptor
    participant UP as buildUptoPayload
    participant PC as PaymentChannels
    participant Server as x402 upto Server

    App->>Server: GET /metered
    Server-->>IC: 402 + PAYMENT-REQUIRED header (upto challenge)
    IC->>IC: parseUptoChallenge(headers, body)
    IC->>IC: uptoExpiresAt(now, maxTimeoutSeconds) range-guarded
    IC->>UP: buildUptoPayload(signer, requirements, expiresAt)
    UP->>PC: "buildOpenTransaction(payer=signer, feePayer=operator, deposit=maxAmount)"
    PC-->>UP: OpenTransaction(channelId, base64-signed-tx)
    UP-->>IC: X402UptoPayload(from, maxAmount, channelId, openTransaction)
    IC->>IC: encodeUptoHeader base64 JSON envelope
    IC->>Server: GET /metered + Payment-Signature header
    Note over Server: operator co-signs open tx, broadcasts, settles actual <= max
    Server-->>App: 200 + x-payment-settlement-signature
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 as Client App
    participant IC as X402.UptoInterceptor
    participant UP as buildUptoPayload
    participant PC as PaymentChannels
    participant Server as x402 upto Server

    App->>Server: GET /metered
    Server-->>IC: 402 + PAYMENT-REQUIRED header (upto challenge)
    IC->>IC: parseUptoChallenge(headers, body)
    IC->>IC: uptoExpiresAt(now, maxTimeoutSeconds) range-guarded
    IC->>UP: buildUptoPayload(signer, requirements, expiresAt)
    UP->>PC: "buildOpenTransaction(payer=signer, feePayer=operator, deposit=maxAmount)"
    PC-->>UP: OpenTransaction(channelId, base64-signed-tx)
    UP-->>IC: X402UptoPayload(from, maxAmount, channelId, openTransaction)
    IC->>IC: encodeUptoHeader base64 JSON envelope
    IC->>Server: GET /metered + Payment-Signature header
    Note over Server: operator co-signs open tx, broadcasts, settles actual <= max
    Server-->>App: 200 + x-payment-settlement-signature
Loading

Reviews (10): Last reviewed commit: "refactor(swift): model demo endpoint int..." | Re-trigger Greptile

Comment thread swift/Sources/SolanaPayKit/Protocols/X402/Client/Upto/UptoTransport.swift Outdated
Comment thread swift/Sources/SolanaPayKit/Protocols/X402/Client/Upto/UptoPayment.swift Outdated
Extract the response-header flattening shared by the exact and upto
interceptors into a single HTTPURLResponse.headerPairs() extension so the two
paths cannot drift, and drop the duplicate PAYMENT-REQUIRED constant in the
upto parser in favour of the existing X402PaymentRequiredHeaderName.
Document that a 100% facilitator fee with a non-operator payTo emits a 0-bps
payTo recipient, matching the Rust/Go/Python clients and the SVM spec (encode
payTo at pay_to_bps even when 0).
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

On the facilitatorFee==10000 / non-operator payTo case: this is intentional parity, not a gap. The SVM spec says encode payTo at pay_to_bps = 10000 - facilitatorFee whenever payTo differs from the operator, and the canonical Rust client (plus Go and Python) emit the same 0-bps payTo entry. Eliding it client-side would diverge from the spine on the wire while the happy-path harness would not catch it. I added a test pinning the behavior (8f8d92a). Whether the program should reject a 0-bps recipient is a spine + on-chain-program question rather than a Swift-only change, so I have left it matching the reference.

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Confirmed against the server: eliding the 0-bps entry would break verification, not fix it. The rust upto server derives its expected distribution as Distribution { bps: 10_000 - facilitatorFee } (server/upto.rs distribution()), so at facilitatorFee==10000 the server itself expects a 0-bps payTo entry, and verify_open requires the channel to commit to exactly that distribution_hash. If the client dropped the entry, the hashes would mismatch and the open would be rejected. The Swift client matches the server (and the rust/go/python clients) on purpose; the new test pins it. This is a verified false positive.

Tapping the metered (x402 upto) playground endpoint now authorizes a ceiling
through PayKit.HttpClient.x402Upto and surfaces the billed amount, instead of the
'demo doesn't drive' fallback. The server meters actual usage and settles
actual <= max, refunding the rest.
A metered x402 upto route advertises the generic `charge` discovery intent, so
the demo now keys the usage flow off the offer `scheme` (`upto`) rather than the
intent. Adds the scheme to the parsed Endpoint and refreshes the screenshot with
a settled metered upto payment captured on the simulator.
Comment thread .github/workflows/swift.yml Outdated
Comment thread .github/workflows/swift.yml Outdated
Comment thread harness/swift-x402-upto-client/Sources/SwiftX402UptoClient/main.swift Outdated
Comment thread harness/swift-x402-upto-client/Package.swift
Comment thread swift/Sources/SolanaPayKit/Protocols/X402/Client/Upto/UptoPayment.swift Outdated
Comment thread swift/Sources/SolanaPayKit/Protocols/X402/Client/Upto/UptoTransport.swift Outdated
Comment thread swift/README.md Outdated
…x, scheme constant

- Harness adapter reuses the SolanaPayKit settlement/payment headers (x402Upto
  default + X402PaymentHeader) instead of redefining them.
- CI upto leg checks out solana-foundation/payment-channels (the org repo).
- Demo routes upto by the X402UptoScheme constant, not a string literal.
- README support matrix uses the shared checkmark style.
…2 interceptors

The single SDK error type is protocol-agnostic (it lives in PayCore and is thrown
by mpp, x402 exact, and x402 upto alike), so name it PayKitError rather than
MppError. Package the x402 interceptors under an X402 namespace so each scheme is
reached as X402.Interceptor (exact) and X402.UptoInterceptor (upto).
Restore the prior demo screenshot and its plain caption per review; the upto
demo wiring itself is unchanged.
The upto interop leg's checkout + treasury-sentinel patch + SBF build is now one
reusable composite action (.github/actions/build-payment-channels) the leg calls
in a single step. Streaming the program from the surfnet datasource was tried and
fails (the hosted deployment lacks the localnet treasury owner the harness pins),
so the localnet build stays; this removes the duplication instead.
A wrapped `nil as Any` is not a valid JSONSerialization value, so a nil
settlement would drop the entire result line. Use NSNull so the settlement
serializes as JSON null.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile on the suggested typealias X402Interceptor = X402.Interceptor: this SDK is pre-1.0 (0.6.x) with no stable published consumers to keep source-compatible, and the namespacing (X402.Interceptor / X402.UptoInterceptor) was an explicit maintainer request — the rename is the intended change, not an accidental break. The repo also deliberately avoids backward-compat shims. A compat typealias would keep the old flat name alongside the namespaced one, defeating the namespacing intent, so I am skipping it. Is there a concrete backward-compat requirement that would make it necessary regardless?

@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown

No concrete backward-compat requirement exists that would override those factors. For a pre-1.0 library with no stable published consumers, semver provides no stability guarantee, and if the namespacing was an explicit maintainer decision, a compat alias actively works against it by preserving the flat name you're trying to retire.

My original suggestion was defensive boilerplate that doesn't apply here — you're right to skip it.

@EfeDurmaz16 EfeDurmaz16 requested a review from lgalabru July 1, 2026 21:12
maxTimeoutSeconds comes from the 402 challenge, is decoded unchecked as Int,
and Swift + traps on overflow, so a hostile endpoint advertising Int.max (or
a negative value) deterministically crashed the paying app in the one path
every upto payment traverses. uptoExpiresAt now rejects values outside
0..=31_536_000 (one year) with a typed unsupportedChallenge error before any
arithmetic, matching the threat model of agents paying unknown endpoints.
Adds boundary tests (Int.max, -1, 300, exact ceiling).
Comment thread swift/Examples/PayKitDemo/PayKitDemo/ContentView.swift Outdated
Comment thread swift/Examples/PayKitDemo/PayKitDemo/ContentView.swift Outdated
Comment thread swift/Sources/SolanaPayKit/PayCore/RpcClient.swift
The demo now carries EndpointIntent and EndpointScheme enums (each with an
.other fallback for offers it does not special-case) instead of raw strings, and
routes the tap flow by matching on them.
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