docs: codify paykit issuer contract - #719
Conversation
This comment has been minimized.
This comment has been minimized.
# Conflicts: # Bitkit/Views/PaymentRequests/PaymentRequestsView.swift
jvsena42
left a comment
There was a problem hiding this comment.
Checked the documented contract against what the code actually enforces, and cross-checked the Android port (#1216). The code changes are behaviour-preserving and the doc matches the request gate. I empirically ruled out an expiry-parsing divergence — ISO8601DateFormatter handles 1-9 fractional digits and offsets here.
Two real divergences remain at the endpoint level, which the shared fixture cannot currently see. For the first one, note that iOS matches Docs/paykit-issuer-interoperability.md's two-stage design and Android is the one gating early — so the question is which stage should own the check, not which platform is wrong.
0f6919b to
2d05152
Compare
2d05152 to
ac1468b
Compare
jvsena42
left a comment
There was a problem hiding this comment.
Confirmed ac1468b resolves both threads I opened. The endpoint parser now rejects non-string min/max, and parseEndpoint takes an injectable network: with Env.network as the production default — numeric-min, boolean-max and foreign-network-endpoint are all in the shared fixture.
I diffed the fixture against the Android copy on codex/1208-paykit-interop: byte-identical (sha256 35f671bb…). I also hand-checked the three contract clauses the fixture doesn't cover, and the platforms agree on all of them:
| iOS | Android | |
|---|---|---|
| Amount precision | fraction.count <= 8 after stripping zeros → "0.0000000015" rejected |
toBigIntegerExact() throws → rejected |
| Amount cap | <= UInt64.max / 1000 + per-step overflow guards |
<= ULong.MAX_VALUE / 1000uL |
| Expiry | ISO8601DateFormatter .withFractionalSeconds then fallback |
kotlin.time.Instant.parse |
I ran the Swift parser against 1/2/3/6/9-digit fractional seconds, Z and +02:00 — all accepted, bare no-offset rejected on both sides. Those rules are covered by PaykitPaymentRequestServiceTests rather than the shared fixture, which is a coherent boundary; no reason to grow the fixture.
Two notes for the record, neither needing action here:
- Gating the network at parse means a foreign-network on-chain identifier now yields
.noEndpoint("hasn't shared payment data") instead of.notOpened. Reads better, just flagging the user-visible change. - The
Env.networkguard inisPayableEndpointstill earns its keep —payableEndpoints(from:)accepts endpoints built outsideparseEndpoint(PublicPaykitService.swift:442/:455,PrivatePaykitService+Endpoints.swift:67/:87), andPublicPaykitServiceTests.swift:171-178exercises exactly that. Not dead code.
One genuine cross-PR conflict inline.
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed at caa2484. No HIGH/MEDIUM, nothing new to file.
I treated this as a funds-and-trust-boundary change rather than a docs PR, since it adds an issuer-payload parser and touches SendConfirmationView. The question I cared about: can a hostile or non-conforming issuer payload make the executed payment differ from the confirmed one? It can't. terms.amount.value goes through sats(fromBitcoinAmount:) (unchanged here — digits-only, <=8 decimals, overflow-checked, >0, <= UInt64.max/1000) into amountSats, and SendConfirmationView renders wallet.sendAmountSats ?? invoice.amountSatoshis while performPayment executes the identical expression, after validateIncomingPaymentRequestAmounts re-checks acceptsPaymentAmount and the BOLT11 msat amount. canEditAmount is false for requests, and no normalisation happens after display — display and execution read the same field.
Destination is the same story: the endpoint value is decoded through the existing decode(invoice:) + validateBitcoinAddress + network-mismatch path, and the send uses the same invoice.address/invoice.bolt11 objects the details view renders. The counterparty on ReviewContactRecipient is the local saved contact, not issuer-supplied text.
On the trust boundary specifically — the only issuer strings reaching UI are note (pre-existing, trimmed) and a locally formatted numeric amount, so there's no spoofing surface on the confirmation line. min/max are retained but never read for a payment decision. No issuer field lands in a URL, a path, or a log; PaykitIssuerInterop has no Logger calls at all. No trapping path on hostile input either: try? JSON, as? casts, split(maxSplits: 1) on a non-empty array, and multipliedReportingOverflow/addingReportingOverflow rather than bare arithmetic.
Both pushed fixes are correct, not just present. The network gate at parse (PublicPaykitService.swift:229-230) uses Env.network, which is already LDKNode.Network, so production callers keep their meaning — and I checked the two PubkyService adapter call sites are payment selection, not own-endpoint cleanup, so dropping foreign-network candidates earlier only removes what isPayableEndpoint would have rejected anyway. The non-string min/max guard is right for JSONSerialization output: strings bridge to String, numbers and bools land as NSNumber and fail, NSNull is accepted and decodes to nil — which matches Android's strict isLenient = false decode. The numeric-min/boolean-max fixtures lock both.
The SendConfirmationView diff is reachable on the ordinary non-Paykit send path, so I checked it deliberately: it's a brace reformat plus accessibility modifiers, and .accessibilityElement(children: .contain) doesn't alter hit-testing or the swipe-to-pay gesture. Everything else is dev/QA-facing today (isUIEnabled defaults false).
One behaviour delta already on record: a foreign-network on-chain-only issuer now fails at parse with .noEndpoint rather than later at payability. No persisted format changed, so there's no migration concern from the shipped build.
jvsena42
left a comment
There was a problem hiding this comment.
No findings. Clean at the HIGH/MEDIUM bar.
I reviewed this as a trust-boundary and funds-path change despite the docs: label. Alongside the doc, journey and fixture files, the diff adds PaykitIssuerInterop.swift — a parser for issuer-supplied endpoint payloads feeding the pay path — adds a network gate to PublicPaykitService.parseEndpoint, routes the request gate through the new parser, and edits SendConfirmationView.swift, which runs on the ordinary non-Paykit send path too. That last hunk turns out to be a brace reformat plus .accessibilityElement(children: .contain) and a conditional identifier, and .contain doesn't alter hit-testing or the swipe-to-pay gesture — so no behaviour change for regular sends. Flagging the labelling rather than the commit message: a reviewer skimming by type would file this under docs.
Gating: PaykitFeatureFlags.isUIEnabled = isUIAvailable && UserDefaults.bool("paykitUiEnabled"), default false. The only writer of true is Dev Settings behind the hidden SupportScreen tap. FEATURE_PAYKIT_UI_DISABLED isn't set in any build config, so the code compiles into release, but no release user reaches it. Dev/QA-facing today.
Delta since my last review: exactly one commit, 4d077e4c3 "merge: sync master into paykit interop". git show --cc prints no hunks — no conflict-resolution edits, no evil-merge content — and all nine PR-owned files are blob-identical to the head I last read. The real master-side delta is the HW broadcast fix set (HwSendSignView, HwFundingSigner, AppViewModel toast keys), none of which overlaps this PR; SendConfirmationView's .hardwareSign handoff is untouched on both sides.
Checked and clean:
PaykitIssuerInterop.swiftfails closed on every hostile shape. Empty/whitespace payload, non-UTF-8, malformed JSON, non-object top level, missing/null/non-string/nestedvalue, empty-after-trimvalue, and non-stringmin/maxall reject. Worth noting thetry?onJSONSerializationrejects rather than swallows here, because the guard turns nil into a failure.NSNumber, arrays and dicts are all refused for the bounds. NoCodableinit(from:)defaults exist that could silently substitute a value — which is the fail-open shape that would have mattered.- No issuer value reaches a path, URL or log.
PaykitIssuerInterophas zeroLoggercalls.valuegoes through the existingdecode(invoice:)+validateBitcoinAddress+ network-mismatch path inisPayableEndpointbefore it can be paid.min/maxare retained onEndpointbut never read for any payment decision. - Amount is one value from parse to pay.
sats(fromBitcoinAmount:)is ASCII-digits-only, so-1,+1,1e3, Arabic-Indic digits and leading spaces all reject; at most 8 significant fractional digits after stripping trailing zeros;multipliedReportingOverflow/addingReportingOverflow;> 0; then theUInt64.max / 1000cap.184467440737.09551616rejects on the add. Display and execution use the identicalwallet.sendAmountSats ?? invoice.amountSatoshisexpression,canEditAmountis hardfalsefor an incoming request, andperformPaymentre-runs context and amount validation before proof prep and again before send — exact equality plus, for Lightning, an msat match on the freshly re-parsed BOLT11. The HW path validates the same pair before.hardwareSign. No second independent read anywhere. - Recipient can't be spoofed.
ReviewContactRecipientrenders the locally saved contact'sdisplayNamematched by public key, not issuer text; the non-contact branch renders the decoded invoice address, ellipsised. The only issuer string reaching UI isnote, which is pre-existing, trimmed, single-line, and not on the confirmation view. - The contract doc matches what the code enforces — asset casing, the
18,446,744,073,709,551cap, the eight-fractional-digit rule, expiry semantics, identifier filter/dedupe/order, the rejection list, post-shape usability, and deferral until usable payment details arrive. Each maps to a specific guard; nothing promises validation the code skips. - Journey matches the code — auto-open path, the Show-details step, and the hyphenated
PaymentRequestRow-/PaymentRequestPay-/PaymentRequestDismiss-selectors agreed in the #721 thread. - Fixture covers the hostile cases — raw string, empty, missing/empty/whitespace/numeric
value, numericmin, booleanmax, top-level array, malformed JSON, unsupported and empty identifier lists, foreign-network on-chain identifiers on every network, uppercase asset and identifier, dedupe+filter — and every case is asserted against the production parser, not a double. Amount precision, cap and expiry sit outside the fixture (they're inPaykitPaymentRequestServiceTests); I hand-checked those separately.
Cross-repo parity with synonymdev/bitkit-android#1216: the shared fixture is byte-identical on both heads (sha256 35f671bb…7a2a5e). Network gate at parse, non-string bounds rejection, unknown-key tolerance, identifier filtering and the request gate ordering are all present and same-shaped. Android adds a ^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$ pre-check before the enum match — every enum rawValue satisfies it, so neither side accepts something the other rejects. The amount parsers are differently shaped but agreed on every input traced.
One immaterial divergence, recorded rather than filed: your .whitespacesAndNewlines trim covers Unicode spaces (U+00A0, U+2028, VT/FF), while Android's kotlinx path skips only ASCII whitespace — so a payload led by U+00A0 parses here and is rejected there. It can't alter an amount or recipient and no issuer emits it; worth a fixture case pinning ASCII-only whitespace if you want the two provably identical.
Both of my earlier threads (network gate at parse; non-string bounds) were fixed the way I proposed, so nothing above reopens them.
Closes #713
Description
Codifies the issuer contract Bitkit applies to one-time Paykit Payment Requests:
Paykit protocol and SDK policy remain owned by Paykit.
Linked Issues/Tasks
Preview
N/A — no user-visible changes.
QA Notes
Product journey / regtest evidence
cc85df0e24b54be353a57700429d144b35264c1af97f3de41c503dc52f1e4792at height77318.journeys/payment-requests/issuer-interoperability.xmlrecords the repeatable path with a linked fixture issuer, exact request data, and stable accessibility identifiers.btc, strict JSON endpoint payloads, wrong-network endpoint rejection, and non-string bound rejection.Automated checks
git diff --checkpassed.