diff --git a/CLAUDE.md b/CLAUDE.md index 4c6948e..6295ef8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,12 +100,12 @@ Each package represents a bounded context with clear responsibilities: **Core Services (gRPC Servers):** - `account/` - User registration, login, public key management, user flags (flags also carry the `send/` presets to clients as `SendPresets`) -- `activity/` - Activity feed for payments, deposits, withdrawals, gift cards. A DM or group payment names the other party by user ID (`intent.IsUserChatPayment` gates both counterparty resolvers), so the payer's entry reads Sent (or Tipped) and the recipient's Received; a group payment to several members (a `SendPublicMultiPayment`) reads Sent too, naming no one, since the payer's record is the total with no counterparty, and each recipient's reads Received naming the payer; a reaction boost is one too: the payer's entry reads Boosted, naming the payee when one is paid (a `SendPublicPayment`) and no one when two are (a `SendPublicMultiPayment`, whose payer record is the total with no counterparty), and each payee's reads Received naming the payer; a requested payment (a debtor paying their share of a payment request, `intent.GetRequestedPayment`) reads Sent for the payer naming the creator and Received for the creator naming the payer; a contact DM payment names them by phone number; a payment with no chat metadata (handed off in person) names no one, and the payer's entry reads Gave. The feed carries no chat reference except a boost's: both sides' entries carry `boosted_reaction` (chat, message, emoji; `toBoostedReaction`, off the intent's app metadata, as paid and never rechecked against the chat) +- `activity/` - Activity feed for payments, deposits, withdrawals, gift cards. A DM or group payment names the other party by user ID (`intent.IsUserChatPayment` gates both counterparty resolvers), so the payer's entry reads Sent (or Tipped) and the recipient's Received; a group payment to several members (a `SendPublicMultiPayment`) reads Sent too, naming no one, since the payer's record is the total with no counterparty, and each recipient's reads Received naming the payer; a reaction boost is one too: the payer's entry reads Boosted, naming the payee when one is paid (a `SendPublicPayment`) and no one when two are (a `SendPublicMultiPayment`, whose payer record is the total with no counterparty), and each payee's reads Received naming the payer; a requested payment (a debtor paying their share of a payment request, `intent.GetRequestedPayment`) reads Sent for the payer naming the creator and Received for the creator naming the payer; a contact DM payment names them by phone number; a payment with no chat metadata (handed off in person) names no one, and the payer's entry reads Gave. The feed carries no chat reference except a boost's and a requested payment's: both sides' entries for a boost carry `boosted_reaction` (chat, message, emoji; `toBoostedReaction`), and both sides' for a requested payment carry `requested_payment` (the request's group and ID; `toRequestedPayment`), the two cases of the metadata's `context` oneof, each off the intent's app metadata, as paid and never rechecked against the chat or the request (one may since be closed or expired, and a party who has left the group is denied the request on fetch) - `chat/` - Group/DM chat metadata, membership, feeds (DynamoDB-backed). See "Chat, messaging and events" below. - `messaging/` - Message persistence, delivery/read pointers, reactions and reaction boosts (see "Reaction boosts" below), typing notifications (DynamoDB-backed); `sender.go` is the transport-free engine for server-initiated messages (used by `task/` and `flipcashteam/`; `SendBatch` sends several atomically over `PutMessages`, one broadcast, a push per message; `Send` is a batch of one; `NewSender`'s required `teamUserID` (nil for none) makes the team account write-only: its sends don't advance its READ pointer, its DMs are created with it excluded from the feed (the chat store is built excluding the team) so no send moves its inbox row, and `publishChatUpdate` never delivers to it, neither events nor pushes, with a user's message to it skipping push preparation entirely; the push path drops it only after deriving the DM type from the full pair; and nobody messages it: a DM with it carries a `Never` speaker rule (see `chat.RuleEvaluator` under "Rules"), so `Server.canSpeak` refuses every send, edit, deletion and typing notification in one, whoever asks, the team included, and only the `Sender`, which no rule gates, writes there; DM and group payments to or from it are refused at intent validation, so no payment message lands either, and reactions, which gate on `isMember`, are unaffected) - `blob/` - User-uploaded media (images only today). Two S3 buckets (upload → origin) plus CloudFront signed download URLs; the server never proxies bytes. Flow: `GetUploadPolicy` → `InitiateExternalUpload` → client uploads → `CompleteExternalUpload` (queues finalization, returns PROCESSING) → poll `GetBlobs`. `finalizer.go` owns the checkpointed pipeline (inspect → moderate → copy → WebP renditions). Reads: owner always; others need an `AccessContext` whose principal (User, Chat, UserProfile, ChatProfile) holds a grant *and* covers the caller (`access.go`). `image.go` rejects EXIF/privacy metadata. `integration.go` (`ShareIntoChat`, `SetAsProfileMedia`, `SetAsChatMedia` (a group's profile or cover picture: one surface, granted to the chat and the chat-profile principals), `ResolveRenditions`) is the API other domains use. **End-to-end encrypted blobs** (`encrypted.go`; `Blob.EncryptedFor` is a `*Principal` — the surface the READY grant is made to, `PrincipalForChat` for a DM — so the pipeline, stores (`encrypted_for_type` N + `encrypted_for_id` B) and read paths are surface-agnostic and only `initiateEncryptedUpload` maps a proto arm to a principal and its admission gate; `ContentKindEncrypted`, its own finalization queue and so its own `Worker` instance in the parent) are reserved with `InitiateExternalUploadRequest.end_to_end_encrypted_for.chat`: mime must be `application/octet-stream`, size ≤ `MaxEncryptedBlobSizeBytes` (the largest plaintext ceiling across encryptable kinds, today the image one; no allowance for the nonce and tag), and the chat must take encrypted content from the caller (`blob.EncryptedUploadGate`, satisfied by `chat.NewBlobEncryptedUploadGate` over the shared `chat.Access`: an explicit `Access.IsMember` check, then `Access.SpeakerStanding(...).takesEncrypted()`, i.e. the caller is a member who may speak there and the chat takes encrypted content, a **DM** or a **private group that has its key**; the chat package owns the discriminator check and the rule) or the reservation is `DENIED`. Finalization (`finalizeEncrypted`; the `Finalizer` takes the `AccessStore` for this) checks only the declared size (`TOO_LARGE` otherwise), promotes, **grants the chat read access, then** reaches READY, so READY implies granted and a rejected blob is never granted: no inspection, moderation or renditions. Before READY the other members see nothing through `AccessContext.chat` (a reference that outruns READY reads as missing, which is why the sender waits for READY before sending). Metadata carries `kind.encrypted`. Every attach surface refuses one (`validateAttachable`) and `ResolveRenditions` omits it; only `EncryptedContent` in that chat, which the server never reads, references it. Policy always advertises `UploadPolicy.encrypted`; its image bounds (1600px) are advisory by construction. - `poll/` - Polls in group chats, public or private (`pollpb.Poll`: `CreatePoll`, `GetPoll`, `GetPolls`, `Vote`). A poll is shown in its chat as a message carrying a `messagingpb.PollWidget` (the poll's ID alone). ID derived like a group's (`MustDerivePollID`: truncated, domain-separated SHA-256 of creator + `IdempotencyKey`, UUIDv8-stamped), so a retried `CreatePoll` is answered from the stored poll whatever it carries. **Gates (all via the shared `chat.Access`)**: create = `SpeakerStanding` (whose encryption mode also decides the text: plaintext in a public group, `EncryptedPollText` under the chat key in a keyed private group); see = `CanListen` (members, plus a public group's non-members who satisfy its listener rules; a private group's members only); vote = `IsMember` alone, never the speaker rules. **Unsent polls**: a public group's poll is created unsent (`Poll.Live` false), its creator's alone (`NOT_FOUND` to everyone else, no votes), and goes live on its first send (`poll.PrepareSend`, called by messaging before the message is written: only its creator, in its chat, while open), and **is sent at most once**: going live records the send's client message ID on the poll (`Poll.ClientMessageID`, DynamoDB `cmid`, written by `MarkLive`, which refuses a poll live by any other send with `ErrPollAlreadyLive`, so one of racing sends wins), and only a retry of that send passes again, within `SendRetryWindow` (`MaxDuration`) of creation and whether or not the poll is still open, which messaging answers with the original message or, after a send that failed past `MarkLive`, writes the one message; this rests on messaging keeping a client message ID at least that long (`cmidTTL`, pinned by a test), and a deleted poll message is not replaced; one never sent is discarded at its end (TTL `expires_at`, and every read drops it). A private group's poll is live from creation, since its widget travels inside encrypted content. Plaintext is moderated as **one** `ClassifyText` text (question then each option, a line each; any error incl. unsupported language fails the RPC; flagged → `TEXT_MODERATED`). A `BALANCE` poll's mint must be the core mint or an OCP currency in state available, and its currency one with `send/` presets, both checked at creation (`poll/currencies.go`) so no tally can be stuck on parameters it cannot value. **Closing is by time, not a seal** (decided 2026-10-07): a vote is one write of the voter's own `poll_votes` item (never a transaction on the poll item, which would make every concurrent vote in a big group a transaction conflict), accepted while the server's clock is before `ends_at`, with its write abandoned `VoteWriteTimeout` (10s) after that read; the tally takes the poll up `TallyDelay` (1 min) after its end, so every accepted vote has landed; the margin covers clock skew. Only open and closed are stored; TALLYING is the clock's (from `ends_at` until results), and `closed_at` is `ends_at`. **Storage (`poll/dynamodb`)**: `polls` (one item per poll, the definition and results as marshalled protos) and `poll_votes` (pk poll, sk voter); the tally queue is a sparse GSI on `polls` modelled on blob finalization's (`tally_queue`/`tally_due_at`/`tally_attempts`, one unsharded partition; going live enqueues, `Close` dequeues in the same write; the backlog gauges read only the due range, one page capped at `MaxTallyQueueStatsDepth`, so they cost the backlog, not the open polls). **Tally (`poll/tally.go`, `poll.Worker`)**: claims a due poll, counts every vote with no membership read (only a member can vote, so a vote was a member's when cast and counts even if they have since left; decided 2026-10-07), and closes it; `COUNT` shows its totals however few voted (never `WithheldResults`; the voter minimum was removed 2026-10-13, which the proto leaves to the server); `BALANCE` reads each voter's holdings with `balance.GetMintHoldingsForUsers` at tally time and records exact totals, both the mint's own quarks and the value in the poll's currency (ranking is on the quarks, exact where a sum of float values is not), never withheld. Those `BalanceResults` are what is stored, but no reader is sent them: `ResultsProto` (`poll/model.go`) shows a BALANCE poll's results as `RankedResults` alone (each option's competition rank, ties sharing one, and `total_voters`, every voter including those holding none of the mint, recorded on `BalanceResults.total_voters`; no amounts, no per-option voter counts), which is why the totals need no coarsening or voter minimum (removed 2026-10-10). A failed tally is retried under backoff (capped at 10 min) **forever**: results are final, never guessed. No event announces a close; clients refetch after `ends_at`. -- `paymentrequest/` - Payment requests in group chats (`paymentrequestpb.PaymentRequest`: `CreatePaymentRequest`, `GetPaymentRequest`, `GetChatPaymentRequests`, `ClosePaymentRequest`, `MarkPaid`): a creator asks named members (debtors) each to pay them a share in one fiat currency, a **split**, the only type today; the proto is layered (`PaymentRequestMetadata.kind` → `GroupChatPaymentRequest{chat_id}.type` → `Split`), and the Go model holds one field per arm (`ChatID`, `Split`). Shown in its chat as a message carrying a `messagingpb.PaymentRequestWidget` (the ID alone; see "Widget content"), live from creation, sendable by its creator in its chat any number of times. ID derived like a poll's (`MustDeriveID`, creator + `IdempotencyKey`), so a retried create is answered from the stored request whatever it carries. The split's title and description are plaintext in a public group (moderated as **one** `ClassifyText` text, title then description; any error incl. unsupported language fails the RPC; flagged → `TEXT_MODERATED`) and `EncryptedPaymentRequestText` under the chat key in a keyed private group (`textVerdict`, as a poll's); **everything else is plaintext on the server** (currency, `ends_at`, the shares and who paid), since it checks each payment against its share and decides completion. **Gates (via the shared `chat.Access`)**: create = `SpeakerStanding` (a DM `CHAT_NOT_SUPPORTED` by ID length before any read; a missing chat `DENIED`, the proto has no NOT_FOUND for creation) behind **two transitional gates, both decided 2026-10-20, each removed by deleting its check**: the caller must be **staff** (`canCreate`, `DENIED` first, like `canCreatePrivateGroup`), and a **public group is refused outright** whoever asks (`CHAT_NOT_SUPPORTED` on a plaintext standing; the plaintext path is built and tested behind the test-only `WithPublicGroupRequests` option, so opening public groups changes nothing else); see and list = `CanListen`; close and mark-paid = the recorded creator alone, whatever their standing now. Create also requires `ends_at` after now and at most `MaxDuration` (4 weeks) away (`INVALID_ENDS_AT`; the bound also keeps every derived Unix-nanos key, the expiry due time and `open_sort`, from overflowing), a currency with `send/` presets (`INVALID_CURRENCY`), and valid shares (`INVALID_SHARES`: no `paid` set, distinct users, at least one share naming someone but the creator, each amount a whole number, at least one, of the currency's minimum transfer value to within a few ulps (`isWholeMinorUnits`), so a payment's half-unit window holds exactly one whole-unit amount, the share, and every other debtor a current member, checked in **one eventually consistent** `GetGroupMembersByID` batch, accepted for a validation since the proto lets a share name someone who was a member at creation and a just-joined member refused by a stale read retries); the creator's own share, if listed, is paid from creation (`PaidShare.Creator`, `paid_at = created_at`; `NewSplitRequest`). **Payment intent IDs**: `NewSplitRequest` draws 32 random bytes for every other share (`PaymentRequest.PaymentIntentIDs`, keyed by debtor, never changed, never projected by `ToProto`); `GetPaymentRequest` gives a caller holding an unpaid share their own (`payment_intent_id`) and no one else, and a payment of the share must use it. Random and stored rather than derived, since an ID anyone could compute could be used up by someone else first, leaving the share unpayable. **Status and version** sit directly on `PaymentRequestMetadata` (`status`, a top-level `PaymentRequestStatus`; `closed_at`; `version`; there is no separate state message, since `version` versions the whole message, shares' `paid` included): `version` starts at 1 and moves by exactly one per change (a share paid, by payment or by hand; a close), no-ops leave it alone; status moves OPEN → COMPLETED (last share paid, `closed_at` = that `paid_at`) / CLOSED (creator, `closed_at` = now) / EXPIRED (`closed_at` = `ends_at`). **Expiry is the clock's**: every reader derives it (`StatusAt(now)`: stored open past `ends_at` reads EXPIRED, moving no version), `Close` of a clock-expired request is the no-op, a manual `MarkPaid` needs OPEN as of now (`NOT_OPEN`; an already-paid share is the OK no-op whatever the status), but a payment's `PaidShare.Intent` is recorded **whatever the status** (validated while open; a close or expiry landing in between must not lose money that moved), and **replaces a hand mark** made while it was in flight (`PaidShareSupersedes`: only `Manual` → `Intent`; nothing replaces a payment or the creator's share, so the first payment stands; status and `closed_at` unchanged). `MarkPaid` posts no message. `GetChatPaymentRequests` pages one `Section`: OPEN by `ends_at` asc, RESOLVED by `closed_at` desc, each a key range on its own sparse GSI; page size capped at 100, `limit+1` for `has_more`, token `[version][section][chat ID][sort nanos][request ID]` bound to chat and section and refused elsewhere; an OPEN page drops requests expired by the clock and reads on until it fills. **Storage (`paymentrequest/dynamodb`)**: one table `payment_requests_v2` (pk `req#`; v1 keyed on the raw binary ID and a key schema cannot change, so the prefix took a new table; sk `#meta` for the request's own item, so a future type can keep child items in the request's partition; a `type` N attribute (split = 1) that every reader checks first, refusing an unknown or missing one rather than reading it as a split; `details` the type's message marshalled whole (the `Split` for a split; one attribute for every type, so `type` alone says which message it holds), shares' `paid` rewritten on each payment; `intent_ids` a map of hex debtor ID to intent ID, written at creation only; `created_at` and `closed_at` epoch **nanos**, since `Validate` compares the creator share's `paid_at` with `CreatedAt` exactly), every mutation a read then one `UpdateItem` conditioned on **`version` and `status`** (status too, because `Expire` moves no version), retried from the returned item; sparse GSIs `by_chat_open` (`chat`, `open_sort` = sign-flipped `ends_at` nanos ‖ ID, present only while open) and `by_chat_resolved` (`chat`, `resolved_sort` = `closed_at` nanos ‖ ID, present only once final), swapped in the finalizing write so a request is in exactly one, and the `expiry_queue` GSI modelled on the poll tally queue (`expiry_queue`/`expiry_due_at`/`expiry_attempts`; create enqueues, every finalizing write dequeues). **Expiry worker (`paymentrequest/worker.go`, `paymentrequest.Worker`)**: takes a request up `ExpireDelay` (1 min) after its `ends_at`, claims it, re-reads it strongly consistent, and stores EXPIRED (`Store.Expire`: `closed_at` = `ends_at`, version unchanged, dequeued; a no-op on a request already final); it exists only to move the request from the open index to the resolved one, so a slow worker delays the RESOLVED listing and nothing a reader sees; failures retried under backoff (capped at 10 min) forever. No pushes to debtors, no events (clients refetch, e.g. when showing the widget), no edits (shares fixed at creation), no batch getter. Paying a share is the intent integration's and the task's (see both above); the activity feed shows the payer Sent naming the creator and the creator Received naming the payer. +- `paymentrequest/` - Payment requests in group chats (`paymentrequestpb.PaymentRequest`: `CreatePaymentRequest`, `GetPaymentRequest`, `GetChatPaymentRequests`, `ClosePaymentRequest`, `MarkPaid`): a creator asks named members (debtors) each to pay them a share in one fiat currency, a **split**, the only type today; the proto is layered (`PaymentRequestMetadata.kind` → `GroupChatPaymentRequest{chat_id}.type` → `Split`), and the Go model holds one field per arm (`ChatID`, `Split`). Shown in its chat as a message carrying a `messagingpb.PaymentRequestWidget` (the ID alone; see "Widget content"), live from creation, sendable by its creator in its chat any number of times. ID derived like a poll's (`MustDeriveID`, creator + `IdempotencyKey`), so a retried create is answered from the stored request whatever it carries. The split's title and description are plaintext in a public group (moderated as **one** `ClassifyText` text, title then description; any error incl. unsupported language fails the RPC; flagged → `TEXT_MODERATED`) and `EncryptedPaymentRequestText` under the chat key in a keyed private group (`textVerdict`, as a poll's); **everything else is plaintext on the server** (currency, `ends_at`, the shares and who paid), since it checks each payment against its share and decides completion. **Gates (via the shared `chat.Access`)**: create = `SpeakerStanding` (a DM `CHAT_NOT_SUPPORTED` by ID length before any read; a missing chat `DENIED`, the proto has no NOT_FOUND for creation) behind **two transitional gates, both decided 2026-10-20, each removed by deleting its check**: the caller must be **staff** (`canCreate`, `DENIED` first, like `canCreatePrivateGroup`), and a **public group is refused outright** whoever asks (`CHAT_NOT_SUPPORTED` on a plaintext standing; the plaintext path is built and tested behind the test-only `WithPublicGroupRequests` option, so opening public groups changes nothing else); see and list = `CanListen`; close and mark-paid = the recorded creator alone, whatever their standing now. Create also requires `ends_at` after now and at most `MaxDuration` (4 weeks) away (`INVALID_ENDS_AT`; the bound also keeps every derived Unix-nanos key, the expiry due time and `open_sort`, from overflowing), a currency with `send/` presets (`INVALID_CURRENCY`), and valid shares (`INVALID_SHARES`: no `paid` set, distinct users, at least one share naming someone but the creator, each amount a whole number, at least one, of the currency's minimum transfer value to within a few ulps (`isWholeMinorUnits`), so a payment's half-unit window holds exactly one whole-unit amount, the share, and every other debtor a current member, checked in **one eventually consistent** `GetGroupMembersByID` batch, accepted for a validation since the proto lets a share name someone who was a member at creation and a just-joined member refused by a stale read retries); the creator's own share, if listed, is paid from creation (`PaidShare.Creator`, `paid_at = created_at`; `NewSplitRequest`). **Payment intent IDs**: `NewSplitRequest` draws 32 random bytes for every other share (`PaymentRequest.PaymentIntentIDs`, keyed by debtor, never changed, never projected by `ToProto`); `GetPaymentRequest` gives a caller holding an unpaid share their own (`payment_intent_id`) and no one else, and a payment of the share must use it. Random and stored rather than derived, since an ID anyone could compute could be used up by someone else first, leaving the share unpayable. **Status and version** sit directly on `PaymentRequestMetadata` (`status`, a top-level `PaymentRequestStatus`; `closed_at`; `version`; there is no separate state message, since `version` versions the whole message, shares' `paid` included): `version` starts at 1 and moves by exactly one per change (a share paid, by payment or by hand; a close), no-ops leave it alone; status moves OPEN → COMPLETED (last share paid, `closed_at` = that `paid_at`) / CLOSED (creator, `closed_at` = now) / EXPIRED (`closed_at` = `ends_at`). **Expiry is the clock's**: every reader derives it (`StatusAt(now)`: stored open past `ends_at` reads EXPIRED, moving no version), `Close` of a clock-expired request is the no-op, a manual `MarkPaid` needs OPEN as of now (`NOT_OPEN`; an already-paid share is the OK no-op whatever the status), but a payment's `PaidShare.Intent` is recorded **whatever the status** (validated while open; a close or expiry landing in between must not lose money that moved), and **replaces a hand mark** made while it was in flight (`PaidShareSupersedes`: only `Manual` → `Intent`; nothing replaces a payment or the creator's share, so the first payment stands; status and `closed_at` unchanged). `MarkPaid` posts no message. `GetChatPaymentRequests` pages one `Section`: OPEN by `ends_at` asc, RESOLVED by `closed_at` desc, each a key range on its own sparse GSI; page size capped at 100, `limit+1` for `has_more`, token `[version][section][chat ID][sort nanos][request ID]` bound to chat and section and refused elsewhere; an OPEN page drops requests expired by the clock and reads on until it fills. **Storage (`paymentrequest/dynamodb`)**: one table `payment_requests_v2` (pk `req#`; v1 keyed on the raw binary ID and a key schema cannot change, so the prefix took a new table; sk `#meta` for the request's own item, so a future type can keep child items in the request's partition; a `type` N attribute (split = 1) that every reader checks first, refusing an unknown or missing one rather than reading it as a split; `details` the type's message marshalled whole (the `Split` for a split; one attribute for every type, so `type` alone says which message it holds), shares' `paid` rewritten on each payment; `intent_ids` a map of hex debtor ID to intent ID, written at creation only; `created_at` and `closed_at` epoch **nanos**, since `Validate` compares the creator share's `paid_at` with `CreatedAt` exactly), every mutation a read then one `UpdateItem` conditioned on **`version` and `status`** (status too, because `Expire` moves no version), retried from the returned item; sparse GSIs `by_chat_open` (`chat`, `open_sort` = sign-flipped `ends_at` nanos ‖ ID, present only while open) and `by_chat_resolved` (`chat`, `resolved_sort` = `closed_at` nanos ‖ ID, present only once final), swapped in the finalizing write so a request is in exactly one, and the `expiry_queue` GSI modelled on the poll tally queue (`expiry_queue`/`expiry_due_at`/`expiry_attempts`; create enqueues, every finalizing write dequeues). **Expiry worker (`paymentrequest/worker.go`, `paymentrequest.Worker`)**: takes a request up `ExpireDelay` (1 min) after its `ends_at`, claims it, re-reads it strongly consistent, and stores EXPIRED (`Store.Expire`: `closed_at` = `ends_at`, version unchanged, dequeued; a no-op on a request already final); it exists only to move the request from the open index to the resolved one, so a slow worker delays the RESOLVED listing and nothing a reader sees; failures retried under backoff (capped at 10 min) forever. No pushes to debtors, no events (clients refetch, e.g. when showing the widget), no edits (shares fixed at creation), no batch getter. Paying a share is the intent integration's and the task's (see both above); the activity feed shows the payer Sent naming the creator and the creator Received naming the payer, both entries carrying `requested_payment` (the request's group and ID). - `blocklist/` - Per-user block lists. `chat/` must not import it, so `blocklist/chat.go` adapts the store to `chat.BlocklistReader`; messaging uses `GetBlockers` to filter DM event fan-out. - `contact/` - Contact list sync (hashed phone numbers, XOR-of-SHA256 checksums, streaming delta/full uploads); maps contacts to Flipcash users and their DM chat IDs - `event/` - Real-time event streaming with bidirectional gRPC streams. See below. diff --git a/activity/counterparty_test.go b/activity/counterparty_test.go index c9b806a..9323cdf 100644 --- a/activity/counterparty_test.go +++ b/activity/counterparty_test.go @@ -1,6 +1,7 @@ package activity import ( + "bytes" "context" "testing" "time" @@ -34,6 +35,11 @@ type counterpartyEnv struct { // boostChatID is the group reactionBoost was paid in. boostChatID *commonpb.ChatId + + // requestChatID and requestID name the payment request requestedPayment + // paid a share of, and its group. + requestChatID *commonpb.ChatId + requestID *paymentrequestpb.PaymentRequestId } func newCounterpartyEnv(t *testing.T) *counterpartyEnv { @@ -64,11 +70,13 @@ func newCounterpartyEnv(t *testing.T) *counterpartyEnv { GroupPayment: &intentpb.ChatMetadata_GroupPayment{Action: intentpb.PaymentAction_SEND}, }, }) + e.requestChatID = chat.MustGenerateGroupChatID() + e.requestID = &paymentrequestpb.PaymentRequestId{Value: bytes.Repeat([]byte{7}, 16)} e.requestedPayment = chatAppMetadata(t, &intentpb.ChatMetadata{ - ChatId: chat.MustGenerateGroupChatID(), + ChatId: e.requestChatID, Type: &intentpb.ChatMetadata_RequestedPayment_{ RequestedPayment: &intentpb.ChatMetadata_RequestedPayment{ - PaymentRequestId: &paymentrequestpb.PaymentRequestId{Value: make([]byte, 16)}, + PaymentRequestId: e.requestID, }, }, }) @@ -112,7 +120,7 @@ func chatAppMetadata(t *testing.T, chatMetadata *intentpb.ChatMetadata) []byte { // TestHistoryCounterparties: a group payment names the other party by user // ID from both sides, and reads Sent and Received, exactly as a DM payment // does, and so does a requested payment, from its payer to the request's -// creator. A reaction boost paying one person names the other party the same +// creator, whose entries on both sides also name the request. A reaction boost paying one person names the other party the same // way, and the payer's side reads Boosted. A payment carrying no chat // metadata, like one handed off in person, names no one, and the payer's side // reads Gave. @@ -127,13 +135,14 @@ func TestHistoryCounterparties(t *testing.T) { appMetadata []byte named bool boosted bool + requested bool sentText string }{ - {"group_payment", e.groupPayment, true, false, "Sent"}, - {"requested_payment", e.requestedPayment, true, false, "Sent"}, - {"dm_payment", e.dmPayment, true, false, "Sent"}, - {"reaction_boost", e.reactionBoost, true, true, "Boosted"}, - {"no_chat_metadata", nil, false, false, "Gave"}, + {"group_payment", e.groupPayment, true, false, false, "Sent"}, + {"requested_payment", e.requestedPayment, true, false, true, "Sent"}, + {"dm_payment", e.dmPayment, true, false, false, "Sent"}, + {"reaction_boost", e.reactionBoost, true, true, false, "Boosted"}, + {"no_chat_metadata", nil, false, false, false, "Gave"}, } { t.Run(tc.name, func(t *testing.T) { records := []*history.Record{ @@ -159,6 +168,13 @@ func TestHistoryCounterparties(t *testing.T) { require.Nil(t, sent.GetDirectlySentCrypto().GetBoostedReaction()) require.Nil(t, received.GetReceivedCrypto().GetBoostedReaction()) } + if tc.requested { + e.requireRequestedPayment(t, sent.GetDirectlySentCrypto().GetRequestedPayment()) + e.requireRequestedPayment(t, received.GetReceivedCrypto().GetRequestedPayment()) + } else { + require.Nil(t, sent.GetDirectlySentCrypto().GetRequestedPayment()) + require.Nil(t, received.GetReceivedCrypto().GetRequestedPayment()) + } if tc.named { require.True(t, proto.Equal(e.recipient, sent.GetDirectlySentCrypto().GetUserId())) require.True(t, proto.Equal(e.payer, received.GetReceivedCrypto().GetUserId())) @@ -277,10 +293,12 @@ func TestHistoryCounterparties_GroupMultiPayment(t *testing.T) { require.NotNil(t, notifications[0].GetDirectlySentCrypto()) require.Nil(t, notifications[0].GetDirectlySentCrypto().GetDestinationIdentifier()) require.Nil(t, notifications[0].GetDirectlySentCrypto().GetBoostedReaction()) + require.Nil(t, notifications[0].GetDirectlySentCrypto().GetRequestedPayment()) require.Equal(t, "Received", notifications[1].LocalizedText) require.True(t, proto.Equal(e.payer, notifications[1].GetReceivedCrypto().GetUserId())) require.Nil(t, notifications[1].GetReceivedCrypto().GetBoostedReaction()) + require.Nil(t, notifications[1].GetReceivedCrypto().GetRequestedPayment()) } // requireBoostedReaction checks that a notification names the reaction @@ -292,3 +310,13 @@ func (e *counterpartyEnv) requireBoostedReaction(t *testing.T, boosted *activity require.EqualValues(t, 1, boosted.GetMessageId().GetValue()) require.Equal(t, "🔥", boosted.GetEmoji().GetValue()) } + +// requireRequestedPayment checks that a notification names the payment +// request e.requestedPayment paid a share of, and that it is valid as the +// proto defines it. +func (e *counterpartyEnv) requireRequestedPayment(t *testing.T, requested *activitypb.RequestedPayment) { + require.NotNil(t, requested) + require.NoError(t, requested.Validate()) + require.True(t, proto.Equal(e.requestChatID, requested.GetChatId())) + require.True(t, proto.Equal(e.requestID, requested.GetPaymentRequestId())) +} diff --git a/activity/history.go b/activity/history.go index e0c4381..6831f3a 100644 --- a/activity/history.go +++ b/activity/history.go @@ -124,7 +124,13 @@ func toNotification( UserId: userID, } } - metadata.BoostedReaction = toBoostedReaction(record.AppMetadata) + // A payment carries at most one chat metadata type, so at most one + // context applies. + if boosted := toBoostedReaction(record.AppMetadata); boosted != nil { + metadata.Context = &activitypb.DirectlySentCryptoNotificationMetadata_BoostedReaction{BoostedReaction: boosted} + } else if requested := toRequestedPayment(record.AppMetadata); requested != nil { + metadata.Context = &activitypb.DirectlySentCryptoNotificationMetadata_RequestedPayment{RequestedPayment: requested} + } notification.AdditionalMetadata = &activitypb.Notification_DirectlySentCrypto{DirectlySentCrypto: metadata} case history.DirectlyReceived, history.IndirectlyReceived: @@ -138,7 +144,11 @@ func toNotification( UserId: userID, } } - metadata.BoostedReaction = toBoostedReaction(record.AppMetadata) + if boosted := toBoostedReaction(record.AppMetadata); boosted != nil { + metadata.Context = &activitypb.ReceivedCryptoNotificationMetadata_BoostedReaction{BoostedReaction: boosted} + } else if requested := toRequestedPayment(record.AppMetadata); requested != nil { + metadata.Context = &activitypb.ReceivedCryptoNotificationMetadata_RequestedPayment{RequestedPayment: requested} + } notification.AdditionalMetadata = &activitypb.Notification_ReceivedCrypto{ReceivedCrypto: metadata} case history.IndirectlySent: @@ -213,6 +223,23 @@ func toBoostedReaction(appMetadata []byte) *activitypb.BoostedReaction { } } +// toRequestedPayment names the payment request a payment paid a share of, +// read off the app metadata its intent carried, or nil for a payment that was +// not a requested payment. A requested payment was validated when it was +// paid, so its metadata names the request and the request's group; it is what +// the payment was for then, and is not checked against the request now, which +// may since have closed or expired. +func toRequestedPayment(appMetadata []byte) *activitypb.RequestedPayment { + requested := intent.GetRequestedPayment(appMetadata) + if requested == nil { + return nil + } + return &activitypb.RequestedPayment{ + ChatId: intent.GetChatMetadata(appMetadata).GetChatId(), + PaymentRequestId: requested.GetPaymentRequestId(), + } +} + // toSwapMetadata describes the conversion behind a record, or nil for a record // that converted nothing. // diff --git a/go.mod b/go.mod index d46262e..ac15195 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/aws/smithy-go v1.27.7 github.com/buckket/go-blurhash v1.1.0 github.com/cespare/xxhash/v2 v2.3.0 - github.com/code-payments/flipcash2-protobuf-api v1.31.1-0.20261027220701-28b039661c35 + github.com/code-payments/flipcash2-protobuf-api v1.31.1-0.20261028161846-14f05bb0521e github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261016120110-6f70c0d8e832 github.com/code-payments/ocp-server v1.27.1-0.20261016133142-d1b9b228d45d github.com/devsisters/go-applereceipt v0.0.0-20240805020915-fa22a0160fc2 @@ -105,7 +105,6 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/steebchen/prisma-client-go v0.47.0 // indirect github.com/tyler-smith/go-bip39 v1.1.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect diff --git a/go.sum b/go.sum index da7b629..0e386e3 100644 --- a/go.sum +++ b/go.sum @@ -78,10 +78,8 @@ github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB0 github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= github.com/code-payments/code-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0= github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8= -github.com/code-payments/flipcash2-protobuf-api v1.30.1-0.20261026192314-b655df1770f1 h1:rFKHMwujz7xkEhiMbdw9vmGzVxsZefz5YGHlljzbfns= -github.com/code-payments/flipcash2-protobuf-api v1.30.1-0.20261026192314-b655df1770f1/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= -github.com/code-payments/flipcash2-protobuf-api v1.31.1-0.20261027220701-28b039661c35 h1:Z5Ryi0Bhg+0jRV0VCWi9xBPOi8m232hjHkrU3G3vsAc= -github.com/code-payments/flipcash2-protobuf-api v1.31.1-0.20261027220701-28b039661c35/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= +github.com/code-payments/flipcash2-protobuf-api v1.31.1-0.20261028161846-14f05bb0521e h1:1fM6uhPIfj57ss2BGoCVf1cARx9y0spvaPw4TtnO+rw= +github.com/code-payments/flipcash2-protobuf-api v1.31.1-0.20261028161846-14f05bb0521e/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261016120110-6f70c0d8e832 h1:HFY6mGBaZm8C3tJDK2PvaVQjfUS91NFDDWQSzla80hA= github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261016120110-6f70c0d8e832/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= github.com/code-payments/ocp-server v1.27.1-0.20261016133142-d1b9b228d45d h1:tliC1IE+4TYInxfv3zzd+iGLRq7QQkFaeebIZTdP/os= @@ -322,8 +320,6 @@ github.com/smartystreets/assertions v1.13.1 h1:Ef7KhSmjZcK6AVf9YbJdvPYG9avaF0Zxu github.com/smartystreets/assertions v1.13.1/go.mod h1:cXr/IwVfSo/RbCSPhoAPv73p3hlSdrBH/b3SdnW/LMY= github.com/smartystreets/goconvey v1.8.0 h1:Oi49ha/2MURE0WexF052Z0m+BNSGirfjg5RL+JXWq3w= github.com/smartystreets/goconvey v1.8.0/go.mod h1:EdX8jtrTIj26jmjCOVNMVSIYAtgexqXKHOXW2Dx9JLg= -github.com/steebchen/prisma-client-go v0.47.0 h1:mKelgkcGPcIardjTP5diGq6hvnueQc/DYEyQ+6uZ0/E= -github.com/steebchen/prisma-client-go v0.47.0/go.mod h1:i1B0PEaE+BUcBUiwvd9drWpyMG/zNYMRrD5MancMf2I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=