@midnightntwrk/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.
Files changed (60) hide show
  1. package/README.md +11 -6
  2. package/dist/Restore.d.ts +40 -0
  3. package/dist/Restore.js +63 -0
  4. package/dist/ShieldedWallet.d.ts +248 -62
  5. package/dist/ShieldedWallet.js +366 -76
  6. package/dist/ShieldedWalletAPI.d.ts +179 -0
  7. package/dist/ShieldedWalletAPI.js +75 -0
  8. package/dist/SingleVariantShieldedWallet.d.ts +35 -0
  9. package/dist/SingleVariantShieldedWallet.js +240 -0
  10. package/dist/index.d.ts +4 -1
  11. package/dist/index.js +4 -1
  12. package/dist/v1/CoinsAndBalances.d.ts +1 -1
  13. package/dist/v1/CoreWallet.d.ts +48 -1
  14. package/dist/v1/CoreWallet.js +54 -1
  15. package/dist/v1/Migration.d.ts +95 -0
  16. package/dist/v1/Migration.js +82 -0
  17. package/dist/v1/RunningV1Variant.d.ts +1 -1
  18. package/dist/v1/RunningV1Variant.js +37 -8
  19. package/dist/v1/Serialization.js +1 -1
  20. package/dist/v1/Sync.d.ts +132 -9
  21. package/dist/v1/Sync.js +244 -35
  22. package/dist/v1/Transacting.d.ts +1 -1
  23. package/dist/v1/Transacting.js +1 -1
  24. package/dist/v1/TransactionHistory.d.ts +9 -2
  25. package/dist/v1/TransactionHistory.js +26 -5
  26. package/dist/v1/TransactionOps.d.ts +1 -1
  27. package/dist/v1/V1Builder.d.ts +87 -26
  28. package/dist/v1/V1Builder.js +109 -8
  29. package/dist/v1/WalletError.d.ts +1 -1
  30. package/dist/v1/index.d.ts +2 -1
  31. package/dist/v1/index.js +5 -1
  32. package/dist/v2/CoinsAndBalances.d.ts +24 -0
  33. package/dist/v2/CoinsAndBalances.js +41 -0
  34. package/dist/v2/CoreWallet.d.ts +162 -0
  35. package/dist/v2/CoreWallet.js +305 -0
  36. package/dist/v2/Keys.d.ts +8 -0
  37. package/dist/v2/Keys.js +16 -0
  38. package/dist/v2/Migration.d.ts +112 -0
  39. package/dist/v2/Migration.js +96 -0
  40. package/dist/v2/RunningV2Variant.d.ts +40 -0
  41. package/dist/v2/RunningV2Variant.js +127 -0
  42. package/dist/v2/Serialization.d.ts +12 -0
  43. package/dist/v2/Serialization.js +93 -0
  44. package/dist/v2/Sync.d.ts +208 -0
  45. package/dist/v2/Sync.js +436 -0
  46. package/dist/v2/Transacting.d.ts +50 -0
  47. package/dist/v2/Transacting.js +258 -0
  48. package/dist/v2/TransactionHistory.d.ts +100 -0
  49. package/dist/v2/TransactionHistory.js +132 -0
  50. package/dist/v2/TransactionImbalances.d.ts +10 -0
  51. package/dist/v2/TransactionImbalances.js +30 -0
  52. package/dist/v2/TransactionOps.d.ts +17 -0
  53. package/dist/v2/TransactionOps.js +73 -0
  54. package/dist/v2/V2Builder.d.ts +154 -0
  55. package/dist/v2/V2Builder.js +273 -0
  56. package/dist/v2/WalletError.d.ts +74 -0
  57. package/dist/v2/WalletError.js +53 -0
  58. package/dist/v2/index.d.ts +13 -0
  59. package/dist/v2/index.js +26 -0
  60. 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 '@midnightntwrk/ledger-v9';
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 '@midnightntwrk/wallet-sdk-abstractions';
15
16
  import { CoreWallet } from './CoreWallet.js';
16
- import { getLastBlock, getBlockEventsFrom, } from '@midnightntwrk/wallet-sdk-capabilities/simulation';
17
- import { ZswapEvents } from '@midnightntwrk/wallet-sdk-indexer-client';
18
- import { ConnectionHelper, WsSubscriptionClient } from '@midnightntwrk/wallet-sdk-indexer-client/effect';
17
+ import { V8 } from '@midnightntwrk/wallet-sdk-capabilities/simulation';
18
+ import { BlockHash, ZswapEventTip, ZswapEvents } from '@midnightntwrk/wallet-sdk-indexer-client';
19
+ import { ConnectionHelper, HttpQueryClient, WsSubscriptionClient, } from '@midnightntwrk/wallet-sdk-indexer-client/effect';
19
20
  import { SyncWalletError } from './WalletError.js';
20
21
  import { WsURL } from '@midnightntwrk/wallet-sdk-utilities/networking';
21
22
  import { EitherOps } from '@midnightntwrk/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
- event: LedgerEventSchema,
123
+ raw: Schema.String,
67
124
  });
68
- const EventsSyncUpdateFromPayload = Schema.transformOrFail(EventsSyncUpdatePayload, EventsSyncUpdate, {
69
- decode: (input) => pipe(Schema.decodeUnknownEither(HexedLedgerEvent)(input.raw), Either.map((event) => ({
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
- event,
75
- })), Either.mapLeft((error) => new ParseResult.Unexpected(error, 'Failed to decode ledger event payload')), EitherOps.toEffect),
76
- encode: (output) => pipe(Schema.encodeEither(HexedLedgerEvent)(output.event), Either.map((raw) => ({
77
- id: output.id,
78
- raw,
79
- protocolVersion: output.protocolVersion,
80
- maxId: output.maxId,
81
- })), Either.mapLeft((error) => new ParseResult.Unexpected(error, 'Failed to encode ledger event payload')), EitherOps.toEffect),
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
- return batchSpacing > 0
128
- ? Stream.schedule(eventsStream, Schedule.spaced(Duration.millis(batchSpacing)))
129
- : eventsStream;
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, { changes: [], protocolVersion: Number(state.protocolVersion) }];
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 [newState, newChanges] = freshUpdates.length === 0
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, freshUpdates.map((u) => u.event));
152
- const updatedState = CoreWallet.updateProgress(newState, {
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: freshUpdates.length === 0 ? appliedIndex : BigInt(freshUpdates.at(-1).id),
347
+ appliedIndex: applied.length === 0 ? appliedIndex : BigInt(applied.at(-1).id),
155
348
  isConnected: true,
156
349
  });
157
- return [updatedState, { changes: newChanges, protocolVersion: lastUpdate.protocolVersion }];
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
- const events = [...getBlockEventsFrom(simulatorState, state.progress.appliedIndex)];
197
- const [newState, newChanges] = CoreWallet.replayEventsWithChanges(state, secretKeys, events);
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: lastBlock.number + 1n,
405
+ CoreWallet.updateProgress(annotateVersion(newState, observedVersion), {
406
+ appliedIndex: lastAppliedBlock === undefined ? state.progress.appliedIndex : lastAppliedBlock.number + 1n,
201
407
  }),
202
- { changes: newChanges, protocolVersion: Number(state.protocolVersion) },
408
+ {
409
+ changes: newChanges,
410
+ protocolVersion: lastAppliedBlock === undefined ? Number(state.protocolVersion) : Number(lastAppliedBlock.protocolVersion),
411
+ },
203
412
  ];
204
413
  },
205
414
  };
@@ -1,4 +1,4 @@
1
- import * as ledger from '@midnightntwrk/ledger-v9';
1
+ import * as ledger from '@midnight-ntwrk/ledger-v8';
2
2
  import { type NetworkId } from '@midnightntwrk/wallet-sdk-abstractions';
3
3
  import { Array as Arr, Either } from 'effect';
4
4
  import { CoreWallet } from './CoreWallet.js';
@@ -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 '@midnightntwrk/ledger-v9';
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 '@midnightntwrk/wallet-sdk-utilities';
16
16
  import { CoreWallet } from './CoreWallet.js';
@@ -1,6 +1,6 @@
1
1
  import { TransactionHistoryStorage } from '@midnightntwrk/wallet-sdk-abstractions';
2
- import type * as ledger from '@midnightntwrk/ledger-v9';
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
- const tx = result.transactions[0];
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, Effect.retry(Schedule.exponential(Duration.seconds(1)).pipe(Schedule.compose(Schedule.recurs(3)))), Effect.mapError((cause) => new TransactionHistoryError({
83
- message: `Failed to fetch transaction metadata for ${hash}`,
84
- cause,
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 '@midnightntwrk/wallet-sdk-capabilities';
2
2
  import { type TransactionImbalances } from './TransactionImbalances.js';
3
- import type * as ledger from '@midnightntwrk/ledger-v9';
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;