@totemsdk/wots-lease 0.1.6 → 1.0.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/dist/local.js CHANGED
@@ -22,21 +22,129 @@ class LocalLeaseProvider {
22
22
  constructor(storage, logger = new core_1.NoopLogger(), deviceId = 'local') {
23
23
  this.logger = logger;
24
24
  this._initialized = false;
25
+ this.initializePromise = null;
26
+ this.mutationTail = Promise.resolve();
25
27
  this.watermark = new watermark_js_1.WotsWatermarkStore(storage, logger);
26
28
  this.leaseStore = new core_2.LeaseStore(storage, logger);
27
29
  this.journal = new journal_js_1.LeaseJournal(storage, logger);
28
30
  this.deviceId = deviceId;
31
+ this.cas = LocalLeaseProvider.detectCas(storage);
32
+ }
33
+ static detectCas(storage) {
34
+ const candidate = storage;
35
+ return typeof candidate.conditionalUpdate === 'function' ? candidate : undefined;
29
36
  }
30
37
  async initialize() {
31
38
  if (this._initialized)
32
39
  return;
40
+ if (!this.initializePromise) {
41
+ this.initializePromise = this.initializeOnce().catch((error) => {
42
+ this.initializePromise = null;
43
+ throw error;
44
+ });
45
+ }
46
+ await this.initializePromise;
47
+ }
48
+ async initializeOnce() {
49
+ await this.journal.initialize();
33
50
  await this.watermark.initialize();
34
51
  await this.leaseStore.initialize();
35
- await this.journal.initialize();
52
+ await this.reconcileDurableSafetyState();
36
53
  await this.recoverExpiredReservations();
37
54
  await this.burnUnresolvedReservations();
38
55
  this._initialized = true;
39
56
  }
57
+ /**
58
+ * Rebuild the no-reuse watermark from immutable journal history and leases.
59
+ * The journal is audit authority; the watermark remains allocation authority.
60
+ */
61
+ async reconcileDurableSafetyState() {
62
+ const entries = this.journal.getAll();
63
+ const latestBySlot = new Map();
64
+ const latestByReservation = new Map();
65
+ for (const entry of entries) {
66
+ latestBySlot.set(`${entry.treeId}:${entry.wotsIndex}`, entry);
67
+ if (entry.reservationId)
68
+ latestByReservation.set(entry.reservationId, entry);
69
+ }
70
+ for (const entry of latestBySlot.values()) {
71
+ await this.watermark.markUnavailable(entry.treeId, entry.indices, entry.status);
72
+ }
73
+ const leases = this.leaseStore.getAll();
74
+ const leaseIds = new Set(leases.map((lease) => lease.leaseId));
75
+ for (const entry of latestByReservation.values()) {
76
+ if (entry.status !== 'reserved' || leaseIds.has(entry.reservationId))
77
+ continue;
78
+ await this.journal.append({
79
+ ...entry,
80
+ status: 'burned',
81
+ timestamp: Date.now(),
82
+ });
83
+ await this.watermark.markUnavailable(entry.treeId, entry.indices, 'burned');
84
+ }
85
+ for (const lease of leases) {
86
+ const treeId = lease.treeId ?? 'default';
87
+ const latest = this.journal.getByReservation(lease.leaseId);
88
+ if (!latest) {
89
+ const status = lease.status === 'finalized'
90
+ ? 'committed'
91
+ : lease.status === 'cancelled'
92
+ ? 'burned'
93
+ : lease.status === 'expired'
94
+ ? 'reserved-expired'
95
+ : 'reserved';
96
+ await this.journal.append({
97
+ treeId,
98
+ branchId: 'default',
99
+ wotsIndex: (0, watermark_js_1.flatIndex)(lease.indices),
100
+ indices: lease.indices,
101
+ status,
102
+ reservationId: lease.leaseId,
103
+ ...(lease.txId !== undefined ? { txId: lease.txId } : {}),
104
+ timestamp: lease.createdAt,
105
+ deviceId: this.deviceId,
106
+ });
107
+ await this.watermark.markUnavailable(treeId, lease.indices, status);
108
+ continue;
109
+ }
110
+ if (latest.status === 'committed' && lease.status !== 'finalized') {
111
+ await this.leaseStore.updateStatus(lease.leaseId, 'finalized');
112
+ }
113
+ else if (latest.status === 'burned' && lease.status !== 'cancelled') {
114
+ await this.leaseStore.updateStatus(lease.leaseId, 'cancelled');
115
+ }
116
+ else if (latest.status === 'reserved-expired' && lease.status !== 'expired') {
117
+ await this.leaseStore.updateStatus(lease.leaseId, 'expired');
118
+ }
119
+ else if (latest.status === 'reserved' && lease.status === 'finalized') {
120
+ await this.journal.append({
121
+ treeId,
122
+ branchId: 'default',
123
+ wotsIndex: (0, watermark_js_1.flatIndex)(lease.indices),
124
+ indices: lease.indices,
125
+ status: 'committed',
126
+ reservationId: lease.leaseId,
127
+ ...(lease.txId !== undefined ? { txId: lease.txId } : {}),
128
+ timestamp: Date.now(),
129
+ deviceId: this.deviceId,
130
+ });
131
+ await this.watermark.markUnavailable(treeId, lease.indices, 'committed');
132
+ }
133
+ else if (latest.status === 'reserved' && lease.status === 'cancelled') {
134
+ await this.journal.append({
135
+ treeId,
136
+ branchId: 'default',
137
+ wotsIndex: (0, watermark_js_1.flatIndex)(lease.indices),
138
+ indices: lease.indices,
139
+ status: 'burned',
140
+ reservationId: lease.leaseId,
141
+ timestamp: Date.now(),
142
+ deviceId: this.deviceId,
143
+ });
144
+ await this.watermark.markUnavailable(treeId, lease.indices, 'burned');
145
+ }
146
+ }
147
+ }
40
148
  /** Burn all active (non-expired) leases that have no matching treeId.
41
149
  * On restart, leases with missing treeId (from before the treeId field was added)
42
150
  * must be burned to prevent index reuse. */
@@ -45,46 +153,56 @@ class LocalLeaseProvider {
45
153
  for (const lease of this.leaseStore.getAll()) {
46
154
  if (lease.status === 'active' && lease.expiresAt >= now && !lease.treeId) {
47
155
  this.logger.warn(`[LocalLeaseProvider] Burning unresolved lease ${lease.leaseId} (no treeId)`);
156
+ if (this.journal.getByReservation(lease.leaseId)?.status !== 'burned') {
157
+ await this.journal.append({
158
+ treeId: 'default',
159
+ branchId: 'default',
160
+ wotsIndex: (0, watermark_js_1.flatIndex)(lease.indices),
161
+ indices: lease.indices,
162
+ status: 'burned',
163
+ reservationId: lease.leaseId,
164
+ timestamp: now,
165
+ deviceId: this.deviceId,
166
+ });
167
+ }
48
168
  await this.watermark.markUnavailable('default', lease.indices, 'burned');
49
169
  await this.leaseStore.updateStatus(lease.leaseId, 'cancelled');
50
- await this.journal.append({
51
- treeId: 'default',
52
- branchId: 'default',
53
- wotsIndex: (0, watermark_js_1.flatIndex)(lease.indices),
54
- indices: lease.indices,
55
- status: 'burned',
56
- reservationId: lease.leaseId,
57
- timestamp: now,
58
- deviceId: this.deviceId,
59
- });
60
170
  }
61
171
  }
62
172
  }
63
173
  async recoverExpiredReservations() {
64
174
  const now = Date.now();
65
175
  for (const lease of this.leaseStore.getAll()) {
66
- if (lease.status === 'active' && lease.expiresAt < now) {
176
+ if (lease.status === 'expired' || (lease.status === 'active' && lease.expiresAt < now)) {
67
177
  const treeId = lease.treeId ?? 'default';
178
+ if (this.journal.getByReservation(lease.leaseId)?.status !== 'reserved-expired') {
179
+ await this.journal.append({
180
+ treeId,
181
+ branchId: 'default',
182
+ wotsIndex: (0, watermark_js_1.flatIndex)(lease.indices),
183
+ indices: lease.indices,
184
+ status: 'reserved-expired',
185
+ reservationId: lease.leaseId,
186
+ timestamp: now,
187
+ deviceId: this.deviceId,
188
+ });
189
+ }
68
190
  await this.watermark.markUnavailable(treeId, lease.indices, 'reserved-expired');
69
- await this.leaseStore.updateStatus(lease.leaseId, 'expired');
70
- await this.journal.append({
71
- treeId,
72
- branchId: 'default',
73
- wotsIndex: (0, watermark_js_1.flatIndex)(lease.indices),
74
- indices: lease.indices,
75
- status: 'reserved-expired',
76
- reservationId: lease.leaseId,
77
- timestamp: now,
78
- deviceId: this.deviceId,
79
- });
191
+ if (lease.status !== 'expired')
192
+ await this.leaseStore.updateStatus(lease.leaseId, 'expired');
80
193
  }
81
194
  }
82
195
  }
83
196
  async reserveKeyUse(params) {
84
- if (!this._initialized)
85
- await this.initialize();
86
- const indices = this.watermark.getNextIndices(params.treeId);
87
- return this.reserveSpecificKeyUse(params, indices);
197
+ return this.withMutationLock(async () => {
198
+ if (!this._initialized)
199
+ await this.initialize();
200
+ // Pick up any allocation made by another provider over the same storage
201
+ // before choosing a slot (AUD-004).
202
+ await this.watermark.refresh();
203
+ const indices = this.watermark.getNextIndices(params.treeId);
204
+ return this.reserveSpecificKeyUseUnlocked(params, indices);
205
+ });
88
206
  }
89
207
  /**
90
208
  * Reserve a specific set of indices (used by quorum coordination so every
@@ -92,6 +210,14 @@ class LocalLeaseProvider {
92
210
  * slot is already taken.
93
211
  */
94
212
  async reserveSpecificKeyUse(params, indices) {
213
+ return this.withMutationLock(async () => {
214
+ if (!this._initialized)
215
+ await this.initialize();
216
+ await this.watermark.refresh();
217
+ return this.reserveSpecificKeyUseUnlocked(params, indices);
218
+ });
219
+ }
220
+ async reserveSpecificKeyUseUnlocked(params, indices) {
95
221
  if (!this._initialized)
96
222
  await this.initialize();
97
223
  const treeId = params.treeId;
@@ -106,20 +232,29 @@ class LocalLeaseProvider {
106
232
  throw new errors_js_1.DeviceRangeViolationError(indices.addressIndex, range.start, range.end);
107
233
  }
108
234
  }
109
- await this.watermark.markUnavailable(treeId, indices, 'reserved');
110
235
  const reservationId = randomId();
111
236
  const expiresAt = Date.now() + ttlMs;
112
- const lease = {
113
- leaseId: reservationId,
114
- leaseToken: reservationId,
115
- treeId,
116
- indices,
117
- expiresAt,
118
- status: 'active',
119
- createdAt: Date.now(),
120
- leaseTTL: ttlMs,
121
- };
122
- await this.leaseStore.save(lease);
237
+ // Cross-instance CAS claim (AUD-004): on a durable medium shared by two
238
+ // provider instances over distinct adapter objects, refresh() above can
239
+ // race and both pick the same slot. Claim it atomically first; a lost race
240
+ // means another instance owns it, so the slot is not re-allocated.
241
+ if (this.cas) {
242
+ const claimKey = `totem_wots_claim:${treeId}:${(0, watermark_js_1.flatIndex)(indices)}`;
243
+ const claim = await this.cas.conditionalUpdate(claimKey, (current) => current
244
+ ? { abort: 'slot already claimed' }
245
+ : {
246
+ next: {
247
+ reservationId,
248
+ ...(params.deviceId !== undefined ? { deviceId: params.deviceId } : {}),
249
+ claimedAt: Date.now(),
250
+ },
251
+ });
252
+ if (!claim.applied) {
253
+ throw new errors_js_1.IndicesUnavailableError(treeId, indices);
254
+ }
255
+ }
256
+ // Write-ahead safety record: once this succeeds, recovery will never
257
+ // re-expose the index even if a later snapshot write fails.
123
258
  await this.journal.append({
124
259
  treeId,
125
260
  branchId: params.branchId ?? 'default',
@@ -131,20 +266,51 @@ class LocalLeaseProvider {
131
266
  timestamp: Date.now(),
132
267
  deviceId: params.deviceId ?? this.deviceId,
133
268
  });
269
+ await this.watermark.markUnavailable(treeId, indices, 'reserved');
270
+ const lease = {
271
+ leaseId: reservationId,
272
+ leaseToken: reservationId,
273
+ treeId,
274
+ indices,
275
+ expiresAt,
276
+ status: 'active',
277
+ createdAt: Date.now(),
278
+ leaseTTL: ttlMs,
279
+ };
280
+ await this.leaseStore.save(lease);
134
281
  return { reservationId, indices, expiresAt };
135
282
  }
283
+ withMutationLock(operation) {
284
+ const run = this.mutationTail.then(operation, operation);
285
+ this.mutationTail = run.then(() => undefined, () => undefined);
286
+ return run;
287
+ }
136
288
  async commitKeyUse(reservationId, txId) {
289
+ return this.withMutationLock(() => this.commitKeyUseUnlocked(reservationId, txId));
290
+ }
291
+ async commitKeyUseUnlocked(reservationId, txId) {
137
292
  if (!this._initialized)
138
293
  await this.initialize();
294
+ await this.watermark.refresh();
139
295
  const lease = this.leaseStore.get(reservationId);
140
296
  if (!lease)
141
297
  throw new errors_js_1.LeaseNotFoundError(reservationId);
142
- if (lease.status === 'finalized')
143
- return;
144
298
  const treeId = lease.treeId ?? 'default';
145
299
  const indices = lease.indices;
146
- await this.watermark.markUnavailable(treeId, indices, 'committed');
147
- await this.leaseStore.updateStatus(reservationId, 'finalized');
300
+ const latest = this.journal.getByReservation(reservationId);
301
+ if (latest?.status === 'committed') {
302
+ if (latest.txId !== txId) {
303
+ throw new errors_js_1.InvalidLeaseTransitionError(reservationId, 'committed', 'committed');
304
+ }
305
+ await this.watermark.markUnavailable(treeId, indices, 'committed');
306
+ if (lease.status !== 'finalized')
307
+ await this.leaseStore.updateStatus(reservationId, 'finalized');
308
+ return;
309
+ }
310
+ if (latest?.status === 'burned' || latest?.status === 'reserved-expired'
311
+ || lease.status === 'cancelled' || lease.status === 'expired') {
312
+ throw new errors_js_1.InvalidLeaseTransitionError(reservationId, latest?.status ?? lease.status, 'committed');
313
+ }
148
314
  await this.journal.append({
149
315
  treeId,
150
316
  branchId: 'default',
@@ -156,18 +322,32 @@ class LocalLeaseProvider {
156
322
  timestamp: Date.now(),
157
323
  deviceId: this.deviceId,
158
324
  });
325
+ await this.watermark.markUnavailable(treeId, indices, 'committed');
326
+ await this.leaseStore.updateStatus(reservationId, 'finalized');
159
327
  }
160
328
  async burnReservation(reservationId, reason) {
329
+ return this.withMutationLock(() => this.burnReservationUnlocked(reservationId, reason));
330
+ }
331
+ async burnReservationUnlocked(reservationId, reason) {
161
332
  if (!this._initialized)
162
333
  await this.initialize();
334
+ await this.watermark.refresh();
163
335
  const lease = this.leaseStore.get(reservationId);
164
336
  if (!lease)
165
337
  throw new errors_js_1.LeaseNotFoundError(reservationId);
166
338
  const treeId = lease.treeId ?? 'default';
167
339
  const indices = lease.indices;
168
- await this.watermark.markUnavailable(treeId, indices, 'burned');
169
- await this.leaseStore.updateStatus(reservationId, 'cancelled');
170
- this.logger.warn(`[LocalLeaseProvider] Burning reservation ${reservationId}: ${reason}`);
340
+ const latest = this.journal.getByReservation(reservationId);
341
+ if (latest?.status === 'burned') {
342
+ await this.watermark.markUnavailable(treeId, indices, 'burned');
343
+ if (lease.status !== 'cancelled')
344
+ await this.leaseStore.updateStatus(reservationId, 'cancelled');
345
+ return;
346
+ }
347
+ if (latest?.status === 'committed' || latest?.status === 'reserved-expired'
348
+ || lease.status === 'finalized' || lease.status === 'expired') {
349
+ throw new errors_js_1.InvalidLeaseTransitionError(reservationId, latest?.status ?? lease.status, 'burned');
350
+ }
171
351
  await this.journal.append({
172
352
  treeId,
173
353
  branchId: 'default',
@@ -178,12 +358,35 @@ class LocalLeaseProvider {
178
358
  timestamp: Date.now(),
179
359
  deviceId: this.deviceId,
180
360
  });
361
+ await this.watermark.markUnavailable(treeId, indices, 'burned');
362
+ await this.leaseStore.updateStatus(reservationId, 'cancelled');
363
+ this.logger.warn(`[LocalLeaseProvider] Burning reservation ${reservationId}: ${reason}`);
181
364
  }
182
365
  async getLocalWatermark(treeId) {
183
366
  if (!this._initialized)
184
367
  await this.initialize();
185
368
  return this.watermark.getLocalWatermark(treeId);
186
369
  }
370
+ /**
371
+ * Export a portable watermark seed for restore (RFC-013 §9).
372
+ */
373
+ async exportWatermark(treeId) {
374
+ if (!this._initialized)
375
+ await this.initialize();
376
+ return this.watermark.exportSeed(treeId);
377
+ }
378
+ /**
379
+ * Seed the local watermark from an exported/operator/on-chain snapshot.
380
+ * Forward-only by default; refuses to start below the true high-water mark.
381
+ */
382
+ async seedWatermark(seed, options) {
383
+ return this.withMutationLock(async () => {
384
+ if (!this._initialized)
385
+ await this.initialize();
386
+ await this.watermark.refresh();
387
+ await this.watermark.seed(seed, options);
388
+ });
389
+ }
187
390
  /** List all tree IDs known to the local watermark store. */
188
391
  listTrees() {
189
392
  return Object.keys(this.watermark.getRawState().trees);
package/dist/onchain.d.ts CHANGED
@@ -23,7 +23,8 @@
23
23
  import type { WotsLeaseProvider, ReserveParams, LeaseReservation, LeaseCertificate, LocalWatermark, SyncResult, OnchainWatermarkProviderConfig } from './types.js';
24
24
  export declare class OnchainWatermarkProvider implements WotsLeaseProvider {
25
25
  private readonly chain;
26
- private readonly watermarkCoinId;
26
+ private readonly initialWatermarkCoinId;
27
+ private currentCoinId;
27
28
  private readonly watermarkAddress;
28
29
  private readonly tokenId;
29
30
  private readonly amount;
@@ -31,6 +32,7 @@ export declare class OnchainWatermarkProvider implements WotsLeaseProvider {
31
32
  private readonly signer;
32
33
  private readonly statePort;
33
34
  private readonly minBlocksBetweenPublishes;
35
+ private readonly storage?;
34
36
  private _initialized;
35
37
  private _lastPublishBlock;
36
38
  constructor(config: OnchainWatermarkProviderConfig);
package/dist/onchain.js CHANGED
@@ -27,7 +27,9 @@ const watermark_js_1 = require("./watermark.js");
27
27
  const errors_js_1 = require("./errors.js");
28
28
  const core_1 = require("@totemsdk/core");
29
29
  const txpow_1 = require("@totemsdk/txpow");
30
+ const certificate_js_1 = require("./certificate.js");
30
31
  const MAX_L = 64;
32
+ const WATERMARK_COIN_STORAGE_KEY = 'wots-lease:onchain:watermark-coin';
31
33
  function multiConcat(parts) {
32
34
  if (parts.length === 0)
33
35
  return new Uint8Array(0);
@@ -89,7 +91,8 @@ class OnchainWatermarkProvider {
89
91
  this._initialized = false;
90
92
  this._lastPublishBlock = -1;
91
93
  this.chain = config.chain;
92
- this.watermarkCoinId = config.watermarkCoinId;
94
+ this.initialWatermarkCoinId = config.watermarkCoinId;
95
+ this.currentCoinId = config.watermarkCoinId;
93
96
  this.watermarkAddress = config.watermarkAddress;
94
97
  this.tokenId = config.tokenId ?? '0x00';
95
98
  this.amount = config.amount ?? '1';
@@ -97,10 +100,25 @@ class OnchainWatermarkProvider {
97
100
  this.signer = config.signer;
98
101
  this.statePort = config.statePort ?? 0;
99
102
  this.minBlocksBetweenPublishes = config.minBlocksBetweenPublishes ?? 1;
103
+ this.storage = config.storage;
100
104
  }
101
105
  async initialize() {
102
106
  if (this._initialized)
103
107
  return;
108
+ // Recover the advanced watermark coin identity across restarts.
109
+ if (this.storage) {
110
+ try {
111
+ const saved = await this.storage.get(WATERMARK_COIN_STORAGE_KEY);
112
+ if (saved?.coinId)
113
+ this.currentCoinId = saved.coinId;
114
+ if (typeof saved?.lastPublishBlock === 'number') {
115
+ this._lastPublishBlock = saved.lastPublishBlock;
116
+ }
117
+ }
118
+ catch {
119
+ // Storage unavailable — fall back to the configured coin.
120
+ }
121
+ }
104
122
  await this.local.initialize();
105
123
  this._initialized = true;
106
124
  }
@@ -127,6 +145,7 @@ class OnchainWatermarkProvider {
127
145
  expiresAt: reservation.expiresAt,
128
146
  signature: '',
129
147
  };
148
+ certificate.signature = await (0, certificate_js_1.signCertificate)(this.signer, certificate);
130
149
  return { ...reservation, certificate };
131
150
  }
132
151
  async commitKeyUse(reservationId, txId) {
@@ -170,10 +189,11 @@ class OnchainWatermarkProvider {
170
189
  catch {
171
190
  // Tip unavailable — proceed without rate limiting.
172
191
  }
173
- // Read the current on-chain cursor so the input state matches the coin.
174
- const coin = await this.chain.getCoin(this.watermarkCoinId);
192
+ // Read the current on-chain cursor so the input state matches the coin
193
+ // anchored by the CURRENT watermark coin (which advances on every spend).
194
+ const coin = await this.chain.getCoin(this.currentCoinId);
175
195
  if (!coin) {
176
- throw new errors_js_1.OnchainWatermarkError(`Watermark coin not found: ${this.watermarkCoinId}`);
196
+ throw new errors_js_1.OnchainWatermarkError(`Watermark coin not found: ${this.currentCoinId}`);
177
197
  }
178
198
  const currentState = coin.state ?? [];
179
199
  const currentFlat = readStateCursor(currentState, this.statePort);
@@ -182,7 +202,7 @@ class OnchainWatermarkProvider {
182
202
  return;
183
203
  }
184
204
  const inputCoin = {
185
- coinid: this.watermarkCoinId,
205
+ coinid: this.currentCoinId,
186
206
  address: addressToHex(this.watermarkAddress),
187
207
  amount: this.amount,
188
208
  tokenid: this.tokenId,
@@ -207,12 +227,29 @@ class OnchainWatermarkProvider {
207
227
  const digest = (0, core_1.computeTransactionDigest)(txBytes);
208
228
  const sig = await this.signer.sign(digest);
209
229
  const witnessBytes = buildWitnessBytes([sig]);
210
- const prng = (0, core_1.sha3_256)(new TextEncoder().encode(`watermark:${treeId}:${this.watermarkCoinId}:${newFlat}`));
230
+ const prng = (0, core_1.sha3_256)(new TextEncoder().encode(`watermark:${treeId}:${this.currentCoinId}:${newFlat}`));
211
231
  const txHex = Buffer.from((0, txpow_1.serializeTxPoW)(txBytes, witnessBytes, { prng })).toString('hex');
212
232
  const result = await this.chain.broadcastTxPoW(txHex);
213
233
  if (!result.success) {
214
234
  throw new errors_js_1.OnchainWatermarkError(`Watermark publish rejected: ${result.message ?? 'unknown error'}`);
215
235
  }
236
+ // Rollover: the spent watermark coin is destroyed and replaced by a fresh
237
+ // coin with a new coin ID (output 0 of this spend). Track and persist the
238
+ // ADVANCED identity so the next publish spends the live coin, not the
239
+ // consumed one.
240
+ const newCoinId = '0x' + (0, core_1.bytesToHex)((0, core_1.precomputeTransactionCoinID)((0, core_1.hexToBytes)(this.currentCoinId), 0));
241
+ this.currentCoinId = newCoinId;
242
+ if (this.storage) {
243
+ try {
244
+ await this.storage.set(WATERMARK_COIN_STORAGE_KEY, {
245
+ coinId: newCoinId,
246
+ lastPublishBlock: this._lastPublishBlock,
247
+ });
248
+ }
249
+ catch {
250
+ // Storage unavailable — in-memory rollover still applies this session.
251
+ }
252
+ }
216
253
  }
217
254
  // -------------------------------------------------------------------------
218
255
  // Sync — merge on-chain cursor into local watermark
@@ -230,7 +267,7 @@ class OnchainWatermarkProvider {
230
267
  });
231
268
  let onchainFlat = null;
232
269
  try {
233
- const coin = await this.chain.getCoin(this.watermarkCoinId);
270
+ const coin = await this.chain.getCoin(this.currentCoinId);
234
271
  if (coin)
235
272
  onchainFlat = readStateCursor(coin.state ?? [], this.statePort);
236
273
  }
@@ -269,6 +306,8 @@ class OnchainWatermarkProvider {
269
306
  return false;
270
307
  if (cert.expiresAt <= Date.now())
271
308
  return false;
309
+ if (!(await (0, certificate_js_1.certificateSignatureVerified)(cert, this.signer)))
310
+ return false;
272
311
  return cert.issuedBy === 'onchain-watermark';
273
312
  }
274
313
  }
package/dist/quorum.d.ts CHANGED
@@ -34,6 +34,13 @@ export declare class P2PQuorumLeaseProvider implements WotsLeaseProvider {
34
34
  private readonly local;
35
35
  private readonly requestTimeoutMs;
36
36
  private readonly requireQuorumOnCommit;
37
+ private readonly certificateSigner?;
38
+ /**
39
+ * localReservationId -> (peerId -> the reservation id that peer issued).
40
+ * Commit/burn must reference each peer's own id, because a peer only knows
41
+ * the reservation it issued, not the local provider's id (AUD-044).
42
+ */
43
+ private readonly peerReservationIds;
37
44
  private _initialized;
38
45
  constructor(config: P2PQuorumLeaseProviderConfig);
39
46
  initialize(): Promise<void>;
@@ -52,6 +59,14 @@ export declare class P2PQuorumLeaseProvider implements WotsLeaseProvider {
52
59
  }): Promise<QuorumAttestation[]>;
53
60
  commitKeyUse(reservationId: string, txId: string): Promise<void>;
54
61
  burnReservation(reservationId: string, reason: string): Promise<void>;
62
+ /** Record which peer issued which reservation id for a local reservation. */
63
+ private recordPeerReservationIds;
64
+ /**
65
+ * The id to send a peer for a local reservation. Peers only know the id they
66
+ * issued; fall back to the local id only when no peer id was recorded (e.g.
67
+ * attestations gathered outside `reserveKeyUse`).
68
+ */
69
+ private peerReservationIdFor;
55
70
  private broadcastAck;
56
71
  getLocalWatermark(treeId: string): Promise<LocalWatermark>;
57
72
  publishWatermark(treeId: string): Promise<void>;