@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 +1 -0
- package/dist/index.js +1 -0
- package/dist/liveness/index.d.ts +2 -0
- package/dist/liveness/index.js +14 -0
- package/dist/liveness/livenessReads.d.ts +73 -0
- package/dist/liveness/livenessReads.js +228 -0
- package/dist/liveness/livenessService.d.ts +137 -0
- package/dist/liveness/livenessService.js +201 -0
- package/dist/pendingTransactions/pendingTransactions.d.ts +102 -1
- package/dist/pendingTransactions/pendingTransactions.js +101 -1
- package/dist/pendingTransactions/pendingTransactionsService.d.ts +40 -0
- package/dist/pendingTransactions/pendingTransactionsService.js +30 -2
- package/package.json +6 -6
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,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 {
|
|
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(
|
|
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.
|
|
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.
|
|
69
|
-
"@midnight-ntwrk/wallet-sdk-indexer-client": "2.0.0-rc.
|
|
70
|
-
"@midnight-ntwrk/wallet-sdk-node-client": "2.0.0-rc.
|
|
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.
|
|
80
|
-
"@effect/platform-node": "0.108.
|
|
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",
|