@totemsdk/wots-lease 1.0.0 → 1.0.2
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/LICENSE +202 -21
- package/NOTICE +60 -0
- package/dist/axia.d.ts +1 -1
- package/dist/axia.js +41 -28
- package/dist/certificate.d.ts +37 -0
- package/dist/certificate.js +69 -0
- package/dist/cjs/axia.d.ts +33 -0
- package/dist/cjs/axia.js +145 -0
- package/dist/cjs/certificate.d.ts +37 -0
- package/dist/cjs/certificate.js +74 -0
- package/dist/cjs/device.d.ts +18 -0
- package/dist/cjs/device.js +34 -0
- package/dist/cjs/errors.d.ts +71 -0
- package/dist/cjs/errors.js +135 -0
- package/dist/cjs/hybrid.d.ts +37 -0
- package/dist/cjs/hybrid.js +145 -0
- package/dist/cjs/index.d.ts +17 -0
- package/dist/cjs/index.js +45 -0
- package/dist/cjs/journal.d.ts +30 -0
- package/dist/cjs/journal.js +369 -0
- package/dist/cjs/local.d.ts +83 -0
- package/dist/cjs/local.js +440 -0
- package/dist/cjs/onchain.d.ts +54 -0
- package/dist/cjs/onchain.js +314 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/quorum.d.ts +75 -0
- package/dist/cjs/quorum.js +307 -0
- package/dist/cjs/replicated-backup.d.ts +30 -0
- package/dist/cjs/replicated-backup.js +52 -0
- package/dist/cjs/seed.d.ts +41 -0
- package/dist/cjs/seed.js +84 -0
- package/dist/cjs/stubs.d.ts +50 -0
- package/dist/cjs/stubs.js +125 -0
- package/dist/cjs/types.d.ts +272 -0
- package/dist/cjs/types.js +5 -0
- package/dist/cjs/watermark.d.ts +51 -0
- package/dist/cjs/watermark.js +328 -0
- package/dist/device.js +2 -6
- package/dist/errors.d.ts +26 -0
- package/dist/errors.js +49 -25
- package/dist/hybrid.d.ts +4 -1
- package/dist/hybrid.js +72 -26
- package/dist/index.d.ts +6 -2
- package/dist/index.js +12 -38
- package/dist/journal.d.ts +14 -4
- package/dist/journal.js +327 -49
- package/dist/local.d.ts +31 -1
- package/dist/local.js +267 -68
- package/dist/onchain.d.ts +3 -1
- package/dist/onchain.js +63 -28
- package/dist/quorum.d.ts +15 -0
- package/dist/quorum.js +54 -15
- package/dist/replicated-backup.d.ts +30 -0
- package/dist/replicated-backup.js +48 -0
- package/dist/seed.d.ts +41 -0
- package/dist/seed.js +80 -0
- package/dist/stubs.d.ts +2 -0
- package/dist/stubs.js +20 -10
- package/dist/types.d.ts +74 -1
- package/dist/types.js +1 -2
- package/dist/watermark.d.ts +22 -1
- package/dist/watermark.js +133 -16
- package/package.json +25 -15
- package/rust/pkg/package.json +3 -3
- package/rust/pkg/wots_lease_wasm_bg.wasm +0 -0
- package/rust/pkg-node/package.json +3 -3
- package/rust/pkg-node/wots_lease_wasm_bg.wasm +0 -0
package/dist/journal.js
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
/**
|
|
3
|
-
* LeaseJournal — append-only audit log for WOTS key-use events.
|
|
4
|
-
*
|
|
2
|
+
* LeaseJournal — physically append-only audit log for WOTS key-use events.
|
|
3
|
+
*
|
|
4
|
+
* Layout v1 stores one immutable record per sequence. The legacy whole-array
|
|
5
|
+
* key is retained as migration input/forensic history but is never rewritten.
|
|
6
|
+
* The SHA-256 chain format is intentionally unchanged (RFC-007 OQ3).
|
|
5
7
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
8
|
+
import { sha256 } from '@noble/hashes/sha256.js';
|
|
9
|
+
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
10
|
+
import { NoopLogger } from '@totemsdk/core';
|
|
11
|
+
import { StorageError } from '@totemsdk/storage/errors';
|
|
12
|
+
const LEGACY_STORAGE_KEY = 'totem_wots_journal';
|
|
13
|
+
const ENTRY_PREFIX = 'totem_wots_journal:v1:entry:';
|
|
14
|
+
const RECORD_VERSION = 1;
|
|
15
|
+
const SEQUENCE_WIDTH = 16;
|
|
16
|
+
const MAX_INDEX_COMPONENT = 63;
|
|
17
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/;
|
|
18
|
+
const KEY_PATTERN = /^(\d{16}):([0-9a-f]{64})$/;
|
|
19
|
+
const STATUSES = new Set([
|
|
20
|
+
'reserved',
|
|
21
|
+
'committed',
|
|
22
|
+
'burned',
|
|
23
|
+
'reserved-expired',
|
|
24
|
+
]);
|
|
25
|
+
const storageTails = new WeakMap();
|
|
26
|
+
function withStorageLock(storage, fn) {
|
|
27
|
+
const prior = storageTails.get(storage) ?? Promise.resolve();
|
|
28
|
+
const run = prior.then(fn, fn);
|
|
29
|
+
storageTails.set(storage, run.then(() => undefined, () => undefined));
|
|
30
|
+
return run;
|
|
31
|
+
}
|
|
32
|
+
/** Existing commitment format; property names/order and SHA-256 stay unchanged. */
|
|
11
33
|
function hashEntry(entry) {
|
|
12
34
|
const encoder = new TextEncoder();
|
|
13
35
|
const data = {
|
|
@@ -21,67 +43,323 @@ function hashEntry(entry) {
|
|
|
21
43
|
d: entry.deviceId,
|
|
22
44
|
};
|
|
23
45
|
const bytes = encoder.encode(JSON.stringify(data));
|
|
24
|
-
return (
|
|
46
|
+
return bytesToHex(sha256(bytes));
|
|
47
|
+
}
|
|
48
|
+
function cloneEntry(entry) {
|
|
49
|
+
return {
|
|
50
|
+
treeId: entry.treeId,
|
|
51
|
+
branchId: entry.branchId,
|
|
52
|
+
wotsIndex: entry.wotsIndex,
|
|
53
|
+
indices: { ...entry.indices },
|
|
54
|
+
status: entry.status,
|
|
55
|
+
...(entry.reservationId !== undefined ? { reservationId: entry.reservationId } : {}),
|
|
56
|
+
...(entry.payloadHash !== undefined ? { payloadHash: entry.payloadHash } : {}),
|
|
57
|
+
...(entry.txId !== undefined ? { txId: entry.txId } : {}),
|
|
58
|
+
timestamp: entry.timestamp,
|
|
59
|
+
deviceId: entry.deviceId,
|
|
60
|
+
...(entry.previousHash !== undefined ? { previousHash: entry.previousHash } : {}),
|
|
61
|
+
...(entry.hash !== undefined ? { hash: entry.hash } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function asObject(value, key) {
|
|
65
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
66
|
+
throw new StorageError(`LeaseJournal: invalid object at ${key}`, 'corrupt', { key });
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
function requiredString(object, field, key) {
|
|
71
|
+
const value = object[field];
|
|
72
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
73
|
+
throw new StorageError(`LeaseJournal: invalid ${field} at ${key}`, 'corrupt', { key });
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function optionalString(object, field, key) {
|
|
78
|
+
const value = object[field];
|
|
79
|
+
if (value === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (typeof value !== 'string') {
|
|
82
|
+
throw new StorageError(`LeaseJournal: invalid ${field} at ${key}`, 'corrupt', { key });
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
function requiredInteger(object, field, key) {
|
|
87
|
+
const value = object[field];
|
|
88
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
89
|
+
throw new StorageError(`LeaseJournal: invalid ${field} at ${key}`, 'corrupt', { key });
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
function validateEntry(value, key) {
|
|
94
|
+
const object = asObject(value, key);
|
|
95
|
+
const indices = asObject(object.indices, `${key}.indices`);
|
|
96
|
+
const status = requiredString(object, 'status', key);
|
|
97
|
+
if (!STATUSES.has(status)) {
|
|
98
|
+
throw new StorageError(`LeaseJournal: invalid status at ${key}`, 'corrupt', { key });
|
|
99
|
+
}
|
|
100
|
+
const timestamp = object.timestamp;
|
|
101
|
+
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp < 0) {
|
|
102
|
+
throw new StorageError(`LeaseJournal: invalid timestamp at ${key}`, 'corrupt', { key });
|
|
103
|
+
}
|
|
104
|
+
const validated = cloneEntry({
|
|
105
|
+
treeId: requiredString(object, 'treeId', key),
|
|
106
|
+
branchId: requiredString(object, 'branchId', key),
|
|
107
|
+
wotsIndex: requiredInteger(object, 'wotsIndex', key),
|
|
108
|
+
indices: {
|
|
109
|
+
addressIndex: requiredInteger(indices, 'addressIndex', `${key}.indices`),
|
|
110
|
+
l1: requiredInteger(indices, 'l1', `${key}.indices`),
|
|
111
|
+
l2: requiredInteger(indices, 'l2', `${key}.indices`),
|
|
112
|
+
},
|
|
113
|
+
status,
|
|
114
|
+
reservationId: optionalString(object, 'reservationId', key),
|
|
115
|
+
payloadHash: optionalString(object, 'payloadHash', key),
|
|
116
|
+
txId: optionalString(object, 'txId', key),
|
|
117
|
+
timestamp,
|
|
118
|
+
deviceId: requiredString(object, 'deviceId', key),
|
|
119
|
+
previousHash: optionalString(object, 'previousHash', key),
|
|
120
|
+
hash: optionalString(object, 'hash', key),
|
|
121
|
+
});
|
|
122
|
+
const { addressIndex, l1, l2 } = validated.indices;
|
|
123
|
+
if (addressIndex > MAX_INDEX_COMPONENT || l1 > MAX_INDEX_COMPONENT || l2 > MAX_INDEX_COMPONENT) {
|
|
124
|
+
throw new StorageError(`LeaseJournal: indices out of range at ${key}`, 'corrupt', { key });
|
|
125
|
+
}
|
|
126
|
+
const expectedIndex = addressIndex * 64 * 64 + l1 * 64 + l2;
|
|
127
|
+
if (validated.wotsIndex !== expectedIndex) {
|
|
128
|
+
throw new StorageError(`LeaseJournal: wotsIndex/indices mismatch at ${key}`, 'corrupt', { key });
|
|
129
|
+
}
|
|
130
|
+
return validated;
|
|
131
|
+
}
|
|
132
|
+
function verifyChain(entries, source) {
|
|
133
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
134
|
+
const entry = entries[i];
|
|
135
|
+
if (entry.hash === undefined || !HASH_PATTERN.test(entry.hash)) {
|
|
136
|
+
throw new StorageError(`LeaseJournal: invalid hash at ${source}[${i}]`, 'corrupt');
|
|
137
|
+
}
|
|
138
|
+
const expectedHash = hashEntry(entry);
|
|
139
|
+
if (entry.hash !== expectedHash) {
|
|
140
|
+
throw new StorageError(`LeaseJournal: hash mismatch at ${source}[${i}]`, 'corrupt');
|
|
141
|
+
}
|
|
142
|
+
if (i === 0) {
|
|
143
|
+
if (entry.previousHash !== undefined) {
|
|
144
|
+
throw new StorageError(`LeaseJournal: first entry has previousHash at ${source}[0]`, 'corrupt');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else if (entry.previousHash !== entries[i - 1].hash) {
|
|
148
|
+
throw new StorageError(`LeaseJournal: previousHash mismatch at ${source}[${i}]`, 'corrupt');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function entriesEqual(a, b) {
|
|
153
|
+
return a.treeId === b.treeId
|
|
154
|
+
&& a.branchId === b.branchId
|
|
155
|
+
&& a.wotsIndex === b.wotsIndex
|
|
156
|
+
&& a.indices.addressIndex === b.indices.addressIndex
|
|
157
|
+
&& a.indices.l1 === b.indices.l1
|
|
158
|
+
&& a.indices.l2 === b.indices.l2
|
|
159
|
+
&& a.status === b.status
|
|
160
|
+
&& a.reservationId === b.reservationId
|
|
161
|
+
&& a.payloadHash === b.payloadHash
|
|
162
|
+
&& a.txId === b.txId
|
|
163
|
+
&& a.timestamp === b.timestamp
|
|
164
|
+
&& a.deviceId === b.deviceId
|
|
165
|
+
&& a.previousHash === b.previousHash
|
|
166
|
+
&& a.hash === b.hash;
|
|
167
|
+
}
|
|
168
|
+
function sequenceText(sequence) {
|
|
169
|
+
return sequence.toString(10).padStart(SEQUENCE_WIDTH, '0');
|
|
170
|
+
}
|
|
171
|
+
function recordKey(sequence, hash) {
|
|
172
|
+
return `${ENTRY_PREFIX}${sequenceText(sequence)}:${hash}`;
|
|
25
173
|
}
|
|
26
|
-
|
|
27
|
-
|
|
174
|
+
function parseRecordKey(key) {
|
|
175
|
+
const relative = key.slice(ENTRY_PREFIX.length);
|
|
176
|
+
const match = KEY_PATTERN.exec(relative);
|
|
177
|
+
if (!match) {
|
|
178
|
+
throw new StorageError(`LeaseJournal: malformed record key ${key}`, 'corrupt', { key });
|
|
179
|
+
}
|
|
180
|
+
const sequence = Number(match[1]);
|
|
181
|
+
if (!Number.isSafeInteger(sequence)) {
|
|
182
|
+
throw new StorageError(`LeaseJournal: invalid sequence in ${key}`, 'corrupt', { key });
|
|
183
|
+
}
|
|
184
|
+
return { key, sequence, hash: match[2] };
|
|
185
|
+
}
|
|
186
|
+
export class LeaseJournal {
|
|
187
|
+
constructor(storage, logger = new NoopLogger()) {
|
|
28
188
|
this.storage = storage;
|
|
29
189
|
this.logger = logger;
|
|
30
190
|
this.entries = [];
|
|
31
|
-
this.
|
|
191
|
+
this.initialized = false;
|
|
192
|
+
this.initializePromise = null;
|
|
193
|
+
this.appendTail = Promise.resolve();
|
|
32
194
|
}
|
|
33
195
|
async initialize() {
|
|
34
|
-
if (this.
|
|
196
|
+
if (this.initialized)
|
|
35
197
|
return;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
198
|
+
if (!this.initializePromise) {
|
|
199
|
+
this.initializePromise = withStorageLock(this.storage, () => this.initializeOnce()).catch((error) => {
|
|
200
|
+
this.initializePromise = null;
|
|
201
|
+
throw error;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
await this.initializePromise;
|
|
205
|
+
}
|
|
206
|
+
async initializeOnce() {
|
|
207
|
+
const physical = await this.readPhysicalRecords();
|
|
208
|
+
const legacy = await this.readLegacyEntries();
|
|
209
|
+
if (legacy !== null) {
|
|
210
|
+
const commonLength = Math.min(physical.length, legacy.length);
|
|
211
|
+
for (let i = 0; i < commonLength; i += 1) {
|
|
212
|
+
if (!entriesEqual(physical[i], legacy[i])) {
|
|
213
|
+
throw new StorageError(`LeaseJournal: legacy/v1 divergence at sequence ${i}`, 'corrupt');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (let i = physical.length; i < legacy.length; i += 1) {
|
|
217
|
+
await this.writeRecord(i, legacy[i]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const replayed = legacy !== null && legacy.length > physical.length
|
|
221
|
+
? await this.readPhysicalRecords()
|
|
222
|
+
: physical;
|
|
223
|
+
this.entries = replayed.map(cloneEntry);
|
|
224
|
+
this.initialized = true;
|
|
225
|
+
}
|
|
226
|
+
async readLegacyEntries() {
|
|
227
|
+
const raw = await this.storage.get(LEGACY_STORAGE_KEY);
|
|
228
|
+
if (raw === null)
|
|
229
|
+
return null;
|
|
230
|
+
if (!Array.isArray(raw)) {
|
|
231
|
+
throw new StorageError('LeaseJournal: legacy journal is not an array', 'corrupt', { key: LEGACY_STORAGE_KEY });
|
|
232
|
+
}
|
|
233
|
+
const entries = raw.map((entry, index) => validateEntry(entry, `${LEGACY_STORAGE_KEY}[${index}]`));
|
|
234
|
+
verifyChain(entries, LEGACY_STORAGE_KEY);
|
|
235
|
+
return entries;
|
|
236
|
+
}
|
|
237
|
+
async readPhysicalRecords() {
|
|
238
|
+
const parsed = (await this.storage.keys())
|
|
239
|
+
.filter((key) => key.startsWith(ENTRY_PREFIX))
|
|
240
|
+
.map(parseRecordKey)
|
|
241
|
+
.sort((a, b) => a.sequence - b.sequence || a.key.localeCompare(b.key));
|
|
242
|
+
for (let i = 0; i < parsed.length; i += 1) {
|
|
243
|
+
if (i > 0 && parsed[i - 1].sequence === parsed[i].sequence) {
|
|
244
|
+
throw new StorageError(`LeaseJournal: fork at sequence ${parsed[i].sequence}`, 'corrupt');
|
|
245
|
+
}
|
|
246
|
+
if (parsed[i].sequence !== i) {
|
|
247
|
+
throw new StorageError(`LeaseJournal: sequence gap at ${i}`, 'corrupt');
|
|
43
248
|
}
|
|
44
249
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
entry.hash = hashEntry(entry);
|
|
51
|
-
this.entries.push(entry);
|
|
52
|
-
await this.storage.set(STORAGE_KEY, this.entries);
|
|
53
|
-
this.logger.debug(`[LeaseJournal] ${entry.status} treeId=${entry.treeId} idx=${entry.wotsIndex} txId=${entry.txId ?? '-'} hash=${entry.hash.slice(0, 8)}`);
|
|
54
|
-
}
|
|
55
|
-
_verifyChain() {
|
|
56
|
-
for (let i = 0; i < this.entries.length; i++) {
|
|
57
|
-
const e = this.entries[i];
|
|
58
|
-
const expectedHash = hashEntry(e);
|
|
59
|
-
if (e.hash !== expectedHash) {
|
|
60
|
-
this.logger.error(`[LeaseJournal] hash mismatch at entry ${i}: expected ${expectedHash}, stored ${e.hash}`);
|
|
61
|
-
return false;
|
|
250
|
+
const entries = [];
|
|
251
|
+
for (const descriptor of parsed) {
|
|
252
|
+
const raw = await this.storage.get(descriptor.key);
|
|
253
|
+
if (raw === null) {
|
|
254
|
+
throw new StorageError(`LeaseJournal: listed record is missing`, 'corrupt', { key: descriptor.key });
|
|
62
255
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
256
|
+
const record = asObject(raw, descriptor.key);
|
|
257
|
+
const formatVersion = record.formatVersion;
|
|
258
|
+
if (formatVersion !== RECORD_VERSION) {
|
|
259
|
+
throw new StorageError('LeaseJournal: unsupported record version', 'corrupt', {
|
|
260
|
+
key: descriptor.key,
|
|
261
|
+
detectedVersion: typeof formatVersion === 'number' ? formatVersion : undefined,
|
|
262
|
+
unsupportedVersion: typeof formatVersion === 'number' ? formatVersion : undefined,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (record.sequence !== descriptor.sequence) {
|
|
266
|
+
throw new StorageError('LeaseJournal: sequence/key mismatch', 'corrupt', { key: descriptor.key });
|
|
267
|
+
}
|
|
268
|
+
const entry = validateEntry(record.entry, `${descriptor.key}.entry`);
|
|
269
|
+
if (entry.hash !== descriptor.hash) {
|
|
270
|
+
throw new StorageError('LeaseJournal: hash/key mismatch', 'corrupt', { key: descriptor.key });
|
|
271
|
+
}
|
|
272
|
+
entries.push(entry);
|
|
273
|
+
}
|
|
274
|
+
verifyChain(entries, ENTRY_PREFIX);
|
|
275
|
+
return entries;
|
|
276
|
+
}
|
|
277
|
+
async writeRecord(sequence, entry) {
|
|
278
|
+
if (entry.hash === undefined) {
|
|
279
|
+
throw new StorageError(`LeaseJournal: entry ${sequence} has no hash`, 'corrupt');
|
|
280
|
+
}
|
|
281
|
+
const key = recordKey(sequence, entry.hash);
|
|
282
|
+
const record = {
|
|
283
|
+
formatVersion: RECORD_VERSION,
|
|
284
|
+
sequence,
|
|
285
|
+
entry: cloneEntry(entry),
|
|
286
|
+
};
|
|
287
|
+
await this.storage.set(key, record);
|
|
288
|
+
}
|
|
289
|
+
append(entry) {
|
|
290
|
+
const run = this.appendTail.then(async () => this.appendOne(entry));
|
|
291
|
+
this.appendTail = run.catch(() => undefined);
|
|
292
|
+
return run;
|
|
293
|
+
}
|
|
294
|
+
async appendOne(input) {
|
|
295
|
+
await this.initialize();
|
|
296
|
+
await withStorageLock(this.storage, async () => {
|
|
297
|
+
// Refresh under the shared lock so multiple journal instances that share
|
|
298
|
+
// the same adapter object cannot allocate the same sequence or slot.
|
|
299
|
+
this.entries = (await this.readPhysicalRecords()).map(cloneEntry);
|
|
300
|
+
const entry = validateEntry(cloneEntry(input), 'append');
|
|
301
|
+
if (entry.status === 'reserved' && this.entries.some((existing) => existing.treeId === entry.treeId && existing.wotsIndex === entry.wotsIndex)) {
|
|
302
|
+
throw new StorageError(`LeaseJournal: WOTS slot ${entry.treeId}/${entry.wotsIndex} is already recorded`, 'write-failed');
|
|
303
|
+
}
|
|
304
|
+
if (entry.status !== 'reserved' && entry.reservationId) {
|
|
305
|
+
const latest = [...this.entries].reverse().find((existing) => existing.reservationId === entry.reservationId);
|
|
306
|
+
if (latest?.status === entry.status) {
|
|
307
|
+
if (latest.treeId !== entry.treeId || latest.wotsIndex !== entry.wotsIndex
|
|
308
|
+
|| (entry.status === 'committed' && latest.txId !== entry.txId)) {
|
|
309
|
+
throw new StorageError(`LeaseJournal: reservation ${entry.reservationId} is already ${latest.status} for a different use`, 'write-failed');
|
|
310
|
+
}
|
|
311
|
+
if (latest.previousHash === undefined)
|
|
312
|
+
delete input.previousHash;
|
|
313
|
+
else
|
|
314
|
+
input.previousHash = latest.previousHash;
|
|
315
|
+
input.hash = latest.hash;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (latest && latest.status !== 'reserved') {
|
|
319
|
+
throw new StorageError(`LeaseJournal: reservation ${entry.reservationId} is already ${latest.status}`, 'write-failed');
|
|
68
320
|
}
|
|
69
321
|
}
|
|
322
|
+
const previous = this.entries[this.entries.length - 1];
|
|
323
|
+
if (previous?.hash !== undefined)
|
|
324
|
+
entry.previousHash = previous.hash;
|
|
325
|
+
else
|
|
326
|
+
delete entry.previousHash;
|
|
327
|
+
entry.hash = hashEntry(entry);
|
|
328
|
+
const sequence = this.entries.length;
|
|
329
|
+
const sequencePrefix = `${ENTRY_PREFIX}${sequenceText(sequence)}:`;
|
|
330
|
+
if ((await this.storage.keys()).some((key) => key.startsWith(sequencePrefix))) {
|
|
331
|
+
throw new StorageError(`LeaseJournal: sequence ${sequence} already exists`, 'write-failed');
|
|
332
|
+
}
|
|
333
|
+
await this.writeRecord(sequence, entry);
|
|
334
|
+
this.entries.push(entry);
|
|
335
|
+
if (entry.previousHash === undefined)
|
|
336
|
+
delete input.previousHash;
|
|
337
|
+
else
|
|
338
|
+
input.previousHash = entry.previousHash;
|
|
339
|
+
input.hash = entry.hash;
|
|
340
|
+
this.logger.debug(`[LeaseJournal] ${entry.status} treeId=${entry.treeId} idx=${entry.wotsIndex} `
|
|
341
|
+
+ `txId=${entry.txId ?? '-'} hash=${entry.hash.slice(0, 8)}`);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
ensureInitialized() {
|
|
345
|
+
if (!this.initialized) {
|
|
346
|
+
throw new Error('LeaseJournal not initialized — call initialize() first');
|
|
70
347
|
}
|
|
71
|
-
return true;
|
|
72
348
|
}
|
|
73
349
|
getAll() {
|
|
74
|
-
|
|
350
|
+
this.ensureInitialized();
|
|
351
|
+
return this.entries.map(cloneEntry);
|
|
75
352
|
}
|
|
76
353
|
getByTree(treeId) {
|
|
77
|
-
|
|
354
|
+
this.ensureInitialized();
|
|
355
|
+
return this.entries.filter((entry) => entry.treeId === treeId).map(cloneEntry);
|
|
78
356
|
}
|
|
79
357
|
getByReservation(reservationId) {
|
|
80
|
-
|
|
358
|
+
this.ensureInitialized();
|
|
359
|
+
const entry = [...this.entries].reverse().find((candidate) => candidate.reservationId === reservationId);
|
|
360
|
+
return entry ? cloneEntry(entry) : undefined;
|
|
81
361
|
}
|
|
82
362
|
async clear() {
|
|
83
|
-
|
|
84
|
-
await this.storage.remove(STORAGE_KEY);
|
|
363
|
+
throw new StorageError('LeaseJournal is append-only and cannot be cleared', 'write-failed');
|
|
85
364
|
}
|
|
86
365
|
}
|
|
87
|
-
exports.LeaseJournal = LeaseJournal;
|
package/dist/local.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* All WOTS safety guaranteed by the local WotsWatermarkStore + LeaseJournal.
|
|
6
6
|
*/
|
|
7
7
|
import type { StorageAdapter, LoggerAdapter } from '@totemsdk/core';
|
|
8
|
-
import type { WotsLeaseProvider, ReserveParams, LeaseReservation, LeaseCertificate, LocalWatermark, SyncResult, SigningIndices } from './types.js';
|
|
8
|
+
import type { WotsLeaseProvider, ReserveParams, LeaseReservation, LeaseCertificate, LocalWatermark, SyncResult, SigningIndices, WatermarkSeed, SeedWatermarkOptions } from './types.js';
|
|
9
9
|
import { LeaseJournal } from './journal.js';
|
|
10
10
|
export declare class LocalLeaseProvider implements WotsLeaseProvider {
|
|
11
11
|
private readonly logger;
|
|
@@ -13,9 +13,26 @@ export declare class LocalLeaseProvider implements WotsLeaseProvider {
|
|
|
13
13
|
private readonly leaseStore;
|
|
14
14
|
private readonly journal;
|
|
15
15
|
private _initialized;
|
|
16
|
+
private initializePromise;
|
|
17
|
+
private mutationTail;
|
|
16
18
|
private readonly deviceId;
|
|
19
|
+
/**
|
|
20
|
+
* Optional CAS primitive (AUD-004). When the adapter exposes
|
|
21
|
+
* `conditionalUpdate`, a per-slot claim key is written atomically before the
|
|
22
|
+
* reservation so two providers over distinct adapter objects on the same
|
|
23
|
+
* durable medium cannot both claim one slot. Adapters without CAS fall back
|
|
24
|
+
* to the existing refresh() + check behavior.
|
|
25
|
+
*/
|
|
26
|
+
private readonly cas?;
|
|
17
27
|
constructor(storage: StorageAdapter, logger?: LoggerAdapter, deviceId?: string);
|
|
28
|
+
private static detectCas;
|
|
18
29
|
initialize(): Promise<void>;
|
|
30
|
+
private initializeOnce;
|
|
31
|
+
/**
|
|
32
|
+
* Rebuild the no-reuse watermark from immutable journal history and leases.
|
|
33
|
+
* The journal is audit authority; the watermark remains allocation authority.
|
|
34
|
+
*/
|
|
35
|
+
private reconcileDurableSafetyState;
|
|
19
36
|
/** Burn all active (non-expired) leases that have no matching treeId.
|
|
20
37
|
* On restart, leases with missing treeId (from before the treeId field was added)
|
|
21
38
|
* must be burned to prevent index reuse. */
|
|
@@ -28,9 +45,22 @@ export declare class LocalLeaseProvider implements WotsLeaseProvider {
|
|
|
28
45
|
* slot is already taken.
|
|
29
46
|
*/
|
|
30
47
|
reserveSpecificKeyUse(params: ReserveParams, indices: SigningIndices): Promise<LeaseReservation>;
|
|
48
|
+
private reserveSpecificKeyUseUnlocked;
|
|
49
|
+
private withMutationLock;
|
|
31
50
|
commitKeyUse(reservationId: string, txId: string): Promise<void>;
|
|
51
|
+
private commitKeyUseUnlocked;
|
|
32
52
|
burnReservation(reservationId: string, reason: string): Promise<void>;
|
|
53
|
+
private burnReservationUnlocked;
|
|
33
54
|
getLocalWatermark(treeId: string): Promise<LocalWatermark>;
|
|
55
|
+
/**
|
|
56
|
+
* Export a portable watermark seed for restore (RFC-013 §9).
|
|
57
|
+
*/
|
|
58
|
+
exportWatermark(treeId: string): Promise<WatermarkSeed>;
|
|
59
|
+
/**
|
|
60
|
+
* Seed the local watermark from an exported/operator/on-chain snapshot.
|
|
61
|
+
* Forward-only by default; refuses to start below the true high-water mark.
|
|
62
|
+
*/
|
|
63
|
+
seedWatermark(seed: WatermarkSeed, options?: SeedWatermarkOptions): Promise<void>;
|
|
34
64
|
/** List all tree IDs known to the local watermark store. */
|
|
35
65
|
listTrees(): string[];
|
|
36
66
|
/** Expose the journal for quorum/on-chain providers to merge remote entries. */
|