@lerna-labs/hydra-sdk 1.0.0-beta.9 → 2.0.0-beta.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.
Files changed (39) hide show
  1. package/README.md +159 -0
  2. package/dist/cache/disk-cache.d.ts +37 -0
  3. package/dist/cache/disk-cache.js +84 -0
  4. package/dist/config.d.ts +4 -0
  5. package/dist/config.js +12 -0
  6. package/dist/hydra/hydra-http-client.d.ts +36 -0
  7. package/dist/hydra/hydra-http-client.js +66 -0
  8. package/dist/hydra/hydra-monitor.d.ts +88 -0
  9. package/dist/hydra/hydra-monitor.js +270 -0
  10. package/dist/hydra/hydra-websocket.d.ts +46 -0
  11. package/dist/hydra/hydra-websocket.js +181 -0
  12. package/dist/hydra/messages.d.ts +14 -0
  13. package/dist/hydra/messages.js +1 -0
  14. package/dist/hydra/types.d.ts +486 -0
  15. package/dist/hydra/types.js +2 -0
  16. package/dist/hydra/utxo-conversion.d.ts +10 -0
  17. package/dist/hydra/utxo-conversion.js +111 -0
  18. package/dist/hydra/utxo.d.ts +25 -5
  19. package/dist/hydra/utxo.js +37 -31
  20. package/dist/index.d.ts +15 -7
  21. package/dist/index.js +11 -7
  22. package/dist/ipfs/ipfs.d.ts +22 -0
  23. package/dist/ipfs/ipfs.js +90 -0
  24. package/dist/mesh/get-admin.d.ts +13 -1
  25. package/dist/mesh/get-admin.js +37 -7
  26. package/dist/mesh/native-script.d.ts +30 -5
  27. package/dist/mesh/native-script.js +38 -10
  28. package/dist/test.js +3 -3
  29. package/dist/tx3/submit-tx.d.ts +8 -0
  30. package/dist/tx3/submit-tx.js +8 -0
  31. package/dist/utils/chunk-string.d.ts +7 -0
  32. package/dist/utils/chunk-string.js +7 -0
  33. package/dist/utils/verify-signature.d.ts +28 -5
  34. package/dist/utils/verify-signature.js +39 -18
  35. package/dist/wrangler.d.ts +179 -0
  36. package/dist/wrangler.js +452 -0
  37. package/package.json +25 -6
  38. package/dist/mesh/wrangler.d.ts +0 -29
  39. package/dist/mesh/wrangler.js +0 -277
@@ -0,0 +1,179 @@
1
+ import { HydraHttpClient } from './hydra/hydra-http-client.js';
2
+ import type { HydraMonitor } from './hydra/hydra-monitor.js';
3
+ import { HydraWebSocket } from './hydra/hydra-websocket.js';
4
+ import type { HeadStatus, HydraStatus, HydraTransaction } from './hydra/types.js';
5
+ /** UTxO reference for committing funds into a Hydra head. */
6
+ export interface UTxORef {
7
+ /** Transaction hash containing the UTxO. */
8
+ txHash: string;
9
+ /** Output index of the UTxO within the transaction. */
10
+ outputIndex: number;
11
+ }
12
+ /**
13
+ * Arguments for committing UTxOs into an open Hydra head.
14
+ *
15
+ * As of Hydra v2 (ADR-33) a head opens empty and all commits are drafted as
16
+ * deposits into the open head — see {@link Wrangler.incrementalCommit}.
17
+ */
18
+ export interface CommitArgs {
19
+ /** One or more UTxO references to commit. */
20
+ utxos: UTxORef[];
21
+ /** Blueprint transaction that spends the committed UTxOs (CBOR-encoded, unsigned). Optional — omit for simple ADA-only commits. */
22
+ blueprintTx?: HydraTransaction;
23
+ }
24
+ /**
25
+ * High-level controller for Hydra head lifecycle operations.
26
+ *
27
+ * Wraps `HydraWebSocket` and `HydraHttpClient` to provide a simplified API
28
+ * for opening, funding, and closing a Hydra head.
29
+ *
30
+ * As of Hydra v2 (ADR-33) opening a head no longer requires a commit: `Init`
31
+ * opens the head directly with an empty UTxO set. Funds are added afterwards
32
+ * via {@link Wrangler.incrementalCommit} (a deposit into the open head).
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * const wrangler = new Wrangler("http://localhost:4001", "ws://localhost:4001");
37
+ * await wrangler.waitForHeadOpen();
38
+ * await wrangler.incrementalCommit({ utxos: [{ txHash: "abc...", outputIndex: 0 }] });
39
+ * ```
40
+ */
41
+ export declare class Wrangler {
42
+ private mode;
43
+ readonly ws: HydraWebSocket;
44
+ readonly http: HydraHttpClient;
45
+ private readonly blockfrost;
46
+ private readonly monitor;
47
+ constructor(url?: string, wsUrl?: string, monitor?: HydraMonitor);
48
+ /**
49
+ * Connect to the Hydra node with exponential-backoff retry.
50
+ *
51
+ * Uses `HydraWebSocket.waitForGreetings()` which establishes the WebSocket
52
+ * **and** waits for the Hydra `Greetings` handshake.
53
+ *
54
+ * @param maxAttempts - Maximum number of connection attempts (default 5).
55
+ * @param baseDelayMs - Initial retry delay in milliseconds (default 1000). Doubles each attempt, capped at 30 s.
56
+ */
57
+ private connectWithRetry;
58
+ /**
59
+ * Shared helper for promise-based methods that wait for a specific
60
+ * Hydra message. Handles connection, timeout, and settlement in one place.
61
+ *
62
+ * Uses EventEmitter `on`/`removeListener` for multi-listener support,
63
+ * avoiding the single-callback overwrite bug in HydraProvider.
64
+ */
65
+ private awaitMessage;
66
+ /** Connect the underlying WebSocket with retry logic. */
67
+ connect(): Promise<void>;
68
+ /** Disconnect the underlying WebSocket. */
69
+ disconnect(timeout?: number): Promise<void>;
70
+ /** Return the current Hydra head status (uppercase). */
71
+ getStatus(): HydraStatus;
72
+ /** Register a callback for head status changes. */
73
+ onStatusChange(callback: (status: HydraStatus) => void): HydraStatus;
74
+ /**
75
+ * Begin the head-opening sequence: send `Init` on an `Idle` head and listen
76
+ * for state changes until it reaches `Open`.
77
+ *
78
+ * As of Hydra v2 (ADR-33) opening no longer commits any UTxOs — the head
79
+ * opens empty. Use {@link incrementalCommit} to add funds once it is `Open`.
80
+ */
81
+ startHead(): Promise<void>;
82
+ /** Begin the head-closing sequence: close, fanout, and finalize. */
83
+ shutdownHead(): Promise<void>;
84
+ /**
85
+ * Wait for the Hydra head to fully close and finalize.
86
+ *
87
+ * Resolves on the `HeadIsClosed` / `HeadIsFinalized` transition events, **and**
88
+ * on the initial `Greetings` replay if the head is already at `Closed` or
89
+ * `Final`. Rejects fast if the head is `Idle` (no head exists to close).
90
+ *
91
+ * @param timeoutMs - Maximum time to wait in milliseconds.
92
+ */
93
+ waitForHeadClose(timeoutMs?: number): Promise<void>;
94
+ /**
95
+ * Open the Hydra head and wait for it to reach the `Open` state.
96
+ *
97
+ * As of Hydra v2 (ADR-33) the head opens directly with an empty UTxO set:
98
+ * on an `Idle` head this sends `Init` and resolves once the node reports
99
+ * `HeadIsOpen`. Funds are added afterwards via {@link incrementalCommit}.
100
+ *
101
+ * Resolves on the `HeadIsOpen` transition event, **and** on the initial
102
+ * `Greetings` replay if the head is already `Open`. Rejects fast if the
103
+ * head is in a terminal or shutting-down state (`Closed`, `FanoutPossible`,
104
+ * `Final`) — a new head must be started from a fresh node.
105
+ *
106
+ * @param timeoutMs - Maximum time to wait in milliseconds.
107
+ */
108
+ waitForHeadOpen(timeoutMs?: number): Promise<void>;
109
+ /**
110
+ * Query the current Hydra head status via a `Greetings` message.
111
+ * @param timeoutMs - Maximum time to wait for the status response.
112
+ * @returns The head status string (e.g. `"Idle"`, `"Open"`, `"Closed"`).
113
+ */
114
+ getHeadStatus(timeoutMs?: number): Promise<HeadStatus>;
115
+ /**
116
+ * Decommit funds from an open Hydra head back to L1.
117
+ *
118
+ * Posts the decommit transaction via HTTP to avoid overwriting message handlers.
119
+ * Resolves on `DecommitApproved` — L1 settlement happens asynchronously.
120
+ *
121
+ * @param transaction - The decommit transaction (CBOR-encoded).
122
+ * @param timeoutMs - Maximum time to wait for approval (default 60s).
123
+ */
124
+ decommit(transaction: HydraTransaction, timeoutMs?: number): Promise<void>;
125
+ /**
126
+ * Incrementally commit funds into an already-open Hydra head.
127
+ *
128
+ * Only single-UTxO commits are supported (MeshJS limitation).
129
+ * The raw L1 transaction is submitted to Blockfrost automatically.
130
+ *
131
+ * Resolves on `CommitFinalized`.
132
+ *
133
+ * @param commitArgs - Single UTxO (with optional blueprint) to commit.
134
+ * @param timeoutMs - Maximum time to wait for finalization (default 120s).
135
+ */
136
+ incrementalCommit(commitArgs: CommitArgs, timeoutMs?: number): Promise<void>;
137
+ /** Transient L1-submission errors caused by a stale node/chain view post-rollback. */
138
+ private static readonly TRANSIENT_SUBMIT;
139
+ /** Sleep helper (real timers; advanced by fake timers in tests). */
140
+ private delay;
141
+ /**
142
+ * Deposit funds into an open head, resilient to L1 rollbacks.
143
+ *
144
+ * Two failure modes are handled:
145
+ *
146
+ * 1. **Stale fee input (transient).** The hydra-node funds the deposit tx's fee
147
+ * from the committer's *other* UTxOs using its chain view. Just after a
148
+ * rollback, that view can reference a reverted tx's outputs, so submission
149
+ * fails with `BadInputsUTxO`/`ValueNotConserved`. We re-draft after a short
150
+ * delay (letting the node re-sync) before giving up on the attempt.
151
+ * 2. **Cancelled increment (rollback).** A deposit's increment is triggered by
152
+ * its on-chain *observation*; a fork-switch can cancel it so the deposit
153
+ * lingers in `GET /commits` and never enters the snapshot. On a finalize
154
+ * timeout we retry with a **fresh** UTxO.
155
+ *
156
+ * Funds are never lost: a stranded deposit is recoverable after its deadline.
157
+ *
158
+ * @param getUtxo - Provides a fresh, unspent small UTxO ref for each attempt.
159
+ * Must return a *different* UTxO each call. A live L1 query satisfies this.
160
+ * @param sign - Signs the drafted deposit tx (CBOR hex) with the UTxO owner's
161
+ * key, returning the signed CBOR hex (e.g. `(tx) => wallet.signTx(tx, true)`).
162
+ * @param opts.maxAttempts - Max deposit attempts with fresh UTxOs (default 3).
163
+ * @param opts.finalizeTimeoutMs - Per-attempt wait for `CommitFinalized` (default 180s).
164
+ * @param opts.submitRetries - Re-draft attempts on a transient submit error (default 3).
165
+ * @param opts.submitRetryDelayMs - Delay before re-drafting, lets the node re-sync (default 8s).
166
+ * @returns The L1 tx id of the deposit that finalized.
167
+ */
168
+ depositResilient(getUtxo: () => Promise<UTxORef>, sign: (cborHex: string) => Promise<string>, opts?: {
169
+ maxAttempts?: number;
170
+ finalizeTimeoutMs?: number;
171
+ submitRetries?: number;
172
+ submitRetryDelayMs?: number;
173
+ }): Promise<string>;
174
+ /** Draft a deposit for `ref` via `POST /commit`, sign it, and submit to L1. */
175
+ private submitDeposit;
176
+ private doIncrementalCommit;
177
+ private handleIncoming;
178
+ private onGreetings;
179
+ }
@@ -0,0 +1,452 @@
1
+ import { BlockfrostProvider } from '@meshsdk/core';
2
+ import { requireEnv } from './config.js';
3
+ import { HydraHttpClient } from './hydra/hydra-http-client.js';
4
+ import { HydraWebSocket } from './hydra/hydra-websocket.js';
5
+ import { toHydraUTxO } from './hydra/utxo-conversion.js';
6
+ const HYDRA_TO_HEAD_STATUS = {
7
+ IDLE: 'Idle',
8
+ OPEN: 'Open',
9
+ CLOSED: 'Closed',
10
+ FANOUT_POSSIBLE: 'FanoutPossible',
11
+ FINAL: 'Final',
12
+ };
13
+ /**
14
+ * High-level controller for Hydra head lifecycle operations.
15
+ *
16
+ * Wraps `HydraWebSocket` and `HydraHttpClient` to provide a simplified API
17
+ * for opening, funding, and closing a Hydra head.
18
+ *
19
+ * As of Hydra v2 (ADR-33) opening a head no longer requires a commit: `Init`
20
+ * opens the head directly with an empty UTxO set. Funds are added afterwards
21
+ * via {@link Wrangler.incrementalCommit} (a deposit into the open head).
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const wrangler = new Wrangler("http://localhost:4001", "ws://localhost:4001");
26
+ * await wrangler.waitForHeadOpen();
27
+ * await wrangler.incrementalCommit({ utxos: [{ txHash: "abc...", outputIndex: 0 }] });
28
+ * ```
29
+ */
30
+ export class Wrangler {
31
+ mode;
32
+ ws;
33
+ http;
34
+ blockfrost;
35
+ monitor;
36
+ constructor(url, wsUrl, monitor) {
37
+ const httpUrl = url || requireEnv('HYDRA_API_URL');
38
+ this.blockfrost = new BlockfrostProvider(requireEnv('BLOCKFROST_API_KEY'));
39
+ this.http = new HydraHttpClient(httpUrl);
40
+ this.monitor = monitor ?? null;
41
+ if (monitor) {
42
+ // Share the monitor's WebSocket — no new connections
43
+ this.ws = monitor.ws;
44
+ }
45
+ else {
46
+ const socketUrl = wsUrl || requireEnv('HYDRA_WS_URL');
47
+ this.ws = new HydraWebSocket(socketUrl);
48
+ }
49
+ }
50
+ /**
51
+ * Connect to the Hydra node with exponential-backoff retry.
52
+ *
53
+ * Uses `HydraWebSocket.waitForGreetings()` which establishes the WebSocket
54
+ * **and** waits for the Hydra `Greetings` handshake.
55
+ *
56
+ * @param maxAttempts - Maximum number of connection attempts (default 5).
57
+ * @param baseDelayMs - Initial retry delay in milliseconds (default 1000). Doubles each attempt, capped at 30 s.
58
+ */
59
+ async connectWithRetry(maxAttempts = 5, baseDelayMs = 1000) {
60
+ // When using a monitor, the WebSocket is already connected
61
+ if (this.monitor?.connected)
62
+ return;
63
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
64
+ try {
65
+ const connected = await this.ws.waitForGreetings();
66
+ if (connected)
67
+ return;
68
+ throw new Error('waitForGreetings() returned false');
69
+ }
70
+ catch (err) {
71
+ if (attempt === maxAttempts - 1) {
72
+ throw new Error(`Failed to connect after ${maxAttempts} attempts: ${String(err)}`);
73
+ }
74
+ const delay = Math.min(baseDelayMs * 2 ** attempt, 30_000);
75
+ await new Promise((r) => setTimeout(r, delay));
76
+ }
77
+ }
78
+ }
79
+ /**
80
+ * Shared helper for promise-based methods that wait for a specific
81
+ * Hydra message. Handles connection, timeout, and settlement in one place.
82
+ *
83
+ * Uses EventEmitter `on`/`removeListener` for multi-listener support,
84
+ * avoiding the single-callback overwrite bug in HydraProvider.
85
+ */
86
+ awaitMessage(handler, timeoutMs, timeoutMessage) {
87
+ return new Promise((resolve, reject) => {
88
+ let settled = false;
89
+ const settle = (fn, value) => {
90
+ if (settled)
91
+ return;
92
+ settled = true;
93
+ clearTimeout(timer);
94
+ this.ws.removeListener('message', onMsg);
95
+ fn(value);
96
+ };
97
+ const timer = setTimeout(() => settle(reject, new Error(timeoutMessage)), timeoutMs);
98
+ const onMsg = (message) => {
99
+ handler(message, (value) => settle(resolve, value), (reason) => settle(reject, reason));
100
+ };
101
+ this.connectWithRetry()
102
+ .then(() => {
103
+ this.ws.on('message', onMsg);
104
+ // Seed the handler with the current head status so steady-state callers
105
+ // resolve/drive immediately. With a long-lived monitor, the cached
106
+ // `lastGreetings` is the ONE Greetings from initial connect (often
107
+ // `Idle`) and is stale across multiple lifecycle calls — use the
108
+ // monitor's live tracked status instead. Without a monitor, replay the
109
+ // Greetings consumed during connect (the node only sends it once).
110
+ if (this.monitor?.connected) {
111
+ onMsg({ tag: 'Greetings', headStatus: this.monitor.headStatusMixed });
112
+ }
113
+ else if (this.ws.lastGreetings) {
114
+ onMsg(this.ws.lastGreetings);
115
+ }
116
+ })
117
+ .catch((err) => settle(reject, new Error(`Failed to connect: ${String(err)}`)));
118
+ });
119
+ }
120
+ /** Connect the underlying WebSocket with retry logic. */
121
+ async connect() {
122
+ return await this.connectWithRetry();
123
+ }
124
+ /** Disconnect the underlying WebSocket. */
125
+ async disconnect(timeout) {
126
+ return this.ws.disconnect(timeout);
127
+ }
128
+ /** Return the current Hydra head status (uppercase). */
129
+ getStatus() {
130
+ return this.ws.getStatus();
131
+ }
132
+ /** Register a callback for head status changes. */
133
+ onStatusChange(callback) {
134
+ return this.ws.onStatusChange(callback);
135
+ }
136
+ /**
137
+ * Begin the head-opening sequence: send `Init` on an `Idle` head and listen
138
+ * for state changes until it reaches `Open`.
139
+ *
140
+ * As of Hydra v2 (ADR-33) opening no longer commits any UTxOs — the head
141
+ * opens empty. Use {@link incrementalCommit} to add funds once it is `Open`.
142
+ */
143
+ async startHead() {
144
+ this.mode = 'start';
145
+ await this.connectWithRetry();
146
+ this.ws.on('message', (msg) => this.handleIncoming(msg));
147
+ }
148
+ /** Begin the head-closing sequence: close, fanout, and finalize. */
149
+ async shutdownHead() {
150
+ this.mode = 'shutdown';
151
+ await this.connectWithRetry();
152
+ this.ws.on('message', (msg) => this.handleIncoming(msg));
153
+ }
154
+ /**
155
+ * Wait for the Hydra head to fully close and finalize.
156
+ *
157
+ * Resolves on the `HeadIsClosed` / `HeadIsFinalized` transition events, **and**
158
+ * on the initial `Greetings` replay if the head is already at `Closed` or
159
+ * `Final`. Rejects fast if the head is `Idle` (no head exists to close).
160
+ *
161
+ * @param timeoutMs - Maximum time to wait in milliseconds.
162
+ */
163
+ async waitForHeadClose(timeoutMs = 180000) {
164
+ this.mode = 'shutdown';
165
+ return this.awaitMessage((message, resolve, reject) => {
166
+ switch (message.tag) {
167
+ // Resolve only once the head is fully finalized (after fanout). On
168
+ // HeadIsClosed we keep waiting; the node will emit ReadyToFanout after
169
+ // the contestation period, which we answer with Fanout.
170
+ case 'HeadIsFinalized':
171
+ resolve();
172
+ break;
173
+ case 'ReadyToFanout':
174
+ this.ws.send({ tag: 'Fanout' });
175
+ break;
176
+ case 'Greetings': {
177
+ const status = message.headStatus;
178
+ if (status === 'Final') {
179
+ resolve();
180
+ return;
181
+ }
182
+ if (status === 'Idle') {
183
+ reject(new Error(`Cannot wait for head to close: head is "Idle" — no head exists to close`));
184
+ return;
185
+ }
186
+ // Open → Close, FanoutPossible → Fanout, Closed → wait for ReadyToFanout.
187
+ this.onGreetings(status).catch((err) => reject(new Error(`Greetings handler failed: ${String(err)}`)));
188
+ break;
189
+ }
190
+ }
191
+ }, timeoutMs, 'Timeout waiting for head to close!');
192
+ }
193
+ /**
194
+ * Open the Hydra head and wait for it to reach the `Open` state.
195
+ *
196
+ * As of Hydra v2 (ADR-33) the head opens directly with an empty UTxO set:
197
+ * on an `Idle` head this sends `Init` and resolves once the node reports
198
+ * `HeadIsOpen`. Funds are added afterwards via {@link incrementalCommit}.
199
+ *
200
+ * Resolves on the `HeadIsOpen` transition event, **and** on the initial
201
+ * `Greetings` replay if the head is already `Open`. Rejects fast if the
202
+ * head is in a terminal or shutting-down state (`Closed`, `FanoutPossible`,
203
+ * `Final`) — a new head must be started from a fresh node.
204
+ *
205
+ * @param timeoutMs - Maximum time to wait in milliseconds.
206
+ */
207
+ async waitForHeadOpen(timeoutMs = 180000) {
208
+ this.mode = 'start';
209
+ return this.awaitMessage((message, resolve, reject) => {
210
+ if (message.tag === 'HeadIsOpen') {
211
+ resolve();
212
+ }
213
+ else if (message.tag === 'Greetings') {
214
+ const status = message.headStatus;
215
+ if (status === 'Open') {
216
+ resolve();
217
+ return;
218
+ }
219
+ if (status === 'Closed' || status === 'FanoutPossible' || status === 'Final') {
220
+ reject(new Error(`Cannot wait for head to open: head is "${status}" (terminal or shutting down)`));
221
+ return;
222
+ }
223
+ this.onGreetings(status).catch((err) => reject(new Error(`Greetings handler failed: ${String(err)}`)));
224
+ }
225
+ }, timeoutMs, 'Timeout waiting for head to open');
226
+ }
227
+ /**
228
+ * Query the current Hydra head status via a `Greetings` message.
229
+ * @param timeoutMs - Maximum time to wait for the status response.
230
+ * @returns The head status string (e.g. `"Idle"`, `"Open"`, `"Closed"`).
231
+ */
232
+ async getHeadStatus(timeoutMs = 5000) {
233
+ // When using a monitor, read the cached status directly — no new WebSocket
234
+ if (this.monitor?.connected) {
235
+ return HYDRA_TO_HEAD_STATUS[this.monitor.headStatus];
236
+ }
237
+ return this.awaitMessage((message, resolve, _reject) => {
238
+ if (message.tag === 'Greetings') {
239
+ resolve(message.headStatus);
240
+ }
241
+ }, timeoutMs, 'Timeout waiting for head status');
242
+ }
243
+ /**
244
+ * Decommit funds from an open Hydra head back to L1.
245
+ *
246
+ * Posts the decommit transaction via HTTP to avoid overwriting message handlers.
247
+ * Resolves on `DecommitApproved` — L1 settlement happens asynchronously.
248
+ *
249
+ * @param transaction - The decommit transaction (CBOR-encoded).
250
+ * @param timeoutMs - Maximum time to wait for approval (default 60s).
251
+ */
252
+ async decommit(transaction, timeoutMs = 60000) {
253
+ const status = await this.getHeadStatus();
254
+ if (status !== 'Open') {
255
+ throw new Error(`Cannot decommit: head is "${status}", expected "Open"`);
256
+ }
257
+ const result = this.awaitMessage((message, resolve, reject) => {
258
+ if (message.tag === 'DecommitApproved') {
259
+ resolve();
260
+ }
261
+ else if (message.tag === 'DecommitInvalid') {
262
+ reject(new Error(`Decommit invalid: ${JSON.stringify(message.decommitInvalidReason)}`));
263
+ }
264
+ }, timeoutMs, 'Timeout waiting for decommit approval');
265
+ await this.http.publishDecommit(transaction);
266
+ return result;
267
+ }
268
+ /**
269
+ * Incrementally commit funds into an already-open Hydra head.
270
+ *
271
+ * Only single-UTxO commits are supported (MeshJS limitation).
272
+ * The raw L1 transaction is submitted to Blockfrost automatically.
273
+ *
274
+ * Resolves on `CommitFinalized`.
275
+ *
276
+ * @param commitArgs - Single UTxO (with optional blueprint) to commit.
277
+ * @param timeoutMs - Maximum time to wait for finalization (default 120s).
278
+ */
279
+ async incrementalCommit(commitArgs, timeoutMs = 120000) {
280
+ const status = await this.getHeadStatus();
281
+ if (status !== 'Open') {
282
+ throw new Error(`Cannot incrementally commit: head is "${status}", expected "Open"`);
283
+ }
284
+ await this.doIncrementalCommit(commitArgs);
285
+ return this.awaitMessage((message, resolve, _reject) => {
286
+ if (message.tag === 'CommitFinalized') {
287
+ resolve();
288
+ }
289
+ }, timeoutMs, 'Timeout waiting for incremental commit finalization');
290
+ }
291
+ /** Transient L1-submission errors caused by a stale node/chain view post-rollback. */
292
+ static TRANSIENT_SUBMIT = /BadInputsUTxO|ValueNotConserved|StaleUTxO|TxSubmitFail|Bad Request|already in the mempool/i;
293
+ /** Sleep helper (real timers; advanced by fake timers in tests). */
294
+ delay(ms) {
295
+ return new Promise((resolve) => setTimeout(resolve, ms));
296
+ }
297
+ /**
298
+ * Deposit funds into an open head, resilient to L1 rollbacks.
299
+ *
300
+ * Two failure modes are handled:
301
+ *
302
+ * 1. **Stale fee input (transient).** The hydra-node funds the deposit tx's fee
303
+ * from the committer's *other* UTxOs using its chain view. Just after a
304
+ * rollback, that view can reference a reverted tx's outputs, so submission
305
+ * fails with `BadInputsUTxO`/`ValueNotConserved`. We re-draft after a short
306
+ * delay (letting the node re-sync) before giving up on the attempt.
307
+ * 2. **Cancelled increment (rollback).** A deposit's increment is triggered by
308
+ * its on-chain *observation*; a fork-switch can cancel it so the deposit
309
+ * lingers in `GET /commits` and never enters the snapshot. On a finalize
310
+ * timeout we retry with a **fresh** UTxO.
311
+ *
312
+ * Funds are never lost: a stranded deposit is recoverable after its deadline.
313
+ *
314
+ * @param getUtxo - Provides a fresh, unspent small UTxO ref for each attempt.
315
+ * Must return a *different* UTxO each call. A live L1 query satisfies this.
316
+ * @param sign - Signs the drafted deposit tx (CBOR hex) with the UTxO owner's
317
+ * key, returning the signed CBOR hex (e.g. `(tx) => wallet.signTx(tx, true)`).
318
+ * @param opts.maxAttempts - Max deposit attempts with fresh UTxOs (default 3).
319
+ * @param opts.finalizeTimeoutMs - Per-attempt wait for `CommitFinalized` (default 180s).
320
+ * @param opts.submitRetries - Re-draft attempts on a transient submit error (default 3).
321
+ * @param opts.submitRetryDelayMs - Delay before re-drafting, lets the node re-sync (default 8s).
322
+ * @returns The L1 tx id of the deposit that finalized.
323
+ */
324
+ async depositResilient(getUtxo, sign, opts = {}) {
325
+ const maxAttempts = opts.maxAttempts ?? 3;
326
+ const finalizeTimeoutMs = opts.finalizeTimeoutMs ?? 180_000;
327
+ const submitRetries = opts.submitRetries ?? 3;
328
+ const submitRetryDelayMs = opts.submitRetryDelayMs ?? 8_000;
329
+ const status = await this.getHeadStatus();
330
+ if (status !== 'Open') {
331
+ throw new Error(`Cannot deposit: head is "${status}", expected "Open"`);
332
+ }
333
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
334
+ const ref = await getUtxo();
335
+ // Draft → sign → submit, re-drafting on transient stale-input errors.
336
+ let l1TxId;
337
+ for (let sub = 1; sub <= submitRetries; sub++) {
338
+ try {
339
+ l1TxId = await this.submitDeposit(ref, sign);
340
+ break;
341
+ }
342
+ catch (err) {
343
+ if (!Wrangler.TRANSIENT_SUBMIT.test(String(err)) || sub === submitRetries)
344
+ throw err;
345
+ console.warn(`Deposit submit hit a transient stale-input error (try ${sub}/${submitRetries}); ` +
346
+ `re-drafting in ${submitRetryDelayMs}ms…`);
347
+ await this.delay(submitRetryDelayMs);
348
+ }
349
+ }
350
+ // Wait for the increment to finalize. The deposit matures over ~deposit-period,
351
+ // so registering the listener now (just after submit) cannot miss the event.
352
+ try {
353
+ await this.awaitMessage((message, resolve) => {
354
+ if (message.tag === 'CommitFinalized')
355
+ resolve();
356
+ }, finalizeTimeoutMs, 'Timeout waiting for CommitFinalized');
357
+ return l1TxId;
358
+ }
359
+ catch {
360
+ const pending = await this.http.getPendingCommits().catch(() => []);
361
+ if (attempt === maxAttempts) {
362
+ throw new Error(`Deposit failed to finalize after ${maxAttempts} attempts` +
363
+ (pending.length ? ` (stranded, recoverable after deadline: ${pending.join(', ')})` : ''));
364
+ }
365
+ console.warn(`Deposit attempt ${attempt}/${maxAttempts} did not finalize ` +
366
+ `(pending deposits: ${pending.length}). Retrying with a fresh UTxO…`);
367
+ }
368
+ }
369
+ // Unreachable, but satisfies the type checker.
370
+ throw new Error('Deposit failed to finalize');
371
+ }
372
+ /** Draft a deposit for `ref` via `POST /commit`, sign it, and submit to L1. */
373
+ async submitDeposit(ref, sign) {
374
+ const { txHash, outputIndex } = ref;
375
+ const utxos = await this.blockfrost.fetchUTxOs(txHash, outputIndex);
376
+ if (!utxos[0])
377
+ throw new Error(`UTxO not found: ${txHash}#${outputIndex}`);
378
+ const cborHex = await this.http.buildCommit({ [`${txHash}#${outputIndex}`]: toHydraUTxO(utxos[0]) });
379
+ const signed = await sign(cborHex);
380
+ return this.blockfrost.submitTx(signed);
381
+ }
382
+ async doIncrementalCommit(commitArgs) {
383
+ if (commitArgs.utxos.length !== 1) {
384
+ throw new Error('Incremental commit requires exactly one UTxO');
385
+ }
386
+ const { txHash, outputIndex } = commitArgs.utxos[0];
387
+ const utxos = await this.blockfrost.fetchUTxOs(txHash, outputIndex);
388
+ if (!utxos[0])
389
+ throw new Error('UTxO not found');
390
+ const hydraUtxo = toHydraUTxO(utxos[0]);
391
+ let cborHex;
392
+ if (commitArgs.blueprintTx) {
393
+ cborHex = await this.http.buildCommit({
394
+ blueprintTx: commitArgs.blueprintTx,
395
+ utxo: { [`${txHash}#${outputIndex}`]: hydraUtxo },
396
+ });
397
+ }
398
+ else {
399
+ cborHex = await this.http.buildCommit({ [`${txHash}#${outputIndex}`]: hydraUtxo });
400
+ }
401
+ return await this.blockfrost.submitTx(cborHex);
402
+ }
403
+ async handleIncoming(message) {
404
+ if (message.tag === 'Greetings') {
405
+ await this.onGreetings(message.headStatus);
406
+ }
407
+ else {
408
+ switch (this.mode) {
409
+ case 'start':
410
+ if (message.tag === 'HeadIsOpen') {
411
+ // Head opened directly (ADR-33) — nothing left to drive.
412
+ }
413
+ break;
414
+ case 'shutdown':
415
+ if (message.tag === 'ReadyToFanout') {
416
+ this.ws.send({ tag: 'Fanout' });
417
+ }
418
+ break;
419
+ }
420
+ }
421
+ }
422
+ async onGreetings(status) {
423
+ switch (this.mode) {
424
+ case 'start':
425
+ switch (status) {
426
+ case 'Idle':
427
+ console.log('Idle → init()');
428
+ this.ws.send({ tag: 'Init' });
429
+ break;
430
+ case 'Open':
431
+ console.log('Open → already ready, proceeding');
432
+ break;
433
+ default:
434
+ console.log(`Greetings in start mode, ignoring status: ${status}`);
435
+ }
436
+ break;
437
+ case 'shutdown':
438
+ switch (status) {
439
+ case 'Open':
440
+ console.log('Shutting down: closing head…');
441
+ this.ws.send({ tag: 'Close' });
442
+ break;
443
+ case 'FanoutPossible':
444
+ console.log('Fanout now possible: fanning out…');
445
+ this.ws.send({ tag: 'Fanout' });
446
+ break;
447
+ default:
448
+ console.log(`Greetings in shutdown mode, ignoring status: ${status}`);
449
+ }
450
+ }
451
+ }
452
+ }
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "@lerna-labs/hydra-sdk",
3
- "version": "1.0.0-beta.9",
3
+ "version": "2.0.0-beta.1",
4
+ "description": "TypeScript SDK for managing Cardano Hydra Heads — lifecycle, UTxO queries, wallet management, transaction submission, and signature verification",
5
+ "keywords": [
6
+ "cardano",
7
+ "hydra",
8
+ "l2",
9
+ "layer2",
10
+ "blockchain",
11
+ "utxo",
12
+ "sdk",
13
+ "typescript"
14
+ ],
4
15
  "private": false,
5
16
  "type": "module",
6
17
  "main": "./dist/index.js",
@@ -27,15 +38,23 @@
27
38
  "engines": {
28
39
  "node": ">=18"
29
40
  },
30
- "dependencies": {},
31
- "devDependencies": {},
32
- "homepage": "https://github.com/Lerna-Labs/hydra-sdk#readme",
41
+ "dependencies": {
42
+ "@emurgo/cardano-message-signing-nodejs": "^1.1.0",
43
+ "@emurgo/cardano-serialization-lib-nodejs": "^15.0.3",
44
+ "@meshsdk/core": "1.9.0-beta.99",
45
+ "@meshsdk/core-cst": "1.9.0-beta.99",
46
+ "ws": "^8.18.3"
47
+ },
48
+ "devDependencies": {
49
+ "@types/ws": "^8.18.1"
50
+ },
51
+ "homepage": "https://github.com/lerna-labs/hydra-sdk#readme",
33
52
  "bugs": {
34
- "url": "https://github.com/Lerna-Labs/hydra-sdk/issues"
53
+ "url": "https://github.com/lerna-labs/hydra-sdk/issues"
35
54
  },
36
55
  "repository": {
37
56
  "type": "git",
38
- "url": "https://github.com/Lerna-Labs/hydra-sdk",
57
+ "url": "https://github.com/lerna-labs/hydra-sdk",
39
58
  "directory": "packages/core"
40
59
  }
41
60
  }