Skip to content

feat: add V4 d14n listener with payload format support#96

Merged
neekolas merged 20 commits into
mainfrom
04-04-v4_listener_cc
Apr 8, 2026
Merged

feat: add V4 d14n listener with payload format support#96
neekolas merged 20 commits into
mainfrom
04-04-v4_listener_cc

Conversation

@neekolas

@neekolas neekolas commented Apr 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add a V4 listener that connects to the XMTP d14n network via NotificationApi.SubscribeAllEnvelopes, receiving OriginatorEnvelope messages. Operators select --listener-type=v4 at startup.

  • V4 listener processes OriginatorEnvelope payloads (group messages, welcome messages, welcome pointers)
  • Converts V4 envelopes to V3 format for backward-compatible push delivery
  • Adds payloadFormat field to APNS/FCM/HTTP delivery payloads
  • Installations can register a preferred payload format (v3 or v4)
  • Extracts shared delivery dispatch, HMAC sender filtering, and worker pool utilities
  • Custom MarshalJSON on SendRequest preserves backward-compatible HTTP delivery JSON shape
  • Database migration adds payload_format column to installations

Design: docs/plans/2026-04-03-d14n-listener-design.md

Test plan

  • go test -p 1 ./... passes (unit tests including V4 listener, delivery, API)
  • Existing V3 listener behavior unchanged
  • HTTP delivery JSON format backward-compatible (nested message.content_topic shape preserved)
  • APNS/FCM payloads include payloadFormat field

🤖 Generated with Claude Code

Note

Add V4 notification listener with payload format support and binary topic storage

  • Introduces V4Listener in pkg/xmtp/v4_listener.go that subscribes to XMTP v4 envelopes via a new NotificationApiClient, converts them to V3-compatible payloads, and dispatches push notifications through the shared deliveryDispatcher.
  • Adds PayloadFormat enum (V3/V4) to the notification proto, installation registration, and subscription APIs; APNs and FCM delivery payloads now include a payload_format field.
  • Migrates the subscriptions topic column from TEXT to BYTEA (migration 00004) and adds a payload_format column to installations (migration 00005); all subscription queries updated accordingly.
  • Extends the API server to accept binary topics (topic_bytes) alongside legacy string topics, deduplicate them, and validate payload format against the configured listener type.
  • Adds a --listener-type option (v3/v4, default v3) to select the active listener at startup.
  • Risk: the binary topic migration drops rows with non-conforming topic strings and changes deduplication key semantics for idempotency (timestamp now included in buildIdempotencyKey).

Macroscope summarized 07882a8.

@neekolas
neekolas requested a review from a team as a code owner April 4, 2026 15:56
Comment thread pkg/subscriptions/subscriptions.go
Comment thread pkg/xmtp/v4_listener.go
Comment thread pkg/xmtp/v4_listener.go Outdated
Comment thread pkg/xmtp/v4_listener.go
Comment thread pkg/xmtp/v4_listener.go
@macroscopeapp

macroscopeapp Bot commented Apr 4, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

Comment thread pkg/xmtp/v4_listener.go Outdated
@neekolas
neekolas force-pushed the 04-04-v4_listener_cc branch from 6fb14d2 to 03c9351 Compare April 4, 2026 17:26
@neekolas
neekolas changed the base branch from main to 04-04-remove_local_protos April 4, 2026 17:26
Comment thread pkg/xmtp/v4_listener.go
Comment on lines +88 to +94
func (p PayloadFormat) ValidateForListener(listenerType ListenerType) error {
normalized := NormalizePayloadFormat(p)
if listenerType == ListenerTypeV3 && normalized == PayloadFormatV4 {
return fmt.Errorf("payload format %q is not supported by listener type %q", normalized.String(), listenerType)
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low interfaces/interfaces.go:88

When listenerType is an empty string, ValidateForListener allows PayloadFormatV4 to pass validation because the condition listenerType == ListenerTypeV3 evaluates to false. However, an empty listenerType is treated as V3 elsewhere in the codebase, so this validation mismatch permits V4 format registrations that are incompatible with the actual V3 listener behavior. Consider explicitly rejecting empty listenerType in NormalizePayloadFormat or treating empty string as V3 in ValidateForListener to match the listener initialization logic.

 func (p PayloadFormat) ValidateForListener(listenerType ListenerType) error {
 	normalized := NormalizePayloadFormat(p)
-	if listenerType == ListenerTypeV3 && normalized == PayloadFormatV4 {
+	if (listenerType == ListenerTypeV3 || listenerType == "") && normalized == PayloadFormatV4 {
 		return fmt.Errorf("payload format %q is not supported by listener type %q", normalized.String(), listenerType)
 	}
 	return nil
 }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file pkg/interfaces/interfaces.go around lines 88-94:

When `listenerType` is an empty string, `ValidateForListener` allows `PayloadFormatV4` to pass validation because the condition `listenerType == ListenerTypeV3` evaluates to false. However, an empty `listenerType` is treated as V3 elsewhere in the codebase, so this validation mismatch permits V4 format registrations that are incompatible with the actual V3 listener behavior. Consider explicitly rejecting empty `listenerType` in `NormalizePayloadFormat` or treating empty string as V3 in `ValidateForListener` to match the listener initialization logic.

Evidence trail:
pkg/interfaces/interfaces.go:74-78 (ListenerType definition: `ListenerTypeV3 ListenerType = "v3"`), pkg/interfaces/interfaces.go:88-93 (ValidateForListener implementation), cmd/server/main.go:93-96 (switch statement with default case treating non-"v4" as V3), cmd/server/main.go:106 (listenerType passed directly to API server without normalization)

Comment thread pkg/delivery/fcm.go
Comment thread pkg/api/api_test.go
Comment thread pkg/xmtp/v4_listener.go
Comment thread pkg/xmtp/v4_listener.go
@neekolas
neekolas force-pushed the 04-04-v4_listener_cc branch from 03c9351 to 5d34849 Compare April 5, 2026 02:48
@neekolas
neekolas force-pushed the 04-04-remove_local_protos branch from 356f73e to 5aab47d Compare April 5, 2026 02:48
Comment thread pkg/interfaces/interfaces.go Outdated
Comment thread pkg/xmtp/v4_listener_test.go
Comment thread pkg/xmtp/v4_listener.go
Comment thread pkg/xmtp/v4_listener.go Outdated
Comment thread pkg/xmtp/v4_listener.go Outdated
Comment thread pkg/xmtp/v4_listener.go
@neekolas
neekolas force-pushed the 04-04-v4_listener_cc branch 2 times, most recently from f685900 to 4ac3963 Compare April 5, 2026 06:37
Comment thread pkg/xmtp/v4_listener_test.go Outdated
@neekolas
neekolas force-pushed the 04-04-v4_listener_cc branch from 4ac3963 to 24536d4 Compare April 5, 2026 17:54
@neekolas
neekolas force-pushed the 04-04-remove_local_protos branch from 5aab47d to 9674141 Compare April 5, 2026 17:54
@neekolas
neekolas force-pushed the 04-04-v4_listener_cc branch from 24536d4 to 5e43ef2 Compare April 5, 2026 17:56
@neekolas
neekolas force-pushed the 04-04-remove_local_protos branch from 9674141 to 7fe7faa Compare April 5, 2026 17:56
Comment thread pkg/testutils/delivery.go
Comment thread pkg/interfaces/interfaces_test.go
Comment thread pkg/xmtp/v4_listener.go

neekolas commented Apr 8, 2026

Copy link
Copy Markdown
Collaborator Author

Merge activity

  • Apr 8, 5:03 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Apr 8, 5:08 PM UTC: Graphite rebased this pull request as part of a merge.
  • Apr 8, 5:09 PM UTC: @neekolas merged this pull request with Graphite.

@neekolas
neekolas changed the base branch from 04-04-remove_local_protos to graphite-base/96 April 8, 2026 17:06
@neekolas
neekolas changed the base branch from graphite-base/96 to main April 8, 2026 17:07
neekolas and others added 20 commits April 8, 2026 17:08
…erfaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The spec requires payloadFormat (camelCase) in all delivery payloads.
The HTTP delivery JSON-serializes SendRequest directly, so the json
tag must use camelCase to match APNS and FCM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add ListenerType typed constant, NormalizePayloadFormat, ValidateForListener
- Add cloneBytes for defensive byte slice copying in conversions
- Add TopicMatchesPayload validation before V3 conversion
- Add WelcomePointer V3 conversion support
- Add logV4ConversionIssue for differentiated error/warning logging
- Add tests for WelcomePointer, log capture, ValidateForListener
- Cap exponential backoff at 30s to prevent unbounded growth (V3+V4)
- Add context cancellation check in V4 listener outer reconnect loop
- Close old gRPC connection before creating new one in refreshV4Client
- Handle idempotency key marshal failure with fallback instead of silent empty hash

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The SendRequest struct was changed to use flat Topic/EncryptedMessage
fields instead of the original nested v1.Envelope. This broke the HTTP
delivery JSON payload format that downstream consumers expect.

Add a custom MarshalJSON to SendRequest that produces the original
format with a nested "message" object containing "content_topic" and
"message" fields, preserving backward compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents leaking the gRPC connection established by NewV4Listener
or refreshV4Client when the listener is stopped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add pkg/testutils/delivery.go with MockDeliveryAcceptAll,
MockDeliveryWithSendCounter, GetSendRequests, RequireSendRequestForInstallation,
and RequireEventuallySendCount. Replace local helpers in v3/v4_listener_test.go.
Standardize on testutils.TestLogger, t.Cleanup, require over assert,
and testutils.MustParseTopic across all Branch 4 test files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@neekolas
neekolas force-pushed the 04-04-v4_listener_cc branch from b532c6f to 07882a8 Compare April 8, 2026 17:08
@neekolas
neekolas merged commit 345c468 into main Apr 8, 2026
8 checks passed
@neekolas
neekolas deleted the 04-04-v4_listener_cc branch April 8, 2026 17:09
Comment thread pkg/xmtp/v3_listener.go
IdempotencyKey: idempotencyKey,
Topic: topics.TopicToString(t),
EncryptedMessage: envelope.Message,
PayloadFormat: interfaces.PayloadFormatV3,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low xmtp/v3_listener.go:234

buildSendRequests hardcodes PayloadFormatV3 at line 234, ignoring Installation.PayloadFormat. Installations registered with PayloadFormatV4 will receive messages in the wrong format. Consider using installation.PayloadFormat to match the V4 listener's dispatch pattern.

-			PayloadFormat:    interfaces.PayloadFormatV3,
+			PayloadFormat:    installation.PayloadFormat,
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file pkg/xmtp/v3_listener.go around line 234:

`buildSendRequests` hardcodes `PayloadFormatV3` at line 234, ignoring `Installation.PayloadFormat`. Installations registered with `PayloadFormatV4` will receive messages in the wrong format. Consider using `installation.PayloadFormat` to match the V4 listener's dispatch pattern.

Evidence trail:
- pkg/xmtp/v3_listener.go lines 219-244 (REVIEWED_COMMIT): `buildSendRequests` function hardcodes `PayloadFormat: interfaces.PayloadFormatV3` at line 234
- pkg/interfaces/interfaces.go lines 106-110: `Installation` struct has `PayloadFormat PayloadFormat` field
- pkg/xmtp/v4_listener.go lines 196-206: V4 listener properly switches on `inst.PayloadFormat` to dispatch to either `buildV4SendRequest` or `buildV3SendRequest`

Comment thread pkg/xmtp/v3_listener.go
logger.Error("error delivering request", zap.Error(err))
}
}
return err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low xmtp/v3_listener.go:197

processEnvelope returns err at line 197, which holds the last result from the delivery loop. If shouldDeliver skips a subscription after a delivery failure, the stale non-nil err is returned. If a delivery fails but a later delivery succeeds, err is overwritten to nil, swallowing the earlier error. The V4 equivalent processOriginatorEnvelope returns nil unconditionally. Consider returning nil at line 197 to match V4 behavior and avoid incorrect error propagation.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file pkg/xmtp/v3_listener.go around line 197:

`processEnvelope` returns `err` at line 197, which holds the last result from the delivery loop. If `shouldDeliver` skips a subscription after a delivery failure, the stale non-nil `err` is returned. If a delivery fails but a later delivery succeeds, `err` is overwritten to `nil`, swallowing the earlier error. The V4 equivalent `processOriginatorEnvelope` returns `nil` unconditionally. Consider returning `nil` at line 197 to match V4 behavior and avoid incorrect error propagation.

Evidence trail:
pkg/xmtp/v3_listener.go lines 186-197 (REVIEWED_COMMIT): Shows the delivery loop with `if err = l.dispatcher.deliver(request); err != nil` and `return err` at line 197.
pkg/xmtp/v4_listener.go lines 158-230 (REVIEWED_COMMIT): Shows equivalent V4 function with same loop pattern but `return nil` at line 230 instead of `return err`.

Comment thread pkg/topics/topics.go

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low topics/topics.go:14

strings.TrimPrefix returns the string unchanged when the prefix is absent, so ParseV3Topic silently parses topic strings that lack the required V3_PREFIX (e.g., "g-abc123/proto" without the leading /xmtp/mls/1/). Consider checking that the original topicStr actually has the prefix before trimming, returning an error if it does not.

+func ParseV3Topic(topicStr string) (*topic.Topic, error) {
+	if !strings.HasPrefix(topicStr, V3_PREFIX) {
+		return nil, fmt.Errorf("invalid V3 topic: missing %s prefix", V3_PREFIX)
+	}
 	topicStr = strings.TrimPrefix(topicStr, V3_PREFIX)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file pkg/topics/topics.go around lines 14-19:

`strings.TrimPrefix` returns the string unchanged when the prefix is absent, so `ParseV3Topic` silently parses topic strings that lack the required `V3_PREFIX` (e.g., `"g-abc123/proto"` without the leading `/xmtp/mls/1/`). Consider checking that the original `topicStr` actually has the prefix before trimming, returning an error if it does not.

Evidence trail:
pkg/topics/topics.go lines 12-39 at REVIEWED_COMMIT: Shows V3_PREFIX constant as "/xmtp/mls/1/" on line 12, ParseV3Topic function starting at line 14, and TrimPrefix call on line 15 without any check that the prefix was actually present. Go standard library documentation confirms strings.TrimPrefix returns the original string unchanged if the prefix is not present.

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.

2 participants