feat(swift): x402 upto (payment-channel) client#200
Conversation
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 SummaryThis PR adds the x402
Confidence Score: 5/5Safe 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
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
%%{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
Reviews (10): Last reviewed commit: "refactor(swift): model demo endpoint int..." | Re-trigger Greptile |
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).
|
On the facilitatorFee==10000 / non-operator payTo case: this is intentional parity, not a gap. The SVM spec says encode payTo at |
|
Confirmed against the server: eliding the 0-bps entry would break verification, not fix it. The rust upto server derives its expected distribution as |
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.
…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.
|
@greptile on the suggested |
|
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. |
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).
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.
Summary
Adds the x402
upto(payment-channel) client to the Swift 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/UptoTypes.swiftwire types.facilitatorFeeis omittedwhen
0; the payload carries noprofile/signature; the offered requirementis echoed verbatim in
accepted.Protocols/X402/Client/Upto/UptoPayment.swiftparseUptoChallenge/parseUptoAcceptsandbuildUptoPayload/encodeUptoHeader/buildUptoHeader.UptoTransport.swifttheX402UptoInterceptor402 retry path and thePayKit.HttpClient.x402Uptofactory.Tests and coverage
36 adversarial unit tests:
assetTransferMethodmismatch, missingfacilitatorAddress/recentBlockhash, fee out of range, fee0JSONomission,
payTo == operatorempty recipients,payTo != operatorsplit withbps = 10000 - fee(decoded from the signed open tx), nonce random andindependent of the channel salt,
channelId == findChannelPda, envelopebase64/JSON round-trip,
deposit == maxAmount,validAfterdefault, and thefull interceptor retry.
swift testpasses 200/200; coverage on the new uptocode is 95.6% line.
Interop
harness/swift-x402-upto-clientdrives the upto client; registered asswift-x402-upto(intents[x402-upto], opt-in viaX402_HARNESS_CLIENTS). Theswift.ymlinterop leg runs it againstrust-x402-uptoon the embedded surfnetwith the canonical payment-channels program (
x402-upto-basic,x402-upto-zero-actual), mirroring the go/python upto legs.Read order
swift/.../X402/Upto/UptoTypes.swift, the four wire types, field-for-field against rustschemes/upto/types.rsswift/.../Client/Upto/UptoPayment.swift, challenge parse, payload build, header encodeswift/.../Client/Upto/UptoTransport.swift, the 402 interceptor (now with the bounded expiry math)swift/.../Client/HeaderPairs.swift, extracted shared helperswift/.../Client/Exact/Transport.swift, X402 namespace refactor, behavior-neutralswift/.../PayCore/Errors.swiftonward, mechanical MppError to PayKitError rename churn, skimswift/Tests/.../X402UptoTests.swift, decodes the actual signed transaction bytes.github/actions/build-payment-channels+swift.yml, e2e wiringGenerated 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 + maxTimeoutSecondsunchecked 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.