@totemsdk/wots-lease 0.1.4 → 0.1.6
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/README.md +42 -3
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +37 -1
- package/dist/hybrid.js +27 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +9 -1
- package/dist/local.d.ts +23 -1
- package/dist/local.js +46 -3
- package/dist/onchain.d.ts +52 -0
- package/dist/onchain.js +275 -0
- package/dist/quorum.d.ts +60 -0
- package/dist/quorum.js +264 -0
- package/dist/stubs.d.ts +4 -22
- package/dist/stubs.js +7 -54
- package/dist/types.d.ts +92 -0
- package/package.json +15 -13
- package/rust/pkg/package.json +27 -0
- package/rust/pkg/wots_lease_wasm.d.ts +24 -0
- package/rust/pkg/wots_lease_wasm.js +9 -0
- package/rust/pkg/wots_lease_wasm_bg.js +547 -0
- package/rust/pkg/wots_lease_wasm_bg.wasm +0 -0
- package/rust/pkg/wots_lease_wasm_bg.wasm.d.ts +22 -0
- package/rust/pkg-node/package.json +21 -0
- package/rust/pkg-node/wots_lease_wasm.d.ts +24 -0
- package/rust/pkg-node/wots_lease_wasm.js +563 -0
- package/rust/pkg-node/wots_lease_wasm_bg.wasm +0 -0
- package/rust/pkg-node/wots_lease_wasm_bg.wasm.d.ts +22 -0
package/dist/onchain.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* OnchainWatermarkProvider — Layer 5: on-chain watermark anchoring.
|
|
4
|
+
*
|
|
5
|
+
* Safety model
|
|
6
|
+
* ────────────
|
|
7
|
+
* The local provider remains the authoritative watermark. This provider adds
|
|
8
|
+
* a verifiable public record: a dedicated watermark coin whose STATE(0) holds
|
|
9
|
+
* the flat watermark cursor. Every publish spends the coin back to itself
|
|
10
|
+
* with an advanced cursor, so any third party can read the on-chain state and
|
|
11
|
+
* prove that a given signing slot was consumed — without trusting this device.
|
|
12
|
+
*
|
|
13
|
+
* The watermark coin script is `RETURN SIGNEDBY(0x<digest>)` — the same
|
|
14
|
+
* single-sig pattern used by statechain reclaim addresses. The signer is the
|
|
15
|
+
* WOTS key owner; the digest is the 32-byte public key digest.
|
|
16
|
+
*
|
|
17
|
+
* Failure semantics
|
|
18
|
+
* ─────────────────
|
|
19
|
+
* - publishWatermark fails loudly (OnchainWatermarkError) when the chain
|
|
20
|
+
* rejects the TX — the local watermark is never rolled back.
|
|
21
|
+
* - syncLeaseJournal merges the on-chain cursor into the local watermark
|
|
22
|
+
* monotonically (remote ahead → advance; remote behind → conflict record).
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.OnchainWatermarkProvider = void 0;
|
|
26
|
+
const watermark_js_1 = require("./watermark.js");
|
|
27
|
+
const errors_js_1 = require("./errors.js");
|
|
28
|
+
const core_1 = require("@totemsdk/core");
|
|
29
|
+
const txpow_1 = require("@totemsdk/txpow");
|
|
30
|
+
const MAX_L = 64;
|
|
31
|
+
function multiConcat(parts) {
|
|
32
|
+
if (parts.length === 0)
|
|
33
|
+
return new Uint8Array(0);
|
|
34
|
+
let result = parts[0];
|
|
35
|
+
for (let i = 1; i < parts.length; i++) {
|
|
36
|
+
result = concatBytes(result, parts[i]);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
function concatBytes(a, b) {
|
|
41
|
+
const out = new Uint8Array(a.length + b.length);
|
|
42
|
+
out.set(a, 0);
|
|
43
|
+
out.set(b, a.length);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
function addressToHex(address) {
|
|
47
|
+
const upper = address.startsWith('0x') || address.startsWith('0X') ? address.slice(2) : address;
|
|
48
|
+
return upper.toUpperCase().startsWith('MX')
|
|
49
|
+
? (0, core_1.bytesToHex)((0, core_1.parseMxAddress)(address))
|
|
50
|
+
: address.replace(/^0x/i, '');
|
|
51
|
+
}
|
|
52
|
+
function flatToHex(flat) {
|
|
53
|
+
let hex = BigInt(flat).toString(16);
|
|
54
|
+
if (hex.length % 2 !== 0)
|
|
55
|
+
hex = '0' + hex;
|
|
56
|
+
return hex || '00';
|
|
57
|
+
}
|
|
58
|
+
function hexToFlat(hex) {
|
|
59
|
+
const clean = (hex.startsWith('0x') ? hex.slice(2) : hex).replace(/^0+/, '') || '0';
|
|
60
|
+
return Number(BigInt('0x' + clean));
|
|
61
|
+
}
|
|
62
|
+
function readStateCursor(state, statePort) {
|
|
63
|
+
for (const raw of state) {
|
|
64
|
+
const entry = raw;
|
|
65
|
+
if (typeof entry !== 'object' || entry === null)
|
|
66
|
+
continue;
|
|
67
|
+
if (entry.port !== statePort)
|
|
68
|
+
continue;
|
|
69
|
+
if (typeof entry.data === 'string')
|
|
70
|
+
return hexToFlat(entry.data);
|
|
71
|
+
if (entry.value !== undefined)
|
|
72
|
+
return Number(entry.value);
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function stateVarJson(port, hexValue) {
|
|
77
|
+
return { port, svtype: 'hex', data: hexValue };
|
|
78
|
+
}
|
|
79
|
+
function buildWitnessBytes(sigs) {
|
|
80
|
+
const parts = [(0, core_1.writeMiniNumber)(BigInt(sigs.length), 0)];
|
|
81
|
+
for (const sig of sigs)
|
|
82
|
+
parts.push(sig);
|
|
83
|
+
parts.push((0, core_1.writeMiniNumber)(0n, 0));
|
|
84
|
+
parts.push((0, core_1.writeMiniNumber)(0n, 0));
|
|
85
|
+
return multiConcat(parts);
|
|
86
|
+
}
|
|
87
|
+
class OnchainWatermarkProvider {
|
|
88
|
+
constructor(config) {
|
|
89
|
+
this._initialized = false;
|
|
90
|
+
this._lastPublishBlock = -1;
|
|
91
|
+
this.chain = config.chain;
|
|
92
|
+
this.watermarkCoinId = config.watermarkCoinId;
|
|
93
|
+
this.watermarkAddress = config.watermarkAddress;
|
|
94
|
+
this.tokenId = config.tokenId ?? '0x00';
|
|
95
|
+
this.amount = config.amount ?? '1';
|
|
96
|
+
this.local = config.local;
|
|
97
|
+
this.signer = config.signer;
|
|
98
|
+
this.statePort = config.statePort ?? 0;
|
|
99
|
+
this.minBlocksBetweenPublishes = config.minBlocksBetweenPublishes ?? 1;
|
|
100
|
+
}
|
|
101
|
+
async initialize() {
|
|
102
|
+
if (this._initialized)
|
|
103
|
+
return;
|
|
104
|
+
await this.local.initialize();
|
|
105
|
+
this._initialized = true;
|
|
106
|
+
}
|
|
107
|
+
async ensureInit() {
|
|
108
|
+
if (!this._initialized)
|
|
109
|
+
await this.initialize();
|
|
110
|
+
}
|
|
111
|
+
// -------------------------------------------------------------------------
|
|
112
|
+
// Reserve / commit / burn — local authoritative
|
|
113
|
+
// -------------------------------------------------------------------------
|
|
114
|
+
async reserveKeyUse(params) {
|
|
115
|
+
await this.ensureInit();
|
|
116
|
+
const reservation = await this.local.reserveKeyUse(params);
|
|
117
|
+
const certificate = {
|
|
118
|
+
reservationId: reservation.reservationId,
|
|
119
|
+
treeId: params.treeId,
|
|
120
|
+
branchId: params.branchId,
|
|
121
|
+
deviceId: params.deviceId,
|
|
122
|
+
indices: reservation.indices,
|
|
123
|
+
purpose: params.purpose,
|
|
124
|
+
payloadHash: params.payloadHash,
|
|
125
|
+
issuedBy: 'onchain-watermark',
|
|
126
|
+
issuedAt: Date.now(),
|
|
127
|
+
expiresAt: reservation.expiresAt,
|
|
128
|
+
signature: '',
|
|
129
|
+
};
|
|
130
|
+
return { ...reservation, certificate };
|
|
131
|
+
}
|
|
132
|
+
async commitKeyUse(reservationId, txId) {
|
|
133
|
+
await this.ensureInit();
|
|
134
|
+
await this.local.commitKeyUse(reservationId, txId);
|
|
135
|
+
}
|
|
136
|
+
async burnReservation(reservationId, reason) {
|
|
137
|
+
await this.ensureInit();
|
|
138
|
+
await this.local.burnReservation(reservationId, reason);
|
|
139
|
+
}
|
|
140
|
+
// -------------------------------------------------------------------------
|
|
141
|
+
// Watermark — local read, on-chain publish
|
|
142
|
+
// -------------------------------------------------------------------------
|
|
143
|
+
async getLocalWatermark(treeId) {
|
|
144
|
+
await this.ensureInit();
|
|
145
|
+
return this.local.getLocalWatermark(treeId);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Publish the local watermark cursor on-chain by spending the watermark
|
|
149
|
+
* coin back to itself with STATE(statePort) = flat cursor.
|
|
150
|
+
*
|
|
151
|
+
* Rate-limited by minBlocksBetweenPublishes using the chain tip.
|
|
152
|
+
*/
|
|
153
|
+
async publishWatermark(treeId) {
|
|
154
|
+
await this.ensureInit();
|
|
155
|
+
const wm = await this.local.getLocalWatermark(treeId);
|
|
156
|
+
const newFlat = (0, watermark_js_1.flatIndex)({
|
|
157
|
+
addressIndex: wm.addressCursor,
|
|
158
|
+
l1: wm.l1Cursor,
|
|
159
|
+
l2: wm.l2Cursor,
|
|
160
|
+
});
|
|
161
|
+
// Rate limit: skip when the last publish was too recent.
|
|
162
|
+
try {
|
|
163
|
+
const tip = await this.chain.getTip?.();
|
|
164
|
+
if (tip && this._lastPublishBlock >= 0 && tip.block - this._lastPublishBlock < this.minBlocksBetweenPublishes) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (tip)
|
|
168
|
+
this._lastPublishBlock = tip.block;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Tip unavailable — proceed without rate limiting.
|
|
172
|
+
}
|
|
173
|
+
// Read the current on-chain cursor so the input state matches the coin.
|
|
174
|
+
const coin = await this.chain.getCoin(this.watermarkCoinId);
|
|
175
|
+
if (!coin) {
|
|
176
|
+
throw new errors_js_1.OnchainWatermarkError(`Watermark coin not found: ${this.watermarkCoinId}`);
|
|
177
|
+
}
|
|
178
|
+
const currentState = coin.state ?? [];
|
|
179
|
+
const currentFlat = readStateCursor(currentState, this.statePort);
|
|
180
|
+
if (currentFlat !== null && currentFlat >= newFlat) {
|
|
181
|
+
// On-chain cursor already at or ahead of local — nothing to publish.
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const inputCoin = {
|
|
185
|
+
coinid: this.watermarkCoinId,
|
|
186
|
+
address: addressToHex(this.watermarkAddress),
|
|
187
|
+
amount: this.amount,
|
|
188
|
+
tokenid: this.tokenId,
|
|
189
|
+
storestate: true,
|
|
190
|
+
state: currentState,
|
|
191
|
+
};
|
|
192
|
+
const outputCoin = {
|
|
193
|
+
address: addressToHex(this.watermarkAddress),
|
|
194
|
+
amount: this.amount,
|
|
195
|
+
tokenid: this.tokenId,
|
|
196
|
+
storestate: true,
|
|
197
|
+
state: [stateVarJson(this.statePort, flatToHex(newFlat))],
|
|
198
|
+
};
|
|
199
|
+
const tx = {
|
|
200
|
+
linkhash: '0x00',
|
|
201
|
+
inputs: [inputCoin],
|
|
202
|
+
outputs: [outputCoin],
|
|
203
|
+
state: [],
|
|
204
|
+
};
|
|
205
|
+
const txBytes = (0, core_1.serializeTransaction)(JSON.stringify(tx));
|
|
206
|
+
(0, core_1.precomputeTransactionCoinID)(txBytes, 0);
|
|
207
|
+
const digest = (0, core_1.computeTransactionDigest)(txBytes);
|
|
208
|
+
const sig = await this.signer.sign(digest);
|
|
209
|
+
const witnessBytes = buildWitnessBytes([sig]);
|
|
210
|
+
const prng = (0, core_1.sha3_256)(new TextEncoder().encode(`watermark:${treeId}:${this.watermarkCoinId}:${newFlat}`));
|
|
211
|
+
const txHex = Buffer.from((0, txpow_1.serializeTxPoW)(txBytes, witnessBytes, { prng })).toString('hex');
|
|
212
|
+
const result = await this.chain.broadcastTxPoW(txHex);
|
|
213
|
+
if (!result.success) {
|
|
214
|
+
throw new errors_js_1.OnchainWatermarkError(`Watermark publish rejected: ${result.message ?? 'unknown error'}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
// Sync — merge on-chain cursor into local watermark
|
|
219
|
+
// -------------------------------------------------------------------------
|
|
220
|
+
async syncLeaseJournal() {
|
|
221
|
+
await this.ensureInit();
|
|
222
|
+
const conflicts = [];
|
|
223
|
+
let advancedTo;
|
|
224
|
+
for (const treeId of this.local.listTrees()) {
|
|
225
|
+
const localWm = await this.local.getLocalWatermark(treeId);
|
|
226
|
+
const localFlat = (0, watermark_js_1.flatIndex)({
|
|
227
|
+
addressIndex: localWm.addressCursor,
|
|
228
|
+
l1: localWm.l1Cursor,
|
|
229
|
+
l2: localWm.l2Cursor,
|
|
230
|
+
});
|
|
231
|
+
let onchainFlat = null;
|
|
232
|
+
try {
|
|
233
|
+
const coin = await this.chain.getCoin(this.watermarkCoinId);
|
|
234
|
+
if (coin)
|
|
235
|
+
onchainFlat = readStateCursor(coin.state ?? [], this.statePort);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// Chain unreachable — skip.
|
|
239
|
+
}
|
|
240
|
+
if (onchainFlat === null)
|
|
241
|
+
continue;
|
|
242
|
+
if (onchainFlat > localFlat) {
|
|
243
|
+
const addressIndex = Math.floor(onchainFlat / (MAX_L * MAX_L));
|
|
244
|
+
const rem = onchainFlat % (MAX_L * MAX_L);
|
|
245
|
+
const l1 = Math.floor(rem / MAX_L);
|
|
246
|
+
const l2 = rem % MAX_L;
|
|
247
|
+
const advanced = await this.local.advanceToRemoteWatermark(treeId, {
|
|
248
|
+
addressCursor: addressIndex,
|
|
249
|
+
l1Cursor: l1,
|
|
250
|
+
l2Cursor: l2,
|
|
251
|
+
});
|
|
252
|
+
if (advanced) {
|
|
253
|
+
advancedTo = { addressIndex, l1, l2 };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else if (onchainFlat < localFlat) {
|
|
257
|
+
conflicts.push({
|
|
258
|
+
treeId,
|
|
259
|
+
localIndex: localFlat,
|
|
260
|
+
remoteIndex: onchainFlat,
|
|
261
|
+
timestamp: Date.now(),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { synced: conflicts.length === 0, conflicts, advancedTo };
|
|
266
|
+
}
|
|
267
|
+
async verifyLeaseCertificate(cert) {
|
|
268
|
+
if (!cert)
|
|
269
|
+
return false;
|
|
270
|
+
if (cert.expiresAt <= Date.now())
|
|
271
|
+
return false;
|
|
272
|
+
return cert.issuedBy === 'onchain-watermark';
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
exports.OnchainWatermarkProvider = OnchainWatermarkProvider;
|
package/dist/quorum.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2PQuorumLeaseProvider — Layer 4: multi-device quorum lease coordination.
|
|
3
|
+
*
|
|
4
|
+
* Safety model
|
|
5
|
+
* ────────────
|
|
6
|
+
* The local provider remains the authoritative watermark (it can only move
|
|
7
|
+
* forward). The quorum adds a second, independent check: before a reservation
|
|
8
|
+
* is handed to the caller, at least `minAttestations` peers must confirm that
|
|
9
|
+
* the same (treeId, indices) slot is free in *their* view of the world. This
|
|
10
|
+
* catches the failure mode where two devices share a seed and both believe
|
|
11
|
+
* they own the next slot — the peers see the double-reservation and refuse
|
|
12
|
+
* to attest.
|
|
13
|
+
*
|
|
14
|
+
* Wire protocol
|
|
15
|
+
* ─────────────
|
|
16
|
+
* Peers speak the same LEASE_RESERVE / LEASE_COMMIT / LEASE_BURN /
|
|
17
|
+
* LEASE_WATERMARK message shapes as the lookup protocol, so any peer that
|
|
18
|
+
* implements that protocol can participate: a personal lookup node, another
|
|
19
|
+
* device running this provider, or an in-memory test peer.
|
|
20
|
+
*
|
|
21
|
+
* Failure semantics
|
|
22
|
+
* ─────────────────
|
|
23
|
+
* - If quorum cannot be reached, the local reservation is burned and
|
|
24
|
+
* QuorumUnavailableError is thrown. The slot is NOT reusable (burned),
|
|
25
|
+
* which is the safe direction: a slot that might have been attested by
|
|
26
|
+
* some peers must never be handed out again.
|
|
27
|
+
* - If a peer reports the slot as taken, the local reservation is burned
|
|
28
|
+
* and QuorumConflictError is thrown.
|
|
29
|
+
*/
|
|
30
|
+
import type { WotsLeaseProvider, ReserveParams, LeaseReservation, LeaseCertificate, LocalWatermark, SyncResult, QuorumAttestation, P2PQuorumLeaseProviderConfig } from './types.js';
|
|
31
|
+
export declare class P2PQuorumLeaseProvider implements WotsLeaseProvider {
|
|
32
|
+
private readonly peers;
|
|
33
|
+
private readonly minAttestations;
|
|
34
|
+
private readonly local;
|
|
35
|
+
private readonly requestTimeoutMs;
|
|
36
|
+
private readonly requireQuorumOnCommit;
|
|
37
|
+
private _initialized;
|
|
38
|
+
constructor(config: P2PQuorumLeaseProviderConfig);
|
|
39
|
+
initialize(): Promise<void>;
|
|
40
|
+
private ensureInit;
|
|
41
|
+
reserveKeyUse(params: ReserveParams): Promise<LeaseReservation>;
|
|
42
|
+
/**
|
|
43
|
+
* Collect quorum attestations for a reservation that was already made
|
|
44
|
+
* locally (used by HybridLeaseProvider so the local slot and the attested
|
|
45
|
+
* slot are the same). Throws QuorumUnavailableError / QuorumConflictError
|
|
46
|
+
* on failure — the caller owns the local reservation and must burn it.
|
|
47
|
+
*/
|
|
48
|
+
attestKeyUse(params: ReserveParams, indices: {
|
|
49
|
+
addressIndex: number;
|
|
50
|
+
l1: number;
|
|
51
|
+
l2: number;
|
|
52
|
+
}): Promise<QuorumAttestation[]>;
|
|
53
|
+
commitKeyUse(reservationId: string, txId: string): Promise<void>;
|
|
54
|
+
burnReservation(reservationId: string, reason: string): Promise<void>;
|
|
55
|
+
private broadcastAck;
|
|
56
|
+
getLocalWatermark(treeId: string): Promise<LocalWatermark>;
|
|
57
|
+
publishWatermark(treeId: string): Promise<void>;
|
|
58
|
+
syncLeaseJournal(): Promise<SyncResult>;
|
|
59
|
+
verifyLeaseCertificate(cert?: LeaseCertificate): Promise<boolean>;
|
|
60
|
+
}
|
package/dist/quorum.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* P2PQuorumLeaseProvider — Layer 4: multi-device quorum lease coordination.
|
|
4
|
+
*
|
|
5
|
+
* Safety model
|
|
6
|
+
* ────────────
|
|
7
|
+
* The local provider remains the authoritative watermark (it can only move
|
|
8
|
+
* forward). The quorum adds a second, independent check: before a reservation
|
|
9
|
+
* is handed to the caller, at least `minAttestations` peers must confirm that
|
|
10
|
+
* the same (treeId, indices) slot is free in *their* view of the world. This
|
|
11
|
+
* catches the failure mode where two devices share a seed and both believe
|
|
12
|
+
* they own the next slot — the peers see the double-reservation and refuse
|
|
13
|
+
* to attest.
|
|
14
|
+
*
|
|
15
|
+
* Wire protocol
|
|
16
|
+
* ─────────────
|
|
17
|
+
* Peers speak the same LEASE_RESERVE / LEASE_COMMIT / LEASE_BURN /
|
|
18
|
+
* LEASE_WATERMARK message shapes as the lookup protocol, so any peer that
|
|
19
|
+
* implements that protocol can participate: a personal lookup node, another
|
|
20
|
+
* device running this provider, or an in-memory test peer.
|
|
21
|
+
*
|
|
22
|
+
* Failure semantics
|
|
23
|
+
* ─────────────────
|
|
24
|
+
* - If quorum cannot be reached, the local reservation is burned and
|
|
25
|
+
* QuorumUnavailableError is thrown. The slot is NOT reusable (burned),
|
|
26
|
+
* which is the safe direction: a slot that might have been attested by
|
|
27
|
+
* some peers must never be handed out again.
|
|
28
|
+
* - If a peer reports the slot as taken, the local reservation is burned
|
|
29
|
+
* and QuorumConflictError is thrown.
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.P2PQuorumLeaseProvider = void 0;
|
|
33
|
+
const errors_js_1 = require("./errors.js");
|
|
34
|
+
function sameIndices(a, b) {
|
|
35
|
+
return a.addressIndex === b.addressIndex && a.l1 === b.l1 && a.l2 === b.l2;
|
|
36
|
+
}
|
|
37
|
+
class P2PQuorumLeaseProvider {
|
|
38
|
+
constructor(config) {
|
|
39
|
+
this._initialized = false;
|
|
40
|
+
this.peers = config.peers;
|
|
41
|
+
this.minAttestations = config.minAttestations ?? 1;
|
|
42
|
+
this.local = config.local;
|
|
43
|
+
this.requestTimeoutMs = config.requestTimeoutMs ?? 5000;
|
|
44
|
+
this.requireQuorumOnCommit = config.requireQuorumOnCommit ?? false;
|
|
45
|
+
}
|
|
46
|
+
async initialize() {
|
|
47
|
+
if (this._initialized)
|
|
48
|
+
return;
|
|
49
|
+
await this.local.initialize();
|
|
50
|
+
this._initialized = true;
|
|
51
|
+
}
|
|
52
|
+
async ensureInit() {
|
|
53
|
+
if (!this._initialized)
|
|
54
|
+
await this.initialize();
|
|
55
|
+
}
|
|
56
|
+
// -------------------------------------------------------------------------
|
|
57
|
+
// Reserve — local first, then quorum attestation
|
|
58
|
+
// -------------------------------------------------------------------------
|
|
59
|
+
async reserveKeyUse(params) {
|
|
60
|
+
await this.ensureInit();
|
|
61
|
+
// 1. Local reservation (authoritative, monotonic watermark).
|
|
62
|
+
const reservation = await this.local.reserveKeyUse(params);
|
|
63
|
+
// 2. Collect attestations from peers for the SAME indices.
|
|
64
|
+
// On failure the local reservation is burned — the slot is never reused.
|
|
65
|
+
let attestations;
|
|
66
|
+
try {
|
|
67
|
+
attestations = await this.attestKeyUse(params, reservation.indices);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const reason = err instanceof errors_js_1.QuorumConflictError
|
|
71
|
+
? `quorum conflict: ${err.message}`
|
|
72
|
+
: 'quorum not reached';
|
|
73
|
+
await this.local.burnReservation(reservation.reservationId, reason);
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
const certificate = {
|
|
77
|
+
reservationId: reservation.reservationId,
|
|
78
|
+
treeId: params.treeId,
|
|
79
|
+
branchId: params.branchId,
|
|
80
|
+
deviceId: params.deviceId,
|
|
81
|
+
indices: reservation.indices,
|
|
82
|
+
purpose: params.purpose,
|
|
83
|
+
payloadHash: params.payloadHash,
|
|
84
|
+
issuedBy: 'p2p-quorum',
|
|
85
|
+
issuedAt: Date.now(),
|
|
86
|
+
expiresAt: reservation.expiresAt,
|
|
87
|
+
signature: '',
|
|
88
|
+
attestations,
|
|
89
|
+
};
|
|
90
|
+
return { ...reservation, certificate };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Collect quorum attestations for a reservation that was already made
|
|
94
|
+
* locally (used by HybridLeaseProvider so the local slot and the attested
|
|
95
|
+
* slot are the same). Throws QuorumUnavailableError / QuorumConflictError
|
|
96
|
+
* on failure — the caller owns the local reservation and must burn it.
|
|
97
|
+
*/
|
|
98
|
+
async attestKeyUse(params, indices) {
|
|
99
|
+
await this.ensureInit();
|
|
100
|
+
const attestations = [];
|
|
101
|
+
const conflicts = [];
|
|
102
|
+
await Promise.all(this.peers.map(async (peer) => {
|
|
103
|
+
try {
|
|
104
|
+
const resp = await peer.request({
|
|
105
|
+
type: 'LEASE_RESERVE',
|
|
106
|
+
payload: {
|
|
107
|
+
treeId: params.treeId,
|
|
108
|
+
branchId: params.branchId,
|
|
109
|
+
deviceId: params.deviceId,
|
|
110
|
+
ttlMs: params.ttlMs,
|
|
111
|
+
payloadHash: params.payloadHash,
|
|
112
|
+
purpose: params.purpose,
|
|
113
|
+
indices,
|
|
114
|
+
},
|
|
115
|
+
}, this.requestTimeoutMs);
|
|
116
|
+
if (resp.type === 'ERROR') {
|
|
117
|
+
conflicts.push(String(resp.payload.message ?? 'peer error'));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const body = resp.payload;
|
|
121
|
+
const peerIndices = body.reservation?.indices;
|
|
122
|
+
if (!peerIndices || !sameIndices(peerIndices, indices)) {
|
|
123
|
+
conflicts.push('peer returned mismatched indices');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
attestations.push({
|
|
127
|
+
peerId: peer.peerId,
|
|
128
|
+
indices,
|
|
129
|
+
expiresAt: body.reservation?.expiresAt ?? Date.now() + (params.ttlMs ?? 120000),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Peer unreachable — counted as no attestation.
|
|
134
|
+
}
|
|
135
|
+
}));
|
|
136
|
+
if (conflicts.length > 0) {
|
|
137
|
+
throw new errors_js_1.QuorumConflictError(params.treeId, indices);
|
|
138
|
+
}
|
|
139
|
+
if (attestations.length < this.minAttestations) {
|
|
140
|
+
throw new errors_js_1.QuorumUnavailableError(this.minAttestations, attestations.length);
|
|
141
|
+
}
|
|
142
|
+
return attestations;
|
|
143
|
+
}
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
// Commit / burn — local authoritative, broadcast to peers
|
|
146
|
+
// -------------------------------------------------------------------------
|
|
147
|
+
async commitKeyUse(reservationId, txId) {
|
|
148
|
+
await this.ensureInit();
|
|
149
|
+
await this.local.commitKeyUse(reservationId, txId);
|
|
150
|
+
const acks = await this.broadcastAck('LEASE_COMMIT', { reservationId, txId });
|
|
151
|
+
if (this.requireQuorumOnCommit && acks < this.minAttestations) {
|
|
152
|
+
throw new errors_js_1.QuorumUnavailableError(this.minAttestations, acks);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async burnReservation(reservationId, reason) {
|
|
156
|
+
await this.ensureInit();
|
|
157
|
+
await this.local.burnReservation(reservationId, reason);
|
|
158
|
+
await this.broadcastAck('LEASE_BURN', { reservationId, reason });
|
|
159
|
+
}
|
|
160
|
+
async broadcastAck(type, payload) {
|
|
161
|
+
let acks = 0;
|
|
162
|
+
await Promise.all(this.peers.map(async (peer) => {
|
|
163
|
+
try {
|
|
164
|
+
const resp = await peer.request({ type, payload }, this.requestTimeoutMs);
|
|
165
|
+
if (resp.type !== 'ERROR')
|
|
166
|
+
acks++;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Peer unreachable — no ack.
|
|
170
|
+
}
|
|
171
|
+
}));
|
|
172
|
+
return acks;
|
|
173
|
+
}
|
|
174
|
+
// -------------------------------------------------------------------------
|
|
175
|
+
// Watermark / journal
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
async getLocalWatermark(treeId) {
|
|
178
|
+
await this.ensureInit();
|
|
179
|
+
return this.local.getLocalWatermark(treeId);
|
|
180
|
+
}
|
|
181
|
+
async publishWatermark(treeId) {
|
|
182
|
+
await this.ensureInit();
|
|
183
|
+
const wm = await this.local.getLocalWatermark(treeId);
|
|
184
|
+
await Promise.all(this.peers.map(async (peer) => {
|
|
185
|
+
try {
|
|
186
|
+
await peer.request({
|
|
187
|
+
type: 'LEASE_WATERMARK',
|
|
188
|
+
payload: {
|
|
189
|
+
treeId,
|
|
190
|
+
addressCursor: wm.addressCursor,
|
|
191
|
+
l1Cursor: wm.l1Cursor,
|
|
192
|
+
l2Cursor: wm.l2Cursor,
|
|
193
|
+
unavailableCount: wm.unavailableCount,
|
|
194
|
+
lastSyncTimestamp: wm.lastSyncTimestamp ?? Date.now(),
|
|
195
|
+
},
|
|
196
|
+
}, this.requestTimeoutMs);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// Peer unreachable — watermark publish is best-effort.
|
|
200
|
+
}
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
async syncLeaseJournal() {
|
|
204
|
+
await this.ensureInit();
|
|
205
|
+
const conflicts = [];
|
|
206
|
+
let advancedTo;
|
|
207
|
+
for (const treeId of this.local.listTrees()) {
|
|
208
|
+
const localWm = await this.local.getLocalWatermark(treeId);
|
|
209
|
+
for (const peer of this.peers) {
|
|
210
|
+
try {
|
|
211
|
+
const resp = await peer.request({
|
|
212
|
+
type: 'LEASE_WATERMARK',
|
|
213
|
+
payload: { treeId, query: true },
|
|
214
|
+
}, this.requestTimeoutMs);
|
|
215
|
+
if (resp.type === 'ERROR')
|
|
216
|
+
continue;
|
|
217
|
+
const p = resp.payload;
|
|
218
|
+
if (typeof p.addressCursor !== 'number')
|
|
219
|
+
continue;
|
|
220
|
+
const remoteFlat = p.addressCursor * 64 * 64 + p.l1Cursor * 64 + p.l2Cursor;
|
|
221
|
+
const localFlat = localWm.addressCursor * 64 * 64 + localWm.l1Cursor * 64 + localWm.l2Cursor;
|
|
222
|
+
if (remoteFlat > localFlat) {
|
|
223
|
+
const advanced = await this.local.advanceToRemoteWatermark(treeId, {
|
|
224
|
+
addressCursor: p.addressCursor,
|
|
225
|
+
l1Cursor: p.l1Cursor,
|
|
226
|
+
l2Cursor: p.l2Cursor,
|
|
227
|
+
});
|
|
228
|
+
if (advanced) {
|
|
229
|
+
advancedTo = {
|
|
230
|
+
addressIndex: p.addressCursor,
|
|
231
|
+
l1: p.l1Cursor,
|
|
232
|
+
l2: p.l2Cursor,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (remoteFlat < localFlat) {
|
|
237
|
+
conflicts.push({
|
|
238
|
+
treeId,
|
|
239
|
+
localIndex: localFlat,
|
|
240
|
+
remoteIndex: remoteFlat,
|
|
241
|
+
timestamp: Date.now(),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Peer unreachable — skip.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { synced: conflicts.length === 0, conflicts, advancedTo };
|
|
251
|
+
}
|
|
252
|
+
async verifyLeaseCertificate(cert) {
|
|
253
|
+
if (!cert)
|
|
254
|
+
return false;
|
|
255
|
+
if (cert.expiresAt <= Date.now())
|
|
256
|
+
return false;
|
|
257
|
+
const attestations = cert.attestations ?? [];
|
|
258
|
+
if (attestations.length < this.minAttestations)
|
|
259
|
+
return false;
|
|
260
|
+
return attestations.every((a) => sameIndices(a.indices, cert.indices) &&
|
|
261
|
+
a.expiresAt > Date.now());
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
exports.P2PQuorumLeaseProvider = P2PQuorumLeaseProvider;
|
package/dist/stubs.d.ts
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* that has lease coordination enabled ({ lease: { enabled: true } }).
|
|
6
6
|
* The node exposes REST endpoints under /v1/lease/*.
|
|
7
7
|
*
|
|
8
|
-
* Layers 4–5 (P2PQuorumLeaseProvider, OnchainWatermarkProvider) —
|
|
9
|
-
*
|
|
8
|
+
* Layers 4–5 (P2PQuorumLeaseProvider, OnchainWatermarkProvider) — implemented in
|
|
9
|
+
* quorum.ts and onchain.ts; re-exported here for backward compatibility.
|
|
10
10
|
*/
|
|
11
11
|
import type { WotsLeaseProvider, ReserveParams, LeaseReservation, LeaseCertificate, LocalWatermark, SyncResult, PersonalLeaseNodeConfig } from './types.js';
|
|
12
|
+
export { P2PQuorumLeaseProvider } from './quorum.js';
|
|
13
|
+
export { OnchainWatermarkProvider } from './onchain.js';
|
|
12
14
|
/**
|
|
13
15
|
* Layer 3 — personal lookup-node lease coordinator.
|
|
14
16
|
*
|
|
@@ -44,23 +46,3 @@ export declare class PersonalLeaseNodeProvider implements WotsLeaseProvider {
|
|
|
44
46
|
syncLeaseJournal(): Promise<SyncResult>;
|
|
45
47
|
verifyLeaseCertificate(cert?: LeaseCertificate): Promise<boolean>;
|
|
46
48
|
}
|
|
47
|
-
/** Layer 4 — p2p replicated lease witnesses. */
|
|
48
|
-
export declare class P2PQuorumLeaseProvider implements WotsLeaseProvider {
|
|
49
|
-
reserveKeyUse(_params: ReserveParams): Promise<LeaseReservation>;
|
|
50
|
-
commitKeyUse(_reservationId: string, _txId: string): Promise<void>;
|
|
51
|
-
burnReservation(_reservationId: string, _reason: string): Promise<void>;
|
|
52
|
-
getLocalWatermark(_treeId: string): Promise<LocalWatermark>;
|
|
53
|
-
publishWatermark(_treeId: string): Promise<void>;
|
|
54
|
-
syncLeaseJournal(): Promise<SyncResult>;
|
|
55
|
-
verifyLeaseCertificate(_cert?: LeaseCertificate): Promise<boolean>;
|
|
56
|
-
}
|
|
57
|
-
/** Layer 5 — on-chain watermark coin. */
|
|
58
|
-
export declare class OnchainWatermarkProvider implements WotsLeaseProvider {
|
|
59
|
-
reserveKeyUse(_params: ReserveParams): Promise<LeaseReservation>;
|
|
60
|
-
commitKeyUse(_reservationId: string, _txId: string): Promise<void>;
|
|
61
|
-
burnReservation(_reservationId: string, _reason: string): Promise<void>;
|
|
62
|
-
getLocalWatermark(_treeId: string): Promise<LocalWatermark>;
|
|
63
|
-
publishWatermark(_treeId: string): Promise<void>;
|
|
64
|
-
syncLeaseJournal(): Promise<SyncResult>;
|
|
65
|
-
verifyLeaseCertificate(_cert?: LeaseCertificate): Promise<boolean>;
|
|
66
|
-
}
|