@totemsdk/wots-lease 0.1.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,9 +28,9 @@ npm install @totemsdk/wots-lease
28
28
  | `LocalLeaseProvider` | Single-device, in-process coordination |
29
29
  | `AxiaLeaseProvider` | Cloud-coordinated leases via Axia API |
30
30
  | `HybridLeaseProvider` | Local-first with Axia sync fallback |
31
- | `PersonalLeaseNodeProvider` | Coordinates through your personal lookup node *(ready for implementation)* |
32
- | `P2PQuorumLeaseProvider` | Multi-device quorum consensus *(stub)* |
33
- | `OnchainWatermarkProvider` | On-chain watermark anchoring *(stub)* |
31
+ | `PersonalLeaseNodeProvider` | Coordinates through your personal lookup node |
32
+ | `P2PQuorumLeaseProvider` | Multi-device quorum consensus — peers attest to the same slot before it is handed out |
33
+ | `OnchainWatermarkProvider` | On-chain watermark anchoring — spends a dedicated watermark coin whose STATE(0) holds the flat cursor |
34
34
 
35
35
  ### Device range splitting
36
36
 
@@ -90,6 +90,45 @@ const entries = await journal.getEntries({ addressIndex: 0 });
90
90
  entries.forEach(e => console.log(e.flatIndex, e.allocatedAt, e.committedAt));
91
91
  ```
92
92
 
93
+ ### P2P quorum (multi-device)
94
+
95
+ ```typescript
96
+ import { P2PQuorumLeaseProvider, LocalLeaseProvider } from '@totemsdk/wots-lease';
97
+
98
+ const provider = new P2PQuorumLeaseProvider({
99
+ local: new LocalLeaseProvider(storage),
100
+ peers: [peerA, peerB, peerC], // QuorumPeer[] — any LEASE_* wire-protocol speaker
101
+ minAttestations: 2, // require 2 peers to attest before handing out a slot
102
+ });
103
+
104
+ const reservation = await provider.reserveKeyUse({ treeId: 'wallet' });
105
+ // reservation.certificate.attestations — peer attestations for the same indices
106
+ ```
107
+
108
+ Peers speak the same `LEASE_RESERVE` / `LEASE_COMMIT` / `LEASE_BURN` /
109
+ `LEASE_WATERMARK` messages as the lookup protocol, so a personal lookup node
110
+ (`@totemsdk/lookup-node` with `lease.enabled`) or another device running this
111
+ provider can participate. If quorum cannot be reached the local reservation is
112
+ **burned** — a slot that might have been attested is never handed out again.
113
+
114
+ ### On-chain watermark anchoring
115
+
116
+ ```typescript
117
+ import { OnchainWatermarkProvider, LocalLeaseProvider } from '@totemsdk/wots-lease';
118
+
119
+ const provider = new OnchainWatermarkProvider({
120
+ local: new LocalLeaseProvider(storage),
121
+ chain: chainProvider, // any ChainStateProvider (hosted / RPC / lookup node)
122
+ watermarkCoinId: '0x…', // dedicated watermark coin
123
+ watermarkAddress: '0x…', // its current spending address
124
+ signer: { publicKeyDigest, sign }, // SIGNEDBY key for the watermark coin
125
+ });
126
+
127
+ await provider.publishWatermark('wallet');
128
+ // Spends the watermark coin back to itself with STATE(0) = flat cursor,
129
+ // so any third party can verify slot consumption on-chain.
130
+ ```
131
+
93
132
  ## See also
94
133
 
95
134
  - [`@totemsdk/core`](https://www.npmjs.com/package/@totemsdk/core) — WOTS primitives this package protects
package/dist/errors.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SigningIndices } from './types.js';
1
2
  export declare class WatermarkMonotonicityError extends Error {
2
3
  constructor(message: string);
3
4
  }
@@ -9,6 +10,11 @@ export declare class LeaseNotFoundError extends Error {
9
10
  readonly reservationId: string;
10
11
  constructor(reservationId: string);
11
12
  }
13
+ export declare class IndicesUnavailableError extends Error {
14
+ readonly treeId: string;
15
+ readonly indices: SigningIndices;
16
+ constructor(treeId: string, indices: SigningIndices);
17
+ }
12
18
  export declare class PersonalLeaseNodeNotConfiguredError extends Error {
13
19
  constructor();
14
20
  }
@@ -18,6 +24,19 @@ export declare class P2PQuorumNotImplementedError extends Error {
18
24
  export declare class OnchainWatermarkNotImplementedError extends Error {
19
25
  constructor();
20
26
  }
27
+ export declare class QuorumUnavailableError extends Error {
28
+ readonly required: number;
29
+ readonly available: number;
30
+ constructor(required: number, available: number);
31
+ }
32
+ export declare class QuorumConflictError extends Error {
33
+ readonly treeId: string;
34
+ readonly indices: SigningIndices;
35
+ constructor(treeId: string, indices: SigningIndices);
36
+ }
37
+ export declare class OnchainWatermarkError extends Error {
38
+ constructor(message: string);
39
+ }
21
40
  export declare class DeviceRangeViolationError extends Error {
22
41
  readonly addressIndex: number;
23
42
  readonly allowedStart: number;
package/dist/errors.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DeviceRangeViolationError = exports.OnchainWatermarkNotImplementedError = exports.P2PQuorumNotImplementedError = exports.PersonalLeaseNodeNotConfiguredError = exports.LeaseNotFoundError = exports.WatermarkExhaustedError = exports.WatermarkMonotonicityError = void 0;
3
+ exports.DeviceRangeViolationError = exports.OnchainWatermarkError = exports.QuorumConflictError = exports.QuorumUnavailableError = exports.OnchainWatermarkNotImplementedError = exports.P2PQuorumNotImplementedError = exports.PersonalLeaseNodeNotConfiguredError = exports.IndicesUnavailableError = exports.LeaseNotFoundError = exports.WatermarkExhaustedError = exports.WatermarkMonotonicityError = void 0;
4
4
  class WatermarkMonotonicityError extends Error {
5
5
  constructor(message) {
6
6
  super(message);
@@ -24,6 +24,15 @@ class LeaseNotFoundError extends Error {
24
24
  }
25
25
  }
26
26
  exports.LeaseNotFoundError = LeaseNotFoundError;
27
+ class IndicesUnavailableError extends Error {
28
+ constructor(treeId, indices) {
29
+ super(`Indices (${indices.addressIndex}, ${indices.l1}, ${indices.l2}) are already unavailable for tree: ${treeId}`);
30
+ this.treeId = treeId;
31
+ this.indices = indices;
32
+ this.name = 'IndicesUnavailableError';
33
+ }
34
+ }
35
+ exports.IndicesUnavailableError = IndicesUnavailableError;
27
36
  class PersonalLeaseNodeNotConfiguredError extends Error {
28
37
  constructor() {
29
38
  super('PersonalLeaseNodeProvider requires a running lookup node. ' +
@@ -46,6 +55,33 @@ class OnchainWatermarkNotImplementedError extends Error {
46
55
  }
47
56
  }
48
57
  exports.OnchainWatermarkNotImplementedError = OnchainWatermarkNotImplementedError;
58
+ class QuorumUnavailableError extends Error {
59
+ constructor(required, available) {
60
+ super(`Quorum not reached: ${available}/${required} peers attested. ` +
61
+ 'The reservation was burned locally — retry when more peers are reachable.');
62
+ this.required = required;
63
+ this.available = available;
64
+ this.name = 'QuorumUnavailableError';
65
+ }
66
+ }
67
+ exports.QuorumUnavailableError = QuorumUnavailableError;
68
+ class QuorumConflictError extends Error {
69
+ constructor(treeId, indices) {
70
+ super(`Quorum conflict for tree ${treeId} at (${indices.addressIndex}, ${indices.l1}, ${indices.l2}): ` +
71
+ 'a peer already holds a reservation for these indices.');
72
+ this.treeId = treeId;
73
+ this.indices = indices;
74
+ this.name = 'QuorumConflictError';
75
+ }
76
+ }
77
+ exports.QuorumConflictError = QuorumConflictError;
78
+ class OnchainWatermarkError extends Error {
79
+ constructor(message) {
80
+ super(message);
81
+ this.name = 'OnchainWatermarkError';
82
+ }
83
+ }
84
+ exports.OnchainWatermarkError = OnchainWatermarkError;
49
85
  class DeviceRangeViolationError extends Error {
50
86
  constructor(addressIndex, allowedStart, allowedEnd) {
51
87
  super(`Address index ${addressIndex} is outside device range [${allowedStart}, ${allowedEnd}]`);
package/dist/hybrid.js CHANGED
@@ -24,6 +24,33 @@ class HybridLeaseProvider {
24
24
  async reserveKeyUse(params) {
25
25
  const reservation = await this.local.reserveKeyUse(params);
26
26
  if (this.isHighValue(params)) {
27
+ // Layer 4 — quorum attestation over the SAME local slot.
28
+ if (this.quorum) {
29
+ try {
30
+ const attestations = await this.quorum.attestKeyUse(params, reservation.indices);
31
+ return {
32
+ ...reservation,
33
+ certificate: {
34
+ reservationId: reservation.reservationId,
35
+ treeId: params.treeId,
36
+ branchId: params.branchId,
37
+ deviceId: params.deviceId,
38
+ indices: reservation.indices,
39
+ purpose: params.purpose,
40
+ payloadHash: params.payloadHash,
41
+ issuedBy: 'p2p-quorum',
42
+ issuedAt: Date.now(),
43
+ expiresAt: reservation.expiresAt,
44
+ signature: '',
45
+ attestations,
46
+ },
47
+ };
48
+ }
49
+ catch {
50
+ // Quorum unavailable — fall through to node / local only.
51
+ }
52
+ }
53
+ // Layer 3 — personal lookup node certificate.
27
54
  if (this.node) {
28
55
  try {
29
56
  const nodeReservation = await this.node.reserveKeyUse(params);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { UnavailableReason, SigningIndices, LeaseStatus, TreeWatermark, WotsWatermarkState, LeaseCertificate, LeaseReservation, ReserveParams, LocalWatermark, ConflictRecord, SyncResult, WotsLeaseProvider, JournalEntry, DeviceKeyRange, PersonalLeaseNodeConfig, } from './types.js';
1
+ export type { UnavailableReason, SigningIndices, LeaseStatus, TreeWatermark, WotsWatermarkState, LeaseCertificate, LeaseReservation, ReserveParams, LocalWatermark, ConflictRecord, SyncResult, WotsLeaseProvider, JournalEntry, DeviceKeyRange, PersonalLeaseNodeConfig, QuorumAttestation, QuorumPeer, P2PQuorumLeaseProviderConfig, OnchainWatermarkProviderConfig, } from './types.js';
2
2
  export { WotsWatermarkStore, flatIndex, fromFlatIndex, } from './watermark.js';
3
3
  export { LeaseJournal } from './journal.js';
4
4
  export { LocalLeaseProvider } from './local.js';
@@ -7,5 +7,7 @@ export type { AxiaLeaseProviderConfig } from './axia.js';
7
7
  export { HybridLeaseProvider } from './hybrid.js';
8
8
  export type { HybridLeaseProviderConfig } from './hybrid.js';
9
9
  export { PersonalLeaseNodeProvider, P2PQuorumLeaseProvider, OnchainWatermarkProvider, } from './stubs.js';
10
+ export { P2PQuorumLeaseProvider as QuorumLeaseProvider } from './quorum.js';
11
+ export { OnchainWatermarkProvider as ChainWatermarkProvider } from './onchain.js';
10
12
  export { allocateDeviceRange, deviceSlotForAddressIndex } from './device.js';
11
- export { WatermarkMonotonicityError, WatermarkExhaustedError, LeaseNotFoundError, PersonalLeaseNodeNotConfiguredError, P2PQuorumNotImplementedError, OnchainWatermarkNotImplementedError, DeviceRangeViolationError, } from './errors.js';
13
+ export { WatermarkMonotonicityError, WatermarkExhaustedError, LeaseNotFoundError, IndicesUnavailableError, PersonalLeaseNodeNotConfiguredError, P2PQuorumNotImplementedError, OnchainWatermarkNotImplementedError, QuorumUnavailableError, QuorumConflictError, OnchainWatermarkError, DeviceRangeViolationError, } from './errors.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DeviceRangeViolationError = exports.OnchainWatermarkNotImplementedError = exports.P2PQuorumNotImplementedError = exports.PersonalLeaseNodeNotConfiguredError = exports.LeaseNotFoundError = exports.WatermarkExhaustedError = exports.WatermarkMonotonicityError = exports.deviceSlotForAddressIndex = exports.allocateDeviceRange = exports.OnchainWatermarkProvider = exports.P2PQuorumLeaseProvider = exports.PersonalLeaseNodeProvider = exports.HybridLeaseProvider = exports.AxiaLeaseProvider = exports.LocalLeaseProvider = exports.LeaseJournal = exports.fromFlatIndex = exports.flatIndex = exports.WotsWatermarkStore = void 0;
3
+ exports.DeviceRangeViolationError = exports.OnchainWatermarkError = exports.QuorumConflictError = exports.QuorumUnavailableError = exports.OnchainWatermarkNotImplementedError = exports.P2PQuorumNotImplementedError = exports.PersonalLeaseNodeNotConfiguredError = exports.IndicesUnavailableError = exports.LeaseNotFoundError = exports.WatermarkExhaustedError = exports.WatermarkMonotonicityError = exports.deviceSlotForAddressIndex = exports.allocateDeviceRange = exports.ChainWatermarkProvider = exports.QuorumLeaseProvider = exports.OnchainWatermarkProvider = exports.P2PQuorumLeaseProvider = exports.PersonalLeaseNodeProvider = exports.HybridLeaseProvider = exports.AxiaLeaseProvider = exports.LocalLeaseProvider = exports.LeaseJournal = exports.fromFlatIndex = exports.flatIndex = exports.WotsWatermarkStore = void 0;
4
4
  var watermark_js_1 = require("./watermark.js");
5
5
  Object.defineProperty(exports, "WotsWatermarkStore", { enumerable: true, get: function () { return watermark_js_1.WotsWatermarkStore; } });
6
6
  Object.defineProperty(exports, "flatIndex", { enumerable: true, get: function () { return watermark_js_1.flatIndex; } });
@@ -17,6 +17,10 @@ var stubs_js_1 = require("./stubs.js");
17
17
  Object.defineProperty(exports, "PersonalLeaseNodeProvider", { enumerable: true, get: function () { return stubs_js_1.PersonalLeaseNodeProvider; } });
18
18
  Object.defineProperty(exports, "P2PQuorumLeaseProvider", { enumerable: true, get: function () { return stubs_js_1.P2PQuorumLeaseProvider; } });
19
19
  Object.defineProperty(exports, "OnchainWatermarkProvider", { enumerable: true, get: function () { return stubs_js_1.OnchainWatermarkProvider; } });
20
+ var quorum_js_1 = require("./quorum.js");
21
+ Object.defineProperty(exports, "QuorumLeaseProvider", { enumerable: true, get: function () { return quorum_js_1.P2PQuorumLeaseProvider; } });
22
+ var onchain_js_1 = require("./onchain.js");
23
+ Object.defineProperty(exports, "ChainWatermarkProvider", { enumerable: true, get: function () { return onchain_js_1.OnchainWatermarkProvider; } });
20
24
  var device_js_1 = require("./device.js");
21
25
  Object.defineProperty(exports, "allocateDeviceRange", { enumerable: true, get: function () { return device_js_1.allocateDeviceRange; } });
22
26
  Object.defineProperty(exports, "deviceSlotForAddressIndex", { enumerable: true, get: function () { return device_js_1.deviceSlotForAddressIndex; } });
@@ -24,7 +28,11 @@ var errors_js_1 = require("./errors.js");
24
28
  Object.defineProperty(exports, "WatermarkMonotonicityError", { enumerable: true, get: function () { return errors_js_1.WatermarkMonotonicityError; } });
25
29
  Object.defineProperty(exports, "WatermarkExhaustedError", { enumerable: true, get: function () { return errors_js_1.WatermarkExhaustedError; } });
26
30
  Object.defineProperty(exports, "LeaseNotFoundError", { enumerable: true, get: function () { return errors_js_1.LeaseNotFoundError; } });
31
+ Object.defineProperty(exports, "IndicesUnavailableError", { enumerable: true, get: function () { return errors_js_1.IndicesUnavailableError; } });
27
32
  Object.defineProperty(exports, "PersonalLeaseNodeNotConfiguredError", { enumerable: true, get: function () { return errors_js_1.PersonalLeaseNodeNotConfiguredError; } });
28
33
  Object.defineProperty(exports, "P2PQuorumNotImplementedError", { enumerable: true, get: function () { return errors_js_1.P2PQuorumNotImplementedError; } });
29
34
  Object.defineProperty(exports, "OnchainWatermarkNotImplementedError", { enumerable: true, get: function () { return errors_js_1.OnchainWatermarkNotImplementedError; } });
35
+ Object.defineProperty(exports, "QuorumUnavailableError", { enumerable: true, get: function () { return errors_js_1.QuorumUnavailableError; } });
36
+ Object.defineProperty(exports, "QuorumConflictError", { enumerable: true, get: function () { return errors_js_1.QuorumConflictError; } });
37
+ Object.defineProperty(exports, "OnchainWatermarkError", { enumerable: true, get: function () { return errors_js_1.OnchainWatermarkError; } });
30
38
  Object.defineProperty(exports, "DeviceRangeViolationError", { enumerable: true, get: function () { return errors_js_1.DeviceRangeViolationError; } });
package/dist/local.d.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * All WOTS safety guaranteed by the local WotsWatermarkStore + LeaseJournal.
6
6
  */
7
7
  import type { StorageAdapter, LoggerAdapter } from '@totemsdk/core';
8
- import type { WotsLeaseProvider, ReserveParams, LeaseReservation, LeaseCertificate, LocalWatermark, SyncResult } from './types.js';
8
+ import type { WotsLeaseProvider, ReserveParams, LeaseReservation, LeaseCertificate, LocalWatermark, SyncResult, SigningIndices } from './types.js';
9
+ import { LeaseJournal } from './journal.js';
9
10
  export declare class LocalLeaseProvider implements WotsLeaseProvider {
10
11
  private readonly logger;
11
12
  private readonly watermark;
@@ -21,9 +22,30 @@ export declare class LocalLeaseProvider implements WotsLeaseProvider {
21
22
  private burnUnresolvedReservations;
22
23
  private recoverExpiredReservations;
23
24
  reserveKeyUse(params: ReserveParams): Promise<LeaseReservation>;
25
+ /**
26
+ * Reserve a specific set of indices (used by quorum coordination so every
27
+ * peer attests to the same slot). Throws IndicesUnavailableError when the
28
+ * slot is already taken.
29
+ */
30
+ reserveSpecificKeyUse(params: ReserveParams, indices: SigningIndices): Promise<LeaseReservation>;
24
31
  commitKeyUse(reservationId: string, txId: string): Promise<void>;
25
32
  burnReservation(reservationId: string, reason: string): Promise<void>;
26
33
  getLocalWatermark(treeId: string): Promise<LocalWatermark>;
34
+ /** List all tree IDs known to the local watermark store. */
35
+ listTrees(): string[];
36
+ /** Expose the journal for quorum/on-chain providers to merge remote entries. */
37
+ getJournal(): LeaseJournal;
38
+ /**
39
+ * Advance the local watermark to a remote cursor (monotonic merge).
40
+ * Used by quorum sync and the lookup-node LeaseCoordinator when a peer
41
+ * publishes a watermark ahead of ours. Returns false when the remote
42
+ * cursor is behind (no-op).
43
+ */
44
+ advanceToRemoteWatermark(treeId: string, remote: {
45
+ addressCursor: number;
46
+ l1Cursor: number;
47
+ l2Cursor: number;
48
+ }): Promise<boolean>;
27
49
  publishWatermark(_treeId: string): Promise<void>;
28
50
  syncLeaseJournal(): Promise<SyncResult>;
29
51
  verifyLeaseCertificate(cert?: LeaseCertificate): Promise<boolean>;
package/dist/local.js CHANGED
@@ -81,12 +81,24 @@ class LocalLeaseProvider {
81
81
  }
82
82
  }
83
83
  async reserveKeyUse(params) {
84
+ if (!this._initialized)
85
+ await this.initialize();
86
+ const indices = this.watermark.getNextIndices(params.treeId);
87
+ return this.reserveSpecificKeyUse(params, indices);
88
+ }
89
+ /**
90
+ * Reserve a specific set of indices (used by quorum coordination so every
91
+ * peer attests to the same slot). Throws IndicesUnavailableError when the
92
+ * slot is already taken.
93
+ */
94
+ async reserveSpecificKeyUse(params, indices) {
84
95
  if (!this._initialized)
85
96
  await this.initialize();
86
97
  const treeId = params.treeId;
87
98
  const ttlMs = params.ttlMs ?? 120000;
88
- // Atomically get the next available index AND mark it reserved in the watermark
89
- const indices = this.watermark.getNextIndices(treeId);
99
+ if (this.watermark.isUnavailable(treeId, indices)) {
100
+ throw new errors_js_1.IndicesUnavailableError(treeId, indices);
101
+ }
90
102
  // Validate device range BEFORE marking unavailable — prevents permanent index loss
91
103
  if (params.deviceId) {
92
104
  const range = this.getDeviceRange(params.deviceId);
@@ -172,6 +184,36 @@ class LocalLeaseProvider {
172
184
  await this.initialize();
173
185
  return this.watermark.getLocalWatermark(treeId);
174
186
  }
187
+ /** List all tree IDs known to the local watermark store. */
188
+ listTrees() {
189
+ return Object.keys(this.watermark.getRawState().trees);
190
+ }
191
+ /** Expose the journal for quorum/on-chain providers to merge remote entries. */
192
+ getJournal() {
193
+ return this.journal;
194
+ }
195
+ /**
196
+ * Advance the local watermark to a remote cursor (monotonic merge).
197
+ * Used by quorum sync and the lookup-node LeaseCoordinator when a peer
198
+ * publishes a watermark ahead of ours. Returns false when the remote
199
+ * cursor is behind (no-op).
200
+ */
201
+ async advanceToRemoteWatermark(treeId, remote) {
202
+ if (!this._initialized)
203
+ await this.initialize();
204
+ const local = this.watermark.getLocalWatermark(treeId);
205
+ const localFlat = (0, watermark_js_1.flatIndex)({ addressIndex: local.addressCursor, l1: local.l1Cursor, l2: local.l2Cursor });
206
+ const remoteFlat = (0, watermark_js_1.flatIndex)({ addressIndex: remote.addressCursor, l1: remote.l1Cursor, l2: remote.l2Cursor });
207
+ if (remoteFlat <= localFlat)
208
+ return false;
209
+ await this.watermark.save(treeId, {
210
+ addressCursor: remote.addressCursor,
211
+ l1Cursor: remote.l1Cursor,
212
+ l2Cursor: remote.l2Cursor,
213
+ lastSyncTimestamp: Date.now(),
214
+ });
215
+ return true;
216
+ }
175
217
  async publishWatermark(_treeId) {
176
218
  // Layer 1: no-op
177
219
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * OnchainWatermarkProvider — Layer 5: on-chain watermark anchoring.
3
+ *
4
+ * Safety model
5
+ * ────────────
6
+ * The local provider remains the authoritative watermark. This provider adds
7
+ * a verifiable public record: a dedicated watermark coin whose STATE(0) holds
8
+ * the flat watermark cursor. Every publish spends the coin back to itself
9
+ * with an advanced cursor, so any third party can read the on-chain state and
10
+ * prove that a given signing slot was consumed — without trusting this device.
11
+ *
12
+ * The watermark coin script is `RETURN SIGNEDBY(0x<digest>)` — the same
13
+ * single-sig pattern used by statechain reclaim addresses. The signer is the
14
+ * WOTS key owner; the digest is the 32-byte public key digest.
15
+ *
16
+ * Failure semantics
17
+ * ─────────────────
18
+ * - publishWatermark fails loudly (OnchainWatermarkError) when the chain
19
+ * rejects the TX — the local watermark is never rolled back.
20
+ * - syncLeaseJournal merges the on-chain cursor into the local watermark
21
+ * monotonically (remote ahead → advance; remote behind → conflict record).
22
+ */
23
+ import type { WotsLeaseProvider, ReserveParams, LeaseReservation, LeaseCertificate, LocalWatermark, SyncResult, OnchainWatermarkProviderConfig } from './types.js';
24
+ export declare class OnchainWatermarkProvider implements WotsLeaseProvider {
25
+ private readonly chain;
26
+ private readonly watermarkCoinId;
27
+ private readonly watermarkAddress;
28
+ private readonly tokenId;
29
+ private readonly amount;
30
+ private readonly local;
31
+ private readonly signer;
32
+ private readonly statePort;
33
+ private readonly minBlocksBetweenPublishes;
34
+ private _initialized;
35
+ private _lastPublishBlock;
36
+ constructor(config: OnchainWatermarkProviderConfig);
37
+ initialize(): Promise<void>;
38
+ private ensureInit;
39
+ reserveKeyUse(params: ReserveParams): Promise<LeaseReservation>;
40
+ commitKeyUse(reservationId: string, txId: string): Promise<void>;
41
+ burnReservation(reservationId: string, reason: string): Promise<void>;
42
+ getLocalWatermark(treeId: string): Promise<LocalWatermark>;
43
+ /**
44
+ * Publish the local watermark cursor on-chain by spending the watermark
45
+ * coin back to itself with STATE(statePort) = flat cursor.
46
+ *
47
+ * Rate-limited by minBlocksBetweenPublishes using the chain tip.
48
+ */
49
+ publishWatermark(treeId: string): Promise<void>;
50
+ syncLeaseJournal(): Promise<SyncResult>;
51
+ verifyLeaseCertificate(cert?: LeaseCertificate): Promise<boolean>;
52
+ }
@@ -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;