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