@totemsdk/wots-lease 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +202 -21
  2. package/NOTICE +60 -0
  3. package/dist/axia.js +21 -25
  4. package/dist/certificate.js +7 -12
  5. package/dist/cjs/axia.d.ts +33 -0
  6. package/dist/cjs/axia.js +145 -0
  7. package/dist/cjs/certificate.d.ts +37 -0
  8. package/dist/cjs/certificate.js +74 -0
  9. package/dist/cjs/device.d.ts +18 -0
  10. package/dist/cjs/device.js +34 -0
  11. package/dist/cjs/errors.d.ts +71 -0
  12. package/dist/cjs/errors.js +135 -0
  13. package/dist/cjs/hybrid.d.ts +37 -0
  14. package/dist/cjs/hybrid.js +145 -0
  15. package/dist/cjs/index.d.ts +17 -0
  16. package/dist/cjs/index.js +45 -0
  17. package/dist/cjs/journal.d.ts +30 -0
  18. package/dist/cjs/journal.js +369 -0
  19. package/dist/cjs/local.d.ts +83 -0
  20. package/dist/cjs/local.js +440 -0
  21. package/dist/cjs/onchain.d.ts +54 -0
  22. package/dist/cjs/onchain.js +314 -0
  23. package/dist/cjs/package.json +1 -0
  24. package/dist/cjs/quorum.d.ts +75 -0
  25. package/dist/cjs/quorum.js +307 -0
  26. package/dist/cjs/replicated-backup.d.ts +30 -0
  27. package/dist/cjs/replicated-backup.js +52 -0
  28. package/dist/cjs/seed.d.ts +41 -0
  29. package/dist/cjs/seed.js +84 -0
  30. package/dist/cjs/stubs.d.ts +50 -0
  31. package/dist/cjs/stubs.js +125 -0
  32. package/dist/cjs/types.d.ts +272 -0
  33. package/dist/cjs/types.js +5 -0
  34. package/dist/cjs/watermark.d.ts +51 -0
  35. package/dist/cjs/watermark.js +328 -0
  36. package/dist/device.js +2 -6
  37. package/dist/errors.js +14 -31
  38. package/dist/hybrid.js +4 -8
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.js +12 -43
  41. package/dist/journal.js +35 -38
  42. package/dist/local.js +29 -33
  43. package/dist/onchain.js +23 -27
  44. package/dist/quorum.js +9 -13
  45. package/dist/replicated-backup.d.ts +30 -0
  46. package/dist/replicated-backup.js +48 -0
  47. package/dist/seed.js +7 -11
  48. package/dist/stubs.js +5 -11
  49. package/dist/types.js +1 -2
  50. package/dist/watermark.js +11 -18
  51. package/package.json +22 -13
  52. package/rust/pkg/package.json +2 -2
  53. package/rust/pkg/wots_lease_wasm_bg.wasm +0 -0
  54. package/rust/pkg-node/package.json +2 -2
  55. package/rust/pkg-node/wots_lease_wasm_bg.wasm +0 -0
@@ -0,0 +1,314 @@
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 certificate_js_1 = require("./certificate.js");
31
+ const MAX_L = 64;
32
+ const WATERMARK_COIN_STORAGE_KEY = 'wots-lease:onchain:watermark-coin';
33
+ function multiConcat(parts) {
34
+ if (parts.length === 0)
35
+ return new Uint8Array(0);
36
+ let result = parts[0];
37
+ for (let i = 1; i < parts.length; i++) {
38
+ result = concatBytes(result, parts[i]);
39
+ }
40
+ return result;
41
+ }
42
+ function concatBytes(a, b) {
43
+ const out = new Uint8Array(a.length + b.length);
44
+ out.set(a, 0);
45
+ out.set(b, a.length);
46
+ return out;
47
+ }
48
+ function addressToHex(address) {
49
+ const upper = address.startsWith('0x') || address.startsWith('0X') ? address.slice(2) : address;
50
+ return upper.toUpperCase().startsWith('MX')
51
+ ? (0, core_1.bytesToHex)((0, core_1.parseMxAddress)(address))
52
+ : address.replace(/^0x/i, '');
53
+ }
54
+ function flatToHex(flat) {
55
+ let hex = BigInt(flat).toString(16);
56
+ if (hex.length % 2 !== 0)
57
+ hex = '0' + hex;
58
+ return hex || '00';
59
+ }
60
+ function hexToFlat(hex) {
61
+ const clean = (hex.startsWith('0x') ? hex.slice(2) : hex).replace(/^0+/, '') || '0';
62
+ return Number(BigInt('0x' + clean));
63
+ }
64
+ function readStateCursor(state, statePort) {
65
+ for (const raw of state) {
66
+ const entry = raw;
67
+ if (typeof entry !== 'object' || entry === null)
68
+ continue;
69
+ if (entry.port !== statePort)
70
+ continue;
71
+ if (typeof entry.data === 'string')
72
+ return hexToFlat(entry.data);
73
+ if (entry.value !== undefined)
74
+ return Number(entry.value);
75
+ }
76
+ return null;
77
+ }
78
+ function stateVarJson(port, hexValue) {
79
+ return { port, svtype: 'hex', data: hexValue };
80
+ }
81
+ function buildWitnessBytes(sigs) {
82
+ const parts = [(0, core_1.writeMiniNumber)(BigInt(sigs.length), 0)];
83
+ for (const sig of sigs)
84
+ parts.push(sig);
85
+ parts.push((0, core_1.writeMiniNumber)(0n, 0));
86
+ parts.push((0, core_1.writeMiniNumber)(0n, 0));
87
+ return multiConcat(parts);
88
+ }
89
+ class OnchainWatermarkProvider {
90
+ constructor(config) {
91
+ this._initialized = false;
92
+ this._lastPublishBlock = -1;
93
+ this.chain = config.chain;
94
+ this.initialWatermarkCoinId = config.watermarkCoinId;
95
+ this.currentCoinId = config.watermarkCoinId;
96
+ this.watermarkAddress = config.watermarkAddress;
97
+ this.tokenId = config.tokenId ?? '0x00';
98
+ this.amount = config.amount ?? '1';
99
+ this.local = config.local;
100
+ this.signer = config.signer;
101
+ this.statePort = config.statePort ?? 0;
102
+ this.minBlocksBetweenPublishes = config.minBlocksBetweenPublishes ?? 1;
103
+ this.storage = config.storage;
104
+ }
105
+ async initialize() {
106
+ if (this._initialized)
107
+ return;
108
+ // Recover the advanced watermark coin identity across restarts.
109
+ if (this.storage) {
110
+ try {
111
+ const saved = await this.storage.get(WATERMARK_COIN_STORAGE_KEY);
112
+ if (saved?.coinId)
113
+ this.currentCoinId = saved.coinId;
114
+ if (typeof saved?.lastPublishBlock === 'number') {
115
+ this._lastPublishBlock = saved.lastPublishBlock;
116
+ }
117
+ }
118
+ catch {
119
+ // Storage unavailable — fall back to the configured coin.
120
+ }
121
+ }
122
+ await this.local.initialize();
123
+ this._initialized = true;
124
+ }
125
+ async ensureInit() {
126
+ if (!this._initialized)
127
+ await this.initialize();
128
+ }
129
+ // -------------------------------------------------------------------------
130
+ // Reserve / commit / burn — local authoritative
131
+ // -------------------------------------------------------------------------
132
+ async reserveKeyUse(params) {
133
+ await this.ensureInit();
134
+ const reservation = await this.local.reserveKeyUse(params);
135
+ const certificate = {
136
+ reservationId: reservation.reservationId,
137
+ treeId: params.treeId,
138
+ branchId: params.branchId,
139
+ deviceId: params.deviceId,
140
+ indices: reservation.indices,
141
+ purpose: params.purpose,
142
+ payloadHash: params.payloadHash,
143
+ issuedBy: 'onchain-watermark',
144
+ issuedAt: Date.now(),
145
+ expiresAt: reservation.expiresAt,
146
+ signature: '',
147
+ };
148
+ certificate.signature = await (0, certificate_js_1.signCertificate)(this.signer, certificate);
149
+ return { ...reservation, certificate };
150
+ }
151
+ async commitKeyUse(reservationId, txId) {
152
+ await this.ensureInit();
153
+ await this.local.commitKeyUse(reservationId, txId);
154
+ }
155
+ async burnReservation(reservationId, reason) {
156
+ await this.ensureInit();
157
+ await this.local.burnReservation(reservationId, reason);
158
+ }
159
+ // -------------------------------------------------------------------------
160
+ // Watermark — local read, on-chain publish
161
+ // -------------------------------------------------------------------------
162
+ async getLocalWatermark(treeId) {
163
+ await this.ensureInit();
164
+ return this.local.getLocalWatermark(treeId);
165
+ }
166
+ /**
167
+ * Publish the local watermark cursor on-chain by spending the watermark
168
+ * coin back to itself with STATE(statePort) = flat cursor.
169
+ *
170
+ * Rate-limited by minBlocksBetweenPublishes using the chain tip.
171
+ */
172
+ async publishWatermark(treeId) {
173
+ await this.ensureInit();
174
+ const wm = await this.local.getLocalWatermark(treeId);
175
+ const newFlat = (0, watermark_js_1.flatIndex)({
176
+ addressIndex: wm.addressCursor,
177
+ l1: wm.l1Cursor,
178
+ l2: wm.l2Cursor,
179
+ });
180
+ // Rate limit: skip when the last publish was too recent.
181
+ try {
182
+ const tip = await this.chain.getTip?.();
183
+ if (tip && this._lastPublishBlock >= 0 && tip.block - this._lastPublishBlock < this.minBlocksBetweenPublishes) {
184
+ return;
185
+ }
186
+ if (tip)
187
+ this._lastPublishBlock = tip.block;
188
+ }
189
+ catch {
190
+ // Tip unavailable — proceed without rate limiting.
191
+ }
192
+ // Read the current on-chain cursor so the input state matches the coin
193
+ // anchored by the CURRENT watermark coin (which advances on every spend).
194
+ const coin = await this.chain.getCoin(this.currentCoinId);
195
+ if (!coin) {
196
+ throw new errors_js_1.OnchainWatermarkError(`Watermark coin not found: ${this.currentCoinId}`);
197
+ }
198
+ const currentState = coin.state ?? [];
199
+ const currentFlat = readStateCursor(currentState, this.statePort);
200
+ if (currentFlat !== null && currentFlat >= newFlat) {
201
+ // On-chain cursor already at or ahead of local — nothing to publish.
202
+ return;
203
+ }
204
+ const inputCoin = {
205
+ coinid: this.currentCoinId,
206
+ address: addressToHex(this.watermarkAddress),
207
+ amount: this.amount,
208
+ tokenid: this.tokenId,
209
+ storestate: true,
210
+ state: currentState,
211
+ };
212
+ const outputCoin = {
213
+ address: addressToHex(this.watermarkAddress),
214
+ amount: this.amount,
215
+ tokenid: this.tokenId,
216
+ storestate: true,
217
+ state: [stateVarJson(this.statePort, flatToHex(newFlat))],
218
+ };
219
+ const rolledCoinId = (0, core_1.rollCoinId)((0, core_1.hexToBytes)(this.currentCoinId), 0);
220
+ const tx = {
221
+ linkhash: '0x00',
222
+ inputs: [inputCoin],
223
+ outputs: [{ ...outputCoin, coinid: '0x' + (0, core_1.bytesToHex)(rolledCoinId) }],
224
+ state: [],
225
+ };
226
+ const txBytes = (0, core_1.serializeTransaction)(JSON.stringify(tx));
227
+ const digest = (0, core_1.computeTransactionDigest)(txBytes);
228
+ const sig = await this.signer.sign(digest);
229
+ const witnessBytes = buildWitnessBytes([sig]);
230
+ const prng = (0, core_1.sha3_256)(new TextEncoder().encode(`watermark:${treeId}:${this.currentCoinId}:${newFlat}`));
231
+ const txHex = Buffer.from((0, txpow_1.serializeTxPoW)(txBytes, witnessBytes, { prng })).toString('hex');
232
+ const result = await this.chain.broadcastTxPoW(txHex);
233
+ if (!result.success) {
234
+ throw new errors_js_1.OnchainWatermarkError(`Watermark publish rejected: ${result.message ?? 'unknown error'}`);
235
+ }
236
+ // Rollover: the spent watermark coin is destroyed and replaced by a fresh
237
+ // coin with a new coin ID (output 0 of this spend). Track and persist the
238
+ // ADVANCED identity so the next publish spends the live coin, not the
239
+ // consumed one.
240
+ const newCoinId = '0x' + (0, core_1.bytesToHex)(rolledCoinId);
241
+ this.currentCoinId = newCoinId;
242
+ if (this.storage) {
243
+ try {
244
+ await this.storage.set(WATERMARK_COIN_STORAGE_KEY, {
245
+ coinId: newCoinId,
246
+ lastPublishBlock: this._lastPublishBlock,
247
+ });
248
+ }
249
+ catch {
250
+ // Storage unavailable — in-memory rollover still applies this session.
251
+ }
252
+ }
253
+ }
254
+ // -------------------------------------------------------------------------
255
+ // Sync — merge on-chain cursor into local watermark
256
+ // -------------------------------------------------------------------------
257
+ async syncLeaseJournal() {
258
+ await this.ensureInit();
259
+ const conflicts = [];
260
+ let advancedTo;
261
+ for (const treeId of this.local.listTrees()) {
262
+ const localWm = await this.local.getLocalWatermark(treeId);
263
+ const localFlat = (0, watermark_js_1.flatIndex)({
264
+ addressIndex: localWm.addressCursor,
265
+ l1: localWm.l1Cursor,
266
+ l2: localWm.l2Cursor,
267
+ });
268
+ let onchainFlat = null;
269
+ try {
270
+ const coin = await this.chain.getCoin(this.currentCoinId);
271
+ if (coin)
272
+ onchainFlat = readStateCursor(coin.state ?? [], this.statePort);
273
+ }
274
+ catch {
275
+ // Chain unreachable — skip.
276
+ }
277
+ if (onchainFlat === null)
278
+ continue;
279
+ if (onchainFlat > localFlat) {
280
+ const addressIndex = Math.floor(onchainFlat / (MAX_L * MAX_L));
281
+ const rem = onchainFlat % (MAX_L * MAX_L);
282
+ const l1 = Math.floor(rem / MAX_L);
283
+ const l2 = rem % MAX_L;
284
+ const advanced = await this.local.advanceToRemoteWatermark(treeId, {
285
+ addressCursor: addressIndex,
286
+ l1Cursor: l1,
287
+ l2Cursor: l2,
288
+ });
289
+ if (advanced) {
290
+ advancedTo = { addressIndex, l1, l2 };
291
+ }
292
+ }
293
+ else if (onchainFlat < localFlat) {
294
+ conflicts.push({
295
+ treeId,
296
+ localIndex: localFlat,
297
+ remoteIndex: onchainFlat,
298
+ timestamp: Date.now(),
299
+ });
300
+ }
301
+ }
302
+ return { synced: conflicts.length === 0, conflicts, advancedTo };
303
+ }
304
+ async verifyLeaseCertificate(cert) {
305
+ if (!cert)
306
+ return false;
307
+ if (cert.expiresAt <= Date.now())
308
+ return false;
309
+ if (!(await (0, certificate_js_1.certificateSignatureVerified)(cert, this.signer)))
310
+ return false;
311
+ return cert.issuedBy === 'onchain-watermark';
312
+ }
313
+ }
314
+ exports.OnchainWatermarkProvider = OnchainWatermarkProvider;
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,75 @@
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 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;
44
+ private _initialized;
45
+ constructor(config: P2PQuorumLeaseProviderConfig);
46
+ initialize(): Promise<void>;
47
+ private ensureInit;
48
+ reserveKeyUse(params: ReserveParams): Promise<LeaseReservation>;
49
+ /**
50
+ * Collect quorum attestations for a reservation that was already made
51
+ * locally (used by HybridLeaseProvider so the local slot and the attested
52
+ * slot are the same). Throws QuorumUnavailableError / QuorumConflictError
53
+ * on failure — the caller owns the local reservation and must burn it.
54
+ */
55
+ attestKeyUse(params: ReserveParams, indices: {
56
+ addressIndex: number;
57
+ l1: number;
58
+ l2: number;
59
+ }): Promise<QuorumAttestation[]>;
60
+ commitKeyUse(reservationId: string, txId: string): Promise<void>;
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;
70
+ private broadcastAck;
71
+ getLocalWatermark(treeId: string): Promise<LocalWatermark>;
72
+ publishWatermark(treeId: string): Promise<void>;
73
+ syncLeaseJournal(): Promise<SyncResult>;
74
+ verifyLeaseCertificate(cert?: LeaseCertificate): Promise<boolean>;
75
+ }