@totemsdk/wots-lease 0.1.6 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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/quorum.js
CHANGED
|
@@ -31,17 +31,25 @@
|
|
|
31
31
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
32
|
exports.P2PQuorumLeaseProvider = void 0;
|
|
33
33
|
const errors_js_1 = require("./errors.js");
|
|
34
|
+
const certificate_js_1 = require("./certificate.js");
|
|
34
35
|
function sameIndices(a, b) {
|
|
35
36
|
return a.addressIndex === b.addressIndex && a.l1 === b.l1 && a.l2 === b.l2;
|
|
36
37
|
}
|
|
37
38
|
class P2PQuorumLeaseProvider {
|
|
38
39
|
constructor(config) {
|
|
40
|
+
/**
|
|
41
|
+
* localReservationId -> (peerId -> the reservation id that peer issued).
|
|
42
|
+
* Commit/burn must reference each peer's own id, because a peer only knows
|
|
43
|
+
* the reservation it issued, not the local provider's id (AUD-044).
|
|
44
|
+
*/
|
|
45
|
+
this.peerReservationIds = new Map();
|
|
39
46
|
this._initialized = false;
|
|
40
47
|
this.peers = config.peers;
|
|
41
48
|
this.minAttestations = config.minAttestations ?? 1;
|
|
42
49
|
this.local = config.local;
|
|
43
50
|
this.requestTimeoutMs = config.requestTimeoutMs ?? 5000;
|
|
44
51
|
this.requireQuorumOnCommit = config.requireQuorumOnCommit ?? false;
|
|
52
|
+
this.certificateSigner = config.certificateSigner;
|
|
45
53
|
}
|
|
46
54
|
async initialize() {
|
|
47
55
|
if (this._initialized)
|
|
@@ -73,6 +81,7 @@ class P2PQuorumLeaseProvider {
|
|
|
73
81
|
await this.local.burnReservation(reservation.reservationId, reason);
|
|
74
82
|
throw err;
|
|
75
83
|
}
|
|
84
|
+
this.recordPeerReservationIds(reservation.reservationId, attestations);
|
|
76
85
|
const certificate = {
|
|
77
86
|
reservationId: reservation.reservationId,
|
|
78
87
|
treeId: params.treeId,
|
|
@@ -81,12 +90,13 @@ class P2PQuorumLeaseProvider {
|
|
|
81
90
|
indices: reservation.indices,
|
|
82
91
|
purpose: params.purpose,
|
|
83
92
|
payloadHash: params.payloadHash,
|
|
84
|
-
issuedBy: 'p2p-quorum',
|
|
93
|
+
issuedBy: this.certificateSigner?.name ?? 'p2p-quorum',
|
|
85
94
|
issuedAt: Date.now(),
|
|
86
95
|
expiresAt: reservation.expiresAt,
|
|
87
96
|
signature: '',
|
|
88
97
|
attestations,
|
|
89
98
|
};
|
|
99
|
+
certificate.signature = this.certificateSigner ? await (0, certificate_js_1.signCertificate)(this.certificateSigner, certificate) : '';
|
|
90
100
|
return { ...reservation, certificate };
|
|
91
101
|
}
|
|
92
102
|
/**
|
|
@@ -123,10 +133,14 @@ class P2PQuorumLeaseProvider {
|
|
|
123
133
|
conflicts.push('peer returned mismatched indices');
|
|
124
134
|
return;
|
|
125
135
|
}
|
|
136
|
+
const peerReservationId = body.reservation?.reservationId;
|
|
126
137
|
attestations.push({
|
|
127
138
|
peerId: peer.peerId,
|
|
128
139
|
indices,
|
|
129
140
|
expiresAt: body.reservation?.expiresAt ?? Date.now() + (params.ttlMs ?? 120000),
|
|
141
|
+
...(typeof peerReservationId === 'string' && peerReservationId.length > 0
|
|
142
|
+
? { peerReservationId }
|
|
143
|
+
: {}),
|
|
130
144
|
});
|
|
131
145
|
}
|
|
132
146
|
catch {
|
|
@@ -147,7 +161,10 @@ class P2PQuorumLeaseProvider {
|
|
|
147
161
|
async commitKeyUse(reservationId, txId) {
|
|
148
162
|
await this.ensureInit();
|
|
149
163
|
await this.local.commitKeyUse(reservationId, txId);
|
|
150
|
-
const acks = await this.broadcastAck('LEASE_COMMIT',
|
|
164
|
+
const acks = await this.broadcastAck('LEASE_COMMIT', (peerId) => ({
|
|
165
|
+
reservationId: this.peerReservationIdFor(reservationId, peerId),
|
|
166
|
+
txId,
|
|
167
|
+
}));
|
|
151
168
|
if (this.requireQuorumOnCommit && acks < this.minAttestations) {
|
|
152
169
|
throw new errors_js_1.QuorumUnavailableError(this.minAttestations, acks);
|
|
153
170
|
}
|
|
@@ -155,13 +172,35 @@ class P2PQuorumLeaseProvider {
|
|
|
155
172
|
async burnReservation(reservationId, reason) {
|
|
156
173
|
await this.ensureInit();
|
|
157
174
|
await this.local.burnReservation(reservationId, reason);
|
|
158
|
-
await this.broadcastAck('LEASE_BURN',
|
|
175
|
+
await this.broadcastAck('LEASE_BURN', (peerId) => ({
|
|
176
|
+
reservationId: this.peerReservationIdFor(reservationId, peerId),
|
|
177
|
+
reason,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
/** Record which peer issued which reservation id for a local reservation. */
|
|
181
|
+
recordPeerReservationIds(localReservationId, attestations) {
|
|
182
|
+
const byPeer = new Map();
|
|
183
|
+
for (const attestation of attestations) {
|
|
184
|
+
if (attestation.peerReservationId) {
|
|
185
|
+
byPeer.set(attestation.peerId, attestation.peerReservationId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (byPeer.size > 0)
|
|
189
|
+
this.peerReservationIds.set(localReservationId, byPeer);
|
|
159
190
|
}
|
|
160
|
-
|
|
191
|
+
/**
|
|
192
|
+
* The id to send a peer for a local reservation. Peers only know the id they
|
|
193
|
+
* issued; fall back to the local id only when no peer id was recorded (e.g.
|
|
194
|
+
* attestations gathered outside `reserveKeyUse`).
|
|
195
|
+
*/
|
|
196
|
+
peerReservationIdFor(localReservationId, peerId) {
|
|
197
|
+
return this.peerReservationIds.get(localReservationId)?.get(peerId) ?? localReservationId;
|
|
198
|
+
}
|
|
199
|
+
async broadcastAck(type, payloadFor) {
|
|
161
200
|
let acks = 0;
|
|
162
201
|
await Promise.all(this.peers.map(async (peer) => {
|
|
163
202
|
try {
|
|
164
|
-
const resp = await peer.request({ type, payload }, this.requestTimeoutMs);
|
|
203
|
+
const resp = await peer.request({ type, payload: payloadFor(peer.peerId) }, this.requestTimeoutMs);
|
|
165
204
|
if (resp.type !== 'ERROR')
|
|
166
205
|
acks++;
|
|
167
206
|
}
|
|
@@ -254,6 +293,10 @@ class P2PQuorumLeaseProvider {
|
|
|
254
293
|
return false;
|
|
255
294
|
if (cert.expiresAt <= Date.now())
|
|
256
295
|
return false;
|
|
296
|
+
// An unsigned certificate carries no authenticity evidence.
|
|
297
|
+
if (!(await (0, certificate_js_1.certificateSignatureVerified)(cert, this.certificateSigner))) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
257
300
|
const attestations = cert.attestations ?? [];
|
|
258
301
|
if (attestations.length < this.minAttestations)
|
|
259
302
|
return false;
|
package/dist/seed.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @totemsdk/wots-lease/seed
|
|
3
|
+
*
|
|
4
|
+
* Watermark seeding for import/restore (RFC-013 §9). The safety property is
|
|
5
|
+
* "never reissue a used leaf". A local watermark alone is insufficient across
|
|
6
|
+
* devices/restores, so seeding draws from, in priority order:
|
|
7
|
+
*
|
|
8
|
+
* 1. the on-chain watermark cursor (advances the local watermark),
|
|
9
|
+
* 2. an exported watermark blob,
|
|
10
|
+
* 3. an operator-provided high-water mark.
|
|
11
|
+
*
|
|
12
|
+
* If none is available and the caller has not declared the keyspace fresh,
|
|
13
|
+
* seeding **fails closed** — starting at 0 would reissue spent leaves.
|
|
14
|
+
*/
|
|
15
|
+
import type { LocalLeaseProvider } from './local.js';
|
|
16
|
+
import type { OnchainWatermarkProvider } from './onchain.js';
|
|
17
|
+
import type { LocalWatermark, WatermarkSeed } from './types.js';
|
|
18
|
+
export interface SeedLeaseWatermarkParams {
|
|
19
|
+
readonly local: LocalLeaseProvider;
|
|
20
|
+
readonly treeId: string;
|
|
21
|
+
/** Layer 5 — the on-chain cursor is authoritative when reachable. */
|
|
22
|
+
readonly onchain?: OnchainWatermarkProvider;
|
|
23
|
+
/** Exported watermark blob. */
|
|
24
|
+
readonly exported?: WatermarkSeed;
|
|
25
|
+
/** Operator-provided flat high-water mark. */
|
|
26
|
+
readonly operatorHighWaterMark?: number;
|
|
27
|
+
/** Declares the keyspace fresh/unspent, so cursor 0 is safe. */
|
|
28
|
+
readonly fresh?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Seed a local watermark from the best available source, requiring at least one
|
|
32
|
+
* source unless `fresh` is set. Returns the resulting local watermark.
|
|
33
|
+
*
|
|
34
|
+
* @throws {WatermarkSeedRequiredError} when no source is available and the
|
|
35
|
+
* keyspace is not declared fresh.
|
|
36
|
+
* @throws {WatermarkSeedRegressionError} when a supplied seed is behind the
|
|
37
|
+
* current cursor.
|
|
38
|
+
*/
|
|
39
|
+
export declare function seedLeaseWatermark(params: SeedLeaseWatermarkParams): Promise<LocalWatermark>;
|
|
40
|
+
/** Export a portable watermark blob for backup/restore. */
|
|
41
|
+
export declare function exportLeaseWatermark(local: LocalLeaseProvider, treeId: string): Promise<WatermarkSeed>;
|
package/dist/seed.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module @totemsdk/wots-lease/seed
|
|
4
|
+
*
|
|
5
|
+
* Watermark seeding for import/restore (RFC-013 §9). The safety property is
|
|
6
|
+
* "never reissue a used leaf". A local watermark alone is insufficient across
|
|
7
|
+
* devices/restores, so seeding draws from, in priority order:
|
|
8
|
+
*
|
|
9
|
+
* 1. the on-chain watermark cursor (advances the local watermark),
|
|
10
|
+
* 2. an exported watermark blob,
|
|
11
|
+
* 3. an operator-provided high-water mark.
|
|
12
|
+
*
|
|
13
|
+
* If none is available and the caller has not declared the keyspace fresh,
|
|
14
|
+
* seeding **fails closed** — starting at 0 would reissue spent leaves.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.seedLeaseWatermark = seedLeaseWatermark;
|
|
18
|
+
exports.exportLeaseWatermark = exportLeaseWatermark;
|
|
19
|
+
const watermark_js_1 = require("./watermark.js");
|
|
20
|
+
const errors_js_1 = require("./errors.js");
|
|
21
|
+
function flatOf(w) {
|
|
22
|
+
return (0, watermark_js_1.flatIndex)({ addressIndex: w.addressCursor, l1: w.l1Cursor, l2: w.l2Cursor });
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Seed a local watermark from the best available source, requiring at least one
|
|
26
|
+
* source unless `fresh` is set. Returns the resulting local watermark.
|
|
27
|
+
*
|
|
28
|
+
* @throws {WatermarkSeedRequiredError} when no source is available and the
|
|
29
|
+
* keyspace is not declared fresh.
|
|
30
|
+
* @throws {WatermarkSeedRegressionError} when a supplied seed is behind the
|
|
31
|
+
* current cursor.
|
|
32
|
+
*/
|
|
33
|
+
async function seedLeaseWatermark(params) {
|
|
34
|
+
const { local, treeId } = params;
|
|
35
|
+
// 1. On-chain cursor — advances the same local watermark (monotonic).
|
|
36
|
+
if (params.onchain) {
|
|
37
|
+
try {
|
|
38
|
+
await params.onchain.syncLeaseJournal();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Chain unreachable — fall through to exported/operator sources below.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const current = await local.getLocalWatermark(treeId);
|
|
45
|
+
let bestFlat = flatOf({ addressCursor: current.addressCursor, l1Cursor: current.l1Cursor, l2Cursor: current.l2Cursor });
|
|
46
|
+
let best;
|
|
47
|
+
// 2. Exported blob.
|
|
48
|
+
if (params.exported) {
|
|
49
|
+
if (flatOf(params.exported) > bestFlat) {
|
|
50
|
+
best = params.exported;
|
|
51
|
+
bestFlat = flatOf(params.exported);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// 3. Operator high-water mark.
|
|
55
|
+
if (params.operatorHighWaterMark !== undefined) {
|
|
56
|
+
const indices = (0, watermark_js_1.fromFlatIndex)(params.operatorHighWaterMark);
|
|
57
|
+
const candidate = {
|
|
58
|
+
version: 1,
|
|
59
|
+
treeId,
|
|
60
|
+
addressCursor: indices.addressIndex,
|
|
61
|
+
l1Cursor: indices.l1,
|
|
62
|
+
l2Cursor: indices.l2,
|
|
63
|
+
unavailable: [],
|
|
64
|
+
};
|
|
65
|
+
if (flatOf(candidate) > bestFlat) {
|
|
66
|
+
best = candidate;
|
|
67
|
+
bestFlat = flatOf(candidate);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (best) {
|
|
71
|
+
await local.seedWatermark(best);
|
|
72
|
+
}
|
|
73
|
+
if (!params.onchain &&
|
|
74
|
+
!params.exported &&
|
|
75
|
+
params.operatorHighWaterMark === undefined &&
|
|
76
|
+
!params.fresh) {
|
|
77
|
+
throw new errors_js_1.WatermarkSeedRequiredError(treeId);
|
|
78
|
+
}
|
|
79
|
+
return local.getLocalWatermark(treeId);
|
|
80
|
+
}
|
|
81
|
+
/** Export a portable watermark blob for backup/restore. */
|
|
82
|
+
function exportLeaseWatermark(local, treeId) {
|
|
83
|
+
return local.exportWatermark(treeId);
|
|
84
|
+
}
|
package/dist/stubs.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export declare class PersonalLeaseNodeProvider implements WotsLeaseProvider {
|
|
|
34
34
|
private readonly baseUrl;
|
|
35
35
|
private readonly nodePubkey;
|
|
36
36
|
private readonly authToken?;
|
|
37
|
+
private readonly certificateSigner?;
|
|
38
|
+
private readonly verify?;
|
|
37
39
|
constructor(config: PersonalLeaseNodeConfig);
|
|
38
40
|
private headers;
|
|
39
41
|
private post;
|
package/dist/stubs.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
13
|
exports.PersonalLeaseNodeProvider = exports.OnchainWatermarkProvider = exports.P2PQuorumLeaseProvider = void 0;
|
|
14
|
+
const certificate_js_1 = require("./certificate.js");
|
|
14
15
|
var quorum_js_1 = require("./quorum.js");
|
|
15
16
|
Object.defineProperty(exports, "P2PQuorumLeaseProvider", { enumerable: true, get: function () { return quorum_js_1.P2PQuorumLeaseProvider; } });
|
|
16
17
|
var onchain_js_1 = require("./onchain.js");
|
|
@@ -39,6 +40,8 @@ class PersonalLeaseNodeProvider {
|
|
|
39
40
|
this.baseUrl = config.nodeUrl.replace(/\/$/, '');
|
|
40
41
|
this.nodePubkey = config.nodePubkey;
|
|
41
42
|
this.authToken = config.authToken;
|
|
43
|
+
this.certificateSigner = config.certificateSigner;
|
|
44
|
+
this.verify = config.verify;
|
|
42
45
|
}
|
|
43
46
|
headers() {
|
|
44
47
|
const h = { 'Content-Type': 'application/json' };
|
|
@@ -103,7 +106,20 @@ class PersonalLeaseNodeProvider {
|
|
|
103
106
|
return false;
|
|
104
107
|
if (cert.expiresAt <= Date.now())
|
|
105
108
|
return false;
|
|
106
|
-
|
|
109
|
+
// AUD-016: fail closed. The provider can only verify a certificate when it
|
|
110
|
+
// holds a verifier; an `issuedBy` match is not authenticity evidence.
|
|
111
|
+
const signer = this.certificateSigner?.verify
|
|
112
|
+
? this.certificateSigner
|
|
113
|
+
: this.verify
|
|
114
|
+
? {
|
|
115
|
+
publicKeyDigest: this.nodePubkey,
|
|
116
|
+
sign: async () => {
|
|
117
|
+
throw new Error('PersonalLeaseNodeProvider holds a verify-only signer and cannot sign');
|
|
118
|
+
},
|
|
119
|
+
verify: this.verify,
|
|
120
|
+
}
|
|
121
|
+
: undefined;
|
|
122
|
+
return (0, certificate_js_1.certificateSignatureVerified)(cert, signer);
|
|
107
123
|
}
|
|
108
124
|
}
|
|
109
125
|
exports.PersonalLeaseNodeProvider = PersonalLeaseNodeProvider;
|
package/dist/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* @totemsdk/wots-lease — canonical v3 watermark types and WotsLeaseProvider interface
|
|
3
3
|
*/
|
|
4
4
|
import type { LocalLeaseProvider } from './local.js';
|
|
5
|
+
import type { StorageAdapter } from '@totemsdk/core';
|
|
5
6
|
export type UnavailableReason = 'reserved' | 'committed' | 'burned' | 'reserved-expired';
|
|
6
7
|
export interface SigningIndices {
|
|
7
8
|
addressIndex: number;
|
|
@@ -28,6 +29,28 @@ export interface QuorumAttestation {
|
|
|
28
29
|
indices: SigningIndices;
|
|
29
30
|
expiresAt: number;
|
|
30
31
|
signature?: string;
|
|
32
|
+
/**
|
|
33
|
+
* The reservation id the peer issued for these indices in its own journal.
|
|
34
|
+
* Commit/burn broadcasts must reference the peer's id, not the local one
|
|
35
|
+
* (AUD-044) — each peer only knows the reservation it issued.
|
|
36
|
+
*/
|
|
37
|
+
peerReservationId?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Identity used to authenticate lease certificates (Layer 4/5).
|
|
41
|
+
*
|
|
42
|
+
* A certificate's `signature` is a signature over the canonical certificate
|
|
43
|
+
* payload (see certificate.ts). Verifiers reject unsigned certificates.
|
|
44
|
+
*/
|
|
45
|
+
export interface CertificateSigner {
|
|
46
|
+
/** Hex (0x-prefixed or bare) public key digest of the issuing identity. */
|
|
47
|
+
publicKeyDigest: string;
|
|
48
|
+
/** Identity label stored on issued certificates via `issuedBy`. */
|
|
49
|
+
name?: string;
|
|
50
|
+
/** Sign the canonical certificate message. */
|
|
51
|
+
sign(message: Uint8Array): Promise<Uint8Array>;
|
|
52
|
+
/** Verify a signature over the canonical certificate message. */
|
|
53
|
+
verify?(message: Uint8Array, signature: Uint8Array): Promise<boolean>;
|
|
31
54
|
}
|
|
32
55
|
export interface LeaseCertificate {
|
|
33
56
|
reservationId: string;
|
|
@@ -71,6 +94,28 @@ export interface LocalWatermark {
|
|
|
71
94
|
capacity: number;
|
|
72
95
|
lastSyncTimestamp?: number;
|
|
73
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* A portable, self-describing watermark snapshot (RFC-013 §9). Used to seed a
|
|
99
|
+
* freshly-provisioned local watermark from an exported blob, an operator-supplied
|
|
100
|
+
* high-water mark, or the on-chain cursor, so a restored wallet never starts
|
|
101
|
+
* below the true high-water mark and reissues a spent leaf.
|
|
102
|
+
*/
|
|
103
|
+
export interface WatermarkSeed {
|
|
104
|
+
readonly version: 1;
|
|
105
|
+
readonly treeId: string;
|
|
106
|
+
readonly addressCursor: number;
|
|
107
|
+
readonly l1Cursor: number;
|
|
108
|
+
readonly l2Cursor: number;
|
|
109
|
+
/** Flat indices already consumed (reserved/committed/burned). */
|
|
110
|
+
readonly unavailable?: readonly number[];
|
|
111
|
+
}
|
|
112
|
+
export interface SeedWatermarkOptions {
|
|
113
|
+
/**
|
|
114
|
+
* Permit a seed whose cursor is behind the current local cursor. Defaults to
|
|
115
|
+
* `false` — regression re-exposes used leaves, so seeding fails closed.
|
|
116
|
+
*/
|
|
117
|
+
readonly allowRegression?: boolean;
|
|
118
|
+
}
|
|
74
119
|
export interface ConflictRecord {
|
|
75
120
|
treeId: string;
|
|
76
121
|
localIndex: number;
|
|
@@ -115,6 +160,21 @@ export interface PersonalLeaseNodeConfig {
|
|
|
115
160
|
nodeUrl: string;
|
|
116
161
|
nodePubkey: string;
|
|
117
162
|
authToken?: string;
|
|
163
|
+
/**
|
|
164
|
+
* Identity that authenticates certificates issued by this node. When it
|
|
165
|
+
* carries a `verify()` function, `verifyLeaseCertificate` uses it. Without
|
|
166
|
+
* any verifier the provider FAILS CLOSED (AUD-016) — an identity match on
|
|
167
|
+
* `issuedBy` alone is never enough.
|
|
168
|
+
*/
|
|
169
|
+
certificateSigner?: CertificateSigner;
|
|
170
|
+
/**
|
|
171
|
+
* Verify-only cryptographic verifier for certificates issued by the node.
|
|
172
|
+
* The provider holds no signing key, so this is the recommended way to make
|
|
173
|
+
* `verifyLeaseCertificate` functional. When neither this nor a
|
|
174
|
+
* `certificateSigner.verify` is supplied, the provider rejects every
|
|
175
|
+
* certificate (fail closed).
|
|
176
|
+
*/
|
|
177
|
+
verify?: (message: Uint8Array, signature: Uint8Array) => Promise<boolean>;
|
|
118
178
|
}
|
|
119
179
|
/**
|
|
120
180
|
* Layer 4 — P2P quorum lease coordination.
|
|
@@ -146,6 +206,12 @@ export interface P2PQuorumLeaseProviderConfig {
|
|
|
146
206
|
requestTimeoutMs?: number;
|
|
147
207
|
/** Require quorum approval on commit as well as reserve. Default: false. */
|
|
148
208
|
requireQuorumOnCommit?: boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Identity that authenticates issued certificates. When set, reserved
|
|
211
|
+
* certificates carry a real signature; without it, certificates are
|
|
212
|
+
* issued unsigned and will FAIL verification.
|
|
213
|
+
*/
|
|
214
|
+
certificateSigner?: CertificateSigner;
|
|
149
215
|
}
|
|
150
216
|
/**
|
|
151
217
|
* Layer 5 — on-chain watermark anchoring.
|
|
@@ -187,13 +253,20 @@ export interface OnchainWatermarkProviderConfig {
|
|
|
187
253
|
amount?: string;
|
|
188
254
|
/** Local provider used for the authoritative local watermark + journal. */
|
|
189
255
|
local: LocalLeaseProvider;
|
|
190
|
-
/** Signer for the watermark coin's script (SIGNEDBY digest). */
|
|
256
|
+
/** Signer for the watermark coin's script (SIGNEDBY digest). Also authenticates issued certificates. */
|
|
191
257
|
signer: {
|
|
192
258
|
publicKeyDigest: string;
|
|
193
259
|
sign(message: Uint8Array): Promise<Uint8Array>;
|
|
260
|
+
verify?(message: Uint8Array, signature: Uint8Array): Promise<boolean>;
|
|
194
261
|
};
|
|
195
262
|
/** Port holding the flat watermark cursor in the coin state. Default: 0. */
|
|
196
263
|
statePort?: number;
|
|
197
264
|
/** Minimum blocks between on-chain publishes (rate limit). Default: 1. */
|
|
198
265
|
minBlocksBetweenPublishes?: number;
|
|
266
|
+
/**
|
|
267
|
+
* Optional durable storage for the watermark coin's identity. When set, the
|
|
268
|
+
* provider persists the advanced watermark coin ID after each publish so the
|
|
269
|
+
* rollover survives restarts. Default: in-memory only.
|
|
270
|
+
*/
|
|
271
|
+
storage?: StorageAdapter;
|
|
199
272
|
}
|
package/dist/watermark.d.ts
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* - Storage key unchanged ('totem_wots_watermark') to avoid storage churn.
|
|
8
8
|
*/
|
|
9
9
|
import type { StorageAdapter, LoggerAdapter } from '@totemsdk/core';
|
|
10
|
-
import type { WotsWatermarkState, TreeWatermark, SigningIndices, UnavailableReason, LocalWatermark } from './types.js';
|
|
10
|
+
import type { WotsWatermarkState, TreeWatermark, SigningIndices, UnavailableReason, LocalWatermark, WatermarkSeed, SeedWatermarkOptions } from './types.js';
|
|
11
11
|
export declare function flatIndex(idx: SigningIndices): number;
|
|
12
12
|
export declare function fromFlatIndex(flat: number): SigningIndices;
|
|
13
|
+
export declare function assertValidSigningIndices(indices: SigningIndices): void;
|
|
13
14
|
export declare class WotsWatermarkStore {
|
|
14
15
|
private readonly storage;
|
|
15
16
|
private state;
|
|
@@ -19,12 +20,32 @@ export declare class WotsWatermarkStore {
|
|
|
19
20
|
initialize(): Promise<void>;
|
|
20
21
|
private ensureInit;
|
|
21
22
|
private persist;
|
|
23
|
+
/**
|
|
24
|
+
* Reload the persisted watermark and merge it monotonically into the local
|
|
25
|
+
* state. A provider that shares its storage with another provider (AUD-004)
|
|
26
|
+
* must call this inside its mutation critical section before allocating or
|
|
27
|
+
* marking, so it observes writes it did not make and never re-issues a slot
|
|
28
|
+
* another instance already took.
|
|
29
|
+
*/
|
|
30
|
+
refresh(): Promise<void>;
|
|
22
31
|
private getOrCreateTree;
|
|
23
32
|
getNextIndices(treeId: string): SigningIndices;
|
|
24
33
|
markUnavailable(treeId: string, indices: SigningIndices, reason: UnavailableReason): Promise<void>;
|
|
25
34
|
isUnavailable(treeId: string, indices: SigningIndices): boolean;
|
|
26
35
|
save(treeId: string, patch: Partial<TreeWatermark>): Promise<void>;
|
|
27
36
|
getLocalWatermark(treeId: string): LocalWatermark;
|
|
37
|
+
/**
|
|
38
|
+
* Export a portable seed for a tree (RFC-013 §9). Includes the cursor and the
|
|
39
|
+
* flat indices already consumed, so it can rebuild the no-reuse state exactly.
|
|
40
|
+
*/
|
|
41
|
+
exportSeed(treeId: string): WatermarkSeed;
|
|
42
|
+
/**
|
|
43
|
+
* Seed the watermark from an exported/operator/on-chain snapshot. Forward-only
|
|
44
|
+
* by default: a seed behind the current cursor raises
|
|
45
|
+
* {@link WatermarkSeedRegressionError} so a restore can never re-expose used
|
|
46
|
+
* leaves. The unavailable set is unioned, never shrunk.
|
|
47
|
+
*/
|
|
48
|
+
seed(seed: WatermarkSeed, options?: SeedWatermarkOptions): Promise<void>;
|
|
28
49
|
getRawState(): WotsWatermarkState;
|
|
29
50
|
clear(): Promise<void>;
|
|
30
51
|
}
|
package/dist/watermark.js
CHANGED
|
@@ -11,6 +11,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
11
11
|
exports.WotsWatermarkStore = void 0;
|
|
12
12
|
exports.flatIndex = flatIndex;
|
|
13
13
|
exports.fromFlatIndex = fromFlatIndex;
|
|
14
|
+
exports.assertValidSigningIndices = assertValidSigningIndices;
|
|
14
15
|
const core_1 = require("@totemsdk/core");
|
|
15
16
|
const errors_js_1 = require("./errors.js");
|
|
16
17
|
const STORAGE_KEY = 'totem_wots_watermark';
|
|
@@ -18,15 +19,27 @@ const MAX_L = 64;
|
|
|
18
19
|
const CAPACITY_PER_TREE = MAX_L * MAX_L * MAX_L;
|
|
19
20
|
const DEFAULT_TREE = 'default';
|
|
20
21
|
function flatIndex(idx) {
|
|
22
|
+
assertValidSigningIndices(idx);
|
|
21
23
|
return idx.addressIndex * MAX_L * MAX_L + idx.l1 * MAX_L + idx.l2;
|
|
22
24
|
}
|
|
23
25
|
function fromFlatIndex(flat) {
|
|
26
|
+
if (!Number.isSafeInteger(flat) || flat < 0 || flat >= CAPACITY_PER_TREE) {
|
|
27
|
+
throw new RangeError(`WOTS flat index must be an integer in [0, ${CAPACITY_PER_TREE - 1}]`);
|
|
28
|
+
}
|
|
24
29
|
const addressIndex = Math.floor(flat / (MAX_L * MAX_L));
|
|
25
30
|
const rem = flat % (MAX_L * MAX_L);
|
|
26
31
|
const l1 = Math.floor(rem / MAX_L);
|
|
27
32
|
const l2 = rem % MAX_L;
|
|
28
33
|
return { addressIndex, l1, l2 };
|
|
29
34
|
}
|
|
35
|
+
function assertValidSigningIndices(indices) {
|
|
36
|
+
for (const name of ['addressIndex', 'l1', 'l2']) {
|
|
37
|
+
const value = indices[name];
|
|
38
|
+
if (!Number.isSafeInteger(value) || value < 0 || value >= MAX_L) {
|
|
39
|
+
throw new RangeError(`WOTS ${name} must be an integer in [0, ${MAX_L - 1}]`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
30
43
|
function emptyTree(treeId) {
|
|
31
44
|
return { treeId, addressCursor: 0, l1Cursor: 0, l2Cursor: 0, unavailable: {} };
|
|
32
45
|
}
|
|
@@ -45,6 +58,37 @@ function nextIndices(cur) {
|
|
|
45
58
|
return null;
|
|
46
59
|
return { addressIndex, l1, l2 };
|
|
47
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Monotonic merge of two watermark states: the cursor is the further of the
|
|
63
|
+
* two and the unavailable maps are unioned, so merging never re-exposes a used
|
|
64
|
+
* index. Used to pick up writes made by another provider over the same storage
|
|
65
|
+
* (AUD-004).
|
|
66
|
+
*/
|
|
67
|
+
function mergeWatermarkState(a, b) {
|
|
68
|
+
if (!a)
|
|
69
|
+
return b;
|
|
70
|
+
const trees = { ...a.trees };
|
|
71
|
+
for (const [treeId, bt] of Object.entries(b.trees)) {
|
|
72
|
+
const at = trees[treeId];
|
|
73
|
+
if (!at) {
|
|
74
|
+
trees[treeId] = bt;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const aFlat = flatIndex({ addressIndex: at.addressCursor, l1: at.l1Cursor, l2: at.l2Cursor });
|
|
78
|
+
const bFlat = flatIndex({ addressIndex: bt.addressCursor, l1: bt.l1Cursor, l2: bt.l2Cursor });
|
|
79
|
+
const cursor = bFlat > aFlat ? bt : at;
|
|
80
|
+
const lastSync = Math.max(at.lastSyncTimestamp ?? 0, bt.lastSyncTimestamp ?? 0);
|
|
81
|
+
trees[treeId] = {
|
|
82
|
+
treeId,
|
|
83
|
+
addressCursor: cursor.addressCursor,
|
|
84
|
+
l1Cursor: cursor.l1Cursor,
|
|
85
|
+
l2Cursor: cursor.l2Cursor,
|
|
86
|
+
unavailable: { ...bt.unavailable, ...at.unavailable },
|
|
87
|
+
...(lastSync > 0 ? { lastSyncTimestamp: lastSync } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { version: 3, trees };
|
|
91
|
+
}
|
|
48
92
|
function migrateToV3(raw) {
|
|
49
93
|
if (!raw) {
|
|
50
94
|
return { version: 3, trees: {} };
|
|
@@ -113,6 +157,19 @@ class WotsWatermarkStore {
|
|
|
113
157
|
async persist() {
|
|
114
158
|
await this.storage.set(STORAGE_KEY, this.state);
|
|
115
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Reload the persisted watermark and merge it monotonically into the local
|
|
162
|
+
* state. A provider that shares its storage with another provider (AUD-004)
|
|
163
|
+
* must call this inside its mutation critical section before allocating or
|
|
164
|
+
* marking, so it observes writes it did not make and never re-issues a slot
|
|
165
|
+
* another instance already took.
|
|
166
|
+
*/
|
|
167
|
+
async refresh() {
|
|
168
|
+
if (!this._initialized || !this.state)
|
|
169
|
+
return;
|
|
170
|
+
const raw = await this.storage.get(STORAGE_KEY);
|
|
171
|
+
this.state = mergeWatermarkState(this.state, migrateToV3(raw));
|
|
172
|
+
}
|
|
116
173
|
getOrCreateTree(treeId) {
|
|
117
174
|
const s = this.ensureInit();
|
|
118
175
|
if (!s.trees[treeId]) {
|
|
@@ -160,7 +217,13 @@ class WotsWatermarkStore {
|
|
|
160
217
|
const tree = s.trees[treeId];
|
|
161
218
|
if (!tree)
|
|
162
219
|
return false;
|
|
163
|
-
|
|
220
|
+
const index = flatIndex(indices);
|
|
221
|
+
const cursor = flatIndex({
|
|
222
|
+
addressIndex: tree.addressCursor,
|
|
223
|
+
l1: tree.l1Cursor,
|
|
224
|
+
l2: tree.l2Cursor,
|
|
225
|
+
});
|
|
226
|
+
return index < cursor || index in tree.unavailable;
|
|
164
227
|
}
|
|
165
228
|
async save(treeId, patch) {
|
|
166
229
|
const s = this.ensureInit();
|
|
@@ -192,6 +255,67 @@ class WotsWatermarkStore {
|
|
|
192
255
|
lastSyncTimestamp: tree.lastSyncTimestamp,
|
|
193
256
|
};
|
|
194
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Export a portable seed for a tree (RFC-013 §9). Includes the cursor and the
|
|
260
|
+
* flat indices already consumed, so it can rebuild the no-reuse state exactly.
|
|
261
|
+
*/
|
|
262
|
+
exportSeed(treeId) {
|
|
263
|
+
const s = this.ensureInit();
|
|
264
|
+
const tree = s.trees[treeId] ?? emptyTree(treeId);
|
|
265
|
+
const unavailable = Object.keys(tree.unavailable)
|
|
266
|
+
.map((k) => Number(k))
|
|
267
|
+
.filter((f) => Number.isSafeInteger(f))
|
|
268
|
+
.sort((a, b) => a - b);
|
|
269
|
+
return {
|
|
270
|
+
version: 1,
|
|
271
|
+
treeId,
|
|
272
|
+
addressCursor: tree.addressCursor,
|
|
273
|
+
l1Cursor: tree.l1Cursor,
|
|
274
|
+
l2Cursor: tree.l2Cursor,
|
|
275
|
+
unavailable,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Seed the watermark from an exported/operator/on-chain snapshot. Forward-only
|
|
280
|
+
* by default: a seed behind the current cursor raises
|
|
281
|
+
* {@link WatermarkSeedRegressionError} so a restore can never re-expose used
|
|
282
|
+
* leaves. The unavailable set is unioned, never shrunk.
|
|
283
|
+
*/
|
|
284
|
+
async seed(seed, options = {}) {
|
|
285
|
+
const s = this.ensureInit();
|
|
286
|
+
const existing = s.trees[seed.treeId] ?? emptyTree(seed.treeId);
|
|
287
|
+
const seedCursor = flatIndex({
|
|
288
|
+
addressIndex: seed.addressCursor,
|
|
289
|
+
l1: seed.l1Cursor,
|
|
290
|
+
l2: seed.l2Cursor,
|
|
291
|
+
});
|
|
292
|
+
const existingCursor = flatIndex({
|
|
293
|
+
addressIndex: existing.addressCursor,
|
|
294
|
+
l1: existing.l1Cursor,
|
|
295
|
+
l2: existing.l2Cursor,
|
|
296
|
+
});
|
|
297
|
+
if (!options.allowRegression && seedCursor < existingCursor) {
|
|
298
|
+
throw new errors_js_1.WatermarkSeedRegressionError(seed.treeId, existingCursor, seedCursor);
|
|
299
|
+
}
|
|
300
|
+
const unavailable = { ...existing.unavailable };
|
|
301
|
+
for (const flat of seed.unavailable ?? []) {
|
|
302
|
+
if (!Number.isSafeInteger(flat) || flat < 0 || flat >= CAPACITY_PER_TREE) {
|
|
303
|
+
throw new RangeError(`Seed unavailable index must be an integer in [0, ${CAPACITY_PER_TREE - 1}]`);
|
|
304
|
+
}
|
|
305
|
+
if (!(flat in unavailable))
|
|
306
|
+
unavailable[flat] = 'committed';
|
|
307
|
+
}
|
|
308
|
+
s.trees[seed.treeId] = {
|
|
309
|
+
...existing,
|
|
310
|
+
treeId: seed.treeId,
|
|
311
|
+
addressCursor: seed.addressCursor,
|
|
312
|
+
l1Cursor: seed.l1Cursor,
|
|
313
|
+
l2Cursor: seed.l2Cursor,
|
|
314
|
+
unavailable,
|
|
315
|
+
lastSyncTimestamp: Date.now(),
|
|
316
|
+
};
|
|
317
|
+
await this.persist();
|
|
318
|
+
}
|
|
195
319
|
getRawState() {
|
|
196
320
|
return this.ensureInit();
|
|
197
321
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@totemsdk/wots-lease",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "WOTS key-use coordination — canonical v3 watermark, provider-based lease safety layers",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@totemsdk/
|
|
23
|
-
"@totemsdk/
|
|
24
|
-
"@totemsdk/txpow": "^0.
|
|
22
|
+
"@totemsdk/core": "^1.2.11",
|
|
23
|
+
"@totemsdk/lookup-protocol": "^0.2.0",
|
|
24
|
+
"@totemsdk/txpow": "^1.0.2",
|
|
25
|
+
"@totemsdk/storage": "^0.1.0"
|
|
25
26
|
},
|
|
26
27
|
"devDependencies": {
|
|
27
28
|
"@types/jest": "^30.0.0",
|
|
@@ -38,13 +39,13 @@
|
|
|
38
39
|
},
|
|
39
40
|
"license": "MIT",
|
|
40
41
|
"author": "Totem SDK",
|
|
41
|
-
"homepage": "https://totem.ing",
|
|
42
|
+
"homepage": "https://docs.totem.ing",
|
|
42
43
|
"bugs": {
|
|
43
|
-
"url": "https://github.com/
|
|
44
|
+
"url": "https://github.com/Totem-Edge/totem-sdk/issues"
|
|
44
45
|
},
|
|
45
46
|
"repository": {
|
|
46
47
|
"type": "git",
|
|
47
|
-
"url": "git+https://github.com/
|
|
48
|
+
"url": "git+https://github.com/Totem-Edge/totem-sdk.git",
|
|
48
49
|
"directory": "packages/wots-lease"
|
|
49
50
|
},
|
|
50
51
|
"keywords": [
|