@xyo-network/xl1-protocol-sdk 1.29.8 → 1.30.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.
@@ -4051,7 +4051,6 @@ import {
4051
4051
  asSignedHydratedBlockWithStorageMeta,
4052
4052
  asXL1BlockNumber as asXL1BlockNumber8,
4053
4053
  BlockViewerMoniker as BlockViewerMoniker2,
4054
- DataLakeViewerMoniker,
4055
4054
  FinalizationViewerMoniker
4056
4055
  } from "@xyo-network/xl1-protocol-lib";
4057
4056
 
@@ -4092,10 +4091,9 @@ var MIN_HEAD_POLL_INTERVAL_MS = 5e3;
4092
4091
  var SimpleBlockViewer = class extends AbstractCreatableProvider {
4093
4092
  moniker = SimpleBlockViewer.defaultMoniker;
4094
4093
  _store;
4095
- dataLakeViewer;
4096
4094
  finalizationViewer;
4097
4095
  payloadCache = new LruCacheMap({ max: 1e4 });
4098
- signedHydratedBlockWithDataLakePayloadsCache = new LruCacheMap({ max: 2e3, ttl: 1e3 * 60 * 60 });
4096
+ signedHydratedBlockWithHashMetaCache = new LruCacheMap({ max: 2e3, ttl: 1e3 * 60 * 60 });
4099
4097
  _headPollHash;
4100
4098
  _headPollInProgress = false;
4101
4099
  _headPollTimer = null;
@@ -4131,17 +4129,16 @@ var SimpleBlockViewer = class extends AbstractCreatableProvider {
4131
4129
  }
4132
4130
  async blockByHash(hash) {
4133
4131
  return await this.spanAsync("blockByHash", async () => {
4134
- const cachedBlock = this.signedHydratedBlockWithDataLakePayloadsCache.get(hash);
4132
+ const cachedBlock = this.signedHydratedBlockWithHashMetaCache.get(hash);
4135
4133
  if (cachedBlock) {
4136
4134
  return cachedBlock;
4137
4135
  }
4138
4136
  const cache = this.hydratedBlockCache;
4139
4137
  const block = await cache.get(hash);
4140
- const [result] = block ? await addDataLakePayloads(block, this.dataLakeViewer) : [null, []];
4141
- if (result) {
4142
- this.signedHydratedBlockWithDataLakePayloadsCache.set(hash, result);
4138
+ if (block) {
4139
+ this.signedHydratedBlockWithHashMetaCache.set(hash, block);
4143
4140
  }
4144
- return result;
4141
+ return block ?? null;
4145
4142
  }, { ...this.context, timeBudgetLimit: 100 });
4146
4143
  }
4147
4144
  async blockByNumber(blockNumber) {
@@ -4194,13 +4191,11 @@ var SimpleBlockViewer = class extends AbstractCreatableProvider {
4194
4191
  }
4195
4192
  async createHandler() {
4196
4193
  await super.createHandler();
4197
- this.dataLakeViewer = await this.locator.tryGetInstance(DataLakeViewerMoniker);
4198
4194
  this.finalizationViewer = await this.locator.getInstance(FinalizationViewerMoniker);
4199
4195
  this._store = { chainMap: this.params.finalizedArchivist };
4200
4196
  }
4201
4197
  async currentBlock() {
4202
- const [result] = await addDataLakePayloads(await this.finalizationViewer.head(), this.dataLakeViewer);
4203
- return result;
4198
+ return await this.finalizationViewer.head();
4204
4199
  }
4205
4200
  async currentBlockHash() {
4206
4201
  return await this.finalizationViewer.headHash();
@@ -4218,7 +4213,7 @@ var SimpleBlockViewer = class extends AbstractCreatableProvider {
4218
4213
  const cachedHashes = new Set(cachedPayloads.map((p) => p._hash));
4219
4214
  remainingHashes = remainingHashes.filter((h) => !cachedHashes.has(h));
4220
4215
  const finalizedPayloads = remainingHashes.length > 0 ? await this.finalizedArchivist.get(remainingHashes) : [];
4221
- const [resultPayloads] = await addDataLakePayloadsToPayloads(hashes, [...cachedPayloads, ...finalizedPayloads.filter(exists4)], this.dataLakeViewer);
4216
+ const resultPayloads = [...cachedPayloads, ...finalizedPayloads.filter(exists4)];
4222
4217
  resultPayloads.map((payload) => {
4223
4218
  this.payloadCache.set(payload._hash, payload);
4224
4219
  });
@@ -4257,9 +4252,7 @@ var SimpleBlockViewer = class extends AbstractCreatableProvider {
4257
4252
  await super.stopHandler();
4258
4253
  }
4259
4254
  async blockByNumberWithContext(chainContext, blockNumber) {
4260
- const block = asSignedHydratedBlockWithHashMeta(await hydratedBlockByNumber(chainContext, blockNumber)) ?? null;
4261
- const [result] = block ? await addDataLakePayloads(block, this.dataLakeViewer) : [null, []];
4262
- return result;
4255
+ return asSignedHydratedBlockWithHashMeta(await hydratedBlockByNumber(chainContext, blockNumber)) ?? null;
4263
4256
  }
4264
4257
  async pollHead(emitOnChange) {
4265
4258
  if (this._headPollInProgress) return;
@@ -4629,14 +4622,14 @@ RestDataLakeRunner = __decorateClass([
4629
4622
 
4630
4623
  // src/simple/datalake/RestDataLakeViewer.ts
4631
4624
  import {
4632
- DataLakeViewerMoniker as DataLakeViewerMoniker2
4625
+ DataLakeViewerMoniker
4633
4626
  } from "@xyo-network/xl1-protocol-lib";
4634
4627
  var RestDataLakeViewer = class extends AbstractRestDataLake {
4635
4628
  moniker = RestDataLakeViewer.defaultMoniker;
4636
4629
  };
4637
- __publicField(RestDataLakeViewer, "defaultMoniker", DataLakeViewerMoniker2);
4630
+ __publicField(RestDataLakeViewer, "defaultMoniker", DataLakeViewerMoniker);
4638
4631
  __publicField(RestDataLakeViewer, "dependencies", []);
4639
- __publicField(RestDataLakeViewer, "monikers", [DataLakeViewerMoniker2]);
4632
+ __publicField(RestDataLakeViewer, "monikers", [DataLakeViewerMoniker]);
4640
4633
  RestDataLakeViewer = __decorateClass([
4641
4634
  creatableProvider()
4642
4635
  ], RestDataLakeViewer);
@@ -4714,14 +4707,14 @@ SimpleDataLakeRunner = __decorateClass([
4714
4707
 
4715
4708
  // src/simple/datalake/SimpleDataLakeViewer.ts
4716
4709
  import {
4717
- DataLakeViewerMoniker as DataLakeViewerMoniker3
4710
+ DataLakeViewerMoniker as DataLakeViewerMoniker2
4718
4711
  } from "@xyo-network/xl1-protocol-lib";
4719
4712
  var SimpleDataLakeViewer = class extends AbstractSimpleDataLake {
4720
4713
  moniker = SimpleDataLakeViewer.defaultMoniker;
4721
4714
  };
4722
- __publicField(SimpleDataLakeViewer, "defaultMoniker", DataLakeViewerMoniker3);
4715
+ __publicField(SimpleDataLakeViewer, "defaultMoniker", DataLakeViewerMoniker2);
4723
4716
  __publicField(SimpleDataLakeViewer, "dependencies", []);
4724
- __publicField(SimpleDataLakeViewer, "monikers", [DataLakeViewerMoniker3]);
4717
+ __publicField(SimpleDataLakeViewer, "monikers", [DataLakeViewerMoniker2]);
4725
4718
  SimpleDataLakeViewer = __decorateClass([
4726
4719
  creatableProvider()
4727
4720
  ], SimpleDataLakeViewer);
@@ -4980,18 +4973,26 @@ import {
4980
4973
  isSignedHydratedBlockWithHashMeta,
4981
4974
  isSignedHydratedTransactionWithHashMeta,
4982
4975
  MempoolRunnerMoniker,
4976
+ MempoolViewerMoniker,
4983
4977
  TransactionRejectionSchema,
4984
4978
  TransactionValidationViewerMoniker
4985
4979
  } from "@xyo-network/xl1-protocol-lib";
4986
4980
  import { Mutex } from "async-mutex";
4987
4981
  var DEFAULT_SYNC_INTERVAL = 3e4;
4988
4982
  var DEFAULT_SYNC_LIMIT = 100;
4983
+ var ENFORCE_CAP_BATCH_SIZE = 1e3;
4984
+ function isDemotionAware(viewer) {
4985
+ if (!viewer) return false;
4986
+ const candidate = viewer;
4987
+ return typeof candidate.forget === "function" && typeof candidate.getEvictionPriorityOrder === "function";
4988
+ }
4989
4989
  var SimpleMempoolRunner = class extends AbstractCreatableProvider {
4990
4990
  moniker = SimpleMempoolRunner.defaultMoniker;
4991
4991
  _blockValidationViewer;
4992
4992
  _chainContractViewer;
4993
4993
  _deadLetterQueueRunner;
4994
4994
  _finalizationViewer;
4995
+ _mempoolViewer;
4995
4996
  _transactionValidationViewer;
4996
4997
  _syncMutex = new Mutex();
4997
4998
  _syncTimerId = null;
@@ -5010,6 +5011,9 @@ var SimpleMempoolRunner = class extends AbstractCreatableProvider {
5010
5011
  get maxExpAhead() {
5011
5012
  return this.params.maxExpAhead ?? DEFAULT_MAX_EXP_AHEAD;
5012
5013
  }
5014
+ get maxPendingTransactions() {
5015
+ return this.params.maxPendingTransactions ?? 0;
5016
+ }
5013
5017
  get pendingBlocksArchivist() {
5014
5018
  return this.params.pendingBlocksArchivist;
5015
5019
  }
@@ -5042,6 +5046,7 @@ var SimpleMempoolRunner = class extends AbstractCreatableProvider {
5042
5046
  this._finalizationViewer = await this.locator.getInstance(FinalizationViewerMoniker4);
5043
5047
  this._transactionValidationViewer = await this.locator.getInstance(TransactionValidationViewerMoniker);
5044
5048
  this._deadLetterQueueRunner = await this.locator.tryGetInstance(DeadLetterQueueRunnerMoniker);
5049
+ this._mempoolViewer = await this.locator.tryGetInstance(MempoolViewerMoniker);
5045
5050
  }
5046
5051
  async prunePendingBlocks({
5047
5052
  batchSize = 10,
@@ -5156,6 +5161,7 @@ var SimpleMempoolRunner = class extends AbstractCreatableProvider {
5156
5161
  pruned += pruneHashes.length;
5157
5162
  total += batch.length;
5158
5163
  await this.pendingTransactionsArchivist.delete(pruneHashes);
5164
+ this.forgetBundleHashes(pruneHashes);
5159
5165
  const pruneSet = new Set(pruneHashes);
5160
5166
  const lastSurvivor = batch.findLast((p) => !pruneSet.has(p._hash));
5161
5167
  cursor = lastSurvivor?._sequence ?? cursor;
@@ -5165,8 +5171,7 @@ var SimpleMempoolRunner = class extends AbstractCreatableProvider {
5165
5171
  order: "desc"
5166
5172
  });
5167
5173
  }
5168
- this.logger?.debug(`prunePendingTransactions completed: pruned=${pruned}, totalChecked=${total}`);
5169
- return [pruned, total];
5174
+ return this.finalizePruneTransactionsResult(pruned, total);
5170
5175
  }
5171
5176
  async submitBlocks(blocks) {
5172
5177
  const bundles = await Promise.all(blocks.map(async ([bw, payloads]) => {
@@ -5212,6 +5217,7 @@ var SimpleMempoolRunner = class extends AbstractCreatableProvider {
5212
5217
  }
5213
5218
  const bundles = hashedTransactions.map((tx) => hydratedTransactionToPayloadBundle(tx));
5214
5219
  const inserted = await this.pendingTransactionsArchivist.insert(bundles);
5220
+ await this.enforceCap();
5215
5221
  return inserted.map((p) => p._hash);
5216
5222
  }
5217
5223
  async startHandler() {
@@ -5230,6 +5236,50 @@ var SimpleMempoolRunner = class extends AbstractCreatableProvider {
5230
5236
  this._syncTimerId = null;
5231
5237
  }
5232
5238
  }
5239
+ async collectAllPendingTransactionBundles() {
5240
+ const all = [];
5241
+ let cursor;
5242
+ while (true) {
5243
+ const batch = await this.pendingTransactionsArchivist.next({
5244
+ limit: ENFORCE_CAP_BATCH_SIZE,
5245
+ cursor,
5246
+ order: "asc"
5247
+ });
5248
+ if (batch.length === 0) break;
5249
+ all.push(...batch);
5250
+ cursor = batch.at(-1)?._sequence;
5251
+ if (batch.length < ENFORCE_CAP_BATCH_SIZE) break;
5252
+ }
5253
+ return all;
5254
+ }
5255
+ async enforceCap() {
5256
+ const cap = this.maxPendingTransactions;
5257
+ if (cap <= 0) return;
5258
+ const all = await this.collectAllPendingTransactionBundles();
5259
+ if (all.length <= cap) return;
5260
+ const excess = all.length - cap;
5261
+ const bundleHashesOldestFirst = all.map((p) => p._hash);
5262
+ const orderedForEviction = this.orderForEviction(bundleHashesOldestFirst);
5263
+ const toEvict = orderedForEviction.slice(0, excess);
5264
+ await this.pendingTransactionsArchivist.delete(toEvict);
5265
+ this.forgetBundleHashes(toEvict);
5266
+ this.logger?.debug(`enforceCap evicted ${toEvict.length} bundles (pool=${all.length}, cap=${cap})`);
5267
+ }
5268
+ async finalizePruneTransactionsResult(pruned, total) {
5269
+ this.logger?.debug(`prunePendingTransactions completed: pruned=${pruned}, totalChecked=${total}`);
5270
+ await this.enforceCap();
5271
+ return [pruned, total];
5272
+ }
5273
+ forgetBundleHashes(bundleHashes) {
5274
+ if (bundleHashes.length === 0) return;
5275
+ if (isDemotionAware(this._mempoolViewer)) this._mempoolViewer.forget(bundleHashes);
5276
+ }
5277
+ orderForEviction(bundleHashesOldestFirst) {
5278
+ if (isDemotionAware(this._mempoolViewer)) {
5279
+ return this._mempoolViewer.getEvictionPriorityOrder(bundleHashesOldestFirst);
5280
+ }
5281
+ return bundleHashesOldestFirst;
5282
+ }
5233
5283
  async routeRejectedTransaction(transaction, errors) {
5234
5284
  if (!this._deadLetterQueueRunner) return;
5235
5285
  const rejectionErrors = errors.map((e) => ({
@@ -5342,13 +5392,22 @@ import {
5342
5392
  } from "@xylabs/sdk-js";
5343
5393
  import { isHashMeta as isHashMeta2, isPayloadBundle as isPayloadBundle2 } from "@xyo-network/sdk-js";
5344
5394
  import {
5345
- MempoolViewerMoniker,
5395
+ MempoolViewerMoniker as MempoolViewerMoniker2,
5346
5396
  WindowedBlockViewerMoniker
5347
5397
  } from "@xyo-network/xl1-protocol-lib";
5348
5398
  var DEFAULT_MEMPOOL_SELECTION_RATIO = 0.66;
5399
+ var DEFAULT_DEMOTION_THRESHOLD = 3;
5400
+ var DEFAULT_HANDOUT_STATS_TTL_BLOCKS = 1e3;
5349
5401
  var SimpleMempoolViewer = class extends AbstractCreatableProvider {
5350
5402
  moniker = SimpleMempoolViewer.defaultMoniker;
5403
+ _handoutStats = /* @__PURE__ */ new Map();
5351
5404
  _windowedBlockViewer;
5405
+ get demotionThreshold() {
5406
+ return this.params.demotionThreshold ?? DEFAULT_DEMOTION_THRESHOLD;
5407
+ }
5408
+ get handoutStatsTtlBlocks() {
5409
+ return this.params.handoutStatsTtlBlocks ?? DEFAULT_HANDOUT_STATS_TTL_BLOCKS;
5410
+ }
5352
5411
  get pendingBlocksArchivist() {
5353
5412
  return this.params.pendingBlocksArchivist;
5354
5413
  }
@@ -5362,6 +5421,32 @@ var SimpleMempoolViewer = class extends AbstractCreatableProvider {
5362
5421
  await super.createHandler();
5363
5422
  this._windowedBlockViewer = await this.locator.getInstance(WindowedBlockViewerMoniker);
5364
5423
  }
5424
+ /** Drop handout stats for the given bundle hashes. Called when a bundle has been evicted or otherwise removed from the pool. */
5425
+ forget(bundleHashes) {
5426
+ for (const hash of bundleHashes) this._handoutStats.delete(hash);
5427
+ }
5428
+ /** Return the subset of the given bundle hashes that are currently considered demoted. */
5429
+ getDemotedBundleHashes(bundleHashes) {
5430
+ return bundleHashes.filter((hash) => this.isDemoted(hash));
5431
+ }
5432
+ /**
5433
+ * Return the bundle hashes in the order they should be evicted under size pressure.
5434
+ * Demoted entries come first, sorted by handouts descending; the remainder is left as-is
5435
+ * so the caller can append by FIFO sequence order.
5436
+ */
5437
+ getEvictionPriorityOrder(bundleHashes) {
5438
+ const demoted = [];
5439
+ const nonDemoted = [];
5440
+ for (const hash of bundleHashes) {
5441
+ if (this.isDemoted(hash)) demoted.push(hash);
5442
+ else nonDemoted.push(hash);
5443
+ }
5444
+ demoted.sort((a, b) => (this._handoutStats.get(b)?.handouts ?? 0) - (this._handoutStats.get(a)?.handouts ?? 0));
5445
+ return [...demoted, ...nonDemoted];
5446
+ }
5447
+ getHandoutStats(bundleHash) {
5448
+ return this._handoutStats.get(bundleHash);
5449
+ }
5365
5450
  async pendingBlocks({ cursor: providedCursor } = {}) {
5366
5451
  let cursor = void 0;
5367
5452
  if (isHash2(providedCursor)) {
@@ -5404,6 +5489,7 @@ var SimpleMempoolViewer = class extends AbstractCreatableProvider {
5404
5489
  })
5405
5490
  )).filter(exists9);
5406
5491
  const currentBlock = await this.windowedBlockViewer.currentBlock();
5492
+ const currentBlockNumber = currentBlock[0].block;
5407
5493
  const evaluated = await Promise.all(
5408
5494
  hydratedWithBundle.map(async ({ bundle: bundle3, tx }) => ({
5409
5495
  bundle: bundle3,
@@ -5417,25 +5503,43 @@ var SimpleMempoolViewer = class extends AbstractCreatableProvider {
5417
5503
  await Promise.all(
5418
5504
  deletionCandidates.map(async ({ bundle: bundle3, tx }) => {
5419
5505
  await this.deleteBundledTransaction(bundle3);
5506
+ this._handoutStats.delete(bundle3._hash);
5420
5507
  this.logger?.debug(`Purged completed/expired bundled transaction: ${bundle3._hash}/${tx[0]._hash}`);
5421
5508
  })
5422
5509
  );
5423
- const inclusionCandidates = (await Promise.all(validTransactions.map((x) => x.tx).map(async (tx) => {
5424
- if (await this.isInclusionCandidate(tx, currentBlock, false)) return tx;
5510
+ this.gcHandoutStats(currentBlockNumber);
5511
+ const inclusionCandidates = (await Promise.all(validTransactions.map(async ({ bundle: bundle3, tx }) => {
5512
+ if (await this.isInclusionCandidate(tx, currentBlock, false)) return { bundle: bundle3, tx };
5425
5513
  }))).filter(exists9);
5426
5514
  const selectionRatio = this.params.mempoolSelectionRatio ?? DEFAULT_MEMPOOL_SELECTION_RATIO;
5427
- const maxByRatio = Math.ceil(inclusionCandidates.length * selectionRatio);
5428
- const effectiveLimit = Math.min(limit, maxByRatio);
5429
- const randomInclusionCandidates = deduplicateBySigner(
5430
- inclusionCandidates.filter(() => Math.random() < selectionRatio)
5431
- ).slice(0, effectiveLimit);
5432
- const result = randomInclusionCandidates.length > 0 ? randomInclusionCandidates : deduplicateBySigner(inclusionCandidates).slice(0, 1);
5433
- this.logger?.debug(`Inclusion candidates: ${inclusionCandidates.length}`);
5434
- return result;
5515
+ const nonDemoted = inclusionCandidates.filter(({ bundle: bundle3 }) => !this.isDemoted(bundle3._hash));
5516
+ const demoted = inclusionCandidates.filter(({ bundle: bundle3 }) => this.isDemoted(bundle3._hash));
5517
+ const primary = this.selectWithRatio(nonDemoted, limit, selectionRatio);
5518
+ const topupNeeded = limit - primary.length;
5519
+ const topup = topupNeeded > 0 ? this.selectWithRatio(demoted, topupNeeded, selectionRatio) : [];
5520
+ let combined = [...primary, ...topup];
5521
+ if (combined.length === 0 && inclusionCandidates.length > 0) {
5522
+ combined = deduplicateWithBundleBySigner(inclusionCandidates).slice(0, 1);
5523
+ }
5524
+ for (const { bundle: bundle3 } of combined) {
5525
+ this.recordHandout(bundle3._hash, currentBlockNumber);
5526
+ }
5527
+ this.logger?.debug(`Inclusion candidates: ${inclusionCandidates.length} (nonDemoted=${nonDemoted.length}, demoted=${demoted.length}); returning ${combined.length}`);
5528
+ return combined.map(({ tx }) => tx);
5529
+ }
5530
+ isDemoted(bundleHash) {
5531
+ const stats = this._handoutStats.get(bundleHash);
5532
+ return stats !== void 0 && stats.handouts >= this.demotionThreshold;
5435
5533
  }
5436
5534
  async deleteBundledTransaction(bundle3) {
5437
5535
  await this.pendingTransactionsArchivist.delete([bundle3._hash]);
5438
5536
  }
5537
+ gcHandoutStats(currentBlockNumber) {
5538
+ const ttl = this.handoutStatsTtlBlocks;
5539
+ for (const [hash, stats] of this._handoutStats) {
5540
+ if (currentBlockNumber - stats.firstHandoutAt > ttl) this._handoutStats.delete(hash);
5541
+ }
5542
+ }
5439
5543
  /**
5440
5544
  * Evaluates a transaction to determine if it should be purged from the mempool.
5441
5545
  * @param tx The transaction to evaluate
@@ -5468,16 +5572,31 @@ var SimpleMempoolViewer = class extends AbstractCreatableProvider {
5468
5572
  if (checkForDeletable && await this.isDeletable(tx, currentBlock)) return false;
5469
5573
  return true;
5470
5574
  }
5575
+ recordHandout(bundleHash, currentBlockNumber) {
5576
+ const existing = this._handoutStats.get(bundleHash);
5577
+ if (existing) {
5578
+ existing.handouts += 1;
5579
+ } else {
5580
+ this._handoutStats.set(bundleHash, { handouts: 1, firstHandoutAt: currentBlockNumber });
5581
+ }
5582
+ }
5583
+ selectWithRatio(group, take, selectionRatio) {
5584
+ if (take <= 0 || group.length === 0) return [];
5585
+ const maxByRatio = Math.ceil(group.length * selectionRatio);
5586
+ const effectiveLimit = Math.min(take, maxByRatio);
5587
+ const randomSelected = group.filter(() => Math.random() < selectionRatio);
5588
+ return deduplicateWithBundleBySigner(randomSelected).slice(0, effectiveLimit);
5589
+ }
5471
5590
  };
5472
- __publicField(SimpleMempoolViewer, "defaultMoniker", MempoolViewerMoniker);
5591
+ __publicField(SimpleMempoolViewer, "defaultMoniker", MempoolViewerMoniker2);
5473
5592
  __publicField(SimpleMempoolViewer, "dependencies", [WindowedBlockViewerMoniker]);
5474
- __publicField(SimpleMempoolViewer, "monikers", [MempoolViewerMoniker]);
5593
+ __publicField(SimpleMempoolViewer, "monikers", [MempoolViewerMoniker2]);
5475
5594
  SimpleMempoolViewer = __decorateClass([
5476
5595
  creatableProvider()
5477
5596
  ], SimpleMempoolViewer);
5478
- function deduplicateBySigner(txs) {
5597
+ function deduplicateWithBundleBySigner(items) {
5479
5598
  const seen = /* @__PURE__ */ new Set();
5480
- return txs.filter((tx) => {
5599
+ return items.filter(({ tx }) => {
5481
5600
  const key = tx[0].addresses.toSorted().join(",");
5482
5601
  if (seen.has(key)) return false;
5483
5602
  seen.add(key);