@midnightntwrk/wallet-sdk-node-client 2.0.0-rc.0 → 2.0.0-rc.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.
@@ -1,18 +1,148 @@
1
- import { Context, Effect, Stream } from 'effect';
1
+ import { Context, Effect, Option, Stream } from 'effect';
2
2
  import * as SubmissionEvent from './SubmissionEvent.js';
3
3
  import * as NodeClientError from './NodeClientError.js';
4
4
  import { type SerializedTransaction } from '@midnightntwrk/wallet-sdk-abstractions';
5
5
  export type Genesis = {
6
6
  readonly transactions: readonly SerializedTransaction.SerializedTransaction[];
7
7
  };
8
+ /**
9
+ * The node's highest block that GRANDPA has finalized.
10
+ *
11
+ * @remarks
12
+ * This is the reference a wallet compares an indexer's self-reported position against. It comes from consensus rather
13
+ * than from the indexer, which is what makes the comparison meaningful.
14
+ */
15
+ export type FinalizedBlock = {
16
+ /** The hash of the finalized block, hex-encoded. */
17
+ readonly hash: string;
18
+ /** The height of the finalized block. */
19
+ readonly height: bigint;
20
+ };
8
21
  export interface Service {
9
22
  sendMidnightTransaction(serializedTransaction: SerializedTransaction.SerializedTransaction): Stream.Stream<SubmissionEvent.SubmissionEvent, NodeClientError.NodeClientError>;
10
23
  getGenesis(): Effect.Effect<Genesis, NodeClientError.NodeClientError>;
24
+ /**
25
+ * Reads the node's highest finalized block.
26
+ *
27
+ * @remarks
28
+ * Finalized, not best: the wallet's liveness check compares an indexer's self-reported position against this value,
29
+ * and the indexer ingests finalized blocks only. An implementation that answered with the best (latest, possibly
30
+ * reverted) head would report a healthy indexer as behind by the finality gap on every poll. The default
31
+ * implementation reads the finalized head's hash and then the header at that hash, so the two fields describe the
32
+ * same block.
33
+ *
34
+ * Must be safe to call while a `sendMidnightTransaction` is in flight on the same instance: the liveness check polls
35
+ * on a timer and takes no lock. The default implementation reference-counts its shared connection and disconnects
36
+ * only when the last in-flight call finishes, so a read completing never drops a submission's status subscription.
37
+ *
38
+ * An unreachable node must surface as a typed `NodeClientError`, not a defect: the liveness check turns that failure
39
+ * into an `Unavailable` verdict, and a defect would kill its poll instead.
40
+ * @example
41
+ * ```ts
42
+ * const { hash, height } = yield* client.getFinalizedBlock();
43
+ * ```;
44
+ *
45
+ * @returns An effect yielding the hex-encoded hash and the height of the highest block GRANDPA has finalized.
46
+ */
47
+ getFinalizedBlock(): Effect.Effect<FinalizedBlock, NodeClientError.NodeClientError>;
48
+ /**
49
+ * Reads the hash of the block at a given height.
50
+ *
51
+ * @remarks
52
+ * This is what makes the wallet's liveness check a comparison of blocks rather than of numbers. A height is a value
53
+ * the indexer chooses, so an indexer reporting one it never reached passes a height-only comparison at no cost; the
54
+ * block at that height is the part it cannot invent. The check reads one block here per poll — the newest block
55
+ * both endpoints claim to have passed — and compares it against what the indexer names there.
56
+ *
57
+ * `height` is at or below the node's own finalized head, so an implementation may answer from finalized blocks only.
58
+ * A canonical chain has a block at every such height, which is what makes absence meaningful: it says this node
59
+ * does not have the block the indexer is claiming, not that the read went wrong. Absence is therefore `Option.none`
60
+ * rather than a failure — to the liveness check one is a wrong-network proof and the other an outage.
61
+ *
62
+ * An unreachable node must surface as a typed `NodeClientError`, not a defect, for the same reason as
63
+ * {@link Service.getFinalizedBlock}: the check turns that failure into an `Unavailable` verdict, and a defect would
64
+ * kill its poll instead.
65
+ * @example
66
+ * ```ts
67
+ * const hash = yield* client.getBlockHashAt(1_000n);
68
+ * // Option.some('0x1234…'), or Option.none() when this node has no block at that height
69
+ * ```;
70
+ *
71
+ * @param height - The height to read, at or below the node's finalized head.
72
+ * @returns An effect yielding the block's hex-encoded hash, or `Option.none` when the node has no block at that
73
+ * height.
74
+ */
75
+ getBlockHashAt(height: bigint): Effect.Effect<Option.Option<string>, NodeClientError.NodeClientError>;
76
+ /**
77
+ * Reads the hash of the node's genesis block.
78
+ *
79
+ * @remarks
80
+ * Identifies the chain the node is on. The wallet compares it, once, against the indexer's block at height zero, and
81
+ * pins a `WrongNetwork` verdict when they differ — so this must be the hash of block zero, not of any later
82
+ * checkpoint. The comparison is by bytes, tolerant of a `0x` prefix and of case, but the convention on this
83
+ * interface is a lowercase, `0x`-prefixed hex string, which is what the default implementation returns.
84
+ *
85
+ * Should not require a live connection. The default implementation answers from state the api captured when it was
86
+ * created, so the check can establish the chain identity even while the node is temporarily unreachable.
87
+ * @example
88
+ * ```ts
89
+ * const genesisHash = yield* client.getGenesisHash();
90
+ * // '0x1234…' — compare with IndexerLiveness.sameBlockHash(indexerHash, genesisHash)
91
+ * ```;
92
+ *
93
+ * @returns An effect yielding the genesis-block hash as a `0x`-prefixed hex string.
94
+ */
95
+ getGenesisHash(): Effect.Effect<string, NodeClientError.NodeClientError>;
11
96
  }
12
97
  declare const NodeClient_base: Context.TagClass<NodeClient, "@midnight-ntwrk/wallet-node-client#NodeClient", Service>;
13
98
  export declare class NodeClient extends NodeClient_base {
14
99
  }
15
100
  export declare const getGenesisTransactions: () => Effect.Effect<Genesis, NodeClientError.NodeClientError, NodeClient>;
101
+ /**
102
+ * Reads the node's highest finalized block.
103
+ *
104
+ * @remarks
105
+ * Safe to interleave with other calls on the same service instance: the default implementation reference-counts its
106
+ * shared connection and disconnects only when the last in-flight call finishes, so a read completing never drops an
107
+ * in-flight `sendMidnightTransaction`'s status subscription.
108
+ * @example
109
+ * ```ts
110
+ * const finalized = yield* NodeClient.getFinalizedBlock();
111
+ * ```;
112
+ *
113
+ * @returns An effect yielding the hash and height of the highest block GRANDPA has finalized.
114
+ */
115
+ export declare const getFinalizedBlock: () => Effect.Effect<FinalizedBlock, NodeClientError.NodeClientError, NodeClient>;
116
+ /**
117
+ * Reads the hash of the block at a given height.
118
+ *
119
+ * @remarks
120
+ * The wallet's liveness check reads one block per poll through this, to compare the block the indexer names at that
121
+ * height rather than only the height itself. Absence is `Option.none` rather than a failure: a node with no block at
122
+ * a height below its own finalized head is answering that it does not have the block being claimed.
123
+ * @example
124
+ * ```ts
125
+ * const hash = yield* NodeClient.getBlockHashAt(1_000n);
126
+ * ```;
127
+ *
128
+ * @param height - The height to read, at or below the node's finalized head.
129
+ * @returns An effect yielding the block's hex-encoded hash, or `Option.none` when the node has no block at that height.
130
+ */
131
+ export declare const getBlockHashAt: (height: bigint) => Effect.Effect<Option.Option<string>, NodeClientError.NodeClientError, NodeClient>;
132
+ /**
133
+ * Reads the hash of the node's genesis block.
134
+ *
135
+ * @remarks
136
+ * Identifies the chain the node is on: two endpoints reporting different genesis hashes are on different networks. The
137
+ * default implementation answers from state the client already holds, without opening a connection.
138
+ * @example
139
+ * ```ts
140
+ * const genesisHash = yield* NodeClient.getGenesisHash();
141
+ * ```;
142
+ *
143
+ * @returns An effect yielding the genesis-block hash as a `0x`-prefixed hex string.
144
+ */
145
+ export declare const getGenesisHash: () => Effect.Effect<string, NodeClientError.NodeClientError, NodeClient>;
16
146
  export declare const sendMidnightTransaction: (serializedTransaction: SerializedTransaction.SerializedTransaction) => Stream.Stream<SubmissionEvent.SubmissionEvent, NodeClientError.NodeClientError, NodeClient>;
17
147
  export declare function sendMidnightTransactionAndWait(serializedTransaction: SerializedTransaction.SerializedTransaction, waitFor: SubmissionEvent.Cases.Submitted['_tag']): Effect.Effect<SubmissionEvent.Cases.Submitted, NodeClientError.NodeClientError, NodeClient>;
18
148
  export declare function sendMidnightTransactionAndWait(serializedTransaction: SerializedTransaction.SerializedTransaction, waitFor: SubmissionEvent.Cases.InBlock['_tag']): Effect.Effect<SubmissionEvent.Cases.InBlock, NodeClientError.NodeClientError, NodeClient>;
@@ -16,6 +16,51 @@ import * as NodeClientError from './NodeClientError.js';
16
16
  export class NodeClient extends Context.Tag('@midnight-ntwrk/wallet-node-client#NodeClient')() {
17
17
  }
18
18
  export const getGenesisTransactions = () => NodeClient.pipe(Effect.flatMap((client) => client.getGenesis()));
19
+ /**
20
+ * Reads the node's highest finalized block.
21
+ *
22
+ * @remarks
23
+ * Safe to interleave with other calls on the same service instance: the default implementation reference-counts its
24
+ * shared connection and disconnects only when the last in-flight call finishes, so a read completing never drops an
25
+ * in-flight `sendMidnightTransaction`'s status subscription.
26
+ * @example
27
+ * ```ts
28
+ * const finalized = yield* NodeClient.getFinalizedBlock();
29
+ * ```;
30
+ *
31
+ * @returns An effect yielding the hash and height of the highest block GRANDPA has finalized.
32
+ */
33
+ export const getFinalizedBlock = () => NodeClient.pipe(Effect.flatMap((client) => client.getFinalizedBlock()));
34
+ /**
35
+ * Reads the hash of the block at a given height.
36
+ *
37
+ * @remarks
38
+ * The wallet's liveness check reads one block per poll through this, to compare the block the indexer names at that
39
+ * height rather than only the height itself. Absence is `Option.none` rather than a failure: a node with no block at
40
+ * a height below its own finalized head is answering that it does not have the block being claimed.
41
+ * @example
42
+ * ```ts
43
+ * const hash = yield* NodeClient.getBlockHashAt(1_000n);
44
+ * ```;
45
+ *
46
+ * @param height - The height to read, at or below the node's finalized head.
47
+ * @returns An effect yielding the block's hex-encoded hash, or `Option.none` when the node has no block at that height.
48
+ */
49
+ export const getBlockHashAt = (height) => NodeClient.pipe(Effect.flatMap((client) => client.getBlockHashAt(height)));
50
+ /**
51
+ * Reads the hash of the node's genesis block.
52
+ *
53
+ * @remarks
54
+ * Identifies the chain the node is on: two endpoints reporting different genesis hashes are on different networks. The
55
+ * default implementation answers from state the client already holds, without opening a connection.
56
+ * @example
57
+ * ```ts
58
+ * const genesisHash = yield* NodeClient.getGenesisHash();
59
+ * ```;
60
+ *
61
+ * @returns An effect yielding the genesis-block hash as a `0x`-prefixed hex string.
62
+ */
63
+ export const getGenesisHash = () => NodeClient.pipe(Effect.flatMap((client) => client.getGenesisHash()));
19
64
  export const sendMidnightTransaction = (serializedTransaction) => NodeClient.pipe(Stream.fromEffect, Stream.flatMap((client) => client.sendMidnightTransaction(serializedTransaction)));
20
65
  export function sendMidnightTransactionAndWait(serializedTransaction, waitFor) {
21
66
  return sendMidnightTransaction(serializedTransaction).pipe(Stream.find(SubmissionEvent.is(waitFor)), Stream.runHead, Effect.flatMap(Option.match({
@@ -1,6 +1,6 @@
1
1
  import '../gen/augment-api.js';
2
2
  import { ApiPromise } from '@polkadot/api';
3
- import { Duration, Effect, Layer, type Scope, Stream } from 'effect';
3
+ import { Duration, Effect, Layer, Option, type Scope, Stream, SynchronizedRef } from 'effect';
4
4
  import * as NodeClient from './NodeClient.js';
5
5
  import * as SubmissionEvent from './SubmissionEvent.js';
6
6
  import * as NodeClientError from './NodeClientError.js';
@@ -21,10 +21,13 @@ export declare class PolkadotNodeClient implements NodeClient.Service {
21
21
  static layer(configInput: Partial<Config> & Pick<Config, 'nodeURL'>): Layer.Layer<NodeClient.NodeClient, NodeClientError.NodeClientError, Scope.Scope>;
22
22
  readonly config: Config;
23
23
  readonly api: ApiPromise;
24
- constructor(config: Config, api: ApiPromise);
24
+ constructor(config: Config, api: ApiPromise, activeCalls: SynchronizedRef.SynchronizedRef<number>);
25
25
  ensureConnection(): Effect.Effect<void, NodeClientError.NodeClientError>;
26
26
  sendMidnightTransaction(serializedTransaction: SerializedTransaction.SerializedTransaction): Stream.Stream<SubmissionEvent.SubmissionEvent, NodeClientError.NodeClientError>;
27
27
  getGenesis(): Effect.Effect<{
28
28
  readonly transactions: readonly SerializedTransaction.SerializedTransaction[];
29
29
  }, NodeClientError.NodeClientError>;
30
+ getGenesisHash(): Effect.Effect<string, NodeClientError.NodeClientError>;
31
+ getFinalizedBlock(): Effect.Effect<NodeClient.FinalizedBlock, NodeClientError.NodeClientError>;
32
+ getBlockHashAt(height: bigint): Effect.Effect<Option.Option<string>, NodeClientError.NodeClientError>;
30
33
  }
@@ -12,13 +12,78 @@
12
12
  // limitations under the License.
13
13
  import '../gen/augment-api.js';
14
14
  import { ApiPromise, WsProvider } from '@polkadot/api';
15
- import { Duration, Effect, Either, Layer, pipe, Schedule, Schema, Stream, } from 'effect';
15
+ import { Duration, Effect, Either, Layer, Option, pipe, Ref, Schedule, Schema, Stream, SynchronizedRef, } from 'effect';
16
16
  import * as NodeClient from './NodeClient.js';
17
17
  import * as SubmissionEvent from './SubmissionEvent.js';
18
18
  import * as NodeClientError from './NodeClientError.js';
19
19
  import BN from 'bn.js';
20
20
  import { u8aToHex } from '@polkadot/util';
21
21
  import { SerializedTransaction } from '@midnightntwrk/wallet-sdk-abstractions';
22
+ /**
23
+ * How many consecutive readiness probes may fail on a connected socket before the failure is surfaced.
24
+ *
25
+ * @remarks
26
+ * Small, because each failure on a connected socket already says the node is answering the transport but not RPC — more
27
+ * retries only delay the verdict. Three tolerates a probe racing a reconnect that has not finished re-initialising,
28
+ * without letting a genuinely broken node hide behind an unbounded retry loop.
29
+ */
30
+ const MAX_CONNECTED_PROBE_FAILURES = 3;
31
+ /**
32
+ * Clamps a duration to the largest delay `setTimeout` honours.
33
+ *
34
+ * @remarks
35
+ * A delay of 2^31 ms or more overflows and fires after about a millisecond, which turned a generous bound such as 30
36
+ * days into an instant failure on every connection attempt. A bound that large behaves as "still waiting" on any
37
+ * human timescale, so the clamp loses nothing.
38
+ */
39
+ const toTimerMillis = (duration) => Math.min(Duration.toMillis(duration), 2 ** 31 - 1);
40
+ /**
41
+ * Recognises the hash the chain RPC answers with for a height it has no block at.
42
+ *
43
+ * @remarks
44
+ * It is a well-formed hash of all zeroes rather than an error, so only its value distinguishes "no such block" from a
45
+ * real answer. The `0x` prefix is optional here because nothing guarantees which presentation a given codec returns.
46
+ */
47
+ const isEmptyHash = (hash) => /^(0x)?0*$/.test(hash);
48
+ /**
49
+ * Disconnects the api and waits until its socket has actually closed.
50
+ *
51
+ * @remarks
52
+ * `WsProvider.disconnect()` is fire-and-forget: it dispatches the close frame and returns while the socket is still
53
+ * CLOSING, and `isConnected` only flips false once the close event fires. A caller that returns without waiting
54
+ * leaves the next `ensureConnection()` reading a stale `true`: it skips the reconnect, drops its readiness probe on
55
+ * the dying socket, sleeps `reconnectionDelay`, reconnects, and usually fails one more pre-open probe — seconds lost
56
+ * on every call. Locally the close-ack lands fast enough to hide this; against a remote node it does not. Both places
57
+ * that release the socket — the build in `make()` and the last call's `#deregister` — wait here, so the two cannot
58
+ * drift.
59
+ *
60
+ * The wait shares the caller's bound. A node that completes the handshake and then goes half-open never acknowledges
61
+ * the close frame, and only ws's own 30-second close timeout would end the wait — from inside an uninterruptible
62
+ * acquire, in `make()`'s case, which no outer deadline can cut short. A finite bound is therefore honoured here too:
63
+ * the wait is abandoned once it elapses, the socket is left CLOSING for ws to finish, and a stale `isConnected` costs
64
+ * one failed readiness probe. An infinite bound waits for the event, as an unbounded caller expects.
65
+ * @param api - The api whose socket to release.
66
+ * @param bound - How long to wait for the close to be acknowledged; `Duration.infinity` waits indefinitely.
67
+ * @returns A promise that settles once the socket has closed or the bound has elapsed.
68
+ */
69
+ const disconnectAndAwaitClose = (api, bound) => new Promise((resolve) => {
70
+ if (!api.isConnected) {
71
+ resolve();
72
+ return;
73
+ }
74
+ const closeTimer = {};
75
+ // The handler stays registered if the bound wins: firing later, it resolves a settled promise and clears a fired
76
+ // timer, both no-ops. `once` returns the api, not an unsubscribe, so there is nothing cheaper to do.
77
+ api.once('disconnected', () => {
78
+ if (closeTimer.handle !== undefined)
79
+ clearTimeout(closeTimer.handle);
80
+ resolve();
81
+ });
82
+ if (Duration.isFinite(bound)) {
83
+ closeTimer.handle = setTimeout(resolve, toTimerMillis(bound));
84
+ }
85
+ void api.disconnect();
86
+ });
22
87
  export const DEFAULT_CONFIG = {
23
88
  reconnectionTimeout: Duration.infinity,
24
89
  reconnectionDelay: Duration.seconds(1),
@@ -30,84 +95,144 @@ export const makeConfig = (input) => ({
30
95
  export class PolkadotNodeClient {
31
96
  static make(configInput) {
32
97
  const config = makeConfig(configInput);
33
- return Effect.acquireRelease(Effect.promise(async () => {
34
- const api = await ApiPromise.create({
98
+ // A finite `reconnectionTimeout` is a caller asking to be told when the node cannot be reached. Honouring it here as
99
+ // well as in `ensureConnection` is what makes that possible: left to its defaults, `WsProvider` retries on a timer
100
+ // and `ApiPromise.create` waits for a connection that may never arrive.
101
+ const isBounded = Duration.isFinite(config.reconnectionTimeout);
102
+ // The bound is enforced inside the promise rather than with `Effect.timeout`, because `Effect.acquireRelease` runs
103
+ // its acquire uninterruptibly — deliberately, so a resource cannot be acquired and then leaked — and an
104
+ // uninterruptible region ignores an outer timeout. Racing here also lets the half-open provider be closed, which an
105
+ // interruption could not do.
106
+ const connect = Effect.tryPromise(async () => {
107
+ // `autoConnectMs` keeps its default deliberately. Passing `false` does not mean "connect once without retrying" —
108
+ // it means "do not connect at all", leaving `ApiPromise.create` waiting on a connection nobody started. The bound
109
+ // below is what limits the wait; the provider's own retry behaviour is left alone.
110
+ const provider = new WsProvider(config.nodeURL.toString());
111
+ const created = ApiPromise.create({
35
112
  // @ts-expect-error -- exactOptionalPropertyTypes cause an incompatibility here
36
- provider: new WsProvider(config.nodeURL.toString()),
113
+ provider,
114
+ // Off for bounded callers too. With it on, `create` returns `isReadyOrError`, which rejects on the provider's
115
+ // first `error` event — any socket error, such as a node restarting — although the provider would have retried
116
+ // and connected moments later. A bounded caller then failed within milliseconds and never used the window it
117
+ // asked for. The race below is the bound; the provider's retries fill the window, as `ensureConnection`'s do.
37
118
  throwOnConnect: false,
38
119
  noInitWarn: true,
39
120
  });
121
+ const timeoutMillis = toTimerMillis(config.reconnectionTimeout);
122
+ // Held so the timer can be cleared once the race settles. Left armed, it keeps Node's event loop alive, so a
123
+ // short-lived process that reads once cannot exit until it fires.
124
+ const timer = {};
125
+ const api = isBounded
126
+ ? await Promise.race([
127
+ created,
128
+ new Promise((_resolve, reject) => {
129
+ timer.handle = setTimeout(() => reject(new Error(`Timed out after ${timeoutMillis}ms`)), timeoutMillis);
130
+ }),
131
+ ])
132
+ .catch(async (error) => {
133
+ // Without this the provider keeps retrying on its timer for the lifetime of the process.
134
+ await provider.disconnect().catch(() => undefined);
135
+ throw error;
136
+ })
137
+ .finally(() => {
138
+ if (timer.handle !== undefined)
139
+ clearTimeout(timer.handle);
140
+ })
141
+ : await created;
40
142
  // Disconnect immediately after loading metadata to avoid keeping the WebSocket open.
41
143
  // The health-check timer (10s interval) and timeout handler (5s interval) are cleared on disconnect.
42
- // Metadata and type registry remain cached in memory for subsequent on-demand connections.
43
- //
44
- // WsProvider.disconnect() is fire-and-forget: it dispatches the close frame and returns while the socket is
45
- // still CLOSING. `isConnected` only flips false once #onSocketClose fires, so returning here without waiting
46
- // leaves ensureConnection() reading a stale `true`, skipping the reconnect, and sending on a dying socket.
47
- // Locally the close-ack lands fast enough to hide this; against a remote node it does not.
48
- await new Promise((resolve) => {
49
- if (!api.isConnected) {
50
- resolve();
51
- return;
52
- }
53
- api.once('disconnected', () => resolve());
54
- void api.disconnect();
55
- });
144
+ // Metadata and type registry remain cached in memory for subsequent on-demand connections. The wait for the
145
+ // close, and its bound, are explained on `disconnectAndAwaitClose`.
146
+ await disconnectAndAwaitClose(api, config.reconnectionTimeout);
56
147
  return api;
57
- }), (api) => Effect.promise(() => api.disconnect())).pipe(Effect.map((api) => new PolkadotNodeClient(config, api)));
148
+ });
149
+ return Effect.acquireRelease(
150
+ // `tryPromise` rather than `promise`: a rejected connection has to reach the error channel this method already
151
+ // declares, instead of arriving as a defect that no `catchTag` can handle.
152
+ connect.pipe(Effect.mapError((cause) => new NodeClientError.ConnectionError({
153
+ message: `Could not connect to ${config.nodeURL.toString()}`,
154
+ cause,
155
+ }))), (api) => Effect.promise(() => api.disconnect())).pipe(Effect.flatMap((api) => SynchronizedRef.make(0).pipe(Effect.map((activeCalls) => new PolkadotNodeClient(config, api, activeCalls)))));
58
156
  }
59
157
  static layer(configInput) {
60
158
  return Layer.scoped(NodeClient.NodeClient, PolkadotNodeClient.make(configInput));
61
159
  }
62
160
  config;
63
161
  api;
64
- /** Operations currently holding the shared connection open. */
65
- #activeOperations = 0;
66
- constructor(config, api) {
162
+ /**
163
+ * How many calls are in flight on this instance's shared `api`.
164
+ *
165
+ * @remarks
166
+ * Every method used to end with an unconditional disconnect of the one shared socket, so interleaving any two calls
167
+ * on the same instance let whichever finished first tear the socket down under the other — an in-flight
168
+ * submission's status subscription being the costly case. The count makes the disconnect conditional: each call
169
+ * registers before it connects and deregisters when it finishes, and only the last one out releases the socket.
170
+ * `SynchronizedRef` serialises the transitions, so a call arriving while the last one is disconnecting waits, then
171
+ * reconnects through `ensureConnection`.
172
+ */
173
+ #activeCalls;
174
+ constructor(config, api, activeCalls) {
67
175
  this.config = config;
68
176
  this.api = api;
177
+ this.#activeCalls = activeCalls;
69
178
  }
70
- ensureConnection() {
71
- return pipe(Effect.promise(async () => {
72
- if (!this.api.isConnected) {
73
- try {
74
- await this.api.connect();
75
- }
76
- catch (error) {
77
- // WsProvider.connect() rejects if a WebSocket already exists (connection in progress).
78
- // This is expected when the repeat loop re-enters before the 'open' event fires.
79
- if (!(error instanceof Error && error.message === 'WebSocket is already connected')) {
80
- throw error;
81
- }
82
- }
83
- }
84
- }), Effect.andThen(Effect.sync(() => this.api.isConnected)), Effect.repeat({
85
- until: (value) => value,
86
- schedule: Schedule.spaced(this.config.reconnectionDelay),
87
- }), Effect.timeout(this.config.reconnectionTimeout), Effect.asVoid, Effect.mapError((timeout) => new NodeClientError.ConnectionError({
88
- message: `Could not establish a usable connection within ${Duration.format(this.config.reconnectionTimeout)}`,
89
- cause: timeout,
90
- })));
91
- }
92
- /** Takes a hold on the shared connection for the duration of one operation. */
93
- #acquire() {
94
- return pipe(this.ensureConnection(), Effect.tap(() => Effect.sync(() => {
95
- this.#activeOperations += 1;
96
- })));
179
+ /** Registers one call on the shared connection. Must be balanced by {@link PolkadotNodeClient.#deregister}. */
180
+ #register() {
181
+ return SynchronizedRef.update(this.#activeCalls, (active) => active + 1);
97
182
  }
98
183
  /**
99
- * Drops one hold, disconnecting only once the last operation finishes.
184
+ * Deregisters one call, disconnecting the shared socket when it was the last one in flight.
100
185
  *
101
- * The api instance is shared, so an unconditional `disconnect()` in a per-operation finalizer closes the transport
102
- * out from under any operation still in flight.
186
+ * @remarks
187
+ * Waits for the close to be acknowledged, as `make()` does — see `disconnectAndAwaitClose`. Returning on
188
+ * `disconnect()` alone left the next call a stale `isConnected: true` and cost it a dropped probe and a reconnect
189
+ * delay.
103
190
  */
104
- #release() {
105
- return Effect.promise(async () => {
106
- this.#activeOperations = Math.max(0, this.#activeOperations - 1);
107
- if (this.#activeOperations === 0) {
108
- await this.api.disconnect();
191
+ #deregister() {
192
+ return SynchronizedRef.updateEffect(this.#activeCalls, (active) => active === 1
193
+ ? Effect.promise(() => disconnectAndAwaitClose(this.api, this.config.reconnectionTimeout)).pipe(Effect.as(0))
194
+ : Effect.succeed(active - 1));
195
+ }
196
+ ensureConnection() {
197
+ // The counter distinguishes "the node is not there yet" from "the node is there and broken". Failures while the
198
+ // socket is down retry without limit — that is the unbounded caller's contract, and what submission relies on.
199
+ // Failures while the socket reports connected are a verdict about the node, and surfacing them restores the loud
200
+ // failure this method's probe had silently absorbed: before the probe existed, such a node failed on the first
201
+ // real call; with the probe swallowing every error, it span the retry loop forever under the default (infinite)
202
+ // reconnectionTimeout.
203
+ return Ref.make(0).pipe(Effect.flatMap((connectedProbeFailures) => pipe(
204
+ // `tryPromise` + swallow, not `Effect.promise`: a rejected connect() inside `Effect.promise` is a defect that
205
+ // bypasses the typed ConnectionError mapping below and kills the caller's fibre as a crash. A rejection here
206
+ // is one failed attempt, not a verdict — the probe below decides usability and the schedule retries, with
207
+ // the surrounding timeout as the overall bound. This also covers WsProvider's rejection when a WebSocket
208
+ // already exists (a connection in progress), which the repeat loop routinely races into.
209
+ Effect.tryPromise(async () => {
210
+ if (!this.api.isConnected) {
211
+ await this.api.connect();
109
212
  }
110
- });
213
+ }), Effect.catchAll(() => Effect.void),
214
+ // Readiness is established by making a call, not by reading `isConnected`. That flag goes true when the
215
+ // socket opens, which is earlier than the api can serve requests: after `make()` disconnects to release the
216
+ // socket, a reconnect has to re-initialise the runtime metadata and subscriptions, and any `api.rpc` call
217
+ // issued in the gap fails with a disconnection. A trivial call is the only honest test of "usable".
218
+ Effect.andThen(Effect.tryPromise(() => this.api.rpc.system.chain()).pipe(Effect.zipLeft(Ref.set(connectedProbeFailures, 0)), Effect.as(true), Effect.catchAll((probeError) => this.api.isConnected
219
+ ? Ref.updateAndGet(connectedProbeFailures, (failures) => failures + 1).pipe(Effect.flatMap((failures) => failures >= MAX_CONNECTED_PROBE_FAILURES
220
+ ? Effect.fail(new NodeClientError.ConnectionError({
221
+ message: 'Node accepted the connection but repeatedly failed to answer RPC',
222
+ cause: probeError,
223
+ }))
224
+ : Effect.succeed(false)))
225
+ : // A failed probe on a closed socket says nothing beyond "not connected yet" — reset, keep waiting.
226
+ Ref.set(connectedProbeFailures, 0).pipe(Effect.as(false))))), Effect.repeat({
227
+ until: (usable) => usable,
228
+ schedule: Schedule.spaced(this.config.reconnectionDelay),
229
+ }), Effect.timeout(this.config.reconnectionTimeout), Effect.asVoid,
230
+ // `catchTag`, not `mapError`: the probe's ConnectionError must pass through unwrapped, so the caller sees
231
+ // "node answered the socket but not RPC" rather than a second ConnectionError blaming the timeout.
232
+ Effect.catchTag('TimeoutException', (timeout) => new NodeClientError.ConnectionError({
233
+ message: 'Could not connect before the configured reconnectionTimeout elapsed',
234
+ cause: timeout,
235
+ })))));
111
236
  }
112
237
  sendMidnightTransaction(serializedTransaction) {
113
238
  const outputStream = Stream.async((emit) => {
@@ -126,10 +251,13 @@ export class PolkadotNodeClient {
126
251
  });
127
252
  return Effect.promise(callUnsubscribe);
128
253
  });
129
- return pipe(Stream.fromEffect(this.#acquire()), Stream.flatMap(() => outputStream), Stream.ensuring(this.#release()));
254
+ return pipe(Stream.acquireRelease(this.#register(), () => this.#deregister()), Stream.flatMap(() => Stream.fromEffect(this.ensureConnection())), Stream.flatMap(() => outputStream));
130
255
  }
131
256
  getGenesis() {
132
- return pipe(this.#acquire(), Effect.andThen(() => Effect.promise(() => this.api.rpc.chain.getBlock(this.api.genesisHash))),
257
+ return Effect.acquireUseRelease(this.#register(), () => pipe(this.ensureConnection(),
258
+ // `tryPromise` rather than `promise`: a rejected RPC has to reach the `mapError` below and the caller's
259
+ // `catchTag`, not arrive as a defect that bypasses both and kills the fibre as a crash.
260
+ Effect.andThen(() => Effect.tryPromise(() => this.api.rpc.chain.getBlock(this.api.genesisHash))),
133
261
  // https://polkadot.js.org/docs/api/cookbook/blocks/#how-do-i-view-extrinsic-information
134
262
  Effect.map(({ block }) => ({
135
263
  transactions: block.extrinsics
@@ -139,7 +267,40 @@ export class PolkadotNodeClient {
139
267
  })), Effect.mapError((error) => new NodeClientError.ConnectionError({
140
268
  message: 'Failed to retrieve genesis transactions',
141
269
  cause: error,
142
- })), Effect.ensuring(this.#release()));
270
+ }))), () => this.#deregister());
271
+ }
272
+ getGenesisHash() {
273
+ // Answered from the api rather than the chain: `ApiPromise.create` fetched the genesis hash once and caches it, so
274
+ // this read needs neither a connection nor the register/deregister dance the RPC-backed calls run.
275
+ return Effect.sync(() => this.api.genesisHash.toString());
276
+ }
277
+ getFinalizedBlock() {
278
+ return Effect.acquireUseRelease(this.#register(), () => pipe(this.ensureConnection(), Effect.andThen(() =>
279
+ // `tryPromise` rather than `promise`: an unreachable node has to surface as a typed failure the periodic
280
+ // liveness check can handle, not as a defect that tears the caller's fiber down.
281
+ Effect.tryPromise(async () => {
282
+ // The header must be read at the finalized head's hash. `getHeader()` with no argument returns the best
283
+ // block, whose height may not be finalized yet.
284
+ const hash = await this.api.rpc.chain.getFinalizedHead();
285
+ const header = await this.api.rpc.chain.getHeader(hash.toString());
286
+ return { hash: hash.toString(), height: header.number.toBigInt() };
287
+ })), Effect.mapError((error) => new NodeClientError.ConnectionError({
288
+ message: 'Failed to retrieve the finalized block',
289
+ cause: error,
290
+ }))), () => this.#deregister());
291
+ }
292
+ getBlockHashAt(height) {
293
+ return Effect.acquireUseRelease(this.#register(), () => pipe(this.ensureConnection(), Effect.andThen(() =>
294
+ // `tryPromise` rather than `promise`: an unreachable node has to surface as a typed failure the periodic
295
+ // liveness check can handle, not as a defect that tears the caller's fiber down.
296
+ Effect.tryPromise(() => this.api.rpc.chain.getBlockHash(height))),
297
+ // The RPC does not fail for a height the chain has nothing at — it answers with a hash of all zeroes. Passed
298
+ // on as a hash, that would compare unequal to every real block and so read as a chain mismatch rather than as
299
+ // the absence it is.
300
+ Effect.map((hash) => Option.liftPredicate(hash.toString(), (value) => !isEmptyHash(value))), Effect.mapError((error) => new NodeClientError.ConnectionError({
301
+ message: `Failed to retrieve the block hash at height ${height}`,
302
+ cause: error,
303
+ }))), () => this.#deregister());
143
304
  }
144
305
  #handleSubmissionResult = (serializedTransaction, emit, unsubscribe) => {
145
306
  const WithBNBlockNumber = Schema.Struct({
@@ -114,7 +114,7 @@ export const generateTestTransactions = (environment, paths) => Effect.gen(funct
114
114
  }), Stream.tapBoth({
115
115
  onSuccess: (entry) => Effect.log('entry', entry),
116
116
  onFailure: (error) => Effect.log(error),
117
- }), Stream.mapEffect((entry) => Stream.fromAsyncIterable(entry, (error) => error).pipe(Stream.tapBoth({
117
+ }), Stream.mapEffect((entry) => Stream.fromAsyncIterable(entry, (error) => error).pipe(Stream.mapEffect(Schema.decodeUnknown(Uint8ArraySchema)), Stream.tapBoth({
118
118
  onSuccess: (chunk) => Effect.log('chunk', chunk),
119
119
  onFailure: (error) => Effect.log(error),
120
120
  }), Stream.run(fs.sink(paths.fullPath)))), Stream.runDrain, Effect.andThen(Effect.log('done')))));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@midnightntwrk/wallet-sdk-node-client",
3
- "version": "2.0.0-rc.0",
3
+ "version": "2.0.0-rc.1",
4
4
  "type": "module",
5
5
  "module": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -33,7 +33,7 @@
33
33
  }
34
34
  },
35
35
  "dependencies": {
36
- "@midnightntwrk/wallet-sdk-abstractions": "3.0.0-rc.0",
36
+ "@midnightntwrk/wallet-sdk-abstractions": "3.0.0-rc.1",
37
37
  "@midnightntwrk/wallet-sdk-utilities": "1.2.2-rc.0",
38
38
  "@polkadot/api": "^16.5.4",
39
39
  "@polkadot/types": "^16.5.4",
@@ -57,16 +57,15 @@
57
57
  "devDependencies": {
58
58
  "@effect/cluster": "0.60.2",
59
59
  "@effect/experimental": "0.61.1",
60
- "@effect/platform": "0.97.1",
61
- "@effect/platform-node": "0.108.1",
60
+ "@effect/platform": "0.97.2",
61
+ "@effect/platform-node": "0.108.2",
62
62
  "@effect/rpc": "0.76.2",
63
63
  "@effect/sql": "0.52.1",
64
64
  "@effect/workflow": "0.19.1",
65
65
  "@midnightntwrk/ledger-v9": "1.0.0-rc.5",
66
66
  "@midnightntwrk/wallet-sdk-prover-client": "^2.0.0-rc.0",
67
- "@types/tar-stream": "3.1.4",
68
- "tar-stream": "3.2.0",
69
- "tsx": "4.23.13"
67
+ "tar-stream": "3.2.1",
68
+ "tsx": "4.23.15"
70
69
  },
71
70
  "scripts": {
72
71
  "typecheck-script": "tsc -b ./tsconfig.script.json --noEmit",