feat: add subscription requests - #736
Conversation
This PR is not safe to merge until subscription creation avoids orphaned public icon uploads and canceled creator subscriptions retain an accessible payment history. Findings
|
| let iconURI: String? = if let iconData = draft.iconData { | ||
| try await sdk.uploadProfileAvatar( | ||
| bytes: Self.compressedSubscriptionIcon(iconData), | ||
| contentType: "image/jpeg", | ||
| expectedIdentity: expectedIdentity | ||
| ) |
There was a problem hiding this comment.
The icon is published before the final expiry check and proposal write. If the draft expires or proposePaymentRequest fails after the upload, the UI reports that no proposal was created but the selected image remains publicly hosted, with no rollback or cleanup path. The upload also separates the initial recipient and payment-rail checks from the final proposal, so unlinking the contact or disabling a rail during the upload can submit stale terms because the final SDK call rechecks only the identity. Revalidate eligibility and endpoint settings immediately before proposing, and remove an uploaded blob after failure or defer publication until the proposal can be committed.
How this was verified: The icon upload is an externally visible write performed before the final expiry check and proposal call, while the later proposal boundary validates the identity but not current recipient eligibility or endpoint settings.
Knowledge Base Used:
There was a problem hiding this comment.
Confirmed that a failed proposal can leave the icon public. The SDK reuses content-based avatar names and can fail after enqueueing, so deleting on every error could break a shared image or a queued proposal. This remains open, including the payment-option recheck after upload.
There was a problem hiding this comment.
Agreed. Because avatar names are content-addressed and an upload may already be referenced by a queued proposal, unconditional cleanup on failure is unsafe. The remaining actionable fix is to revalidate expiry, current recipient eligibility, and accepted payment endpoints immediately after the upload and before constructing/enqueueing the proposal; if any changed, fail without enqueueing rather than using the stale pre-upload snapshot. The orphaned-icon case likely needs an SDK-level ownership/cleanup mechanism to resolve safely, so I’m keeping this finding open for the post-upload validation.
| func isCreatedVisible(at date: Date) -> Bool { | ||
| isCreatedByUser && (isProposalVisible(at: date) || isActive(at: date)) | ||
| } |
There was a problem hiding this comment.
Cancellation Hides Payment History
Canceling a creator-owned subscription makes its retained payment history unreachable. A canceled subscription is neither proposed nor active, so isCreatedVisible removes it from the Created section. The Expired section accepts only payer-side subscriptions, and creator payments are exposed only through receivedPaymentRequests() on the now-hidden detail screen. Deleting an accepted subscription with prior payments therefore removes the user's only route to those payments, contrary to the stated retained-history behavior. Keep canceled creator subscriptions with payments in a historical section or persist their payment rows into accessible history.
Knowledge Base Used: Payment request management
There was a problem hiding this comment.
Canceled or ended creator subscriptions with payments now appear in the Expired section, where their payment history remains accessible. Deleted proposals without payments still disappear.
| .prefix(8) | ||
| .compactMap { Self.trimmed($0, limit: 160) } | ||
| iconURI = Self.trimmed(subscription["icon_uri"] as? String, limit: 512) | ||
| .flatMap { $0.hasPrefix("pubky://") ? $0 : nil } |
There was a problem hiding this comment.
Incoming subscription metadata can reference any pubky:// resource, and the image loader downloads and fully decodes that resource without a byte or pixel limit. A malicious proposer can point icon_uri at an oversized or decompression-heavy public image; opening the subscription list then downloads it, decodes it with UIImage(data:), and caches the raw data, potentially causing excessive memory and storage use. Bind the URI to the proposal creator where appropriate and enforce download and decoded-dimension limits before constructing the image.
How this was verified: The proposal-controlled URI is accepted solely by its scheme, then fetched and decoded at full resolution without a size check before being cached.
Knowledge Base Used: Contacts and Pubky identity
There was a problem hiding this comment.
Confirmed that the loader has no download or decoded-image limit. The pinned SDK returns the full response before the app can inspect it, so an app-side size check alone would not cap the download. This remains open pending a bounded fetch path.
| "subscriptions__choose_recipient" = "Choose Recipient"; | ||
| "subscriptions__create" = "Create"; | ||
| "subscriptions__content_too_long" = "Shorten the subscription name or description and try again."; | ||
| "subscriptions__icon_error" = "Could not load this image. Choose another image and try again."; | ||
| "subscriptions__create_subscription" = "Create Subscription"; | ||
| "subscriptions__created" = "Created"; | ||
| "subscriptions__created_summary" = "1 subscriber · {count} payments"; | ||
| "subscriptions__created_summary_single_payment" = "1 subscriber · 1 payment"; | ||
| "subscriptions__custom_icon" = "Icon"; | ||
| "subscriptions__custom_icon_description" = "Tap to upload a custom icon"; | ||
| "subscriptions__delete_subscription" = "Delete Subscription"; | ||
| "subscriptions__description" = "Description"; | ||
| "subscriptions__description_placeholder" = "What is this subscription for?"; | ||
| "subscriptions__name" = "Subscription Name"; | ||
| "subscriptions__name_placeholder" = "Subscription name"; | ||
| "subscriptions__pending" = "Pending"; | ||
| "subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically."; | ||
| "subscriptions__proposal_queued_headline" = "Queued\n<accent>Proposal</accent>"; | ||
| "subscriptions__proposal_queued_title" = "Queued"; | ||
| "subscriptions__proposal_queued_status" = "Proposal queued"; | ||
| "subscriptions__proposal_sent_description" = "You have sent a subscription proposal to"; | ||
| "subscriptions__proposal_sent_headline" = "Sent\n<accent>Proposal</accent>"; | ||
| "subscriptions__proposal_sent_status" = "Proposal sent"; | ||
| "subscriptions__propose_subscription" = "Propose Subscription"; | ||
| "subscriptions__subscribers" = "Subscribers"; | ||
| "subscriptions__swipe_to_delete" = "Swipe To Delete"; |
There was a problem hiding this comment.
Translations Contain English Placeholders
The new subscription strings were copied as English values into every non-English localization file instead of going through the repository's translation workflow. Because validation checks key presence rather than translated content, these placeholders silently pass CI and become indistinguishable from completed translations. This leaves the entire flow untranslated and prevents missing-key warnings from tracking the work. Keep only the English source strings until genuine translations are pulled, or add actual translations for each locale. The same pattern appears in the Arabic, Catalan, Czech, German, Greek, Latin American Spanish, Spanish, Italian, Dutch, Polish, Brazilian Portuguese, Portuguese, and Russian localization files.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Removed the new English placeholders from the non-English files. The flow uses the existing English fallback until translations arrive, and missing-translation warnings can track those keys again.
Description
This PR adds subscription proposals to contacts, building on the payer flow merged in #685.
Discover, autopay and renewal UI are intentionally excluded. Icon hosting is public by design. Adds one changelog fragment.
Base:
master, including the merged payer PR. No additional unmerged branch dependency.Linked Issues/Tasks
Screenshot / Video
Recordings are silent at 4× speed using test wallets. All attached media was inspected and shows only Bitkit, including its keyboard; no device home screen, photo picker or other app is included.
Final keyboard and recipient behavior:
ios-keyboard-fix-4x.mp4
Final recipient and keyboard screenshots
Earlier create → recipient → sent → overview walkthrough
This walkthrough predates the final keyboard and recipient sizing/spacing corrections. The recording and screenshots above show those final corrections.
ios-figma-audit-4x.mp4
QA Notes
Manual Tests
Live regtest creation/delivery/acceptance/manual on-chain payment, public icon transfer and cancellation passed in both directions with Android. Latest-build confirmation, pending deletion and restart also passed. Offline draft retention/retry was tested on Android. Live Lightning, mainnet and production push were not tested.
Automated Checks
PaykitSubscriptionProposalTests.swiftcovers UTF-8 wire boundaries, escaped strings, reserved icon space and image downsampling/errors.PaykitPaymentRequestServiceTests.swiftcovers creator lifecycle, validation before upload/enqueue and proof aggregation, including fractional billing instants.DEBUG E2E_BUILD,E2E_BACKEND=network,E2E_NETWORK=regtest; SwiftFormat lint passed for the touched Swift files. This was a focused test run, not the entire iOS suite.