From 592cd3d3ce322aa9c75579a8e64ab9e244c2b90a Mon Sep 17 00:00:00 2001 From: David Case Date: Thu, 23 Apr 2026 15:31:15 -0400 Subject: [PATCH] EntityTransaction.mergeFind: match by (userId, txid) first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prefer txid when the incoming row has one, falling through to reference only for pre-broadcast rows. `reference` is locally-assigned by whichever storage first ingested the row — it is not a cross-storage identifier — so matching by reference alone duplicates rows whenever two storages independently internalized the same txid under different local references. The fall-through preserves the existing behavior for the createAction → signAction path: when a pre-broadcast row exists on the receiver under some reference and the sender is now delivering the broadcast version with both reference and txid present, the txid lookup misses on the receiver's pre-broadcast row and the reference fall-through finds it so `mergeExisting` stamps the txid on the same row instead of inserting a twin. `mergeExisting` already treats `reference` as never-updated, so post-fix references on each side remain whatever the local storage assigned. Surfaced while debugging duplicate ordinals in yours-wallet after the user wiped local IndexedDB, let `syncAddresses` internalize BRC-29 payments into the empty local store (generating fresh local references), then connected a remote that already held those same txids under its own references. Connecting the remote ran a sync, the old mergeFind by reference missed on both sides, and every ordinal duplicated. --- .../schema/entities/EntityTransaction.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/storage/schema/entities/EntityTransaction.ts b/src/storage/schema/entities/EntityTransaction.ts index c8e4b786..1bccc0ab 100644 --- a/src/storage/schema/entities/EntityTransaction.ts +++ b/src/storage/schema/entities/EntityTransaction.ts @@ -240,12 +240,25 @@ export class EntityTransaction extends EntityBase { syncMap: SyncMap, trx?: TrxToken ): Promise<{ found: boolean; eo: EntityTransaction; eiId: number }> { - const ef = verifyOneOrNone( - await storage.findTransactions({ - partial: { reference: ei.reference, userId }, - trx - }) - ) + // Prefer (userId, txid) when txid is known — txid is a globally stable + // identifier, whereas `reference` is locally-assigned by whichever + // storage first ingested the row. Two storages that independently + // internalized the same txid will hold different references, and + // matching by reference would insert a duplicate row instead of + // merging. Fall through to reference when the txid lookup misses so + // post-broadcast syncs land on an existing pre-broadcast row that + // hasn't learned its txid yet. + let ef: TableTransaction | undefined + if (ei.txid) { + ef = verifyOneOrNone( + await storage.findTransactions({ partial: { txid: ei.txid, userId }, trx }) + ) + } + if (!ef && ei.reference) { + ef = verifyOneOrNone( + await storage.findTransactions({ partial: { reference: ei.reference, userId }, trx }) + ) + } return { found: !!ef, eo: new EntityTransaction(ef || { ...ei }),