@midnightntwrk/wallet-sdk-shielded 4.0.0-beta.2 → 4.0.0-beta.4

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 (61) hide show
  1. package/README.md +18 -9
  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 +272 -36
  22. package/dist/v1/Transacting.d.ts +1 -1
  23. package/dist/v1/Transacting.js +3 -2
  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 +27 -2
  30. package/dist/v1/WalletError.js +16 -0
  31. package/dist/v1/index.d.ts +2 -1
  32. package/dist/v1/index.js +5 -1
  33. package/dist/v2/CoinsAndBalances.d.ts +24 -0
  34. package/dist/v2/CoinsAndBalances.js +41 -0
  35. package/dist/v2/CoreWallet.d.ts +162 -0
  36. package/dist/v2/CoreWallet.js +305 -0
  37. package/dist/v2/Keys.d.ts +8 -0
  38. package/dist/v2/Keys.js +16 -0
  39. package/dist/v2/Migration.d.ts +112 -0
  40. package/dist/v2/Migration.js +96 -0
  41. package/dist/v2/RunningV2Variant.d.ts +40 -0
  42. package/dist/v2/RunningV2Variant.js +127 -0
  43. package/dist/v2/Serialization.d.ts +12 -0
  44. package/dist/v2/Serialization.js +93 -0
  45. package/dist/v2/Sync.d.ts +208 -0
  46. package/dist/v2/Sync.js +463 -0
  47. package/dist/v2/Transacting.d.ts +50 -0
  48. package/dist/v2/Transacting.js +259 -0
  49. package/dist/v2/TransactionHistory.d.ts +100 -0
  50. package/dist/v2/TransactionHistory.js +132 -0
  51. package/dist/v2/TransactionImbalances.d.ts +10 -0
  52. package/dist/v2/TransactionImbalances.js +30 -0
  53. package/dist/v2/TransactionOps.d.ts +17 -0
  54. package/dist/v2/TransactionOps.js +73 -0
  55. package/dist/v2/V2Builder.d.ts +154 -0
  56. package/dist/v2/V2Builder.js +273 -0
  57. package/dist/v2/WalletError.d.ts +99 -0
  58. package/dist/v2/WalletError.js +69 -0
  59. package/dist/v2/index.d.ts +13 -0
  60. package/dist/v2/index.js +26 -0
  61. package/package.json +16 -8
package/dist/v1/Sync.js CHANGED
@@ -10,15 +10,69 @@
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 { Array as Arr, 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';
19
- import { SyncWalletError } from './WalletError.js';
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';
20
+ import { OutOfOrderSyncUpdateError, 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
+ /**
52
+ * Checks that freshly delivered events arrive in strictly ascending id order.
53
+ *
54
+ * @remarks
55
+ * Order is all that can be checked, never contiguity: zswap events share one id sequence with dust and contract events
56
+ * in the indexer, so gaps in a zswap stream are normal. A skipped commitment-inserting event is caught by the
57
+ * ledger's own insertion check instead. An empty batch is trivially ordered.
58
+ * @param fresh The events left after the cursor filter, in source order.
59
+ * @param appliedIndex The highest event id already applied; the first fresh id must exceed it.
60
+ * @returns The batch unchanged, or the first id that fails to exceed its predecessor as an
61
+ * {@link OutOfOrderSyncUpdateError}.
62
+ */
63
+ const expectAscending = (fresh, appliedIndex) => pipe(Arr.zipWith(fresh, Arr.prepend(Arr.map(fresh, (item) => BigInt(item.id)), appliedIndex), (item, previous) => ({ item, previous })), Arr.findFirst(({ item, previous }) => BigInt(item.id) <= previous), Option.match({
64
+ onNone: () => Either.right(fresh),
65
+ onSome: ({ item, previous }) => Either.left(new OutOfOrderSyncUpdateError({
66
+ message: `Zswap event ${item.id} delivered at or below its predecessor ${previous}; batch refused, applied index stays at ${appliedIndex}`,
67
+ expected: previous,
68
+ received: BigInt(item.id),
69
+ })),
70
+ }));
71
+ /** Records a batch's observed protocol version on the state, monotonically. A batch that observed none is a no-op. */
72
+ export const annotateVersion = (state, observedVersion) => Option.match(observedVersion, {
73
+ onNone: () => state,
74
+ onSome: (version) => CoreWallet.withProtocolVersion(state, version),
75
+ });
22
76
  const Uint8ArraySchema = Schema.declare((input) => input instanceof Uint8Array).annotations({
23
77
  identifier: 'Uint8Array',
24
78
  });
@@ -31,9 +85,19 @@ export const SecretKeysResource = {
31
85
  };
32
86
  },
33
87
  };
88
+ export const VersionSignalSyncUpdate = {
89
+ create: (version, highestEventId) => {
90
+ return {
91
+ _tag: 'VersionSignal',
92
+ version,
93
+ highestEventId,
94
+ };
95
+ },
96
+ };
34
97
  export const WalletSyncUpdate = {
35
98
  create: (updates, secretKeys) => {
36
99
  return {
100
+ _tag: 'Events',
37
101
  updates,
38
102
  secretKeys,
39
103
  };
@@ -59,27 +123,132 @@ const EventsSyncUpdatePayload = Schema.Struct({
59
123
  protocolVersion: Schema.Number,
60
124
  maxId: Schema.Number,
61
125
  });
126
+ /**
127
+ * One event of the source's timeline, still encoded.
128
+ *
129
+ * @remarks
130
+ * The event is carried as the source served it rather than as a `ledger.Event`, because whether this ledger version may
131
+ * read it at all is not the source's question to answer. A batch spanning a protocol boundary carries events of the
132
+ * version that follows this one, which this ledger version cannot deserialize — the serialization header names a
133
+ * different version — and after a hand-over the inclusive cursor re-delivers an event of the version that preceded
134
+ * it, which it equally cannot. Both are ordinary, and neither is an error: they belong to the variant either side.
135
+ *
136
+ * So the bytes travel undecoded and only the capability, which knows the activation range, reads the ones it is about
137
+ * to apply — see {@link readEvent}. Decoding here would fail the whole batch on an event nobody intended to apply.
138
+ */
62
139
  export const EventsSyncUpdate = Schema.TaggedStruct('EventsSyncUpdate', {
63
140
  id: Schema.Number,
64
141
  protocolVersion: Schema.Number,
65
142
  maxId: Schema.Number,
66
- event: LedgerEventSchema,
143
+ raw: Schema.String,
67
144
  });
68
- const EventsSyncUpdateFromPayload = Schema.transformOrFail(EventsSyncUpdatePayload, EventsSyncUpdate, {
69
- decode: (input) => pipe(Schema.decodeUnknownEither(HexedLedgerEvent)(input.raw), Either.map((event) => ({
145
+ const EventsSyncUpdateFromPayload = Schema.transform(EventsSyncUpdatePayload, EventsSyncUpdate, {
146
+ strict: true,
147
+ decode: (input) => ({
70
148
  _tag: 'EventsSyncUpdate',
71
149
  id: input.id,
72
150
  protocolVersion: input.protocolVersion,
73
151
  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),
152
+ raw: input.raw,
153
+ }),
154
+ encode: (update) => ({
155
+ id: update.id,
156
+ raw: update.raw,
157
+ protocolVersion: update.protocolVersion,
158
+ maxId: update.maxId,
159
+ }),
82
160
  });
161
+ /**
162
+ * Reads an event this variant is going to apply.
163
+ *
164
+ * @remarks
165
+ * The counterpart of {@link EventsSyncUpdate} carrying its event encoded: a capability calls this on the batch prefix it
166
+ * owns, and never on what it defers. Failure here is a genuine one — an event this variant claimed and cannot read —
167
+ * and is raised rather than returned, because `SyncCapability.applyUpdate` is total in its own domain and the variant
168
+ * already turns a throw from it into a typed synchronization error.
169
+ * @param update The update to read.
170
+ * @returns The event it carries.
171
+ * @throws ParseError if the bytes are not an event this ledger version can deserialize.
172
+ */
173
+ export const readEvent = (update) => Schema.decodeSync(HexedLedgerEvent)(update.raw);
174
+ /** How often the chain is asked its version when the configuration does not say. */
175
+ const DEFAULT_VERSION_WATCH_INTERVAL_MS = 30_000;
176
+ /**
177
+ * How long one event-id probe is given to produce its single answer.
178
+ *
179
+ * @remarks
180
+ * Only a stall guard. The probe subscribes at a cursor whose event provably exists, so the answer arrives at once or
181
+ * the transport is broken; without a bound, a half-open socket would leave the poll loop parked on a tick that never
182
+ * completes and the wallet would stop asking altogether. A bound turns that into a skipped tick.
183
+ */
184
+ const EVENT_TIP_PROBE_TIMEOUT = Duration.seconds(10);
185
+ /**
186
+ * Asks the source how far its zswap event timeline goes.
187
+ *
188
+ * @remarks
189
+ * Over the event subscription rather than a query, because the schema has no query that reaches a zswap ledger event:
190
+ * `block`/`transactions` reach one only through a block that happens to contain zswap events, which on a quiet chain
191
+ * is precisely the block that does not. `maxId` is a property of the whole timeline, so any single event answers it —
192
+ * the first one delivered is taken and the subscription closed.
193
+ *
194
+ * The cursor is the wallet's own. The event it last applied provably exists, so the source answers immediately and has
195
+ * the least backfill to abandon; a wallet that has applied nothing asks from the start, where the first event is.
196
+ * @param config The sync configuration, for the keep-alive the source's subscriptions use.
197
+ * @param url Where to subscribe.
198
+ * @param resumeFrom The wallet's cursor; below zero means "from the very start".
199
+ * @returns The highest event id, or nothing if the source did not answer.
200
+ */
201
+ 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));
202
+ /**
203
+ * One check of the chain's protocol version, gated on the wallet being caught up on the source's event ids.
204
+ *
205
+ * @remarks
206
+ * The order of the two questions is load-bearing. The tip is read **first**: a tip reported at a version means the
207
+ * source has indexed through the block that carries it, so every event below it is already counted in the `maxId`
208
+ * read afterwards. Asked the other way round, an event indexed between the two answers could be one of the version
209
+ * that preceded the tip — unread, uncounted, and exactly what the gate exists to catch.
210
+ *
211
+ * Two short-circuits, both of them answers rather than shortcuts. A tip at or below the version the wallet already held
212
+ * is a signal that could only be a no-op, so no probe is opened and nothing is emitted — which is what keeps a
213
+ * settled wallet from polling a subscription for the rest of its life. And a tip whose commitment tree has never
214
+ * grown cannot have had a nullifier spent against it either, so such a chain provably holds no zswap event: nothing
215
+ * can be unread, and the signal goes out with `null`.
216
+ *
217
+ * Everything else is swallowed. A tick that fails says nothing about the chain, so it must neither reach the state nor
218
+ * take the sync stream down with it; the next tick is the retry, and costs nothing to wait for.
219
+ * @param config The sync configuration, for the indexer to ask.
220
+ * @param url Where to run the event-id probe.
221
+ * @param resumeFrom The wallet's cursor, for the probe.
222
+ * @param knownVersion The version the wallet already held when this stream opened — a lower bound on what it holds now.
223
+ * @returns The signal, or nothing when there is nothing to say or nobody said it.
224
+ */
225
+ 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), {
226
+ onNone: () => Effect.succeedNone,
227
+ onSome: (tip) => BigInt(tip.protocolVersion) <= knownVersion
228
+ ? Effect.succeedNone
229
+ : tip.zswapEndIndex === 0
230
+ ? Effect.succeedSome(VersionSignalSyncUpdate.create(tip.protocolVersion, null))
231
+ : pipe(highestEventId(config, url, resumeFrom), Effect.map(Option.map((maxId) => VersionSignalSyncUpdate.create(tip.protocolVersion, maxId)))),
232
+ })), Effect.catchAll(() => Effect.succeedNone));
233
+ /**
234
+ * The chain's version, re-asked on a timer for as long as sync runs.
235
+ *
236
+ * @remarks
237
+ * Deliberately silent about ticks that say nothing: a tick that finds the chain where the wallet left it, or that
238
+ * cannot reach the chain at all, emits no element rather than an empty one, so nothing downstream has to know that
239
+ * polling is how the answer was arrived at.
240
+ * @param config The sync configuration, carrying the interval.
241
+ * @param url Where to run the event-id probe.
242
+ * @param resumeFrom The wallet's cursor, for the probe.
243
+ * @param knownVersion The version the wallet held when this stream opened.
244
+ * @returns The signals, or an empty stream when watching is turned off.
245
+ */
246
+ const versionWatch = (config, url, resumeFrom, knownVersion) => {
247
+ const intervalMs = config.versionWatch?.intervalMs ?? DEFAULT_VERSION_WATCH_INTERVAL_MS;
248
+ return intervalMs <= 0
249
+ ? Stream.empty
250
+ : pipe(Stream.fromSchedule(Schedule.spaced(Duration.millis(intervalMs))), Stream.mapEffect(() => readVersionSignal(config, url, resumeFrom, knownVersion)), Stream.filterMap(identity));
251
+ };
83
252
  export const makeEventsSyncService = (config) => {
84
253
  return {
85
254
  updates: (state, secretKeys) => {
@@ -124,17 +293,54 @@ export const makeEventsSyncService = (config) => {
124
293
  variables: (cursor) => ({ id: cursor < 0n ? null : Number(cursor) }),
125
294
  key: (r) => BigInt(r.zswapLedgerEvents.id),
126
295
  }), 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;
296
+ const timeline = batchSpacing > 0 ? Stream.schedule(eventsStream, Schedule.spaced(Duration.millis(batchSpacing))) : eventsStream;
297
+ // The timeline is what the source is for, so it decides when the source is done: `haltStrategy: 'left'` stops the
298
+ // watcher with it rather than leaving a poll loop running against a stream nobody is reading. It has nothing to
299
+ // say about failure — a failing timeline still fails the merged stream, where the variant's retry can see it.
300
+ return Stream.merge(timeline, versionWatch(config, indexerWsUrl, resumeFrom, state.protocolVersion), {
301
+ haltStrategy: 'left',
302
+ });
130
303
  },
131
304
  };
132
305
  };
306
+ /** The result of an update that observed nothing of the chain. */
307
+ const noChanges = (state) => ({
308
+ changes: [],
309
+ protocolVersion: Number(state.protocolVersion),
310
+ });
311
+ /**
312
+ * Folds what the chain said about its own version into the wallet state.
313
+ *
314
+ * @remarks
315
+ * The same recording the event path makes through {@link annotateVersion}, and nothing else: no cursor moves, no coin
316
+ * changes hands, no tree grows. A signal is an observation about the chain, not a piece of it.
317
+ *
318
+ * Unread events below the source's tip make the observation unsafe to record, and leave the state exactly as it was:
319
+ * the hand-over would park the cursor in front of history the next variant cannot read — and those events carry the
320
+ * version themselves, so nothing is lost by waiting for them. That is not an error; the next tick asks again.
321
+ *
322
+ * A version at or below the one already recorded needs no guard of its own — {@link annotateVersion} never goes
323
+ * backwards — so a source briefly answering from a lagging replica cannot drag a wallet back over a boundary.
324
+ * @param state The wallet to record on.
325
+ * @param update What the chain said, and how far its event timeline goes.
326
+ * @returns The wallet, annotated or untouched, and an empty result.
327
+ */
328
+ const applyVersionSignal = (state, update) => {
329
+ const appliedIndex = state.progress?.appliedIndex ?? 0n;
330
+ const unreadEvents = update.highestEventId !== null && BigInt(update.highestEventId) > appliedIndex;
331
+ return [
332
+ unreadEvents ? state : annotateVersion(state, Option.some(ProtocolVersion.ProtocolVersion(BigInt(update.version)))),
333
+ noChanges(state),
334
+ ];
335
+ };
133
336
  export const makeEventsSyncCapability = () => {
134
337
  return {
135
- applyUpdate: (state, wrappedUpdate) => {
338
+ applyUpdate: (state, wrappedUpdate, activeRange) => {
339
+ if (wrappedUpdate._tag === 'VersionSignal') {
340
+ return applyVersionSignal(state, wrappedUpdate);
341
+ }
136
342
  if (wrappedUpdate.updates.length === 0) {
137
- return [state, { changes: [], protocolVersion: Number(state.protocolVersion) }];
343
+ return [state, noChanges(state)];
138
344
  }
139
345
  // The subscription resumes at the last-applied index and its cursor is inclusive, so the
140
346
  // boundary event (id === appliedIndex) is re-delivered. Replaying it would re-insert
@@ -144,17 +350,39 @@ export const makeEventsSyncCapability = () => {
144
350
  // learns it is synced when no new events exist.
145
351
  const appliedIndex = state.progress?.appliedIndex ?? 0n;
146
352
  const freshUpdates = wrappedUpdate.updates.filter((u) => BigInt(u.id) > appliedIndex);
353
+ // The tip is a property of the source, not of what this variant chose to apply, so it is read from
354
+ // the batch tail even when the tail belongs to the next protocol version.
147
355
  const lastUpdate = wrappedUpdate.updates.at(-1);
148
356
  const highestRelevantWalletIndex = BigInt(lastUpdate.maxId);
149
- const [newState, newChanges] = freshUpdates.length === 0
357
+ // Refuse the whole batch: applying the sound prefix would move the cursor and hide the fault behind
358
+ // `isConnected: true`. State and cursor stay untouched, so the retry re-fetches the same range; a transient
359
+ // reorder heals, a persistent one fails visibly.
360
+ //
361
+ // `SyncCapability.applyUpdate` has no typed error channel, so throwing preserves its public tuple-returning API;
362
+ // `RunningV1Variant` catches this at the capability boundary. See #572 for the planned `Either`-based API.
363
+ const orderedUpdates = Either.getOrThrowWith(expectAscending(freshUpdates, appliedIndex), identity);
364
+ const { applied, observedVersion } = splitAtVersionBoundary(orderedUpdates, (update) => update.protocolVersion, activeRange);
365
+ // Read here and nowhere earlier: `applied` is exactly the slice this variant owns, so nothing outside its
366
+ // activation range — nor anything below its cursor — is ever handed to this ledger version's deserializer.
367
+ const [newState, newChanges] = applied.length === 0
150
368
  ? [state, []]
151
- : CoreWallet.replayEventsWithChanges(state, wrappedUpdate.secretKeys, freshUpdates.map((u) => u.event));
152
- const updatedState = CoreWallet.updateProgress(newState, {
369
+ : CoreWallet.replayEventsWithChanges(state, wrappedUpdate.secretKeys, applied.map(readEvent));
370
+ // `appliedIndex` stops at the last event this variant actually replayed. The next variant resumes one
371
+ // below it on an inclusive cursor, so the deferred suffix is re-fetched rather than skipped.
372
+ const updatedState = CoreWallet.updateProgress(annotateVersion(newState, observedVersion), {
153
373
  highestRelevantWalletIndex,
154
- appliedIndex: freshUpdates.length === 0 ? appliedIndex : BigInt(freshUpdates.at(-1).id),
374
+ appliedIndex: applied.length === 0 ? appliedIndex : BigInt(applied.at(-1).id),
155
375
  isConnected: true,
156
376
  });
157
- return [updatedState, { changes: newChanges, protocolVersion: lastUpdate.protocolVersion }];
377
+ return [
378
+ updatedState,
379
+ {
380
+ changes: newChanges,
381
+ // Tags the changes that were actually produced, so tx-history records the version they were applied
382
+ // under rather than the one that is about to trigger the hand-over.
383
+ protocolVersion: applied.at(-1)?.protocolVersion ?? Number(state.protocolVersion),
384
+ },
385
+ ];
158
386
  },
159
387
  };
160
388
  };
@@ -166,7 +394,7 @@ export const makeSimulatorSyncService = (config) => {
166
394
  // to avoid processing the same block twice.
167
395
  let lastSeenBlockNumber;
168
396
  return pipe(Stream.fromEffect(config.simulator.getLatestState()), Stream.concat(config.simulator.state$), Stream.filter((state) => {
169
- const lastBlock = getLastBlock(state);
397
+ const lastBlock = V8.getLastBlock(state);
170
398
  if (lastBlock === undefined) {
171
399
  return false; // Skip blank state
172
400
  }
@@ -183,23 +411,31 @@ export const makeSimulatorSyncService = (config) => {
183
411
  };
184
412
  export const makeSimulatorSyncCapability = () => {
185
413
  return {
186
- applyUpdate: (state, update) => {
414
+ applyUpdate: (state, update, activeRange) => {
187
415
  const { update: simulatorState, secretKeys } = update;
188
- const lastBlock = getLastBlock(simulatorState);
416
+ const lastBlock = V8.getLastBlock(simulatorState);
189
417
  if (lastBlock === undefined) {
190
418
  return [state, { changes: [], protocolVersion: Number(state.protocolVersion) }];
191
419
  }
192
- // Get all events from blocks starting at appliedIndex (the next block to process).
193
420
  // appliedIndex semantics: the first block number we haven't processed yet.
194
421
  // Initial: appliedIndex = 0 (haven't processed any blocks)
195
422
  // 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);
423
+ //
424
+ // The boundary is applied at block granularity here — a block carries exactly one protocol version,
425
+ // stamped when it was produced — but it is the same rule and the same helper the indexer path uses.
426
+ const pending = simulatorState.blocks.filter((block) => block.number >= state.progress.appliedIndex);
427
+ const { applied, observedVersion } = splitAtVersionBoundary(pending, (block) => Number(block.protocolVersion), activeRange);
428
+ const events = applied.flatMap((block) => block.transactions.flatMap((tx) => tx.result.events));
429
+ const [newState, newChanges] = applied.length === 0 ? [state, []] : CoreWallet.replayEventsWithChanges(state, secretKeys, events);
430
+ const lastAppliedBlock = applied.at(-1);
198
431
  return [
199
- CoreWallet.updateProgress(newState, {
200
- appliedIndex: lastBlock.number + 1n,
432
+ CoreWallet.updateProgress(annotateVersion(newState, observedVersion), {
433
+ appliedIndex: lastAppliedBlock === undefined ? state.progress.appliedIndex : lastAppliedBlock.number + 1n,
201
434
  }),
202
- { changes: newChanges, protocolVersion: Number(state.protocolVersion) },
435
+ {
436
+ changes: newChanges,
437
+ protocolVersion: lastAppliedBlock === undefined ? Number(state.protocolVersion) : Number(lastAppliedBlock.protocolVersion),
438
+ },
203
439
  ];
204
440
  },
205
441
  };
@@ -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';
@@ -132,7 +132,8 @@ export class TransactingCapabilityImplementation {
132
132
  feeTokenType: '',
133
133
  coinSelection,
134
134
  createOutput: (coin) => ledger.createShieldedCoinInfo(coin.type, coin.value),
135
- isCoinEqual: (a, b) => a.type === b.type && a.value === b.value,
135
+ // Distinct coins can share a type and a value; only the nonce tells them apart.
136
+ isCoinEqual: (a, b) => a.nonce === b.nonce,
136
137
  });
137
138
  return this.#prepareOffer(secretKeys, newState, fallibleBalanceRecipe, segment);
138
139
  },
@@ -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;