@totemsdk/wots-lease 0.1.0
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 +33 -0
- package/dist/axia.js +123 -0
- package/dist/device.d.ts +18 -0
- package/dist/device.js +30 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.js +48 -0
- package/dist/hybrid.d.ts +34 -0
- package/dist/hybrid.js +64 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/journal.d.ts +19 -0
- package/dist/journal.js +39 -0
- package/dist/local.d.ts +29 -0
- package/dist/local.js +163 -0
- package/dist/stubs.d.ts +43 -0
- package/dist/stubs.js +92 -0
- package/dist/types.d.ts +104 -0
- package/dist/types.js +4 -0
- package/dist/watermark.d.ts +30 -0
- package/dist/watermark.js +198 -0
- package/package.json +42 -0
- package/src/__tests__/watermark.test.ts +200 -0
- package/src/axia.ts +169 -0
- package/src/device.ts +40 -0
- package/src/errors.ts +57 -0
- package/src/hybrid.ts +98 -0
- package/src/index.ts +51 -0
- package/src/journal.ts +52 -0
- package/src/local.ts +196 -0
- package/src/stubs.ts +106 -0
- package/src/types.ts +119 -0
- package/src/watermark.ts +234 -0
package/src/local.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
|
|
8
|
+
import type { StorageAdapter, LoggerAdapter } from '@totemsdk/core';
|
|
9
|
+
import { NoopLogger } from '@totemsdk/core';
|
|
10
|
+
import { LeaseStore, type StoredLease } from '@totemsdk/core';
|
|
11
|
+
import type {
|
|
12
|
+
WotsLeaseProvider,
|
|
13
|
+
ReserveParams,
|
|
14
|
+
LeaseReservation,
|
|
15
|
+
LeaseCertificate,
|
|
16
|
+
LocalWatermark,
|
|
17
|
+
SyncResult,
|
|
18
|
+
SigningIndices,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
import { WotsWatermarkStore, flatIndex } from './watermark.js';
|
|
21
|
+
import { LeaseJournal } from './journal.js';
|
|
22
|
+
import { LeaseNotFoundError, DeviceRangeViolationError } from './errors.js';
|
|
23
|
+
|
|
24
|
+
function randomId(): string {
|
|
25
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
26
|
+
return crypto.randomUUID();
|
|
27
|
+
}
|
|
28
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class LocalLeaseProvider implements WotsLeaseProvider {
|
|
32
|
+
private readonly watermark: WotsWatermarkStore;
|
|
33
|
+
private readonly leaseStore: LeaseStore;
|
|
34
|
+
private readonly journal: LeaseJournal;
|
|
35
|
+
private _initialized = false;
|
|
36
|
+
private readonly deviceId: string;
|
|
37
|
+
/** In-memory map: reservationId → treeId (lost on restart; watermark survives). */
|
|
38
|
+
private readonly _treeIdByReservation = new Map<string, string>();
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
storage: StorageAdapter,
|
|
42
|
+
private readonly logger: LoggerAdapter = new NoopLogger(),
|
|
43
|
+
deviceId = 'local',
|
|
44
|
+
) {
|
|
45
|
+
this.watermark = new WotsWatermarkStore(storage, logger);
|
|
46
|
+
this.leaseStore = new LeaseStore(storage, logger);
|
|
47
|
+
this.journal = new LeaseJournal(storage, logger);
|
|
48
|
+
this.deviceId = deviceId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async initialize(): Promise<void> {
|
|
52
|
+
if (this._initialized) return;
|
|
53
|
+
await this.watermark.initialize();
|
|
54
|
+
await this.leaseStore.initialize();
|
|
55
|
+
await this.journal.initialize();
|
|
56
|
+
await this.recoverExpiredReservations();
|
|
57
|
+
this._initialized = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async recoverExpiredReservations(): Promise<void> {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
for (const lease of this.leaseStore.getAll()) {
|
|
63
|
+
if (lease.status === 'active' && lease.expiresAt < now) {
|
|
64
|
+
const treeId = this._treeIdByReservation.get(lease.leaseId) ?? 'default';
|
|
65
|
+
await this.watermark.markUnavailable(treeId, lease.indices, 'reserved-expired');
|
|
66
|
+
await this.leaseStore.updateStatus(lease.leaseId, 'expired');
|
|
67
|
+
await this.journal.append({
|
|
68
|
+
treeId,
|
|
69
|
+
branchId: 'default',
|
|
70
|
+
wotsIndex: flatIndex(lease.indices),
|
|
71
|
+
indices: lease.indices,
|
|
72
|
+
status: 'reserved-expired',
|
|
73
|
+
timestamp: now,
|
|
74
|
+
deviceId: this.deviceId,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async reserveKeyUse(params: ReserveParams): Promise<LeaseReservation> {
|
|
81
|
+
if (!this._initialized) await this.initialize();
|
|
82
|
+
const treeId = params.treeId;
|
|
83
|
+
const ttlMs = params.ttlMs ?? 120_000;
|
|
84
|
+
|
|
85
|
+
const indices = this.watermark.getNextIndices(treeId);
|
|
86
|
+
|
|
87
|
+
if (params.deviceId) {
|
|
88
|
+
const range = this.getDeviceRange(params.deviceId);
|
|
89
|
+
if (range && (indices.addressIndex < range.start || indices.addressIndex > range.end)) {
|
|
90
|
+
throw new DeviceRangeViolationError(indices.addressIndex, range.start, range.end);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const reservationId = randomId();
|
|
95
|
+
const expiresAt = Date.now() + ttlMs;
|
|
96
|
+
|
|
97
|
+
const lease: StoredLease = {
|
|
98
|
+
leaseId: reservationId,
|
|
99
|
+
leaseToken: reservationId,
|
|
100
|
+
indices,
|
|
101
|
+
expiresAt,
|
|
102
|
+
status: 'active',
|
|
103
|
+
createdAt: Date.now(),
|
|
104
|
+
leaseTTL: ttlMs,
|
|
105
|
+
};
|
|
106
|
+
await this.leaseStore.save(lease);
|
|
107
|
+
|
|
108
|
+
// Track treeId in memory so commit/burn can reference the right watermark tree
|
|
109
|
+
this._treeIdByReservation.set(reservationId, treeId);
|
|
110
|
+
|
|
111
|
+
await this.journal.append({
|
|
112
|
+
treeId,
|
|
113
|
+
branchId: params.branchId ?? 'default',
|
|
114
|
+
wotsIndex: flatIndex(indices),
|
|
115
|
+
indices,
|
|
116
|
+
status: 'reserved',
|
|
117
|
+
payloadHash: params.payloadHash,
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
deviceId: params.deviceId ?? this.deviceId,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return { reservationId, indices, expiresAt };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async commitKeyUse(reservationId: string, txId: string): Promise<void> {
|
|
126
|
+
if (!this._initialized) await this.initialize();
|
|
127
|
+
const lease = this.leaseStore.get(reservationId);
|
|
128
|
+
if (!lease) throw new LeaseNotFoundError(reservationId);
|
|
129
|
+
|
|
130
|
+
const treeId = this._treeIdByReservation.get(reservationId) ?? 'default';
|
|
131
|
+
const indices: SigningIndices = lease.indices;
|
|
132
|
+
await this.watermark.markUnavailable(treeId, indices, 'committed');
|
|
133
|
+
await this.leaseStore.updateStatus(reservationId, 'finalized');
|
|
134
|
+
this._treeIdByReservation.delete(reservationId);
|
|
135
|
+
|
|
136
|
+
await this.journal.append({
|
|
137
|
+
treeId,
|
|
138
|
+
branchId: 'default',
|
|
139
|
+
wotsIndex: flatIndex(indices),
|
|
140
|
+
indices,
|
|
141
|
+
status: 'committed',
|
|
142
|
+
txId,
|
|
143
|
+
timestamp: Date.now(),
|
|
144
|
+
deviceId: this.deviceId,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async burnReservation(reservationId: string, reason: string): Promise<void> {
|
|
149
|
+
if (!this._initialized) await this.initialize();
|
|
150
|
+
const lease = this.leaseStore.get(reservationId);
|
|
151
|
+
if (!lease) throw new LeaseNotFoundError(reservationId);
|
|
152
|
+
|
|
153
|
+
const treeId = this._treeIdByReservation.get(reservationId) ?? 'default';
|
|
154
|
+
const indices: SigningIndices = lease.indices;
|
|
155
|
+
await this.watermark.markUnavailable(treeId, indices, 'burned');
|
|
156
|
+
await this.leaseStore.updateStatus(reservationId, 'cancelled');
|
|
157
|
+
this._treeIdByReservation.delete(reservationId);
|
|
158
|
+
|
|
159
|
+
this.logger.warn(`[LocalLeaseProvider] Burning reservation ${reservationId}: ${reason}`);
|
|
160
|
+
await this.journal.append({
|
|
161
|
+
treeId,
|
|
162
|
+
branchId: 'default',
|
|
163
|
+
wotsIndex: flatIndex(indices),
|
|
164
|
+
indices,
|
|
165
|
+
status: 'burned',
|
|
166
|
+
timestamp: Date.now(),
|
|
167
|
+
deviceId: this.deviceId,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async getLocalWatermark(treeId: string): Promise<LocalWatermark> {
|
|
172
|
+
if (!this._initialized) await this.initialize();
|
|
173
|
+
return this.watermark.getLocalWatermark(treeId);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async publishWatermark(_treeId: string): Promise<void> {
|
|
177
|
+
// Layer 1: no-op
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async syncLeaseJournal(): Promise<SyncResult> {
|
|
181
|
+
// Layer 1: no-op
|
|
182
|
+
return { synced: true, conflicts: [] };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async verifyLeaseCertificate(cert?: LeaseCertificate): Promise<boolean> {
|
|
186
|
+
if (cert === undefined) return true;
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private getDeviceRange(deviceId: string): { start: number; end: number } | null {
|
|
191
|
+
const match = deviceId.match(/^device-?(\d+)$/i);
|
|
192
|
+
if (!match) return null;
|
|
193
|
+
const slot = parseInt(match[1], 10);
|
|
194
|
+
return { start: slot * 8, end: slot * 8 + 7 };
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/stubs.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub implementations for Layers 3–5.
|
|
3
|
+
* These throw descriptive errors until the full implementations land.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
WotsLeaseProvider,
|
|
8
|
+
ReserveParams,
|
|
9
|
+
LeaseReservation,
|
|
10
|
+
LeaseCertificate,
|
|
11
|
+
LocalWatermark,
|
|
12
|
+
SyncResult,
|
|
13
|
+
PersonalLeaseNodeConfig,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
import {
|
|
16
|
+
PersonalLeaseNodeNotConfiguredError,
|
|
17
|
+
P2PQuorumNotImplementedError,
|
|
18
|
+
OnchainWatermarkNotImplementedError,
|
|
19
|
+
} from './errors.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Layer 3 — personal p2p lease node.
|
|
23
|
+
*
|
|
24
|
+
* Verifies ed25519 certs issued by a configured node public key.
|
|
25
|
+
* Reserve/commit throws until the node is reachable — callers should
|
|
26
|
+
* wrap with HybridLeaseProvider using a LocalLeaseProvider as the local layer.
|
|
27
|
+
*/
|
|
28
|
+
export class PersonalLeaseNodeProvider implements WotsLeaseProvider {
|
|
29
|
+
constructor(private readonly config: PersonalLeaseNodeConfig) {}
|
|
30
|
+
|
|
31
|
+
reserveKeyUse(_params: ReserveParams): Promise<LeaseReservation> {
|
|
32
|
+
throw new PersonalLeaseNodeNotConfiguredError();
|
|
33
|
+
}
|
|
34
|
+
commitKeyUse(_reservationId: string, _txId: string): Promise<void> {
|
|
35
|
+
throw new PersonalLeaseNodeNotConfiguredError();
|
|
36
|
+
}
|
|
37
|
+
burnReservation(_reservationId: string, _reason: string): Promise<void> {
|
|
38
|
+
throw new PersonalLeaseNodeNotConfiguredError();
|
|
39
|
+
}
|
|
40
|
+
getLocalWatermark(_treeId: string): Promise<LocalWatermark> {
|
|
41
|
+
throw new PersonalLeaseNodeNotConfiguredError();
|
|
42
|
+
}
|
|
43
|
+
publishWatermark(_treeId: string): Promise<void> {
|
|
44
|
+
throw new PersonalLeaseNodeNotConfiguredError();
|
|
45
|
+
}
|
|
46
|
+
syncLeaseJournal(): Promise<SyncResult> {
|
|
47
|
+
throw new PersonalLeaseNodeNotConfiguredError();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async verifyLeaseCertificate(cert?: LeaseCertificate): Promise<boolean> {
|
|
51
|
+
if (!cert) return false;
|
|
52
|
+
if (cert.issuedBy !== this.config.nodePubkey) return false;
|
|
53
|
+
if (cert.expiresAt <= Date.now()) return false;
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Layer 4 — p2p replicated lease witnesses. */
|
|
59
|
+
export class P2PQuorumLeaseProvider implements WotsLeaseProvider {
|
|
60
|
+
reserveKeyUse(_params: ReserveParams): Promise<LeaseReservation> {
|
|
61
|
+
throw new P2PQuorumNotImplementedError();
|
|
62
|
+
}
|
|
63
|
+
commitKeyUse(_reservationId: string, _txId: string): Promise<void> {
|
|
64
|
+
throw new P2PQuorumNotImplementedError();
|
|
65
|
+
}
|
|
66
|
+
burnReservation(_reservationId: string, _reason: string): Promise<void> {
|
|
67
|
+
throw new P2PQuorumNotImplementedError();
|
|
68
|
+
}
|
|
69
|
+
getLocalWatermark(_treeId: string): Promise<LocalWatermark> {
|
|
70
|
+
throw new P2PQuorumNotImplementedError();
|
|
71
|
+
}
|
|
72
|
+
publishWatermark(_treeId: string): Promise<void> {
|
|
73
|
+
throw new P2PQuorumNotImplementedError();
|
|
74
|
+
}
|
|
75
|
+
syncLeaseJournal(): Promise<SyncResult> {
|
|
76
|
+
throw new P2PQuorumNotImplementedError();
|
|
77
|
+
}
|
|
78
|
+
verifyLeaseCertificate(_cert?: LeaseCertificate): Promise<boolean> {
|
|
79
|
+
throw new P2PQuorumNotImplementedError();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Layer 5 — on-chain watermark coin. */
|
|
84
|
+
export class OnchainWatermarkProvider implements WotsLeaseProvider {
|
|
85
|
+
reserveKeyUse(_params: ReserveParams): Promise<LeaseReservation> {
|
|
86
|
+
throw new OnchainWatermarkNotImplementedError();
|
|
87
|
+
}
|
|
88
|
+
commitKeyUse(_reservationId: string, _txId: string): Promise<void> {
|
|
89
|
+
throw new OnchainWatermarkNotImplementedError();
|
|
90
|
+
}
|
|
91
|
+
burnReservation(_reservationId: string, _reason: string): Promise<void> {
|
|
92
|
+
throw new OnchainWatermarkNotImplementedError();
|
|
93
|
+
}
|
|
94
|
+
getLocalWatermark(_treeId: string): Promise<LocalWatermark> {
|
|
95
|
+
throw new OnchainWatermarkNotImplementedError();
|
|
96
|
+
}
|
|
97
|
+
publishWatermark(_treeId: string): Promise<void> {
|
|
98
|
+
throw new OnchainWatermarkNotImplementedError();
|
|
99
|
+
}
|
|
100
|
+
syncLeaseJournal(): Promise<SyncResult> {
|
|
101
|
+
throw new OnchainWatermarkNotImplementedError();
|
|
102
|
+
}
|
|
103
|
+
verifyLeaseCertificate(_cert?: LeaseCertificate): Promise<boolean> {
|
|
104
|
+
throw new OnchainWatermarkNotImplementedError();
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @totemsdk/wots-lease — canonical v3 watermark types and WotsLeaseProvider interface
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type UnavailableReason = 'committed' | 'burned' | 'reserved-expired';
|
|
6
|
+
|
|
7
|
+
export interface SigningIndices {
|
|
8
|
+
addressIndex: number;
|
|
9
|
+
l1: number;
|
|
10
|
+
l2: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type LeaseStatus = 'pending' | 'active' | 'expired' | 'finalized' | 'cancelled';
|
|
14
|
+
|
|
15
|
+
export interface TreeWatermark {
|
|
16
|
+
treeId: string;
|
|
17
|
+
deviceId?: string;
|
|
18
|
+
branchId?: string;
|
|
19
|
+
addressCursor: number;
|
|
20
|
+
l1Cursor: number;
|
|
21
|
+
l2Cursor: number;
|
|
22
|
+
unavailable: Record<number, UnavailableReason>;
|
|
23
|
+
lastSyncTimestamp?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WotsWatermarkState {
|
|
27
|
+
version: 3;
|
|
28
|
+
trees: Record<string, TreeWatermark>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LeaseCertificate {
|
|
32
|
+
reservationId: string;
|
|
33
|
+
treeId: string;
|
|
34
|
+
branchId?: string;
|
|
35
|
+
deviceId?: string;
|
|
36
|
+
indices: SigningIndices;
|
|
37
|
+
purpose?: string;
|
|
38
|
+
payloadHash?: string;
|
|
39
|
+
issuedBy: string;
|
|
40
|
+
issuedAt: number;
|
|
41
|
+
expiresAt: number;
|
|
42
|
+
signature: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LeaseReservation {
|
|
46
|
+
reservationId: string;
|
|
47
|
+
indices: SigningIndices;
|
|
48
|
+
expiresAt: number;
|
|
49
|
+
certificate?: LeaseCertificate;
|
|
50
|
+
leaseToken?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ReserveParams {
|
|
54
|
+
treeId: string;
|
|
55
|
+
branchId?: string;
|
|
56
|
+
purpose?: string;
|
|
57
|
+
deviceId?: string;
|
|
58
|
+
ttlMs?: number;
|
|
59
|
+
payloadHash?: string;
|
|
60
|
+
valueHint?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LocalWatermark {
|
|
64
|
+
treeId: string;
|
|
65
|
+
addressCursor: number;
|
|
66
|
+
l1Cursor: number;
|
|
67
|
+
l2Cursor: number;
|
|
68
|
+
unavailableCount: number;
|
|
69
|
+
capacity: number;
|
|
70
|
+
lastSyncTimestamp?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ConflictRecord {
|
|
74
|
+
treeId: string;
|
|
75
|
+
localIndex: number;
|
|
76
|
+
remoteIndex: number;
|
|
77
|
+
timestamp: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SyncResult {
|
|
81
|
+
synced: boolean;
|
|
82
|
+
conflicts: ConflictRecord[];
|
|
83
|
+
advancedTo?: SigningIndices;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface WotsLeaseProvider {
|
|
87
|
+
reserveKeyUse(params: ReserveParams): Promise<LeaseReservation>;
|
|
88
|
+
commitKeyUse(reservationId: string, txId: string): Promise<void>;
|
|
89
|
+
burnReservation(reservationId: string, reason: string): Promise<void>;
|
|
90
|
+
getLocalWatermark(treeId: string): Promise<LocalWatermark>;
|
|
91
|
+
publishWatermark(treeId: string): Promise<void>;
|
|
92
|
+
syncLeaseJournal(): Promise<SyncResult>;
|
|
93
|
+
verifyLeaseCertificate(cert?: LeaseCertificate): Promise<boolean>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface JournalEntry {
|
|
97
|
+
treeId: string;
|
|
98
|
+
branchId: string;
|
|
99
|
+
wotsIndex: number;
|
|
100
|
+
indices: SigningIndices;
|
|
101
|
+
status: 'reserved' | 'committed' | 'burned' | 'reserved-expired';
|
|
102
|
+
payloadHash?: string;
|
|
103
|
+
txId?: string;
|
|
104
|
+
timestamp: number;
|
|
105
|
+
deviceId: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DeviceKeyRange {
|
|
109
|
+
deviceId: string;
|
|
110
|
+
startAddressIndex: number;
|
|
111
|
+
endAddressIndex: number;
|
|
112
|
+
addressCount: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface PersonalLeaseNodeConfig {
|
|
116
|
+
nodeUrl: string;
|
|
117
|
+
nodePubkey: string;
|
|
118
|
+
authToken?: string;
|
|
119
|
+
}
|
package/src/watermark.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WotsWatermarkStore — canonical v3 implementation
|
|
3
|
+
*
|
|
4
|
+
* - Reads v1 (flat usedIndices) and v2 (per-address usedIndices) automatically on load.
|
|
5
|
+
* - Writes only v3.
|
|
6
|
+
* - Enforces monotonicity: unavailable map can only grow, cursors can only advance.
|
|
7
|
+
* - Storage key unchanged ('totem_wots_watermark') to avoid storage churn.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { StorageAdapter, LoggerAdapter } from '@totemsdk/core';
|
|
11
|
+
import { NoopLogger } from '@totemsdk/core';
|
|
12
|
+
import type {
|
|
13
|
+
WotsWatermarkState,
|
|
14
|
+
TreeWatermark,
|
|
15
|
+
SigningIndices,
|
|
16
|
+
UnavailableReason,
|
|
17
|
+
LocalWatermark,
|
|
18
|
+
} from './types.js';
|
|
19
|
+
import { WatermarkMonotonicityError, WatermarkExhaustedError } from './errors.js';
|
|
20
|
+
|
|
21
|
+
const STORAGE_KEY = 'totem_wots_watermark';
|
|
22
|
+
const MAX_L = 64;
|
|
23
|
+
const CAPACITY_PER_TREE = MAX_L * MAX_L * MAX_L;
|
|
24
|
+
const DEFAULT_TREE = 'default';
|
|
25
|
+
|
|
26
|
+
export function flatIndex(idx: SigningIndices): number {
|
|
27
|
+
return idx.addressIndex * MAX_L * MAX_L + idx.l1 * MAX_L + idx.l2;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function fromFlatIndex(flat: number): SigningIndices {
|
|
31
|
+
const addressIndex = Math.floor(flat / (MAX_L * MAX_L));
|
|
32
|
+
const rem = flat % (MAX_L * MAX_L);
|
|
33
|
+
const l1 = Math.floor(rem / MAX_L);
|
|
34
|
+
const l2 = rem % MAX_L;
|
|
35
|
+
return { addressIndex, l1, l2 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function emptyTree(treeId: string): TreeWatermark {
|
|
39
|
+
return { treeId, addressCursor: 0, l1Cursor: 0, l2Cursor: 0, unavailable: {} };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function nextIndices(cur: SigningIndices): SigningIndices | null {
|
|
43
|
+
let { addressIndex, l1, l2 } = cur;
|
|
44
|
+
l2++;
|
|
45
|
+
if (l2 >= MAX_L) { l2 = 0; l1++; }
|
|
46
|
+
if (l1 >= MAX_L) { l1 = 0; addressIndex++; }
|
|
47
|
+
if (addressIndex >= MAX_L) return null;
|
|
48
|
+
return { addressIndex, l1, l2 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type V1State = { next_addressIndex?: number; next_l1?: number; next_l2?: number; usedIndices?: Array<[number, number, number]> };
|
|
52
|
+
type V2State = { version: 2; addresses: Record<number, { next_l1: number; next_l2: number; usedIndices: [number, number][] }> };
|
|
53
|
+
type V3State = WotsWatermarkState;
|
|
54
|
+
type RawState = V1State | V2State | V3State | null;
|
|
55
|
+
|
|
56
|
+
function migrateToV3(raw: RawState): WotsWatermarkState {
|
|
57
|
+
if (!raw) {
|
|
58
|
+
return { version: 3, trees: {} };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if ('version' in raw && (raw as { version: number }).version === 3) {
|
|
62
|
+
return raw as V3State;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if ('version' in raw && (raw as { version: number }).version === 2) {
|
|
66
|
+
const v2 = raw as V2State;
|
|
67
|
+
const tree = emptyTree(DEFAULT_TREE);
|
|
68
|
+
let maxFlat = -1;
|
|
69
|
+
for (const [addrIdxStr, addrData] of Object.entries(v2.addresses)) {
|
|
70
|
+
const addressIndex = Number(addrIdxStr);
|
|
71
|
+
for (const [l1, l2] of addrData.usedIndices) {
|
|
72
|
+
const f = flatIndex({ addressIndex, l1, l2 });
|
|
73
|
+
tree.unavailable[f] = 'committed';
|
|
74
|
+
if (f > maxFlat) maxFlat = f;
|
|
75
|
+
}
|
|
76
|
+
const curFlat = flatIndex({ addressIndex, l1: addrData.next_l1, l2: addrData.next_l2 });
|
|
77
|
+
if (curFlat > maxFlat) maxFlat = curFlat;
|
|
78
|
+
}
|
|
79
|
+
if (maxFlat >= 0) {
|
|
80
|
+
const cur = fromFlatIndex(maxFlat);
|
|
81
|
+
tree.addressCursor = cur.addressIndex;
|
|
82
|
+
tree.l1Cursor = cur.l1;
|
|
83
|
+
tree.l2Cursor = cur.l2;
|
|
84
|
+
}
|
|
85
|
+
return { version: 3, trees: { [DEFAULT_TREE]: tree } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const v1 = raw as V1State;
|
|
89
|
+
const tree = emptyTree(DEFAULT_TREE);
|
|
90
|
+
for (const [ai, l1, l2] of v1.usedIndices ?? []) {
|
|
91
|
+
const f = flatIndex({ addressIndex: ai, l1, l2 });
|
|
92
|
+
tree.unavailable[f] = 'committed';
|
|
93
|
+
}
|
|
94
|
+
tree.addressCursor = v1.next_addressIndex ?? 0;
|
|
95
|
+
tree.l1Cursor = v1.next_l1 ?? 0;
|
|
96
|
+
tree.l2Cursor = v1.next_l2 ?? 0;
|
|
97
|
+
return { version: 3, trees: { [DEFAULT_TREE]: tree } };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class WotsWatermarkStore {
|
|
101
|
+
private state: WotsWatermarkState | null = null;
|
|
102
|
+
private _initialized = false;
|
|
103
|
+
private readonly logger: LoggerAdapter;
|
|
104
|
+
|
|
105
|
+
constructor(
|
|
106
|
+
private readonly storage: StorageAdapter,
|
|
107
|
+
logger: LoggerAdapter = new NoopLogger(),
|
|
108
|
+
) {
|
|
109
|
+
this.logger = logger;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async initialize(): Promise<void> {
|
|
113
|
+
if (this._initialized) return;
|
|
114
|
+
const raw = await this.storage.get<RawState>(STORAGE_KEY);
|
|
115
|
+
this.state = migrateToV3(raw);
|
|
116
|
+
if (!('version' in (raw ?? {})) || (raw as { version?: number }).version !== 3) {
|
|
117
|
+
await this.persist();
|
|
118
|
+
this.logger.info('[WotsWatermarkStore] Migrated to v3');
|
|
119
|
+
}
|
|
120
|
+
this._initialized = true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private ensureInit(): WotsWatermarkState {
|
|
124
|
+
if (!this.state) throw new Error('WotsWatermarkStore not initialized — call initialize() first');
|
|
125
|
+
return this.state;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async persist(): Promise<void> {
|
|
129
|
+
await this.storage.set(STORAGE_KEY, this.state);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private getOrCreateTree(treeId: string): TreeWatermark {
|
|
133
|
+
const s = this.ensureInit();
|
|
134
|
+
if (!s.trees[treeId]) {
|
|
135
|
+
s.trees[treeId] = emptyTree(treeId);
|
|
136
|
+
}
|
|
137
|
+
return s.trees[treeId];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getNextIndices(treeId: string): SigningIndices {
|
|
141
|
+
const tree = this.getOrCreateTree(treeId);
|
|
142
|
+
let cur: SigningIndices = {
|
|
143
|
+
addressIndex: tree.addressCursor,
|
|
144
|
+
l1: tree.l1Cursor,
|
|
145
|
+
l2: tree.l2Cursor,
|
|
146
|
+
};
|
|
147
|
+
let attempts = 0;
|
|
148
|
+
while (attempts < CAPACITY_PER_TREE) {
|
|
149
|
+
if (!tree.unavailable[flatIndex(cur)]) return cur;
|
|
150
|
+
const nxt = nextIndices(cur);
|
|
151
|
+
if (!nxt) break;
|
|
152
|
+
cur = nxt;
|
|
153
|
+
attempts++;
|
|
154
|
+
}
|
|
155
|
+
throw new WatermarkExhaustedError(treeId);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async markUnavailable(
|
|
159
|
+
treeId: string,
|
|
160
|
+
indices: SigningIndices,
|
|
161
|
+
reason: UnavailableReason,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
const tree = this.getOrCreateTree(treeId);
|
|
164
|
+
const f = flatIndex(indices);
|
|
165
|
+
tree.unavailable[f] = reason;
|
|
166
|
+
const nxt = nextIndices(indices);
|
|
167
|
+
if (nxt) {
|
|
168
|
+
const nxtFlat = flatIndex(nxt);
|
|
169
|
+
const curFlat = flatIndex({ addressIndex: tree.addressCursor, l1: tree.l1Cursor, l2: tree.l2Cursor });
|
|
170
|
+
if (nxtFlat > curFlat) {
|
|
171
|
+
tree.addressCursor = nxt.addressIndex;
|
|
172
|
+
tree.l1Cursor = nxt.l1;
|
|
173
|
+
tree.l2Cursor = nxt.l2;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
await this.persist();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
isUnavailable(treeId: string, indices: SigningIndices): boolean {
|
|
180
|
+
const s = this.ensureInit();
|
|
181
|
+
const tree = s.trees[treeId];
|
|
182
|
+
if (!tree) return false;
|
|
183
|
+
return flatIndex(indices) in tree.unavailable;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async save(treeId: string, patch: Partial<TreeWatermark>): Promise<void> {
|
|
187
|
+
const s = this.ensureInit();
|
|
188
|
+
const existing = s.trees[treeId] ?? emptyTree(treeId);
|
|
189
|
+
const merged: TreeWatermark = { ...existing, ...patch, treeId };
|
|
190
|
+
|
|
191
|
+
const existingFlat = flatIndex({ addressIndex: existing.addressCursor, l1: existing.l1Cursor, l2: existing.l2Cursor });
|
|
192
|
+
const newFlat = flatIndex({ addressIndex: merged.addressCursor, l1: merged.l1Cursor, l2: merged.l2Cursor });
|
|
193
|
+
if (newFlat < existingFlat) {
|
|
194
|
+
throw new WatermarkMonotonicityError(
|
|
195
|
+
`Cannot decrease watermark cursor for ${treeId}: ${existingFlat} → ${newFlat}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const existingUnavailCount = Object.keys(existing.unavailable).length;
|
|
200
|
+
const newUnavailCount = Object.keys(merged.unavailable).length;
|
|
201
|
+
if (newUnavailCount < existingUnavailCount) {
|
|
202
|
+
throw new WatermarkMonotonicityError(
|
|
203
|
+
`Cannot shrink unavailable map for ${treeId}: ${existingUnavailCount} → ${newUnavailCount}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
s.trees[treeId] = merged;
|
|
208
|
+
await this.persist();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
getLocalWatermark(treeId: string): LocalWatermark {
|
|
212
|
+
const s = this.ensureInit();
|
|
213
|
+
const tree = s.trees[treeId] ?? emptyTree(treeId);
|
|
214
|
+
return {
|
|
215
|
+
treeId,
|
|
216
|
+
addressCursor: tree.addressCursor,
|
|
217
|
+
l1Cursor: tree.l1Cursor,
|
|
218
|
+
l2Cursor: tree.l2Cursor,
|
|
219
|
+
unavailableCount: Object.keys(tree.unavailable).length,
|
|
220
|
+
capacity: CAPACITY_PER_TREE,
|
|
221
|
+
lastSyncTimestamp: tree.lastSyncTimestamp,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
getRawState(): WotsWatermarkState {
|
|
226
|
+
return this.ensureInit();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async clear(): Promise<void> {
|
|
230
|
+
this.state = { version: 3, trees: {} };
|
|
231
|
+
this._initialized = false;
|
|
232
|
+
await this.storage.remove(STORAGE_KEY);
|
|
233
|
+
}
|
|
234
|
+
}
|