@unicitylabs/sphere-sdk 0.9.2-dev.0 → 0.9.2-dev.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.
@@ -3013,9 +3013,14 @@ function planOps(params) {
3013
3013
  ops.push({ plainKey, wireKey: wk, baseVersion: cur.version, sealVersion: cur.version + 1, isDelete: false, value });
3014
3014
  }
3015
3015
  }
3016
- for (const [wk, cur] of known) {
3017
- if (!cur.deleted && !liveWire.has(wk) && !reserved?.has(wk)) {
3018
- ops.push({ plainKey: "", wireKey: wk, baseVersion: cur.version, sealVersion: cur.version + 1, isDelete: true });
3016
+ const sweptWire = /* @__PURE__ */ new Set();
3017
+ for (const tokenId of params.tombstonedTokenIds ?? []) {
3018
+ const wk = wireKeyOf(tokenId);
3019
+ if (sweptWire.has(wk)) continue;
3020
+ sweptWire.add(wk);
3021
+ const cur = known.get(wk);
3022
+ if (cur && !cur.deleted && !liveWire.has(wk) && !reserved?.has(wk)) {
3023
+ ops.push({ plainKey: tokenId, wireKey: wk, baseVersion: cur.version, sealVersion: cur.version + 1, isDelete: true });
3019
3024
  }
3020
3025
  }
3021
3026
  return ops;
@@ -3127,10 +3132,13 @@ var LoadDeltaTracker = class {
3127
3132
  */
3128
3133
  async beginLoad(known) {
3129
3134
  this.baseline = await this.loadBaseline();
3130
- this.accState = new Map(known);
3131
- if (this.baseline && !verifyRoot(this.bindingFor(this.baseline.cursor, this.baseline.root), this.baseline.sig, this.ownerPubForVerify())) {
3132
- this.baseline = null;
3135
+ if (this.baseline) {
3136
+ const entries = new Map(this.baseline.entries ?? []);
3137
+ const sigOk = verifyRoot(this.bindingFor(this.baseline.cursor, this.baseline.root), this.baseline.sig, this.ownerPubForVerify());
3138
+ const entriesOk = (this.baseline.entries?.length ?? 0) > 0 && computeRoot(entries) === this.baseline.root;
3139
+ if (!sigOk || !entriesOk) this.baseline = null;
3133
3140
  }
3141
+ this.accState = this.baseline ? new Map(this.baseline.entries ?? []) : new Map(known);
3134
3142
  }
3135
3143
  /**
3136
3144
  * Re-arm the tracker for a SANCTIONED-RESET re-pass (Task 8.2). After a verified
@@ -3162,7 +3170,7 @@ var LoadDeltaTracker = class {
3162
3170
  if (!mono.ok) return mono;
3163
3171
  this.fold(page.entries);
3164
3172
  if (this.baseline) {
3165
- const verdict = this.checkRoot();
3173
+ const verdict = this.checkRegression();
3166
3174
  if (!verdict.ok) return verdict;
3167
3175
  }
3168
3176
  return { ok: true, reset: false };
@@ -3198,14 +3206,22 @@ var LoadDeltaTracker = class {
3198
3206
  this.accState = state;
3199
3207
  }
3200
3208
  /**
3201
- * The folded root must equal the signed baseline root: the wallet is the only
3202
- * writer, so a delta it did not author (no intervening flush advanced the
3203
- * baseline) means the server injected state — a rollback.
3209
+ * Multi-device anti-rollback (finding: vault-anti-rollback-single-writer). Alarm
3210
+ * ONLY on a REGRESSION of a committed entry — a baseline key now MISSING from, or
3211
+ * at a LOWER version than, the folded server state. New keys and higher versions
3212
+ * are legitimate FORWARD progress (this OR another device wrote them), never a
3213
+ * rollback. Vault versions are strictly monotonic per key (create→update→delete→
3214
+ * resurrect all increment), so a version going backwards (or a known entry
3215
+ * vanishing) is the rollback signature, independent of which device authored the
3216
+ * forward writes. (The old root-EQUALITY check assumed a SINGLE writer and so
3217
+ * false-alarmed on every peer-device write — which broke multi-device sync.)
3204
3218
  */
3205
- checkRoot() {
3206
- const folded = computeRoot(this.accState);
3207
- if (folded !== this.baseline.root) {
3208
- return { ok: false, reason: "rollback" };
3219
+ checkRegression() {
3220
+ for (const [key, base] of this.baseline.entries ?? []) {
3221
+ const cur = this.accState.get(key);
3222
+ if (!cur || cur.version < base.version) {
3223
+ return { ok: false, reason: "rollback" };
3224
+ }
3209
3225
  }
3210
3226
  return { ok: true };
3211
3227
  }
@@ -3225,9 +3241,10 @@ var LoadDeltaTracker = class {
3225
3241
  */
3226
3242
  async commitBaseline(cursor, epoch, _epochSig) {
3227
3243
  if (this.config.baseline && this.walletPriv) {
3228
- const root = computeRoot(this.accState ?? /* @__PURE__ */ new Map());
3244
+ const acc = this.accState ?? /* @__PURE__ */ new Map();
3245
+ const root = computeRoot(acc);
3229
3246
  const sig = signRoot(this.walletPriv, this.bindingFor(cursor, root));
3230
- const baseline = { cursor, root, sig, epoch };
3247
+ const baseline = { cursor, root, sig, epoch, entries: [...acc] };
3231
3248
  await this.config.baseline.set(this.baselineKey(), JSON.stringify(baseline));
3232
3249
  }
3233
3250
  this.accState = null;
@@ -3244,7 +3261,7 @@ var LoadDeltaTracker = class {
3244
3261
  const root = computeRoot(known);
3245
3262
  const epoch = this.baseline?.epoch ?? 0;
3246
3263
  const sig = signRoot(this.walletPriv, this.bindingFor(cursor, root));
3247
- const baseline = { cursor, root, sig, epoch };
3264
+ const baseline = { cursor, root, sig, epoch, entries: [...known] };
3248
3265
  this.baseline = baseline;
3249
3266
  await this.config.baseline.set(this.baselineKey(), JSON.stringify(baseline));
3250
3267
  }
@@ -3492,7 +3509,13 @@ var RemoteTokenStorageProvider = class {
3492
3509
  this.wakeClient = new VaultWakeClient({
3493
3510
  vaultUrl: this.config.vaultUrl,
3494
3511
  auth: this.auth,
3495
- onWake: () => this.emit("storage:remote-updated", { source: "vault-wake" })
3512
+ // Only wake when this provider is actually connected. A stale wake (e.g. fired
3513
+ // after shutdown/identity-switch, before stop() lands) must NOT drive a receive/
3514
+ // sync against torn-down wallet storage ("IndexedDBStorageProvider not connected").
3515
+ onWake: () => {
3516
+ if (this.status !== "connected") return;
3517
+ this.emit("storage:remote-updated", { source: "vault-wake" });
3518
+ }
3496
3519
  });
3497
3520
  this.wakeClient.start();
3498
3521
  }
@@ -3576,11 +3599,13 @@ var RemoteTokenStorageProvider = class {
3576
3599
  }
3577
3600
  /** Diff the TXF snapshot vs internal last-known state into a list of CAS ops. */
3578
3601
  planFlush(localData) {
3602
+ const tombstones = localData._tombstones ?? [];
3579
3603
  return planOps({
3580
3604
  tokens: extractTokens(localData),
3581
3605
  known: this.known,
3582
3606
  wireKeyOf: (plainKey) => this.wireKeyFor(plainKey),
3583
- changed: (wk, value) => this.hasChanged(wk, value)
3607
+ changed: (wk, value) => this.hasChanged(wk, value),
3608
+ tombstonedTokenIds: tombstones.map((t) => t.tokenId)
3584
3609
  });
3585
3610
  }
3586
3611
  hasChanged(wk, value) {
@@ -3992,6 +4017,16 @@ var RemoteTokenStorageProvider = class {
3992
4017
  for (const e of this.known.values()) if (!e.deleted) n += 1;
3993
4018
  return n;
3994
4019
  }
4020
+ /**
4021
+ * True if the vault's last-known view has this token's wireKey TOMBSTONED — i.e.
4022
+ * another device SPENT it. PaymentsModule.load uses this to DROP a stale local
4023
+ * token the vault marked spent, so the `delete-resurrect` sync path can't
4024
+ * re-create it and inflate the balance (finding: vault-cross-device-resurrect).
4025
+ * `plainKey` is the genesis tokenId (v2TokenId === engine.tokenId === keyFromTokenId base).
4026
+ */
4027
+ isPlainKeyDeleted(plainKey) {
4028
+ return this.known.get(this.wireKeyFor(plainKey))?.deleted === true;
4029
+ }
3995
4030
  /** Snapshot the internal last-known state as a merkle `EntryState` map. */
3996
4031
  knownAsEntryState() {
3997
4032
  const out = /* @__PURE__ */ new Map();
@@ -13307,26 +13342,52 @@ var PaymentsModule = class _PaymentsModule {
13307
13342
  await TokenRegistry.waitForReady();
13308
13343
  const providers = this.getTokenStorageProviders();
13309
13344
  const ownAddresses = await this.ownMetaAddresses();
13345
+ let mergedData = null;
13346
+ const mergedHistory = [];
13347
+ const tombKeys = /* @__PURE__ */ new Set();
13310
13348
  for (const [id, provider] of providers) {
13311
- try {
13312
- const result = await provider.load();
13313
- if (result.success && result.data) {
13314
- const loadedMeta = result.data?._meta;
13315
- if (!this.isOwnAddress(loadedMeta?.address, ownAddresses)) {
13316
- logger.warn("Payments", `Load: rejecting data from provider ${id} \u2014 address mismatch (got=${loadedMeta.address.slice(0, 20)}... expected one of this wallet's addresses)`);
13317
- continue;
13318
- }
13319
- this.loadFromStorageData(result.data);
13320
- await this.rebuildParsedTokenCache();
13321
- const txfData = result.data;
13322
- if (txfData._history && txfData._history.length > 0) {
13323
- await this.importRemoteHistoryEntries(txfData._history);
13349
+ const result = await provider.load().catch((err) => {
13350
+ logger.error("Payments", `Failed to load from provider ${id}:`, err);
13351
+ return null;
13352
+ });
13353
+ if (!result || !result.success || !result.data) continue;
13354
+ const data = result.data;
13355
+ if (!this.isOwnAddress(data?._meta?.address, ownAddresses)) {
13356
+ logger.warn("Payments", `Load: rejecting data from provider ${id} \u2014 address mismatch (got=${data._meta?.address?.slice(0, 20)}... expected one of this wallet's addresses)`);
13357
+ continue;
13358
+ }
13359
+ if (!mergedData) mergedData = { _meta: data._meta };
13360
+ for (const [k, v] of Object.entries(data)) {
13361
+ if (k === "_meta" || k === "_history" || k === "_tombstones") continue;
13362
+ if (!(k in mergedData)) mergedData[k] = v;
13363
+ }
13364
+ for (const t of data._tombstones ?? []) {
13365
+ const tk = `${t.tokenId}:${t.stateHash}`;
13366
+ if (tombKeys.has(tk)) continue;
13367
+ tombKeys.add(tk);
13368
+ (mergedData._tombstones ??= []).push(t);
13369
+ }
13370
+ if (data._history?.length) mergedHistory.push(...data._history);
13371
+ logger.debug("Payments", `Merged token data from provider ${id}`);
13372
+ }
13373
+ if (mergedData) {
13374
+ const deletionCheckers = [...providers.values()].filter(
13375
+ (p) => typeof p.isPlainKeyDeleted === "function"
13376
+ );
13377
+ if (deletionCheckers.length > 0) {
13378
+ for (const k of Object.keys(mergedData)) {
13379
+ if (!/^_[0-9a-f]{64}$/i.test(k)) continue;
13380
+ const plainKey = k.slice(1);
13381
+ if (deletionCheckers.some((p) => p.isPlainKeyDeleted(plainKey))) {
13382
+ delete mergedData[k];
13383
+ logger.debug("Payments", `load(): dropping ${plainKey.slice(0, 12)}\u2026 \u2014 vault marks it spent (cross-device)`);
13324
13384
  }
13325
- logger.debug("Payments", `Loaded metadata from provider ${id}`);
13326
- break;
13327
13385
  }
13328
- } catch (err) {
13329
- logger.error("Payments", `Failed to load from provider ${id}:`, err);
13386
+ }
13387
+ this.loadFromStorageData(mergedData);
13388
+ await this.rebuildParsedTokenCache();
13389
+ if (mergedHistory.length > 0) {
13390
+ await this.importRemoteHistoryEntries(mergedHistory);
13330
13391
  }
13331
13392
  }
13332
13393
  for (const [id, token] of this.tokens) {
@@ -15140,12 +15201,22 @@ var PaymentsModule = class _PaymentsModule {
15140
15201
  }
15141
15202
  this.syncDebounceTimer = setTimeout(() => {
15142
15203
  this.syncDebounceTimer = null;
15204
+ const storage = this.deps?.storage;
15205
+ if (storage?.isConnected && !storage.isConnected()) {
15206
+ logger.debug("Payments", "Remote-update wake ignored \u2014 wallet storage not connected");
15207
+ return;
15208
+ }
15143
15209
  void (async () => {
15144
15210
  try {
15145
15211
  await this.receive();
15146
15212
  } catch (err) {
15147
15213
  logger.debug("Payments", "Auto-receive from remote update failed:", err);
15148
15214
  }
15215
+ try {
15216
+ await this.load();
15217
+ } catch (err) {
15218
+ logger.debug("Payments", "Auto-load (reconcile) from remote update failed:", err);
15219
+ }
15149
15220
  try {
15150
15221
  const result = await this.sync();
15151
15222
  const data = eventData;