@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.
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
@@ -0,0 +1,436 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) Midnight Foundation
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // You may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+ import * as ledger from '@midnightntwrk/ledger-v9';
14
+ import { Chunk, Duration, Effect, Either, identity, Option, ParseResult, pipe, Schedule, Schema, Stream, } from 'effect';
15
+ import { ProtocolVersion } from '@midnight-ntwrk/wallet-sdk-abstractions';
16
+ import { CoreWallet } from './CoreWallet.js';
17
+ import { getLastBlock } 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';
20
+ import { SyncWalletError } from './WalletError.js';
21
+ import { WsURL } from '@midnight-ntwrk/wallet-sdk-utilities/networking';
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
+ });
56
+ const Uint8ArraySchema = Schema.declare((input) => input instanceof Uint8Array).annotations({
57
+ identifier: 'Uint8Array',
58
+ });
59
+ export const SecretKeysResource = {
60
+ create: (secretKeys) => {
61
+ return (cb) => {
62
+ const result = cb(secretKeys);
63
+ secretKeys.clear();
64
+ return result;
65
+ };
66
+ },
67
+ };
68
+ export const VersionSignalSyncUpdate = {
69
+ create: (version, highestEventId) => {
70
+ return {
71
+ _tag: 'VersionSignal',
72
+ version,
73
+ highestEventId,
74
+ };
75
+ },
76
+ };
77
+ export const WalletSyncUpdate = {
78
+ create: (updates, secretKeys) => {
79
+ return {
80
+ _tag: 'Events',
81
+ updates,
82
+ secretKeys,
83
+ };
84
+ },
85
+ };
86
+ const LedgerEventSchema = Schema.declare((input) => input instanceof ledger.Event).annotations({
87
+ identifier: 'ledger.Event',
88
+ });
89
+ const LedgerEventFromUint8Array = Schema.transformOrFail(Uint8ArraySchema, LedgerEventSchema, {
90
+ encode: (event) => Effect.try({
91
+ try: () => event.serialize(),
92
+ catch: (error) => new ParseResult.Unexpected(error, 'Could not serialize ledger event'),
93
+ }),
94
+ decode: (bytes) => Effect.try({
95
+ try: () => ledger.Event.deserialize(bytes),
96
+ catch: (error) => new ParseResult.Unexpected(error, 'Could not deserialize ledger event'),
97
+ }),
98
+ });
99
+ const HexedLedgerEvent = pipe(Schema.Uint8ArrayFromHex, Schema.compose(LedgerEventFromUint8Array));
100
+ const EventsSyncUpdatePayload = Schema.Struct({
101
+ id: Schema.Number,
102
+ raw: Schema.String,
103
+ protocolVersion: Schema.Number,
104
+ maxId: Schema.Number,
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
+ */
119
+ export const EventsSyncUpdate = Schema.TaggedStruct('EventsSyncUpdate', {
120
+ id: Schema.Number,
121
+ protocolVersion: Schema.Number,
122
+ maxId: Schema.Number,
123
+ raw: Schema.String,
124
+ });
125
+ const EventsSyncUpdateFromPayload = Schema.transform(EventsSyncUpdatePayload, EventsSyncUpdate, {
126
+ strict: true,
127
+ decode: (input) => ({
128
+ _tag: 'EventsSyncUpdate',
129
+ id: input.id,
130
+ protocolVersion: input.protocolVersion,
131
+ maxId: input.maxId,
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
+ }),
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
+ };
232
+ export const makeEventsSyncService = (config) => {
233
+ return {
234
+ updates: (state, secretKeys) => {
235
+ const { indexerClientConnection } = config;
236
+ const webSocketUrlResult = ConnectionHelper.createWebSocketUrl(indexerClientConnection.indexerHttpUrl, indexerClientConnection.indexerWsUrl);
237
+ if (Either.isLeft(webSocketUrlResult)) {
238
+ return Stream.fail(new SyncWalletError(new Error(`Could not derive WebSocket URL from indexer HTTP URL: ${webSocketUrlResult.left.message}`)));
239
+ }
240
+ const indexerWsUrlResult = WsURL.make(webSocketUrlResult.right);
241
+ if (Either.isLeft(indexerWsUrlResult)) {
242
+ return Stream.fail(new SyncWalletError(new Error(`Invalid indexer WS URL: ${indexerWsUrlResult.left.message}`)));
243
+ }
244
+ const indexerWsUrl = indexerWsUrlResult.right;
245
+ const appliedIndex = state.progress?.appliedIndex ?? 0n;
246
+ // The boundary is load-bearing, not waste: this subscription emits only events (no tip/progress
247
+ // sentinel), and `isConnected`/the tip (`maxId`) are set only when an event is received. So the
248
+ // cursor must stay `<= appliedIndex` — never `appliedIndex + 1`. Requesting one event later would
249
+ // deliver nothing to a wallet already at the tip, so `applyUpdate` would never run and sync would
250
+ // hang.
251
+ //
252
+ // A fresh wallet has `appliedIndex === 0n` (the "nothing applied yet" sentinel), so `resumeFrom`
253
+ // is `-1n` and the `variables` mapping below opens the subscription with no `id` — the indexer
254
+ // streams from the very start. A restored wallet has `appliedIndex >= 1`, so `resumeFrom` is
255
+ // `appliedIndex - 1` and the inclusive cursor re-delivers the boundary event.
256
+ const resumeFrom = appliedIndex - 1n;
257
+ const batchSize = config.batchUpdates?.size ?? 10;
258
+ const batchTimeout = Duration.millis(config.batchUpdates?.timeout ?? 1);
259
+ const batchSpacing = config.batchUpdates?.spacing ?? 4;
260
+ const bufferSize = config.indexerClientConnection.bufferSize ?? 10000;
261
+ const resumeThreshold = config.indexerClientConnection.resumeThreshold ?? 100;
262
+ const eventsStream = pipe(
263
+ // Backpressure caps the in-flight queue between the WS push and the
264
+ // apply loop. Without it the JS heap grows linearly with catch-up
265
+ // depth, since `Stream.asyncPush({ bufferSize: 'unbounded' })`
266
+ // buffers every event the indexer pushes regardless of apply rate.
267
+ ZswapEvents.runWithBackpressure({
268
+ bufferSize,
269
+ resumeThreshold,
270
+ from: resumeFrom,
271
+ // `resumeFrom < 0n` means a fresh wallet: send no `id` so the indexer streams from the very
272
+ // start, rather than relying on `id: 0` sorting below the first real event id.
273
+ variables: (cursor) => ({ id: cursor < 0n ? null : Number(cursor) }),
274
+ key: (r) => BigInt(r.zswapLedgerEvents.id),
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)));
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
+ });
283
+ },
284
+ };
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 — which is also why it
297
+ * does not resolve pending coin hashes: it carries no keys, and the first batch of the timeline does.
298
+ *
299
+ * One situation makes the observation unsafe to record, and it leaves the state exactly as it was: unread events below
300
+ * the source's tip mean the hand-over would park the cursor in front of history the next variant cannot read — and
301
+ * those events carry the version themselves, so nothing is lost by waiting for them. Not an error: the next tick asks
302
+ * again.
303
+ *
304
+ * A version at or below the one already recorded needs no guard of its own — {@link annotateVersion} never goes
305
+ * backwards — so a source briefly answering from a lagging replica cannot drag a wallet back over a boundary.
306
+ * @param wallet The wallet to record on.
307
+ * @param update What the chain said, and how far its event timeline goes.
308
+ * @returns The wallet, annotated or untouched, and an empty result.
309
+ */
310
+ const applyVersionSignal = (wallet, update) => {
311
+ const appliedIndex = wallet.progress?.appliedIndex ?? 0n;
312
+ const unreadEvents = update.highestEventId !== null && BigInt(update.highestEventId) > appliedIndex;
313
+ return [
314
+ unreadEvents
315
+ ? wallet
316
+ : annotateVersion(wallet, Option.some(ProtocolVersion.ProtocolVersion(BigInt(update.version)))),
317
+ noChanges(wallet),
318
+ ];
319
+ };
320
+ export const makeEventsSyncCapability = () => {
321
+ return {
322
+ applyUpdate: (wallet, wrappedUpdate, activeRange) => {
323
+ if (wrappedUpdate._tag === 'VersionSignal') {
324
+ return applyVersionSignal(wallet, wrappedUpdate);
325
+ }
326
+ // First, and before any early return: a wallet that crossed the ledger-version boundary arrives with its whole
327
+ // local state but neither hashes for it nor a way to un-book the spends it carried, and this update is the first
328
+ // place its keys are at hand. An empty batch is exactly the case that must not skip this — a wallet whose
329
+ // timeline has gone quiet still has to be able to name and to spend the coins it crossed with.
330
+ const state = CoreWallet.completeCrossing(wallet, wrappedUpdate.secretKeys);
331
+ if (wrappedUpdate.updates.length === 0) {
332
+ return [state, noChanges(state)];
333
+ }
334
+ // The subscription resumes at the last-applied index and its cursor is inclusive, so the
335
+ // boundary event (id === appliedIndex) is re-delivered. Replaying it would re-insert
336
+ // commitments already in the zswap state ("non-linear insertion" error), so only events
337
+ // strictly after the applied index are replayed. The boundary is still used (below) to
338
+ // refresh the tip and mark the wallet connected — that is how an already-caught-up wallet
339
+ // learns it is synced when no new events exist.
340
+ const appliedIndex = state.progress?.appliedIndex ?? 0n;
341
+ const freshUpdates = wrappedUpdate.updates.filter((u) => BigInt(u.id) > appliedIndex);
342
+ // The tip is a property of the source, not of what this variant chose to apply, so it is read from
343
+ // the batch tail even when the tail belongs to the next protocol version.
344
+ const lastUpdate = wrappedUpdate.updates.at(-1);
345
+ const highestRelevantWalletIndex = BigInt(lastUpdate.maxId);
346
+ const { applied, observedVersion } = splitAtVersionBoundary(freshUpdates, (update) => update.protocolVersion, activeRange);
347
+ // Read here and nowhere earlier: `applied` is exactly the slice this variant owns, so nothing outside its
348
+ // activation range — nor anything below its cursor — is ever handed to this ledger version's deserializer.
349
+ const [newState, newChanges] = applied.length === 0
350
+ ? [state, []]
351
+ : CoreWallet.replayEventsWithChanges(state, wrappedUpdate.secretKeys, applied.map(readEvent));
352
+ // `appliedIndex` stops at the last event this variant actually replayed. The next variant resumes one
353
+ // below it on an inclusive cursor, so the deferred suffix is re-fetched rather than skipped.
354
+ const updatedState = CoreWallet.updateProgress(annotateVersion(newState, observedVersion), {
355
+ highestRelevantWalletIndex,
356
+ appliedIndex: applied.length === 0 ? appliedIndex : BigInt(applied.at(-1).id),
357
+ isConnected: true,
358
+ });
359
+ return [
360
+ updatedState,
361
+ {
362
+ changes: newChanges,
363
+ // Tags the changes that were actually produced, so tx-history records the version they were applied
364
+ // under rather than the one that is about to trigger the hand-over.
365
+ protocolVersion: applied.at(-1)?.protocolVersion ?? Number(state.protocolVersion),
366
+ },
367
+ ];
368
+ },
369
+ };
370
+ };
371
+ export const SimulatorSyncUpdate = {
372
+ create: (update, secretKeys) => {
373
+ return {
374
+ _tag: 'Events',
375
+ update,
376
+ secretKeys,
377
+ };
378
+ },
379
+ };
380
+ export const makeSimulatorSyncService = (config) => {
381
+ return {
382
+ updates: (_state, secretKeys) => {
383
+ // Get the initial state immediately to ensure we process the genesis block.
384
+ // Then subscribe to state$ for subsequent changes, but deduplicate by block number
385
+ // to avoid processing the same block twice.
386
+ let lastSeenBlockNumber;
387
+ return pipe(Stream.fromEffect(config.simulator.getLatestState()), Stream.concat(config.simulator.state$), Stream.filter((simulatorState) => {
388
+ const lastBlock = getLastBlock(simulatorState);
389
+ if (lastBlock === undefined) {
390
+ return false; // Skip blank state
391
+ }
392
+ const blockNumber = lastBlock.number;
393
+ // Skip if we've already seen this block (deduplication)
394
+ if (lastSeenBlockNumber !== undefined && blockNumber <= lastSeenBlockNumber) {
395
+ return false;
396
+ }
397
+ lastSeenBlockNumber = blockNumber;
398
+ return true;
399
+ }), Stream.map((simulatorState) => SimulatorSyncUpdate.create(simulatorState, secretKeys)));
400
+ },
401
+ };
402
+ };
403
+ export const makeSimulatorSyncCapability = () => {
404
+ return {
405
+ applyUpdate: (wallet, update, activeRange) => {
406
+ const { update: simulatorState, secretKeys } = update;
407
+ // The same first step as the indexer capability's, for the same reason: this is where a migrated wallet's keys
408
+ // and its carried state first meet, and a block that turns out to hold nothing must not skip it.
409
+ const state = CoreWallet.completeCrossing(wallet, secretKeys);
410
+ const lastBlock = getLastBlock(simulatorState);
411
+ if (lastBlock === undefined) {
412
+ return [state, noChanges(state)];
413
+ }
414
+ // appliedIndex semantics: the first block number we haven't processed yet.
415
+ // Initial: appliedIndex = 0 (haven't processed any blocks)
416
+ // After processing block N: appliedIndex = N + 1 (next block to process)
417
+ //
418
+ // The boundary is applied at block granularity here — a block carries exactly one protocol version,
419
+ // stamped when it was produced — but it is the same rule and the same helper the indexer path uses.
420
+ const pending = simulatorState.blocks.filter((block) => block.number >= state.progress.appliedIndex);
421
+ const { applied, observedVersion } = splitAtVersionBoundary(pending, (block) => Number(block.protocolVersion), activeRange);
422
+ const events = applied.flatMap((block) => block.transactions.flatMap((tx) => tx.result.events));
423
+ const [newState, newChanges] = applied.length === 0 ? [state, []] : CoreWallet.replayEventsWithChanges(state, secretKeys, events);
424
+ const lastAppliedBlock = applied.at(-1);
425
+ return [
426
+ CoreWallet.updateProgress(annotateVersion(newState, observedVersion), {
427
+ appliedIndex: lastAppliedBlock === undefined ? state.progress.appliedIndex : lastAppliedBlock.number + 1n,
428
+ }),
429
+ {
430
+ changes: newChanges,
431
+ protocolVersion: lastAppliedBlock === undefined ? Number(state.protocolVersion) : Number(lastAppliedBlock.protocolVersion),
432
+ },
433
+ ];
434
+ },
435
+ };
436
+ };
@@ -0,0 +1,50 @@
1
+ import * as ledger from '@midnightntwrk/ledger-v9';
2
+ import { type NetworkId } from '@midnight-ntwrk/wallet-sdk-abstractions';
3
+ import { Array as Arr, Either } from 'effect';
4
+ import { CoreWallet } from './CoreWallet.js';
5
+ import { type WalletError } from './WalletError.js';
6
+ import { type CoinSelection } from '@midnight-ntwrk/wallet-sdk-capabilities';
7
+ import { type ShieldedAddress } from '@midnight-ntwrk/wallet-sdk-address-format';
8
+ import { TransactionImbalances } from './TransactionImbalances.js';
9
+ import { TransactionOps } from './TransactionOps.js';
10
+ import { type CoinsAndBalancesCapability } from './CoinsAndBalances.js';
11
+ import { type KeysCapability } from './Keys.js';
12
+ export interface TokenTransfer {
13
+ readonly amount: bigint;
14
+ readonly type: ledger.RawTokenType;
15
+ readonly receiverAddress: ShieldedAddress;
16
+ }
17
+ export type BalancingResult = ledger.UnprovenTransaction | undefined;
18
+ export interface TransactingCapability<TSecrets, TState, _TTransaction> {
19
+ balanceTransaction(secrets: TSecrets, state: TState, tx: ledger.Transaction<ledger.Signaturish, ledger.Proofish, ledger.Bindingish>): Either.Either<[BalancingResult, TState], WalletError>;
20
+ makeTransfer(secrets: TSecrets, state: TState, outputs: ReadonlyArray<TokenTransfer>): Either.Either<[ledger.UnprovenTransaction, TState], WalletError>;
21
+ initSwap(secrets: TSecrets, state: TState, desiredInputs: Record<ledger.RawTokenType, bigint>, desiredOutputs: ReadonlyArray<TokenTransfer>): Either.Either<[ledger.UnprovenTransaction, TState], WalletError>;
22
+ revertTransaction(state: TState, transaction: ledger.Transaction<ledger.Signaturish, ledger.Proofish, ledger.Bindingish>): Either.Either<TState, WalletError>;
23
+ }
24
+ export type DefaultTransactingConfiguration = {
25
+ networkId: NetworkId.NetworkId;
26
+ };
27
+ export type DefaultTransactingContext = {
28
+ coinSelection: CoinSelection<ledger.QualifiedShieldedCoinInfo>;
29
+ coinsAndBalancesCapability: CoinsAndBalancesCapability<CoreWallet>;
30
+ keysCapability: KeysCapability<CoreWallet>;
31
+ };
32
+ export declare const makeDefaultTransactingCapability: (config: DefaultTransactingConfiguration, getContext: () => DefaultTransactingContext) => TransactingCapability<ledger.ZswapSecretKeys, CoreWallet, ledger.FinalizedTransaction>;
33
+ export declare const makeSimulatorTransactingCapability: (config: DefaultTransactingConfiguration, getContext: () => DefaultTransactingContext) => TransactingCapability<ledger.ZswapSecretKeys, CoreWallet, ledger.ProofErasedTransaction>;
34
+ export declare class TransactingCapabilityImplementation<TTransaction extends ledger.Transaction<ledger.Signaturish, ledger.Proofish, ledger.Bindingish>> implements TransactingCapability<ledger.ZswapSecretKeys, CoreWallet, TTransaction> {
35
+ #private;
36
+ readonly networkId: NetworkId.NetworkId;
37
+ readonly getCoinSelection: () => CoinSelection<ledger.QualifiedShieldedCoinInfo>;
38
+ readonly txOps: TransactionOps<TTransaction>;
39
+ readonly getCoins: () => CoinsAndBalancesCapability<CoreWallet>;
40
+ readonly getKeys: () => KeysCapability<CoreWallet>;
41
+ constructor(networkId: NetworkId.NetworkId, getCoinSelection: () => CoinSelection<ledger.QualifiedShieldedCoinInfo>, getCoins: () => CoinsAndBalancesCapability<CoreWallet>, getKeys: () => KeysCapability<CoreWallet>, txOps: TransactionOps<TTransaction>);
42
+ balanceTransaction(secretKeys: ledger.ZswapSecretKeys, state: CoreWallet, tx: TTransaction): Either.Either<[BalancingResult, CoreWallet], WalletError>;
43
+ makeTransfer(secretKeys: ledger.ZswapSecretKeys, state: CoreWallet, transfers: Arr.NonEmptyReadonlyArray<TokenTransfer>): Either.Either<[ledger.UnprovenTransaction, CoreWallet], WalletError>;
44
+ initSwap(secretKeys: ledger.ZswapSecretKeys, state: CoreWallet, desiredInputs: Record<ledger.RawTokenType, bigint>, desiredOutputs: ReadonlyArray<TokenTransfer>): Either.Either<[ledger.UnprovenTransaction, CoreWallet], WalletError>;
45
+ revertTransaction(state: CoreWallet, transaction: TTransaction | ledger.UnprovenTransaction): Either.Either<CoreWallet, WalletError>;
46
+ balanceFallibleSection(secretKeys: ledger.ZswapSecretKeys, state: CoreWallet, imbalances: TransactionImbalances, coinSelection: CoinSelection<ledger.QualifiedShieldedCoinInfo>): Either.Either<{
47
+ fallibleOffers: Map<number, ledger.ZswapOffer<ledger.PreProof>>;
48
+ newState: CoreWallet;
49
+ }, WalletError>;
50
+ }