@midnight-ntwrk/wallet-sdk-dust-wallet 5.0.0-beta.3 → 5.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.
@@ -1,4 +1,4 @@
1
- import { Effect, Stream, Scope } from 'effect';
1
+ import { Effect, Stream, Scope, Duration, Schedule } from 'effect';
2
2
  import { type TransactionHistoryService } from './TransactionHistory.js';
3
3
  import { type DustSecretKey, type Signature, type SignatureVerifyingKey, type FinalizedTransaction, type UnprovenTransaction } from '@midnight-ntwrk/ledger-v8';
4
4
  import { type WalletError } from './WalletError.js';
@@ -27,6 +27,14 @@ export declare namespace RunningV1Variant {
27
27
  type AnyContext = Context<any, any, any, any>;
28
28
  }
29
29
  export declare const V1Tag: unique symbol;
30
+ /**
31
+ * How a failing synchronization pass is retried.
32
+ *
33
+ * @remarks
34
+ * Exported so the delays it actually produces can be asserted. A schedule reads as though its combinators apply to the
35
+ * delay, and for some of them they do not — which is worth pinning in a test rather than trusting by eye.
36
+ */
37
+ export declare const syncRetrySchedule: Schedule.Schedule<[Duration.Duration, number], unknown, never>;
30
38
  export type DefaultRunningV1 = RunningV1Variant<string, V8.SimulatorState, FinalizedTransaction, DustSecretKey>;
31
39
  export declare class RunningV1Variant<TSerialized, TSyncUpdate, TTransaction, TStartAux> implements Variant.RunningVariant<typeof V1Tag, CoreWallet> {
32
40
  #private;
@@ -15,6 +15,7 @@ import { ProtocolVersion, Token } from '@midnight-ntwrk/wallet-sdk-abstractions'
15
15
  import { OtherWalletError } from './WalletError.js';
16
16
  import { ArrayOps, EitherOps } from '@midnight-ntwrk/wallet-sdk-utilities';
17
17
  import { StateChange, VersionChangeType, } from '@midnight-ntwrk/wallet-sdk-runtime/abstractions';
18
+ import { BackgroundRepeat } from './Sync.js';
18
19
  const progress = (state) => {
19
20
  const appliedIndex = state.progress?.appliedIndex ?? 0n;
20
21
  const highestRelevantWalletIndex = state.progress?.highestRelevantWalletIndex ?? 0n;
@@ -48,6 +49,21 @@ const protocolVersionChange = (previous, current, isInitial, activationRange) =>
48
49
  : [];
49
50
  };
50
51
  export const V1Tag = Symbol('V1');
52
+ /**
53
+ * How a failing synchronization pass is retried.
54
+ *
55
+ * @remarks
56
+ * Exported so the delays it actually produces can be asserted. A schedule reads as though its combinators apply to the
57
+ * delay, and for some of them they do not — which is worth pinning in a test rather than trusting by eye.
58
+ */
59
+ export const syncRetrySchedule = pipe(Schedule.exponential(Duration.seconds(1), 2), Schedule.jittered,
60
+ // `union` retries as soon as either side would, so pairing the backoff with a fixed two-minute schedule takes the
61
+ // shorter of the two — which is the backoff until it grows past two minutes, and two minutes from then on. Written
62
+ // this way rather than as a `Schedule.map` over the delay: `map` rewrites a schedule's *output*, which it takes
63
+ // **after** the delay has already been read from it, so mapping an exponential leaves the timing untouched. The
64
+ // earlier form did exactly that, and backed off unboundedly — past an hour by the twelfth attempt — while reading
65
+ // as though it were capped.
66
+ Schedule.union(Schedule.spaced(Duration.minutes(2))));
51
67
  export class RunningV1Variant {
52
68
  __polyTag__ = V1Tag;
53
69
  #scope;
@@ -59,11 +75,26 @@ export class RunningV1Variant {
59
75
  #txHistoryPermits = Effect.makeSemaphore(8).pipe(Effect.runSync);
60
76
  state;
61
77
  #syncLock;
78
+ /**
79
+ * Whether a background synchronization worker is already running.
80
+ *
81
+ * @remarks
82
+ * Distinct from {@link #syncLock}, which is released at the end of every pass and so says nothing about the worker
83
+ * that runs them. Held for the worker's whole lifetime — polling delays and retries included — and released as it
84
+ * exits, so a wallet started again while it is already synchronizing keeps the worker it has instead of gaining a
85
+ * second one that polls alongside it until the wallet stops.
86
+ *
87
+ * The guard asks only whether a worker is running, not what it was started with, so a second start is ignored even
88
+ * when it carries different key material. Starting a running wallet on a second key is not a supported way to
89
+ * change keys: stop it first. Two workers would be no better — they would apply two keys' updates to one state.
90
+ */
91
+ #backgroundWorkerLock;
62
92
  constructor(scope, context, v1Context) {
63
93
  this.#scope = scope;
64
94
  this.#context = context;
65
95
  this.#v1Context = v1Context;
66
96
  this.#syncLock = Effect.runSync(Ref.make(false));
97
+ this.#backgroundWorkerLock = Effect.runSync(Ref.make(false));
67
98
  this.state = Stream.fromEffect(context.stateRef.get).pipe(Stream.flatMap((initialState) => context.stateRef.changes.pipe(
68
99
  // The accumulator carries the "have we seen anything yet" flag alongside the previous state: the first
69
100
  // observation is the only one that can be a restored state nobody has inspected against this variant's
@@ -78,12 +109,29 @@ export class RunningV1Variant {
78
109
  }));
79
110
  }
80
111
  startSyncInBackground(startAux) {
81
- return this.startSync(startAux).pipe(Stream.retry(pipe(Schedule.exponential(Duration.seconds(1), 2), Schedule.map((delay) => {
82
- const maxDelay = Duration.minutes(2);
83
- const jitter = Duration.millis(Math.floor(Math.random() * 1000));
84
- const delayWithJitter = Duration.toMillis(delay) + Duration.toMillis(jitter);
85
- return Duration.millis(Math.min(delayWithJitter, Duration.toMillis(maxDelay)));
86
- }))), Stream.runScoped(Sink.drain), Effect.forkScoped, Effect.provideService(Scope.Scope, this.#scope));
112
+ // One pass, in a scope of its own. `Stream.runScoped` acquires into whichever scope it is handed, and the source's
113
+ // client layers take `Scope` as an input requirement rather than discharging one of their own — so a pass drained
114
+ // straight into the variant scope leaves its clients open there until the wallet stops, one more set per repeat.
115
+ // Scoping here closes them as the pass ends, on success, failure or interruption alike, and before any retry delay.
116
+ // The tx-history fan-out `startSync` forks is deliberately unaffected: it is pinned to the variant scope
117
+ // explicitly, and none of its work needs a client this scope holds.
118
+ const pass = this.startSync(startAux).pipe(Stream.runScoped(Sink.drain), Effect.scoped);
119
+ // Retrying the drained pass rather than the stream keeps the scope inside the retry: a failed attempt gives its
120
+ // resources back before the backoff, instead of holding them for the length of it.
121
+ const attempt = pass.pipe(Effect.retry(syncRetrySchedule));
122
+ // A service that synchronizes in finite passes would otherwise leave the wallet frozen at whatever its first pass
123
+ // saw. Repeating re-runs `startSync`, which re-reads the wallet state, so each pass resumes from what the previous
124
+ // one applied. The retry above cannot serve this purpose: it re-runs a pass on failure, not on completion.
125
+ // Repeating outside it keeps a transient failure to the pass it happened in, rather than restarting the cycle.
126
+ const worker = BackgroundRepeat.$match(this.#v1Context.syncService.backgroundRepeat, {
127
+ Once: () => attempt,
128
+ WithDelay: ({ delay }) => Effect.repeat(attempt, Schedule.spaced(delay)).pipe(Effect.asVoid),
129
+ });
130
+ // Taken before the worker is forked and given back as it exits, so the guard covers the gaps between passes that
131
+ // the sync lock cannot. A second start finds it held and leaves the running worker alone.
132
+ return Ref.modify(this.#backgroundWorkerLock, (isRunning) => [!isRunning, true]).pipe(Effect.flatMap((acquired) => acquired
133
+ ? worker.pipe(Effect.ensuring(Ref.set(this.#backgroundWorkerLock, false)), Effect.forkScoped, Effect.asVoid)
134
+ : Effect.void), Effect.provideService(Scope.Scope, this.#scope));
87
135
  }
88
136
  /**
89
137
  * Runs one sync pass to completion, rather than forking it into the background.
package/dist/v1/Sync.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect, Layer, Schema, type Scope, Stream, Option } from 'effect';
1
+ import { Data, Effect, Layer, Schema, type Scope, Stream, Duration, Option } from 'effect';
2
2
  import { ProtocolVersion } from '@midnight-ntwrk/wallet-sdk-abstractions';
3
3
  import { LedgerParametersCodec } from '@midnight-ntwrk/wallet-sdk-capabilities/codecs';
4
4
  import { type DustSecretKey, type DustStateChanges, Event as LedgerEvent, LedgerParameters } from '@midnight-ntwrk/ledger-v8';
@@ -7,9 +7,96 @@ import { type WalletError } from './WalletError.js';
7
7
  import { V8 } from '@midnight-ntwrk/wallet-sdk-capabilities/simulation';
8
8
  import { CoreWallet } from './CoreWallet.js';
9
9
  import { type NetworkId } from './types/ledger.js';
10
+ /**
11
+ * How often background synchronization runs a sync service's `updates`.
12
+ *
13
+ * @remarks
14
+ * Every service states this, because the answer cannot be inferred from the service: it follows from whether `updates`
15
+ * is finite, which only the service knows. A service whose `updates` is a long-lived subscription is
16
+ * {@link BackgroundRepeat.Once} — that single pass never ends, so there is nothing to repeat. A service that
17
+ * synchronizes in finite passes is {@link BackgroundRepeat.WithDelay}, or a wallet synchronizing in the background
18
+ * would converge once and then never observe anything again. The variant's background retry does not cover the
19
+ * latter: it re-runs a pass on failure, not on completion.
20
+ *
21
+ * Every service this variant has is {@link BackgroundRepeat.Once}: the finite-pass projections sync is a ledger-v9
22
+ * capability that no published ledger-v8 can support. The type is declared in both twins so that their background
23
+ * synchronization reads one contract, and so that a ledger-v8 service which one day synchronizes in finite passes has
24
+ * the vocabulary to say so.
25
+ */
26
+ export type BackgroundRepeat = Data.TaggedEnum<{
27
+ /** Run `updates` once and leave it running. For a service whose `updates` never completes. */
28
+ Once: {};
29
+ /** Re-run `updates` this long after each pass completes. For a service that synchronizes in finite passes. */
30
+ WithDelay: {
31
+ readonly delay: Duration.DurationInput;
32
+ };
33
+ }>;
34
+ /**
35
+ * Constructors and matchers for {@link BackgroundRepeat}.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const service: SyncService<CoreWallet, DustSecretKey, WalletSyncUpdate> = {
40
+ * backgroundRepeat: BackgroundRepeat.Once(),
41
+ * updates: (state, secretKey) => subscribe(state, secretKey),
42
+ * blockData: () => latestBlock(),
43
+ * };
44
+ * ```;
45
+ */
46
+ export declare const BackgroundRepeat: {
47
+ readonly Once: Data.Case.Constructor<{
48
+ readonly _tag: "Once";
49
+ }, "_tag">;
50
+ readonly WithDelay: Data.Case.Constructor<{
51
+ readonly _tag: "WithDelay";
52
+ readonly delay: Duration.DurationInput;
53
+ }, "_tag">;
54
+ readonly $is: <Tag extends "Once" | "WithDelay">(tag: Tag) => (u: unknown) => u is Extract<{
55
+ readonly _tag: "Once";
56
+ }, {
57
+ readonly _tag: Tag;
58
+ }> | Extract<{
59
+ readonly _tag: "WithDelay";
60
+ readonly delay: Duration.DurationInput;
61
+ }, {
62
+ readonly _tag: Tag;
63
+ }>;
64
+ readonly $match: {
65
+ <const Cases extends {
66
+ readonly Once: (args: {
67
+ readonly _tag: "Once";
68
+ }) => any;
69
+ readonly WithDelay: (args: {
70
+ readonly _tag: "WithDelay";
71
+ readonly delay: Duration.DurationInput;
72
+ }) => any;
73
+ }>(cases: Cases & { [K in Exclude<keyof Cases, "Once" | "WithDelay">]: never; }): (value: {
74
+ readonly _tag: "Once";
75
+ } | {
76
+ readonly _tag: "WithDelay";
77
+ readonly delay: Duration.DurationInput;
78
+ }) => import("effect/Unify").Unify<ReturnType<Cases["Once" | "WithDelay"]>>;
79
+ <const Cases extends {
80
+ readonly Once: (args: {
81
+ readonly _tag: "Once";
82
+ }) => any;
83
+ readonly WithDelay: (args: {
84
+ readonly _tag: "WithDelay";
85
+ readonly delay: Duration.DurationInput;
86
+ }) => any;
87
+ }>(value: {
88
+ readonly _tag: "Once";
89
+ } | {
90
+ readonly _tag: "WithDelay";
91
+ readonly delay: Duration.DurationInput;
92
+ }, cases: Cases & { [K in Exclude<keyof Cases, "Once" | "WithDelay">]: never; }): import("effect/Unify").Unify<ReturnType<Cases["Once" | "WithDelay"]>>;
93
+ };
94
+ };
10
95
  export interface SyncService<TState, TStartAux, TUpdate> {
11
96
  updates: (state: TState, auxData: TStartAux) => Stream.Stream<TUpdate, WalletError, Scope.Scope>;
12
97
  blockData: () => Effect.Effect<BlockData, WalletError>;
98
+ /** How often background synchronization re-runs {@link SyncService.updates}. See {@link BackgroundRepeat}. */
99
+ readonly backgroundRepeat: BackgroundRepeat;
13
100
  }
14
101
  export interface BlockData {
15
102
  hash: string;
package/dist/v1/Sync.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
- import { Effect, Either, Layer, ParseResult, pipe, Schema, Stream, Duration, Chunk, Schedule, Option, identity, } from 'effect';
13
+ import { Array as Arr, Data, Effect, Either, Layer, ParseResult, pipe, Schema, Stream, Duration, Chunk, Schedule, Option, identity, } from 'effect';
14
14
  import { ProtocolVersion } from '@midnight-ntwrk/wallet-sdk-abstractions';
15
15
  import { LedgerParametersCodec } from '@midnight-ntwrk/wallet-sdk-capabilities/codecs';
16
16
  import { Event as LedgerEvent, LedgerParameters, } from '@midnight-ntwrk/ledger-v8';
@@ -18,10 +18,23 @@ import { BlockHash, DustLedgerEventTip, DustLedgerEvents } from '@midnight-ntwrk
18
18
  import { WsSubscriptionClient, HttpQueryClient, ConnectionHelper, } from '@midnight-ntwrk/wallet-sdk-indexer-client/effect';
19
19
  import { EitherOps } from '@midnight-ntwrk/wallet-sdk-utilities';
20
20
  import { WsURL } from '@midnight-ntwrk/wallet-sdk-utilities/networking';
21
- import { OtherWalletError, SyncWalletError } from './WalletError.js';
21
+ import { OtherWalletError, OutOfOrderSyncUpdateError, SyncWalletError } from './WalletError.js';
22
22
  import { V8 } from '@midnight-ntwrk/wallet-sdk-capabilities/simulation';
23
23
  import { CoreWallet } from './CoreWallet.js';
24
24
  import { Uint8ArraySchema } from './Serialization.js';
25
+ /**
26
+ * Constructors and matchers for {@link BackgroundRepeat}.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const service: SyncService<CoreWallet, DustSecretKey, WalletSyncUpdate> = {
31
+ * backgroundRepeat: BackgroundRepeat.Once(),
32
+ * updates: (state, secretKey) => subscribe(state, secretKey),
33
+ * blockData: () => latestBlock(),
34
+ * };
35
+ * ```;
36
+ */
37
+ export const BackgroundRepeat = Data.taggedEnum();
25
38
  /**
26
39
  * The ledger parameters codecs this variant reads blocks with, unless it is told otherwise.
27
40
  *
@@ -244,6 +257,8 @@ const versionWatch = (config, resumeFrom, knownVersion) => {
244
257
  export const makeDefaultSyncService = (config) => {
245
258
  const indexerSyncService = makeIndexerSyncService(config);
246
259
  return {
260
+ // A long-lived indexer subscription: the one pass never ends, so there is nothing to repeat.
261
+ backgroundRepeat: BackgroundRepeat.Once(),
247
262
  updates: (state, secretKey) => {
248
263
  const batchSize = config.batchUpdates?.size ?? 10;
249
264
  const batchTimeout = Duration.millis(config.batchUpdates?.timeout ?? 1);
@@ -358,6 +373,26 @@ const applyVersionSignal = (state, update) => [
358
373
  : annotateVersion(state, Option.some(ProtocolVersion.ProtocolVersion(BigInt(update.version)))),
359
374
  noChanges(state),
360
375
  ];
376
+ /**
377
+ * Checks that freshly delivered events arrive in strictly ascending id order.
378
+ *
379
+ * @remarks
380
+ * Order is all that can be checked, never contiguity: dust events share one id sequence with zswap and contract events
381
+ * in the indexer, so gaps in a dust stream are normal. A skipped commitment-inserting event is caught by the ledger's
382
+ * own insertion check instead. An empty batch is trivially ordered.
383
+ * @param fresh The events left after the cursor filter, in source order.
384
+ * @param appliedIndex The highest event id already applied; the first fresh id must exceed it.
385
+ * @returns The batch unchanged, or the first id that fails to exceed its predecessor as an
386
+ * {@link OutOfOrderSyncUpdateError}.
387
+ */
388
+ 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({
389
+ onNone: () => Either.right(fresh),
390
+ onSome: ({ item, previous }) => Either.left(new OutOfOrderSyncUpdateError({
391
+ message: `Dust event ${item.id} delivered at or below its predecessor ${previous}; batch refused, applied index stays at ${appliedIndex}`,
392
+ expected: previous,
393
+ received: BigInt(item.id),
394
+ })),
395
+ }));
361
396
  export const makeDefaultSyncCapability = () => {
362
397
  return {
363
398
  applyUpdate(state, wrappedUpdate, activeRange) {
@@ -374,7 +409,14 @@ export const makeDefaultSyncCapability = () => {
374
409
  // The tip is a property of the source, not of what this variant chose to apply, so it is read from the batch
375
410
  // tail even when the tail belongs to the next protocol version.
376
411
  const highestRelevantWalletIndex = BigInt(updates.at(-1).maxId);
377
- const { applied, observedVersion } = splitAtVersionBoundary(freshUpdates, (update) => update.protocolVersion, activeRange);
412
+ // Refuse the whole batch: applying the sound prefix would move the cursor and hide the fault behind
413
+ // `isConnected: true`. State and cursor stay untouched, so the retry re-fetches the same range; a transient
414
+ // reorder heals, a persistent one fails visibly.
415
+ //
416
+ // `SyncCapability.applyUpdate` has no typed error channel, so throwing preserves its public tuple-returning API;
417
+ // `RunningV1Variant` catches this at the capability boundary. See #572 for the planned `Either`-based API.
418
+ const orderedUpdates = Either.getOrThrowWith(expectAscending(freshUpdates, appliedIndex), identity);
419
+ const { applied, observedVersion } = splitAtVersionBoundary(orderedUpdates, (update) => update.protocolVersion, activeRange);
378
420
  // Read here and nowhere earlier: `applied` is exactly the slice this variant owns, so nothing outside its
379
421
  // activation range — nor anything below its cursor — is ever handed to this ledger version's deserializer.
380
422
  const [newState, changes] = applied.length === 0
@@ -402,6 +444,8 @@ export const makeDefaultSyncCapability = () => {
402
444
  };
403
445
  export const makeSimulatorSyncService = (config) => {
404
446
  return {
447
+ // A live feed of simulator states: the one pass never ends, so there is nothing to repeat.
448
+ backgroundRepeat: BackgroundRepeat.Once(),
405
449
  updates: (_state, secretKey) => {
406
450
  // Get the initial state immediately to ensure we process the genesis block.
407
451
  // Then subscribe to state$ for subsequent changes, but deduplicate by block number
@@ -39,5 +39,30 @@ export declare class TransactionHistoryError extends TransactionHistoryError_bas
39
39
  cause?: unknown;
40
40
  }> {
41
41
  }
42
- export type WalletError = OtherWalletError | SyncWalletError | TransactingError | InsufficientFundsError | TransactionHistoryError | LedgerOps.LedgerError;
42
+ declare const OutOfOrderSyncUpdateError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
43
+ readonly _tag: "Wallet.OutOfOrderSyncUpdate";
44
+ } & Readonly<A>;
45
+ /**
46
+ * A batch of dust ledger events was not in ascending id order: an event arrived at or below the one before it, or at or
47
+ * below the applied cursor.
48
+ *
49
+ * @remarks
50
+ * Order is all the wallet can check: dust events share one id sequence with zswap and contract events in the indexer,
51
+ * so gaps in the stream are normal and a skipped event is only ever caught by the ledger's own insertion check. The
52
+ * whole batch is refused, nothing is applied and the cursor stays, so the running variant retries from the same
53
+ * place.
54
+ * @example
55
+ * ```ts
56
+ * if (error._tag === 'Wallet.OutOfOrderSyncUpdate') console.warn(`expected above ${error.expected}, got ${error.received}`);
57
+ * ```
58
+ */
59
+ export declare class OutOfOrderSyncUpdateError extends OutOfOrderSyncUpdateError_base<{
60
+ readonly message: string;
61
+ /** The id the delivered event had to exceed: its predecessor in the batch, or the applied cursor. */
62
+ readonly expected: bigint;
63
+ /** The event id the source delivered instead. */
64
+ readonly received: bigint;
65
+ }> {
66
+ }
67
+ export type WalletError = OtherWalletError | SyncWalletError | TransactingError | InsufficientFundsError | TransactionHistoryError | OutOfOrderSyncUpdateError | LedgerOps.LedgerError;
43
68
  export {};
@@ -21,3 +21,19 @@ export class InsufficientFundsError extends Data.TaggedError('Wallet.Insufficien
21
21
  }
22
22
  export class TransactionHistoryError extends Data.TaggedError('Wallet.TransactionHistory') {
23
23
  }
24
+ /**
25
+ * A batch of dust ledger events was not in ascending id order: an event arrived at or below the one before it, or at or
26
+ * below the applied cursor.
27
+ *
28
+ * @remarks
29
+ * Order is all the wallet can check: dust events share one id sequence with zswap and contract events in the indexer,
30
+ * so gaps in the stream are normal and a skipped event is only ever caught by the ledger's own insertion check. The
31
+ * whole batch is refused, nothing is applied and the cursor stays, so the running variant retries from the same
32
+ * place.
33
+ * @example
34
+ * ```ts
35
+ * if (error._tag === 'Wallet.OutOfOrderSyncUpdate') console.warn(`expected above ${error.expected}, got ${error.received}`);
36
+ * ```
37
+ */
38
+ export class OutOfOrderSyncUpdateError extends Data.TaggedError('Wallet.OutOfOrderSyncUpdate') {
39
+ }
@@ -1,4 +1,4 @@
1
- import { Effect, Stream, Scope } from 'effect';
1
+ import { Effect, Stream, Scope, Duration, Schedule } from 'effect';
2
2
  import { type TransactionHistoryService } from './TransactionHistory.js';
3
3
  import { type DustSecretKey, type Signature, type SignatureVerifyingKey, type FinalizedTransaction, type UnprovenTransaction } from '@midnightntwrk/ledger-v9';
4
4
  import { type WalletError } from './WalletError.js';
@@ -28,6 +28,14 @@ export declare namespace RunningV2Variant {
28
28
  type AnyContext = Context<any, any, any, any>;
29
29
  }
30
30
  export declare const V2Tag: unique symbol;
31
+ /**
32
+ * How a failing synchronization pass is retried.
33
+ *
34
+ * @remarks
35
+ * Exported so the delays it actually produces can be asserted. A schedule reads as though its combinators apply to the
36
+ * delay, and for some of them they do not — which is worth pinning in a test rather than trusting by eye.
37
+ */
38
+ export declare const syncRetrySchedule: Schedule.Schedule<[Duration.Duration, number], unknown, never>;
31
39
  export type DefaultRunningV2 = RunningV2Variant<string, SimulatorState, FinalizedTransaction, DustSecretKey>;
32
40
  export declare class RunningV2Variant<TSerialized, TSyncUpdate, TTransaction, TStartAux> implements Variant.RunningVariant<typeof V2Tag, CoreWallet> {
33
41
  #private;
@@ -15,6 +15,7 @@ import { ProtocolVersion, Token } from '@midnight-ntwrk/wallet-sdk-abstractions'
15
15
  import { OtherWalletError } from './WalletError.js';
16
16
  import { ArrayOps, EitherOps } from '@midnight-ntwrk/wallet-sdk-utilities';
17
17
  import { StateChange, VersionChangeType, } from '@midnight-ntwrk/wallet-sdk-runtime/abstractions';
18
+ import { BackgroundRepeat } from './Sync.js';
18
19
  const progress = (state) => {
19
20
  const appliedIndex = state.progress?.appliedIndex ?? 0n;
20
21
  const highestRelevantWalletIndex = state.progress?.highestRelevantWalletIndex ?? 0n;
@@ -48,6 +49,21 @@ const protocolVersionChange = (previous, current, isInitial, activationRange) =>
48
49
  : [];
49
50
  };
50
51
  export const V2Tag = Symbol('V2');
52
+ /**
53
+ * How a failing synchronization pass is retried.
54
+ *
55
+ * @remarks
56
+ * Exported so the delays it actually produces can be asserted. A schedule reads as though its combinators apply to the
57
+ * delay, and for some of them they do not — which is worth pinning in a test rather than trusting by eye.
58
+ */
59
+ export const syncRetrySchedule = pipe(Schedule.exponential(Duration.seconds(1), 2), Schedule.jittered,
60
+ // `union` retries as soon as either side would, so pairing the backoff with a fixed two-minute schedule takes the
61
+ // shorter of the two — which is the backoff until it grows past two minutes, and two minutes from then on. Written
62
+ // this way rather than as a `Schedule.map` over the delay: `map` rewrites a schedule's *output*, which it takes
63
+ // **after** the delay has already been read from it, so mapping an exponential leaves the timing untouched. The
64
+ // earlier form did exactly that, and backed off unboundedly — past an hour by the twelfth attempt — while reading
65
+ // as though it were capped.
66
+ Schedule.union(Schedule.spaced(Duration.minutes(2))));
51
67
  export class RunningV2Variant {
52
68
  __polyTag__ = V2Tag;
53
69
  #scope;
@@ -59,11 +75,26 @@ export class RunningV2Variant {
59
75
  #txHistoryPermits = Effect.makeSemaphore(8).pipe(Effect.runSync);
60
76
  state;
61
77
  #syncLock;
78
+ /**
79
+ * Whether a background synchronization worker is already running.
80
+ *
81
+ * @remarks
82
+ * Distinct from {@link #syncLock}, which is released at the end of every pass and so says nothing about the worker
83
+ * that runs them. Held for the worker's whole lifetime — polling delays and retries included — and released as it
84
+ * exits, so a wallet started again while it is already synchronizing keeps the worker it has instead of gaining a
85
+ * second one that polls alongside it until the wallet stops.
86
+ *
87
+ * The guard asks only whether a worker is running, not what it was started with, so a second start is ignored even
88
+ * when it carries different key material. Starting a running wallet on a second key is not a supported way to
89
+ * change keys: stop it first. Two workers would be no better — they would apply two keys' updates to one state.
90
+ */
91
+ #backgroundWorkerLock;
62
92
  constructor(scope, context, v2Context) {
63
93
  this.#scope = scope;
64
94
  this.#context = context;
65
95
  this.#v2Context = v2Context;
66
96
  this.#syncLock = Effect.runSync(Ref.make(false));
97
+ this.#backgroundWorkerLock = Effect.runSync(Ref.make(false));
67
98
  this.state = Stream.fromEffect(context.stateRef.get).pipe(Stream.flatMap((initialState) => context.stateRef.changes.pipe(
68
99
  // The accumulator carries the "have we seen anything yet" flag alongside the previous state: the first
69
100
  // observation is the only one that can be a restored state nobody has inspected against this variant's
@@ -78,12 +109,29 @@ export class RunningV2Variant {
78
109
  }));
79
110
  }
80
111
  startSyncInBackground(startAux) {
81
- return this.startSync(startAux).pipe(Stream.retry(pipe(Schedule.exponential(Duration.seconds(1), 2), Schedule.map((delay) => {
82
- const maxDelay = Duration.minutes(2);
83
- const jitter = Duration.millis(Math.floor(Math.random() * 1000));
84
- const delayWithJitter = Duration.toMillis(delay) + Duration.toMillis(jitter);
85
- return Duration.millis(Math.min(delayWithJitter, Duration.toMillis(maxDelay)));
86
- }))), Stream.runScoped(Sink.drain), Effect.forkScoped, Effect.provideService(Scope.Scope, this.#scope));
112
+ // One pass, in a scope of its own. `Stream.runScoped` acquires into whichever scope it is handed, and the source's
113
+ // client layers take `Scope` as an input requirement rather than discharging one of their own — so a pass drained
114
+ // straight into the variant scope leaves its clients open there until the wallet stops, one more set per repeat.
115
+ // Scoping here closes them as the pass ends, on success, failure or interruption alike, and before any retry delay.
116
+ // The tx-history fan-out `startSync` forks is deliberately unaffected: it is pinned to the variant scope
117
+ // explicitly, and none of its work needs a client this scope holds.
118
+ const pass = this.startSync(startAux).pipe(Stream.runScoped(Sink.drain), Effect.scoped);
119
+ // Retrying the drained pass rather than the stream keeps the scope inside the retry: a failed attempt gives its
120
+ // resources back before the backoff, instead of holding them for the length of it.
121
+ const attempt = pass.pipe(Effect.retry(syncRetrySchedule));
122
+ // A service that synchronizes in finite passes would otherwise leave the wallet frozen at whatever its first pass
123
+ // saw. Repeating re-runs `startSync`, which re-reads the wallet state, so each pass resumes from what the previous
124
+ // one applied. The retry above cannot serve this purpose: it re-runs a pass on failure, not on completion.
125
+ // Repeating outside it keeps a transient failure to the pass it happened in, rather than restarting the cycle.
126
+ const worker = BackgroundRepeat.$match(this.#v2Context.syncService.backgroundRepeat, {
127
+ Once: () => attempt,
128
+ WithDelay: ({ delay }) => Effect.repeat(attempt, Schedule.spaced(delay)).pipe(Effect.asVoid),
129
+ });
130
+ // Taken before the worker is forked and given back as it exits, so the guard covers the gaps between passes that
131
+ // the sync lock cannot. A second start finds it held and leaves the running worker alone.
132
+ return Ref.modify(this.#backgroundWorkerLock, (isRunning) => [!isRunning, true]).pipe(Effect.flatMap((acquired) => acquired
133
+ ? worker.pipe(Effect.ensuring(Ref.set(this.#backgroundWorkerLock, false)), Effect.forkScoped, Effect.asVoid)
134
+ : Effect.void), Effect.provideService(Scope.Scope, this.#scope));
87
135
  }
88
136
  sync(startAux) {
89
137
  return this.startSync(startAux).pipe(Stream.runScoped(Sink.drain), Effect.scoped);
package/dist/v2/Sync.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Array as Arr, Effect, Layer, Option, type Scope, Stream } from 'effect';
1
+ import { Array as Arr, Data, Effect, Layer, Option, type Scope, Stream, Duration } from 'effect';
2
2
  import { ProtocolVersion } from '@midnight-ntwrk/wallet-sdk-abstractions';
3
3
  import { type LedgerParametersCodec } from '@midnight-ntwrk/wallet-sdk-capabilities/codecs';
4
4
  import { type DustNullifier, type DustSecretKey, DustStateChanges, type DustLocalState, type LedgerParameters } from '@midnightntwrk/ledger-v9';
@@ -9,9 +9,91 @@ import { CoreWallet } from './CoreWallet.js';
9
9
  import { type NetworkId } from './types/ledger.js';
10
10
  import { type CollapsedMerkleTree, type DustGenerationsSubscription, type DustNullifierTransactionsSubscription, type DustProjectionsUpdate, type DustSpendProcessedEvent, type DustUtxoUpdate, DustUtxoMap, type WalletSyncSubscription, WalletSyncUpdate, type BlockData, type DustGenerationDtimUpdate, type NullifierRegularTransaction } from './SyncSchema.js';
11
11
  import { type Dust } from './types/index.js';
12
+ /**
13
+ * How often background synchronization runs a sync service's `updates`.
14
+ *
15
+ * @remarks
16
+ * Every service states this, because the answer cannot be inferred from the service: it follows from whether `updates`
17
+ * is finite, which only the service knows. A service whose `updates` is a long-lived subscription is
18
+ * {@link BackgroundRepeat.Once} — that single pass never ends, so there is nothing to repeat. A service that
19
+ * synchronizes in finite passes is {@link BackgroundRepeat.WithDelay}, or a wallet synchronizing in the background
20
+ * would converge once and then never observe anything again. The variant's background retry does not cover the
21
+ * latter: it re-runs a pass on failure, not on completion.
22
+ */
23
+ export type BackgroundRepeat = Data.TaggedEnum<{
24
+ /** Run `updates` once and leave it running. For a service whose `updates` never completes. */
25
+ Once: {};
26
+ /** Re-run `updates` this long after each pass completes. For a service that synchronizes in finite passes. */
27
+ WithDelay: {
28
+ readonly delay: Duration.DurationInput;
29
+ };
30
+ }>;
31
+ /**
32
+ * Constructors and matchers for {@link BackgroundRepeat}.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * const service: SyncService<CoreWallet, DustSecretKey, WalletSyncUpdate> = {
37
+ * backgroundRepeat: BackgroundRepeat.Once(),
38
+ * updates: (state, secretKey) => subscribe(state, secretKey),
39
+ * blockData: () => latestBlock(),
40
+ * };
41
+ * ```;
42
+ */
43
+ export declare const BackgroundRepeat: {
44
+ readonly Once: Data.Case.Constructor<{
45
+ readonly _tag: "Once";
46
+ }, "_tag">;
47
+ readonly WithDelay: Data.Case.Constructor<{
48
+ readonly _tag: "WithDelay";
49
+ readonly delay: Duration.DurationInput;
50
+ }, "_tag">;
51
+ readonly $is: <Tag extends "Once" | "WithDelay">(tag: Tag) => (u: unknown) => u is Extract<{
52
+ readonly _tag: "Once";
53
+ }, {
54
+ readonly _tag: Tag;
55
+ }> | Extract<{
56
+ readonly _tag: "WithDelay";
57
+ readonly delay: Duration.DurationInput;
58
+ }, {
59
+ readonly _tag: Tag;
60
+ }>;
61
+ readonly $match: {
62
+ <const Cases extends {
63
+ readonly Once: (args: {
64
+ readonly _tag: "Once";
65
+ }) => any;
66
+ readonly WithDelay: (args: {
67
+ readonly _tag: "WithDelay";
68
+ readonly delay: Duration.DurationInput;
69
+ }) => any;
70
+ }>(cases: Cases & { [K in Exclude<keyof Cases, "Once" | "WithDelay">]: never; }): (value: {
71
+ readonly _tag: "Once";
72
+ } | {
73
+ readonly _tag: "WithDelay";
74
+ readonly delay: Duration.DurationInput;
75
+ }) => import("effect/Unify").Unify<ReturnType<Cases["Once" | "WithDelay"]>>;
76
+ <const Cases extends {
77
+ readonly Once: (args: {
78
+ readonly _tag: "Once";
79
+ }) => any;
80
+ readonly WithDelay: (args: {
81
+ readonly _tag: "WithDelay";
82
+ readonly delay: Duration.DurationInput;
83
+ }) => any;
84
+ }>(value: {
85
+ readonly _tag: "Once";
86
+ } | {
87
+ readonly _tag: "WithDelay";
88
+ readonly delay: Duration.DurationInput;
89
+ }, cases: Cases & { [K in Exclude<keyof Cases, "Once" | "WithDelay">]: never; }): import("effect/Unify").Unify<ReturnType<Cases["Once" | "WithDelay"]>>;
90
+ };
91
+ };
12
92
  export interface SyncService<TState, TStartAux, TUpdate> {
13
93
  updates: (state: TState, auxData: TStartAux) => Stream.Stream<TUpdate, WalletError, Scope.Scope>;
14
94
  blockData: (height?: number) => Effect.Effect<BlockData, WalletError>;
95
+ /** How often background synchronization re-runs {@link SyncService.updates}. See {@link BackgroundRepeat}. */
96
+ readonly backgroundRepeat: BackgroundRepeat;
15
97
  }
16
98
  export type ChangesResult = {
17
99
  readonly changes: DustStateChanges[];
@@ -121,6 +203,19 @@ export type DefaultSyncConfiguration = {
121
203
  versionWatch?: VersionWatchConfig;
122
204
  /** The ledger parameters codecs blocks are read with; defaults to {@link defaultLedgerParametersCodecs}. */
123
205
  ledgerParametersCodecs?: LedgerParametersCodec.LedgerParametersCodecs<LedgerParameters>;
206
+ /**
207
+ * Delay in milliseconds between background synchronization passes, for a sync service that synchronizes in finite
208
+ * passes rather than over a live subscription — currently only the projections service. Ignored by the event-based
209
+ * service, whose subscription never completes.
210
+ *
211
+ * Lower values keep the wallet closer to the chain tip at the cost of more idle queries; a pass with nothing to do
212
+ * costs one block query, because the generation subscription and the commitment load both short-circuit on an
213
+ * unchanged tip. Passes never overlap regardless of this value: a pass that cannot take the sync lock yields to the
214
+ * one already running and waits for the next interval.
215
+ *
216
+ * @default 5000
217
+ */
218
+ backgroundSyncInterval?: number;
124
219
  };
125
220
  export type SimulatorSyncConfiguration = {
126
221
  simulator: Simulator;
@@ -136,6 +231,23 @@ export declare const SecretKeysResource: {
136
231
  };
137
232
  export declare const makeDefaultSyncService: (config: DefaultSyncConfiguration) => SyncService<CoreWallet, DustSecretKey, WalletSyncUpdate>;
138
233
  export declare const nullifierPhaseProgress: (mtIndicesSum: bigint, totalNullifiers: number, freshCount: number, maxCommitmentEndIndex: number) => number;
234
+ /**
235
+ * The applied index a mid-phase nullifier report carries, on the same basis as every other report in the pass.
236
+ *
237
+ * @remarks
238
+ * The nullifier term is the larger of two true statements about the same work. {@link nullifierPhaseProgress} measures
239
+ * this phase from zero, while the report that opened the phase already credited the nullifiers the wallet arrived
240
+ * holding; early in the phase the credit is the further along of the two, and reporting the measurement alone would
241
+ * take progress backwards.
242
+ * @param basis The two terms this phase does not advance: commitment work, and nullifier work already credited.
243
+ * @param generationWork The generation term, which is complete by the time this phase runs.
244
+ * @param phaseProgress What {@link nullifierPhaseProgress} has measured so far.
245
+ * @returns The applied index to report.
246
+ */
247
+ export declare const nullifierPhaseAppliedIndex: (basis: {
248
+ readonly commitmentWork: number;
249
+ readonly creditedNullifierWork: number;
250
+ }, generationWork: number, phaseProgress: number) => number;
139
251
  export declare const doEventlessSync: (state: CoreWallet, secretKey: DustSecretKey, anonymityLevel: number, indexerSyncService: IndexerSyncService, ledgerParametersCodecs?: LedgerParametersCodec.LedgerParametersCodecs<LedgerParameters>) => Stream.Stream<DustProjectionsUpdate, WalletError, Scope.Scope | QueryClient | SubscriptionClient>;
140
252
  export declare const makeEventLessSyncService: (config: DefaultSyncConfiguration) => SyncService<CoreWallet, DustSecretKey, DustProjectionsUpdate>;
141
253
  export type IndexerSyncService = {
package/dist/v2/Sync.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
- import { Array as Arr, Effect, Either, HashMap, HashSet, Layer, Option, pipe, Schema, Stream, Duration, Chunk, Schedule, identity, } from 'effect';
13
+ import { Array as Arr, Data, Effect, Either, HashMap, HashSet, Layer, Option, pipe, Schema, Stream, Duration, Chunk, Schedule, identity, } from 'effect';
14
14
  import { ProtocolVersion } from '@midnight-ntwrk/wallet-sdk-abstractions';
15
15
  import { dustNullifier, successorDustUtxo, DustStateChanges, } from '@midnightntwrk/ledger-v9';
16
16
  import { DustAddress } from '@midnight-ntwrk/wallet-sdk-address-format';
@@ -18,11 +18,24 @@ import { DustGenerationEvents, BlockHash, DustLedgerEventTip, DustLedgerEvents,
18
18
  import { WsSubscriptionClient, HttpQueryClient, ConnectionHelper, } from '@midnight-ntwrk/wallet-sdk-indexer-client/effect';
19
19
  import { EitherOps } from '@midnight-ntwrk/wallet-sdk-utilities';
20
20
  import { WsURL } from '@midnight-ntwrk/wallet-sdk-utilities/networking';
21
- import { OtherWalletError, SyncWalletError } from './WalletError.js';
21
+ import { OtherWalletError, OutOfOrderSyncUpdateError, SyncWalletError } from './WalletError.js';
22
22
  import { getLastBlock } from '@midnight-ntwrk/wallet-sdk-capabilities/simulation';
23
23
  import { CoreWallet } from './CoreWallet.js';
24
24
  import { CollapsedMerkleTreeSchema, DustGenerationsSubscriptionSchema, DustGenerationsSyncUpdate, DustNullifierTransactionSubscriptionSchema, DustUtxoMap, SyncEventsUpdateSchema, VersionSignalSyncUpdate, WalletSyncUpdate, defaultLedgerParametersCodecs, makeBlockDataSchema, ProgressUpdate, StateUpdate, isProgressUpdate, readEvent, readNullifierBlockParameters, tryReadEvent, } from './SyncSchema.js';
25
25
  import { bigintToLeHex, calculatePrefixLength, gapRanges, hashMapGroupBy, leBigintToHex, uniqueArray, } from './Utils.js';
26
+ /**
27
+ * Constructors and matchers for {@link BackgroundRepeat}.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const service: SyncService<CoreWallet, DustSecretKey, WalletSyncUpdate> = {
32
+ * backgroundRepeat: BackgroundRepeat.Once(),
33
+ * updates: (state, secretKey) => subscribe(state, secretKey),
34
+ * blockData: () => latestBlock(),
35
+ * };
36
+ * ```;
37
+ */
38
+ export const BackgroundRepeat = Data.taggedEnum();
26
39
  /**
27
40
  * Splits a batch of version-tagged items at the end of a variant's activation range.
28
41
  *
@@ -155,6 +168,8 @@ export const SecretKeysResource = {
155
168
  export const makeDefaultSyncService = (config) => {
156
169
  const indexerSyncService = makeIndexerSyncService(config);
157
170
  return {
171
+ // A long-lived indexer subscription: the one pass never ends, so there is nothing to repeat.
172
+ backgroundRepeat: BackgroundRepeat.Once(),
158
173
  updates: (state, secretKey) => {
159
174
  const batchSize = config.batchUpdates?.size ?? 10;
160
175
  const batchTimeout = Duration.millis(config.batchUpdates?.timeout ?? 1);
@@ -205,7 +220,26 @@ const NULLIFIER_LENGTH = NULLIFIER_BYTE_LENGTH * 2;
205
220
  // counts as a full scan of the commitment space; a still-live chain counts up to the merkle index of its freshest
206
221
  // successor. Bounded by totalNullifiers * maxCommitmentEndIndex (the phase ceiling).
207
222
  export const nullifierPhaseProgress = (mtIndicesSum, totalNullifiers, freshCount, maxCommitmentEndIndex) => Math.min(Number(mtIndicesSum) + (totalNullifiers - freshCount) * maxCommitmentEndIndex, totalNullifiers * maxCommitmentEndIndex);
208
- const resolveNullifierSpends = (initialNullifiers, initialNewUtxos, pendingDust, dustState, secretKey, latestBlock, dustGenerationUpdates, indexerSyncService, anonymityLevel, ledgerParametersCodecs, emit) => {
223
+ /**
224
+ * The applied index a mid-phase nullifier report carries, on the same basis as every other report in the pass.
225
+ *
226
+ * @remarks
227
+ * The nullifier term is the larger of two true statements about the same work. {@link nullifierPhaseProgress} measures
228
+ * this phase from zero, while the report that opened the phase already credited the nullifiers the wallet arrived
229
+ * holding; early in the phase the credit is the further along of the two, and reporting the measurement alone would
230
+ * take progress backwards.
231
+ * @param basis The two terms this phase does not advance: commitment work, and nullifier work already credited.
232
+ * @param generationWork The generation term, which is complete by the time this phase runs.
233
+ * @param phaseProgress What {@link nullifierPhaseProgress} has measured so far.
234
+ * @returns The applied index to report.
235
+ */
236
+ export const nullifierPhaseAppliedIndex = (basis, generationWork, phaseProgress) => generationWork + basis.commitmentWork + Math.max(basis.creditedNullifierWork, phaseProgress);
237
+ const resolveNullifierSpends = (initialNullifiers, initialNewUtxos, pendingDust, dustState, secretKey, latestBlock, dustGenerationUpdates, indexerSyncService, anonymityLevel, ledgerParametersCodecs,
238
+ /**
239
+ * The two terms of the pass's progress basis this phase does not itself advance, so that the progress it reports is a
240
+ * sum on the same basis as every other report rather than a figure of its own.
241
+ */
242
+ progressBasis, emit) => {
209
243
  const maxCommitmentEndIndex = latestBlock.dustCommitmentEndIndex - 1;
210
244
  const initialSpentUtxos = HashMap.empty();
211
245
  return pipe(Stream.unfoldEffect([initialNullifiers, initialNewUtxos, initialSpentUtxos], ([nullifiersToCheck, newUtxos, spentUtxos]) => {
@@ -222,8 +256,13 @@ const resolveNullifierSpends = (initialNullifiers, initialNewUtxos, pendingDust,
222
256
  const mtIndicesSum = freshUtxos.reduce((partialSum, a) => partialSum + a.qdo.mtIndex, 0n);
223
257
  const generationEndIndex = latestBlock.dustGenerationEndIndex - 1;
224
258
  const progress = nullifierPhaseProgress(mtIndicesSum, initialNullifiers.length, freshUtxos.length, maxCommitmentEndIndex);
225
- // NOTE: since this process goes after the generation updates, we need to add the generationEndIndex to the progress
226
- yield* Effect.promise(() => emit.single(ProgressUpdate({ appliedIndex: progress + generationEndIndex })));
259
+ // This phase runs after the generation updates, so its term sits on top of a completed generation tree and
260
+ // the commitment work the wallet already had. `nullifierPhaseProgress` counts this phase from zero, so on
261
+ // an early iteration it can still be behind the credit report 2 gave for nullifiers the wallet arrived
262
+ // holding; the larger of the two is the one that is true.
263
+ yield* Effect.promise(() => emit.single(ProgressUpdate({
264
+ appliedIndex: nullifierPhaseAppliedIndex(progressBasis, generationEndIndex, progress),
265
+ })));
227
266
  }
228
267
  const { nextUtxos, nextSpentUtxos } = accumulateUtxoUpdates(dustUtxoUpdates, newUtxos, spentUtxos);
229
268
  const nextNullifiersToCheck = dustUtxoUpdates.filter((u) => !u.isSpent).map((u) => u.dustNullifier);
@@ -242,10 +281,15 @@ export const doEventlessSync = (state, secretKey, anonymityLevel, indexerSyncSer
242
281
  const lastSyncedCommitmentIndex = state.state.commitmentTreeFirstFree - 1n;
243
282
  const lastSyncedGenerationIndex = state.state.generatingTreeFirstFree - 1n;
244
283
  const lastSyncedBlockHeight = state.progress.highestIndex;
245
- const highestInitialIndex = maxGeneratingTreeIndex + maxCommitmentTreeIndex + state.state.nullifiers.size * maxCommitmentTreeIndex;
246
- const initialAppliedIndex = Number(lastSyncedGenerationIndex) +
247
- Number(lastSyncedCommitmentIndex) +
248
- state.state.nullifiers.size * Number(maxCommitmentTreeIndex);
284
+ // Every report this pass makes is a sum of the same three terms — generation work, commitment work, nullifier
285
+ // work — against a total on that same basis. A report that leaves one of them out does not read as "less far
286
+ // along"; it reads as progress going *backwards*, because the terms it drops were already counted. That
287
+ // matters beyond cosmetics: `isSynced` is exact equality between the applied and relevant indexes, so a
288
+ // settled wallet whose pass undercounts flips to "not synced" and back on every background repeat.
289
+ const commitmentWork = Number(lastSyncedCommitmentIndex);
290
+ const creditedNullifierWork = state.state.nullifiers.size * maxCommitmentTreeIndex;
291
+ const highestInitialIndex = maxGeneratingTreeIndex + maxCommitmentTreeIndex + creditedNullifierWork;
292
+ const initialAppliedIndex = Number(lastSyncedGenerationIndex) + commitmentWork + creditedNullifierWork;
249
293
  // What this pass resumed from, in one line. A projections pass is a single snapshot fetch, so when it comes back
250
294
  // with less than expected the only question worth asking is where it started — and the three cursors that decide
251
295
  // that are otherwise invisible to anything outside this function, including to a wallet that arrived here by
@@ -261,12 +305,20 @@ export const doEventlessSync = (state, secretKey, anonymityLevel, indexerSyncSer
261
305
  .map((n) => n.dustNullifier)
262
306
  .concat([...state.state.nullifiers.keys(), ...state.pendingDust.map((d) => d.nullifier)]);
263
307
  const highestRelevantIndex = maxGeneratingTreeIndex + maxCommitmentTreeIndex + allNullifiers.length * maxCommitmentTreeIndex;
264
- // increase the highestRelevantIndex as our nullifier list got expanded by new generations
265
- // appliedIndex now reflects the completed generation tree sync
266
- yield* Effect.promise(() => emit.single(ProgressUpdate({ highestRelevantIndex, appliedIndex: maxGeneratingTreeIndex })));
308
+ // The generation tree is now synced to the chain's end index, so its term becomes the full
309
+ // `maxGeneratingTreeIndex`. The commitment and nullifier terms are carried across untouched — this phase did
310
+ // not undo them. `highestRelevantIndex` rises here because new generations added nullifiers to check.
311
+ yield* Effect.promise(() => emit.single(ProgressUpdate({
312
+ highestRelevantIndex,
313
+ appliedIndex: maxGeneratingTreeIndex + commitmentWork + creditedNullifierWork,
314
+ })));
267
315
  const newUtxos = DustUtxoMap.create(dustGenerationUpdates.newGenerations);
268
- const { nextUtxos: finalUtxos, nextSpentUtxos: finalSpentUtxos } = yield* resolveNullifierSpends(allNullifiers, newUtxos, CoreWallet.pendingDustToMap(state.pendingDust), state.state, secretKey, latestBlock, dustGenerationUpdates, indexerSyncService, anonymityLevel, ledgerParametersCodecs, emit);
269
- yield* Effect.promise(() => emit.single(ProgressUpdate({ appliedIndex: maxGeneratingTreeIndex + allNullifiers.length * maxCommitmentTreeIndex })));
316
+ const { nextUtxos: finalUtxos, nextSpentUtxos: finalSpentUtxos } = yield* resolveNullifierSpends(allNullifiers, newUtxos, CoreWallet.pendingDustToMap(state.pendingDust), state.state, secretKey, latestBlock, dustGenerationUpdates, indexerSyncService, anonymityLevel, ledgerParametersCodecs, { commitmentWork, creditedNullifierWork }, emit);
317
+ // Every nullifier has been resolved, so the nullifier term reaches its full width. The commitment phase has
318
+ // not run yet, so its term is still what the wallet arrived holding.
319
+ yield* Effect.promise(() => emit.single(ProgressUpdate({
320
+ appliedIndex: maxGeneratingTreeIndex + commitmentWork + allNullifiers.length * maxCommitmentTreeIndex,
321
+ })));
270
322
  // lastSyncedCommitmentIndex out of maxCommitmentTreeIndex
271
323
  const collapsedCommitments = yield* loadCollapsedCommitments(Number(lastSyncedCommitmentIndex), maxCommitmentTreeIndex, finalUtxos, indexerSyncService);
272
324
  yield* Effect.promise(() => emit.single(StateUpdate({
@@ -279,13 +331,21 @@ export const doEventlessSync = (state, secretKey, anonymityLevel, indexerSyncSer
279
331
  // NOTE: appliedIndex = highestRelevantIndex means we're fully synced
280
332
  yield* Effect.promise(() => emit.single(ProgressUpdate({ appliedIndex: highestRelevantIndex })));
281
333
  yield* Effect.promise(() => emit.end());
282
- }));
334
+ }),
335
+ // The whole pass runs inside this register effect, and Effect only starts draining the stream once that effect
336
+ // returns — so every update a pass emits has to sit in the buffer simultaneously. The default buffer is bounded
337
+ // (16), which caps the sync at a handful of Dust spends: the emission that overflows it suspends waiting for
338
+ // capacity no consumer is running to free, and the pass deadlocks silently rather than failing. A pass emits one
339
+ // update per resolved spend, so the ceiling is reached by any wallet with a modest spend history.
340
+ 'unbounded');
283
341
  };
284
342
  export const makeEventLessSyncService = (config) => {
285
343
  const defaultSyncService = makeDefaultSyncService(config);
286
344
  const indexerSyncService = makeIndexerSyncService(config);
287
345
  const anonymityLevel = config.anonymityLevel ?? 7;
288
346
  return {
347
+ // Each pass ends its own stream, so background synchronization has to be told to run another one.
348
+ backgroundRepeat: BackgroundRepeat.WithDelay({ delay: Duration.millis(config.backgroundSyncInterval ?? 5_000) }),
289
349
  updates: (state, secretKey) => pipe(doEventlessSync(state, secretKey, anonymityLevel, indexerSyncService, config.ledgerParametersCodecs ?? defaultLedgerParametersCodecs), Stream.provideSomeLayer(Layer.merge(indexerSyncService.connectionLayer(), indexerSyncService.queryClient()))),
290
350
  blockData: defaultSyncService.blockData,
291
351
  };
@@ -401,6 +461,26 @@ const applyVersionSignal = (state, update) => [
401
461
  : annotateVersion(state, Option.some(ProtocolVersion.ProtocolVersion(BigInt(update.version)))),
402
462
  noChanges(state),
403
463
  ];
464
+ /**
465
+ * Checks that freshly delivered events arrive in strictly ascending id order.
466
+ *
467
+ * @remarks
468
+ * Order is all that can be checked, never contiguity: dust events share one id sequence with zswap and contract events
469
+ * in the indexer, so gaps in a dust stream are normal. A skipped commitment-inserting event is caught by the ledger's
470
+ * own insertion check instead. An empty batch is trivially ordered.
471
+ * @param fresh The events left after the cursor filter, in source order.
472
+ * @param appliedIndex The highest event id already applied; the first fresh id must exceed it.
473
+ * @returns The batch unchanged, or the first id that fails to exceed its predecessor as an
474
+ * {@link OutOfOrderSyncUpdateError}.
475
+ */
476
+ 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({
477
+ onNone: () => Either.right(fresh),
478
+ onSome: ({ item, previous }) => Either.left(new OutOfOrderSyncUpdateError({
479
+ message: `Dust event ${item.id} delivered at or below its predecessor ${previous}; batch refused, applied index stays at ${appliedIndex}`,
480
+ expected: previous,
481
+ received: BigInt(item.id),
482
+ })),
483
+ }));
404
484
  export const makeDefaultSyncCapability = () => {
405
485
  return {
406
486
  applyUpdate(state, wrappedUpdate, activeRange) {
@@ -417,7 +497,14 @@ export const makeDefaultSyncCapability = () => {
417
497
  // The tip is a property of the source, not of what this variant chose to apply, so it is read from the batch
418
498
  // tail even when the tail belongs to the next protocol version.
419
499
  const highestRelevantWalletIndex = BigInt(updates.at(-1).maxId);
420
- const { applied, observedVersion } = splitAtVersionBoundary(freshUpdates, (update) => update.protocolVersion, activeRange);
500
+ // Refuse the whole batch: applying the sound prefix would move the cursor and hide the fault behind
501
+ // `isConnected: true`. State and cursor stay untouched, so the retry re-fetches the same range; a transient
502
+ // reorder heals, a persistent one fails visibly.
503
+ //
504
+ // `SyncCapability.applyUpdate` has no typed error channel, so throwing preserves its public tuple-returning API;
505
+ // `RunningV2Variant` catches this at the capability boundary. See #572 for the planned `Either`-based API.
506
+ const orderedUpdates = Either.getOrThrowWith(expectAscending(freshUpdates, appliedIndex), identity);
507
+ const { applied, observedVersion } = splitAtVersionBoundary(orderedUpdates, (update) => update.protocolVersion, activeRange);
421
508
  // Read here and nowhere earlier: `applied` is exactly the slice this variant owns, so nothing outside its
422
509
  // activation range — nor anything below its cursor — is ever handed to this ledger version's deserializer.
423
510
  const [newState, changes] = applied.length === 0
@@ -569,6 +656,8 @@ export const matchedDustSpends = (nullifierTransactions) => pipe(nullifierTransa
569
656
  export const createDustUtxoUpdates = (dustState, spends, secretKey, knownUtxos, pendingDust, generationDtimeUpdates, ledgerParametersCodecs = defaultLedgerParametersCodecs) => pipe(spends, Arr.map(({ transaction, dustSpend }) => createUtxoUpdatesFromSpend(dustState, secretKey, knownUtxos, pendingDust, generationDtimeUpdates, transaction, dustSpend, ledgerParametersCodecs)), Effect.all, Effect.map(Arr.getSomes), Effect.map(Arr.flatten));
570
657
  export const makeSimulatorSyncService = (config) => {
571
658
  return {
659
+ // A live feed of simulator states: the one pass never ends, so there is nothing to repeat.
660
+ backgroundRepeat: BackgroundRepeat.Once(),
572
661
  updates: (_state, secretKey) => {
573
662
  // Get the initial state immediately to ensure we process the genesis block.
574
663
  // Then subscribe to state$ for subsequent changes, but deduplicate by block number
@@ -39,5 +39,30 @@ export declare class TransactionHistoryError extends TransactionHistoryError_bas
39
39
  cause?: unknown;
40
40
  }> {
41
41
  }
42
- export type WalletError = OtherWalletError | SyncWalletError | TransactingError | InsufficientFundsError | TransactionHistoryError | LedgerOps.LedgerError;
42
+ declare const OutOfOrderSyncUpdateError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
43
+ readonly _tag: "Wallet.OutOfOrderSyncUpdate";
44
+ } & Readonly<A>;
45
+ /**
46
+ * A batch of dust ledger events was not in ascending id order: an event arrived at or below the one before it, or at or
47
+ * below the applied cursor.
48
+ *
49
+ * @remarks
50
+ * Order is all the wallet can check: dust events share one id sequence with zswap and contract events in the indexer,
51
+ * so gaps in the stream are normal and a skipped event is only ever caught by the ledger's own insertion check. The
52
+ * whole batch is refused, nothing is applied and the cursor stays, so the running variant retries from the same
53
+ * place.
54
+ * @example
55
+ * ```ts
56
+ * if (error._tag === 'Wallet.OutOfOrderSyncUpdate') console.warn(`expected above ${error.expected}, got ${error.received}`);
57
+ * ```
58
+ */
59
+ export declare class OutOfOrderSyncUpdateError extends OutOfOrderSyncUpdateError_base<{
60
+ readonly message: string;
61
+ /** The id the delivered event had to exceed: its predecessor in the batch, or the applied cursor. */
62
+ readonly expected: bigint;
63
+ /** The event id the source delivered instead. */
64
+ readonly received: bigint;
65
+ }> {
66
+ }
67
+ export type WalletError = OtherWalletError | SyncWalletError | TransactingError | InsufficientFundsError | TransactionHistoryError | OutOfOrderSyncUpdateError | LedgerOps.LedgerError;
43
68
  export {};
@@ -21,3 +21,19 @@ export class InsufficientFundsError extends Data.TaggedError('Wallet.Insufficien
21
21
  }
22
22
  export class TransactionHistoryError extends Data.TaggedError('Wallet.TransactionHistory') {
23
23
  }
24
+ /**
25
+ * A batch of dust ledger events was not in ascending id order: an event arrived at or below the one before it, or at or
26
+ * below the applied cursor.
27
+ *
28
+ * @remarks
29
+ * Order is all the wallet can check: dust events share one id sequence with zswap and contract events in the indexer,
30
+ * so gaps in the stream are normal and a skipped event is only ever caught by the ledger's own insertion check. The
31
+ * whole batch is refused, nothing is applied and the cursor stays, so the running variant retries from the same
32
+ * place.
33
+ * @example
34
+ * ```ts
35
+ * if (error._tag === 'Wallet.OutOfOrderSyncUpdate') console.warn(`expected above ${error.expected}, got ${error.received}`);
36
+ * ```
37
+ */
38
+ export class OutOfOrderSyncUpdateError extends Data.TaggedError('Wallet.OutOfOrderSyncUpdate') {
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@midnight-ntwrk/wallet-sdk-dust-wallet",
3
- "version": "5.0.0-beta.3",
3
+ "version": "5.0.0-beta.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -35,12 +35,12 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@midnight-ntwrk/ledger-v8": "^8.1.0",
38
- "@midnightntwrk/ledger-v9": "1.0.0-rc.4",
39
- "@midnight-ntwrk/wallet-sdk-abstractions": "3.0.0-beta.1",
40
- "@midnight-ntwrk/wallet-sdk-address-format": "4.0.0-beta.3",
41
- "@midnight-ntwrk/wallet-sdk-capabilities": "4.0.0-beta.3",
38
+ "@midnightntwrk/ledger-v9": "1.0.0-rc.5",
39
+ "@midnight-ntwrk/wallet-sdk-abstractions": "3.0.0-beta.2",
40
+ "@midnight-ntwrk/wallet-sdk-address-format": "4.0.0-beta.4",
41
+ "@midnight-ntwrk/wallet-sdk-capabilities": "4.0.0-beta.4",
42
42
  "@midnight-ntwrk/wallet-sdk-indexer-client": "2.0.0-beta.2",
43
- "@midnight-ntwrk/wallet-sdk-runtime": "1.1.0-beta.1",
43
+ "@midnight-ntwrk/wallet-sdk-runtime": "1.1.0-beta.2",
44
44
  "@midnight-ntwrk/wallet-sdk-utilities": "1.2.2-beta.0",
45
45
  "effect": "^3.19.19",
46
46
  "rxjs": "^7.8.2"
@@ -59,6 +59,6 @@
59
59
  "publint": "publint --strict"
60
60
  },
61
61
  "devDependencies": {
62
- "@midnight-ntwrk/wallet-sdk-hd": "^3.1.0-beta.2"
62
+ "@midnight-ntwrk/wallet-sdk-hd": "^3.1.0-beta.3"
63
63
  }
64
64
  }