@totemsdk/wots-lease 1.0.0 → 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/axia.d.ts +1 -1
- package/dist/axia.js +24 -7
- package/dist/certificate.d.ts +37 -0
- package/dist/certificate.js +74 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.js +42 -1
- package/dist/hybrid.d.ts +4 -1
- package/dist/hybrid.js +71 -21
- package/dist/index.d.ts +4 -2
- package/dist/index.js +6 -1
- package/dist/journal.d.ts +14 -4
- package/dist/journal.js +321 -40
- package/dist/local.d.ts +31 -1
- package/dist/local.js +249 -46
- package/dist/onchain.d.ts +3 -1
- package/dist/onchain.js +46 -7
- package/dist/quorum.d.ts +15 -0
- package/dist/quorum.js +48 -5
- package/dist/seed.d.ts +41 -0
- package/dist/seed.js +84 -0
- package/dist/stubs.d.ts +2 -0
- package/dist/stubs.js +17 -1
- package/dist/types.d.ts +74 -1
- package/dist/watermark.d.ts +22 -1
- package/dist/watermark.js +125 -1
- package/package.json +8 -7
- package/rust/pkg/package.json +1 -1
- package/rust/pkg/wots_lease_wasm_bg.wasm +0 -0
- package/rust/pkg-node/package.json +1 -1
- package/rust/pkg-node/wots_lease_wasm_bg.wasm +0 -0
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.
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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>;
|