feat: add V4 d14n listener with payload format support#96
Conversation
ApprovabilityVerdict: 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. |
6fb14d2 to
03c9351
Compare
| 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 | ||
| } |
There was a problem hiding this comment.
🟢 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)
03c9351 to
5d34849
Compare
356f73e to
5aab47d
Compare
f685900 to
4ac3963
Compare
4ac3963 to
24536d4
Compare
5aab47d to
9674141
Compare
24536d4 to
5e43ef2
Compare
9674141 to
7fe7faa
Compare
7fe7faa to
c4452ec
Compare
5e43ef2 to
b532c6f
Compare
…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>
b532c6f to
07882a8
Compare
| IdempotencyKey: idempotencyKey, | ||
| Topic: topics.TopicToString(t), | ||
| EncryptedMessage: envelope.Message, | ||
| PayloadFormat: interfaces.PayloadFormatV3, |
There was a problem hiding this comment.
🟢 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`
| logger.Error("error delivering request", zap.Error(err)) | ||
| } | ||
| } | ||
| return err |
There was a problem hiding this comment.
🟢 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`.
There was a problem hiding this comment.
🟢 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.

Summary
Add a V4 listener that connects to the XMTP d14n network via
NotificationApi.SubscribeAllEnvelopes, receivingOriginatorEnvelopemessages. Operators select--listener-type=v4at startup.OriginatorEnvelopepayloads (group messages, welcome messages, welcome pointers)payloadFormatfield to APNS/FCM/HTTP delivery payloadsv3orv4)MarshalJSONonSendRequestpreserves backward-compatible HTTP delivery JSON shapepayload_formatcolumn to installationsDesign:
docs/plans/2026-04-03-d14n-listener-design.mdTest plan
go test -p 1 ./...passes (unit tests including V4 listener, delivery, API)message.content_topicshape preserved)payloadFormatfield🤖 Generated with Claude Code
Note
Add V4 notification listener with payload format support and binary topic storage
V4Listenerin pkg/xmtp/v4_listener.go that subscribes to XMTP v4 envelopes via a newNotificationApiClient, converts them to V3-compatible payloads, and dispatches push notifications through the shareddeliveryDispatcher.PayloadFormatenum (V3/V4) to the notification proto, installation registration, and subscription APIs; APNs and FCM delivery payloads now include apayload_formatfield.topiccolumn fromTEXTtoBYTEA(migration 00004) and adds apayload_formatcolumn to installations (migration 00005); all subscription queries updated accordingly.topic_bytes) alongside legacy string topics, deduplicate them, and validate payload format against the configured listener type.--listener-typeoption (v3/v4, defaultv3) to select the active listener at startup.buildIdempotencyKey).Macroscope summarized 07882a8.