@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
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
*
|
|
184
|
+
* Deregisters one call, disconnecting the shared socket when it was the last one in flight.
|
|
100
185
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
-
#
|
|
105
|
-
return
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.
|
|
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
|
|
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
|
-
})),
|
|
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.
|
|
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.
|
|
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.
|
|
61
|
-
"@effect/platform-node": "0.108.
|
|
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
|
-
"
|
|
68
|
-
"
|
|
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",
|