Skip to content

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

Merged
bmc08gt merged 3 commits into
mainfrom
feat/persist-payment-requests-offline
Oct 30, 2026
Merged

bmc08gt merged 3 commits into
mainfrom
feat/persist-payment-requests-offline

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Replaces #1166, which targeted feat/chat-pins-contract after #1165 had already squash-merged that branch into main. Same three commits, rebased onto main with no changes.

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.

  • 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 self-assigned this Oct 30, 2026
@bmc08gt
bmc08gt merged commit c1248b4 into main Oct 30, 2026
4 checks passed
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