@midnight-ntwrk/wallet-sdk-shielded 4.0.0-beta.2 → 4.0.0-beta.3
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/README.md +11 -6
- package/dist/Restore.d.ts +40 -0
- package/dist/Restore.js +63 -0
- package/dist/ShieldedWallet.d.ts +248 -62
- package/dist/ShieldedWallet.js +366 -76
- package/dist/ShieldedWalletAPI.d.ts +179 -0
- package/dist/ShieldedWalletAPI.js +75 -0
- package/dist/SingleVariantShieldedWallet.d.ts +35 -0
- package/dist/SingleVariantShieldedWallet.js +240 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/v1/CoinsAndBalances.d.ts +1 -1
- package/dist/v1/CoreWallet.d.ts +48 -1
- package/dist/v1/CoreWallet.js +54 -1
- package/dist/v1/Migration.d.ts +95 -0
- package/dist/v1/Migration.js +82 -0
- package/dist/v1/RunningV1Variant.d.ts +1 -1
- package/dist/v1/RunningV1Variant.js +37 -8
- package/dist/v1/Serialization.js +1 -1
- package/dist/v1/Sync.d.ts +132 -9
- package/dist/v1/Sync.js +244 -35
- package/dist/v1/Transacting.d.ts +1 -1
- package/dist/v1/Transacting.js +1 -1
- package/dist/v1/TransactionHistory.d.ts +9 -2
- package/dist/v1/TransactionHistory.js +26 -5
- package/dist/v1/TransactionOps.d.ts +1 -1
- package/dist/v1/V1Builder.d.ts +87 -26
- package/dist/v1/V1Builder.js +109 -8
- package/dist/v1/WalletError.d.ts +1 -1
- package/dist/v1/index.d.ts +2 -1
- package/dist/v1/index.js +5 -1
- package/dist/v2/CoinsAndBalances.d.ts +24 -0
- package/dist/v2/CoinsAndBalances.js +41 -0
- package/dist/v2/CoreWallet.d.ts +162 -0
- package/dist/v2/CoreWallet.js +305 -0
- package/dist/v2/Keys.d.ts +8 -0
- package/dist/v2/Keys.js +16 -0
- package/dist/v2/Migration.d.ts +112 -0
- package/dist/v2/Migration.js +96 -0
- package/dist/v2/RunningV2Variant.d.ts +40 -0
- package/dist/v2/RunningV2Variant.js +127 -0
- package/dist/v2/Serialization.d.ts +12 -0
- package/dist/v2/Serialization.js +93 -0
- package/dist/v2/Sync.d.ts +208 -0
- package/dist/v2/Sync.js +436 -0
- package/dist/v2/Transacting.d.ts +50 -0
- package/dist/v2/Transacting.js +258 -0
- package/dist/v2/TransactionHistory.d.ts +100 -0
- package/dist/v2/TransactionHistory.js +132 -0
- package/dist/v2/TransactionImbalances.d.ts +10 -0
- package/dist/v2/TransactionImbalances.js +30 -0
- package/dist/v2/TransactionOps.d.ts +17 -0
- package/dist/v2/TransactionOps.js +73 -0
- package/dist/v2/V2Builder.d.ts +154 -0
- package/dist/v2/V2Builder.js +273 -0
- package/dist/v2/WalletError.d.ts +74 -0
- package/dist/v2/WalletError.js +53 -0
- package/dist/v2/index.d.ts +13 -0
- package/dist/v2/index.js +26 -0
- package/package.json +16 -8
package/dist/v1/Sync.js
CHANGED
|
@@ -10,15 +10,49 @@
|
|
|
10
10
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
11
|
// See the License for the specific language governing permissions and
|
|
12
12
|
// limitations under the License.
|
|
13
|
-
import * as ledger from '@
|
|
14
|
-
import { Chunk, Duration, Effect, Either, ParseResult, pipe, Schedule, Schema, Stream } from 'effect';
|
|
13
|
+
import * as ledger from '@midnight-ntwrk/ledger-v8';
|
|
14
|
+
import { Chunk, Duration, Effect, Either, identity, Option, ParseResult, pipe, Schedule, Schema, Stream, } from 'effect';
|
|
15
|
+
import { ProtocolVersion } from '@midnight-ntwrk/wallet-sdk-abstractions';
|
|
15
16
|
import { CoreWallet } from './CoreWallet.js';
|
|
16
|
-
import {
|
|
17
|
-
import { ZswapEvents } from '@midnight-ntwrk/wallet-sdk-indexer-client';
|
|
18
|
-
import { ConnectionHelper, WsSubscriptionClient } from '@midnight-ntwrk/wallet-sdk-indexer-client/effect';
|
|
17
|
+
import { V8 } from '@midnight-ntwrk/wallet-sdk-capabilities/simulation';
|
|
18
|
+
import { BlockHash, ZswapEventTip, ZswapEvents } from '@midnight-ntwrk/wallet-sdk-indexer-client';
|
|
19
|
+
import { ConnectionHelper, HttpQueryClient, WsSubscriptionClient, } from '@midnight-ntwrk/wallet-sdk-indexer-client/effect';
|
|
19
20
|
import { SyncWalletError } from './WalletError.js';
|
|
20
21
|
import { WsURL } from '@midnight-ntwrk/wallet-sdk-utilities/networking';
|
|
21
22
|
import { EitherOps } from '@midnight-ntwrk/wallet-sdk-utilities';
|
|
23
|
+
/**
|
|
24
|
+
* Splits a batch of version-tagged items at the end of a variant's activation range.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* The split is positional, not a filter: everything from the first out-of-range item onwards is deferred, even if a
|
|
28
|
+
* later item reports an in-range version again. A batch is a contiguous slice of one timeline, so applying past a
|
|
29
|
+
* boundary and then resuming behind it would leave a hole no cursor can describe.
|
|
30
|
+
*
|
|
31
|
+
* This is the one place the boundary rule lives; both the indexer capability (per event) and the simulator capability
|
|
32
|
+
* (per block) go through it, so they cannot drift apart.
|
|
33
|
+
* @param items The batch, in source order.
|
|
34
|
+
* @param versionOf Reads the protocol version an item was reported at.
|
|
35
|
+
* @param activeRange The variant's half-open activation range.
|
|
36
|
+
* @returns The applied/deferred split and the version to record.
|
|
37
|
+
*/
|
|
38
|
+
export const splitAtVersionBoundary = (items, versionOf, activeRange) => {
|
|
39
|
+
const [, end] = activeRange;
|
|
40
|
+
const boundary = items.findIndex((item) => BigInt(versionOf(item)) >= end);
|
|
41
|
+
const [applied, deferred] = boundary < 0 ? [items, []] : [items.slice(0, boundary), items.slice(boundary)];
|
|
42
|
+
const signalling = deferred.at(0) ?? applied.at(-1);
|
|
43
|
+
return {
|
|
44
|
+
applied,
|
|
45
|
+
deferred,
|
|
46
|
+
observedVersion: signalling === undefined
|
|
47
|
+
? Option.none()
|
|
48
|
+
: Option.some(ProtocolVersion.ProtocolVersion(BigInt(versionOf(signalling)))),
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
/** Records a batch's observed protocol version on the state, monotonically. A batch that observed none is a no-op. */
|
|
52
|
+
export const annotateVersion = (state, observedVersion) => Option.match(observedVersion, {
|
|
53
|
+
onNone: () => state,
|
|
54
|
+
onSome: (version) => CoreWallet.withProtocolVersion(state, version),
|
|
55
|
+
});
|
|
22
56
|
const Uint8ArraySchema = Schema.declare((input) => input instanceof Uint8Array).annotations({
|
|
23
57
|
identifier: 'Uint8Array',
|
|
24
58
|
});
|
|
@@ -31,9 +65,19 @@ export const SecretKeysResource = {
|
|
|
31
65
|
};
|
|
32
66
|
},
|
|
33
67
|
};
|
|
68
|
+
export const VersionSignalSyncUpdate = {
|
|
69
|
+
create: (version, highestEventId) => {
|
|
70
|
+
return {
|
|
71
|
+
_tag: 'VersionSignal',
|
|
72
|
+
version,
|
|
73
|
+
highestEventId,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
34
77
|
export const WalletSyncUpdate = {
|
|
35
78
|
create: (updates, secretKeys) => {
|
|
36
79
|
return {
|
|
80
|
+
_tag: 'Events',
|
|
37
81
|
updates,
|
|
38
82
|
secretKeys,
|
|
39
83
|
};
|
|
@@ -59,27 +103,132 @@ const EventsSyncUpdatePayload = Schema.Struct({
|
|
|
59
103
|
protocolVersion: Schema.Number,
|
|
60
104
|
maxId: Schema.Number,
|
|
61
105
|
});
|
|
106
|
+
/**
|
|
107
|
+
* One event of the source's timeline, still encoded.
|
|
108
|
+
*
|
|
109
|
+
* @remarks
|
|
110
|
+
* The event is carried as the source served it rather than as a `ledger.Event`, because whether this ledger version may
|
|
111
|
+
* read it at all is not the source's question to answer. A batch spanning a protocol boundary carries events of the
|
|
112
|
+
* version that follows this one, which this ledger version cannot deserialize — the serialization header names a
|
|
113
|
+
* different version — and after a hand-over the inclusive cursor re-delivers an event of the version that preceded
|
|
114
|
+
* it, which it equally cannot. Both are ordinary, and neither is an error: they belong to the variant either side.
|
|
115
|
+
*
|
|
116
|
+
* So the bytes travel undecoded and only the capability, which knows the activation range, reads the ones it is about
|
|
117
|
+
* to apply — see {@link readEvent}. Decoding here would fail the whole batch on an event nobody intended to apply.
|
|
118
|
+
*/
|
|
62
119
|
export const EventsSyncUpdate = Schema.TaggedStruct('EventsSyncUpdate', {
|
|
63
120
|
id: Schema.Number,
|
|
64
121
|
protocolVersion: Schema.Number,
|
|
65
122
|
maxId: Schema.Number,
|
|
66
|
-
|
|
123
|
+
raw: Schema.String,
|
|
67
124
|
});
|
|
68
|
-
const EventsSyncUpdateFromPayload = Schema.
|
|
69
|
-
|
|
125
|
+
const EventsSyncUpdateFromPayload = Schema.transform(EventsSyncUpdatePayload, EventsSyncUpdate, {
|
|
126
|
+
strict: true,
|
|
127
|
+
decode: (input) => ({
|
|
70
128
|
_tag: 'EventsSyncUpdate',
|
|
71
129
|
id: input.id,
|
|
72
130
|
protocolVersion: input.protocolVersion,
|
|
73
131
|
maxId: input.maxId,
|
|
74
|
-
|
|
75
|
-
})
|
|
76
|
-
encode: (
|
|
77
|
-
id:
|
|
78
|
-
raw,
|
|
79
|
-
protocolVersion:
|
|
80
|
-
maxId:
|
|
81
|
-
})
|
|
132
|
+
raw: input.raw,
|
|
133
|
+
}),
|
|
134
|
+
encode: (update) => ({
|
|
135
|
+
id: update.id,
|
|
136
|
+
raw: update.raw,
|
|
137
|
+
protocolVersion: update.protocolVersion,
|
|
138
|
+
maxId: update.maxId,
|
|
139
|
+
}),
|
|
82
140
|
});
|
|
141
|
+
/**
|
|
142
|
+
* Reads an event this variant is going to apply.
|
|
143
|
+
*
|
|
144
|
+
* @remarks
|
|
145
|
+
* The counterpart of {@link EventsSyncUpdate} carrying its event encoded: a capability calls this on the batch prefix it
|
|
146
|
+
* owns, and never on what it defers. Failure here is a genuine one — an event this variant claimed and cannot read —
|
|
147
|
+
* and is raised rather than returned, because `SyncCapability.applyUpdate` is total in its own domain and the variant
|
|
148
|
+
* already turns a throw from it into a typed synchronization error.
|
|
149
|
+
* @param update The update to read.
|
|
150
|
+
* @returns The event it carries.
|
|
151
|
+
* @throws ParseError if the bytes are not an event this ledger version can deserialize.
|
|
152
|
+
*/
|
|
153
|
+
export const readEvent = (update) => Schema.decodeSync(HexedLedgerEvent)(update.raw);
|
|
154
|
+
/** How often the chain is asked its version when the configuration does not say. */
|
|
155
|
+
const DEFAULT_VERSION_WATCH_INTERVAL_MS = 30_000;
|
|
156
|
+
/**
|
|
157
|
+
* How long one event-id probe is given to produce its single answer.
|
|
158
|
+
*
|
|
159
|
+
* @remarks
|
|
160
|
+
* Only a stall guard. The probe subscribes at a cursor whose event provably exists, so the answer arrives at once or
|
|
161
|
+
* the transport is broken; without a bound, a half-open socket would leave the poll loop parked on a tick that never
|
|
162
|
+
* completes and the wallet would stop asking altogether. A bound turns that into a skipped tick.
|
|
163
|
+
*/
|
|
164
|
+
const EVENT_TIP_PROBE_TIMEOUT = Duration.seconds(10);
|
|
165
|
+
/**
|
|
166
|
+
* Asks the source how far its zswap event timeline goes.
|
|
167
|
+
*
|
|
168
|
+
* @remarks
|
|
169
|
+
* Over the event subscription rather than a query, because the schema has no query that reaches a zswap ledger event:
|
|
170
|
+
* `block`/`transactions` reach one only through a block that happens to contain zswap events, which on a quiet chain
|
|
171
|
+
* is precisely the block that does not. `maxId` is a property of the whole timeline, so any single event answers it —
|
|
172
|
+
* the first one delivered is taken and the subscription closed.
|
|
173
|
+
*
|
|
174
|
+
* The cursor is the wallet's own. The event it last applied provably exists, so the source answers immediately and has
|
|
175
|
+
* the least backfill to abandon; a wallet that has applied nothing asks from the start, where the first event is.
|
|
176
|
+
* @param config The sync configuration, for the keep-alive the source's subscriptions use.
|
|
177
|
+
* @param url Where to subscribe.
|
|
178
|
+
* @param resumeFrom The wallet's cursor; below zero means "from the very start".
|
|
179
|
+
* @returns The highest event id, or nothing if the source did not answer.
|
|
180
|
+
*/
|
|
181
|
+
const highestEventId = (config, url, resumeFrom) => pipe(ZswapEventTip.run({ id: resumeFrom < 0n ? null : Number(resumeFrom) }), Stream.runHead, Effect.map(Option.map((answer) => answer.zswapLedgerEvents.maxId)), Effect.provide(WsSubscriptionClient.layer({ url, keepAlive: config.indexerClientConnection.keepAlive })), Effect.scoped, Effect.timeout(EVENT_TIP_PROBE_TIMEOUT));
|
|
182
|
+
/**
|
|
183
|
+
* One check of the chain's protocol version, gated on the wallet being caught up on the source's event ids.
|
|
184
|
+
*
|
|
185
|
+
* @remarks
|
|
186
|
+
* The order of the two questions is load-bearing. The tip is read **first**: a tip reported at a version means the
|
|
187
|
+
* source has indexed through the block that carries it, so every event below it is already counted in the `maxId`
|
|
188
|
+
* read afterwards. Asked the other way round, an event indexed between the two answers could be one of the version
|
|
189
|
+
* that preceded the tip — unread, uncounted, and exactly what the gate exists to catch.
|
|
190
|
+
*
|
|
191
|
+
* Two short-circuits, both of them answers rather than shortcuts. A tip at or below the version the wallet already held
|
|
192
|
+
* is a signal that could only be a no-op, so no probe is opened and nothing is emitted — which is what keeps a
|
|
193
|
+
* settled wallet from polling a subscription for the rest of its life. And a tip whose commitment tree has never
|
|
194
|
+
* grown cannot have had a nullifier spent against it either, so such a chain provably holds no zswap event: nothing
|
|
195
|
+
* can be unread, and the signal goes out with `null`.
|
|
196
|
+
*
|
|
197
|
+
* Everything else is swallowed. A tick that fails says nothing about the chain, so it must neither reach the state nor
|
|
198
|
+
* take the sync stream down with it; the next tick is the retry, and costs nothing to wait for.
|
|
199
|
+
* @param config The sync configuration, for the indexer to ask.
|
|
200
|
+
* @param url Where to run the event-id probe.
|
|
201
|
+
* @param resumeFrom The wallet's cursor, for the probe.
|
|
202
|
+
* @param knownVersion The version the wallet already held when this stream opened — a lower bound on what it holds now.
|
|
203
|
+
* @returns The signal, or nothing when there is nothing to say or nobody said it.
|
|
204
|
+
*/
|
|
205
|
+
const readVersionSignal = (config, url, resumeFrom, knownVersion) => pipe(BlockHash.run({ offset: null }), Effect.provide(HttpQueryClient.layer({ url: config.indexerClientConnection.indexerHttpUrl })), Effect.scoped, Effect.flatMap((answer) => Option.match(Option.fromNullable(answer.block), {
|
|
206
|
+
onNone: () => Effect.succeedNone,
|
|
207
|
+
onSome: (tip) => BigInt(tip.protocolVersion) <= knownVersion
|
|
208
|
+
? Effect.succeedNone
|
|
209
|
+
: tip.zswapEndIndex === 0
|
|
210
|
+
? Effect.succeedSome(VersionSignalSyncUpdate.create(tip.protocolVersion, null))
|
|
211
|
+
: pipe(highestEventId(config, url, resumeFrom), Effect.map(Option.map((maxId) => VersionSignalSyncUpdate.create(tip.protocolVersion, maxId)))),
|
|
212
|
+
})), Effect.catchAll(() => Effect.succeedNone));
|
|
213
|
+
/**
|
|
214
|
+
* The chain's version, re-asked on a timer for as long as sync runs.
|
|
215
|
+
*
|
|
216
|
+
* @remarks
|
|
217
|
+
* Deliberately silent about ticks that say nothing: a tick that finds the chain where the wallet left it, or that
|
|
218
|
+
* cannot reach the chain at all, emits no element rather than an empty one, so nothing downstream has to know that
|
|
219
|
+
* polling is how the answer was arrived at.
|
|
220
|
+
* @param config The sync configuration, carrying the interval.
|
|
221
|
+
* @param url Where to run the event-id probe.
|
|
222
|
+
* @param resumeFrom The wallet's cursor, for the probe.
|
|
223
|
+
* @param knownVersion The version the wallet held when this stream opened.
|
|
224
|
+
* @returns The signals, or an empty stream when watching is turned off.
|
|
225
|
+
*/
|
|
226
|
+
const versionWatch = (config, url, resumeFrom, knownVersion) => {
|
|
227
|
+
const intervalMs = config.versionWatch?.intervalMs ?? DEFAULT_VERSION_WATCH_INTERVAL_MS;
|
|
228
|
+
return intervalMs <= 0
|
|
229
|
+
? Stream.empty
|
|
230
|
+
: pipe(Stream.fromSchedule(Schedule.spaced(Duration.millis(intervalMs))), Stream.mapEffect(() => readVersionSignal(config, url, resumeFrom, knownVersion)), Stream.filterMap(identity));
|
|
231
|
+
};
|
|
83
232
|
export const makeEventsSyncService = (config) => {
|
|
84
233
|
return {
|
|
85
234
|
updates: (state, secretKeys) => {
|
|
@@ -124,17 +273,54 @@ export const makeEventsSyncService = (config) => {
|
|
|
124
273
|
variables: (cursor) => ({ id: cursor < 0n ? null : Number(cursor) }),
|
|
125
274
|
key: (r) => BigInt(r.zswapLedgerEvents.id),
|
|
126
275
|
}), Stream.provideLayer(WsSubscriptionClient.layer({ url: indexerWsUrl, keepAlive: config.indexerClientConnection.keepAlive })), Stream.mapError((error) => new SyncWalletError(error)), Stream.mapEffect((subscription) => pipe(subscription.zswapLedgerEvents, Schema.decodeUnknownEither(EventsSyncUpdateFromPayload), Either.mapLeft((err) => new SyncWalletError(err)), EitherOps.toEffect)), Stream.groupedWithin(batchSize, batchTimeout), Stream.map(Chunk.toArray), Stream.map((data) => WalletSyncUpdate.create(data, secretKeys)));
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
276
|
+
const timeline = batchSpacing > 0 ? Stream.schedule(eventsStream, Schedule.spaced(Duration.millis(batchSpacing))) : eventsStream;
|
|
277
|
+
// The timeline is what the source is for, so it decides when the source is done: `haltStrategy: 'left'` stops the
|
|
278
|
+
// watcher with it rather than leaving a poll loop running against a stream nobody is reading. It has nothing to
|
|
279
|
+
// say about failure — a failing timeline still fails the merged stream, where the variant's retry can see it.
|
|
280
|
+
return Stream.merge(timeline, versionWatch(config, indexerWsUrl, resumeFrom, state.protocolVersion), {
|
|
281
|
+
haltStrategy: 'left',
|
|
282
|
+
});
|
|
130
283
|
},
|
|
131
284
|
};
|
|
132
285
|
};
|
|
286
|
+
/** The result of an update that observed nothing of the chain. */
|
|
287
|
+
const noChanges = (state) => ({
|
|
288
|
+
changes: [],
|
|
289
|
+
protocolVersion: Number(state.protocolVersion),
|
|
290
|
+
});
|
|
291
|
+
/**
|
|
292
|
+
* Folds what the chain said about its own version into the wallet state.
|
|
293
|
+
*
|
|
294
|
+
* @remarks
|
|
295
|
+
* The same recording the event path makes through {@link annotateVersion}, and nothing else: no cursor moves, no coin
|
|
296
|
+
* changes hands, no tree grows. A signal is an observation about the chain, not a piece of it.
|
|
297
|
+
*
|
|
298
|
+
* Unread events below the source's tip make the observation unsafe to record, and leave the state exactly as it was:
|
|
299
|
+
* the hand-over would park the cursor in front of history the next variant cannot read — and those events carry the
|
|
300
|
+
* version themselves, so nothing is lost by waiting for them. That is not an error; the next tick asks again.
|
|
301
|
+
*
|
|
302
|
+
* A version at or below the one already recorded needs no guard of its own — {@link annotateVersion} never goes
|
|
303
|
+
* backwards — so a source briefly answering from a lagging replica cannot drag a wallet back over a boundary.
|
|
304
|
+
* @param state The wallet to record on.
|
|
305
|
+
* @param update What the chain said, and how far its event timeline goes.
|
|
306
|
+
* @returns The wallet, annotated or untouched, and an empty result.
|
|
307
|
+
*/
|
|
308
|
+
const applyVersionSignal = (state, update) => {
|
|
309
|
+
const appliedIndex = state.progress?.appliedIndex ?? 0n;
|
|
310
|
+
const unreadEvents = update.highestEventId !== null && BigInt(update.highestEventId) > appliedIndex;
|
|
311
|
+
return [
|
|
312
|
+
unreadEvents ? state : annotateVersion(state, Option.some(ProtocolVersion.ProtocolVersion(BigInt(update.version)))),
|
|
313
|
+
noChanges(state),
|
|
314
|
+
];
|
|
315
|
+
};
|
|
133
316
|
export const makeEventsSyncCapability = () => {
|
|
134
317
|
return {
|
|
135
|
-
applyUpdate: (state, wrappedUpdate) => {
|
|
318
|
+
applyUpdate: (state, wrappedUpdate, activeRange) => {
|
|
319
|
+
if (wrappedUpdate._tag === 'VersionSignal') {
|
|
320
|
+
return applyVersionSignal(state, wrappedUpdate);
|
|
321
|
+
}
|
|
136
322
|
if (wrappedUpdate.updates.length === 0) {
|
|
137
|
-
return [state,
|
|
323
|
+
return [state, noChanges(state)];
|
|
138
324
|
}
|
|
139
325
|
// The subscription resumes at the last-applied index and its cursor is inclusive, so the
|
|
140
326
|
// boundary event (id === appliedIndex) is re-delivered. Replaying it would re-insert
|
|
@@ -144,17 +330,32 @@ export const makeEventsSyncCapability = () => {
|
|
|
144
330
|
// learns it is synced when no new events exist.
|
|
145
331
|
const appliedIndex = state.progress?.appliedIndex ?? 0n;
|
|
146
332
|
const freshUpdates = wrappedUpdate.updates.filter((u) => BigInt(u.id) > appliedIndex);
|
|
333
|
+
// The tip is a property of the source, not of what this variant chose to apply, so it is read from
|
|
334
|
+
// the batch tail even when the tail belongs to the next protocol version.
|
|
147
335
|
const lastUpdate = wrappedUpdate.updates.at(-1);
|
|
148
336
|
const highestRelevantWalletIndex = BigInt(lastUpdate.maxId);
|
|
149
|
-
const
|
|
337
|
+
const { applied, observedVersion } = splitAtVersionBoundary(freshUpdates, (update) => update.protocolVersion, activeRange);
|
|
338
|
+
// Read here and nowhere earlier: `applied` is exactly the slice this variant owns, so nothing outside its
|
|
339
|
+
// activation range — nor anything below its cursor — is ever handed to this ledger version's deserializer.
|
|
340
|
+
const [newState, newChanges] = applied.length === 0
|
|
150
341
|
? [state, []]
|
|
151
|
-
: CoreWallet.replayEventsWithChanges(state, wrappedUpdate.secretKeys,
|
|
152
|
-
|
|
342
|
+
: CoreWallet.replayEventsWithChanges(state, wrappedUpdate.secretKeys, applied.map(readEvent));
|
|
343
|
+
// `appliedIndex` stops at the last event this variant actually replayed. The next variant resumes one
|
|
344
|
+
// below it on an inclusive cursor, so the deferred suffix is re-fetched rather than skipped.
|
|
345
|
+
const updatedState = CoreWallet.updateProgress(annotateVersion(newState, observedVersion), {
|
|
153
346
|
highestRelevantWalletIndex,
|
|
154
|
-
appliedIndex:
|
|
347
|
+
appliedIndex: applied.length === 0 ? appliedIndex : BigInt(applied.at(-1).id),
|
|
155
348
|
isConnected: true,
|
|
156
349
|
});
|
|
157
|
-
return [
|
|
350
|
+
return [
|
|
351
|
+
updatedState,
|
|
352
|
+
{
|
|
353
|
+
changes: newChanges,
|
|
354
|
+
// Tags the changes that were actually produced, so tx-history records the version they were applied
|
|
355
|
+
// under rather than the one that is about to trigger the hand-over.
|
|
356
|
+
protocolVersion: applied.at(-1)?.protocolVersion ?? Number(state.protocolVersion),
|
|
357
|
+
},
|
|
358
|
+
];
|
|
158
359
|
},
|
|
159
360
|
};
|
|
160
361
|
};
|
|
@@ -166,7 +367,7 @@ export const makeSimulatorSyncService = (config) => {
|
|
|
166
367
|
// to avoid processing the same block twice.
|
|
167
368
|
let lastSeenBlockNumber;
|
|
168
369
|
return pipe(Stream.fromEffect(config.simulator.getLatestState()), Stream.concat(config.simulator.state$), Stream.filter((state) => {
|
|
169
|
-
const lastBlock = getLastBlock(state);
|
|
370
|
+
const lastBlock = V8.getLastBlock(state);
|
|
170
371
|
if (lastBlock === undefined) {
|
|
171
372
|
return false; // Skip blank state
|
|
172
373
|
}
|
|
@@ -183,23 +384,31 @@ export const makeSimulatorSyncService = (config) => {
|
|
|
183
384
|
};
|
|
184
385
|
export const makeSimulatorSyncCapability = () => {
|
|
185
386
|
return {
|
|
186
|
-
applyUpdate: (state, update) => {
|
|
387
|
+
applyUpdate: (state, update, activeRange) => {
|
|
187
388
|
const { update: simulatorState, secretKeys } = update;
|
|
188
|
-
const lastBlock = getLastBlock(simulatorState);
|
|
389
|
+
const lastBlock = V8.getLastBlock(simulatorState);
|
|
189
390
|
if (lastBlock === undefined) {
|
|
190
391
|
return [state, { changes: [], protocolVersion: Number(state.protocolVersion) }];
|
|
191
392
|
}
|
|
192
|
-
// Get all events from blocks starting at appliedIndex (the next block to process).
|
|
193
393
|
// appliedIndex semantics: the first block number we haven't processed yet.
|
|
194
394
|
// Initial: appliedIndex = 0 (haven't processed any blocks)
|
|
195
395
|
// After processing block N: appliedIndex = N + 1 (next block to process)
|
|
196
|
-
|
|
197
|
-
|
|
396
|
+
//
|
|
397
|
+
// The boundary is applied at block granularity here — a block carries exactly one protocol version,
|
|
398
|
+
// stamped when it was produced — but it is the same rule and the same helper the indexer path uses.
|
|
399
|
+
const pending = simulatorState.blocks.filter((block) => block.number >= state.progress.appliedIndex);
|
|
400
|
+
const { applied, observedVersion } = splitAtVersionBoundary(pending, (block) => Number(block.protocolVersion), activeRange);
|
|
401
|
+
const events = applied.flatMap((block) => block.transactions.flatMap((tx) => tx.result.events));
|
|
402
|
+
const [newState, newChanges] = applied.length === 0 ? [state, []] : CoreWallet.replayEventsWithChanges(state, secretKeys, events);
|
|
403
|
+
const lastAppliedBlock = applied.at(-1);
|
|
198
404
|
return [
|
|
199
|
-
CoreWallet.updateProgress(newState, {
|
|
200
|
-
appliedIndex:
|
|
405
|
+
CoreWallet.updateProgress(annotateVersion(newState, observedVersion), {
|
|
406
|
+
appliedIndex: lastAppliedBlock === undefined ? state.progress.appliedIndex : lastAppliedBlock.number + 1n,
|
|
201
407
|
}),
|
|
202
|
-
{
|
|
408
|
+
{
|
|
409
|
+
changes: newChanges,
|
|
410
|
+
protocolVersion: lastAppliedBlock === undefined ? Number(state.protocolVersion) : Number(lastAppliedBlock.protocolVersion),
|
|
411
|
+
},
|
|
203
412
|
];
|
|
204
413
|
},
|
|
205
414
|
};
|
package/dist/v1/Transacting.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as ledger from '@
|
|
1
|
+
import * as ledger from '@midnight-ntwrk/ledger-v8';
|
|
2
2
|
import { type NetworkId } from '@midnight-ntwrk/wallet-sdk-abstractions';
|
|
3
3
|
import { Array as Arr, Either } from 'effect';
|
|
4
4
|
import { CoreWallet } from './CoreWallet.js';
|
package/dist/v1/Transacting.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
11
|
// See the License for the specific language governing permissions and
|
|
12
12
|
// limitations under the License.
|
|
13
|
-
import * as ledger from '@
|
|
13
|
+
import * as ledger from '@midnight-ntwrk/ledger-v8';
|
|
14
14
|
import { Array as Arr, Either, Option, pipe, Record } from 'effect';
|
|
15
15
|
import { ArrayOps } from '@midnight-ntwrk/wallet-sdk-utilities';
|
|
16
16
|
import { CoreWallet } from './CoreWallet.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TransactionHistoryStorage } from '@midnight-ntwrk/wallet-sdk-abstractions';
|
|
2
|
-
import type * as ledger from '@
|
|
3
|
-
import { Effect, Schema } from 'effect';
|
|
2
|
+
import type * as ledger from '@midnight-ntwrk/ledger-v8';
|
|
3
|
+
import { Duration, Effect, Schema } from 'effect';
|
|
4
4
|
import { TransactionHistoryError } from './WalletError.js';
|
|
5
5
|
export declare const QualifiedShieldedCoinInfoSchema: Schema.Struct<{
|
|
6
6
|
type: typeof Schema.String;
|
|
@@ -72,6 +72,13 @@ export type DefaultTransactionHistoryConfiguration = {
|
|
|
72
72
|
indexerClientConnection: {
|
|
73
73
|
indexerHttpUrl: string;
|
|
74
74
|
};
|
|
75
|
+
/**
|
|
76
|
+
* How long to keep re-querying the indexer for a transaction's details before giving up, when the WS event arrives
|
|
77
|
+
* ahead of HTTP ingestion. This is a bounded window, not a guarantee: if the indexer lags beyond it the shielded
|
|
78
|
+
* section is lost (the change is not re-processed, even across restarts) and the failure is logged. Default: 2
|
|
79
|
+
* minutes.
|
|
80
|
+
*/
|
|
81
|
+
transactionDetailsRetryWindow?: Duration.DurationInput;
|
|
75
82
|
};
|
|
76
83
|
export type TransactionDetails = {
|
|
77
84
|
hash: string;
|
|
@@ -60,12 +60,17 @@ const gotFinalizedShielded = (txHistoryStorage, input) => Effect.tryPromise({
|
|
|
60
60
|
export const makeDefaultTransactionHistoryService = (config, _getContext) => {
|
|
61
61
|
const txHistoryStorage = config.txHistoryStorage;
|
|
62
62
|
const queryClientLayer = HttpQueryClient.layer({ url: config.indexerClientConnection.indexerHttpUrl });
|
|
63
|
+
const retryWindow = config.transactionDetailsRetryWindow ?? Duration.minutes(2);
|
|
63
64
|
return {
|
|
64
65
|
put: (changes, metadata, protocolVersion) => gotFinalizedShielded(txHistoryStorage, convertUpdateToFinalizedInput(changes, metadata, protocolVersion)),
|
|
65
66
|
getTransactionDetails: (hash) => Effect.gen(function* () {
|
|
66
67
|
const statusQuery = yield* TransactionHistoryDetail;
|
|
67
68
|
const result = yield* statusQuery({ transactionHash: hash });
|
|
68
|
-
|
|
69
|
+
// The WS ZswapEvents stream can deliver a relevant event before the indexer's HTTP `transactions(...)`
|
|
70
|
+
// endpoint has ingested the same hash — a normal race on any deployed network. In that window the array
|
|
71
|
+
// is empty; fail with a *typed* error so the retry schedule below engages and re-queries once the indexer
|
|
72
|
+
// catches up (rather than dereferencing `undefined` and dying with an unretriable defect).
|
|
73
|
+
const tx = yield* EArray.head(result.transactions).pipe(Effect.orElseFail(() => new TransactionHistoryError({ message: `Indexer has not yet indexed transaction ${hash}` })));
|
|
69
74
|
const rawStatus = tx.__typename === 'RegularTransaction' ? tx.transactionResult.status : undefined;
|
|
70
75
|
const status = rawStatus === 'FAILURE' || rawStatus === 'PARTIAL_SUCCESS' ? rawStatus : 'SUCCESS';
|
|
71
76
|
const identifiers = tx.__typename === 'RegularTransaction' ? tx.identifiers : [];
|
|
@@ -79,10 +84,26 @@ export const makeDefaultTransactionHistoryService = (config, _getContext) => {
|
|
|
79
84
|
status,
|
|
80
85
|
identifiers,
|
|
81
86
|
};
|
|
82
|
-
}).pipe(Effect.provide(queryClientLayer), Effect.scoped,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
}).pipe(Effect.provide(queryClientLayer), Effect.scoped,
|
|
88
|
+
// Retry for a bounded window (default 2 min) while the indexer catches up. The exponential delay is capped
|
|
89
|
+
// at 10s (`either` takes the earlier of the two schedules) so probes keep landing every ≤10s late in the
|
|
90
|
+
// window — uncapped doubling would leave minute-long gaps and overshoot the window. Jitter the delays so a
|
|
91
|
+
// batch of concurrent lookups that all hit the indexer-lag race don't retry in lockstep against an
|
|
92
|
+
// already-behind indexer. Beyond the window we give up — the change is not re-processed, so the caller logs
|
|
93
|
+
// the loss. Only the typed "not yet indexed" failure is transient; anything else (bad URL, 4xx, schema
|
|
94
|
+
// mismatch) cannot succeed by waiting, so it fails fast instead of holding a fan-out slot for the whole
|
|
95
|
+
// window.
|
|
96
|
+
Effect.retry({
|
|
97
|
+
schedule: Schedule.exponential(Duration.seconds(1)).pipe(Schedule.either(Schedule.spaced(Duration.seconds(10))), Schedule.jittered, Schedule.upTo(retryWindow)),
|
|
98
|
+
while: (error) => error instanceof TransactionHistoryError,
|
|
99
|
+
}),
|
|
100
|
+
// Let our own "not yet indexed" error through untouched; only wrap the indexer query's ClientError/ServerError.
|
|
101
|
+
Effect.mapError((error) => error instanceof TransactionHistoryError
|
|
102
|
+
? error
|
|
103
|
+
: new TransactionHistoryError({
|
|
104
|
+
message: `Failed to fetch transaction metadata for ${hash}`,
|
|
105
|
+
cause: error,
|
|
106
|
+
}))),
|
|
86
107
|
};
|
|
87
108
|
};
|
|
88
109
|
export const makeSimulatorTransactionHistoryService = (config, _getContext) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Imbalances } from '@midnight-ntwrk/wallet-sdk-capabilities';
|
|
2
2
|
import { type TransactionImbalances } from './TransactionImbalances.js';
|
|
3
|
-
import type * as ledger from '@
|
|
3
|
+
import type * as ledger from '@midnight-ntwrk/ledger-v8';
|
|
4
4
|
export type TransactionOps<Tx> = {
|
|
5
5
|
getImbalances(tx: Tx): TransactionImbalances;
|
|
6
6
|
id(tx: Tx): string;
|