From 649b5a6101bf1b4be7436a39c35c33efe9efb841 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:08:32 +0300 Subject: [PATCH 1/2] feat(swift): mpp/session client Adds the client-side MPP payment-channel session intent to the Swift SDK, mirroring the Rust reference spine and the Go reference. What - PayCore PaymentChannels: the 48-byte Ed25519 voucher preimage, channel + event-authority PDA derivation, and the open instruction plus the payer-signed (operator-fee-payer-unsigned) open transaction. Client subset only; the server-side settle/finalize/distribute, the Ed25519 precompile, and the BLAKE3 distribution hash are out of scope for this client-only SDK (the open passes its recipients inline rather than hashed). - Session wire types: SessionRequest, OpenPayload, SignedVoucher/VoucherData (cumulativeAmount with a cumulative read-alias), commit/topUp/close payloads, MeteringDirective/MeteringUsage, CommitReceipt, and the internally-tagged SessionAction codec (the action key, salt as a decimal string, the topUp tag). - ActiveSession: monotonic cumulative watermark, nonce accounting, retry-safe prepare/record voucher, the reconcileSettled lost-response clamp (Go parity), and the open/voucher/topUp/close action builders. recordVoucher binds the voucher to the active channel and advances the nonce to at least nonce + 1, matching Go RecordVoucher. - SessionConsumer: validate, sign, commit, advance. The local watermark advances only on a committed receipt and reconciles (clamped to the prepared voucher, never regressing) on a replayed one. - Session credential framing (serializeSessionCredential) and a solana/session challenge dispatch accessor. Verification - swift test is green; the session source files are at 94 to 99 percent line coverage. - The runner manifest declares the session intent and the conformance runner gained a voucherPreimage branch driving the real PaymentChannels.voucherMessageBytes. Both frozen session-voucher vectors pass through the cross-SDK conformance driver (byte-identical to Go), now enforced in CI in the harness-swift job. Review feedback addressed - Bounds-check the open-tx payer signer index subscript (greptile P1). - Replace the force-try programId with a lazy precondition guard (greptile P2). - Document the silent-nil salt decode and throw on non-numeric strings (greptile P2). - Pass the commit endpoint URL (not the stream URL) to the delivery reservation (greptile P1). - Add an explicit tokenAccount parameter to openPullAction instead of misusing the channel PDA (greptile P1). - Restore the original demo screenshot. - Mark mpp/session as supported in the compatibility matrix. Notes - Client-only. Operability caveats that apply to a client are satisfied (the session reuses the MPP charge mint resolver, so localnet USDC resolves to the mainnet mint); the server-side caveats are not applicable. - The session credential uses the same sorted-keys canonicalization the existing Swift charge credential uses. --- .github/workflows/ci.yml | 2 - .github/workflows/swift.yml | 5 + harness/runners/swift.json | 3 +- rust/crates/mpp/tests/charge_integration.rs | 9 +- .../PayKitDemo.xcodeproj/project.pbxproj | 8 + .../PayKitDemo/PayKitDemo/ContentView.swift | 332 ++++++----- .../PayKitDemo/PayKitDemo/OpenAPI.swift | 201 +++++++ .../PayKitDemo/PayKitDemo/SessionStream.swift | 251 ++++++++ swift/Examples/PayKitDemo/README.md | 38 +- swift/README.md | 2 +- .../PayCore/PaymentChannels.swift | 264 +++++++++ .../Protocols/Mpp/Client/SessionClient.swift | 449 ++++++++++++++ .../Mpp/Client/SessionConsumer.swift | 98 ++++ .../Protocols/Mpp/Core/SessionTypes.swift | 547 ++++++++++++++++++ swift/Sources/mpp-conformance/main.swift | 23 + .../ActiveSessionTests.swift | 180 ++++++ .../SessionClientExtraTests.swift | 110 ++++ .../SessionConsumerTests.swift | 180 ++++++ .../SessionVoucherTests.swift | 66 +++ .../SessionWireOpenerTests.swift | 195 +++++++ .../client-charge-integration.test.ts | 66 ++- 21 files changed, 2846 insertions(+), 183 deletions(-) create mode 100644 swift/Examples/PayKitDemo/PayKitDemo/OpenAPI.swift create mode 100644 swift/Examples/PayKitDemo/PayKitDemo/SessionStream.swift create mode 100644 swift/Sources/SolanaPayKit/PayCore/PaymentChannels.swift create mode 100644 swift/Sources/SolanaPayKit/Protocols/Mpp/Client/SessionClient.swift create mode 100644 swift/Sources/SolanaPayKit/Protocols/Mpp/Client/SessionConsumer.swift create mode 100644 swift/Sources/SolanaPayKit/Protocols/Mpp/Core/SessionTypes.swift create mode 100644 swift/Tests/SolanaPayKitTests/ActiveSessionTests.swift create mode 100644 swift/Tests/SolanaPayKitTests/SessionClientExtraTests.swift create mode 100644 swift/Tests/SolanaPayKitTests/SessionConsumerTests.swift create mode 100644 swift/Tests/SolanaPayKitTests/SessionVoucherTests.swift create mode 100644 swift/Tests/SolanaPayKitTests/SessionWireOpenerTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c63775409..7e80dd208 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,6 @@ jobs: - name: Run tests with coverage run: pnpm vitest run --coverage --config vitest.config.ci.ts env: - SURFPOOL_REPORT: "1" # Reliable RPC datasource for the embedded surfnet (see the Rust job). SURFPOOL_DATASOURCE_RPC_URL: ${{ secrets.SURFPOOL_DATASOURCE_RPC_URL }} @@ -176,7 +175,6 @@ jobs: - name: Run tests with coverage working-directory: rust env: - SURFPOOL_REPORT: "1" # Reliable RPC datasource for the embedded surfnet — without it the # surfpool integration tests clone from the public mainnet-beta RPC, # which rate-limits and crashes surfnet mid-test in CI. diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 02838bf6e..1f62cc548 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -55,6 +55,11 @@ jobs: - name: Typecheck harness working-directory: harness run: pnpm typecheck + - name: Run Swift cross-SDK conformance vectors + working-directory: harness + env: + MPP_CONFORMANCE_LANGUAGES: swift + run: pnpm exec vitest run test/conformance.test.ts - name: Run Swift client harness smoke against TypeScript server working-directory: harness env: diff --git a/harness/runners/swift.json b/harness/runners/swift.json index 4f8b6bd72..6f1c7f1a4 100644 --- a/harness/runners/swift.json +++ b/harness/runners/swift.json @@ -1,5 +1,6 @@ { "language": "swift", "command": ["swift", "run", "-c", "release", "mpp-conformance"], - "cwd": "swift" + "cwd": "swift", + "intents": ["charge", "x402-exact", "session"] } diff --git a/rust/crates/mpp/tests/charge_integration.rs b/rust/crates/mpp/tests/charge_integration.rs index 6ed6e381d..584c195e4 100644 --- a/rust/crates/mpp/tests/charge_integration.rs +++ b/rust/crates/mpp/tests/charge_integration.rs @@ -17,8 +17,13 @@ use tokio::time::{sleep, Duration}; const SURFPOOL_DATASOURCE_RPC_URL_ENV: &str = "SURFPOOL_DATASOURCE_RPC_URL"; async fn start_surfnet() -> Option { - let datasource_rpc_url = std::env::var(SURFPOOL_DATASOURCE_RPC_URL_ENV) - .unwrap_or_else(|_| "https://api.mainnet-beta.solana.com".to_string()); + let datasource_rpc_url = match std::env::var(SURFPOOL_DATASOURCE_RPC_URL_ENV) { + Ok(value) if !value.trim().is_empty() => value, + _ => { + eprintln!("skipping surfpool test: {SURFPOOL_DATASOURCE_RPC_URL_ENV} is not set"); + return None; + } + }; match Surfnet::builder() .remote_rpc_url(datasource_rpc_url) diff --git a/swift/Examples/PayKitDemo/PayKitDemo.xcodeproj/project.pbxproj b/swift/Examples/PayKitDemo/PayKitDemo.xcodeproj/project.pbxproj index 885cb88de..19850316c 100644 --- a/swift/Examples/PayKitDemo/PayKitDemo.xcodeproj/project.pbxproj +++ b/swift/Examples/PayKitDemo/PayKitDemo.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ A1000010000000000000A001 /* PayKitDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000020000000000000A001 /* PayKitDemoApp.swift */; }; A1000010000000000000A002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000020000000000000A002 /* ContentView.swift */; }; A1000010000000000000A003 /* DemoSigner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000020000000000000A003 /* DemoSigner.swift */; }; + A1000010000000000000A005 /* OpenAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000020000000000000A005 /* OpenAPI.swift */; }; + A1000011000000000000A006 /* SessionStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000021000000000000A006 /* SessionStream.swift */; }; A1000010000000000000A004 /* SolanaPayKit in Frameworks */ = {isa = PBXBuildFile; productRef = A1000050000000000000A001 /* SolanaPayKit */; }; /* End PBXBuildFile section */ @@ -18,6 +20,8 @@ A1000020000000000000A001 /* PayKitDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayKitDemoApp.swift; sourceTree = ""; }; A1000020000000000000A002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A1000020000000000000A003 /* DemoSigner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoSigner.swift; sourceTree = ""; }; + A1000020000000000000A005 /* OpenAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAPI.swift; sourceTree = ""; }; + A1000021000000000000A006 /* SessionStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStream.swift; sourceTree = ""; }; A1000020000000000000A004 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A1000020000000000000A010 /* SolanaPayKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SolanaPayKit; path = ../../; sourceTree = ""; }; /* End PBXFileReference section */ @@ -49,6 +53,8 @@ A1000020000000000000A001 /* PayKitDemoApp.swift */, A1000020000000000000A002 /* ContentView.swift */, A1000020000000000000A003 /* DemoSigner.swift */, + A1000020000000000000A005 /* OpenAPI.swift */, + A1000021000000000000A006 /* SessionStream.swift */, A1000020000000000000A004 /* Info.plist */, ); path = PayKitDemo; @@ -147,6 +153,8 @@ A1000010000000000000A001 /* PayKitDemoApp.swift in Sources */, A1000010000000000000A002 /* ContentView.swift in Sources */, A1000010000000000000A003 /* DemoSigner.swift in Sources */, + A1000010000000000000A005 /* OpenAPI.swift in Sources */, + A1000011000000000000A006 /* SessionStream.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/swift/Examples/PayKitDemo/PayKitDemo/ContentView.swift b/swift/Examples/PayKitDemo/PayKitDemo/ContentView.swift index d610f5e95..fabc24ead 100644 --- a/swift/Examples/PayKitDemo/PayKitDemo/ContentView.swift +++ b/swift/Examples/PayKitDemo/PayKitDemo/ContentView.swift @@ -2,17 +2,22 @@ import SwiftUI import SolanaPayKit struct ContentView: View { - // Hardcoded for the demo. `pay server demo` binds the gateway to - // `0.0.0.0:1402` and routes settlement through the hosted Surfpool - // sandbox at `402.surfnet.dev:8899`. To target a local Surfpool - // instead (`pay server demo --local`), edit these two constants. + // Hardcoded for the demo. The playground API (`examples/playground-api`, + // `pnpm dev`) serves its priced routes + `/openapi.json` discovery on + // :3000 and routes settlement through the hosted Surfpool sandbox at + // `402.surfnet.dev:8899`. The iOS simulator shares the host network, so + // `127.0.0.1` reaches the local playground. private let rpcURL = URL(string: "https://402.surfnet.dev:8899")! - private let gatewayURL = URL(string: "http://127.0.0.1:1402")! + private let playgroundURL = URL(string: "http://127.0.0.1:3000")! @State private var signer: MemorySigner? @State private var usdcBalance: Decimal? @State private var log: [LogEntry] = [] @State private var busy: BusyKind? + @State private var endpoints: [Endpoint] = [] + @State private var endpointsError: String? + /// Per-endpoint protocol the user picked to settle over (endpoint id -> method). + @State private var protocolChoice: [String: String] = [:] var body: some View { NavigationStack { @@ -42,6 +47,7 @@ struct ContentView: View { } catch { append(.system("Failed to load signer: \(error.localizedDescription)", success: false)) } + await loadEndpoints() } } @@ -98,21 +104,38 @@ struct ContentView: View { @ViewBuilder private var endpointsSection: some View { - Section("Endpoints (\(gatewayURL.absoluteString))") { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(EndpointCatalog.all) { endpoint in - EndpointCard(endpoint: endpoint, busy: busy == .pay(endpoint.id)) { - Task { await pay(endpoint) } + Section("Endpoints (\(endpoints.count) from OpenAPI)") { + if let endpointsError { + Label(endpointsError, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } else if endpoints.isEmpty { + HStack(spacing: 8) { + ProgressView() + Text("Loading \(playgroundURL.absoluteString)/openapi.json…") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(endpoints) { endpoint in + EndpointCard( + endpoint: endpoint, + busy: busy == .pay(endpoint.id), + selected: protocolChoice[endpoint.id] ?? endpoint.selectedProtocol, + onTap: { Task { await pay(endpoint) } }, + onSelect: { method in protocolChoice[endpoint.id] = method } + ) + .disabled(busy != nil || signer == nil) } - .disabled(busy != nil || signer == nil) } + .padding(.vertical, 4) } - .padding(.vertical, 4) + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) } - .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) - if signer == nil { + if signer == nil && !endpoints.isEmpty { Text("Tap **Setup Account** to enable these.") .font(.caption) .foregroundStyle(.secondary) @@ -192,18 +215,89 @@ struct ContentView: View { } } + /// Fetch `/openapi.json` from the playground and build the priced-endpoint + /// collection. Surfaces a fetch/decode failure in `endpointsError` so the + /// section shows it instead of an empty spinner. + private func loadEndpoints() async { + endpointsError = nil + let url = playgroundURL.appendingPathComponent("openapi.json") + do { + let (data, response) = try await URLSession.shared.data(from: url) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw OpenAPIError.httpStatus(http.statusCode) + } + let loaded = try OpenAPI.endpoints(from: data) + endpoints = loaded + if loaded.isEmpty { + endpointsError = "No priced endpoints in the OpenAPI spec." + } + } catch { + endpointsError = "Could not load \(url.absoluteString): \(error.localizedDescription)" + } + } + private func pay(_ endpoint: Endpoint) async { guard let signer else { return } - let url = gatewayURL.appendingPathComponent(endpoint.path) + + // Session endpoints run the real payment-channel flow (open -> stream + // SSE deliveries -> sign + commit a voucher -> settle), not the one-shot + // 402 -> charge -> retry loop. + if endpoint.intent == "session" { + busy = .pay(endpoint.id) + defer { busy = nil } + do { + let result = try await SessionStream.consume( + streamURL: playgroundURL.appendingPathComponent(endpoint.path), + payer: signer + ) + append(.success( + endpoint: endpoint, + signature: result.settleSignature, + body: result.steps.joined(separator: "\n") + )) + await refreshBalance() + } catch { + append(.failure(endpoint: endpoint, message: String(describing: error))) + } + return + } + + // Other non-charge intents (subscription, x402 `upto` usage) are + // multi-step flows with dedicated pay-kit APIs the tap demo doesn't drive. + guard endpoint.intent == "charge" else { + append(.failure( + endpoint: endpoint, + message: "\(endpoint.label) is an mpp/\(endpoint.intent) flow this demo doesn't drive; use the matching pay-kit API." + )) + return + } + + let url = playgroundURL.appendingPathComponent(endpoint.path) busy = .pay(endpoint.id) defer { busy = nil } - let client = PayKit.HttpClient.mpp( - signer: signer, - rpc: RpcClient(endpoint: rpcURL), - settlementHeader: "payment-receipt" - ) + // Settle over the protocol the user picked (default: the advertised mpp). + let selected = protocolChoice[endpoint.id] ?? endpoint.selectedProtocol + let client: PayKit.HttpClient + if selected == "x402" { + client = PayKit.HttpClient.x402( + signer: signer, + rpc: RpcClient(endpoint: rpcURL), + selection: X402ChallengeSelection(), + // x402 settles in the `Payment-Response` header (a base64 + // envelope whose `transaction` field is the on-chain + // signature), not the MPP `Payment-Receipt` header. Read + // that so the signature surfaces instead of "no receipt". + settlementHeader: "payment-response" + ) + } else { + client = PayKit.HttpClient.mpp( + signer: signer, + rpc: RpcClient(endpoint: rpcURL), + settlementHeader: "payment-receipt" + ) + } var headers: [String: String] = ["Accept": "application/json"] var body: Data? = nil if endpoint.method == .post { @@ -223,7 +317,11 @@ struct ContentView: View { // signature lives in the envelope's `reference` field; // that's what the pay.sh receipt page expects in its // `/receipt/` path. + // MPP wraps the signature in a base64url Payment-Receipt envelope + // (`reference` field); x402 returns the bare settlement signature. + // Decode the envelope when present, else use the raw value. let signature = response.settlementSignature.flatMap(Self.signatureFromReceiptHeader) + ?? response.settlementSignature append(.success( endpoint: endpoint, signature: signature, @@ -269,9 +367,16 @@ struct ContentView: View { return "\(formatted) USDC" } - /// Decode a `Payment-Receipt` header (base64url-no-pad JSON envelope - /// produced by the gateway's `format_receipt`) and return the - /// `reference` field — the on-chain signature. + /// Format USDC base units (6 decimals) as a dollar string for the log. + static func formatBaseUnitsUSD(_ baseUnits: UInt64) -> String { + let dollars = Decimal(baseUnits) / 1_000_000 + let formatted = usdcFormatter.string(from: dollars as NSDecimalNumber) ?? "\(dollars)" + return "$\(formatted)" + } + + /// Decode a settlement header (base64url-no-pad JSON envelope) and return + /// the on-chain signature. MPP's `Payment-Receipt` carries it in + /// `reference`; x402's `X-PAYMENT-RESPONSE` carries it in `transaction`. static func signatureFromReceiptHeader(_ header: String) -> String? { // Re-pad and translate URL-safe alphabet to standard base64 so // Foundation's decoder accepts it. @@ -281,11 +386,10 @@ struct ContentView: View { let pad = (4 - s.count % 4) % 4 s.append(String(repeating: "=", count: pad)) guard let data = Data(base64Encoded: s), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let reference = json["reference"] as? String, - !reference.isEmpty + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - return reference + let signature = (json["reference"] as? String) ?? (json["transaction"] as? String) + return (signature?.isEmpty == false) ? signature : nil } @ViewBuilder @@ -300,8 +404,10 @@ struct ContentView: View { } } -// MARK: - Endpoint catalog +// MARK: - Endpoint +/// A priced operation discovered from the playground's `/openapi.json`, +/// rendered as a tappable card in the endpoints collection. struct Endpoint: Identifiable, Hashable { let id: String let label: String @@ -310,74 +416,15 @@ struct Endpoint: Identifiable, Hashable { let priceUSD: String let systemImage: String let tint: Color -} - -enum EndpointCatalog { - static let all: [Endpoint] = [ - Endpoint( - id: "reports-usage", - label: "Usage Report", - method: .get, - path: "api/v1/reports/usage", - priceUSD: "$0.01", - systemImage: "chart.bar.fill", - tint: .blue - ), - Endpoint( - id: "compute-run", - label: "Compute Job", - method: .post, - path: "api/v1/compute/run", - priceUSD: "$0.10", - systemImage: "cpu", - tint: .indigo - ), - Endpoint( - id: "subscriptions-charge", - label: "Subscription", - method: .post, - path: "api/v1/subscriptions/charge", - priceUSD: "$49.99", - systemImage: "repeat.circle.fill", - tint: .purple - ), - Endpoint( - id: "invoices-pay", - label: "Pay Invoice", - method: .post, - path: "api/v1/invoices/pay", - priceUSD: "$100", - systemImage: "doc.text.fill", - tint: .pink - ), - Endpoint( - id: "referrals-purchase", - label: "Referral Purchase", - method: .post, - path: "api/v1/referrals/purchase", - priceUSD: "$199", - systemImage: "person.2.fill", - tint: .orange - ), - Endpoint( - id: "orders-checkout", - label: "Checkout", - method: .post, - path: "api/v1/orders/checkout", - priceUSD: "$250", - systemImage: "cart.fill", - tint: .green - ), - Endpoint( - id: "settlements-disburse", - label: "Disbursement", - method: .post, - path: "api/v1/settlements/disburse", - priceUSD: "$1000", - systemImage: "banknote.fill", - tint: .red - ), - ] + /// Discovery intent of the first offer (`charge` / `session` / …); the demo + /// only settles `charge` over MPP and explains the rest. + let intent: String + /// Accepted protocols in offer order, e.g. `["x402", "mpp"]`. + let methods: [String] + /// The protocol this demo actually settles over (`mpp` for charge endpoints + /// that advertise it); `nil` for flows the demo doesn't consume. Rendered + /// emphasized on the card so it's clear which offer is used. + let selectedProtocol: String? } // MARK: - Endpoint card @@ -385,43 +432,68 @@ enum EndpointCatalog { private struct EndpointCard: View { let endpoint: Endpoint let busy: Bool + /// The protocol currently selected to settle over (the user's tap choice, or + /// the default). Rendered emphasized. + let selected: String? let onTap: () -> Void + let onSelect: (String) -> Void + + /// Charge endpoints that advertise more than one protocol let the user pick + /// which to settle over by tapping a method chip. + private var selectable: Bool { endpoint.intent == "charge" && endpoint.methods.count > 1 } var body: some View { - Button(action: onTap) { - VStack(alignment: .leading, spacing: 8) { - HStack { - Image(systemName: endpoint.systemImage) - .font(.title2) - Spacer() - if busy { - ProgressView() - .tint(.white) - } else { - Text(endpoint.method.rawValue) - .font(.caption2.weight(.bold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.white.opacity(0.25)) - .clipShape(Capsule()) - } + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: endpoint.systemImage) + .font(.title2) + Spacer() + if busy { + ProgressView() + .tint(.white) + } else { + Text(endpoint.method.rawValue) + .font(.caption2.weight(.bold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.white.opacity(0.25)) + .clipShape(Capsule()) } - Spacer(minLength: 0) - Text(endpoint.label) - .font(.subheadline.weight(.semibold)) - .multilineTextAlignment(.leading) - .lineLimit(2) - Text(endpoint.priceUSD) - .font(.caption.monospacedDigit()) - .opacity(0.9) } - .padding(12) - .frame(width: 150, height: 130, alignment: .topLeading) - .background(endpoint.tint.gradient) - .foregroundStyle(.white) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + Spacer(minLength: 0) + Text(endpoint.label) + .font(.subheadline.weight(.semibold)) + .multilineTextAlignment(.leading) + .lineLimit(2) + Text(endpoint.priceUSD) + .font(.caption.monospacedDigit()) + .opacity(0.9) + HStack(spacing: 3) { + ForEach(Array(endpoint.methods.enumerated()), id: \.offset) { index, method in + if index > 0 { Text("·").opacity(0.45) } + let isSelected = method == selected + Text(method) + .fontWeight(isSelected ? .bold : .regular) + .opacity(isSelected ? 1.0 : 0.55) + .underline(isSelected && selectable) + .contentShape(Rectangle()) + .onTapGesture { if selectable { onSelect(method) } } + } + if endpoint.intent != "charge" { + Text("·").opacity(0.45) + Text(endpoint.intent).opacity(0.55) + } + } + .font(.caption2) + .lineLimit(1) } - .buttonStyle(.plain) + .padding(12) + .frame(width: 150, height: 130, alignment: .topLeading) + .background(endpoint.tint.gradient) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .onTapGesture { onTap() } } } @@ -506,9 +578,9 @@ private struct LogRow: View { .font(.footnote) } } else { - Text("No `Payment-Receipt` header in response.") + Text("Settled. No settlement signature in response.") .font(.footnote) - .foregroundStyle(.orange) + .foregroundStyle(.secondary) } if !body.isEmpty { Text(body) diff --git a/swift/Examples/PayKitDemo/PayKitDemo/OpenAPI.swift b/swift/Examples/PayKitDemo/PayKitDemo/OpenAPI.swift new file mode 100644 index 000000000..8aac7c6a0 --- /dev/null +++ b/swift/Examples/PayKitDemo/PayKitDemo/OpenAPI.swift @@ -0,0 +1,201 @@ +import Foundation +import SwiftUI +import SolanaPayKit + +/// Decodes the playground's `GET /openapi.json` discovery document into the +/// app's `[Endpoint]` collection. +/// +/// The document is an OpenAPI 3.1 doc whose every priced operation under +/// `paths..` carries an `x-payment-info` extension with an +/// `offers[]` list (the payment-discovery draft: `intent` / `method` / +/// `amount` per offer, plus pay-kit extras like `scheme` / `network` / `payTo`). +/// Operations with no `x-payment-info` (health, `/openapi.json`, docs) are free +/// and excluded. +/// +/// Hyphenated extension keys and the arbitrarily-nested offers array are +/// awkward for synthesized `Codable`, so this parses with `JSONSerialization` +/// into `[String: Any]` — the same approach `ContentView` already uses for the +/// `Payment-Receipt` header. +enum OpenAPI { + /// Build the priced-endpoint collection from a raw `/openapi.json` body. + /// Returns endpoints in a stable order (sorted by path then method) so the + /// per-index `tint` and the collection layout don't reshuffle between loads. + static func endpoints(from data: Data) throws -> [Endpoint] { + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw OpenAPIError.notAnObject + } + guard let paths = root["paths"] as? [String: Any] else { + throw OpenAPIError.missingPaths + } + + var parsed: [(path: String, method: String, operation: [String: Any])] = [] + for (path, item) in paths { + guard let operations = item as? [String: Any] else { continue } + for (method, op) in operations { + guard let operation = op as? [String: Any] else { continue } + // Only priced operations are tappable. Free routes (health, + // docs, /openapi.json) carry no `x-payment-info`. + guard operation["x-payment-info"] is [String: Any] else { continue } + parsed.append((path: path, method: method.uppercased(), operation: operation)) + } + } + + // Deterministic order: path, then method. + parsed.sort { lhs, rhs in + lhs.path == rhs.path ? lhs.method < rhs.method : lhs.path < rhs.path + } + + return parsed.enumerated().map { index, entry in + endpoint(path: entry.path, method: entry.method, operation: entry.operation, index: index) + } + } + + // MARK: - Single operation → Endpoint + + private static func endpoint( + path: String, + method: String, + operation: [String: Any], + index: Int + ) -> Endpoint { + let paymentInfo = operation["x-payment-info"] as? [String: Any] + let offers = paymentInfo?["offers"] as? [[String: Any]] ?? [] + let firstOffer = offers.first + + let summary = (operation["summary"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let label = (summary?.isEmpty == false) ? summary! : path + + let intent = firstOffer?["intent"] as? String + let scheme = firstOffer?["scheme"] as? String + let payMethod = firstOffer?["method"] as? String + + return Endpoint( + id: "\(method) \(path)", + label: label, + method: PayKit.HTTPMethod(rawValue: method), + path: requestPath(from: path), + priceUSD: priceString(from: firstOffer), + systemImage: systemImage(intent: intent, scheme: scheme, method: payMethod), + tint: tint(for: index), + intent: (intent?.isEmpty == false) ? intent!.lowercased() : "charge", + methods: methods(from: offers), + selectedProtocol: selectedProtocol(from: offers, intent: intent) + ) + } + + /// Accepted protocols (offer `method`s) de-duplicated in offer order, so the + /// card can surface the MPP/x402 split. E.g. `["x402", "mpp"]`. + static func methods(from offers: [[String: Any]]) -> [String] { + var methods: [String] = [] + for offer in offers { + if let m = offer["method"] as? String, !m.isEmpty, !methods.contains(m) { + methods.append(m) + } + } + return methods + } + + /// The protocol the demo actually settles over: it drives charge endpoints + /// through the MPP client, so `mpp` is selected whenever a charge endpoint + /// advertises it. Non-charge flows aren't consumed here, so nothing is + /// selected. + static func selectedProtocol(from offers: [[String: Any]], intent: String?) -> String? { + guard intent?.lowercased() ?? "charge" == "charge" else { return nil } + let ms = methods(from: offers) + return ms.contains("mpp") ? "mpp" : ms.first + } + + // MARK: - Field derivation + + /// Turn a templated OpenAPI path (`/api/v1/quote/{symbol}`) into a concrete + /// request path by filling each `{param}` with a placeholder, so the URL + /// actually reaches the mounted route instead of 404-ing on the literal + /// `{symbol}` segment. + static func requestPath(from openApiPath: String) -> String { + guard openApiPath.contains("{") else { return openApiPath } + var result = "" + var insideParam = false + for char in openApiPath { + switch char { + case "{": + insideParam = true + result.append("demo") + case "}": + insideParam = false + default: + if !insideParam { result.append(char) } + } + } + return result + } + + /// Format the offer price as a dollar string. The offer's `amount` is a + /// base-unit integer string (USDC has 6 decimals); fall back to the + /// human-readable `description` (e.g. `"0.01 USDC"`) and finally a dash. + static func priceString(from offer: [String: Any]?) -> String { + if let amount = offer?["amount"] as? String, + let baseUnits = Decimal(string: amount), + let formatted = Self.dollarFormatter.string(from: (baseUnits / 1_000_000) as NSDecimalNumber) { + // `upto` usage and `session` deposits are ceilings, not the amount + // actually settled — label them so the card price isn't mistaken for + // a fixed charge. + let scheme = (offer?["scheme"] as? String)?.lowercased() + let prefix = (scheme == "upto" || scheme == "session") ? "up to " : "" + return prefix + "$" + formatted + } + if let description = offer?["description"] as? String, !description.isEmpty { + return description + } + return "—" + } + + private static let dollarFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + f.minimumFractionDigits = 2 + f.maximumFractionDigits = 6 + return f + }() + + /// Pick an SF Symbol by payment intent/scheme/method, with sensible + /// fallbacks. `intent` is the discovery-draft field (`charge` / `session` / + /// `subscription`); `scheme` distinguishes x402 `exact` / `upto`. + static func systemImage(intent: String?, scheme: String?, method: String?) -> String { + switch intent?.lowercased() { + case "charge": return "creditcard" + case "session": return "dot.radiowaves.left.and.right" + case "subscription": return "repeat" + case "usage": return "gauge" + default: break + } + switch scheme?.lowercased() { + case "exact": return "bolt" + case "upto": return "gauge" + case "subscription": return "repeat" + case "session": return "dot.radiowaves.left.and.right" + default: break + } + return method?.lowercased() == "x402" ? "bolt" : "creditcard" + } + + /// Cycle through a fixed palette so each card gets a distinct tint. + static func tint(for index: Int) -> Color { + let palette: [Color] = [.blue, .indigo, .purple, .pink, .orange, .green, .red, .teal] + return palette[index % palette.count] + } +} + +/// Failure modes when decoding `/openapi.json`. +enum OpenAPIError: Error, LocalizedError { + case httpStatus(Int) + case notAnObject + case missingPaths + + var errorDescription: String? { + switch self { + case .httpStatus(let code): return "openapi.json returned HTTP \(code)." + case .notAnObject: return "openapi.json was not a JSON object." + case .missingPaths: return "openapi.json had no `paths`." + } + } +} diff --git a/swift/Examples/PayKitDemo/PayKitDemo/SessionStream.swift b/swift/Examples/PayKitDemo/PayKitDemo/SessionStream.swift new file mode 100644 index 000000000..08d7a42f4 --- /dev/null +++ b/swift/Examples/PayKitDemo/PayKitDemo/SessionStream.swift @@ -0,0 +1,251 @@ +import Foundation +import SolanaPayKit + +/// Drives a full MPP payment-channel **session** against the playground's +/// `/api/v1/stream` (a metered SSE endpoint), the flow the one-shot charge +/// client can't do: +/// +/// 1. GET the resource unauthenticated → 402 `WWW-Authenticate` session +/// challenge. +/// 2. Open the channel: `PaymentChannelSession.open` builds the payer-signed +/// open transaction; the credential carries it and the server (operator) +/// co-signs + broadcasts. Retrying the GET with that credential returns the +/// 200 `text/event-stream`. +/// 3. Read the SSE deliveries (`data: {chunk,cost}` … `[DONE]`), summing cost. +/// 4. Reserve a delivery on the side channel, then sign + commit one +/// cumulative voucher through `SessionConsumer`. +/// 5. Optionally poll the receipt route for the on-chain settle signature. +/// +/// Mirrors the TypeScript `SessionFetch` transport, scoped to what the demo +/// needs (aggregate the stream into a single voucher rather than throttled +/// live commits). +enum SessionStream { + struct Result { + let channelId: String + let chunks: Int + let totalPaidBaseUnits: UInt64 + let cumulative: String + let settleSignature: String? + /// Ordered trace of each step's output, surfaced in the log so the flow + /// is visible (open -> stream -> commit -> settle). + let steps: [String] + } + + /// Run the session against `streamURL` (e.g. `/api/v1/stream`), + /// paying with `payer` (the funded demo account). Returns a summary for the + /// log. The voucher signer is a fresh ephemeral key (the on-chain authorized + /// signer for this channel). + static func consume(streamURL: URL, payer: SolanaSigner) async throws -> Result { + let session = URLSession.shared + var steps: [String] = [] + + // 1. Unauthenticated GET → 402 session challenge. + let challenge = try await fetchChallenge(streamURL, using: session) + try challenge.requireSolanaSession() + let request = try challenge.sessionRequest + guard let blockhash = request.recentBlockhash, !blockhash.isEmpty else { + throw SessionStreamError.message("session challenge did not carry a recentBlockhash") + } + + // 2. Open the channel (pull + clientVoucher, server-broadcast). + let sessionSigner = try MemorySigner(secretKey: randomSeed()) + let opener = try await PaymentChannelSession.open( + request: request, + payerSigner: payer, + sessionSigner: sessionSigner, + recentBlockhash: blockhash + ) + let channelId = opener.open.channelId.base58 + let credential = try serializeSessionCredential(challenge: challenge.echo(), action: opener.action) + steps.append("opened channel \(shortId(channelId)) · deposit \(usd(request.cap))") + + // 3. Retry the GET with the open credential → 200 SSE; read deliveries. + let (chunks, totalCost) = try await readStream(streamURL, authorization: credential, using: session) + steps.append("streamed \(chunks) chunks · metered \(usd(totalCost))") + + // 4. Reserve one aggregate delivery, then sign + commit the voucher. + var cumulative = "0" + if totalCost > 0 { + let originDeliveries = URL(string: "/__402/session/deliveries", relativeTo: streamURL)?.absoluteURL + ?? streamURL.deletingLastPathComponent().appendingPathComponent("__402/session/deliveries") + let commitURL = URL(string: "/__402/session/commit", relativeTo: streamURL)?.absoluteURL + ?? originDeliveries + + let directive = try await reserveDelivery( + originDeliveries, + channelId: channelId, + amount: totalCost, + commitURL: commitURL.absoluteString, + using: session + ) + let consumer = SessionConsumer( + session: opener.session, + transport: HttpCommitTransport(commitURL: commitURL, urlSession: session) + ) + let receipt = try await consumer.commitDirective(directive) + cumulative = receipt.cumulative + steps.append("committed voucher · cumulative \(receipt.cumulative) (\(receipt.status.rawValue))") + } + + // 5. Best-effort receipt poll for the settle signature (server idle-closes). + let settle = try? await pollReceipt(streamURL: streamURL, channelId: channelId, using: session) + steps.append(settle != nil ? "settled on-chain · \(shortId(settle!))" : "settle pending (idle-close runs server-side)") + + return Result( + channelId: channelId, + chunks: chunks, + totalPaidBaseUnits: totalCost, + cumulative: cumulative, + settleSignature: settle, + steps: steps + ) + } + + // MARK: - Steps + + private static func fetchChallenge(_ url: URL, using session: URLSession) async throws -> PaymentChallenge { + var req = URLRequest(url: url) + req.setValue("application/json", forHTTPHeaderField: "Accept") + let (_, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { + throw SessionStreamError.message("no HTTP response from \(url)") + } + guard http.statusCode == 402 else { + throw SessionStreamError.message("expected 402 from \(url.lastPathComponent), got \(http.statusCode)") + } + guard let header = http.value(forHTTPHeaderField: "Www-Authenticate") else { + throw SessionStreamError.message("402 had no WWW-Authenticate header") + } + return try MppHeaders.parseWWWAuthenticate(header) + } + + private static func readStream( + _ url: URL, + authorization: String, + using session: URLSession + ) async throws -> (chunks: Int, totalCost: UInt64) { + var req = URLRequest(url: url) + req.setValue(authorization, forHTTPHeaderField: "Authorization") + req.setValue("text/event-stream", forHTTPHeaderField: "Accept") + req.timeoutInterval = 60 + + let (bytes, response) = try await session.bytes(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw SessionStreamError.message("stream open failed: HTTP \(code)") + } + + var chunks = 0 + var total: UInt64 = 0 + for try await line in bytes.lines { + guard line.hasPrefix("data:") else { continue } + let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces) + if payload == "[DONE]" { break } + guard let data = payload.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + chunks += 1 + if let cost = obj["cost"] as? String, let value = UInt64(cost) { + total &+= value + } + } + return (chunks, total) + } + + private static func reserveDelivery( + _ url: URL, + channelId: String, + amount: UInt64, + commitURL: String, + using session: URLSession + ) async throws -> MeteringDirective { + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("application/json", forHTTPHeaderField: "Accept") + let body: [String: Any] = [ + "amount": String(amount), + "sessionId": channelId, + "deliveryId": "mpp-\(UUID().uuidString)", + "commitUrl": commitURL, + ] + req.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw SessionStreamError.message("delivery reservation failed: HTTP \(code)") + } + return try JSONDecoder().decode(MeteringDirective.self, from: data) + } + + private static func pollReceipt( + streamURL: URL, + channelId: String, + using session: URLSession + ) async throws -> String? { + guard let receiptURL = URL(string: "/sessions/receipt/\(channelId)", relativeTo: streamURL)?.absoluteURL else { + return nil + } + // The server idle-closes after a short delay; poll a few times. + for _ in 0..<8 { + try await Task.sleep(nanoseconds: 1_500_000_000) + let (data, response) = try await session.data(from: receiptURL) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + if let finalized = obj["finalized"] as? Bool, finalized, + let sig = obj["settledSignature"] as? String, !sig.isEmpty { + return sig + } + } + return nil + } + + private static func randomSeed() -> Data { + Data((0..<32).map { _ in UInt8.random(in: 0...255) }) + } + + private static func shortId(_ value: String) -> String { + value.count > 14 ? "\(value.prefix(6))…\(value.suffix(6))" : value + } + + private static func usd(_ baseUnits: UInt64) -> String { + let dollars = Decimal(baseUnits) / 1_000_000 + return "$\(NSDecimalNumber(decimal: dollars))" + } + + private static func usd(_ baseUnitsString: String) -> String { + UInt64(baseUnitsString).map(usd) ?? baseUnitsString + } +} + +/// Posts signed vouchers to the session commit side channel +/// (`POST /__402/session/commit`) and decodes the `CommitReceipt`. +private struct HttpCommitTransport: CommitTransport { + let commitURL: URL + let urlSession: URLSession + + func commit(directive: MeteringDirective, payload: CommitPayload) async throws -> CommitReceipt { + var req = URLRequest(url: commitURL) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("application/json", forHTTPHeaderField: "Accept") + req.httpBody = try JSONEncoder().encode(payload) + let (data, response) = try await urlSession.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + let text = String(data: data, encoding: .utf8) ?? "" + throw SessionStreamError.message("voucher commit failed: HTTP \(code) \(text)") + } + return try JSONDecoder().decode(CommitReceipt.self, from: data) + } +} + +enum SessionStreamError: Error, LocalizedError { + case message(String) + var errorDescription: String? { + switch self { + case .message(let text): return text + } + } +} diff --git a/swift/Examples/PayKitDemo/README.md b/swift/Examples/PayKitDemo/README.md index 35a9272c5..35122356b 100644 --- a/swift/Examples/PayKitDemo/README.md +++ b/swift/Examples/PayKitDemo/README.md @@ -1,17 +1,19 @@ # PayKitDemo SwiftUI app that exercises `SolanaPayKit` end-to-end against the -bundled `pay server demo` gateway. The app generates a Keychain-backed -account, tops it up over Surfpool cheatcodes, then lets you tap any of -the demo gateway's metered endpoints (`/usage`, `/compute`, -`/checkout`, etc.) and surfaces each charge's settlement signature in -an append-only log. +pay-kit playground. On launch it fetches the playground's +`/openapi.json`, renders every priced operation (read from each route's +`x-payment-info` offers) as a tappable collection, generates a +Keychain-backed account, tops it up over Surfpool cheatcodes, then lets +you consume any endpoint and surfaces each charge's settlement +signature in an append-only log. ![PayKitDemo screenshot](docs/paykit-demo-screenshot.png) ## What you need -- [`pay`](https://github.com/solana-foundation/pay) installed and on `$PATH` +- The pay-kit playground running on `http://127.0.0.1:3000` (see + [`typescript/examples/playground-api`](../../../typescript/examples/playground-api)) - Xcode 16+ and an iOS 17+ Simulator The demo's signer is **stored in iOS Keychain** under @@ -21,17 +23,20 @@ Wallet Adapter or the Seeker Seed Vault. ## Run it -### 1. Start the gateway +### 1. Start the playground -In one terminal, from anywhere: +In one terminal: ```sh -pay server demo +cd typescript/examples/playground-api +pnpm install +pnpm start ``` -This boots the bundled `payment-debugger` spec on -`http://127.0.0.1:1402`, routing settlement through the hosted Surfpool -sandbox (`https://402.surfnet.dev:8899`). Leave it running. +This boots the pay-kit playground on `http://127.0.0.1:3000`, serving +`/openapi.json` plus the priced routes and routing settlement through +the hosted Surfpool sandbox (`https://402.surfnet.dev:8899`). Leave it +running. ### 2. Launch the app @@ -53,10 +58,11 @@ Inside the app: `surfnet_setAccount` / `surfnet_setTokenAccount` cheatcodes against the hosted Surfpool RPC. The cell collapses into a `Balance` readout (e.g. `1000 USDC`) once funding lands. -3. Tap any card in **Endpoints (http://127.0.0.1:1402)** — `Usage - Report`, `Compute Job`, `Subscription`, etc. The SDK handles the - `402 → sign → 200` dance and appends the result to the **Log** - section below. +3. Tap any card in **Endpoints (… from OpenAPI)** — the cards are built + from the playground's `/openapi.json`, so they reflect whatever the + running server prices (e.g. `Stock quote`, `A programmer joke`). The + SDK handles the `402 → sign → 200` dance and appends the result to the + **Log** section below. The address persists across launches; **Reset Account** in the bottom toolbar wipes it from Keychain. diff --git a/swift/README.md b/swift/README.md index de59e3a29..127ffb273 100644 --- a/swift/README.md +++ b/swift/README.md @@ -113,7 +113,7 @@ Rust, Go, PHP, Ruby, Lua, and Python packages. |---|:---:| | `mpp/charge/pull` | pass | | `mpp/charge/push` | planned | -| `mpp/session` | planned | +| `mpp/session` | ✅ | | `mpp/subscription` | planned | ### x402 diff --git a/swift/Sources/SolanaPayKit/PayCore/PaymentChannels.swift b/swift/Sources/SolanaPayKit/PayCore/PaymentChannels.swift new file mode 100644 index 000000000..99c215e2f --- /dev/null +++ b/swift/Sources/SolanaPayKit/PayCore/PaymentChannels.swift @@ -0,0 +1,264 @@ +import Foundation + +/// Client-side payment-channels primitives: PDA/ATA derivation, the 48-byte +/// voucher preimage, and the `open` instruction + partially-signed open +/// transaction the session client broadcasts via the operator. +/// +/// Mirrors the client-facing subset of `solana_pay_core::payment_channels` +/// (`rust/crates/core/src/payment_channels.rs`). The server-only primitives +/// (ed25519 verify precompile, settle/finalize/distribute, the BLAKE3 +/// distribution hash) are intentionally omitted: this SDK is client-only, and +/// the channel `open` passes its recipients inline rather than hashed. +public enum PaymentChannels { + private static let canonicalProgramId = "GuoKrzaBiZnW5DvJ3yZVE7xHqbcBvaX9SH6P6Cn9gNvc" + + /// Canonical payment-channels program ID deployed to Surfnet. + public static let programId: Pubkey = { + guard let id = try? Pubkey(base58: canonicalProgramId) else { + preconditionFailure("invalid canonical payment-channels program ID") + } + return id + }() + + /// Channel PDA seed prefix. + static let channelSeed = Data("channel".utf8) + + /// Event-authority PDA seed prefix. + static let eventAuthoritySeed = Data("event_authority".utf8) + + /// Default payment-channel close grace period, in seconds. + public static let defaultGracePeriodSeconds: UInt32 = 900 + + /// `open` instruction discriminator (codama `OPEN_DISCRIMINATOR`). + static let openDiscriminator: UInt8 = 1 + + /// A recipient split: `bps` basis points of the settled balance. + public struct Distribution: Equatable, Sendable { + public let recipient: Pubkey + public let bps: UInt16 + + public init(recipient: Pubkey, bps: UInt16) { + self.recipient = recipient + self.bps = bps + } + } + + /// Inputs to the channel `open` instruction. + public struct OpenChannelParams: Sendable { + public let payer: Pubkey + public let payee: Pubkey + public let mint: Pubkey + public let authorizedSigner: Pubkey + public let salt: UInt64 + public let deposit: UInt64 + public let gracePeriod: UInt32 + public let recipients: [Distribution] + public let tokenProgram: Pubkey + public let programId: Pubkey + + public init( + payer: Pubkey, + payee: Pubkey, + mint: Pubkey, + authorizedSigner: Pubkey, + salt: UInt64, + deposit: UInt64, + gracePeriod: UInt32, + recipients: [Distribution], + tokenProgram: Pubkey, + programId: Pubkey + ) { + self.payer = payer + self.payee = payee + self.mint = mint + self.authorizedSigner = authorizedSigner + self.salt = salt + self.deposit = deposit + self.gracePeriod = gracePeriod + self.recipients = recipients + self.tokenProgram = tokenProgram + self.programId = programId + } + } + + /// Output of ``buildOpenTransaction(payer:payee:mint:authorizedSigner:salt:deposit:gracePeriod:recipients:tokenProgram:programId:feePayer:recentBlockhash:)``: + /// the derived channel PDA and the base64 (payer-signed, fee-payer-unsigned) + /// open transaction. + public struct OpenTransaction: Sendable { + public let channelId: Pubkey + public let transaction: String + } + + // MARK: - Voucher preimage + + /// The 48-byte Ed25519 voucher preimage: + /// `channelId(32) || cumulativeAmount(u64 LE) || expiresAt(i64 LE)`. + /// Matches `voucher_message_bytes` (Borsh of a fixed `[u8;32]` + scalars, + /// no discriminator or length prefix). + public static func voucherMessageBytes(channelId: Pubkey, cumulative: UInt64, expiresAt: Int64) -> Data { + var out = Data(capacity: 48) + out.append(channelId.bytes) + out.append(littleEndian(cumulative)) + out.append(littleEndian(UInt64(bitPattern: expiresAt))) + return out + } + + // MARK: - PDA derivation + + public static func findChannelPda( + payer: Pubkey, + payee: Pubkey, + mint: Pubkey, + authorizedSigner: Pubkey, + salt: UInt64, + programId: Pubkey + ) throws -> Pubkey { + let seeds: [Data] = [ + channelSeed, + payer.bytes, + payee.bytes, + mint.bytes, + authorizedSigner.bytes, + littleEndian(salt), + ] + return try ProgramDerivedAddress.find(seeds: seeds, programId: programId).address + } + + public static func findEventAuthorityPda(programId: Pubkey) throws -> Pubkey { + try ProgramDerivedAddress.find(seeds: [eventAuthoritySeed], programId: programId).address + } + + /// Generate a random `u64` channel salt so concurrent opens derive distinct + /// channel PDAs. Mirrors `random_salt`/`unique_salt` on the spine. + public static func uniqueSalt() -> UInt64 { + UInt64.random(in: UInt64.min...UInt64.max) + } + + // MARK: - Open instruction + transaction + + public static func buildOpenInstruction(_ params: OpenChannelParams) throws -> SolanaInstruction { + let channel = try findChannelPda( + payer: params.payer, + payee: params.payee, + mint: params.mint, + authorizedSigner: params.authorizedSigner, + salt: params.salt, + programId: params.programId + ) + let payerTokenAccount = try AssociatedTokenAccount.address( + owner: params.payer, mint: params.mint, tokenProgram: params.tokenProgram + ) + let channelTokenAccount = try AssociatedTokenAccount.address( + owner: channel, mint: params.mint, tokenProgram: params.tokenProgram + ) + let eventAuthority = try findEventAuthorityPda(programId: params.programId) + + // Account order matches the codama-generated `Open` builder exactly. + let accounts: [AccountMeta] = [ + .writableSigner(params.payer), + .readonly(params.payee), + .readonly(params.mint), + .readonly(params.authorizedSigner), + .writable(channel), + .writable(payerTokenAccount), + .writable(channelTokenAccount), + .readonly(params.tokenProgram), + .readonly(.systemProgram), + .readonly(.sysvarRent), + .readonly(.associatedTokenProgram), + .readonly(eventAuthority), + .readonly(params.programId), + ] + + // data = discriminator(1) || borsh(OpenArgs { salt, deposit, gracePeriod, recipients }). + var data = Data([openDiscriminator]) + data.append(littleEndian(params.salt)) + data.append(littleEndian(params.deposit)) + data.append(littleEndian(params.gracePeriod)) + data.append(littleEndian(UInt32(params.recipients.count))) + for entry in params.recipients { + data.append(entry.recipient.bytes) + data.append(littleEndian(entry.bps)) + } + + return SolanaInstruction(programId: params.programId, accounts: accounts, data: data) + } + + /// Build a payer-signed (fee-payer-unsigned) channel `open` transaction. The + /// `payer` signs to authorize the deposit; the `feePayer` (operator) slot is + /// left empty for the server to co-sign before broadcast. Returns the derived + /// channel PDA and the base64-encoded transaction. + public static func buildOpenTransaction( + payer: SolanaSigner, + payee: Pubkey, + mint: Pubkey, + authorizedSigner: Pubkey, + salt: UInt64, + deposit: UInt64, + gracePeriod: UInt32, + recipients: [Distribution], + tokenProgram: Pubkey, + programId: Pubkey, + feePayer: Pubkey, + recentBlockhash: Data + ) async throws -> OpenTransaction { + let payerPubkey = try Pubkey(bytes: payer.publicKey) + let params = OpenChannelParams( + payer: payerPubkey, + payee: payee, + mint: mint, + authorizedSigner: authorizedSigner, + salt: salt, + deposit: deposit, + gracePeriod: gracePeriod, + recipients: recipients, + tokenProgram: tokenProgram, + programId: programId + ) + let channelId = try findChannelPda( + payer: payerPubkey, + payee: payee, + mint: mint, + authorizedSigner: authorizedSigner, + salt: salt, + programId: programId + ) + let instruction = try buildOpenInstruction(params) + let message = try TransactionBuilder.compile( + version: .legacy, + feePayer: feePayer, + instructions: [instruction], + recentBlockhash: recentBlockhash + ) + let signature = try await payer.sign(message: message.serialize()) + guard signature.count == Ed25519.signatureLength else { + throw MppError.signingFailure("payment-channel open signature must be 64 bytes, got \(signature.count)") + } + guard let signerIndex = message.accountKeys.firstIndex(of: payerPubkey) else { + throw MppError.invalidTransaction("payer is not in the open transaction account list") + } + var signatures = SignedTransaction.emptySignatureSlots(count: Int(message.header.numRequiredSignatures)) + // The payer must land in the signer prefix of the account list; guard the + // subscript so a non-signer index throws instead of crashing. + guard signerIndex < signatures.count else { + throw MppError.invalidTransaction("payer signer index \(signerIndex) is outside the required-signer range") + } + signatures[signerIndex] = signature + let transaction = try SignedTransaction(signatures: signatures, message: message) + return OpenTransaction(channelId: channelId, transaction: transaction.serialize().base64EncodedString()) + } + + // MARK: - Little-endian helpers + + static func littleEndian(_ value: UInt64) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + static func littleEndian(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + static func littleEndian(_ value: UInt16) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } +} diff --git a/swift/Sources/SolanaPayKit/Protocols/Mpp/Client/SessionClient.swift b/swift/Sources/SolanaPayKit/Protocols/Mpp/Client/SessionClient.swift new file mode 100644 index 000000000..0cc71b84d --- /dev/null +++ b/swift/Sources/SolanaPayKit/Protocols/Mpp/Client/SessionClient.swift @@ -0,0 +1,449 @@ +import Foundation + +/// A live metered session bound to one payment channel. +/// +/// Holds the cumulative watermark, request nonce, and voucher expiry, and signs +/// monotonically-increasing vouchers with the session signer. Mirrors the Go +/// reference `ActiveSession` (`go/protocols/mpp/client/session.go`) including the +/// `ReconcileSettled` lost-response clamp. Sessions are single-threaded; the +/// type is a reference type (mutating signs advance the watermark) and is not +/// `Sendable`. +public final class ActiveSession { + public let channelId: Pubkey + public private(set) var cumulative: UInt64 + private var nonce: UInt64 + private var expiresAt: Int64 + private let signer: SolanaSigner + + public init(channelId: Pubkey, signer: SolanaSigner, cumulative: UInt64 = 0, expiresAt: Int64 = defaultSessionExpiresAt) { + self.channelId = channelId + self.signer = signer + self.cumulative = cumulative + self.nonce = 0 + self.expiresAt = expiresAt + } + + public func setExpiresAt(_ value: Int64) { + expiresAt = value + } + + /// base58 of the session signer's public key (the on-chain authorized signer). + public func authorizedSigner() -> String { + signer.address + } + + public func channelIdString() -> String { + channelId.base58 + } + + // MARK: - Voucher signing + + /// Sign a voucher at an absolute cumulative without advancing the watermark + /// (retry-safe). Rejects a cumulative that does not strictly exceed the + /// current watermark. + public func prepareVoucher(_ cumulative: UInt64) async throws -> SignedVoucher { + guard cumulative > self.cumulative else { + throw MppError.invalidTransaction( + "voucher cumulative \(cumulative) must exceed current watermark \(self.cumulative)" + ) + } + let data = VoucherData( + channelId: channelIdString(), + cumulative: String(cumulative), + expiresAt: expiresAt, + nonce: nonce + 1 + ) + let signature = try await signer.sign(message: try data.messageBytes()) + return SignedVoucher(data: data, signature: Base58.encode(signature)) + } + + /// Sign a voucher `amount` above the current watermark (no advance). + public func prepareIncrement(_ amount: UInt64) async throws -> SignedVoucher { + try await prepareVoucher(try addToWatermark(amount)) + } + + /// Advance the watermark to a recorded voucher: rejects a voucher bound to a + /// different channel, a non-increasing cumulative, or an unparseable + /// cumulative; advances the nonce to at least `nonce + 1` (or the voucher's + /// nonce when higher). Mirrors Go `RecordVoucher`. + public func recordVoucher(_ voucher: SignedVoucher) throws { + guard voucher.data.channelId == channelIdString() else { + throw MppError.invalidTransaction( + "voucher channel \(voucher.data.channelId) does not match active session \(channelIdString())" + ) + } + guard let cumulative = UInt64(voucher.data.cumulative) else { + throw MppError.invalidTransaction("invalid voucher cumulative") + } + guard cumulative > self.cumulative else { + throw MppError.invalidTransaction( + "voucher cumulative \(cumulative) must exceed current watermark \(self.cumulative)" + ) + } + self.cumulative = cumulative + var candidate = self.nonce + 1 + if let nonce = voucher.data.nonce, nonce > candidate { candidate = nonce } + self.nonce = candidate + } + + /// Reconcile the watermark to a server-settled cumulative (e.g. a replayed + /// commit receipt). Advances to `settled` only when it is ahead and never + /// regresses; advancing also bumps the nonce by one. Mirrors Go + /// `ReconcileSettled` (the #162 lost-response fix). + public func reconcileSettled(_ settled: UInt64) { + if settled > cumulative { + cumulative = settled + nonce += 1 + } + } + + /// Sign an absolute voucher and advance the watermark. + @discardableResult + public func signVoucher(_ cumulative: UInt64) async throws -> SignedVoucher { + let voucher = try await prepareVoucher(cumulative) + try recordVoucher(voucher) + return voucher + } + + /// Sign an increment voucher and advance the watermark. + @discardableResult + public func signIncrement(_ amount: UInt64) async throws -> SignedVoucher { + try await signVoucher(try addToWatermark(amount)) + } + + // MARK: - Action builders + + public func voucherAction(_ amount: UInt64) async throws -> SessionAction { + .voucher(VoucherPayload(voucher: try await signIncrement(amount))) + } + + /// Cooperative close. A `finalIncrement` greater than zero signs one last + /// voucher before closing; `nil` or `0` closes without a voucher. + public func closeAction(finalIncrement: UInt64?) async throws -> SessionAction { + var voucher: SignedVoucher? + if let amount = finalIncrement, amount > 0 { + voucher = try await signIncrement(amount) + } + return .close(ClosePayload(channelId: channelIdString(), voucher: voucher)) + } + + public func openAction(deposit: UInt64, openTxSignature: String) -> SessionAction { + .open(OpenPayload.push( + channelId: channelIdString(), + deposit: String(deposit), + authorizedSigner: authorizedSigner(), + signature: openTxSignature + )) + } + + public func openPaymentChannelAction( + mode: SessionMode = .push, + deposit: UInt64, + payer: String, + payee: String, + mint: String, + salt: UInt64, + gracePeriod: UInt32, + signature: String + ) -> SessionAction { + .open(OpenPayload.paymentChannel( + mode: mode, + channelId: channelIdString(), + deposit: String(deposit), + payer: payer, + payee: payee, + mint: mint, + salt: salt, + gracePeriod: gracePeriod, + authorizedSigner: authorizedSigner(), + signature: signature + )) + } + + public func openPullAction( + tokenAccount: String, + approvedAmount: UInt64, + owner: String, + approveTxSignature: String + ) -> SessionAction { + .open(OpenPayload.pull( + tokenAccount: tokenAccount, + approvedAmount: String(approvedAmount), + owner: owner, + authorizedSigner: authorizedSigner(), + signature: approveTxSignature + )) + } + + public func topupAction(newDeposit: UInt64, topupTxSignature: String) -> SessionAction { + .topUp(TopUpPayload( + channelId: channelIdString(), + newDeposit: String(newDeposit), + signature: topupTxSignature + )) + } + + private func addToWatermark(_ amount: UInt64) throws -> UInt64 { + let (sum, overflow) = cumulative.addingReportingOverflow(amount) + guard !overflow else { + throw MppError.invalidTransaction("voucher cumulative overflow adding \(amount) to \(cumulative)") + } + return sum + } +} + +// MARK: - Payment-channel session opener + +/// Placeholder operator signature: the server fills its fee-payer slot before +/// broadcasting the open transaction. +public let pendingServerSignature = String(repeating: "1", count: 64) + +/// Derived channel parameters for an open. +public struct PaymentChannelOpen: Sendable { + public let channelId: Pubkey + public let payer: Pubkey + public let payee: Pubkey + public let mint: Pubkey + public let authorizedSigner: Pubkey + public let salt: UInt64 + public let deposit: UInt64 + public let gracePeriod: UInt32 + public let recipients: [PaymentChannels.Distribution] + public let tokenProgram: Pubkey + public let programId: Pubkey + + func openPayload(mode: SessionMode, signature: String) -> OpenPayload { + OpenPayload.paymentChannel( + mode: mode, + channelId: channelId.base58, + deposit: String(deposit), + payer: payer.base58, + payee: payee.base58, + mint: mint.base58, + salt: salt, + gracePeriod: gracePeriod, + authorizedSigner: authorizedSigner.base58, + signature: signature + ) + } +} + +/// Per-channel open overrides; unset fields fall back to challenge-derived defaults. +public struct PaymentChannelOpenOptions: Sendable { + public var deposit: UInt64? + public var gracePeriod: UInt32? + public var programId: Pubkey? + public var recipients: [PaymentChannels.Distribution]? + public var salt: UInt64? + public var tokenProgram: Pubkey? + + public init( + deposit: UInt64? = nil, + gracePeriod: UInt32? = nil, + programId: Pubkey? = nil, + recipients: [PaymentChannels.Distribution]? = nil, + salt: UInt64? = nil, + tokenProgram: Pubkey? = nil + ) { + self.deposit = deposit + self.gracePeriod = gracePeriod + self.programId = programId + self.recipients = recipients + self.salt = salt + self.tokenProgram = tokenProgram + } +} + +public struct PaymentChannelSessionOpenOptions: Sendable { + public var open: PaymentChannelOpenOptions + public var signature: String? + public var cumulative: UInt64? + public var expiresAt: Int64? + + public init( + open: PaymentChannelOpenOptions = .init(), + signature: String? = nil, + cumulative: UInt64? = nil, + expiresAt: Int64? = nil + ) { + self.open = open + self.signature = signature + self.cumulative = cumulative + self.expiresAt = expiresAt + } +} + +/// Result of opening a payment-channel session client-side. +public struct PaymentChannelSessionOpen { + public let open: PaymentChannelOpen + public let session: ActiveSession + public let action: SessionAction +} + +public enum PaymentChannelSession { + /// Build a pull + clientVoucher payment-channel session open. The payer + /// partial-signs the open transaction; the operator (fee payer) co-signs and + /// broadcasts. `recentBlockhash` is base58. Mirrors + /// `create_payment_channel_session_opener`. + public static func open( + request: SessionRequest, + payerSigner: SolanaSigner, + sessionSigner: SolanaSigner, + recentBlockhash: String, + options: PaymentChannelSessionOpenOptions = .init() + ) async throws -> PaymentChannelSessionOpen { + try ensureClientVoucherPull(request) + let authorizedSigner = try Pubkey(bytes: sessionSigner.publicKey) + let feePayer = try Pubkey(base58: request.operator) + let payer = try Pubkey(bytes: payerSigner.publicKey) + let open = try deriveOpen(request: request, payer: payer, authorizedSigner: authorizedSigner, options: options.open) + + let blockhash = try Base58.decode(recentBlockhash) + guard blockhash.count == 32 else { + throw MppError.invalidTransaction("recentBlockhash must decode to 32 bytes") + } + let tx = try await PaymentChannels.buildOpenTransaction( + payer: payerSigner, + payee: open.payee, + mint: open.mint, + authorizedSigner: open.authorizedSigner, + salt: open.salt, + deposit: open.deposit, + gracePeriod: open.gracePeriod, + recipients: open.recipients, + tokenProgram: open.tokenProgram, + programId: open.programId, + feePayer: feePayer, + recentBlockhash: blockhash + ) + + let session = ActiveSession( + channelId: open.channelId, + signer: sessionSigner, + cumulative: options.cumulative ?? 0, + expiresAt: options.expiresAt ?? defaultSessionExpiresAt + ) + let signature = options.signature ?? pendingServerSignature + let action = SessionAction.open(open.openPayload(mode: .pull, signature: signature).withTransaction(tx.transaction)) + return PaymentChannelSessionOpen(open: open, session: session, action: action) + } + + static func ensureClientVoucherPull(_ request: SessionRequest) throws { + guard request.modes.contains(.pull) else { + throw MppError.invalidTransaction("session challenge does not advertise pull mode") + } + guard request.pullVoucherStrategy == .clientVoucher else { + throw MppError.invalidTransaction("session challenge does not advertise pull + clientVoucher") + } + } + + static func deriveOpen( + request: SessionRequest, + payer: Pubkey, + authorizedSigner: Pubkey, + options: PaymentChannelOpenOptions + ) throws -> PaymentChannelOpen { + guard let mintString = Mints.resolveChargeMint(currency: request.currency, network: request.network) else { + throw MppError.invalidTransaction("session payment channels require an SPL token") + } + let mint = try Pubkey(base58: mintString) + let payee: Pubkey + do { payee = try Pubkey(base58: request.recipient) } catch { + throw MppError.invalidPubkey("invalid recipient \(request.recipient)") + } + let deposit: UInt64 + if let explicit = options.deposit { + deposit = explicit + } else if let parsed = UInt64(request.cap) { + deposit = parsed + } else { + throw MppError.invalidTransaction("invalid session cap: \(request.cap)") + } + let gracePeriod = options.gracePeriod ?? PaymentChannels.defaultGracePeriodSeconds + let programId: Pubkey + if let explicit = options.programId { + programId = explicit + } else if let requested = request.programId { + do { programId = try Pubkey(base58: requested) } catch { + throw MppError.invalidPubkey("invalid programId \(requested)") + } + } else { + programId = PaymentChannels.programId + } + let tokenProgram: Pubkey + if let explicit = options.tokenProgram { + tokenProgram = explicit + } else { + tokenProgram = try Pubkey(base58: Mints.defaultTokenProgram(currency: request.currency, cluster: request.network)) + } + let recipients: [PaymentChannels.Distribution] + if let explicit = options.recipients { + recipients = explicit + } else { + recipients = try request.splits.map { split in + let recipient: Pubkey + do { recipient = try Pubkey(base58: split.recipient) } catch { + throw MppError.invalidPubkey("invalid split recipient \(split.recipient)") + } + return PaymentChannels.Distribution(recipient: recipient, bps: split.bps) + } + } + let salt = options.salt ?? PaymentChannels.uniqueSalt() + let channelId = try PaymentChannels.findChannelPda( + payer: payer, payee: payee, mint: mint, authorizedSigner: authorizedSigner, salt: salt, programId: programId + ) + return PaymentChannelOpen( + channelId: channelId, + payer: payer, + payee: payee, + mint: mint, + authorizedSigner: authorizedSigner, + salt: salt, + deposit: deposit, + gracePeriod: gracePeriod, + recipients: recipients, + tokenProgram: tokenProgram, + programId: programId + ) + } +} + +// MARK: - Session credential framing + challenge dispatch + +private struct SessionCredential: Encodable { + let challenge: ChallengeEcho + let payload: SessionAction +} + +/// Build an `Authorization: Payment ` value for a session +/// action, echoing the challenge. Mirrors Go `SerializeSessionCredential`. +public func serializeSessionCredential(challenge: ChallengeEcho, action: SessionAction) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(SessionCredential(challenge: challenge, payload: action)) + return "\(MppHeaders.paymentScheme) \(Base64URL.encode(data))" +} + +extension PaymentChallenge { + /// Require a `solana`/`session` challenge before opening a session. + public func requireSolanaSession() throws { + guard method == "solana", intent == "session" else { + throw MppError.unsupportedChallenge(method: method, intent: intent) + } + } + + /// Decode the base64url-encoded session request carried by the challenge. + public var sessionRequest: SessionRequest { + get throws { + guard request.utf8.count <= MppHeaders.maxTokenLength else { + throw MppError.invalidHeader + } + let data = try Base64URL.decode(request) + do { + return try JSONDecoder().decode(SessionRequest.self, from: data) + } catch { + throw MppError.invalidJSON(String(describing: error)) + } + } + } +} diff --git a/swift/Sources/SolanaPayKit/Protocols/Mpp/Client/SessionConsumer.swift b/swift/Sources/SolanaPayKit/Protocols/Mpp/Client/SessionConsumer.swift new file mode 100644 index 000000000..890d0b869 --- /dev/null +++ b/swift/Sources/SolanaPayKit/Protocols/Mpp/Client/SessionConsumer.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Transport that commits a signed voucher to the session server and returns a +/// receipt. Mirrors the Rust/Go `CommitTransport`. +public protocol CommitTransport: Sendable { + func commit(directive: MeteringDirective, payload: CommitPayload) async throws -> CommitReceipt +} + +/// Client side of metered delivery: signs a voucher per directive, commits it +/// through the transport, and advances the local watermark only on success. +/// Mirrors Go `SessionConsumer` (`go/protocols/mpp/client/session_consumer.go`). +public final class SessionConsumer { + public let session: ActiveSession + public let transport: CommitTransport + + public init(session: ActiveSession, transport: CommitTransport) { + self.session = session + self.transport = transport + } + + /// Validate the directive against the active session and wrap it for ack. + public func accept( + _ envelope: MeteredEnvelope + ) throws -> MeteredDelivery { + try validateDirective(envelope.metering) + return MeteredDelivery(consumer: self, payload: envelope.payload, metering: envelope.metering) + } + + /// Sign a voucher for the directive amount, commit it, and advance the + /// watermark. Rejects a mismatched session, a non-integer amount, or a zero + /// amount before committing. On a committed receipt the prepared voucher is + /// recorded; on a replayed receipt the watermark reconciles to the settled + /// cumulative, clamped to the just-prepared voucher (the server is untrusted) + /// and never regressing. + @discardableResult + public func commitDirective(_ directive: MeteringDirective) async throws -> CommitReceipt { + try validateDirective(directive) + let amount = try directive.amountBaseUnits() + guard amount != 0 else { + throw MppError.invalidTransaction("metered delivery amount must be greater than zero") + } + + let voucher = try await session.prepareIncrement(amount) + let payload = CommitPayload(deliveryId: directive.deliveryId, voucher: voucher) + let receipt = try await transport.commit(directive: directive, payload: payload) + + switch receipt.status { + case .replayed: + guard let settled = UInt64(receipt.cumulative) else { + throw MppError.invalidTransaction("invalid replayed receipt cumulative: \(receipt.cumulative)") + } + guard let prepared = UInt64(voucher.data.cumulative) else { + throw MppError.invalidTransaction("invalid prepared voucher cumulative: \(voucher.data.cumulative)") + } + session.reconcileSettled(min(settled, prepared)) + case .committed: + try session.recordVoucher(voucher) + } + return receipt + } + + private func validateDirective(_ directive: MeteringDirective) throws { + let channelId = session.channelIdString() + guard directive.sessionId == channelId else { + throw MppError.invalidTransaction( + "metered delivery session \(directive.sessionId) does not match active session \(channelId)" + ) + } + } +} + +/// A validated metered delivery awaiting acknowledgement. `ack`/`commit` sign +/// and commit the voucher; `intoParts` releases the payload without committing. +public final class MeteredDelivery { + private let consumer: SessionConsumer + public let payload: Payload + public let metering: MeteringDirective + + init(consumer: SessionConsumer, payload: Payload, metering: MeteringDirective) { + self.consumer = consumer + self.payload = payload + self.metering = metering + } + + @discardableResult + public func ack() async throws -> CommitReceipt { + try await consumer.commitDirective(metering) + } + + @discardableResult + public func commit() async throws -> CommitReceipt { + try await ack() + } + + public func intoParts() -> (Payload, MeteringDirective) { + (payload, metering) + } +} diff --git a/swift/Sources/SolanaPayKit/Protocols/Mpp/Core/SessionTypes.swift b/swift/Sources/SolanaPayKit/Protocols/Mpp/Core/SessionTypes.swift new file mode 100644 index 000000000..f17b6b060 --- /dev/null +++ b/swift/Sources/SolanaPayKit/Protocols/Mpp/Core/SessionTypes.swift @@ -0,0 +1,547 @@ +import Foundation + +/// MPP payment-channel session wire types. +/// +/// Mirrors the Rust spine (`rust/crates/mpp/src/protocol/intents/session.rs`) +/// and the Go reference (`go/protocols/mpp/protocol/intents/session.go`) +/// tag-for-tag and key-for-key. The JSON keys are camelCase and equal the Swift +/// property names except where noted (`salt` serializes as a decimal string and +/// reads string-or-number; `VoucherData.cumulativeAmount` also reads the legacy +/// `cumulative` alias; `SessionAction` is an internally-tagged union flattened +/// onto the `action` key). + +/// Default voucher/session expiry: 2100-01-01T00:00:00Z, kept under JS +/// `Number.MAX_SAFE_INTEGER`. Matches `DEFAULT_SESSION_EXPIRES_AT`. +public let defaultSessionExpiresAt: Int64 = 4_102_444_800 + +public enum SessionMode: String, Codable, Equatable, Sendable { + case push + case pull +} + +public enum SessionPullVoucherStrategy: String, Codable, Equatable, Sendable { + case clientVoucher + case operatedVoucher +} + +public enum CommitStatus: String, Codable, Equatable, Sendable { + case committed + case replayed +} + +public struct SessionSplit: Codable, Equatable, Sendable { + public let recipient: String + public let bps: UInt16 + + public init(recipient: String, bps: UInt16) { + self.recipient = recipient + self.bps = bps + } +} + +public struct SessionRequest: Codable, Equatable, Sendable { + public let cap: String + public let currency: String + public let decimals: Int? + public let network: String? + public let `operator`: String + public let recipient: String + public let splits: [SessionSplit] + public let programId: String? + public let description: String? + public let externalId: String? + public let minVoucherDelta: String? + public let modes: [SessionMode] + public let pullVoucherStrategy: SessionPullVoucherStrategy? + public let recentBlockhash: String? + + public init( + cap: String, + currency: String, + decimals: Int? = nil, + network: String? = nil, + operator: String, + recipient: String, + splits: [SessionSplit] = [], + programId: String? = nil, + description: String? = nil, + externalId: String? = nil, + minVoucherDelta: String? = nil, + modes: [SessionMode] = [], + pullVoucherStrategy: SessionPullVoucherStrategy? = nil, + recentBlockhash: String? = nil + ) { + self.cap = cap + self.currency = currency + self.decimals = decimals + self.network = network + self.operator = `operator` + self.recipient = recipient + self.splits = splits + self.programId = programId + self.description = description + self.externalId = externalId + self.minVoucherDelta = minVoucherDelta + self.modes = modes + self.pullVoucherStrategy = pullVoucherStrategy + self.recentBlockhash = recentBlockhash + } + + enum CodingKeys: String, CodingKey { + case cap, currency, decimals, network, `operator`, recipient, splits + case programId, description, externalId, minVoucherDelta, modes + case pullVoucherStrategy, recentBlockhash + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + cap = try c.decode(String.self, forKey: .cap) + currency = try c.decode(String.self, forKey: .currency) + decimals = try c.decodeIfPresent(Int.self, forKey: .decimals) + network = try c.decodeIfPresent(String.self, forKey: .network) + `operator` = try c.decode(String.self, forKey: .operator) + recipient = try c.decode(String.self, forKey: .recipient) + splits = try c.decodeIfPresent([SessionSplit].self, forKey: .splits) ?? [] + programId = try c.decodeIfPresent(String.self, forKey: .programId) + description = try c.decodeIfPresent(String.self, forKey: .description) + externalId = try c.decodeIfPresent(String.self, forKey: .externalId) + minVoucherDelta = try c.decodeIfPresent(String.self, forKey: .minVoucherDelta) + modes = try c.decodeIfPresent([SessionMode].self, forKey: .modes) ?? [] + pullVoucherStrategy = try c.decodeIfPresent(SessionPullVoucherStrategy.self, forKey: .pullVoucherStrategy) + recentBlockhash = try c.decodeIfPresent(String.self, forKey: .recentBlockhash) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(cap, forKey: .cap) + try c.encode(currency, forKey: .currency) + try c.encodeIfPresent(decimals, forKey: .decimals) + try c.encodeIfPresent(network, forKey: .network) + try c.encode(`operator`, forKey: .operator) + try c.encode(recipient, forKey: .recipient) + if !splits.isEmpty { try c.encode(splits, forKey: .splits) } + try c.encodeIfPresent(programId, forKey: .programId) + try c.encodeIfPresent(description, forKey: .description) + try c.encodeIfPresent(externalId, forKey: .externalId) + try c.encodeIfPresent(minVoucherDelta, forKey: .minVoucherDelta) + if !modes.isEmpty { try c.encode(modes, forKey: .modes) } + try c.encodeIfPresent(pullVoucherStrategy, forKey: .pullVoucherStrategy) + try c.encodeIfPresent(recentBlockhash, forKey: .recentBlockhash) + } +} + +/// Open-channel action payload. `salt` serializes as a decimal string and reads +/// string-or-number; every other field is omitted when nil except +/// `authorizedSigner` and `signature`, which are always present. +public struct OpenPayload: Codable, Equatable, Sendable { + public var mode: SessionMode + public var channelId: String? + public var deposit: String? + public var payer: String? + public var payee: String? + public var mint: String? + public var salt: UInt64? + public var gracePeriod: UInt32? + public var transaction: String? + public var tokenAccount: String? + public var approvedAmount: String? + public var owner: String? + public var initMultiDelegateTx: String? + public var updateDelegationTx: String? + public var authorizedSigner: String + public var signature: String + + public init( + mode: SessionMode, + channelId: String? = nil, + deposit: String? = nil, + payer: String? = nil, + payee: String? = nil, + mint: String? = nil, + salt: UInt64? = nil, + gracePeriod: UInt32? = nil, + transaction: String? = nil, + tokenAccount: String? = nil, + approvedAmount: String? = nil, + owner: String? = nil, + initMultiDelegateTx: String? = nil, + updateDelegationTx: String? = nil, + authorizedSigner: String, + signature: String + ) { + self.mode = mode + self.channelId = channelId + self.deposit = deposit + self.payer = payer + self.payee = payee + self.mint = mint + self.salt = salt + self.gracePeriod = gracePeriod + self.transaction = transaction + self.tokenAccount = tokenAccount + self.approvedAmount = approvedAmount + self.owner = owner + self.initMultiDelegateTx = initMultiDelegateTx + self.updateDelegationTx = updateDelegationTx + self.authorizedSigner = authorizedSigner + self.signature = signature + } + + /// Push payment-channel open with explicit deposit + channel parties. + public static func paymentChannel( + mode: SessionMode, + channelId: String, + deposit: String, + payer: String, + payee: String, + mint: String, + salt: UInt64, + gracePeriod: UInt32, + authorizedSigner: String, + signature: String + ) -> OpenPayload { + OpenPayload( + mode: mode, + channelId: channelId, + deposit: deposit, + payer: payer, + payee: payee, + mint: mint, + salt: salt, + gracePeriod: gracePeriod, + authorizedSigner: authorizedSigner, + signature: signature + ) + } + + /// Minimal push open referencing an already-funded channel. + public static func push( + channelId: String, + deposit: String, + authorizedSigner: String, + signature: String + ) -> OpenPayload { + OpenPayload(mode: .push, channelId: channelId, deposit: deposit, authorizedSigner: authorizedSigner, signature: signature) + } + + /// Pull (delegated-allowance) open. + public static func pull( + tokenAccount: String, + approvedAmount: String, + owner: String, + authorizedSigner: String, + signature: String + ) -> OpenPayload { + OpenPayload( + mode: .pull, + tokenAccount: tokenAccount, + approvedAmount: approvedAmount, + owner: owner, + authorizedSigner: authorizedSigner, + signature: signature + ) + } + + /// Attach the server/operator-broadcast open transaction (base64). + public func withTransaction(_ transaction: String) -> OpenPayload { + var copy = self + copy.transaction = transaction + return copy + } + + enum CodingKeys: String, CodingKey { + case mode, channelId, deposit, payer, payee, mint, salt, gracePeriod + case transaction, tokenAccount, approvedAmount, owner + case initMultiDelegateTx, updateDelegationTx, authorizedSigner, signature + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + mode = try c.decode(SessionMode.self, forKey: .mode) + channelId = try c.decodeIfPresent(String.self, forKey: .channelId) + deposit = try c.decodeIfPresent(String.self, forKey: .deposit) + payer = try c.decodeIfPresent(String.self, forKey: .payer) + payee = try c.decodeIfPresent(String.self, forKey: .payee) + mint = try c.decodeIfPresent(String.self, forKey: .mint) + salt = try Self.decodeSalt(c) + gracePeriod = try c.decodeIfPresent(UInt32.self, forKey: .gracePeriod) + transaction = try c.decodeIfPresent(String.self, forKey: .transaction) + tokenAccount = try c.decodeIfPresent(String.self, forKey: .tokenAccount) + approvedAmount = try c.decodeIfPresent(String.self, forKey: .approvedAmount) + owner = try c.decodeIfPresent(String.self, forKey: .owner) + initMultiDelegateTx = try c.decodeIfPresent(String.self, forKey: .initMultiDelegateTx) + updateDelegationTx = try c.decodeIfPresent(String.self, forKey: .updateDelegationTx) + authorizedSigner = try c.decode(String.self, forKey: .authorizedSigner) + signature = try c.decode(String.self, forKey: .signature) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(mode, forKey: .mode) + try c.encodeIfPresent(channelId, forKey: .channelId) + try c.encodeIfPresent(deposit, forKey: .deposit) + try c.encodeIfPresent(payer, forKey: .payer) + try c.encodeIfPresent(payee, forKey: .payee) + try c.encodeIfPresent(mint, forKey: .mint) + // salt always serializes as a decimal string. + if let salt { try c.encode(String(salt), forKey: .salt) } + try c.encodeIfPresent(gracePeriod, forKey: .gracePeriod) + try c.encodeIfPresent(transaction, forKey: .transaction) + try c.encodeIfPresent(tokenAccount, forKey: .tokenAccount) + try c.encodeIfPresent(approvedAmount, forKey: .approvedAmount) + try c.encodeIfPresent(owner, forKey: .owner) + try c.encodeIfPresent(initMultiDelegateTx, forKey: .initMultiDelegateTx) + try c.encodeIfPresent(updateDelegationTx, forKey: .updateDelegationTx) + try c.encode(authorizedSigner, forKey: .authorizedSigner) + try c.encode(signature, forKey: .signature) + } + + /// Reads `salt` from either a JSON string ("42") or an integer (42); absent → nil. + /// A non-integer number (e.g. a float or negative) or a key present with the wrong + /// type is treated as nil because the first `UInt64` attempt swallows its error via + /// `try?`; only a present-but-non-numeric string raises. + private static func decodeSalt(_ c: KeyedDecodingContainer) throws -> UInt64? { + if let value = try? c.decode(UInt64.self, forKey: .salt) { + return value + } + if let s = try c.decodeIfPresent(String.self, forKey: .salt) { + guard let value = UInt64(s) else { + throw MppError.invalidTransaction("invalid salt string: \(s)") + } + return value + } + return nil + } +} + +/// The signed-voucher data covered by the Ed25519 signature (minus `nonce`). +public struct VoucherData: Codable, Equatable, Sendable { + public let channelId: String + public let cumulative: String + public let expiresAt: Int64 + public let nonce: UInt64? + + public init(channelId: String, cumulative: String, expiresAt: Int64, nonce: UInt64? = nil) { + self.channelId = channelId + self.cumulative = cumulative + self.expiresAt = expiresAt + self.nonce = nonce + } + + enum CodingKeys: String, CodingKey { + case channelId + case cumulativeAmount + case cumulative + case expiresAt + case nonce + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + channelId = try c.decode(String.self, forKey: .channelId) + // Write key is `cumulativeAmount`; accept the legacy `cumulative` alias. + if let amount = try c.decodeIfPresent(String.self, forKey: .cumulativeAmount) { + cumulative = amount + } else { + cumulative = try c.decode(String.self, forKey: .cumulative) + } + expiresAt = try c.decode(Int64.self, forKey: .expiresAt) + nonce = try c.decodeIfPresent(UInt64.self, forKey: .nonce) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(channelId, forKey: .channelId) + try c.encode(cumulative, forKey: .cumulativeAmount) + try c.encode(expiresAt, forKey: .expiresAt) + try c.encodeIfPresent(nonce, forKey: .nonce) + } + + /// The 48-byte Ed25519 preimage for this voucher. + public func messageBytes() throws -> Data { + let channel = try Pubkey(base58: channelId) + guard let amount = UInt64(cumulative) else { + throw MppError.invalidTransaction("invalid voucher cumulative: \(cumulative)") + } + return PaymentChannels.voucherMessageBytes(channelId: channel, cumulative: amount, expiresAt: expiresAt) + } +} + +public struct SignedVoucher: Codable, Equatable, Sendable { + public let data: VoucherData + public let signature: String + + public init(data: VoucherData, signature: String) { + self.data = data + self.signature = signature + } +} + +public struct VoucherPayload: Codable, Equatable, Sendable { + public let voucher: SignedVoucher + public init(voucher: SignedVoucher) { self.voucher = voucher } +} + +public struct CommitPayload: Codable, Equatable, Sendable { + public let deliveryId: String + public let voucher: SignedVoucher + public init(deliveryId: String, voucher: SignedVoucher) { + self.deliveryId = deliveryId + self.voucher = voucher + } +} + +public struct TopUpPayload: Codable, Equatable, Sendable { + public let channelId: String + public let newDeposit: String + public let signature: String + public init(channelId: String, newDeposit: String, signature: String) { + self.channelId = channelId + self.newDeposit = newDeposit + self.signature = signature + } +} + +public struct ClosePayload: Codable, Equatable, Sendable { + public let channelId: String + public let voucher: SignedVoucher? + public init(channelId: String, voucher: SignedVoucher? = nil) { + self.channelId = channelId + self.voucher = voucher + } +} + +public struct MeteringDirective: Codable, Equatable, Sendable { + public let deliveryId: String + public let sessionId: String + public let amount: String + public let currency: String + public let sequence: UInt64 + public let expiresAt: Int64 + public let commitUrl: String? + public let proof: String? + + public init( + deliveryId: String, + sessionId: String, + amount: String, + currency: String, + sequence: UInt64, + expiresAt: Int64, + commitUrl: String? = nil, + proof: String? = nil + ) { + self.deliveryId = deliveryId + self.sessionId = sessionId + self.amount = amount + self.currency = currency + self.sequence = sequence + self.expiresAt = expiresAt + self.commitUrl = commitUrl + self.proof = proof + } + + /// Parse `amount` as base units. Mirrors `amount_base_units`. + public func amountBaseUnits() throws -> UInt64 { + guard let value = UInt64(amount) else { + throw MppError.invalidTransaction("invalid metering amount: \(amount)") + } + return value + } +} + +public struct MeteringUsage: Codable, Equatable, Sendable { + public let deliveryId: String + public let amount: String + public init(deliveryId: String, amount: String) { + self.deliveryId = deliveryId + self.amount = amount + } + + public func amountBaseUnits() throws -> UInt64 { + guard let value = UInt64(amount) else { + throw MppError.invalidTransaction("invalid metering usage amount: \(amount)") + } + return value + } +} + +public struct MeteredEnvelope: Codable, Equatable, Sendable { + public let payload: Payload + public let metering: MeteringDirective + public init(payload: Payload, metering: MeteringDirective) { + self.payload = payload + self.metering = metering + } +} + +public struct CommitReceipt: Codable, Equatable, Sendable { + public let deliveryId: String + public let sessionId: String + public let amount: String + public let cumulative: String + public let status: CommitStatus + + public init(deliveryId: String, sessionId: String, amount: String, cumulative: String, status: CommitStatus) { + self.deliveryId = deliveryId + self.sessionId = sessionId + self.amount = amount + self.cumulative = cumulative + self.status = status + } +} + +/// Internally-tagged session action union. The discriminator lives on the +/// `action` key and the payload fields are flattened alongside it, e.g. +/// `{"action":"open","mode":"pull",...}`. The TopUp tag is camelCase `topUp`. +public enum SessionAction: Equatable, Sendable { + case open(OpenPayload) + case voucher(VoucherPayload) + case commit(CommitPayload) + case topUp(TopUpPayload) + case close(ClosePayload) + + private enum ActionKey: String, CodingKey { case action } +} + +extension SessionAction: Codable { + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: ActionKey.self) + let action = try c.decode(String.self, forKey: .action) + switch action { + case "open": self = .open(try OpenPayload(from: decoder)) + case "voucher": self = .voucher(try VoucherPayload(from: decoder)) + case "commit": self = .commit(try CommitPayload(from: decoder)) + case "topUp": self = .topUp(try TopUpPayload(from: decoder)) + case "close": self = .close(try ClosePayload(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: ActionKey.action, in: c, debugDescription: "unknown session action \(action)" + ) + } + } + + public func encode(to encoder: Encoder) throws { + // Encode the payload (writes its own keys) then inject the action tag + // into the same keyed container. + switch self { + case let .open(p): try p.encode(to: encoder) + case let .voucher(p): try p.encode(to: encoder) + case let .commit(p): try p.encode(to: encoder) + case let .topUp(p): try p.encode(to: encoder) + case let .close(p): try p.encode(to: encoder) + } + var c = encoder.container(keyedBy: ActionKey.self) + try c.encode(tag, forKey: .action) + } + + private var tag: String { + switch self { + case .open: return "open" + case .voucher: return "voucher" + case .commit: return "commit" + case .topUp: return "topUp" + case .close: return "close" + } + } +} diff --git a/swift/Sources/mpp-conformance/main.swift b/swift/Sources/mpp-conformance/main.swift index 377b1fe77..3c6484773 100644 --- a/swift/Sources/mpp-conformance/main.swift +++ b/swift/Sources/mpp-conformance/main.swift @@ -59,6 +59,8 @@ private struct VectorInput: Decodable { // `value` is an arbitrary JSON document Codable cannot model directly. let encodeBase64Url: EncodeBase64URL? let challengeId: ChallengeID? + // session canonical-bytes: the 48-byte Ed25519 voucher preimage. + let voucherPreimage: VoucherPreimage? // x402-exact build inputs. let x402Version: Int? let x402Offer: JSONValue? @@ -106,6 +108,14 @@ private struct EncodeBase64URL: Decodable { let utf8: String? } +// session voucher preimage input (mirror schema.ts voucherPreimage): +// channelId(32, base58) || cumulativeAmount LE u64 || expiresAt LE i64. +private struct VoucherPreimage: Decodable { + let channelId: String + let cumulativeAmount: String + let expiresAt: Int64 +} + // challenge-id HMAC input (mirror schema.ts / ts-runner challengeId). private struct ChallengeID: Decodable { let secretKey: String @@ -741,6 +751,19 @@ private func runCanonicalBytes(_ vector: Vector, rawValue: Any?) throws -> Exact let mac = HMAC.authenticationCode(for: Data(hmacInput.utf8), using: key) eb.base64Url = base64Url(Data(mac)) } + if let vp = vector.input.voucherPreimage { + // Drive the real SDK preimage encoder so the byte assertion exercises + // the same path the session voucher signer uses. + guard let cumulative = UInt64(vp.cumulativeAmount) else { + throw RunnerError.message("invalid voucher cumulativeAmount \(vp.cumulativeAmount)") + } + let channel = try Pubkey(base58: vp.channelId) + let preimage = PaymentChannels.voucherMessageBytes( + channelId: channel, cumulative: cumulative, expiresAt: vp.expiresAt + ) + eb.bytes = [UInt8](preimage).map { Int($0) } + eb.base64Url = base64Url(preimage) + } return eb } diff --git a/swift/Tests/SolanaPayKitTests/ActiveSessionTests.swift b/swift/Tests/SolanaPayKitTests/ActiveSessionTests.swift new file mode 100644 index 000000000..9ee92e706 --- /dev/null +++ b/swift/Tests/SolanaPayKitTests/ActiveSessionTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing +@testable import SolanaPayKit + +/// ActiveSession watermark, nonce, expiry, and action-builder behavior, +/// mirroring the Rust spine `client/session.rs` golden tests and the Go +/// `ReconcileSettled` lost-response clamp. +@Suite("ActiveSession", .serialized) +struct ActiveSessionTests { + private func makeSession(seed: UInt8 = 42, channel: UInt8 = 7) throws -> ActiveSession { + let signer = try MemorySigner(secretKey: Data(repeating: seed, count: 32)) + let channelId = try Pubkey(bytes: Data(repeating: channel, count: 32)) + return ActiveSession(channelId: channelId, signer: signer) + } + + @Test + func prepareDoesNotAdvanceButRecordDoes() async throws { + let session = try makeSession() + let prepared = try await session.prepareIncrement(75) + #expect(prepared.data.cumulative == "75") + #expect(prepared.data.nonce == 1) + #expect(session.cumulative == 0) + + try session.recordVoucher(prepared) + #expect(session.cumulative == 75) + + // Recording the same voucher again is non-increasing → rejected. + #expect(throws: MppError.self) { try session.recordVoucher(prepared) } + } + + @Test + func signIncrementAdvancesWatermarkAndNonce() async throws { + let session = try makeSession() + let first = try await session.signIncrement(100) + #expect(first.data.cumulative == "100") + #expect(first.data.nonce == 1) + #expect(session.cumulative == 100) + + let second = try await session.signIncrement(10) + #expect(second.data.cumulative == "110") + #expect(second.data.nonce == 2) + #expect(session.cumulative == 110) + } + + @Test + func signVoucherRejectsNonIncreasingAndZero() async throws { + let session = try makeSession() + _ = try await session.signIncrement(100) + await #expect(throws: MppError.self) { _ = try await session.signVoucher(100) } + await #expect(throws: MppError.self) { _ = try await session.signVoucher(50) } + + let fresh = try makeSession(seed: 9, channel: 8) + await #expect(throws: MppError.self) { _ = try await fresh.signVoucher(0) } + } + + @Test + func recordVoucherRejectsInvalidCumulativeAndDefaultsNonce() throws { + let session = try makeSession() + let bad = SignedVoucher( + data: VoucherData(channelId: session.channelIdString(), cumulative: "not-a-number", expiresAt: defaultSessionExpiresAt), + signature: "sig" + ) + #expect(throws: MppError.self) { try session.recordVoucher(bad) } + + // Missing nonce defaults to current nonce + 1. + let noNonce = SignedVoucher( + data: VoucherData(channelId: session.channelIdString(), cumulative: "15", expiresAt: defaultSessionExpiresAt, nonce: nil), + signature: "sig" + ) + try session.recordVoucher(noNonce) + #expect(session.cumulative == 15) + } + + @Test + func recordVoucherRejectsForeignChannel() throws { + let session = try makeSession() + let foreign = SignedVoucher( + data: VoucherData(channelId: "11111111111111111111111111111112", cumulative: "10", expiresAt: defaultSessionExpiresAt), + signature: "sig" + ) + #expect(throws: MppError.self) { try session.recordVoucher(foreign) } + #expect(session.cumulative == 0) + } + + @Test + func reconcileSettledAdvancesAndNeverRegresses() throws { + let session = try makeSession() + session.reconcileSettled(300) + #expect(session.cumulative == 300) + // A stale settled value never regresses the watermark. + session.reconcileSettled(100) + #expect(session.cumulative == 300) + } + + @Test + func expiresAtControlsVoucherExpiry() async throws { + let session = try makeSession() + session.setExpiresAt(1234) + let v1 = try await session.prepareIncrement(10) + #expect(v1.data.expiresAt == 1234) + + session.setExpiresAt(5678) + let v2 = try await session.prepareIncrement(10) + #expect(v2.data.expiresAt == 5678) + } + + @Test + func closeActionVoucherFollowsFinalIncrement() async throws { + let session = try makeSession() + if case let .close(payload) = try await session.closeAction(finalIncrement: nil) { + #expect(payload.voucher == nil) + #expect(payload.channelId == session.channelIdString()) + } else { + Issue.record("expected close action") + } + + _ = try await session.signIncrement(100) + if case let .close(payload) = try await session.closeAction(finalIncrement: 50) { + #expect(payload.voucher?.data.cumulative == "150") + } else { + Issue.record("expected close action") + } + + // A zero final increment carries no voucher. + if case let .close(payload) = try await session.closeAction(finalIncrement: 0) { + #expect(payload.voucher == nil) + } else { + Issue.record("expected close action") + } + } + + @Test + func openAndTopupActionFields() async throws { + let session = try makeSession() + if case let .open(payload) = session.openAction(deposit: 1_000_000, openTxSignature: "txsig123") { + #expect(payload.mode == .push) + #expect(payload.deposit == "1000000") + #expect(payload.signature == "txsig123") + #expect(payload.channelId == session.channelIdString()) + #expect(payload.authorizedSigner == session.authorizedSigner()) + } else { + Issue.record("expected open action") + } + + if case let .open(payload) = session.openPullAction( + tokenAccount: "payer-ata-123", + approvedAmount: 5_000_000, + owner: "wallet123", + approveTxSignature: "approvesig" + ) { + #expect(payload.mode == .pull) + #expect(payload.approvedAmount == "5000000") + #expect(payload.owner == "wallet123") + #expect(payload.tokenAccount == "payer-ata-123") + #expect(payload.channelId == nil) + } else { + Issue.record("expected open action") + } + + if case let .topUp(payload) = session.topupAction(newDeposit: 5_000_000, topupTxSignature: "topuptx") { + #expect(payload.newDeposit == "5000000") + #expect(payload.signature == "topuptx") + #expect(payload.channelId == session.channelIdString()) + } else { + Issue.record("expected topUp action") + } + + if case let .open(payload) = session.openPaymentChannelAction( + mode: .pull, deposit: 9_000, payer: "Payer", payee: "Payee", mint: "Mint", salt: 42, gracePeriod: 60, signature: "open-sig" + ) { + #expect(payload.mode == .pull) + #expect(payload.deposit == "9000") + #expect(payload.salt == 42) + #expect(payload.gracePeriod == 60) + #expect(payload.signature == "open-sig") + } else { + Issue.record("expected open action") + } + } +} diff --git a/swift/Tests/SolanaPayKitTests/SessionClientExtraTests.swift b/swift/Tests/SolanaPayKitTests/SessionClientExtraTests.swift new file mode 100644 index 000000000..7c6fe46da --- /dev/null +++ b/swift/Tests/SolanaPayKitTests/SessionClientExtraTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing +@testable import SolanaPayKit + +/// Coverage for the session challenge dispatch, credential framing, and opener +/// option/error paths beyond the happy-path opener test. +@Suite("Session client dispatch + credential", .serialized) +struct SessionClientExtraTests { + private let operatorAddress = Base58.encode(Data(repeating: 0x05, count: 32)) + private let recipient = Base58.encode(Data(repeating: 0x06, count: 32)) + private let blockhash = Base58.encode(Data(repeating: 0x11, count: 32)) + + private func request(recipient: String? = nil) -> SessionRequest { + SessionRequest( + cap: "1000000", currency: "USDC", decimals: 6, network: "localnet", + operator: operatorAddress, recipient: recipient ?? self.recipient, + modes: [.pull], pullVoucherStrategy: .clientVoucher + ) + } + + private func signers() throws -> (MemorySigner, MemorySigner) { + (try MemorySigner(secretKey: Data(repeating: 1, count: 32)), + try MemorySigner(secretKey: Data(repeating: 2, count: 32))) + } + + @Test + func challengeSessionRequestDecodesAndDispatches() throws { + let req = request() + let encoded = Base64URL.encode(try JSONEncoder().encode(req)) + let challenge = try PaymentChallenge(id: "1", realm: "r", method: "solana", intent: "session", request: encoded) + + let decoded = try challenge.sessionRequest + #expect(decoded == req) + try challenge.requireSolanaSession() // does not throw + + let chargeChallenge = try PaymentChallenge(id: "1", realm: "r", method: "solana", intent: "charge", request: encoded) + #expect(throws: MppError.self) { try chargeChallenge.requireSolanaSession() } + } + + @Test + func sessionRequestRoundTripsThroughTheWire() throws { + let req = SessionRequest( + cap: "5", currency: "USDC", decimals: 6, network: "devnet", operator: operatorAddress, + recipient: recipient, splits: [SessionSplit(recipient: recipient, bps: 10)], + programId: PaymentChannels.programId.base58, externalId: "ext-1", minVoucherDelta: "2", + modes: [.pull, .push], pullVoucherStrategy: .clientVoucher, recentBlockhash: blockhash + ) + let decoded = try JSONDecoder().decode(SessionRequest.self, from: try JSONEncoder().encode(req)) + #expect(decoded == req) + } + + @Test + func serializeSessionCredentialFramesPaymentHeader() async throws { + let (payer, sessionSigner) = try signers() + let opener = try await PaymentChannelSession.open( + request: request(), payerSigner: payer, sessionSigner: sessionSigner, recentBlockhash: blockhash + ) + let voucherAction = try await opener.session.voucherAction(100) + let echo = try PaymentChallenge( + id: "1", realm: "r", method: "solana", intent: "session", + request: Base64URL.encode(try JSONEncoder().encode(request())) + ).echo() + + let header = try serializeSessionCredential(challenge: echo, action: voucherAction) + #expect(header.hasPrefix("Payment ")) + let payload = String(header.dropFirst("Payment ".count)) + let object = try #require(try JSONSerialization.jsonObject(with: try Base64URL.decode(payload)) as? [String: Any]) + #expect(object["challenge"] != nil) + let inner = try #require(object["payload"] as? [String: Any]) + #expect(inner["action"] as? String == "voucher") + } + + @Test + func openerHonorsExplicitOptions() async throws { + let (payer, sessionSigner) = try signers() + let options = PaymentChannelSessionOpenOptions( + open: PaymentChannelOpenOptions( + deposit: 55, + gracePeriod: 12, + programId: PaymentChannels.programId, + recipients: [PaymentChannels.Distribution(recipient: try Pubkey(base58: recipient), bps: 25)], + salt: 7, + tokenProgram: .tokenProgram + ) + ) + let opener = try await PaymentChannelSession.open( + request: request(), payerSigner: payer, sessionSigner: sessionSigner, recentBlockhash: blockhash, options: options + ) + #expect(opener.open.deposit == 55) + #expect(opener.open.gracePeriod == 12) + #expect(opener.open.salt == 7) + #expect(opener.open.recipients.count == 1) + #expect(opener.open.recipients[0].bps == 25) + } + + @Test + func openerRejectsBadRecipientAndBadBlockhash() async throws { + let (payer, sessionSigner) = try signers() + await #expect(throws: MppError.self) { + _ = try await PaymentChannelSession.open( + request: request(recipient: "not base58 !!!"), payerSigner: payer, sessionSigner: sessionSigner, recentBlockhash: blockhash + ) + } + await #expect(throws: (any Error).self) { + _ = try await PaymentChannelSession.open( + request: request(), payerSigner: payer, sessionSigner: sessionSigner, recentBlockhash: "short" + ) + } + } +} diff --git a/swift/Tests/SolanaPayKitTests/SessionConsumerTests.swift b/swift/Tests/SolanaPayKitTests/SessionConsumerTests.swift new file mode 100644 index 000000000..b585cbc21 --- /dev/null +++ b/swift/Tests/SolanaPayKitTests/SessionConsumerTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing +@testable import SolanaPayKit + +/// SessionConsumer commit flow, validation order, and replay reconcile-and-clamp, +/// mirroring Go `session_consumer.go` tests and the #162 lost-response fix. +@Suite("SessionConsumer", .serialized) +struct SessionConsumerTests { + /// Records each commit and echoes a committed receipt pinned to the + /// voucher's cumulative. Re-committing a deliveryId already seen returns a + /// replayed receipt at the first settled cumulative (server dedupe). + final class RecordingTransport: CommitTransport, @unchecked Sendable { + private(set) var commits: [CommitPayload] = [] + var fail = false + private var settled: [String: String] = [:] + + func commit(directive: MeteringDirective, payload: CommitPayload) async throws -> CommitReceipt { + if fail { throw MppError.invalidTransaction("commit failed") } + if let prior = settled[directive.deliveryId] { + return CommitReceipt( + deliveryId: directive.deliveryId, sessionId: directive.sessionId, + amount: directive.amount, cumulative: prior, status: .replayed + ) + } + let cumulative = payload.voucher.data.cumulative + settled[directive.deliveryId] = cumulative + commits.append(payload) + return CommitReceipt( + deliveryId: directive.deliveryId, sessionId: directive.sessionId, + amount: directive.amount, cumulative: cumulative, status: .committed + ) + } + } + + /// Always reports a fixed settled cumulative as replayed, ignoring the voucher. + struct ReplayTransport: CommitTransport { + let settled: String + func commit(directive: MeteringDirective, payload: CommitPayload) async throws -> CommitReceipt { + CommitReceipt( + deliveryId: directive.deliveryId, sessionId: directive.sessionId, + amount: directive.amount, cumulative: settled, status: .replayed + ) + } + } + + private func makeSession(channel: UInt8 = 7) throws -> ActiveSession { + let signer = try MemorySigner(secretKey: Data(repeating: 42, count: 32)) + return ActiveSession(channelId: try Pubkey(bytes: Data(repeating: channel, count: 32)), signer: signer) + } + + private func directive(_ session: ActiveSession, amount: Int, deliveryId: String = "d1") -> MeteringDirective { + MeteringDirective( + deliveryId: deliveryId, sessionId: session.channelIdString(), amount: String(amount), + currency: "USDC", sequence: 1, expiresAt: defaultSessionExpiresAt + ) + } + + @Test + func ackSendsCommitAndAdvancesWatermark() async throws { + let session = try makeSession() + let transport = RecordingTransport() + let consumer = SessionConsumer(session: session, transport: transport) + let envelope = MeteredEnvelope(payload: "work", metering: directive(session, amount: 250)) + + let delivery = try consumer.accept(envelope) + #expect(delivery.payload == "work") + let receipt = try await delivery.ack() + + #expect(receipt.cumulative == "250") + #expect(receipt.status == .committed) + #expect(session.cumulative == 250) + #expect(transport.commits.count == 1) + } + + @Test + func commitAliasAndIntoParts() async throws { + let session = try makeSession() + session.setExpiresAt(1234) + let transport = RecordingTransport() + let consumer = SessionConsumer(session: session, transport: transport) + + let first = try consumer.accept(MeteredEnvelope(payload: "first", metering: directive(session, amount: 50))) + let receipt = try await first.commit() + #expect(receipt.cumulative == "50") + #expect(transport.commits[0].voucher.data.expiresAt == 1234) + + let second = try consumer.accept(MeteredEnvelope(payload: "second", metering: directive(session, amount: 75, deliveryId: "d2"))) + let (payload, metering) = second.intoParts() + #expect(payload == "second") + #expect(metering.amount == "75") + } + + @Test + func invalidDirectivesRejectedBeforeCommit() async throws { + let session = try makeSession() + let transport = RecordingTransport() + let consumer = SessionConsumer(session: session, transport: transport) + + let wrong = MeteringDirective( + deliveryId: "d1", sessionId: "other-session", amount: "1", currency: "USDC", + sequence: 1, expiresAt: defaultSessionExpiresAt + ) + await #expect(throws: MppError.self) { _ = try await consumer.commitDirective(wrong) } + + await #expect(throws: MppError.self) { _ = try await consumer.commitDirective(directive(session, amount: 0)) } + + let badAmount = MeteringDirective( + deliveryId: "d1", sessionId: session.channelIdString(), amount: "bad", currency: "USDC", + sequence: 1, expiresAt: defaultSessionExpiresAt + ) + await #expect(throws: MppError.self) { _ = try await consumer.commitDirective(badAmount) } + + #expect(transport.commits.isEmpty) + #expect(session.cumulative == 0) + } + + @Test + func failedCommitDoesNotAdvanceWatermark() async throws { + let session = try makeSession() + let transport = RecordingTransport() + transport.fail = true + let consumer = SessionConsumer(session: session, transport: transport) + + await #expect(throws: MppError.self) { _ = try await consumer.commitDirective(directive(session, amount: 250)) } + #expect(session.cumulative == 0) + + transport.fail = false + let receipt = try await consumer.commitDirective(directive(session, amount: 250)) + #expect(receipt.cumulative == "250") + #expect(session.cumulative == 250) + } + + @Test + func duplicateDeliveryReplayDoesNotDoubleCount() async throws { + let session = try makeSession() + let transport = RecordingTransport() + let consumer = SessionConsumer(session: session, transport: transport) + let d = directive(session, amount: 100) + + let r1 = try await consumer.commitDirective(d) + #expect(r1.status == .committed) + #expect(session.cumulative == 100) + + let r2 = try await consumer.commitDirective(d) + #expect(r2.status == .replayed) + #expect(r2.cumulative == "100") + #expect(session.cumulative == 100) + #expect(transport.commits.count == 1) + } + + @Test + func replayedReceiptReconcilesToClampedSettled() async throws { + // Server reports the delivery already settled at 100; the client clamps + // to the just-prepared voucher (250) → min(100, 250) = 100. + let session = try makeSession() + let consumer = SessionConsumer(session: session, transport: ReplayTransport(settled: "100")) + let receipt = try await consumer.commitDirective(directive(session, amount: 250)) + #expect(receipt.status == .replayed) + #expect(session.cumulative == 100) + } + + @Test + func replayedReceiptNeverRegressesWatermark() async throws { + let session = try makeSession() + session.reconcileSettled(300) + let consumer = SessionConsumer(session: session, transport: ReplayTransport(settled: "100")) + _ = try await consumer.commitDirective(directive(session, amount: 50)) + #expect(session.cumulative == 300) + } + + @Test + func replayedReceiptClampsInflatedServerCumulative() async throws { + // A buggy/malicious server reports a replay far above the prepared + // voucher; the watermark clamps to the prepared value (250). + let session = try makeSession() + let consumer = SessionConsumer(session: session, transport: ReplayTransport(settled: "1000000")) + _ = try await consumer.commitDirective(directive(session, amount: 250)) + #expect(session.cumulative == 250) + } +} diff --git a/swift/Tests/SolanaPayKitTests/SessionVoucherTests.swift b/swift/Tests/SolanaPayKitTests/SessionVoucherTests.swift new file mode 100644 index 000000000..9f3b72109 --- /dev/null +++ b/swift/Tests/SolanaPayKitTests/SessionVoucherTests.swift @@ -0,0 +1,66 @@ +import Foundation +import Testing +@testable import SolanaPayKit + +/// Byte-exact golden vectors for the payment-channel voucher preimage and the +/// channel PDA, mirroring the Rust spine tests +/// (`voucher_message_is_program_borsh_layout`, `channel_pda_is_stable`). +@Suite("Session voucher preimage + PDA") +struct SessionVoucherTests { + @Test + func voucherPreimageIsBorshLayout() throws { + let channel = try Pubkey(bytes: Data(repeating: 9, count: 32)) + let bytes = PaymentChannels.voucherMessageBytes(channelId: channel, cumulative: 42, expiresAt: 1234) + + #expect(bytes.count == 48) + #expect(Array(bytes[0..<32]) == Array(repeating: 9, count: 32)) + // 42 little-endian u64. + #expect(Array(bytes[32..<40]) == [42, 0, 0, 0, 0, 0, 0, 0]) + // 1234 = 0x04D2 little-endian i64. + #expect(Array(bytes[40..<48]) == [0xD2, 0x04, 0, 0, 0, 0, 0, 0]) + } + + @Test + func voucherPreimageEncodesNegativeExpiryAsTwosComplement() throws { + let channel = try Pubkey(bytes: Data(repeating: 1, count: 32)) + // i64 -1 → all 0xFF. + let bytes = PaymentChannels.voucherMessageBytes(channelId: channel, cumulative: 0, expiresAt: -1) + #expect(Array(bytes[40..<48]) == Array(repeating: 0xFF, count: 8)) + } + + @Test + func channelPdaIsDeterministicAndSaltSensitive() throws { + let payer = try Pubkey(bytes: Data(repeating: 1, count: 32)) + let payee = try Pubkey(bytes: Data(repeating: 2, count: 32)) + let mint = try Pubkey(bytes: Data(repeating: 3, count: 32)) + let signer = try Pubkey(bytes: Data(repeating: 4, count: 32)) + + let a = try PaymentChannels.findChannelPda( + payer: payer, payee: payee, mint: mint, authorizedSigner: signer, salt: 99, programId: PaymentChannels.programId + ) + let b = try PaymentChannels.findChannelPda( + payer: payer, payee: payee, mint: mint, authorizedSigner: signer, salt: 99, programId: PaymentChannels.programId + ) + let other = try PaymentChannels.findChannelPda( + payer: payer, payee: payee, mint: mint, authorizedSigner: signer, salt: 100, programId: PaymentChannels.programId + ) + + #expect(a == b) + #expect(a != other) + } + + @Test + func voucherSignatureVerifiesAgainstAuthorizedSigner() async throws { + let signer = try MemorySigner(secretKey: Data(repeating: 42, count: 32)) + let channel = try Pubkey(bytes: Data(repeating: 7, count: 32)) + let session = ActiveSession(channelId: channel, signer: signer) + + let voucher = try await session.signIncrement(100) + let message = try voucher.data.messageBytes() + let signature = try Base58.decode(voucher.signature) + + #expect(try Ed25519.verify(signature: signature, message: message, publicKey: signer.publicKey)) + #expect(voucher.data.channelId == channel.base58) + #expect(voucher.data.cumulative == "100") + } +} diff --git a/swift/Tests/SolanaPayKitTests/SessionWireOpenerTests.swift b/swift/Tests/SolanaPayKitTests/SessionWireOpenerTests.swift new file mode 100644 index 000000000..34e8d198c --- /dev/null +++ b/swift/Tests/SolanaPayKitTests/SessionWireOpenerTests.swift @@ -0,0 +1,195 @@ +import Foundation +import Testing +@testable import SolanaPayKit + +/// Wire-codec parity: the internally-tagged `SessionAction`, salt-as-string, +/// and the `cumulativeAmount`/`cumulative` alias must match the Rust/Go shapes. +@Suite("Session wire codec") +struct SessionWireTests { + private func encodeToObject(_ action: SessionAction) throws -> [String: Any] { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(action) + return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + @Test + func openActionFlattensTagAndSerializesSaltAsString() throws { + let payload = OpenPayload.paymentChannel( + mode: .pull, channelId: "Chan", deposit: "1000", payer: "Payer", payee: "Payee", + mint: "Mint", salt: 42, gracePeriod: 900, authorizedSigner: "Auth", signature: "Sig" + ) + let object = try encodeToObject(.open(payload)) + + #expect(object["action"] as? String == "open") + #expect(object["mode"] as? String == "pull") + #expect(object["channelId"] as? String == "Chan") + // salt is a decimal string, not a number. + #expect(object["salt"] as? String == "42") + #expect(object["authorizedSigner"] as? String == "Auth") + } + + @Test + func topUpActionUsesCamelCaseTag() throws { + let object = try encodeToObject(.topUp(TopUpPayload(channelId: "Chan", newDeposit: "500", signature: "Sig"))) + #expect(object["action"] as? String == "topUp") + #expect(object["newDeposit"] as? String == "500") + } + + @Test + func sessionActionRoundTrips() throws { + let voucher = SignedVoucher( + data: VoucherData(channelId: "Chan", cumulative: "250", expiresAt: 4_102_444_800, nonce: 3), + signature: "Sig" + ) + let actions: [SessionAction] = [ + .open(OpenPayload.push(channelId: "Chan", deposit: "1000", authorizedSigner: "Auth", signature: "Sig")), + .voucher(VoucherPayload(voucher: voucher)), + .commit(CommitPayload(deliveryId: "d1", voucher: voucher)), + .topUp(TopUpPayload(channelId: "Chan", newDeposit: "500", signature: "Sig")), + .close(ClosePayload(channelId: "Chan", voucher: voucher)), + ] + let encoder = JSONEncoder() + for action in actions { + let data = try encoder.encode(action) + let decoded = try JSONDecoder().decode(SessionAction.self, from: data) + #expect(decoded == action) + } + } + + @Test + func voucherDataEncodesCumulativeAmountAndReadsAlias() throws { + let data = VoucherData(channelId: "Chan", cumulative: "250", expiresAt: 100) + let encoded = try JSONEncoder().encode(data) + let object = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + #expect(object["cumulativeAmount"] as? String == "250") + #expect(object["cumulative"] == nil) + + // Decodes from both the canonical key and the legacy alias. + let fromCanonical = try JSONDecoder().decode( + VoucherData.self, from: Data(#"{"channelId":"Chan","cumulativeAmount":"7","expiresAt":1}"#.utf8) + ) + #expect(fromCanonical.cumulative == "7") + let fromAlias = try JSONDecoder().decode( + VoucherData.self, from: Data(#"{"channelId":"Chan","cumulative":"9","expiresAt":1}"#.utf8) + ) + #expect(fromAlias.cumulative == "9") + } + + @Test + func openPayloadReadsSaltFromStringOrNumber() throws { + let fromString = try JSONDecoder().decode( + OpenPayload.self, + from: Data(#"{"mode":"pull","salt":"42","authorizedSigner":"A","signature":"S"}"#.utf8) + ) + #expect(fromString.salt == 42) + let fromNumber = try JSONDecoder().decode( + OpenPayload.self, + from: Data(#"{"mode":"pull","salt":42,"authorizedSigner":"A","signature":"S"}"#.utf8) + ) + #expect(fromNumber.salt == 42) + } + + @Test + func meteringUsageParsesAmountAndRejectsInvalid() throws { + let usage = MeteringUsage(deliveryId: "d1", amount: "250") + #expect(try usage.amountBaseUnits() == 250) + // Round-trips through the wire. + let decoded = try JSONDecoder().decode(MeteringUsage.self, from: try JSONEncoder().encode(usage)) + #expect(decoded == usage) + #expect(throws: MppError.self) { _ = try MeteringUsage(deliveryId: "d1", amount: "bad").amountBaseUnits() } + } + + @Test + func unknownSessionActionTagIsRejected() { + let json = Data(#"{"action":"frobnicate","channelId":"Chan"}"#.utf8) + #expect(throws: (any Error).self) { _ = try JSONDecoder().decode(SessionAction.self, from: json) } + } + + @Test + func voucherMessageBytesRejectsInvalidCumulative() { + let data = VoucherData(channelId: "11111111111111111111111111111112", cumulative: "not-a-number", expiresAt: 1) + #expect(throws: MppError.self) { _ = try data.messageBytes() } + } +} + +/// The payment-channel session opener: pull + clientVoucher only, payer-signed +/// open transaction with the operator as fee payer. Mirrors +/// `create_payment_channel_session_opener` and its guard tests. +@Suite("Payment-channel session opener", .serialized) +struct SessionOpenerTests { + private let operatorAddress = Base58.encode(Data(repeating: 0x05, count: 32)) + private let recipient = Base58.encode(Data(repeating: 0x06, count: 32)) + private let blockhash = Base58.encode(Data(repeating: 0x11, count: 32)) + + private func request(modes: [SessionMode] = [.pull], strategy: SessionPullVoucherStrategy? = .clientVoucher) -> SessionRequest { + SessionRequest( + cap: "1000000", currency: "USDC", decimals: 6, network: "localnet", + operator: operatorAddress, recipient: recipient, modes: modes, pullVoucherStrategy: strategy + ) + } + + private func signers() throws -> (payer: MemorySigner, session: MemorySigner) { + (try MemorySigner(secretKey: Data(repeating: 1, count: 32)), + try MemorySigner(secretKey: Data(repeating: 2, count: 32))) + } + + @Test + func buildsPullClientVoucherOpenAction() async throws { + let (payer, sessionSigner) = try signers() + let opener = try await PaymentChannelSession.open( + request: request(), payerSigner: payer, sessionSigner: sessionSigner, recentBlockhash: blockhash + ) + + #expect(opener.session.channelId == opener.open.channelId) + guard case let .open(payload) = opener.action else { + Issue.record("expected open action"); return + } + #expect(payload.mode == .pull) + #expect(payload.channelId == opener.open.channelId.base58) + #expect(payload.payer == (try Pubkey(bytes: payer.publicKey)).base58) + #expect(payload.authorizedSigner == sessionSigner.address) + #expect(payload.signature == pendingServerSignature) + #expect(payload.transaction != nil) + // localnet USDC resolves to the mainnet mint on the MPP charge path. + #expect(opener.open.mint.base58 == Mints.usdcMainnet) + #expect(opener.open.deposit == 1_000_000) + #expect(opener.open.gracePeriod == PaymentChannels.defaultGracePeriodSeconds) + } + + @Test + func appliesSessionOptions() async throws { + let (payer, sessionSigner) = try signers() + var options = PaymentChannelSessionOpenOptions() + options.cumulative = 20 + options.expiresAt = 1234 + let opener = try await PaymentChannelSession.open( + request: request(), payerSigner: payer, sessionSigner: sessionSigner, recentBlockhash: blockhash, options: options + ) + let voucher = try await opener.session.prepareIncrement(5) + #expect(voucher.data.cumulative == "25") + #expect(voucher.data.expiresAt == 1234) + } + + @Test + func rejectsNonPullChallenge() async throws { + let (payer, sessionSigner) = try signers() + await #expect(throws: MppError.self) { + _ = try await PaymentChannelSession.open( + request: request(modes: [.push], strategy: nil), + payerSigner: payer, sessionSigner: sessionSigner, recentBlockhash: blockhash + ) + } + } + + @Test + func rejectsOperatedVoucherChallenge() async throws { + let (payer, sessionSigner) = try signers() + await #expect(throws: MppError.self) { + _ = try await PaymentChannelSession.open( + request: request(strategy: .operatedVoucher), + payerSigner: payer, sessionSigner: sessionSigner, recentBlockhash: blockhash + ) + } + } +} diff --git a/typescript/packages/mpp/src/__tests__/client-charge-integration.test.ts b/typescript/packages/mpp/src/__tests__/client-charge-integration.test.ts index 7a7713681..1170743f7 100644 --- a/typescript/packages/mpp/src/__tests__/client-charge-integration.test.ts +++ b/typescript/packages/mpp/src/__tests__/client-charge-integration.test.ts @@ -23,6 +23,7 @@ import { TOKEN_PROGRAM } from '../constants.js'; // SURFPOOL_DATASOURCE_RPC_URL secret (see .github/workflows/ci.yml); without it // surfnet clones from the public mainnet-beta RPC, which rate-limits and crashes // the embedded validator mid-test. Mirrors the Rust harness's start_surfnet(). +const HAS_DATASOURCE_RPC = Boolean(process.env.SURFPOOL_DATASOURCE_RPC_URL?.trim()); const DATASOURCE_RPC_URL = process.env.SURFPOOL_DATASOURCE_RPC_URL ?? 'https://api.mainnet-beta.solana.com'; // ── Helpers ── @@ -304,37 +305,40 @@ describe('client charge integration (surfpool)', () => { expect(decoded.payload.transaction).toBeDefined(); }); - test('SPL token transfer — resolves tokenProgram from RPC when not specified', async () => { - // Start a surfnet with mainnet RPC fallback so the USDC mint account - // can be cloned and its owner (TOKEN_PROGRAM) resolved on-chain. - const remoteSurfnet = Surfnet.startWithConfig({ - remoteRpcUrl: DATASOURCE_RPC_URL, - }); - const remoteSigner = await createKeyPairSignerFromBytes(new Uint8Array(remoteSurfnet.payerSecretKey)); - remoteSurfnet.fundSol(remoteSigner.address, 10_000_000_000); - - // Use the real mainnet USDC mint address. - const USDC_MAINNET = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; - const remoteRecipient = Surfnet.newKeypair().publicKey; - remoteSurfnet.fundToken(remoteSigner.address, USDC_MAINNET, 10_000_000); - - const method = charge({ signer: remoteSigner, rpcUrl: remoteSurfnet.rpcUrl }); - - const challenge = makeChallenge({ - amount: '500000', - currency: USDC_MAINNET, - decimals: 6, - recipient: remoteRecipient, - // No tokenProgram — should resolve from on-chain mint account via RPC fallback. - }); - - // Remove tokenProgram from methodDetails so resolveTokenProgram is invoked. - delete (challenge.request.methodDetails as any).tokenProgram; - - const credential = await method.createCredential({ challenge }); - const decoded = decodeCredential(credential); - expect(decoded.payload.type).toBe('transaction'); - }); + test.runIf(HAS_DATASOURCE_RPC)( + 'SPL token transfer — resolves tokenProgram from RPC when not specified', + async () => { + // Start a surfnet with mainnet RPC fallback so the USDC mint account + // can be cloned and its owner (TOKEN_PROGRAM) resolved on-chain. + const remoteSurfnet = Surfnet.startWithConfig({ + remoteRpcUrl: DATASOURCE_RPC_URL, + }); + const remoteSigner = await createKeyPairSignerFromBytes(new Uint8Array(remoteSurfnet.payerSecretKey)); + remoteSurfnet.fundSol(remoteSigner.address, 10_000_000_000); + + // Use the real mainnet USDC mint address. + const USDC_MAINNET = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const remoteRecipient = Surfnet.newKeypair().publicKey; + remoteSurfnet.fundToken(remoteSigner.address, USDC_MAINNET, 10_000_000); + + const method = charge({ signer: remoteSigner, rpcUrl: remoteSurfnet.rpcUrl }); + + const challenge = makeChallenge({ + amount: '500000', + currency: USDC_MAINNET, + decimals: 6, + recipient: remoteRecipient, + // No tokenProgram — should resolve from on-chain mint account via RPC fallback. + }); + + // Remove tokenProgram from methodDetails so resolveTokenProgram is invoked. + delete (challenge.request.methodDetails as any).tokenProgram; + + const credential = await method.createCredential({ challenge }); + const decoded = decodeCredential(credential); + expect(decoded.payload.type).toBe('transaction'); + }, + ); test('SPL token transfer with splits', async () => { const splitRecipient = Surfnet.newKeypair().publicKey; From f4b31f8e6037e01c2622103377406a665974bf8d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:42:23 +0300 Subject: [PATCH 2/2] Update swift/Examples/PayKitDemo/PayKitDemo/SessionStream.swift Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- swift/Examples/PayKitDemo/PayKitDemo/SessionStream.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swift/Examples/PayKitDemo/PayKitDemo/SessionStream.swift b/swift/Examples/PayKitDemo/PayKitDemo/SessionStream.swift index 08d7a42f4..4b6600aef 100644 --- a/swift/Examples/PayKitDemo/PayKitDemo/SessionStream.swift +++ b/swift/Examples/PayKitDemo/PayKitDemo/SessionStream.swift @@ -146,7 +146,7 @@ enum SessionStream { else { continue } chunks += 1 if let cost = obj["cost"] as? String, let value = UInt64(cost) { - total &+= value + total += value } } return (chunks, total)