@midnight-ntwrk/wallet-sdk-capabilities 4.0.0-rc.0 → 4.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.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './balancer/index.js';
2
2
  export * from './chainVersion/index.js';
3
3
  export * from './codecs/index.js';
4
+ export * from './liveness/index.js';
4
5
  export * from './pendingTransactions/index.js';
5
6
  export * from './proving/index.js';
6
7
  export * from './signatures/index.js';
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  export * from './balancer/index.js';
14
14
  export * from './chainVersion/index.js';
15
15
  export * from './codecs/index.js';
16
+ export * from './liveness/index.js';
16
17
  export * from './pendingTransactions/index.js';
17
18
  export * from './proving/index.js';
18
19
  export * from './signatures/index.js';
@@ -0,0 +1,2 @@
1
+ export * from './livenessReads.js';
2
+ export * from './livenessService.js';
@@ -0,0 +1,14 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) Midnight Foundation
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // You may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+ export * from './livenessReads.js';
14
+ export * from './livenessService.js';
@@ -0,0 +1,73 @@
1
+ import { type NodeClient, type NodeClientError } from '@midnight-ntwrk/wallet-sdk-node-client/effect';
2
+ import { Effect, Option, Scope } from 'effect';
3
+ import { type LivenessReads } from './livenessService.js';
4
+ /**
5
+ * Parses a block height reported by the indexer.
6
+ *
7
+ * @remarks
8
+ * `BigInt` throws a `RangeError` on a non-integral number, and the indexer's payload is not validated against a schema
9
+ * anywhere on this path. A misbehaving indexer answering `1.5` would therefore raise a defect inside the poll and
10
+ * stop it permanently — letting the very party this check exists to catch switch it off. Parsing here keeps that a
11
+ * typed failure, which becomes an `Unavailable` verdict like any other unreadable answer.
12
+ * @param height - The value the indexer reported.
13
+ * @returns The height, or `Option.none` when it is not a whole non-negative number.
14
+ */
15
+ export declare const parseIndexerHeight: (height: unknown) => Option.Option<bigint>;
16
+ export type LivenessReadsConfiguration = {
17
+ readonly indexerClientConnection: {
18
+ readonly indexerHttpUrl: string;
19
+ };
20
+ readonly nodeClientConnection: {
21
+ readonly nodeURL: string;
22
+ };
23
+ };
24
+ /**
25
+ * The calls the liveness check makes on a node client.
26
+ *
27
+ * @remarks
28
+ * Narrowed to what the check uses so that a test double answers those methods rather than the whole of
29
+ * `NodeClient.Service`, and so that this module depends on no more of the node client's surface than it reads.
30
+ */
31
+ export type LivenessNodeReader = Pick<NodeClient.Service, 'getFinalizedBlock' | 'getGenesisHash' | 'getBlockHashAt'>;
32
+ /** Builds a client for the liveness check's node reads, scoped so the connection is released with its caller. */
33
+ export type NodeClientFactory = (nodeURL: URL) => Effect.Effect<LivenessNodeReader, NodeClientError.NodeClientError, Scope.Scope>;
34
+ /**
35
+ * Builds the reads a liveness check compares.
36
+ *
37
+ * @remarks
38
+ * This is the only place the two sources meet, and it exists in `capabilities` rather than in a wallet package so that
39
+ * no wallet has to depend on `node-client` directly.
40
+ *
41
+ * The node client is built once, on the first read that needs it, and then reused. Building one per read repeated the
42
+ * full `ApiPromise` handshake — including the runtime metadata download — on every poll, against a node the wallet
43
+ * does not own, to fetch two numbers. polkadot-js re-fetches metadata only when the api is not already ready
44
+ * (`_loadMeta`), so a reused client pays that cost once per wallet rather than once per poll. The socket itself is
45
+ * still closed between reads by `getFinalizedBlock`, so nothing is held open on the node's behalf.
46
+ *
47
+ * Creation is deferred to the first read rather than done here, because a connection failure at construction must
48
+ * become an {@link IndexerLiveness.Unavailable} verdict. Connecting while the sync stream is being assembled would put
49
+ * that failure in the stream's acquire instead, so an unreachable node would stop the wallet syncing — the one
50
+ * outcome this check must never cause.
51
+ *
52
+ * A failed read leaves the cached client in place. It recovers by itself: `getFinalizedBlock` reconnects through
53
+ * `ensureConnection`, and polkadot-js retries the metadata load on the next connect whenever an earlier one did not
54
+ * complete. Only construction failing leaves nothing cached, and the next read then starts over — so a node that is
55
+ * down when the first poll runs does not switch the check off for the rest of the session.
56
+ *
57
+ * Both failures are flattened into {@link LivenessReadError} so the service is coupled to neither the indexer's GraphQL
58
+ * errors nor the node client's error type.
59
+ * @example
60
+ * ```ts
61
+ * const reads = yield* makeDefaultLivenessReads({
62
+ * indexerClientConnection: { indexerHttpUrl: 'http://localhost:8088/api/v1/graphql' },
63
+ * nodeClientConnection: { nodeURL: 'ws://localhost:9944' },
64
+ * });
65
+ * ```;
66
+ *
67
+ * @param config - Where to reach the indexer and the node.
68
+ * @param makeNodeClient - How to build the node client. Defaults to a real connection; supplying this is how a test
69
+ * exercises the caching without a node.
70
+ * @returns An effect yielding reads suitable for {@link LivenessServiceImpl.make}. Scoped, because the client they share
71
+ * is released when that scope closes.
72
+ */
73
+ export declare const makeDefaultLivenessReads: (config: LivenessReadsConfiguration, makeNodeClient?: NodeClientFactory) => Effect.Effect<LivenessReads, never, Scope.Scope>;
@@ -0,0 +1,228 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) Midnight Foundation
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // You may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+ import { BlockHash } from '@midnight-ntwrk/wallet-sdk-indexer-client';
14
+ import { HttpQueryClient } from '@midnight-ntwrk/wallet-sdk-indexer-client/effect';
15
+ import { PolkadotNodeClient, } from '@midnight-ntwrk/wallet-sdk-node-client/effect';
16
+ import { Duration, Effect, Option, Scope, SynchronizedRef } from 'effect';
17
+ import { LivenessReadError } from './livenessService.js';
18
+ /**
19
+ * How long to spend trying to reach the node before giving up on a poll.
20
+ *
21
+ * @remarks
22
+ * `PolkadotNodeClient` defaults to `Duration.infinity`, which suits transaction submission — a caller waiting to submit
23
+ * wants to keep trying. It is wrong here: a liveness check that waits forever on an unreachable node never publishes
24
+ * the {@link IndexerLiveness.Unavailable} verdict that exists to report exactly that condition, and its poll fibre
25
+ * hangs instead.
26
+ *
27
+ * The bound applies twice within one connection build — once to the handshake, once to the close that follows it — so
28
+ * the build's worst case is double this value, and the build runs uninterruptibly. That is why the poll's own
29
+ * deadline does not depend on it: the service disconnects the read before applying its timeout, so a slow build
30
+ * finishes in the background and its client is cached for the next poll, while the verdict still lands on time.
31
+ */
32
+ const NODE_CONNECTION_TIMEOUT = Duration.seconds(10);
33
+ /**
34
+ * Flattens a chain of wrapped errors into one line.
35
+ *
36
+ * @remarks
37
+ * Both sources wrap their failures — the node client reports "Failed to retrieve the finalized block" over whatever the
38
+ * RPC actually said. Only the outermost message would otherwise reach `Unavailable.lastError`, which names the
39
+ * operation that failed but not the reason, and so cannot be acted on.
40
+ */
41
+ const describeCause = (error) => error instanceof Error
42
+ ? error.cause === undefined || error.cause === null
43
+ ? error.message
44
+ : `${error.message}: ${describeCause(error.cause)}`
45
+ : String(error);
46
+ /**
47
+ * Parses a block height reported by the indexer.
48
+ *
49
+ * @remarks
50
+ * `BigInt` throws a `RangeError` on a non-integral number, and the indexer's payload is not validated against a schema
51
+ * anywhere on this path. A misbehaving indexer answering `1.5` would therefore raise a defect inside the poll and
52
+ * stop it permanently — letting the very party this check exists to catch switch it off. Parsing here keeps that a
53
+ * typed failure, which becomes an `Unavailable` verdict like any other unreadable answer.
54
+ * @param height - The value the indexer reported.
55
+ * @returns The height, or `Option.none` when it is not a whole non-negative number.
56
+ */
57
+ export const parseIndexerHeight = (height) => typeof height === 'number' && Number.isSafeInteger(height) && height >= 0
58
+ ? Option.some(BigInt(height))
59
+ : Option.none();
60
+ /**
61
+ * Parses a node endpoint.
62
+ *
63
+ * @remarks
64
+ * `new URL` throws on a malformed string — omitting the scheme, for instance — and a throw while constructing a read
65
+ * escapes before any Effect can catch it.
66
+ * @param nodeURL - The configured endpoint.
67
+ * @returns The parsed URL, or `Option.none` when it cannot be parsed.
68
+ */
69
+ const parseNodeURL = (nodeURL) => {
70
+ try {
71
+ return Option.some(new URL(nodeURL));
72
+ }
73
+ catch {
74
+ return Option.none();
75
+ }
76
+ };
77
+ /** Describes a failure in a way that is useful when it surfaces as `Unavailable.lastError`. */
78
+ const readError = (what, cause) => new LivenessReadError({
79
+ message: cause === undefined ? what : `${what}: ${describeCause(cause)}`,
80
+ cause,
81
+ });
82
+ /**
83
+ * Connects to the node with a bounded initial connection.
84
+ *
85
+ * @remarks
86
+ * Separated from {@link makeDefaultLivenessReads} so the caching around it can be tested without a node.
87
+ */
88
+ const connectToNode = (nodeURL) => PolkadotNodeClient.make({ nodeURL, reconnectionTimeout: NODE_CONNECTION_TIMEOUT });
89
+ /**
90
+ * Builds the reads a liveness check compares.
91
+ *
92
+ * @remarks
93
+ * This is the only place the two sources meet, and it exists in `capabilities` rather than in a wallet package so that
94
+ * no wallet has to depend on `node-client` directly.
95
+ *
96
+ * The node client is built once, on the first read that needs it, and then reused. Building one per read repeated the
97
+ * full `ApiPromise` handshake — including the runtime metadata download — on every poll, against a node the wallet
98
+ * does not own, to fetch two numbers. polkadot-js re-fetches metadata only when the api is not already ready
99
+ * (`_loadMeta`), so a reused client pays that cost once per wallet rather than once per poll. The socket itself is
100
+ * still closed between reads by `getFinalizedBlock`, so nothing is held open on the node's behalf.
101
+ *
102
+ * Creation is deferred to the first read rather than done here, because a connection failure at construction must
103
+ * become an {@link IndexerLiveness.Unavailable} verdict. Connecting while the sync stream is being assembled would put
104
+ * that failure in the stream's acquire instead, so an unreachable node would stop the wallet syncing — the one
105
+ * outcome this check must never cause.
106
+ *
107
+ * A failed read leaves the cached client in place. It recovers by itself: `getFinalizedBlock` reconnects through
108
+ * `ensureConnection`, and polkadot-js retries the metadata load on the next connect whenever an earlier one did not
109
+ * complete. Only construction failing leaves nothing cached, and the next read then starts over — so a node that is
110
+ * down when the first poll runs does not switch the check off for the rest of the session.
111
+ *
112
+ * Both failures are flattened into {@link LivenessReadError} so the service is coupled to neither the indexer's GraphQL
113
+ * errors nor the node client's error type.
114
+ * @example
115
+ * ```ts
116
+ * const reads = yield* makeDefaultLivenessReads({
117
+ * indexerClientConnection: { indexerHttpUrl: 'http://localhost:8088/api/v1/graphql' },
118
+ * nodeClientConnection: { nodeURL: 'ws://localhost:9944' },
119
+ * });
120
+ * ```;
121
+ *
122
+ * @param config - Where to reach the indexer and the node.
123
+ * @param makeNodeClient - How to build the node client. Defaults to a real connection; supplying this is how a test
124
+ * exercises the caching without a node.
125
+ * @returns An effect yielding reads suitable for {@link LivenessServiceImpl.make}. Scoped, because the client they share
126
+ * is released when that scope closes.
127
+ */
128
+ export const makeDefaultLivenessReads = (config, makeNodeClient = connectToNode) => Effect.gen(function* () {
129
+ // The scope the reads are built in, so the shared client is released when sync stops rather than when a read ends.
130
+ const scope = yield* Effect.scope;
131
+ // `SynchronizedRef`, not `Ref`: filling the cache is itself an effect, and two concurrent first reads must not race
132
+ // into building two clients — the second would replace the first, whose socket nothing would then close until the
133
+ // scope did.
134
+ const cachedClient = yield* SynchronizedRef.make(Option.none());
135
+ /**
136
+ * Yields the shared client, connecting on the first call.
137
+ *
138
+ * @remarks
139
+ * A failure to build leaves the cache empty, so the next read tries again rather than inheriting a verdict taken
140
+ * once.
141
+ *
142
+ * Uninterruptible, because the poll fails fast: when the sibling indexer read fails, the in-flight node read is
143
+ * interrupted. The client's acquire is uninterruptible on its own, so without this the interrupt would land
144
+ * between the acquire completing and the cache write — stranding a fully-built client on the sync-lifetime scope
145
+ * with the cache still empty, once per poll for as long as the indexer stays down. Deferring the interrupt past
146
+ * the write costs at most the build's worst case: the connection bound for the handshake plus the same again for
147
+ * the close, twenty seconds with the current value. The poll does not wait that long for its verdict — the
148
+ * service disconnects the read before applying its own timeout — but the build itself runs to completion or to
149
+ * that bound.
150
+ */
151
+ const client = (nodeURL) => Effect.uninterruptible(SynchronizedRef.modifyEffect(cachedClient, (current) => Option.match(current, {
152
+ onSome: (existing) => Effect.succeed([existing, current]),
153
+ onNone: () => makeNodeClient(nodeURL).pipe(
154
+ // Extended into the outer scope, not the read's: the client has to outlive the read that built it.
155
+ Scope.extend(scope), Effect.map((built) => [built, Option.some(built)])),
156
+ })));
157
+ /** Resolves the shared client from the configured endpoint — the step both node reads start with. */
158
+ const nodeReader = () => Option.match(parseNodeURL(config.nodeClientConnection.nodeURL), {
159
+ onNone: () => Effect.fail(readError(`Node endpoint is not a valid URL: ${config.nodeClientConnection.nodeURL}`, undefined)),
160
+ onSome: Effect.succeed,
161
+ }).pipe(Effect.flatMap((nodeURL) => client(nodeURL)));
162
+ return {
163
+ indexerTip: () => Effect.gen(function* () {
164
+ const query = yield* BlockHash;
165
+ const { block } = yield* query({ offset: null });
166
+ // A freshly started indexer has ingested nothing, so there is no block to compare against.
167
+ if (block === null || block === undefined) {
168
+ return yield* new LivenessReadError({ message: 'Indexer reported no latest block' });
169
+ }
170
+ return yield* Option.match(parseIndexerHeight(block.height), {
171
+ onNone: () => Effect.fail(new LivenessReadError({
172
+ message: `Indexer reported an unusable block height: ${String(block.height)}`,
173
+ })),
174
+ // The hash comes from the same answer as the height, so the two describe one block. Reading them
175
+ // separately would let a chain that advanced in between pair a height with another block's hash.
176
+ onSome: (height) => Effect.succeed({ height, hash: block.hash }),
177
+ });
178
+ }).pipe(Effect.provide(HttpQueryClient.layer({ url: config.indexerClientConnection.indexerHttpUrl })), Effect.scoped, Effect.catchAll((cause) => cause instanceof LivenessReadError
179
+ ? Effect.fail(cause)
180
+ : Effect.fail(readError('Failed to read the indexer’s latest block', cause)))),
181
+ finalizedBlock: () => nodeReader().pipe(Effect.flatMap((reader) => reader.getFinalizedBlock()),
182
+ // A bound on the whole read, not just the connection: a node that accepts a socket and then never answers would
183
+ // otherwise stall the poll just as effectively as one that refuses it.
184
+ Effect.timeoutFail({
185
+ duration: NODE_CONNECTION_TIMEOUT,
186
+ onTimeout: () => readError('Timed out reading the node’s finalized head', undefined),
187
+ }), Effect.catchAll((cause) => cause instanceof LivenessReadError
188
+ ? Effect.fail(cause)
189
+ : Effect.fail(readError('Failed to read the node’s finalized head', cause)))),
190
+ indexerBlockHashAt: (height) => Effect.gen(function* () {
191
+ const query = yield* BlockHash;
192
+ // `Number` rather than the bigint: the height is one the indexer itself just reported, so it is within the
193
+ // range the indexer's own payload uses.
194
+ const { block } = yield* query({ offset: { height: Number(height) } });
195
+ // Absence, not failure: the caller asked for a height this endpoint claimed to have passed, and "I have no
196
+ // block there" is an answer about the chain rather than a read that went wrong.
197
+ return block === null || block === undefined ? Option.none() : Option.some(block.hash);
198
+ }).pipe(Effect.provide(HttpQueryClient.layer({ url: config.indexerClientConnection.indexerHttpUrl })), Effect.scoped, Effect.catchAll((cause) => cause instanceof LivenessReadError
199
+ ? Effect.fail(cause)
200
+ : Effect.fail(readError(`Failed to read the indexer’s block at height ${height}`, cause)))),
201
+ nodeBlockHashAt: (height) => nodeReader().pipe(Effect.flatMap((reader) => reader.getBlockHashAt(height)), Effect.timeoutFail({
202
+ duration: NODE_CONNECTION_TIMEOUT,
203
+ onTimeout: () => readError(`Timed out reading the node’s block at height ${height}`, undefined),
204
+ }), Effect.catchAll((cause) => cause instanceof LivenessReadError
205
+ ? Effect.fail(cause)
206
+ : Effect.fail(readError(`Failed to read the node’s block at height ${height}`, cause)))),
207
+ indexerGenesisHash: () => Effect.gen(function* () {
208
+ const query = yield* BlockHash;
209
+ const { block } = yield* query({ offset: { height: 0 } });
210
+ // An indexer that has ingested nothing has no genesis block to name — which chain it is on is not yet known.
211
+ if (block === null || block === undefined) {
212
+ return yield* new LivenessReadError({ message: 'Indexer reported no genesis block' });
213
+ }
214
+ return block.hash;
215
+ }).pipe(Effect.provide(HttpQueryClient.layer({ url: config.indexerClientConnection.indexerHttpUrl })), Effect.scoped, Effect.catchAll((cause) => cause instanceof LivenessReadError
216
+ ? Effect.fail(cause)
217
+ : Effect.fail(readError('Failed to read the indexer’s genesis block', cause)))),
218
+ nodeGenesisHash: () => nodeReader().pipe(Effect.flatMap((reader) => reader.getGenesisHash()),
219
+ // The hash itself is served from client state, but the first call builds the client — a connection that
220
+ // must stay bounded like every other node read.
221
+ Effect.timeoutFail({
222
+ duration: NODE_CONNECTION_TIMEOUT,
223
+ onTimeout: () => readError('Timed out reading the node’s genesis hash', undefined),
224
+ }), Effect.catchAll((cause) => cause instanceof LivenessReadError
225
+ ? Effect.fail(cause)
226
+ : Effect.fail(readError('Failed to read the node’s genesis hash', cause)))),
227
+ };
228
+ });
@@ -0,0 +1,137 @@
1
+ import { IndexerLiveness } from '@midnight-ntwrk/wallet-sdk-abstractions';
2
+ import { Cause, Duration, Effect, Option, Stream } from 'effect';
3
+ declare const LivenessReadError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => Cause.YieldableError & {
4
+ readonly _tag: "LivenessReadError";
5
+ } & Readonly<A>;
6
+ /**
7
+ * A block height could not be read.
8
+ *
9
+ * @remarks
10
+ * The indexer and the node fail in their own vocabularies — a GraphQL error and a `NodeClientError` respectively. Each
11
+ * caller adapts its failure into this one type, so the service depends on neither and needs only what it actually
12
+ * uses: a message to report on an {@link IndexerLiveness.Unavailable} verdict.
13
+ */
14
+ export declare class LivenessReadError extends LivenessReadError_base<{
15
+ /** A short description of why the read failed, surfaced to callers for diagnosis. */
16
+ readonly message: string;
17
+ /** The underlying failure, retained for logging. */
18
+ readonly cause?: unknown;
19
+ }> {
20
+ }
21
+ /**
22
+ * The reads a liveness check runs, supplied as a dictionary so that callers decide where each comes from: the two tips
23
+ * it compares every poll, the block those tips share, and the two genesis hashes it compares once to establish that the
24
+ * tips describe the same chain.
25
+ *
26
+ * @remarks
27
+ * Every read is required. A service is only started when a node endpoint is configured — when none is, no service runs
28
+ * at all and the progress keeps its {@link IndexerLiveness.Skipped} default. That is why no read is optional: a
29
+ * service that might have no node would have to represent, and handle, a state it should never be in.
30
+ *
31
+ * The tips carry hashes as well as heights because a height alone proves nothing — it is a number the indexer chooses.
32
+ * The block at that height is what it cannot invent, so the check compares one block per poll: the newest one both
33
+ * endpoints claim to have passed.
34
+ */
35
+ export type LivenessReads = {
36
+ /** Reads the height and hash of the latest block the indexer reports having processed. */
37
+ readonly indexerTip: () => Effect.Effect<IndexerLiveness.BlockRef, LivenessReadError>;
38
+ /** Reads the height and hash of the node's highest finalized block. */
39
+ readonly finalizedBlock: () => Effect.Effect<IndexerLiveness.BlockRef, LivenessReadError>;
40
+ /**
41
+ * Reads the hash of the indexer's block at a given height, or `Option.none` when it has none there.
42
+ *
43
+ * @remarks
44
+ * Called only for a height the indexer has just claimed to have passed, which is why absence is part of a verdict
45
+ * rather than a failure: an indexer that cannot serve a block it reports having ingested contradicts its own
46
+ * answer.
47
+ */
48
+ readonly indexerBlockHashAt: (height: bigint) => Effect.Effect<Option.Option<string>, LivenessReadError>;
49
+ /**
50
+ * Reads the hash of the node's block at a given height, or `Option.none` when it has none there.
51
+ *
52
+ * @remarks
53
+ * Called only for a height at or below the node's own finalized head, where a canonical chain always has a block — so
54
+ * absence says this node does not have the block being claimed, not that the read went wrong.
55
+ */
56
+ readonly nodeBlockHashAt: (height: bigint) => Effect.Effect<Option.Option<string>, LivenessReadError>;
57
+ /** Reads the hash of the indexer's block at height zero — the chain the indexer is indexing. */
58
+ readonly indexerGenesisHash: () => Effect.Effect<string, LivenessReadError>;
59
+ /** Reads the hash of the node's genesis block — the chain the node is on. */
60
+ readonly nodeGenesisHash: () => Effect.Effect<string, LivenessReadError>;
61
+ };
62
+ /** How far apart the indexer and the node may be before the difference is reported. */
63
+ export type LivenessConfiguration = {
64
+ /** How many blocks the indexer may trail the finalized head by and still count as in sync. */
65
+ readonly maxBehindBlocks: bigint;
66
+ /** How many blocks the indexer may lead the finalized head by and still count as in sync. */
67
+ readonly maxAheadBlocks: bigint;
68
+ /**
69
+ * How long a single poll may run before it is abandoned and reported as {@link IndexerLiveness.Unavailable}.
70
+ *
71
+ * @remarks
72
+ * Defaults to {@link DEFAULT_POLL_TIMEOUT}. Polls run one at a time, so without this bound a read that hangs — an
73
+ * endpoint that accepts a connection and never answers — would stop the loop forever, freezing the verdict at
74
+ * whatever was published last. A hung read must not be able to switch the check off.
75
+ */
76
+ readonly pollTimeout?: Duration.Duration;
77
+ };
78
+ /**
79
+ * The default bound on a single poll.
80
+ *
81
+ * @remarks
82
+ * Comfortably above the node read's own ten-second connection bound, and below the default poll interval, so an
83
+ * abandoned poll never delays the tick that follows it.
84
+ */
85
+ export declare const DEFAULT_POLL_TIMEOUT: Duration.Duration;
86
+ /**
87
+ * Tolerances sized against Midnight's 6-second block slots (the indexer pins `SLOT_DURATION` at 6000 ms).
88
+ *
89
+ * @remarks
90
+ * `maxBehindBlocks` is a staleness allowance: ten blocks is about a minute of chain time, generous enough that an
91
+ * indexer briefly catching up is not reported as stale. `maxAheadBlocks` absorbs read skew — the two heights are read
92
+ * at different moments — while staying far below the thousands of blocks that separate two different networks.
93
+ */
94
+ export declare const DEFAULT_LIVENESS_CONFIGURATION: LivenessConfiguration;
95
+ /**
96
+ * How often to compare the indexer against the node, by default.
97
+ *
98
+ * @remarks
99
+ * Deliberately unhurried. Each poll costs a request to a node the wallet does not own, a stalled indexer is a
100
+ * slow-developing condition, and polling faster than blocks are produced only re-reads the same block. At Midnight's
101
+ * 6-second slots, half a minute is about five blocks, and it surfaces a stalled indexer well within the time any
102
+ * caller would notice.
103
+ */
104
+ export declare const DEFAULT_POLL_INTERVAL: Duration.Duration;
105
+ export type LivenessService = {
106
+ /**
107
+ * Consumes `ticks`, comparing the indexer against the node once per tick and publishing the verdict.
108
+ *
109
+ * @remarks
110
+ * Ticks are supplied by the caller rather than generated here, so that tests drive the loop directly instead of
111
+ * waiting on a clock. The returned effect cannot fail: a read that fails is folded into the verdict by
112
+ * {@link IndexerLiveness.afterFailedPoll} — {@link IndexerLiveness.Unavailable}, or a gating verdict kept as it is —
113
+ * because a check that gave up on its first network error would be useless against exactly the conditions it exists
114
+ * to detect.
115
+ */
116
+ readonly startPolling: (ticks: Stream.Stream<unknown>) => Effect.Effect<void>;
117
+ /** The current verdict, followed by each subsequent one. */
118
+ readonly state: () => Stream.Stream<IndexerLiveness.IndexerLiveness>;
119
+ };
120
+ export declare class LivenessServiceImpl implements LivenessService {
121
+ #private;
122
+ /**
123
+ * Creates a service whose verdict starts as {@link IndexerLiveness.Unknown} — a check exists but has not yet run.
124
+ *
125
+ * @param reads - Where the two heights come from.
126
+ * @param configuration - The tolerances to apply, and the bound on a single poll.
127
+ * @param initialVerdict - The verdict to start from. Callers that rebuild the service mid-session pass the verdict
128
+ * they already hold, so a reconnect does not erase a `Behind` the check had already reached — starting afresh at
129
+ * `Unknown` would hand back a stale-view answer the moment the gate re-opened.
130
+ * @returns An effect yielding the service.
131
+ */
132
+ static make(reads: LivenessReads, configuration?: LivenessConfiguration, initialVerdict?: IndexerLiveness.IndexerLiveness): Effect.Effect<LivenessServiceImpl>;
133
+ private constructor();
134
+ state(): Stream.Stream<IndexerLiveness.IndexerLiveness>;
135
+ startPolling(ticks: Stream.Stream<unknown>): Effect.Effect<void>;
136
+ }
137
+ export {};
@@ -0,0 +1,201 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) Midnight Foundation
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // You may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+ import { IndexerLiveness } from '@midnight-ntwrk/wallet-sdk-abstractions';
14
+ import { Cause, Data, Deferred, Duration, Effect, identity, Option, Stream, SubscriptionRef } from 'effect';
15
+ /**
16
+ * A block height could not be read.
17
+ *
18
+ * @remarks
19
+ * The indexer and the node fail in their own vocabularies — a GraphQL error and a `NodeClientError` respectively. Each
20
+ * caller adapts its failure into this one type, so the service depends on neither and needs only what it actually
21
+ * uses: a message to report on an {@link IndexerLiveness.Unavailable} verdict.
22
+ */
23
+ export class LivenessReadError extends Data.TaggedError('LivenessReadError') {
24
+ }
25
+ /**
26
+ * The default bound on a single poll.
27
+ *
28
+ * @remarks
29
+ * Comfortably above the node read's own ten-second connection bound, and below the default poll interval, so an
30
+ * abandoned poll never delays the tick that follows it.
31
+ */
32
+ export const DEFAULT_POLL_TIMEOUT = Duration.seconds(20);
33
+ /**
34
+ * Tolerances sized against Midnight's 6-second block slots (the indexer pins `SLOT_DURATION` at 6000 ms).
35
+ *
36
+ * @remarks
37
+ * `maxBehindBlocks` is a staleness allowance: ten blocks is about a minute of chain time, generous enough that an
38
+ * indexer briefly catching up is not reported as stale. `maxAheadBlocks` absorbs read skew — the two heights are read
39
+ * at different moments — while staying far below the thousands of blocks that separate two different networks.
40
+ */
41
+ export const DEFAULT_LIVENESS_CONFIGURATION = {
42
+ maxBehindBlocks: 10n,
43
+ maxAheadBlocks: 10n,
44
+ };
45
+ /**
46
+ * How often to compare the indexer against the node, by default.
47
+ *
48
+ * @remarks
49
+ * Deliberately unhurried. Each poll costs a request to a node the wallet does not own, a stalled indexer is a
50
+ * slow-developing condition, and polling faster than blocks are produced only re-reads the same block. At Midnight's
51
+ * 6-second slots, half a minute is about five blocks, and it surfaces a stalled indexer well within the time any
52
+ * caller would notice.
53
+ */
54
+ export const DEFAULT_POLL_INTERVAL = Duration.seconds(30);
55
+ /**
56
+ * Reduces a failed poll to one line for {@link IndexerLiveness.Unavailable}'s `lastError`.
57
+ *
58
+ * @remarks
59
+ * That string is shown to whoever is diagnosing a stalled wallet, so it stays a message rather than a rendered cause: a
60
+ * stack trace is noise at that surface. Defects are labelled as unexpected, because unlike a read failure they
61
+ * indicate a bug rather than an unreachable node.
62
+ */
63
+ const describeFailure = (cause) => Option.match(Cause.failureOption(cause), {
64
+ onSome: (error) => error.message,
65
+ onNone: () => Option.match(Cause.dieOption(cause), {
66
+ onSome: (defect) => `Unexpected failure while reading: ${defect instanceof Error ? defect.message : String(defect)}`,
67
+ onNone: () => 'Unexpected failure while reading',
68
+ }),
69
+ });
70
+ const GenesisCheck = Data.taggedEnum();
71
+ export class LivenessServiceImpl {
72
+ #state;
73
+ #reads;
74
+ #configuration;
75
+ // The check settles once and is never rewritten. A `Deferred` enforces that where a `Ref` would only promise it: a
76
+ // second completion is a no-op, and "not yet settled" is the empty `Deferred` rather than a sentinel value.
77
+ #genesisCheck;
78
+ /**
79
+ * Creates a service whose verdict starts as {@link IndexerLiveness.Unknown} — a check exists but has not yet run.
80
+ *
81
+ * @param reads - Where the two heights come from.
82
+ * @param configuration - The tolerances to apply, and the bound on a single poll.
83
+ * @param initialVerdict - The verdict to start from. Callers that rebuild the service mid-session pass the verdict
84
+ * they already hold, so a reconnect does not erase a `Behind` the check had already reached — starting afresh at
85
+ * `Unknown` would hand back a stale-view answer the moment the gate re-opened.
86
+ * @returns An effect yielding the service.
87
+ */
88
+ static make(reads, configuration = DEFAULT_LIVENESS_CONFIGURATION, initialVerdict = IndexerLiveness.Unknown()) {
89
+ return Effect.all([
90
+ SubscriptionRef.make(initialVerdict),
91
+ Deferred.make(),
92
+ ]).pipe(Effect.map(([state, genesisCheck]) => new LivenessServiceImpl(state, reads, configuration, genesisCheck)));
93
+ }
94
+ constructor(state, reads, configuration, genesisCheck) {
95
+ this.#state = state;
96
+ this.#reads = reads;
97
+ this.#configuration = configuration;
98
+ this.#genesisCheck = genesisCheck;
99
+ }
100
+ state() {
101
+ // `changes` already replays the current value before subsequent ones, under the ref's own semaphore. Prepending a
102
+ // separate `get` would emit the first verdict twice — and read it outside that semaphore.
103
+ //
104
+ // `changesWith` then drops a verdict that says nothing new. Every poll writes unconditionally, and each write fans
105
+ // out into the wallet's full state stream — without this, a healthy idle wallet re-notified every subscriber once
106
+ // per poll, forever. Structural equality would not do: the heights on `InSync`, `Behind` and `Ahead` advance with
107
+ // the chain every poll, so consecutive verdicts are never structurally equal. `equivalent` compares what a caller
108
+ // acts on — the kind, a `Behind`'s lag, `Unavailable`'s climbing count — so a lengthening outage or a growing lag
109
+ // still reports, and a healthy idle wallet stays quiet.
110
+ return this.#state.changes.pipe(Stream.changesWith(IndexerLiveness.equivalent));
111
+ }
112
+ startPolling(ticks) {
113
+ return Stream.runForEach(ticks, () => this.#poll());
114
+ }
115
+ /**
116
+ * Compares the two heights once and publishes the result.
117
+ *
118
+ * @remarks
119
+ * The whole poll is folded into the verdict: `catchAllCause` hands a failed read to `afterFailedPoll`, so the
120
+ * returned effect has no error channel and the polling loop survives an unreachable node. `SubscriptionRef.update`
121
+ * reads the previous verdict inside the callback, which is what lets `afterFailedPoll` continue a run of failures —
122
+ * or keep a `Behind` that a failed poll cannot disprove — without a separate get-then-write race.
123
+ */
124
+ #poll() {
125
+ return this.#compareOnce().pipe(Effect.map((verdict) => () => verdict),
126
+ // `disconnect` makes the deadline below a hard one. A timeout interrupts its loser and then waits for it, so a
127
+ // read stuck inside an uninterruptible region — the node client's connection build is one, and cannot be made
128
+ // otherwise without leaking the client it builds — would stretch the deadline to the read's own length. Disconnected,
129
+ // the read is interrupted in the background instead: the verdict lands on time, and a build that does finish still
130
+ // caches its client for the next poll.
131
+ Effect.disconnect,
132
+ // A bound on the whole poll, not only on the reads' own deadlines: polls run one at a time, so a read that hangs
133
+ // — an endpoint that accepts a connection and never answers — would otherwise stop the loop forever, freezing
134
+ // the verdict at whatever was published last. A hung read must not be able to switch the check off.
135
+ Effect.timeoutFail({
136
+ duration: this.#configuration.pollTimeout ?? DEFAULT_POLL_TIMEOUT,
137
+ onTimeout: () => new LivenessReadError({ message: 'Poll abandoned: a read did not complete in time' }),
138
+ }),
139
+ // `catchAllCause`, not `catchAll`: a read can die as well as fail — an unparsed URL, a payload that is not the
140
+ // shape it claims — and a defect would kill the poll fibre outright. The verdict would then freeze at whatever
141
+ // was written last, which at start-up is `Unknown` and never gates, leaving an indexer able to switch off the
142
+ // check simply by answering badly.
143
+ Effect.catchAllCause((cause) => Effect.succeed((previous) => IndexerLiveness.afterFailedPoll(previous, describeFailure(cause)))), Effect.flatMap((nextVerdict) => SubscriptionRef.update(this.#state, nextVerdict)));
144
+ }
145
+ /**
146
+ * Runs one comparison: the chain identity first, then the heights.
147
+ *
148
+ * @remarks
149
+ * Height comparison is meaningful only between endpoints on the same chain, so no height verdict is published until
150
+ * the genesis hashes have been read once and found to match. A mismatch pins {@link IndexerLiveness.WrongNetwork}
151
+ * for the service's lifetime — neither endpoint changes chain until reconfigured, and reconfiguring builds a new
152
+ * wallet — while a match is cached so the hashes are read exactly once. A _failed_ hash read proves nothing about
153
+ * which chain anyone is on, so it caches nothing: it surfaces through the ordinary `Unavailable` path and the next
154
+ * poll tries the hashes again.
155
+ */
156
+ #compareOnce() {
157
+ // `poll` reads without waiting: an empty `Deferred` means the hashes have not yet been compared, so compare them now.
158
+ return Deferred.poll(this.#genesisCheck).pipe(Effect.flatMap(Option.match({ onNone: () => this.#verifyGenesis(), onSome: identity })), Effect.flatMap(GenesisCheck.$match({
159
+ Mismatch: ({ verdict }) => Effect.succeed(verdict),
160
+ SameChain: () => this.#compareTips(),
161
+ })));
162
+ }
163
+ /** Reads both genesis hashes, settles the check, and caches the outcome. */
164
+ #verifyGenesis() {
165
+ return Effect.all([this.#reads.indexerGenesisHash(), this.#reads.nodeGenesisHash()], { concurrency: 2 }).pipe(Effect.map(([indexerGenesisHash, nodeGenesisHash]) => IndexerLiveness.sameBlockHash(indexerGenesisHash, nodeGenesisHash)
166
+ ? GenesisCheck.SameChain()
167
+ : GenesisCheck.Mismatch({
168
+ // The genesis comparison is the height-zero case of the same block comparison every poll runs.
169
+ verdict: IndexerLiveness.WrongNetwork({
170
+ height: 0n,
171
+ indexerBlockHash: Option.some(indexerGenesisHash),
172
+ nodeBlockHash: Option.some(nodeGenesisHash),
173
+ }),
174
+ })), Effect.tap((check) => Deferred.succeed(this.#genesisCheck, check)));
175
+ }
176
+ /**
177
+ * Reads both tips, confirms they name the same block where they overlap, and reduces the heights to a verdict.
178
+ *
179
+ * @remarks
180
+ * Only one extra block is read per poll, and none at all when the two tips are already at the same height: the
181
+ * endpoint that trails is reporting the shared block as its own tip, so the only open question is what the endpoint
182
+ * that leads names there. Asking both would be a second round trip for an answer already in hand.
183
+ */
184
+ #compareTips() {
185
+ return Effect.all([this.#reads.indexerTip(), this.#reads.finalizedBlock()], { concurrency: 2 }).pipe(Effect.flatMap(([indexer, finalized]) => this.#sharedBlockHash(indexer, finalized).pipe(Effect.map((sharedHash) => IndexerLiveness.evaluateTips({
186
+ indexer,
187
+ finalized,
188
+ sharedHash,
189
+ maxBehindBlocks: this.#configuration.maxBehindBlocks,
190
+ maxAheadBlocks: this.#configuration.maxAheadBlocks,
191
+ })))));
192
+ }
193
+ /** Asks whichever endpoint is ahead what it names at the other's height; neither, when the two are level. */
194
+ #sharedBlockHash(indexer, finalized) {
195
+ return indexer.height === finalized.height
196
+ ? Effect.succeed(Option.none())
197
+ : indexer.height > finalized.height
198
+ ? this.#reads.indexerBlockHashAt(finalized.height)
199
+ : this.#reads.nodeBlockHashAt(indexer.height);
200
+ }
201
+ }
@@ -1,5 +1,5 @@
1
1
  import { ProtocolVersion } from '@midnight-ntwrk/wallet-sdk-abstractions';
2
- import { type DateTime, Either, Option, ParseResult, Schema } from 'effect';
2
+ import { DateTime, Either, Option, ParseResult, Schema } from 'effect';
3
3
  export type TransactionTrait<TTransaction> = {
4
4
  ids: (tx: TTransaction) => readonly string[];
5
5
  firstId: (tx: TTransaction) => string;
@@ -111,9 +111,102 @@ export type OrphanedTransactionItem<TTransaction> = PendingTransactionsItem<TTra
111
111
  export type RejectedTransactionItem<TTransaction> = PendingTransactionsItem<TTransaction> & {
112
112
  result: FailedTransactionResult | OrphanedByForkResult;
113
113
  };
114
+ /**
115
+ * A transaction that has been balanced but not yet proven or submitted.
116
+ *
117
+ * Balancing reserves the coins a transaction will spend, and until the transaction is submitted nothing else records
118
+ * that those coins are spoken for — so a transaction abandoned in between strands them. A reservation is that missing
119
+ * record. It never holds the transaction itself: an unproven transaction carries key material and must not be
120
+ * persisted.
121
+ *
122
+ * `identifiers` is what ties a reservation to the transaction that later arrives, and it is stable: proving and binding
123
+ * both leave a transaction's identifiers unchanged. `intentHashes` is kept alongside because it is the value the ledger
124
+ * stamps on the coins the transaction creates.
125
+ */
126
+ export type Reservation = Readonly<{
127
+ identifiers: readonly string[];
128
+ intentHashes: readonly string[];
129
+ /** Ids of the coins this transaction reserved, per wallet, so each wallet can release its own. */
130
+ inputs: Readonly<{
131
+ unshielded: readonly string[];
132
+ }>;
133
+ /** The transaction's TTL: from this instant the ledger rejects it, so the reservation cannot still be valid. */
134
+ ttl: Date;
135
+ createdAt: DateTime.Utc;
136
+ expired: boolean;
137
+ }>;
114
138
  export type PendingTransactions<TTransaction> = Readonly<{
115
139
  all: ReadonlyArray<PendingTransactionsItem<TTransaction>>;
140
+ reservations: ReadonlyArray<Reservation>;
116
141
  }>;
142
+ /**
143
+ * Records a balanced transaction, replacing any earlier reservation for the same spend.
144
+ *
145
+ * @example
146
+ * const held = addReservation(state, { identifiers: tx.identifiers(), inputs: { unshielded: ids }, ttl });
147
+ *
148
+ * @param state - The pending transactions to add to
149
+ * @param reservation - The record of what the balanced transaction booked
150
+ * @returns The state with that reservation held, and any it replaces gone
151
+ */
152
+ export declare const addReservation: <TTransaction>(state: PendingTransactions<TTransaction>, reservation: Reservation) => PendingTransactions<TTransaction>;
153
+ /**
154
+ * Forgets every reservation holding one of `identifiers`.
155
+ *
156
+ * @example
157
+ * const cleared = clearReservation(state, [...tx.identifiers()]);
158
+ *
159
+ * @param state - The pending transactions to clear from
160
+ * @param identifiers - Identifiers of the spend whose record is finished with
161
+ * @returns The state with those reservations gone; identifiers no reservation holds are ignored
162
+ */
163
+ export declare const clearReservation: <TTransaction>(state: PendingTransactions<TTransaction>, identifiers: readonly string[]) => PendingTransactions<TTransaction>;
164
+ /**
165
+ * Marks every reservation whose TTL `now` has passed. Marked rather than removed, mirroring how a failed transaction is
166
+ * kept until a caller has acted on it: the coins still have to be released before the record is dropped.
167
+ *
168
+ * Passed, not reached: the ledger accepts an intent while its TTL is at or after the block's timestamp, so a
169
+ * transaction is still perfectly valid at the instant its TTL names.
170
+ *
171
+ * Returns the state it was given when nothing reaches its expiry. This runs on a timer, so most visits change nothing,
172
+ * and handing back the same value is what lets a caller publish only when something actually moved.
173
+ *
174
+ * @example
175
+ * const swept = expireReservations(state, DateTime.unsafeNow());
176
+ *
177
+ * @param state - The pending transactions to sweep
178
+ * @param now - The instant to expire against
179
+ * @returns The state with newly expired reservations marked, or the same state when none is
180
+ */
181
+ export declare const expireReservations: <TTransaction>(state: PendingTransactions<TTransaction>, now: DateTime.Utc) => PendingTransactions<TTransaction>;
182
+ /**
183
+ * The unshielded coin ids some spend still accounts for.
184
+ *
185
+ * Every reservation's inputs, plus the inputs of every transaction being tracked. Both halves are needed: a reservation
186
+ * covers a transaction that was balanced and never submitted, and registering a transaction drops the reservation
187
+ * standing in for it, so from submission onwards only the tracked transaction says the coins are spoken for.
188
+ *
189
+ * @example
190
+ * const spokenFor = coveredUnshieldedIds(pending, (tx) => ownInputIdsOf(tx));
191
+ *
192
+ * @param state - The reservations and tracked transactions to read
193
+ * @param unshieldedInputsOf - How to name the unshielded coins a tracked transaction spends
194
+ * @returns Every such coin id, without repeats
195
+ */
196
+ export declare const coveredUnshieldedIds: <TTransaction>(state: PendingTransactions<TTransaction>, unshieldedInputsOf: (tx: TTransaction) => readonly string[]) => readonly string[];
197
+ /**
198
+ * The reservations whose transactions can no longer be accepted, and whose coins are therefore free.
199
+ *
200
+ * Expiry marks a reservation rather than removing it, so this is what a caller reads to find the records it still has
201
+ * to act on before dropping them.
202
+ *
203
+ * @example
204
+ * const finished = allExpiredReservations(state);
205
+ *
206
+ * @param state - The pending transactions to read
207
+ * @returns The marked reservations, in the order they were added; empty when none has expired
208
+ */
209
+ export declare const allExpiredReservations: <TTransaction>(state: PendingTransactions<TTransaction>) => ReadonlyArray<Reservation>;
117
210
  export declare const has: <TTransaction>(transactions: PendingTransactions<TTransaction>, transaction: TTransaction, traits: VersionedTransactionTrait<TTransaction>) => boolean;
118
211
  export declare const all: <TTransaction>(transactions: PendingTransactions<TTransaction>) => readonly TTransaction[];
119
212
  export declare const allFailed: <TTransaction>(transactions: PendingTransactions<TTransaction>) => ReadonlyArray<FailedTransactionItem<TTransaction>>;
@@ -153,9 +246,17 @@ export declare const saveResult: <TTransaction>(state: PendingTransactions<TTran
153
246
  * @returns The pending transactions with the stranded ones given an {@link OrphanedByForkResult}.
154
247
  */
155
248
  export declare const orphanBeyond: <TTransaction>(state: PendingTransactions<TTransaction>, traits: VersionedTransactionTrait<TTransaction>, chainNow: ProtocolVersion.ProtocolVersion) => PendingTransactions<TTransaction>;
249
+ /**
250
+ * The stored shape. Reservations arrived after the format was already in the field, so they are an optional member of
251
+ * the version that was already there rather than a version of their own: a reader that predates them sees the version
252
+ * it knows and ignores the member it does not, which is what keeps a store written here readable by an older package.
253
+ *
254
+ * A snapshot that omits them records no reservations, which is the truth about what the writer knew.
255
+ */
156
256
  type Serialized<TTransaction> = Readonly<{
157
257
  version: 'v1';
158
258
  transactions: readonly PendingItem<TTransaction>[];
259
+ reservations: readonly Reservation[];
159
260
  }>;
160
261
  export declare const SerializedSchema: <TTransaction>(traits: VersionedTransactionTrait<TTransaction>) => Schema.Schema<Serialized<TTransaction>, any>;
161
262
  export declare const serialize: <TTransaction>(state: PendingTransactions<TTransaction>, traits: VersionedTransactionTrait<TTransaction>) => string;
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { ProtocolVersion } from '@midnight-ntwrk/wallet-sdk-abstractions';
16
16
  import { Buffer } from 'buffer';
17
- import { Array as Arr, Either, Option, Order, ParseResult, pipe, Schema } from 'effect';
17
+ import { Array as Arr, DateTime, Either, Option, Order, ParseResult, pipe, Schema } from 'effect';
18
18
  /** Registers one trait for every protocol version: the shape a wallet that speaks a single ledger version has. */
19
19
  export const singleTrait = (trait) => ({
20
20
  entries: [
@@ -60,6 +60,90 @@ const idsOf = (traits, tx) => traitForTx(traits, tx).pipe(Option.map((trait) =>
60
60
  const idsOfItem = (traits, item) => traitOfItem(traits, item).pipe(Option.map((trait) => trait.ids(item.tx)), Option.getOrElse(() => []));
61
61
  /** Whether a stored item is covered by the identifiers of an incoming transaction, each read with its own trait. */
62
62
  const isCoveredBy = (traits, item, txIds) => traitOfItem(traits, item).pipe(Option.map((trait) => trait.areAllTxIdsIncluded(item.tx, txIds)), Option.getOrElse(() => false));
63
+ /** Two reservations, or a reservation and a transaction, are the same spend if they share any identifier. */
64
+ const sharesIdentifier = (reservation, identifiers) => reservation.identifiers.some((id) => identifiers.includes(id));
65
+ /**
66
+ * Records a balanced transaction, replacing any earlier reservation for the same spend.
67
+ *
68
+ * @example
69
+ * const held = addReservation(state, { identifiers: tx.identifiers(), inputs: { unshielded: ids }, ttl });
70
+ *
71
+ * @param state - The pending transactions to add to
72
+ * @param reservation - The record of what the balanced transaction booked
73
+ * @returns The state with that reservation held, and any it replaces gone
74
+ */
75
+ export const addReservation = (state, reservation) => ({
76
+ ...state,
77
+ reservations: Arr.append(Arr.filter(state.reservations, (existing) => !sharesIdentifier(existing, reservation.identifiers)), reservation),
78
+ });
79
+ /**
80
+ * Forgets every reservation holding one of `identifiers`.
81
+ *
82
+ * @example
83
+ * const cleared = clearReservation(state, [...tx.identifiers()]);
84
+ *
85
+ * @param state - The pending transactions to clear from
86
+ * @param identifiers - Identifiers of the spend whose record is finished with
87
+ * @returns The state with those reservations gone; identifiers no reservation holds are ignored
88
+ */
89
+ export const clearReservation = (state, identifiers) => ({
90
+ ...state,
91
+ reservations: Arr.filter(state.reservations, (reservation) => !sharesIdentifier(reservation, identifiers)),
92
+ });
93
+ /**
94
+ * Marks every reservation whose TTL `now` has passed. Marked rather than removed, mirroring how a failed transaction is
95
+ * kept until a caller has acted on it: the coins still have to be released before the record is dropped.
96
+ *
97
+ * Passed, not reached: the ledger accepts an intent while its TTL is at or after the block's timestamp, so a
98
+ * transaction is still perfectly valid at the instant its TTL names.
99
+ *
100
+ * Returns the state it was given when nothing reaches its expiry. This runs on a timer, so most visits change nothing,
101
+ * and handing back the same value is what lets a caller publish only when something actually moved.
102
+ *
103
+ * @example
104
+ * const swept = expireReservations(state, DateTime.unsafeNow());
105
+ *
106
+ * @param state - The pending transactions to sweep
107
+ * @param now - The instant to expire against
108
+ * @returns The state with newly expired reservations marked, or the same state when none is
109
+ */
110
+ export const expireReservations = (state, now) => {
111
+ const hasExpiry = (reservation) => !reservation.expired && reservation.ttl.getTime() < DateTime.toEpochMillis(now);
112
+ return Arr.some(state.reservations, hasExpiry)
113
+ ? {
114
+ ...state,
115
+ reservations: Arr.map(state.reservations, (reservation) => hasExpiry(reservation) ? { ...reservation, expired: true } : reservation),
116
+ }
117
+ : state;
118
+ };
119
+ /**
120
+ * The unshielded coin ids some spend still accounts for.
121
+ *
122
+ * Every reservation's inputs, plus the inputs of every transaction being tracked. Both halves are needed: a reservation
123
+ * covers a transaction that was balanced and never submitted, and registering a transaction drops the reservation
124
+ * standing in for it, so from submission onwards only the tracked transaction says the coins are spoken for.
125
+ *
126
+ * @example
127
+ * const spokenFor = coveredUnshieldedIds(pending, (tx) => ownInputIdsOf(tx));
128
+ *
129
+ * @param state - The reservations and tracked transactions to read
130
+ * @param unshieldedInputsOf - How to name the unshielded coins a tracked transaction spends
131
+ * @returns Every such coin id, without repeats
132
+ */
133
+ export const coveredUnshieldedIds = (state, unshieldedInputsOf) => pipe(Arr.appendAll(Arr.flatMap(state.reservations, (reservation) => reservation.inputs.unshielded), Arr.flatMap(state.all, (item) => unshieldedInputsOf(item.tx))), Arr.dedupe);
134
+ /**
135
+ * The reservations whose transactions can no longer be accepted, and whose coins are therefore free.
136
+ *
137
+ * Expiry marks a reservation rather than removing it, so this is what a caller reads to find the records it still has
138
+ * to act on before dropping them.
139
+ *
140
+ * @example
141
+ * const finished = allExpiredReservations(state);
142
+ *
143
+ * @param state - The pending transactions to read
144
+ * @returns The marked reservations, in the order they were added; empty when none has expired
145
+ */
146
+ export const allExpiredReservations = (state) => Arr.filter(state.reservations, (reservation) => reservation.expired);
63
147
  export const has = (transactions, transaction, traits) => traitForTx(traits, transaction).pipe(Option.map((trait) => transactions.all.some((item) => trait.areAllTxIdsIncluded(transaction, idsOfItem(traits, item)))), Option.getOrElse(() => false));
64
148
  export const all = (transactions) => {
65
149
  return transactions.all.map((item) => item.tx);
@@ -88,6 +172,7 @@ export const allPending = (state) => {
88
172
  export const empty = () => {
89
173
  return {
90
174
  all: [],
175
+ reservations: [],
91
176
  };
92
177
  };
93
178
  /**
@@ -115,6 +200,8 @@ export const addPendingTransaction = (state, tx, now, traits, protocolVersion) =
115
200
  return {
116
201
  ...state,
117
202
  all: Arr.append(rest, theBiggestMatchingTx),
203
+ // The transaction now stands for the spend its reservation was holding open, and carries the same identifiers.
204
+ reservations: Arr.filter(state.reservations, (reservation) => !sharesIdentifier(reservation, idsOf(traits, tx))),
118
205
  };
119
206
  };
120
207
  export const clear = (state, tx, traits) => {
@@ -122,6 +209,8 @@ export const clear = (state, tx, traits) => {
122
209
  return {
123
210
  ...state,
124
211
  all: Arr.filter(state.all, (item) => !isCoveredBy(traits, item, txIds)),
212
+ // Whatever stopped tracking the transaction — a revert, or a spend that confirmed — also ends the reservation.
213
+ reservations: Arr.filter(state.reservations, (reservation) => !sharesIdentifier(reservation, txIds)),
125
214
  };
126
215
  };
127
216
  export const saveResult = (state, tx, result, traits) => {
@@ -156,6 +245,14 @@ export const orphanBeyond = (state, traits, chainNow) => ({
156
245
  })),
157
246
  });
158
247
  const hasEpochEnded = (traits, authoredFor, chainNow) => ProtocolVersion.selectEntry(traits, authoredFor).pipe(Option.map((entry) => chainNow >= entry.range[1]), Option.getOrElse(() => false));
248
+ const ReservationSchema = Schema.Struct({
249
+ identifiers: Schema.Array(Schema.String),
250
+ intentHashes: Schema.Array(Schema.String),
251
+ inputs: Schema.Struct({ unshielded: Schema.Array(Schema.String) }),
252
+ ttl: Schema.Date,
253
+ createdAt: Schema.DateTimeUtc,
254
+ expired: Schema.Boolean,
255
+ });
159
256
  const WireItemSchema = Schema.Struct({
160
257
  tx: Schema.String,
161
258
  creationTime: Schema.DateTimeUtc,
@@ -184,6 +281,7 @@ const PendingItemSchema = (traits) => {
184
281
  export const SerializedSchema = (traits) => Schema.Struct({
185
282
  version: Schema.Literal('v1'),
186
283
  transactions: Schema.Array(PendingItemSchema(traits)),
284
+ reservations: Schema.optionalWith(Schema.Array(ReservationSchema), { default: () => [] }),
187
285
  });
188
286
  export const serialize = (state, traits) => pipe(state, toSerialized, Schema.encodeSync(SerializedSchema(traits)), JSON.stringify);
189
287
  export const deserialize = (serialized, traits) => {
@@ -193,10 +291,12 @@ export const toSerialized = (pendingTransactions) => {
193
291
  return {
194
292
  version: 'v1',
195
293
  transactions: pendingTransactions.all,
294
+ reservations: pendingTransactions.reservations,
196
295
  };
197
296
  };
198
297
  export const fromSerialized = (serialized) => {
199
298
  return {
200
299
  all: serialized.transactions,
300
+ reservations: serialized.reservations,
201
301
  };
202
302
  };
@@ -21,6 +21,33 @@ export type PendingTransactionsService<TTransaction> = {
21
21
  * @param chainNow The protocol version the wallets have reached.
22
22
  */
23
23
  orphanBeyond: (chainNow: ProtocolVersion.ProtocolVersion) => Promise<void>;
24
+ /**
25
+ * Records that a balanced transaction has coins reserved, before it is proven or submitted.
26
+ *
27
+ * Between balancing and submission nothing else says those coins are spoken for, so this is the only durable record
28
+ * of them. Any earlier reservation sharing an identifier is replaced, so re-balancing the same spend does not
29
+ * accumulate records.
30
+ *
31
+ * @example
32
+ * await service.addReservation({ identifiers: tx.identifiers(), inputs: { unshielded: ids }, ttl, ... });
33
+ *
34
+ * @param reservation - What the balanced transaction booked, and when the ledger stops accepting it
35
+ * @returns A promise that resolves once the record is held
36
+ */
37
+ addReservation: (reservation: PendingTransactions.Reservation) => Promise<void>;
38
+ /**
39
+ * Forgets the reservation holding any of `identifiers`, once its coins have been released.
40
+ *
41
+ * Call it wherever the booking it stands for ends: the transaction is registered and tracked on its own, the booking
42
+ * is reverted, or the record has expired. A record left behind outlives the coins it named.
43
+ *
44
+ * @example
45
+ * await service.clearReservation([...tx.identifiers()]);
46
+ *
47
+ * @param identifiers - Identifiers of the spend whose record is finished with; unknown ones are ignored
48
+ * @returns A promise that resolves once the record is gone
49
+ */
50
+ clearReservation: (identifiers: readonly string[]) => Promise<void>;
24
51
  };
25
52
  export type IndexerClientConnection = {
26
53
  indexerHttpUrl: string;
@@ -43,6 +70,8 @@ export declare class PendingTransactionsServiceImpl<TTransaction> implements Pen
43
70
  addPendingTransaction(tx: TTransaction, protocolVersion: Option.Option<ProtocolVersion.ProtocolVersion>): Promise<void>;
44
71
  clear(tx: TTransaction): Promise<void>;
45
72
  orphanBeyond(chainNow: ProtocolVersion.ProtocolVersion): Promise<void>;
73
+ addReservation(reservation: PendingTransactions.Reservation): Promise<void>;
74
+ clearReservation(identifiers: readonly string[]): Promise<void>;
46
75
  start(): Promise<void>;
47
76
  state(): rx.Observable<PendingTransactions.PendingTransactions<TTransaction>>;
48
77
  stop(): Promise<void>;
@@ -53,14 +82,25 @@ export type PendingTransactionsServiceEffect<TTransaction> = {
53
82
  addPendingTransaction: (tx: TTransaction, protocolVersion: Option.Option<ProtocolVersion.ProtocolVersion>) => Effect.Effect<void, never, never>;
54
83
  clear: (tx: TTransaction) => Effect.Effect<void, never, never>;
55
84
  orphanBeyond: (chainNow: ProtocolVersion.ProtocolVersion) => Effect.Effect<void, never, never>;
85
+ addReservation: (reservation: PendingTransactions.Reservation) => Effect.Effect<void, never, never>;
86
+ clearReservation: (identifiers: readonly string[]) => Effect.Effect<void, never, never>;
56
87
  };
57
88
  export declare class PendingTransactionsServiceEffectImpl<TTransaction> implements PendingTransactionsServiceEffect<TTransaction> {
58
89
  #private;
59
90
  static restore<TTransaction>(data: string, txTraits: PendingTransactions.VersionedTransactionTrait<TTransaction>): Effect.Effect<PendingTransactionsServiceEffectImpl<TTransaction>, ParseResult.ParseError>;
60
91
  constructor(txTraits: PendingTransactions.VersionedTransactionTrait<TTransaction>, initialState?: PendingTransactions.PendingTransactions<TTransaction>);
92
+ /**
93
+ * The state, published when it moves. The poll writes the ref on every tick whether or not anything expired, and a
94
+ * write publishes regardless, so consecutive identical values are dropped here. Every real change builds a new value,
95
+ * so comparing by identity keeps a change from being mistaken for a repeat.
96
+ */
61
97
  state(): Stream.Stream<PendingTransactions.PendingTransactions<TTransaction>>;
62
98
  startPolling(ticks: Stream.Stream<unknown>): Effect.Effect<void, Error, QueryClient | Scope.Scope>;
63
99
  addPendingTransaction(tx: TTransaction, protocolVersion: Option.Option<ProtocolVersion.ProtocolVersion>): Effect.Effect<void>;
100
+ addReservation(reservation: PendingTransactions.Reservation): Effect.Effect<void>;
101
+ clearReservation(identifiers: readonly string[]): Effect.Effect<void>;
102
+ /** Marks the reservations whose transactions the ledger would no longer accept. Runs on every poll. */
103
+ private expireReservations;
64
104
  clear(tx: TTransaction): Effect.Effect<void>;
65
105
  orphanBeyond(chainNow: ProtocolVersion.ProtocolVersion): Effect.Effect<void>;
66
106
  private saveResult;
@@ -48,6 +48,12 @@ export class PendingTransactionsServiceImpl {
48
48
  orphanBeyond(chainNow) {
49
49
  return this.#effectService.orphanBeyond(chainNow).pipe(Effect.runPromise);
50
50
  }
51
+ addReservation(reservation) {
52
+ return this.#effectService.addReservation(reservation).pipe(Effect.runPromise);
53
+ }
54
+ clearReservation(identifiers) {
55
+ return this.#effectService.clearReservation(identifiers).pipe(Effect.runPromise);
56
+ }
51
57
  start() {
52
58
  return this.#effectService.startPolling(Stream.tick(Duration.seconds(1))).pipe(Effect.provide(HttpQueryClient.layer({
53
59
  url: this.#configuration.indexerClientConnection.indexerHttpUrl,
@@ -70,11 +76,19 @@ export class PendingTransactionsServiceEffectImpl {
70
76
  this.#txTraits = txTraits;
71
77
  this.#state = SubscriptionRef.make(initialState ?? PendingTransactions.empty()).pipe(Effect.runSync); // Should not be here, but otherwise initialization would be too involved
72
78
  }
79
+ /**
80
+ * The state, published when it moves. The poll writes the ref on every tick whether or not anything expired, and a
81
+ * write publishes regardless, so consecutive identical values are dropped here. Every real change builds a new value,
82
+ * so comparing by identity keeps a change from being mistaken for a repeat.
83
+ */
73
84
  state() {
74
- return Stream.concat(Stream.fromEffect(SubscriptionRef.get(this.#state)), this.#state.changes);
85
+ return Stream.concat(Stream.fromEffect(SubscriptionRef.get(this.#state)), this.#state.changes).pipe(Stream.changesWith((a, b) => a === b));
75
86
  }
76
87
  startPolling(ticks) {
77
- return ticks.pipe(Stream.mapEffect(() => SubscriptionRef.get(this.#state)), Stream.mapConcat(PendingTransactions.allPending), Stream.mapConcatEffect((item) => {
88
+ return ticks.pipe(
89
+ // A reservation stands in for a transaction that was never submitted, so no indexer query can settle it. The
90
+ // only thing that can is its TTL, checked on the same beat that reaps the transactions.
91
+ Stream.tap(() => this.expireReservations()), Stream.mapEffect(() => SubscriptionRef.get(this.#state)), Stream.mapConcat(PendingTransactions.allPending), Stream.mapConcatEffect((item) => {
78
92
  return Effect.gen(this, function* () {
79
93
  const now = yield* DateTime.now;
80
94
  const trait = PendingTransactions.traitForVersion(this.#txTraits, item.protocolVersion);
@@ -105,6 +119,16 @@ export class PendingTransactionsServiceEffectImpl {
105
119
  return DateTime.now.pipe(Effect.andThen((now) => PendingTransactions.addPendingTransaction(state, tx, now, this.#txTraits, protocolVersion)));
106
120
  });
107
121
  }
122
+ addReservation(reservation) {
123
+ return SubscriptionRef.update(this.#state, (state) => PendingTransactions.addReservation(state, reservation));
124
+ }
125
+ clearReservation(identifiers) {
126
+ return SubscriptionRef.update(this.#state, (state) => PendingTransactions.clearReservation(state, identifiers));
127
+ }
128
+ /** Marks the reservations whose transactions the ledger would no longer accept. Runs on every poll. */
129
+ expireReservations() {
130
+ return SubscriptionRef.updateEffect(this.#state, (state) => DateTime.now.pipe(Effect.map((now) => PendingTransactions.expireReservations(state, now))));
131
+ }
108
132
  clear(tx) {
109
133
  return SubscriptionRef.update(this.#state, (state) => {
110
134
  return PendingTransactions.clear(state, tx, this.#txTraits);
@@ -135,6 +159,10 @@ export class PendingTransactionsServiceEffectImpl {
135
159
  return pipe(Effect.logWarning(error, 'Observed error in PendingTransactionsService, retrying'), Effect.as(fallback));
136
160
  }));
137
161
  return pipe(result.transactions, Iterable.filterMap((res) => {
162
+ // Only RegularTransaction carries the identifiers and transactionResult this matching
163
+ // needs — and wallet-submitted transactions are always regular. Narrowing to it (rather
164
+ // than excluding known other variants) keeps future schema variants ignored instead of
165
+ // breaking compilation, as BridgeClaimTransaction (indexer 4.4.0) once did.
138
166
  if (res.__typename !== 'RegularTransaction') {
139
167
  return Option.none();
140
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@midnight-ntwrk/wallet-sdk-capabilities",
3
- "version": "4.0.0-rc.0",
3
+ "version": "4.0.0-rc.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -65,9 +65,9 @@
65
65
  "@midnight-ntwrk/ledger-v8": "^8.1.0",
66
66
  "@midnight-ntwrk/zkir-v2": "^2.1.0",
67
67
  "@midnightntwrk/ledger-v9": "1.0.0-rc.5",
68
- "@midnight-ntwrk/wallet-sdk-abstractions": "3.0.0-rc.0",
69
- "@midnight-ntwrk/wallet-sdk-indexer-client": "2.0.0-rc.0",
70
- "@midnight-ntwrk/wallet-sdk-node-client": "2.0.0-rc.0",
68
+ "@midnight-ntwrk/wallet-sdk-abstractions": "3.0.0-rc.1",
69
+ "@midnight-ntwrk/wallet-sdk-indexer-client": "2.0.0-rc.1",
70
+ "@midnight-ntwrk/wallet-sdk-node-client": "2.0.0-rc.1",
71
71
  "@midnight-ntwrk/wallet-sdk-prover-client": "2.0.0-rc.0",
72
72
  "@midnight-ntwrk/wallet-sdk-utilities": "1.2.2-rc.0",
73
73
  "effect": "^3.19.19",
@@ -76,8 +76,8 @@
76
76
  "devDependencies": {
77
77
  "@effect/cluster": "0.60.2",
78
78
  "@effect/experimental": "0.61.1",
79
- "@effect/platform": "0.97.1",
80
- "@effect/platform-node": "0.108.1",
79
+ "@effect/platform": "0.97.2",
80
+ "@effect/platform-node": "0.108.2",
81
81
  "@effect/rpc": "0.76.2",
82
82
  "@effect/sql": "0.52.1",
83
83
  "@effect/workflow": "0.19.1",