Skip to content

feat(payments): persist payment requests for offline - #1166

Closed
bmc08gt wants to merge 3 commits into
feat/chat-pins-contractfrom
feat/persist-payment-requests
Closed

bmc08gt wants to merge 3 commits into
feat/chat-pins-contractfrom
feat/persist-payment-requests

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Payment requests lived only in memory in PaymentRequests, so a chat's request cards were empty after a restart or offline until a refetch came back. This stores each request in the database and reads it before the network.

Stacked on #1165, which takes schemaVersion to 62. Retarget to main once that merges. Android counterpart: code-payments/code-android-app#1886.

  • Schema 62 → 63: new paymentRequest table, one row per request. id, conversationID, version, status, endsAt and createdAt are columns; metadata is the request serialized as PaymentRequestMetadata. The existing requestedPayment table is unchanged.
  • The contract's merge runs in Database.upsertPaymentRequests as INSERT … ON CONFLICT(id) DO UPDATE … WHERE: a row is written when there is none, when the incoming version is greater, or at an equal version when the incoming status is EXPIRED and the stored one is OPEN. A stale write can't overwrite a newer row.
  • Write-through: everything through ingest (chat pages, fetch, refresh, create, close, mark paid, .created updates) is stored, and an applied update stores the whole request at its new version. A failed write is logged and doesn't fail the fetch.
  • Hydrate: refreshChat and refresh(_:) read stored rows before the network call, through the existing merge. A network failure leaves the stored copies showing; a DENIED or NOT_FOUND still marks the card unavailable.
  • Schema 63 → 64: new groupKeyEnvelope table, one row per conversation, holding the GetKeyEnvelopeResponse as fetched. The unwrapped key is never written. On an in-memory miss, GroupKeyEnvelopes.groupKey(for:creator:) opens the stored envelope with the same scheme and wrapper checks as a fetched one, so encrypted request text opens offline; nothing stored, or an envelope that fails a check, falls back to the network. A fetch, storeNewKey or the member's own self-wrapped copy replaces the row. ALREADY_SET, an untrusted wrapper and an unusable key remove it, and so does deleting the conversation.
  • EncryptedChatClient falls back to the stored conversation when it has not seen one this launch, so the seal path reaches the stored envelope offline instead of returning .awaitingKey. The stored copy is used only to open; sends still follow the server's current encryption choice.
  • Private-chat text stays sealed in storage. Split.text is held encrypted and opened at read time, so the stored form is the ciphertext as received.
  • The blob is encoded from the model, not kept byte for byte: an applied update has no received message to keep. Fields the model doesn't carry, such as the payment intent id, aren't stored.
  • A conversation's payment requests are deleted with it: deleteConversation, departed groups in replaceGroupFeed, and conversations replaceConversationFeed no longer returns, in the same transaction as the envelope delete. PaymentRequests.forget(conversations:) drops departed groups' requests from memory. Logout doesn't delete the database, which is one file per owner, matching the other conversation tables.
  • Not stored: unavailable, inFlight, the failure sets, seals and chatRefreshes.
  • Tests: GroupKeyEnvelopeStoreTests (offline open, replace, fallback, one case per invalidation point, key bytes absent from storage), DatabaseGroupKeyEnvelopeTests, EncryptedPaymentRequestOfflineTests (an encrypted request's text opens after a restart with the network down), PaymentRequestDatabaseTests (merge cases, read by chat, blob round trip, sealed text) and PaymentRequestsPersistenceTests (write-through, offline hydrate by chat and by id, applied and stale updates, stale ingest).

Store each request's PaymentRequestMetadata in a new paymentRequest table
(schemaVersion 62 -> 63) so a chat's request cards and details draw offline
and after a restart. The DB upsert applies the contract's merge rule in one
statement: greater version wins, and at an equal version EXPIRED replaces
OPEN. PaymentRequests writes through on ingest and applied updates, and
hydrates stored copies ahead of the network read.
Store each private group's key envelope as the server returned it in a
new groupKeyEnvelope table (schemaVersion 63 -> 64), so encrypted
content opens offline and after a restart. GroupKeyEnvelopes opens the
stored envelope first with the same wrapper and scheme checks as a
fetched one, and falls back to the network when it is missing or fails.
The row follows the in-memory state: replaced by the member's own
envelope after a self-wrap, removed on ALREADY_SET, an untrusted
wrapper, or an invite copy that fails a check, and deleted with the
conversation. The unwrapped key is never written.

EncryptedChatClient opens with the stored conversation when the chat
has not passed through yet, so the seal no longer needs a GetChat.
@bmc08gt

bmc08gt commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

Replaced by #1169.

@bmc08gt bmc08gt closed this Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant