@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/onchain.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
/**
|
|
3
2
|
* OnchainWatermarkProvider — Layer 5: on-chain watermark anchoring.
|
|
4
3
|
*
|
|
@@ -21,13 +20,13 @@
|
|
|
21
20
|
* - syncLeaseJournal merges the on-chain cursor into the local watermark
|
|
22
21
|
* monotonically (remote ahead → advance; remote behind → conflict record).
|
|
23
22
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const txpow_1 = require("@totemsdk/txpow");
|
|
23
|
+
import { flatIndex } from './watermark.js';
|
|
24
|
+
import { OnchainWatermarkError } from './errors.js';
|
|
25
|
+
import { sha3_256, bytesToHex, hexToBytes, parseMxAddress, serializeTransaction, computeTransactionDigest, rollCoinId, writeMiniNumber, } from '@totemsdk/core';
|
|
26
|
+
import { serializeTxPoW } from '@totemsdk/txpow';
|
|
27
|
+
import { signCertificate, certificateSignatureVerified } from './certificate.js';
|
|
30
28
|
const MAX_L = 64;
|
|
29
|
+
const WATERMARK_COIN_STORAGE_KEY = 'wots-lease:onchain:watermark-coin';
|
|
31
30
|
function multiConcat(parts) {
|
|
32
31
|
if (parts.length === 0)
|
|
33
32
|
return new Uint8Array(0);
|
|
@@ -46,7 +45,7 @@ function concatBytes(a, b) {
|
|
|
46
45
|
function addressToHex(address) {
|
|
47
46
|
const upper = address.startsWith('0x') || address.startsWith('0X') ? address.slice(2) : address;
|
|
48
47
|
return upper.toUpperCase().startsWith('MX')
|
|
49
|
-
?
|
|
48
|
+
? bytesToHex(parseMxAddress(address))
|
|
50
49
|
: address.replace(/^0x/i, '');
|
|
51
50
|
}
|
|
52
51
|
function flatToHex(flat) {
|
|
@@ -77,19 +76,20 @@ function stateVarJson(port, hexValue) {
|
|
|
77
76
|
return { port, svtype: 'hex', data: hexValue };
|
|
78
77
|
}
|
|
79
78
|
function buildWitnessBytes(sigs) {
|
|
80
|
-
const parts = [
|
|
79
|
+
const parts = [writeMiniNumber(BigInt(sigs.length), 0)];
|
|
81
80
|
for (const sig of sigs)
|
|
82
81
|
parts.push(sig);
|
|
83
|
-
parts.push(
|
|
84
|
-
parts.push(
|
|
82
|
+
parts.push(writeMiniNumber(0n, 0));
|
|
83
|
+
parts.push(writeMiniNumber(0n, 0));
|
|
85
84
|
return multiConcat(parts);
|
|
86
85
|
}
|
|
87
|
-
class OnchainWatermarkProvider {
|
|
86
|
+
export class OnchainWatermarkProvider {
|
|
88
87
|
constructor(config) {
|
|
89
88
|
this._initialized = false;
|
|
90
89
|
this._lastPublishBlock = -1;
|
|
91
90
|
this.chain = config.chain;
|
|
92
|
-
this.
|
|
91
|
+
this.initialWatermarkCoinId = config.watermarkCoinId;
|
|
92
|
+
this.currentCoinId = config.watermarkCoinId;
|
|
93
93
|
this.watermarkAddress = config.watermarkAddress;
|
|
94
94
|
this.tokenId = config.tokenId ?? '0x00';
|
|
95
95
|
this.amount = config.amount ?? '1';
|
|
@@ -97,10 +97,25 @@ class OnchainWatermarkProvider {
|
|
|
97
97
|
this.signer = config.signer;
|
|
98
98
|
this.statePort = config.statePort ?? 0;
|
|
99
99
|
this.minBlocksBetweenPublishes = config.minBlocksBetweenPublishes ?? 1;
|
|
100
|
+
this.storage = config.storage;
|
|
100
101
|
}
|
|
101
102
|
async initialize() {
|
|
102
103
|
if (this._initialized)
|
|
103
104
|
return;
|
|
105
|
+
// Recover the advanced watermark coin identity across restarts.
|
|
106
|
+
if (this.storage) {
|
|
107
|
+
try {
|
|
108
|
+
const saved = await this.storage.get(WATERMARK_COIN_STORAGE_KEY);
|
|
109
|
+
if (saved?.coinId)
|
|
110
|
+
this.currentCoinId = saved.coinId;
|
|
111
|
+
if (typeof saved?.lastPublishBlock === 'number') {
|
|
112
|
+
this._lastPublishBlock = saved.lastPublishBlock;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Storage unavailable — fall back to the configured coin.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
104
119
|
await this.local.initialize();
|
|
105
120
|
this._initialized = true;
|
|
106
121
|
}
|
|
@@ -127,6 +142,7 @@ class OnchainWatermarkProvider {
|
|
|
127
142
|
expiresAt: reservation.expiresAt,
|
|
128
143
|
signature: '',
|
|
129
144
|
};
|
|
145
|
+
certificate.signature = await signCertificate(this.signer, certificate);
|
|
130
146
|
return { ...reservation, certificate };
|
|
131
147
|
}
|
|
132
148
|
async commitKeyUse(reservationId, txId) {
|
|
@@ -153,7 +169,7 @@ class OnchainWatermarkProvider {
|
|
|
153
169
|
async publishWatermark(treeId) {
|
|
154
170
|
await this.ensureInit();
|
|
155
171
|
const wm = await this.local.getLocalWatermark(treeId);
|
|
156
|
-
const newFlat =
|
|
172
|
+
const newFlat = flatIndex({
|
|
157
173
|
addressIndex: wm.addressCursor,
|
|
158
174
|
l1: wm.l1Cursor,
|
|
159
175
|
l2: wm.l2Cursor,
|
|
@@ -170,10 +186,11 @@ class OnchainWatermarkProvider {
|
|
|
170
186
|
catch {
|
|
171
187
|
// Tip unavailable — proceed without rate limiting.
|
|
172
188
|
}
|
|
173
|
-
// Read the current on-chain cursor so the input state matches the coin
|
|
174
|
-
|
|
189
|
+
// Read the current on-chain cursor so the input state matches the coin
|
|
190
|
+
// anchored by the CURRENT watermark coin (which advances on every spend).
|
|
191
|
+
const coin = await this.chain.getCoin(this.currentCoinId);
|
|
175
192
|
if (!coin) {
|
|
176
|
-
throw new
|
|
193
|
+
throw new OnchainWatermarkError(`Watermark coin not found: ${this.currentCoinId}`);
|
|
177
194
|
}
|
|
178
195
|
const currentState = coin.state ?? [];
|
|
179
196
|
const currentFlat = readStateCursor(currentState, this.statePort);
|
|
@@ -182,7 +199,7 @@ class OnchainWatermarkProvider {
|
|
|
182
199
|
return;
|
|
183
200
|
}
|
|
184
201
|
const inputCoin = {
|
|
185
|
-
coinid: this.
|
|
202
|
+
coinid: this.currentCoinId,
|
|
186
203
|
address: addressToHex(this.watermarkAddress),
|
|
187
204
|
amount: this.amount,
|
|
188
205
|
tokenid: this.tokenId,
|
|
@@ -196,22 +213,39 @@ class OnchainWatermarkProvider {
|
|
|
196
213
|
storestate: true,
|
|
197
214
|
state: [stateVarJson(this.statePort, flatToHex(newFlat))],
|
|
198
215
|
};
|
|
216
|
+
const rolledCoinId = rollCoinId(hexToBytes(this.currentCoinId), 0);
|
|
199
217
|
const tx = {
|
|
200
218
|
linkhash: '0x00',
|
|
201
219
|
inputs: [inputCoin],
|
|
202
|
-
outputs: [outputCoin],
|
|
220
|
+
outputs: [{ ...outputCoin, coinid: '0x' + bytesToHex(rolledCoinId) }],
|
|
203
221
|
state: [],
|
|
204
222
|
};
|
|
205
|
-
const txBytes =
|
|
206
|
-
|
|
207
|
-
const digest = (0, core_1.computeTransactionDigest)(txBytes);
|
|
223
|
+
const txBytes = serializeTransaction(JSON.stringify(tx));
|
|
224
|
+
const digest = computeTransactionDigest(txBytes);
|
|
208
225
|
const sig = await this.signer.sign(digest);
|
|
209
226
|
const witnessBytes = buildWitnessBytes([sig]);
|
|
210
|
-
const prng =
|
|
211
|
-
const txHex = Buffer.from(
|
|
227
|
+
const prng = sha3_256(new TextEncoder().encode(`watermark:${treeId}:${this.currentCoinId}:${newFlat}`));
|
|
228
|
+
const txHex = Buffer.from(serializeTxPoW(txBytes, witnessBytes, { prng })).toString('hex');
|
|
212
229
|
const result = await this.chain.broadcastTxPoW(txHex);
|
|
213
230
|
if (!result.success) {
|
|
214
|
-
throw new
|
|
231
|
+
throw new OnchainWatermarkError(`Watermark publish rejected: ${result.message ?? 'unknown error'}`);
|
|
232
|
+
}
|
|
233
|
+
// Rollover: the spent watermark coin is destroyed and replaced by a fresh
|
|
234
|
+
// coin with a new coin ID (output 0 of this spend). Track and persist the
|
|
235
|
+
// ADVANCED identity so the next publish spends the live coin, not the
|
|
236
|
+
// consumed one.
|
|
237
|
+
const newCoinId = '0x' + bytesToHex(rolledCoinId);
|
|
238
|
+
this.currentCoinId = newCoinId;
|
|
239
|
+
if (this.storage) {
|
|
240
|
+
try {
|
|
241
|
+
await this.storage.set(WATERMARK_COIN_STORAGE_KEY, {
|
|
242
|
+
coinId: newCoinId,
|
|
243
|
+
lastPublishBlock: this._lastPublishBlock,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// Storage unavailable — in-memory rollover still applies this session.
|
|
248
|
+
}
|
|
215
249
|
}
|
|
216
250
|
}
|
|
217
251
|
// -------------------------------------------------------------------------
|
|
@@ -223,14 +257,14 @@ class OnchainWatermarkProvider {
|
|
|
223
257
|
let advancedTo;
|
|
224
258
|
for (const treeId of this.local.listTrees()) {
|
|
225
259
|
const localWm = await this.local.getLocalWatermark(treeId);
|
|
226
|
-
const localFlat =
|
|
260
|
+
const localFlat = flatIndex({
|
|
227
261
|
addressIndex: localWm.addressCursor,
|
|
228
262
|
l1: localWm.l1Cursor,
|
|
229
263
|
l2: localWm.l2Cursor,
|
|
230
264
|
});
|
|
231
265
|
let onchainFlat = null;
|
|
232
266
|
try {
|
|
233
|
-
const coin = await this.chain.getCoin(this.
|
|
267
|
+
const coin = await this.chain.getCoin(this.currentCoinId);
|
|
234
268
|
if (coin)
|
|
235
269
|
onchainFlat = readStateCursor(coin.state ?? [], this.statePort);
|
|
236
270
|
}
|
|
@@ -269,7 +303,8 @@ class OnchainWatermarkProvider {
|
|
|
269
303
|
return false;
|
|
270
304
|
if (cert.expiresAt <= Date.now())
|
|
271
305
|
return false;
|
|
306
|
+
if (!(await certificateSignatureVerified(cert, this.signer)))
|
|
307
|
+
return false;
|
|
272
308
|
return cert.issuedBy === 'onchain-watermark';
|
|
273
309
|
}
|
|
274
310
|
}
|
|
275
|
-
exports.OnchainWatermarkProvider = OnchainWatermarkProvider;
|
package/dist/quorum.d.ts
CHANGED
|
@@ -34,6 +34,13 @@ export declare class P2PQuorumLeaseProvider implements WotsLeaseProvider {
|
|
|
34
34
|
private readonly local;
|
|
35
35
|
private readonly requestTimeoutMs;
|
|
36
36
|
private readonly requireQuorumOnCommit;
|
|
37
|
+
private readonly certificateSigner?;
|
|
38
|
+
/**
|
|
39
|
+
* localReservationId -> (peerId -> the reservation id that peer issued).
|
|
40
|
+
* Commit/burn must reference each peer's own id, because a peer only knows
|
|
41
|
+
* the reservation it issued, not the local provider's id (AUD-044).
|
|
42
|
+
*/
|
|
43
|
+
private readonly peerReservationIds;
|
|
37
44
|
private _initialized;
|
|
38
45
|
constructor(config: P2PQuorumLeaseProviderConfig);
|
|
39
46
|
initialize(): Promise<void>;
|
|
@@ -52,6 +59,14 @@ export declare class P2PQuorumLeaseProvider implements WotsLeaseProvider {
|
|
|
52
59
|
}): Promise<QuorumAttestation[]>;
|
|
53
60
|
commitKeyUse(reservationId: string, txId: string): Promise<void>;
|
|
54
61
|
burnReservation(reservationId: string, reason: string): Promise<void>;
|
|
62
|
+
/** Record which peer issued which reservation id for a local reservation. */
|
|
63
|
+
private recordPeerReservationIds;
|
|
64
|
+
/**
|
|
65
|
+
* The id to send a peer for a local reservation. Peers only know the id they
|
|
66
|
+
* issued; fall back to the local id only when no peer id was recorded (e.g.
|
|
67
|
+
* attestations gathered outside `reserveKeyUse`).
|
|
68
|
+
*/
|
|
69
|
+
private peerReservationIdFor;
|
|
55
70
|
private broadcastAck;
|
|
56
71
|
getLocalWatermark(treeId: string): Promise<LocalWatermark>;
|
|
57
72
|
publishWatermark(treeId: string): Promise<void>;
|
package/dist/quorum.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
/**
|
|
3
2
|
* P2PQuorumLeaseProvider — Layer 4: multi-device quorum lease coordination.
|
|
4
3
|
*
|
|
@@ -28,20 +27,26 @@
|
|
|
28
27
|
* - If a peer reports the slot as taken, the local reservation is burned
|
|
29
28
|
* and QuorumConflictError is thrown.
|
|
30
29
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const errors_js_1 = require("./errors.js");
|
|
30
|
+
import { QuorumUnavailableError, QuorumConflictError, } from './errors.js';
|
|
31
|
+
import { signCertificate, certificateSignatureVerified, } from './certificate.js';
|
|
34
32
|
function sameIndices(a, b) {
|
|
35
33
|
return a.addressIndex === b.addressIndex && a.l1 === b.l1 && a.l2 === b.l2;
|
|
36
34
|
}
|
|
37
|
-
class P2PQuorumLeaseProvider {
|
|
35
|
+
export class P2PQuorumLeaseProvider {
|
|
38
36
|
constructor(config) {
|
|
37
|
+
/**
|
|
38
|
+
* localReservationId -> (peerId -> the reservation id that peer issued).
|
|
39
|
+
* Commit/burn must reference each peer's own id, because a peer only knows
|
|
40
|
+
* the reservation it issued, not the local provider's id (AUD-044).
|
|
41
|
+
*/
|
|
42
|
+
this.peerReservationIds = new Map();
|
|
39
43
|
this._initialized = false;
|
|
40
44
|
this.peers = config.peers;
|
|
41
45
|
this.minAttestations = config.minAttestations ?? 1;
|
|
42
46
|
this.local = config.local;
|
|
43
47
|
this.requestTimeoutMs = config.requestTimeoutMs ?? 5000;
|
|
44
48
|
this.requireQuorumOnCommit = config.requireQuorumOnCommit ?? false;
|
|
49
|
+
this.certificateSigner = config.certificateSigner;
|
|
45
50
|
}
|
|
46
51
|
async initialize() {
|
|
47
52
|
if (this._initialized)
|
|
@@ -67,12 +72,13 @@ class P2PQuorumLeaseProvider {
|
|
|
67
72
|
attestations = await this.attestKeyUse(params, reservation.indices);
|
|
68
73
|
}
|
|
69
74
|
catch (err) {
|
|
70
|
-
const reason = err instanceof
|
|
75
|
+
const reason = err instanceof QuorumConflictError
|
|
71
76
|
? `quorum conflict: ${err.message}`
|
|
72
77
|
: 'quorum not reached';
|
|
73
78
|
await this.local.burnReservation(reservation.reservationId, reason);
|
|
74
79
|
throw err;
|
|
75
80
|
}
|
|
81
|
+
this.recordPeerReservationIds(reservation.reservationId, attestations);
|
|
76
82
|
const certificate = {
|
|
77
83
|
reservationId: reservation.reservationId,
|
|
78
84
|
treeId: params.treeId,
|
|
@@ -81,12 +87,13 @@ class P2PQuorumLeaseProvider {
|
|
|
81
87
|
indices: reservation.indices,
|
|
82
88
|
purpose: params.purpose,
|
|
83
89
|
payloadHash: params.payloadHash,
|
|
84
|
-
issuedBy: 'p2p-quorum',
|
|
90
|
+
issuedBy: this.certificateSigner?.name ?? 'p2p-quorum',
|
|
85
91
|
issuedAt: Date.now(),
|
|
86
92
|
expiresAt: reservation.expiresAt,
|
|
87
93
|
signature: '',
|
|
88
94
|
attestations,
|
|
89
95
|
};
|
|
96
|
+
certificate.signature = this.certificateSigner ? await signCertificate(this.certificateSigner, certificate) : '';
|
|
90
97
|
return { ...reservation, certificate };
|
|
91
98
|
}
|
|
92
99
|
/**
|
|
@@ -123,10 +130,14 @@ class P2PQuorumLeaseProvider {
|
|
|
123
130
|
conflicts.push('peer returned mismatched indices');
|
|
124
131
|
return;
|
|
125
132
|
}
|
|
133
|
+
const peerReservationId = body.reservation?.reservationId;
|
|
126
134
|
attestations.push({
|
|
127
135
|
peerId: peer.peerId,
|
|
128
136
|
indices,
|
|
129
137
|
expiresAt: body.reservation?.expiresAt ?? Date.now() + (params.ttlMs ?? 120000),
|
|
138
|
+
...(typeof peerReservationId === 'string' && peerReservationId.length > 0
|
|
139
|
+
? { peerReservationId }
|
|
140
|
+
: {}),
|
|
130
141
|
});
|
|
131
142
|
}
|
|
132
143
|
catch {
|
|
@@ -134,10 +145,10 @@ class P2PQuorumLeaseProvider {
|
|
|
134
145
|
}
|
|
135
146
|
}));
|
|
136
147
|
if (conflicts.length > 0) {
|
|
137
|
-
throw new
|
|
148
|
+
throw new QuorumConflictError(params.treeId, indices);
|
|
138
149
|
}
|
|
139
150
|
if (attestations.length < this.minAttestations) {
|
|
140
|
-
throw new
|
|
151
|
+
throw new QuorumUnavailableError(this.minAttestations, attestations.length);
|
|
141
152
|
}
|
|
142
153
|
return attestations;
|
|
143
154
|
}
|
|
@@ -147,21 +158,46 @@ class P2PQuorumLeaseProvider {
|
|
|
147
158
|
async commitKeyUse(reservationId, txId) {
|
|
148
159
|
await this.ensureInit();
|
|
149
160
|
await this.local.commitKeyUse(reservationId, txId);
|
|
150
|
-
const acks = await this.broadcastAck('LEASE_COMMIT',
|
|
161
|
+
const acks = await this.broadcastAck('LEASE_COMMIT', (peerId) => ({
|
|
162
|
+
reservationId: this.peerReservationIdFor(reservationId, peerId),
|
|
163
|
+
txId,
|
|
164
|
+
}));
|
|
151
165
|
if (this.requireQuorumOnCommit && acks < this.minAttestations) {
|
|
152
|
-
throw new
|
|
166
|
+
throw new QuorumUnavailableError(this.minAttestations, acks);
|
|
153
167
|
}
|
|
154
168
|
}
|
|
155
169
|
async burnReservation(reservationId, reason) {
|
|
156
170
|
await this.ensureInit();
|
|
157
171
|
await this.local.burnReservation(reservationId, reason);
|
|
158
|
-
await this.broadcastAck('LEASE_BURN',
|
|
172
|
+
await this.broadcastAck('LEASE_BURN', (peerId) => ({
|
|
173
|
+
reservationId: this.peerReservationIdFor(reservationId, peerId),
|
|
174
|
+
reason,
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
/** Record which peer issued which reservation id for a local reservation. */
|
|
178
|
+
recordPeerReservationIds(localReservationId, attestations) {
|
|
179
|
+
const byPeer = new Map();
|
|
180
|
+
for (const attestation of attestations) {
|
|
181
|
+
if (attestation.peerReservationId) {
|
|
182
|
+
byPeer.set(attestation.peerId, attestation.peerReservationId);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (byPeer.size > 0)
|
|
186
|
+
this.peerReservationIds.set(localReservationId, byPeer);
|
|
159
187
|
}
|
|
160
|
-
|
|
188
|
+
/**
|
|
189
|
+
* The id to send a peer for a local reservation. Peers only know the id they
|
|
190
|
+
* issued; fall back to the local id only when no peer id was recorded (e.g.
|
|
191
|
+
* attestations gathered outside `reserveKeyUse`).
|
|
192
|
+
*/
|
|
193
|
+
peerReservationIdFor(localReservationId, peerId) {
|
|
194
|
+
return this.peerReservationIds.get(localReservationId)?.get(peerId) ?? localReservationId;
|
|
195
|
+
}
|
|
196
|
+
async broadcastAck(type, payloadFor) {
|
|
161
197
|
let acks = 0;
|
|
162
198
|
await Promise.all(this.peers.map(async (peer) => {
|
|
163
199
|
try {
|
|
164
|
-
const resp = await peer.request({ type, payload }, this.requestTimeoutMs);
|
|
200
|
+
const resp = await peer.request({ type, payload: payloadFor(peer.peerId) }, this.requestTimeoutMs);
|
|
165
201
|
if (resp.type !== 'ERROR')
|
|
166
202
|
acks++;
|
|
167
203
|
}
|
|
@@ -254,6 +290,10 @@ class P2PQuorumLeaseProvider {
|
|
|
254
290
|
return false;
|
|
255
291
|
if (cert.expiresAt <= Date.now())
|
|
256
292
|
return false;
|
|
293
|
+
// An unsigned certificate carries no authenticity evidence.
|
|
294
|
+
if (!(await certificateSignatureVerified(cert, this.certificateSigner))) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
257
297
|
const attestations = cert.attestations ?? [];
|
|
258
298
|
if (attestations.length < this.minAttestations)
|
|
259
299
|
return false;
|
|
@@ -261,4 +301,3 @@ class P2PQuorumLeaseProvider {
|
|
|
261
301
|
a.expiresAt > Date.now());
|
|
262
302
|
}
|
|
263
303
|
}
|
|
264
|
-
exports.P2PQuorumLeaseProvider = P2PQuorumLeaseProvider;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-057 — replicated WOTS lease-journal backup.
|
|
3
|
+
*
|
|
4
|
+
* The local `LeaseJournal` remains authoritative for allocation. This adapter
|
|
5
|
+
* mirrors the physically append-only journal into a replicated log for audit
|
|
6
|
+
* and disaster recovery, and restores entries into a local journal in order.
|
|
7
|
+
* It never allocates or advances a signing slot.
|
|
8
|
+
*/
|
|
9
|
+
import type { ReplicatedLog } from '@totemsdk/replication';
|
|
10
|
+
import type { JournalEntry } from './types.js';
|
|
11
|
+
export interface LeaseJournalRestoreTarget {
|
|
12
|
+
append(entry: JournalEntry): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare class ReplicatedLeaseJournalBackup {
|
|
15
|
+
private readonly log;
|
|
16
|
+
constructor(log: ReplicatedLog<JournalEntry>);
|
|
17
|
+
/** Mirror one local journal entry. */
|
|
18
|
+
append(entry: JournalEntry): Promise<{
|
|
19
|
+
length: number;
|
|
20
|
+
head: string;
|
|
21
|
+
}>;
|
|
22
|
+
/** Read all mirrored entries, verifying the local hash-chain links in order. */
|
|
23
|
+
readAll(): Promise<JournalEntry[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Restore into a local journal. The local journal's own duplicate-slot and
|
|
26
|
+
* reservation checks remain the final guard; this method only preserves the
|
|
27
|
+
* replicated order and links.
|
|
28
|
+
*/
|
|
29
|
+
restoreInto(target: LeaseJournalRestoreTarget): Promise<number>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-057 — replicated WOTS lease-journal backup.
|
|
3
|
+
*
|
|
4
|
+
* The local `LeaseJournal` remains authoritative for allocation. This adapter
|
|
5
|
+
* mirrors the physically append-only journal into a replicated log for audit
|
|
6
|
+
* and disaster recovery, and restores entries into a local journal in order.
|
|
7
|
+
* It never allocates or advances a signing slot.
|
|
8
|
+
*/
|
|
9
|
+
import { StorageError } from '@totemsdk/storage/errors';
|
|
10
|
+
export class ReplicatedLeaseJournalBackup {
|
|
11
|
+
constructor(log) {
|
|
12
|
+
this.log = log;
|
|
13
|
+
}
|
|
14
|
+
/** Mirror one local journal entry. */
|
|
15
|
+
async append(entry) {
|
|
16
|
+
return this.log.append(entry);
|
|
17
|
+
}
|
|
18
|
+
/** Read all mirrored entries, verifying the local hash-chain links in order. */
|
|
19
|
+
async readAll() {
|
|
20
|
+
const entries = [];
|
|
21
|
+
for await (const entry of this.log.readSince())
|
|
22
|
+
entries.push(entry.record);
|
|
23
|
+
let previous;
|
|
24
|
+
for (const [index, entry] of entries.entries()) {
|
|
25
|
+
if (index === 0) {
|
|
26
|
+
if (entry.previousHash !== undefined) {
|
|
27
|
+
throw new StorageError('replicated lease journal first entry has previousHash', 'corrupt');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else if (entry.previousHash !== previous?.hash) {
|
|
31
|
+
throw new StorageError(`replicated lease journal chain mismatch at ${index}`, 'corrupt');
|
|
32
|
+
}
|
|
33
|
+
previous = entry;
|
|
34
|
+
}
|
|
35
|
+
return entries;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Restore into a local journal. The local journal's own duplicate-slot and
|
|
39
|
+
* reservation checks remain the final guard; this method only preserves the
|
|
40
|
+
* replicated order and links.
|
|
41
|
+
*/
|
|
42
|
+
async restoreInto(target) {
|
|
43
|
+
const entries = await this.readAll();
|
|
44
|
+
for (const entry of entries)
|
|
45
|
+
await target.append(entry);
|
|
46
|
+
return entries.length;
|
|
47
|
+
}
|
|
48
|
+
}
|
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,80 @@
|
|
|
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 { flatIndex, fromFlatIndex } from './watermark.js';
|
|
16
|
+
import { WatermarkSeedRequiredError } from './errors.js';
|
|
17
|
+
function flatOf(w) {
|
|
18
|
+
return flatIndex({ addressIndex: w.addressCursor, l1: w.l1Cursor, l2: w.l2Cursor });
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Seed a local watermark from the best available source, requiring at least one
|
|
22
|
+
* source unless `fresh` is set. Returns the resulting local watermark.
|
|
23
|
+
*
|
|
24
|
+
* @throws {WatermarkSeedRequiredError} when no source is available and the
|
|
25
|
+
* keyspace is not declared fresh.
|
|
26
|
+
* @throws {WatermarkSeedRegressionError} when a supplied seed is behind the
|
|
27
|
+
* current cursor.
|
|
28
|
+
*/
|
|
29
|
+
export async function seedLeaseWatermark(params) {
|
|
30
|
+
const { local, treeId } = params;
|
|
31
|
+
// 1. On-chain cursor — advances the same local watermark (monotonic).
|
|
32
|
+
if (params.onchain) {
|
|
33
|
+
try {
|
|
34
|
+
await params.onchain.syncLeaseJournal();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Chain unreachable — fall through to exported/operator sources below.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const current = await local.getLocalWatermark(treeId);
|
|
41
|
+
let bestFlat = flatOf({ addressCursor: current.addressCursor, l1Cursor: current.l1Cursor, l2Cursor: current.l2Cursor });
|
|
42
|
+
let best;
|
|
43
|
+
// 2. Exported blob.
|
|
44
|
+
if (params.exported) {
|
|
45
|
+
if (flatOf(params.exported) > bestFlat) {
|
|
46
|
+
best = params.exported;
|
|
47
|
+
bestFlat = flatOf(params.exported);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 3. Operator high-water mark.
|
|
51
|
+
if (params.operatorHighWaterMark !== undefined) {
|
|
52
|
+
const indices = fromFlatIndex(params.operatorHighWaterMark);
|
|
53
|
+
const candidate = {
|
|
54
|
+
version: 1,
|
|
55
|
+
treeId,
|
|
56
|
+
addressCursor: indices.addressIndex,
|
|
57
|
+
l1Cursor: indices.l1,
|
|
58
|
+
l2Cursor: indices.l2,
|
|
59
|
+
unavailable: [],
|
|
60
|
+
};
|
|
61
|
+
if (flatOf(candidate) > bestFlat) {
|
|
62
|
+
best = candidate;
|
|
63
|
+
bestFlat = flatOf(candidate);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (best) {
|
|
67
|
+
await local.seedWatermark(best);
|
|
68
|
+
}
|
|
69
|
+
if (!params.onchain &&
|
|
70
|
+
!params.exported &&
|
|
71
|
+
params.operatorHighWaterMark === undefined &&
|
|
72
|
+
!params.fresh) {
|
|
73
|
+
throw new WatermarkSeedRequiredError(treeId);
|
|
74
|
+
}
|
|
75
|
+
return local.getLocalWatermark(treeId);
|
|
76
|
+
}
|
|
77
|
+
/** Export a portable watermark blob for backup/restore. */
|
|
78
|
+
export function exportLeaseWatermark(local, treeId) {
|
|
79
|
+
return local.exportWatermark(treeId);
|
|
80
|
+
}
|
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
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
/**
|
|
3
2
|
* Implementations for Layers 3–5 of the WotsLeaseProvider hierarchy.
|
|
4
3
|
*
|
|
@@ -9,12 +8,9 @@
|
|
|
9
8
|
* Layers 4–5 (P2PQuorumLeaseProvider, OnchainWatermarkProvider) — implemented in
|
|
10
9
|
* quorum.ts and onchain.ts; re-exported here for backward compatibility.
|
|
11
10
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Object.defineProperty(exports, "P2PQuorumLeaseProvider", { enumerable: true, get: function () { return quorum_js_1.P2PQuorumLeaseProvider; } });
|
|
16
|
-
var onchain_js_1 = require("./onchain.js");
|
|
17
|
-
Object.defineProperty(exports, "OnchainWatermarkProvider", { enumerable: true, get: function () { return onchain_js_1.OnchainWatermarkProvider; } });
|
|
11
|
+
import { certificateSignatureVerified } from './certificate.js';
|
|
12
|
+
export { P2PQuorumLeaseProvider } from './quorum.js';
|
|
13
|
+
export { OnchainWatermarkProvider } from './onchain.js';
|
|
18
14
|
/**
|
|
19
15
|
* Layer 3 — personal lookup-node lease coordinator.
|
|
20
16
|
*
|
|
@@ -34,11 +30,13 @@ Object.defineProperty(exports, "OnchainWatermarkProvider", { enumerable: true, g
|
|
|
34
30
|
* });
|
|
35
31
|
* ```
|
|
36
32
|
*/
|
|
37
|
-
class PersonalLeaseNodeProvider {
|
|
33
|
+
export class PersonalLeaseNodeProvider {
|
|
38
34
|
constructor(config) {
|
|
39
35
|
this.baseUrl = config.nodeUrl.replace(/\/$/, '');
|
|
40
36
|
this.nodePubkey = config.nodePubkey;
|
|
41
37
|
this.authToken = config.authToken;
|
|
38
|
+
this.certificateSigner = config.certificateSigner;
|
|
39
|
+
this.verify = config.verify;
|
|
42
40
|
}
|
|
43
41
|
headers() {
|
|
44
42
|
const h = { 'Content-Type': 'application/json' };
|
|
@@ -103,7 +101,19 @@ class PersonalLeaseNodeProvider {
|
|
|
103
101
|
return false;
|
|
104
102
|
if (cert.expiresAt <= Date.now())
|
|
105
103
|
return false;
|
|
106
|
-
|
|
104
|
+
// AUD-016: fail closed. The provider can only verify a certificate when it
|
|
105
|
+
// holds a verifier; an `issuedBy` match is not authenticity evidence.
|
|
106
|
+
const signer = this.certificateSigner?.verify
|
|
107
|
+
? this.certificateSigner
|
|
108
|
+
: this.verify
|
|
109
|
+
? {
|
|
110
|
+
publicKeyDigest: this.nodePubkey,
|
|
111
|
+
sign: async () => {
|
|
112
|
+
throw new Error('PersonalLeaseNodeProvider holds a verify-only signer and cannot sign');
|
|
113
|
+
},
|
|
114
|
+
verify: this.verify,
|
|
115
|
+
}
|
|
116
|
+
: undefined;
|
|
117
|
+
return certificateSignatureVerified(cert, signer);
|
|
107
118
|
}
|
|
108
119
|
}
|
|
109
|
-
exports.PersonalLeaseNodeProvider = PersonalLeaseNodeProvider;
|