Skip to content

Commit 01fca3e

Browse files
committed
feat: add Marmot Protocol relay support (MIPs 00-03)
1 parent d8f62b4 commit 01fca3e

13 files changed

Lines changed: 412 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
Add relay support for the Marmot Protocol (E2EE group messaging over Nostr).
6+
7+
Supported MIPs: 00 (KeyPackages), 01 (Group Construction), 02 (Welcome Events), 03 (Group Messages).
8+
9+
- kind 443 (legacy KeyPackage): stored as a regular event
10+
- kind 10051 (KeyPackage relay list): stored as a replaceable event
11+
- kind 30443 (KeyPackage): stored as a parameterized-replaceable event with `d`-tag deduplication
12+
- kind 444 (Welcome rumor): blocked from direct publishing; must travel inside a kind 1059 gift wrap
13+
- kind 445 (Group Event): dedicated strategy validates the required `h` tag (nostr_group_id) before storing; `#h` tag subscriptions work via the existing generic tag index
14+
- NIP-11 relay info now advertises `supported_mips: [0, 1, 2, 3]`

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
65
2727
],
2828
"supportedNipExtensions": [],
29+
"supportedMips": [0, 1, 2, 3],
2930
"main": "src/index.ts",
3031
"bin": {
3132
"nostream": "./dist/src/cli/index.js"

resources/default-settings.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,11 @@ limits:
181181
- 39999
182182
period: 60000
183183
rate: 24
184+
- description: 60 events/min for Marmot group events (kind 445)
185+
kinds:
186+
- 445
187+
period: 60000
188+
rate: 60
184189
- description: 60 events/min for ephemeral events
185190
kinds:
186191
- - 20000

src/constants/base.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export enum EventKinds {
2020
CHANNEL_MUTE_USER = 44,
2121
CHANNEL_RESERVED_FIRST = 45,
2222
CHANNEL_RESERVED_LAST = 49,
23+
// Marmot Protocol: E2EE Group Messaging (MIPs)
24+
MARMOT_KEY_PACKAGE_LEGACY = 443, // MIP-00: legacy KeyPackage (regular event, superseded by 30443)
25+
MARMOT_WELCOME_RUMOR = 444, // MIP-02: Welcome rumor (must not be published directly; wraps inside gift wrap)
26+
MARMOT_GROUP_EVENT = 445, // MIP-03: Group Event (proposals, commits, application messages)
2327
// NIP-17: Gift Wrap
2428
GIFT_WRAP = 1059,
2529
// NIP-03: OpenTimestamps attestation
@@ -34,12 +38,16 @@ export enum EventKinds {
3438
REPLACEABLE_FIRST = 10000,
3539
// NIP-65: Relay List Metadata
3640
RELAY_LIST = 10002,
41+
// Marmot Protocol MIP-00: KeyPackage Relay List
42+
MARMOT_KEY_PACKAGE_RELAY_LIST = 10051,
3743
REPLACEABLE_LAST = 19999,
3844
// Ephemeral events
3945
EPHEMERAL_FIRST = 20000,
4046
EPHEMERAL_LAST = 29999,
4147
// Parameterized replaceable events
4248
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
49+
// Marmot Protocol MIP-00: KeyPackage (addressable, replaces legacy 443)
50+
MARMOT_KEY_PACKAGE = 30443,
4351
PARAMETERIZED_REPLACEABLE_LAST = 39999,
4452
USER_APPLICATION_FIRST = 40000,
4553
}
@@ -58,6 +66,8 @@ export enum EventTags {
5866
Kind = 'k',
5967
// NIP-12: geohash tag for location-based queries
6068
Geohash = 'g',
69+
// Marmot Protocol MIP-03: group ID for filtering kind:445 Group Events
70+
Group = 'h',
6171
}
6272

6373
export const ALL_RELAYS = 'ALL_RELAYS'

src/factories/event-strategy-factory.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
isDeleteEvent,
44
isEphemeralEvent,
55
isGiftWrapEvent,
6+
isMarmotGroupEvent,
67
isOpenTimestampsEvent,
78
isParameterizedReplaceableEvent,
89
isReplaceableEvent,
@@ -15,6 +16,7 @@ import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-e
1516
import { Event } from '../@types/event'
1617
import { Factory } from '../@types/base'
1718
import { GiftWrapEventStrategy } from '../handlers/event-strategies/gift-wrap-event-strategy'
19+
import { GroupEventStrategy } from '../handlers/event-strategies/group-event-strategy'
1820
import { IEventStrategy } from '../@types/message-handlers'
1921
import { IWebSocketAdapter } from '../@types/adapters'
2022
import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy'
@@ -32,6 +34,8 @@ export const eventStrategyFactory =
3234
return new VanishEventStrategy(adapter, eventRepository, userRepository)
3335
} else if (isGiftWrapEvent(event)) {
3436
return new GiftWrapEventStrategy(adapter, eventRepository)
37+
} else if (isMarmotGroupEvent(event)) {
38+
return new GroupEventStrategy(adapter, eventRepository)
3539
} else if (isOpenTimestampsEvent(event)) {
3640
return new TimestampEventStrategy(adapter, eventRepository)
3741
} else if (isRelayListEvent(event) || isReplaceableEvent(event)) {

src/handlers/event-message-handler.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
isFileMessageEvent,
2424
isRequestToVanishEvent,
2525
isSealEvent,
26+
isWelcomeRumorEvent,
2627
} from '../utils/event'
2728
import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
2829
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
@@ -238,7 +239,9 @@ export class EventMessageHandler implements IMessageHandler {
238239
// NIP-17: kind 13 (Seal) and kind 14 (Direct Message) are inner events that
239240
// must never be published directly to a relay. They are encrypted inside a
240241
// kind 1059 Gift Wrap (NIP-59) before being sent here.
241-
if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event)) {
242+
// Marmot MIP-02: kind 444 (Welcome rumor) is similarly an inner event that
243+
// must only be delivered inside a kind 1059 gift wrap.
244+
if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event) || isWelcomeRumorEvent(event)) {
242245
return `blocked: kind ${event.kind} events must not be published directly; wrap them in a kind 1059 gift wrap`
243246
}
244247
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { createCommandResult } from '../../utils/messages'
2+
import { createLogger } from '../../factories/logger-factory'
3+
import { Event } from '../../@types/event'
4+
import { EventTags } from '../../constants/base'
5+
import { IEventRepository } from '../../@types/repositories'
6+
import { IEventStrategy } from '../../@types/message-handlers'
7+
import { IWebSocketAdapter } from '../../@types/adapters'
8+
import { WebSocketAdapterEvent } from '../../constants/adapter'
9+
10+
const logger = createLogger('group-event-strategy')
11+
12+
export class GroupEventStrategy implements IEventStrategy<Event, Promise<void>> {
13+
public constructor(
14+
private readonly webSocket: IWebSocketAdapter,
15+
private readonly eventRepository: IEventRepository,
16+
) {}
17+
18+
public async execute(event: Event): Promise<void> {
19+
logger('received group event: %o', event)
20+
21+
const reason = this.validateGroupEvent(event)
22+
if (reason) {
23+
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, `invalid: ${reason}`))
24+
return
25+
}
26+
27+
const count = await this.eventRepository.create(event)
28+
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, count ? '' : 'duplicate:'))
29+
30+
if (count) {
31+
this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event)
32+
}
33+
}
34+
35+
// MIP-03: kind:445 Group Events MUST carry exactly one `h` tag whose value is the
36+
// 64-character lowercase hex-encoded nostr_group_id from the Marmot Group Data Extension.
37+
// The relay enforces this so that #h tag subscriptions always work correctly.
38+
private validateGroupEvent(event: Event): string | undefined {
39+
const groupTags = event.tags.filter((tag) => tag.length >= 2 && tag[0] === EventTags.Group)
40+
41+
if (groupTags.length === 0) {
42+
return 'group event (kind 445) must have an h tag identifying the group'
43+
}
44+
45+
if (groupTags.length > 1) {
46+
return 'group event (kind 445) must have exactly one h tag'
47+
}
48+
49+
const groupId = groupTags[0][1]
50+
if (!/^[0-9a-f]{64}$/.test(groupId)) {
51+
return 'group event (kind 445) h tag must contain a valid 64-character lowercase hex group id'
52+
}
53+
54+
return undefined
55+
}
56+
}

src/handlers/request-handlers/root-request-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
8080
contact,
8181
supported_nips: packageJson.supportedNips,
8282
supported_nip_extensions: packageJson.supportedNipExtensions,
83+
supported_mips: packageJson.supportedMips,
8384
software: packageJson.repository.url,
8485
version: packageJson.version,
8586
...(terms_of_service !== undefined ? { terms_of_service } : {}),

src/utils/event.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,13 @@ export const isFileMessageEvent = (event: Event): boolean => {
279279
export const isOpenTimestampsEvent = (event: Event): boolean => {
280280
return event.kind === EventKinds.OPEN_TIMESTAMPS
281281
}
282+
283+
// Marmot Protocol helpers
284+
285+
export const isWelcomeRumorEvent = (event: Event): boolean => {
286+
return event.kind === EventKinds.MARMOT_WELCOME_RUMOR
287+
}
288+
289+
export const isMarmotGroupEvent = (event: Event): boolean => {
290+
return event.kind === EventKinds.MARMOT_GROUP_EVENT
291+
}

test/unit/factories/event-strategy-factory.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { EventKinds } from '../../../src/constants/base'
99
import { eventStrategyFactory } from '../../../src/factories/event-strategy-factory'
1010
import { Factory } from '../../../src/@types/base'
1111
import { GiftWrapEventStrategy } from '../../../src/handlers/event-strategies/gift-wrap-event-strategy'
12+
import { GroupEventStrategy } from '../../../src/handlers/event-strategies/group-event-strategy'
1213
import { IEventStrategy } from '../../../src/@types/message-handlers'
1314
import { IWebSocketAdapter } from '../../../src/@types/adapters'
1415
import { ParameterizedReplaceableEventStrategy } from '../../../src/handlers/event-strategies/parameterized-replaceable-event-strategy'
@@ -72,6 +73,26 @@ describe('eventStrategyFactory', () => {
7273
expect(factory([event, adapter])).to.be.an.instanceOf(GiftWrapEventStrategy)
7374
})
7475

76+
it('returns GroupEventStrategy given a Marmot group event (kind 445)', () => {
77+
event.kind = EventKinds.MARMOT_GROUP_EVENT
78+
expect(factory([event, adapter])).to.be.an.instanceOf(GroupEventStrategy)
79+
})
80+
81+
it('returns ParameterizedReplaceableEventStrategy given a Marmot KeyPackage event (kind 30443)', () => {
82+
event.kind = EventKinds.MARMOT_KEY_PACKAGE
83+
expect(factory([event, adapter])).to.be.an.instanceOf(ParameterizedReplaceableEventStrategy)
84+
})
85+
86+
it('returns ReplaceableEventStrategy given a Marmot KeyPackage relay list (kind 10051)', () => {
87+
event.kind = EventKinds.MARMOT_KEY_PACKAGE_RELAY_LIST
88+
expect(factory([event, adapter])).to.be.an.instanceOf(ReplaceableEventStrategy)
89+
})
90+
91+
it('returns DefaultEventStrategy given a legacy Marmot KeyPackage (kind 443)', () => {
92+
event.kind = EventKinds.MARMOT_KEY_PACKAGE_LEGACY
93+
expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy)
94+
})
95+
7596
it('returns TimestampEventStrategy given an opentimestamps (NIP-03) event', () => {
7697
event.kind = EventKinds.OPEN_TIMESTAMPS
7798
expect(factory([event, adapter])).to.be.an.instanceOf(TimestampEventStrategy)

0 commit comments

Comments
 (0)