libp2r2p 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -358,6 +358,13 @@ By default, each high-level private-message send creates one fresh deletion
358
358
  keypair for its logical message. Every outer kind `3560` event produced for that
359
359
  send, including router chunks, recipient subsets, and nym carriers, carries the
360
360
  same public key in its `s` tag. The result always contains `delivery.reports`.
361
+ Each routed report also identifies its exact outer event in `outerEventId` and
362
+ the encrypted recipient subset in `receiverPubkeys`. The same recipient can
363
+ appear in multiple reports when a message spans chunks or recipient groups; it
364
+ is fully covered only when every report containing it succeeds. Fixed relay
365
+ lists publish one shared recipient set, while explicit `relayToReceivers` maps
366
+ and automatic NIP-65 routing can produce partial recipient coverage. Nym uses
367
+ one carrier union and does not expose per-recipient partial delivery.
361
368
  When libp2r2p generated the keypair, it also contains
362
369
  `delivery.deletionSeckey`; the public key can be derived from that secret.
363
370
 
@@ -797,7 +804,7 @@ Maintainers can rebuild the checked-in WASM module with `npm run build:wasm`;
797
804
  see the
798
805
  [WASM build guide](https://github.com/arthurfranca/libp2r2p/blob/main/wasm/README.md).
799
806
  Run the non-gating comparison against the JavaScript backend with
800
- `npm run benchmark:event`.
807
+ `npm run benchmark:event` (implemented by `benchmark/event-crypto.js`).
801
808
 
802
809
  Median speedup after explicit WASM initialization, measured with Node.js 24 on
803
810
  an AMD Ryzen 5 5600G:
@@ -1181,6 +1188,10 @@ complete per-relay report without delaying the first-success return. Connection
1181
1188
  establishment retains its separate 3-second deadline. PrivateMessenger inherits
1182
1189
  this publication policy, including seeder/recovery messages. A timeout indicates
1183
1190
  missing acknowledgement and cannot prove that the recipient did not receive it.
1191
+ Its optional `getRetryEvent({ event, report })` accepts one replacement signed
1192
+ event after every relay terminally rejected the original with
1193
+ `auth-required:`/`restricted:`/`invalid:`. The replacement and its retry share the original
1194
+ deadline; mixed failures and ambiguous timeouts never invoke it.
1184
1195
 
1185
1196
  ## Private file channels (0.11)
1186
1197
 
@@ -1209,10 +1220,32 @@ including an optional `onSendError` callback identifying the failed outbox messa
1209
1220
  Private messages, including multi-recipient channels, retry NIP-65 read relays
1210
1221
  in pairs per recipient when
1211
1222
  selected relays reject publication or become unreachable while connectivity is
1212
- confirmed. The exact encrypted outer events are reused. Optional `fallbackRelays` on
1223
+ confirmed. The exact encrypted outer events are reused, except that a 3560
1224
+ publication rejected in full by age-related AUTH policy may be re-enveloped once:
1225
+ ciphertext, router, chunks, seeds, recipients and deletion capability remain
1226
+ unchanged while every outer is re-signed with one fresh `created_at` and a
1227
+ refreshed `expiration`. The durable replacement is written before retry and is
1228
+ never refreshed a second time. A relay may use `invalid:` rather than
1229
+ `auth-required:` for a stale 3560 wrapper; `invalid:` triggers this refresh but
1230
+ never attempts NIP-42. Optional `fallbackRelays` on
1213
1231
  `createPrivateMessenger` and `createPrivateMessageSession` are tried after the
1214
1232
  primary destinations by default, including explicit per-call `relays`, channel `sendRelays`
1215
1233
  and channel/global `relays`, as well as explicit `relayToReceivers` maps.
1234
+
1235
+ This bounded re-envelope is preferred over NIP-42 authentication with the
1236
+ channel key because NIP-42 state belongs to the connection, not to one outer
1237
+ event. The `44b-relay` currently permits only one authenticated identity per
1238
+ connection and resets subscriptions when that identity changes. Even relays
1239
+ that support multiple identities per connection must bound them. A user with
1240
+ many contacts has one derived channel key per conversation, so authenticating
1241
+ each channel would either require one authenticated connection per channel or
1242
+ reconnect/resubscribe while switching identities, consuming connection,
1243
+ subscription and recovery budgets. It would also create a connection-scoped linkage
1244
+ between the connection and a channel key. The 3560 wrapper is disposable and
1245
+ privacy-oriented; relays are expected to impose freshness limits instead of
1246
+ correlating it with an authenticated session. A modern 3560 is normally
1247
+ accepted without AUTH, so refreshing an age-rejected outer should remain rare.
1248
+
1216
1249
  Retries preserve each encrypted recipient subset; success requires accepted
1217
1250
  publication covering every recipient in that subset. Receive routing adds them from the start for live
1218
1251
  messages, history and files while preserving automatic NIP-65 updates. Successful sends keep first-ACK
@@ -1224,6 +1257,20 @@ paused or otherwise unavailable now stay pending in the outbox and retry on a
1224
1257
  bounded session backoff (1–30 seconds, 20% jitter) instead of surfacing a
1225
1258
  user-facing send error.
1226
1259
 
1260
+ Multi-recipient callers that persist exact envelopes use the canonical
1261
+ `preparedPublications` array supplied through `onPreparedSeed`. Each element is
1262
+ one recipient group with its own encrypted `events`, recovery `seeds`, and
1263
+ `receiverPubkeys`. A later send can pass the array plus only the pending
1264
+ recipients; matching groups are replayed with their original IDs, bytes and
1265
+ signatures, and routed only for the pending peers. The legacy
1266
+ `preparedPublication` public argument is rejected with
1267
+ `LEGACY_PREPARED_PUBLICATION`; existing encrypted outbox snapshots containing
1268
+ `prepared.publication` are migrated internally to
1269
+ `prepared.publications` before processing. `yell` callers must reuse the
1270
+ original `createdAt` when replaying so the inner rumor identity matches;
1271
+ `broadcastRumor` and `broadcastEvent` already carry their original inner event.
1272
+ Nym broadcasts intentionally remain a single carrier union.
1273
+
1227
1274
 
1228
1275
  ### Relay backpressure and bounded synchronization (0.11.7)
1229
1276
 
@@ -1,3 +1,4 @@
1
+ import { bytesToBase16 } from '../../base16/index.js'
1
2
  import { ValidationError } from '../../error/index.js'
2
3
  import { createSecp256k1WasmBackend } from './binding.js'
3
4
 
@@ -13,8 +14,6 @@ let backend = null
13
14
  let pending = null
14
15
  let generation = 0
15
16
 
16
- const toHex = bytes => Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
17
-
18
17
  async function initialize (forceRetry) {
19
18
  if (!enabled) return 'javascript'
20
19
  if (backend) return 'wasm'
@@ -27,7 +26,7 @@ async function initialize (forceRetry) {
27
26
  let candidate
28
27
  try {
29
28
  candidate = await createSecp256k1WasmBackend()
30
- const publicKey = toHex(candidate.getPublicKey(SELF_TEST_SECRET))
29
+ const publicKey = bytesToBase16(candidate.getPublicKey(SELF_TEST_SECRET))
31
30
  const signature = candidate.signHash(
32
31
  SELF_TEST_HASH,
33
32
  SELF_TEST_SECRET,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -51,8 +51,8 @@
51
51
  "web-storage-queue"
52
52
  ],
53
53
  "scripts": {
54
- "benchmark:event": "node --expose-gc benchmark/event-crypto.mjs",
55
- "build:wasm": "node wasm/build.mjs",
54
+ "benchmark:event": "node --expose-gc benchmark/event-crypto.js",
55
+ "build:wasm": "node wasm/build.js",
56
56
  "test": "node --test --experimental-test-module-mocks 'tests/**/*.test.js'",
57
57
  "test:files": "node --test --experimental-test-module-mocks",
58
58
  "test:only": "node --test --test-only --experimental-test-module-mocks 'tests/**/*.test.js'"
@@ -0,0 +1,12 @@
1
+ import { ValidationError } from '../../error/index.js'
2
+
3
+ export function normalizePreparedPublications ({ preparedPublication, preparedPublications } = {}) {
4
+ if (preparedPublication !== undefined) throw new ValidationError('LEGACY_PREPARED_PUBLICATION')
5
+ if (preparedPublications !== undefined) {
6
+ if (!Array.isArray(preparedPublications) || preparedPublications.length === 0) {
7
+ throw new ValidationError('INVALID_PREPARED_PUBLICATIONS')
8
+ }
9
+ return preparedPublications
10
+ }
11
+ return []
12
+ }
@@ -11,6 +11,7 @@ import { incompleteFetchError } from './helpers/fetch-error.js'
11
11
  import { readHistory } from './helpers/history.js'
12
12
  import { subscriptionError } from './helpers/subscription-error.js'
13
13
  import { createLookupWarningLogger } from './helpers/lookup-warning.js'
14
+ import { normalizePreparedPublications } from './helpers/prepared.js'
14
15
  import * as nip44v3 from '../nip44-v3/index.js'
15
16
  import { relayPool } from '../relay/index.js'
16
17
  import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
@@ -40,7 +41,7 @@ const decoder = new TextDecoder()
40
41
  const warnLookupFailure = createLookupWarningLogger()
41
42
  const NIP44_V3_SCOPE = ''
42
43
 
43
- const sendToRelays = (event, relays) => relayPool.sendEvent(event, relays)
44
+ const sendToRelays = (event, relays, options) => relayPool.sendEvent(event, relays, options)
44
45
  const getEvents = (...args) => relayPool.getEvents(...args)
45
46
  const getLiveEventsGenerator = (...args) => relayPool.getLiveEventsGenerator(...args)
46
47
  const getEventsFeedGenerator = (...args) => relayPool.getEventsFeedGenerator(...args)
@@ -153,18 +154,29 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
153
154
  }
154
155
  }
155
156
 
156
- async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context, preparedPublication }) {
157
+ async function validatePreparedPublication ({ privateChannelSigner, senderSigner, event, receivers, preparedPublication }) {
158
+ const channelPubkey = await privateChannelSigner.getPublicKey()
159
+ const innerEventId = getEventHash({ ...event, pubkey: event.pubkey || await senderSigner.getPublicKey() })
160
+ const publicationReceivers = Array.isArray(preparedPublication?.receiverPubkeys)
161
+ ? [...new Set(preparedPublication.receiverPubkeys)]
162
+ : []
163
+ const expected = receivers ? receiverPubkeys(receivers).sort().join(',') : ''
164
+ if (preparedPublication?.innerEventId !== innerEventId || preparedPublication?.channelPubkey !== channelPubkey ||
165
+ !publicationReceivers.length || (receivers && publicationReceivers.slice().sort().join(',') !== expected) ||
166
+ !Array.isArray(preparedPublication.events) || !preparedPublication.events.length || !Array.isArray(preparedPublication.seeds) ||
167
+ preparedPublication.events.some(value => !isValidEvent(value) || value.pubkey !== channelPubkey || value.kind !== PRIVATE_BROADCAST_KIND)) {
168
+ throw new ValidationError('INVALID_PREPARED_PUBLICATION')
169
+ }
170
+ if (preparedPublication.events.some(value => Number(value.tags.find(tag => tag[0] === 'expiration')?.[1]) <= nowSeconds())) {
171
+ throw new ValidationError('PREPARED_PUBLICATION_EXPIRED')
172
+ }
173
+ return publicationReceivers
174
+ }
175
+
176
+ async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, onPreparedPublication, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context, preparedPublication }) {
157
177
  let publication = preparedPublication
158
178
  if (publication) {
159
- const channelPubkey = await privateChannelSigner.getPublicKey()
160
- const expected = receiverPubkeys(receivers).sort().join(',')
161
- const innerEventId = getEventHash({ ...event, pubkey: event.pubkey || await senderSigner.getPublicKey() })
162
- if (publication.innerEventId !== innerEventId || publication.channelPubkey !== channelPubkey || !Array.isArray(publication.receiverPubkeys) || publication.receiverPubkeys.slice().sort().join(',') !== expected ||
163
- !Array.isArray(publication.events) || !publication.events.length || !Array.isArray(publication.seeds) ||
164
- publication.events.some(event => !isValidEvent(event) || event.pubkey !== channelPubkey || event.kind !== PRIVATE_BROADCAST_KIND)) {
165
- throw new ValidationError('INVALID_PREPARED_PUBLICATION')
166
- }
167
- if (publication.events.some(event => Number(event.tags.find(tag => tag[0] === 'expiration')?.[1]) <= nowSeconds())) throw new ValidationError('PREPARED_PUBLICATION_EXPIRED')
179
+ await validatePreparedPublication({ privateChannelSigner, senderSigner, event, receivers, preparedPublication: publication })
168
180
  } else {
169
181
  const routerSeckey = generateSecretKey()
170
182
  const routerPubkey = getPublicKey(routerSeckey)
@@ -194,6 +206,7 @@ async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event
194
206
  }
195
207
  publication = { innerEventId: context.innerEventId, channelPubkey: context.channelPubkey, receiverPubkeys: receiverPubkeyList, events, seeds }
196
208
  }
209
+ onPreparedPublication?.(publication)
197
210
  // The callback commits recovery data before any bytes leave this publisher.
198
211
  // A session commits its encrypted journal; a direct messenger commits seeds.
199
212
  if (onPreparedSeed) {
@@ -203,9 +216,12 @@ async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event
203
216
  }
204
217
 
205
218
  // Streaming version of wrapEvent
206
- export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs, preparedPublication }) {
219
+ export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, onPreparedPublication, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs, preparedPublication, preparedPublications }) {
207
220
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
208
- const context = preparedPublication
221
+ const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
222
+ if (prepared.length > 1) throw new ValidationError('INVALID_PREPARED_PUBLICATIONS')
223
+ const publication = prepared[0]
224
+ const context = publication
209
225
  ? null
210
226
  : await prepareRoutedMessage({
211
227
  senderSigner,
@@ -218,7 +234,7 @@ export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSi
218
234
  _getIykcProofs
219
235
  })
220
236
  try {
221
- yield * wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })
237
+ yield * wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, onPreparedPublication, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication: publication })
222
238
  } finally {
223
239
  if (context) cleanupEnvelopeRows(context.preparedRows)
224
240
  }
@@ -558,29 +574,208 @@ function withRecoveryRelays (relays, recoveryRelays) {
558
574
  return uniq([...(relays || []), ...(recoveryRelays || [])])
559
575
  }
560
576
 
561
- export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays, preparedPublication }) {
577
+ function withPublicationCoverage (report, event, receivers) {
578
+ if (!report || typeof report !== 'object' || Array.isArray(report)) return report
579
+ return {
580
+ ...report,
581
+ outerEventId: event.id,
582
+ receiverPubkeys: receiverPubkeys(receivers)
583
+ }
584
+ }
585
+
586
+ function expirationRetentionSeconds (publication, expirationSeconds) {
587
+ if (Number.isSafeInteger(expirationSeconds) && expirationSeconds >= 0) return expirationSeconds
588
+ for (const event of publication.events || []) {
589
+ const expiration = Number(event.tags?.find(tag => tag[0] === 'expiration')?.[1])
590
+ if (Number.isSafeInteger(expiration) && expiration >= event.created_at) return expiration - event.created_at
591
+ }
592
+ return null
593
+ }
594
+
595
+ async function refreshPublicationForRetry ({ privateChannelSigner, publication, expirationSeconds }) {
596
+ if (publication?.outerRefreshAttempted === true) throw new ValidationError('OUTER_REFRESH_ALREADY_ATTEMPTED')
597
+ if (!Array.isArray(publication?.events) || publication.events.length === 0) throw new ValidationError('INVALID_PREPARED_PUBLICATION')
598
+ const channelPubkey = await privateChannelSigner.getPublicKey()
599
+ if (publication.channelPubkey !== channelPubkey) throw new ValidationError('INVALID_PREPARED_PUBLICATION')
600
+ const currentTime = nowSeconds()
601
+ const createdAt = Math.max(currentTime, ...publication.events.map(event => event.created_at + 1))
602
+ const retention = expirationRetentionSeconds(publication, expirationSeconds)
603
+ const events = []
604
+ for (const previous of publication.events) {
605
+ if (!isValidEvent(previous) || previous.kind !== PRIVATE_BROADCAST_KIND || previous.pubkey !== channelPubkey) {
606
+ throw new ValidationError('INVALID_PREPARED_PUBLICATION')
607
+ }
608
+ const tags = previous.tags.map(tag => {
609
+ if (tag[0] !== 'expiration' || retention === null) return [...tag]
610
+ return ['expiration', String(createdAt + retention)]
611
+ })
612
+ const refreshed = await privateChannelSigner.signEvent({
613
+ kind: PRIVATE_BROADCAST_KIND,
614
+ created_at: createdAt,
615
+ tags,
616
+ content: previous.content
617
+ })
618
+ if (!isValidEvent(refreshed) || refreshed.id === previous.id || refreshed.kind !== PRIVATE_BROADCAST_KIND || refreshed.pubkey !== channelPubkey ||
619
+ refreshed.created_at !== createdAt || refreshed.content !== previous.content || JSON.stringify(refreshed.tags) !== JSON.stringify(tags)) {
620
+ throw new ValidationError('INVALID_PREPARED_PUBLICATION')
621
+ }
622
+ if (eventByteLength(refreshed) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
623
+ events.push(refreshed)
624
+ }
625
+ return {
626
+ ...structuredClone(publication),
627
+ events,
628
+ outerRefreshAttempted: true
629
+ }
630
+ }
631
+
632
+ async function publishOuterEvents ({
633
+ publication,
634
+ publishRelays,
635
+ primaryRelays,
636
+ receivers,
637
+ privateChannelSigner,
638
+ expirationSeconds,
639
+ onPreparedSeed,
640
+ _publish,
641
+ includeCoverage = true,
642
+ includeRoutingContext = true
643
+ }) {
644
+ let current = publication
645
+ let refreshed = false
646
+ let refreshWork
647
+ const results = []
648
+ for (let index = 0; index < current.events.length; index++) {
649
+ const getRetryEvent = index === 0 && current.outerRefreshAttempted !== true
650
+ ? async ({ event }) => {
651
+ if (refreshed || refreshWork || event.id !== current.events[0].id) return null
652
+ refreshWork = (async () => {
653
+ const next = await refreshPublicationForRetry({ privateChannelSigner, publication: current, expirationSeconds })
654
+ const firstSeed = next.seeds?.[0]
655
+ if (firstSeed && onPreparedSeed) await onPreparedSeed({ ...firstSeed, publication: next }, { replace: true })
656
+ current = next
657
+ refreshed = true
658
+ return current.events[0]
659
+ })()
660
+ return await refreshWork
661
+ }
662
+ : undefined
663
+ const report = await _publish(current.events[index], publishRelays, includeRoutingContext
664
+ ? {
665
+ receiverPubkeys: receiverPubkeys(receivers),
666
+ primaryRelays,
667
+ getRetryEvent
668
+ }
669
+ : { getRetryEvent })
670
+ results.push(includeCoverage ? withPublicationCoverage(report, current.events[index], receivers) : report)
671
+ }
672
+ return { publication: current, results }
673
+ }
674
+
675
+ function relayMapForReceivers (relayToReceivers, receivers) {
676
+ if (!relayToReceivers) return null
677
+ const wanted = new Set(receivers)
678
+ const relays = []
679
+ for (const [relay, values] of relayReceiverEntries(relayToReceivers)) {
680
+ const offered = (Array.isArray(values) ? values : [values])
681
+ .map(value => typeof value === 'string' ? value : receiverPubkeys([value])[0])
682
+ if (!offered.some(pubkey => wanted.has(pubkey))) continue
683
+ relays.push(relay)
684
+ }
685
+ return relays
686
+ }
687
+
688
+ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays, preparedPublication, preparedPublications }) {
562
689
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
563
690
  const results = []
564
- // Relays are grouped only when they have the exact same recipient pubkey set
565
- const groups = groupedRelayReceivers({ relayToReceivers, receivers })
566
- if (groups) {
567
- const context = preparedPublication
568
- ? null
569
- : await prepareRoutedMessage({
570
- senderSigner,
571
- imkcSigner,
691
+ const requestedReceivers = receiverPubkeys(receivers)
692
+ const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
693
+ if (prepared.length) {
694
+ const requested = new Set(requestedReceivers)
695
+ const covered = new Set()
696
+ for (let publicationIndex = 0; publicationIndex < prepared.length; publicationIndex++) {
697
+ const publication = prepared[publicationIndex]
698
+ const candidateReceivers = Array.isArray(publication?.receiverPubkeys)
699
+ ? [...new Set(publication.receiverPubkeys)]
700
+ : []
701
+ const candidateTargets = candidateReceivers.filter(pubkey => requested.has(pubkey))
702
+ if (!candidateTargets.length) continue
703
+ const publicationReceivers = await validatePreparedPublication({
572
704
  privateChannelSigner,
573
- privateChannelReaderPubkey,
574
- receivers,
705
+ senderSigner,
575
706
  event,
576
- temporaryStorageArea,
577
- _getIykcProofs
707
+ preparedPublication: publication
708
+ })
709
+ const targeted = publicationReceivers.filter(pubkey => requested.has(pubkey))
710
+ for (const pubkey of targeted) covered.add(pubkey)
711
+ if (onPreparedSeed) {
712
+ for (const seed of publication.seeds) await onPreparedSeed({ ...seed, publication })
713
+ }
714
+ const targetRelays = relayMapForReceivers(relayToReceivers, targeted) || uniq(relays)
715
+ const publishRelays = withRecoveryRelays(targetRelays, recoveryRelays)
716
+ if (!publishRelays.length) throw new ValidationError('NO_RELAYS')
717
+ const outcome = await publishOuterEvents({
718
+ publication,
719
+ publishRelays,
720
+ primaryRelays: targetRelays,
721
+ receivers: targeted.map(pubkey => ({ receiverPubkey: pubkey })),
722
+ privateChannelSigner,
723
+ expirationSeconds,
724
+ onPreparedSeed,
725
+ _publish
578
726
  })
727
+ prepared[publicationIndex] = outcome.publication
728
+ results.push(...outcome.results)
729
+ }
730
+ if ([...requested].some(pubkey => !covered.has(pubkey))) {
731
+ throw new ValidationError('PREPARED_PUBLICATION_RECEIVER_MISSING')
732
+ }
733
+ return results
734
+ }
735
+ // Relays are grouped only when they have the exact same recipient pubkey set
736
+ const groups = groupedRelayReceivers({ relayToReceivers, receivers })
737
+ if (groups) {
738
+ const context = await prepareRoutedMessage({
739
+ senderSigner,
740
+ imkcSigner,
741
+ privateChannelSigner,
742
+ privateChannelReaderPubkey,
743
+ receivers,
744
+ event,
745
+ temporaryStorageArea,
746
+ _getIykcProofs
747
+ })
579
748
  try {
580
749
  for (const group of groups) {
581
- for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })) {
582
- results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(group.receivers), primaryRelays: group.relays }))
750
+ let publication
751
+ const events = []
752
+ for await (const wrappedEvent of wrapPreparedEvents({
753
+ privateChannelSigner,
754
+ senderSigner,
755
+ event,
756
+ receivers: group.receivers,
757
+ receiverTag,
758
+ fileChunkIndex,
759
+ onPreparedSeed,
760
+ onPreparedPublication: value => { publication = value },
761
+ deletionPubkey: normalizedDeletionPubkey,
762
+ expirationSeconds,
763
+ context
764
+ })) {
765
+ events.push(wrappedEvent)
583
766
  }
767
+ const publishRelays = withRecoveryRelays(group.relays, recoveryRelays)
768
+ const outcome = await publishOuterEvents({
769
+ publication: publication || { events },
770
+ publishRelays,
771
+ primaryRelays: group.relays,
772
+ receivers: group.receivers,
773
+ privateChannelSigner,
774
+ expirationSeconds,
775
+ onPreparedSeed,
776
+ _publish
777
+ })
778
+ results.push(...outcome.results)
584
779
  }
585
780
  } finally {
586
781
  if (context) cleanupEnvelopeRows(context.preparedRows)
@@ -588,9 +783,37 @@ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner
588
783
  return results
589
784
  }
590
785
 
591
- for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs, preparedPublication })) {
592
- results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(receivers), primaryRelays: relays }))
786
+ let publication
787
+ const events = []
788
+ for await (const wrappedEvent of wrapEvents({
789
+ senderSigner,
790
+ imkcSigner,
791
+ privateChannelSigner,
792
+ privateChannelReaderPubkey,
793
+ receivers,
794
+ receiverTag,
795
+ fileChunkIndex,
796
+ onPreparedSeed,
797
+ onPreparedPublication: value => { publication = value },
798
+ deletionPubkey: normalizedDeletionPubkey,
799
+ event,
800
+ expirationSeconds,
801
+ temporaryStorageArea,
802
+ _getIykcProofs
803
+ })) {
804
+ events.push(wrappedEvent)
593
805
  }
806
+ const outcome = await publishOuterEvents({
807
+ publication: publication || { events },
808
+ publishRelays: withRecoveryRelays(relays, recoveryRelays),
809
+ primaryRelays: relays,
810
+ receivers,
811
+ privateChannelSigner,
812
+ expirationSeconds,
813
+ onPreparedSeed,
814
+ _publish
815
+ })
816
+ results.push(...outcome.results)
594
817
  return results
595
818
  }
596
819
 
@@ -598,8 +821,24 @@ export async function publishNymEvent ({ nymSigner, privateChannelSigner, privat
598
821
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
599
822
  const results = []
600
823
  const publishRelays = withRecoveryRelays(relayToReceivers ? relaysFromRelayReceivers(relayToReceivers) : relays, recoveryRelays)
824
+ const events = []
601
825
  for await (const wrappedEvent of wrapNymEvents({ nymSigner, privateChannelSigner, privateChannelReaderPubkey, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds })) {
602
- results.push(await _publish(wrappedEvent, publishRelays))
826
+ events.push(wrappedEvent)
827
+ }
828
+ if (events.length) {
829
+ const channelPubkey = await privateChannelSigner.getPublicKey()
830
+ const outcome = await publishOuterEvents({
831
+ publication: { channelPubkey, receiverPubkeys: [], events, seeds: [] },
832
+ publishRelays,
833
+ primaryRelays: publishRelays,
834
+ receivers: [],
835
+ privateChannelSigner,
836
+ expirationSeconds,
837
+ includeCoverage: false,
838
+ includeRoutingContext: false,
839
+ _publish
840
+ })
841
+ results.push(...outcome.results)
603
842
  }
604
843
  return results
605
844
  }