Skip to content

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

Merged
bmc08gt merged 3 commits into
feat/chat-pins-contractfrom
feat/persist-payment-requests
Oct 30, 2026
Merged

bmc08gt merged 3 commits into
feat/chat-pins-contractfrom
feat/persist-payment-requests

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

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

Stacked on #1884, which takes Room to 48. Retarget to code/cash once that merges. iOS counterpart: code-payments/code-ios-app#1166.

  • Room 48 → 49 (AutoMigration): new payment_requests table, one row per request. id_hex, chat_id_hex, version, status, ends_at and created_at are columns; request holds the serialized PaymentRequestMetadata.
  • The merge from the contract runs in one INSERT … ON CONFLICT … DO UPDATE … WHERE in PaymentRequestDao.mergeIfNewer: 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 fetch can't overwrite a newer row.
  • Write-through: everything that goes through ingest() (chat pages, fetch, refetch, create, close, mark paid, Created updates) is stored, and an applied Updates stores the whole request at its new version. A failed write is logged and doesn't fail the fetch.
  • Hydrate: loadChat, inChat and request(id) read the stored rows before the network call, through the existing open() and merge. A network failure leaves the stored copies showing.
  • Room 49 → 50: new group_key_envelopes table, keyed by chat and own public key, holding each private group's key envelope as fetched (scheme, nonce, ciphertext, wrapper). The unwrapped key is never written. On an in-memory miss, GroupKeyEnvelopes.key() 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 copy replaces the row. ALREADY_SET, an untrusted envelope, a wrong-key invite bundle and NO_ENVELOPE for a member drop it.
  • Private-chat text stays encrypted in storage. Split keeps the sealed form it was opened from (sealedText), and the encoder writes that, not the decrypted text.
  • Rows are deleted in RealChatCoordinator.clearCache(), alongside the other chat caches, so they follow the same rule as chat_metadata: wiped on account deletion, kept across a plain logout because the DB is per account.
  • Leaving a group, being removed from one, and a feed sync dropping a group all call ChatMetadataDataSource.removeFromChat. In one transaction it clears membership and deletes the chat's group_key_envelopes and payment_requests rows. The chat_metadata row stays, since a left chat can still be shown. Hiding a DM is reversible and deletes nothing.
  • Not stored: missing, inFlight, retry state, and the payment intent id. A share paid by payment reads back as paid by payment.
  • A request of a kind the app doesn't know is stored without its kind and reads back as unsupported, which is how it renders today.
  • Tests: GroupKeyEnvelopesTest (stored open offline, replace, fallback, one case per invalidation point, stored bytes equal the fetched envelope), GroupKeyEnvelopeDaoTest, PaymentRequestOfflineDecryptTest (encrypted request text opens offline after a restart, over the real database and GroupContentCrypto), PaymentRequestDaoTest (merge cases, per-chat listing, delete), PaymentRequestStorageTest (proto round trip including sealed text and unparseable bytes), PaymentRequestsStorageTest (Robolectric with the real DB: offline restart, encrypted text at rest, write-through, stale update and stale fetch, clearStored).

Store each payment request in a payment_requests table (DB 48 -> 49) as
the serialized PaymentRequestMetadata, with id, chat_id_hex, version,
status, ends_at and created_at columns for the merge and per-chat reads.
Private-chat text stays in its encrypted form.

The contract merge runs as one conditional upsert: write when there is
no row, the incoming version is greater, or at an equal version the
incoming copy is EXPIRED over a stored OPEN one.

PaymentRequests writes every ingest and applied update through to the
table and loads a chat's stored requests before its first refetch, so
cards and details draw offline. Rows are erased with the rest of the
account cache on account deletion.
@bmc08gt bmc08gt self-assigned this Oct 29, 2026
@github-actions github-actions Bot added area: network gRPC, connectivity, API, exchange rates type: feature New functionality labels Oct 29, 2026
Store each private group's key envelope as the server returned it, so
GroupContentCrypto can open encrypted content offline and after a
restart. The unwrapped chat key is never written. Room 49 -> 50 adds
group_key_envelopes, keyed by (chat id, own public key).

On an in-memory miss the stored envelope is opened with the same scheme
and wrapper checks as a fetched one; a stored envelope that fails a
check is dropped and the network is used. The row follows the cache:
replaced on fetch, on the member's self-wrapped re-wrap and on
storeNewKey; dropped on an untrusted envelope, wrong-key invite bundle,
NO_ENVELOPE and ALREADY_SET. Account deletion clears it with the other
chat tables.
@bmc08gt
bmc08gt merged commit 4aa1219 into feat/chat-pins-contract Oct 30, 2026
5 checks passed
bmc08gt added a commit that referenced this pull request Oct 30, 2026
* feat(payments): persist payment requests for offline

Store each payment request in a payment_requests table (DB 48 -> 49) as
the serialized PaymentRequestMetadata, with id, chat_id_hex, version,
status, ends_at and created_at columns for the merge and per-chat reads.
Private-chat text stays in its encrypted form.

The contract merge runs as one conditional upsert: write when there is
no row, the incoming version is greater, or at an equal version the
incoming copy is EXPIRED over a stored OPEN one.

PaymentRequests writes every ingest and applied update through to the
table and loads a chat's stored requests before its first refetch, so
cards and details draw offline. Rows are erased with the rest of the
account cache on account deletion.

* feat(chat): persist group key envelopes for offline decryption

Store each private group's key envelope as the server returned it, so
GroupContentCrypto can open encrypted content offline and after a
restart. The unwrapped chat key is never written. Room 49 -> 50 adds
group_key_envelopes, keyed by (chat id, own public key).

On an in-memory miss the stored envelope is opened with the same scheme
and wrapper checks as a fetched one; a stored envelope that fails a
check is dropped and the network is used. The row follows the cache:
replaced on fetch, on the member's self-wrapped re-wrap and on
storeNewKey; dropped on an untrusted envelope, wrong-key invite bundle,
NO_ENVELOPE and ALREADY_SET. Account deletion clears it with the other
chat tables.

* fix(chat): drop a chat's key envelope and payment requests when it is removed
bmc08gt added a commit that referenced this pull request Oct 30, 2026
…ke-land

* origin/code/cash:
  feat(payments): persist payment requests for offline (#1886) (#1892)
  feat(chat): add pin message contract scaffolding (#1884)

# Conflicts:
#	apps/flipcash/shared/persistence/db/src/androidHostTest/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDaoTest.kt
#	apps/flipcash/shared/persistence/db/src/androidHostTest/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDaoTest.kt
#	apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/GroupKeyEnvelopeDao.kt
#	apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/PaymentRequestDao.kt
#	apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/GroupKeyEnvelopeEntity.kt
#	apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/PaymentRequestEntity.kt
#	apps/flipcash/shared/persistence/sources/src/androidHostTest/kotlin/com/flipcash/app/persistence/sources/ChatRemovalDataTest.kt
#	apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/GroupKeyEnvelopeDataSource.kt
#	apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/PaymentRequestDataSource.kt
#	services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/internal/network/extensions/PinMappingTest.kt
#	services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorageTest.kt
#	services/flipcash/src/commonMain/kotlin/com/flipcash/services/chat/GroupKeyEnvelopeStore.kt
#	services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/chat/Pin.kt
#	services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/chat/PinSummaryStorage.kt
#	services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestStorage.kt
#	services/flipcash/test-fixtures/src/commonMain/kotlin/com/flipcash/services/chat/InMemoryGroupKeyEnvelopeStore.kt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: network gRPC, connectivity, API, exchange rates type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant