@totemsdk/wots-lease 0.1.6 → 1.0.1

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