@midnightntwrk/wallet-sdk-testkit 1.0.0-beta.3 → 1.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.
package/README.md CHANGED
@@ -11,7 +11,8 @@ files.
11
11
  | -------------------- | ------------------------------------------------------------------------------------------------------------- |
12
12
  | Environment | `createRemoteEnvironment`, `NETWORK_PRESETS`, `makeEnvironment`, `WalletTestEnvironment`, `ResolvedEndpoints` |
13
13
  | Environment (Docker) | `createTestContainersEnvironment` — from `@midnightntwrk/wallet-sdk-testkit/testcontainers` |
14
- | Wallet | `provideWallet`, `initWalletWithSeed`, `saveState`, `WalletInit` |
14
+ | Wallet | `provideWallet`, `initWalletWithSeed`, `saveState`, `shouldPersistState`, `WalletInit` |
15
+ | Dust sync model | `dustWalletFromEnv`, `dustWalletFor`, `DustSyncModel`, `projectionsDustSyncOptions`, … — see below |
15
16
  | Seeds | `getShieldedSeed`, `getUnshieldedSeed`, `getDustSeed` |
16
17
  | Sync waiters | `waitForSyncUnshielded`, `waitForDustBalance`, `waitForTxInHistory`, … |
17
18
  | Assertions | `expectSenderShieldedTxHistory`, `expectReceiverUnshieldedTxHistory`, … |
@@ -61,6 +62,53 @@ import { createTestContainersEnvironment } from '@midnightntwrk/wallet-sdk-testk
61
62
  const getEnv = useWalletTestEnvironment(() => createTestContainersEnvironment({ network: 'undeployed' }));
62
63
  ```
63
64
 
65
+ ## Choosing the Dust sync model
66
+
67
+ Dust can sync from the indexer's event stream or from its projections (the "event-less" fast sync). `DUST_SYNC` selects
68
+ which, for a whole run:
69
+
70
+ ```bash
71
+ DUST_SYNC=projections yarn test-remote -- -t @healthcheck
72
+ ```
73
+
74
+ Unset, `provideWallet` and `initWalletWithSeed` build the event-based sync; the dust and token-transfer healthcheck
75
+ scenarios fall back to projections instead, so the network being monitored is exercised against the model the wallet
76
+ ships to users. `DUST_SYNC` overrides both, so a run can never end up half on one model and half on the other. An
77
+ unrecognized value is rejected rather than defaulted, so a typo cannot report a run as covering a model it did not
78
+ exercise.
79
+
80
+ A test that needs a specific model regardless of the environment pins it in code, which does override `DUST_SYNC`:
81
+
82
+ ```ts
83
+ provideWallet(env, { ...projectionsDustSyncOptions, seed, syncCacheDir, filename });
84
+ ```
85
+
86
+ Use `manualProjectionsDustSyncOptions` instead when the test drives each sync pass itself with `facade.doSync()`; the
87
+ plain `projectionsDustSyncOptions` leaves background synchronization on, so the usual state waiters work unchanged.
88
+
89
+ Pin a model only when the test genuinely requires it. A pinned wallet stops following `DUST_SYNC`, so a lane switched by
90
+ environment will leave it behind — which reports coverage the run did not have.
91
+
92
+ ### Building a Dust wallet directly
93
+
94
+ A test that constructs its own Dust wallet rather than going through the helpers must build it from the same source, or
95
+ it will silently disagree with the rest of the run:
96
+
97
+ ```ts
98
+ const Dust = dustWalletFromEnv()({ ...walletConfig, ...env.getDustWalletConfig() });
99
+ ```
100
+
101
+ This matters most for serialize/restore tests. The two models disagree about the meaning of the single progress value a
102
+ Dust snapshot carries — an event cursor to one, a composite of tree indices and nullifier count to the other — so state
103
+ written by one model and restored into the other resumes from a position that is not a cursor at all, and the wallet
104
+ never reports synced. Hard-coding `DustWallet(...)` as the restore target while the run uses projections does exactly
105
+ that.
106
+
107
+ Snapshots on disk are protected from this automatically: they are stored per model, so switching models finds no
108
+ snapshot and rebuilds from scratch rather than resuming wrongly, with no cache to clear by hand. That protection does
109
+ not extend to state serialized and restored in memory within a test — hence the rule above. The shielded and unshielded
110
+ snapshots are model-independent and shared.
111
+
64
112
  ## Peer dependencies
65
113
 
66
114
  - `vitest` — required (assertions and the environment hooks use it).
package/dist/core.d.ts CHANGED
@@ -5,3 +5,4 @@ export * from './network.js';
5
5
  export * from './seeds.js';
6
6
  export * from './primitives.js';
7
7
  export * from './wallet.js';
8
+ export * from './dust-sync.js';
package/dist/core.js CHANGED
@@ -22,3 +22,4 @@ export * from './network.js';
22
22
  export * from './seeds.js';
23
23
  export * from './primitives.js';
24
24
  export * from './wallet.js';
25
+ export * from './dust-sync.js';
@@ -0,0 +1,126 @@
1
+ import { type DustParameters } from '@midnightntwrk/ledger-v9';
2
+ import { type DefaultDustConfiguration, type DustWalletAPI } from '@midnightntwrk/wallet-sdk-dust-wallet';
3
+ /**
4
+ * The factory shape {@link provideWallet} and the scenarios accept for the dust sub-wallet.
5
+ *
6
+ * Structural rather than `DustWalletClass`, so a single-variant composition is acceptable too — but note that
7
+ * {@link eventLessDustWallet}, the projections factory this exists for, is **not** one: it is the shipped two-variant
8
+ * wallet with the V2 variant's sync service swapped, which is what lets it build transactions as well as sync. On a
9
+ * chain running ledger-v9 from its first block the V1 variant never applies, so it reaches the projections sync
10
+ * immediately. A start resolving asynchronously is accepted because such a wallet may ask the chain where it is before
11
+ * it picks a variant.
12
+ */
13
+ export type DustWalletFactory = (config: DefaultDustConfiguration) => {
14
+ startWithSeed(seed: Uint8Array, dustParameters?: DustParameters): DustWalletAPI | Promise<DustWalletAPI>;
15
+ /** Also required, because `provideWallet` restores a snapshot into whatever composition it was given. */
16
+ restore(serializedState: string): DustWalletAPI;
17
+ };
18
+ /**
19
+ * A dust sub-wallet that syncs from indexer projections instead of the event stream.
20
+ *
21
+ * The shipped `DustWallet` with exactly one substitution — the V2 variant's sync service. The V1 variant keeps the
22
+ * event stream, because the projections sync is a ledger-v9 capability: it rests on `DustLocalState` members no
23
+ * ledger-v8 has. On a chain that runs ledger-v9 from its first block the V1 variant never applies, so a wallet built
24
+ * this way reaches the projections sync immediately.
25
+ *
26
+ * The projections sync synchronizes in finite passes rather than over a live subscription, but background
27
+ * synchronization re-runs those passes on an interval, so this factory can be used on its own and the usual state
28
+ * waiters behave as they do for the event-based sync. Pair it with `manualSync` — see
29
+ * {@link manualProjectionsDustSyncOptions} — only when a caller wants to decide when each pass happens.
30
+ *
31
+ * **Both variants are registered, so a wallet built this way transacts.** A single-variant composition would answer for
32
+ * the whole protocol timeline and therefore report the minimum supported version; because a facade acts at the lowest
33
+ * version its three sub-wallets report, such a sub-wallet holds the facade below the ledger-v9 boundary and the facade
34
+ * then refuses the shielded wallet's ledger-v9 transaction, the two sides of a boundary being unmergeable. Registering
35
+ * both variants leaves the version to the chain, which is what lets the healthcheck scenarios pay fees and transfer
36
+ * while syncing their Dust from projections.
37
+ */
38
+ export declare const eventLessDustWallet: DustWalletFactory;
39
+ /**
40
+ * The event-stream dust sub-wallet, with the long-lived subscription.
41
+ *
42
+ * A dust snapshot stores one progress value, `appliedIndex`, and the two sync models mean different things by it: this
43
+ * service treats it as a ledger-event cursor to resume its subscription from, while the projections service writes a
44
+ * composite of tree indices and nullifier count. Restoring a projections-written snapshot here would therefore resume
45
+ * the subscription from a position that is not an event id at all, and because the cursor is trusted rather than
46
+ * validated, the effect would be silently skipped events rather than an error.
47
+ *
48
+ * Callers do not have to guard against that: dust snapshots are stored per sync model (see {@link dustSnapshotPath}), so
49
+ * switching a wallet's model finds no snapshot and rebuilds from scratch instead of resuming from a cursor that is not
50
+ * one.
51
+ */
52
+ export declare const eventBasedDustWallet: DustWalletFactory;
53
+ /** Which of the two dust sync implementations a wallet is built with. */
54
+ export type DustSyncModel = 'events' | 'projections';
55
+ /**
56
+ * How a dust snapshot on disk is labelled with the sync model that wrote it.
57
+ *
58
+ * `custom` covers a caller-supplied factory whose model cannot be identified. Giving it its own namespace is the safe
59
+ * default: an unidentified factory then shares a snapshot with neither known model rather than with the wrong one.
60
+ */
61
+ export type DustSnapshotModel = DustSyncModel | 'custom';
62
+ /** The dust sub-wallet factory for a sync model. */
63
+ export declare const dustWalletFor: (model: DustSyncModel) => DustWalletFactory;
64
+ /** The sync model a factory implements, or `custom` if it is neither of the two built-in ones. */
65
+ export declare const dustSyncModelOf: (factory: DustWalletFactory) => DustSnapshotModel;
66
+ /** Environment variable selecting the dust sync model for a whole test run. */
67
+ export declare const DUST_SYNC_ENV_VAR = "DUST_SYNC";
68
+ /**
69
+ * Reads a dust sync model from its environment-variable spelling, falling back to `fallback` when unset or empty.
70
+ *
71
+ * An unrecognized value is rejected rather than defaulted. A silent fallback would mean a typo in `DUST_SYNC` reports a
72
+ * run as covering one sync model while it actually covered the other, which is worse than not running at all.
73
+ *
74
+ * @throws Error if `raw` is neither empty nor a known model
75
+ */
76
+ export declare const parseDustSyncModel: (raw: string | undefined, fallback?: DustSyncModel) => DustSyncModel;
77
+ /** The dust sync model selected by `DUST_SYNC`, or `fallback` when it is unset. */
78
+ export declare const dustSyncModelFromEnv: (env?: Record<string, string | undefined>, fallback?: DustSyncModel) => DustSyncModel;
79
+ /**
80
+ * The dust sub-wallet factory selected by `DUST_SYNC`, or the one for `fallback` when it is unset.
81
+ *
82
+ * This is the default an explicit `dustWallet` option overrides, so a whole lane can be switched between the two sync
83
+ * models by configuration while individual tests that need a specific model keep pinning it in code.
84
+ *
85
+ * `fallback` lets a caller choose which model applies when nothing is configured **without** taking the choice away
86
+ * from `DUST_SYNC`. Passing an explicit `dustWallet` instead would pin the model outright and make the variable
87
+ * ineffective for that wallet — which silently splits a lane, since some of its tests would switch and others would
88
+ * not.
89
+ */
90
+ export declare const dustWalletFromEnv: (env?: Record<string, string | undefined>, fallback?: DustSyncModel) => DustWalletFactory;
91
+ /**
92
+ * Where the dust sub-wallet's snapshot lives, namespaced by the sync model that wrote it.
93
+ *
94
+ * The two models disagree on the meaning of the one progress value a snapshot carries, so a snapshot must never be
95
+ * restored into the other model — see {@link eventBasedDustWallet}. Keeping them in separate files makes a model switch
96
+ * degrade to a from-scratch build rather than to a wrong resume position, with no cache-clearing step to remember.
97
+ *
98
+ * The shielded and unshielded snapshots are model-independent and are not namespaced.
99
+ */
100
+ export declare const dustSnapshotPath: (syncCacheDir: string, filename: string, model: DustSnapshotModel) => string;
101
+ /**
102
+ * The projections dust sync, synchronizing in the background like the event-based sync does.
103
+ *
104
+ * This is the plain drop-in: spread it into the wallet options and the usual state waiters work unchanged, because
105
+ * background synchronization re-runs the projections passes on an interval.
106
+ *
107
+ * Use {@link manualProjectionsDustSyncOptions} instead when a test needs to decide when each pass happens.
108
+ */
109
+ export declare const projectionsDustSyncOptions: {
110
+ readonly dustWallet: DustWalletFactory;
111
+ };
112
+ /**
113
+ * The projections dust sync with background synchronization switched off, so the caller decides when each pass runs.
114
+ *
115
+ * Use this when a test needs passes to happen at known points — asserting on the state a specific pass produced, for
116
+ * instance. A caller that spreads this in must drive `facade.doSync(seeds)` itself, after start and again after
117
+ * anything that changes dust state; waiting on `waitForSyncedState()` alone will block, because with `manualSync`
118
+ * nothing advances the dust wallet until `doSync` runs.
119
+ *
120
+ * For a test that just wants the wallet to keep up on its own, spread {@link projectionsDustSyncOptions} instead and let
121
+ * background synchronization run the passes.
122
+ */
123
+ export declare const manualProjectionsDustSyncOptions: {
124
+ readonly dustWallet: DustWalletFactory;
125
+ readonly manualSync: true;
126
+ };
@@ -0,0 +1,140 @@
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 { LedgerParameters } from '@midnightntwrk/ledger-v9';
14
+ import { makeIndexerChainVersionProbe } from '@midnightntwrk/wallet-sdk-capabilities/chainVersion';
15
+ import { asV8DustParameters, CustomForkingDustWallet, DustWallet, makeEventLessSyncCapability, makeEventLessSyncService, } from '@midnightntwrk/wallet-sdk-dust-wallet';
16
+ import { V1Builder } from '@midnightntwrk/wallet-sdk-dust-wallet/v1';
17
+ import { Migration, V2Builder } from '@midnightntwrk/wallet-sdk-dust-wallet/v2';
18
+ /**
19
+ * A dust sub-wallet that syncs from indexer projections instead of the event stream.
20
+ *
21
+ * The shipped `DustWallet` with exactly one substitution — the V2 variant's sync service. The V1 variant keeps the
22
+ * event stream, because the projections sync is a ledger-v9 capability: it rests on `DustLocalState` members no
23
+ * ledger-v8 has. On a chain that runs ledger-v9 from its first block the V1 variant never applies, so a wallet built
24
+ * this way reaches the projections sync immediately.
25
+ *
26
+ * The projections sync synchronizes in finite passes rather than over a live subscription, but background
27
+ * synchronization re-runs those passes on an interval, so this factory can be used on its own and the usual state
28
+ * waiters behave as they do for the event-based sync. Pair it with `manualSync` — see
29
+ * {@link manualProjectionsDustSyncOptions} — only when a caller wants to decide when each pass happens.
30
+ *
31
+ * **Both variants are registered, so a wallet built this way transacts.** A single-variant composition would answer for
32
+ * the whole protocol timeline and therefore report the minimum supported version; because a facade acts at the lowest
33
+ * version its three sub-wallets report, such a sub-wallet holds the facade below the ledger-v9 boundary and the facade
34
+ * then refuses the shielded wallet's ledger-v9 transaction, the two sides of a boundary being unmergeable. Registering
35
+ * both variants leaves the version to the chain, which is what lets the healthcheck scenarios pay fees and transfer
36
+ * while syncing their Dust from projections.
37
+ */
38
+ export const eventLessDustWallet = (config) => {
39
+ const dustParameters = config.dustParameters ?? LedgerParameters.initialParameters().dust;
40
+ return CustomForkingDustWallet({ ...config, chainVersionProbe: config.chainVersionProbe ?? makeIndexerChainVersionProbe(config) }, {
41
+ builder: new V1Builder().withDefaults(),
42
+ // The one field that cannot be shared: `dustParameters` is a WASM object of whichever ledger module produced it,
43
+ // so the V1 variant is handed the ledger-v8 rebuild of the same rates rather than the object itself.
44
+ configuration: { ...config, dustParameters: asV8DustParameters(dustParameters) },
45
+ }, {
46
+ builder: new V2Builder()
47
+ .withDefaults()
48
+ .withSync(makeEventLessSyncService, makeEventLessSyncCapability)
49
+ // Restated because `withSync` drops it: the seed-to-key derivation a start from a seed needs.
50
+ .withStartAuxDefaults()
51
+ .withMigration(() => Migration.makeCrossLedgerMigration({ dustParameters })),
52
+ configuration: config,
53
+ });
54
+ };
55
+ /**
56
+ * The event-stream dust sub-wallet, with the long-lived subscription.
57
+ *
58
+ * A dust snapshot stores one progress value, `appliedIndex`, and the two sync models mean different things by it: this
59
+ * service treats it as a ledger-event cursor to resume its subscription from, while the projections service writes a
60
+ * composite of tree indices and nullifier count. Restoring a projections-written snapshot here would therefore resume
61
+ * the subscription from a position that is not an event id at all, and because the cursor is trusted rather than
62
+ * validated, the effect would be silently skipped events rather than an error.
63
+ *
64
+ * Callers do not have to guard against that: dust snapshots are stored per sync model (see {@link dustSnapshotPath}), so
65
+ * switching a wallet's model finds no snapshot and rebuilds from scratch instead of resuming from a cursor that is not
66
+ * one.
67
+ */
68
+ export const eventBasedDustWallet = DustWallet;
69
+ /** The dust sub-wallet factory for a sync model. */
70
+ export const dustWalletFor = (model) => model === 'projections' ? eventLessDustWallet : eventBasedDustWallet;
71
+ /** The sync model a factory implements, or `custom` if it is neither of the two built-in ones. */
72
+ export const dustSyncModelOf = (factory) => factory === eventLessDustWallet ? 'projections' : factory === eventBasedDustWallet ? 'events' : 'custom';
73
+ /** Environment variable selecting the dust sync model for a whole test run. */
74
+ export const DUST_SYNC_ENV_VAR = 'DUST_SYNC';
75
+ /**
76
+ * Reads a dust sync model from its environment-variable spelling, falling back to `fallback` when unset or empty.
77
+ *
78
+ * An unrecognized value is rejected rather than defaulted. A silent fallback would mean a typo in `DUST_SYNC` reports a
79
+ * run as covering one sync model while it actually covered the other, which is worse than not running at all.
80
+ *
81
+ * @throws Error if `raw` is neither empty nor a known model
82
+ */
83
+ export const parseDustSyncModel = (raw, fallback = 'events') => {
84
+ const value = raw?.trim();
85
+ if (value === undefined || value === '')
86
+ return fallback;
87
+ if (value === 'events' || value === 'projections')
88
+ return value;
89
+ throw new Error(`${DUST_SYNC_ENV_VAR} must be 'events' or 'projections', got '${raw}'`);
90
+ };
91
+ /** The dust sync model selected by `DUST_SYNC`, or `fallback` when it is unset. */
92
+ export const dustSyncModelFromEnv = (env = process.env, fallback = 'events') => parseDustSyncModel(env[DUST_SYNC_ENV_VAR], fallback);
93
+ /**
94
+ * The dust sub-wallet factory selected by `DUST_SYNC`, or the one for `fallback` when it is unset.
95
+ *
96
+ * This is the default an explicit `dustWallet` option overrides, so a whole lane can be switched between the two sync
97
+ * models by configuration while individual tests that need a specific model keep pinning it in code.
98
+ *
99
+ * `fallback` lets a caller choose which model applies when nothing is configured **without** taking the choice away
100
+ * from `DUST_SYNC`. Passing an explicit `dustWallet` instead would pin the model outright and make the variable
101
+ * ineffective for that wallet — which silently splits a lane, since some of its tests would switch and others would
102
+ * not.
103
+ */
104
+ export const dustWalletFromEnv = (env = process.env, fallback = 'events') => dustWalletFor(dustSyncModelFromEnv(env, fallback));
105
+ /**
106
+ * Where the dust sub-wallet's snapshot lives, namespaced by the sync model that wrote it.
107
+ *
108
+ * The two models disagree on the meaning of the one progress value a snapshot carries, so a snapshot must never be
109
+ * restored into the other model — see {@link eventBasedDustWallet}. Keeping them in separate files makes a model switch
110
+ * degrade to a from-scratch build rather than to a wrong resume position, with no cache-clearing step to remember.
111
+ *
112
+ * The shielded and unshielded snapshots are model-independent and are not namespaced.
113
+ */
114
+ export const dustSnapshotPath = (syncCacheDir, filename, model) => `${syncCacheDir}/dust-${model}-${filename}`;
115
+ /**
116
+ * The projections dust sync, synchronizing in the background like the event-based sync does.
117
+ *
118
+ * This is the plain drop-in: spread it into the wallet options and the usual state waiters work unchanged, because
119
+ * background synchronization re-runs the projections passes on an interval.
120
+ *
121
+ * Use {@link manualProjectionsDustSyncOptions} instead when a test needs to decide when each pass happens.
122
+ */
123
+ export const projectionsDustSyncOptions = {
124
+ dustWallet: eventLessDustWallet,
125
+ };
126
+ /**
127
+ * The projections dust sync with background synchronization switched off, so the caller decides when each pass runs.
128
+ *
129
+ * Use this when a test needs passes to happen at known points — asserting on the state a specific pass produced, for
130
+ * instance. A caller that spreads this in must drive `facade.doSync(seeds)` itself, after start and again after
131
+ * anything that changes dust state; waiting on `waitForSyncedState()` alone will block, because with `manualSync`
132
+ * nothing advances the dust wallet until `doSync` runs.
133
+ *
134
+ * For a test that just wants the wallet to keep up on its own, spread {@link projectionsDustSyncOptions} instead and let
135
+ * background synchronization run the passes.
136
+ */
137
+ export const manualProjectionsDustSyncOptions = {
138
+ dustWallet: eventLessDustWallet,
139
+ manualSync: true,
140
+ };
package/dist/logger.js CHANGED
@@ -30,7 +30,7 @@ export const getLogger = () => current;
30
30
  // whatever `setLogger` installed, without every helper having to call `getLogger()`.
31
31
  export const logger = new Proxy({}, {
32
32
  get(_target, property) {
33
- const value = current[property];
33
+ const value = Reflect.get(current, property);
34
34
  return typeof value === 'function' ? value.bind(current) : value;
35
35
  },
36
36
  });
@@ -1,4 +1,5 @@
1
1
  import { type WalletTestEnvironment } from '../types.js';
2
+ import { type ProvideWalletOptions } from '../wallet.js';
2
3
  /** Dependencies the dust scenarios need from the consumer. */
3
4
  export interface DustScenarioDeps {
4
5
  /** Accessor for the active environment (typically the return of `useWalletTestEnvironment`). */
@@ -7,8 +8,19 @@ export interface DustScenarioDeps {
7
8
  seed: string;
8
9
  /** Optional dir to persist/restore wallet state across runs. (Was the `SYNC_CACHE` env var.) */
9
10
  syncCacheDir?: string | undefined;
10
- /** Per-test timeout in ms. Defaults to the upstream value of 1 hour. */
11
+ /** Per-test timeout in ms. Defaults to 15 minutes; a longer bound only spends lane time on a wedged wallet. */
11
12
  timeout?: number | undefined;
13
+ /**
14
+ * Selects the dust sync model. Defaults to the projections sync with background synchronization, so the network being
15
+ * monitored is exercised against the sync model the wallet ships to users.
16
+ *
17
+ * Left unset, the model still follows `DUST_SYNC` when that is configured, so a whole lane can be switched by
18
+ * environment; projections is only the fallback for when nothing is. Pass `{ dustWallet: eventBasedDustWallet }` to
19
+ * pin the event-stream sync regardless of the environment — dust snapshots are namespaced per sync model, so there is
20
+ * no cache to clear when switching. Avoid adding `manualSync` here: these scenarios wait on the state stream rather
21
+ * than driving passes themselves, so they need background synchronization to be running.
22
+ */
23
+ walletOptions?: Pick<ProvideWalletOptions, 'dustWallet' | 'manualSync'> | undefined;
12
24
  }
13
25
  /** Registers the dust register/deregister healthchecks under a `describe('Dust tests')` block. */
14
- export declare function registerDustHealthchecks({ getEnv, seed, syncCacheDir, timeout }: DustScenarioDeps): void;
26
+ export declare function registerDustHealthchecks({ getEnv, seed, syncCacheDir, timeout, walletOptions, }: DustScenarioDeps): void;
@@ -19,10 +19,10 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest';
19
19
  import { inspect } from 'node:util';
20
20
  import * as rx from 'rxjs';
21
21
  import * as ledger from '@midnightntwrk/ledger-v9';
22
- import { provideWallet, saveState } from '../wallet.js';
22
+ import { provideWallet, saveState, shouldPersistState, withProjectionsDefault, } from '../wallet.js';
23
23
  import { logger } from '../logger.js';
24
24
  /** Registers the dust register/deregister healthchecks under a `describe('Dust tests')` block. */
25
- export function registerDustHealthchecks({ getEnv, seed, syncCacheDir, timeout = 3_600_000 }) {
25
+ export function registerDustHealthchecks({ getEnv, seed, syncCacheDir, timeout = 900_000, walletOptions, }) {
26
26
  describe('Dust tests', () => {
27
27
  const unshieldedTokenRaw = ledger.unshieldedToken().raw;
28
28
  let wallet;
@@ -30,11 +30,17 @@ export function registerDustHealthchecks({ getEnv, seed, syncCacheDir, timeout =
30
30
  beforeEach(async () => {
31
31
  const env = getEnv();
32
32
  filenameWallet = `${seed.substring(0, 7)}-${env.network}.state`;
33
- wallet = await provideWallet(env, { seed, syncCacheDir, filename: filenameWallet });
33
+ wallet = await provideWallet(env, {
34
+ ...withProjectionsDefault(walletOptions),
35
+ seed,
36
+ syncCacheDir,
37
+ filename: filenameWallet,
38
+ });
34
39
  });
35
- afterEach(async () => {
36
- if (syncCacheDir) {
37
- await saveState(wallet.wallet, syncCacheDir, filenameWallet);
40
+ afterEach(async (context) => {
41
+ // Only a passing test leaves its wallets in a resumable position; see `shouldPersistState`.
42
+ if (syncCacheDir && shouldPersistState(context)) {
43
+ await saveState(wallet, syncCacheDir, filenameWallet);
38
44
  }
39
45
  await wallet.wallet.stop();
40
46
  logger.info('Wallet stopped');
@@ -1,6 +1,6 @@
1
1
  import { type NetworkId } from '@midnightntwrk/wallet-sdk-abstractions';
2
2
  import { type WalletTestEnvironment } from '../types.js';
3
- import { type WalletInit } from '../wallet.js';
3
+ import { type ProvideWalletOptions, type WalletInit } from '../wallet.js';
4
4
  /** Dependencies the token-transfer setup + scenarios need from the consumer. */
5
5
  export interface TokenTransferScenarioDeps {
6
6
  /** Accessor for the active environment (typically the return of `useWalletTestEnvironment`). */
@@ -11,10 +11,24 @@ export interface TokenTransferScenarioDeps {
11
11
  secondSeed: string;
12
12
  /** Optional dir to persist/restore wallet state across runs. (Was the `SYNC_CACHE` env var.) */
13
13
  syncCacheDir?: string | undefined;
14
- /** Timeout for the sync-heavy `beforeEach` and the healthcheck test in ms. Defaults to 1 hour. */
14
+ /**
15
+ * Timeout for the sync-heavy `beforeEach` and the healthcheck test in ms. Defaults to 15 minutes — a longer bound
16
+ * does not rescue a wedged wallet, it only spends lane time before reporting.
17
+ */
15
18
  syncTimeout?: number | undefined;
16
19
  /** Timeout for the `afterEach` teardown in ms. Defaults to 10 minutes. */
17
20
  timeout?: number | undefined;
21
+ /**
22
+ * Selects the dust sync model for both wallets. Defaults to the projections sync with background synchronization, so
23
+ * these transfers' fees are paid out of a projections-synced dust wallet.
24
+ *
25
+ * Left unset, the model still follows `DUST_SYNC` when that is configured, so a whole lane can be switched by
26
+ * environment; projections is only the fallback for when nothing is. Pass `{ dustWallet: eventBasedDustWallet }` to
27
+ * pin the event-stream sync regardless of the environment — dust snapshots are namespaced per sync model, so there is
28
+ * no cache to clear when switching. Avoid adding `manualSync` here: these scenarios wait on the state stream rather
29
+ * than driving passes themselves, so they need background synchronization to be running.
30
+ */
31
+ walletOptions?: Pick<ProvideWalletOptions, 'dustWallet' | 'manualSync'> | undefined;
18
32
  }
19
33
  /** Accessors returned by {@link useTokenTransferWallets}, valid inside `test`/`it` bodies. */
20
34
  export interface TokenTransferWallets {
@@ -29,7 +43,7 @@ export interface TokenTransferWallets {
29
43
  * Call once inside a `describe`. Both the healthcheck scenario below and the upstream-only token-transfer tests reuse
30
44
  * this so the sender/receiver selection lives in one place.
31
45
  */
32
- export declare function useTokenTransferWallets({ getEnv, fundedSeed, secondSeed, syncCacheDir, syncTimeout, timeout, }: TokenTransferScenarioDeps): TokenTransferWallets;
46
+ export declare function useTokenTransferWallets({ getEnv, fundedSeed, secondSeed, syncCacheDir, syncTimeout, timeout, walletOptions, }: TokenTransferScenarioDeps): TokenTransferWallets;
33
47
  /**
34
48
  * Registers the single `@healthcheck`-tagged token-transfer test under a `describe('Token transfer')` block. This is
35
49
  * the only token-transfer test shipped in the testkit; downstream monitoring selects it with `vitest run -t
@@ -21,10 +21,10 @@ import { firstValueFrom } from 'rxjs';
21
21
  import { inspect } from 'node:util';
22
22
  import * as ledger from '@midnightntwrk/ledger-v9';
23
23
  import { ProtocolVersion, WalletTransaction } from '@midnightntwrk/wallet-sdk-abstractions';
24
- import { provideWallet, saveState } from '../wallet.js';
24
+ import { provideWallet, saveState, shouldPersistState, withProjectionsDefault, } from '../wallet.js';
25
25
  import { tNightAmount } from '../primitives.js';
26
26
  import { getShieldedAddress, getUnshieldedAddress } from '../addresses.js';
27
- import { waitForTxInHistory } from '../state-waiters.js';
27
+ import { waitForFacadePendingClear, waitForTxInHistory } from '../state-waiters.js';
28
28
  import { expectReceiverShieldedTxHistory, expectReceiverUnshieldedTxHistory, expectSenderShieldedTxHistory, expectSenderUnshieldedTxHistory, } from '../tx-history-asserts.js';
29
29
  import { logger } from '../logger.js';
30
30
  /**
@@ -32,7 +32,7 @@ import { logger } from '../logger.js';
32
32
  * Call once inside a `describe`. Both the healthcheck scenario below and the upstream-only token-transfer tests reuse
33
33
  * this so the sender/receiver selection lives in one place.
34
34
  */
35
- export function useTokenTransferWallets({ getEnv, fundedSeed, secondSeed, syncCacheDir, syncTimeout = 60 * 60 * 1000, timeout = 600_000, }) {
35
+ export function useTokenTransferWallets({ getEnv, fundedSeed, secondSeed, syncCacheDir, syncTimeout = 15 * 60 * 1000, timeout = 600_000, walletOptions, }) {
36
36
  const shieldedTokenRaw = ledger.shieldedToken().raw;
37
37
  let sender;
38
38
  let receiver;
@@ -46,8 +46,18 @@ export function useTokenTransferWallets({ getEnv, fundedSeed, secondSeed, syncCa
46
46
  networkId = env.endpoints.networkId;
47
47
  filenameWallet = `${fundedSeed.substring(0, 7)}-${env.network}.state`;
48
48
  filenameWallet2 = `${secondSeed.substring(0, 7)}-${env.network}.state`;
49
- wallet = await provideWallet(env, { seed: fundedSeed, syncCacheDir, filename: filenameWallet });
50
- wallet2 = await provideWallet(env, { seed: secondSeed, syncCacheDir, filename: filenameWallet2 });
49
+ wallet = await provideWallet(env, {
50
+ ...withProjectionsDefault(walletOptions),
51
+ seed: fundedSeed,
52
+ syncCacheDir,
53
+ filename: filenameWallet,
54
+ });
55
+ wallet2 = await provideWallet(env, {
56
+ ...withProjectionsDefault(walletOptions),
57
+ seed: secondSeed,
58
+ syncCacheDir,
59
+ filename: filenameWallet2,
60
+ });
51
61
  logger.info('Two wallets started');
52
62
  const [state1, state2] = await Promise.all([
53
63
  wallet.wallet.waitForSyncedState(),
@@ -67,10 +77,11 @@ export function useTokenTransferWallets({ getEnv, fundedSeed, secondSeed, syncCa
67
77
  receiver = wallet;
68
78
  }
69
79
  }, syncTimeout);
70
- afterEach(async () => {
71
- if (syncCacheDir) {
72
- await saveState(wallet.wallet, syncCacheDir, filenameWallet);
73
- await saveState(wallet2.wallet, syncCacheDir, filenameWallet2);
80
+ afterEach(async (context) => {
81
+ // Only a passing test leaves its wallets in a resumable position; see `shouldPersistState`.
82
+ if (syncCacheDir && shouldPersistState(context)) {
83
+ await saveState(wallet, syncCacheDir, filenameWallet);
84
+ await saveState(wallet2, syncCacheDir, filenameWallet2);
74
85
  }
75
86
  await sender.wallet.stop();
76
87
  await receiver.wallet.stop();
@@ -99,7 +110,7 @@ export function registerTokenTransferHealthchecks(deps) {
99
110
  const shieldedTokenRaw = ledger.shieldedToken().raw;
100
111
  const unshieldedTokenRaw = ledger.unshieldedToken().raw;
101
112
  const outputValue = tNightAmount(10n);
102
- const syncTimeout = deps.syncTimeout ?? 60 * 60 * 1000;
113
+ const syncTimeout = deps.syncTimeout ?? 15 * 60 * 1000;
103
114
  test('Is working for valid transfer @healthcheck', async () => {
104
115
  const sender = getSender();
105
116
  const receiver = getReceiver();
@@ -169,6 +180,7 @@ export function registerTokenTransferHealthchecks(deps) {
169
180
  logger.info('Transaction id: ' + txId);
170
181
  logger.info('waiting for tx in history');
171
182
  const senderTxEntry = await waitForTxInHistory(txHash, sender.wallet, (e) => e.shielded !== undefined && e.unshielded !== undefined);
183
+ await waitForFacadePendingClear(sender.wallet);
172
184
  const senderFinalState = await sender.wallet.waitForSyncedState();
173
185
  const senderFinalShieldedBalance = senderFinalState.shielded.balances[shieldedTokenRaw];
174
186
  const senderFinalUnshieldedBalance = senderFinalState.unshielded.balances[unshieldedTokenRaw];
@@ -193,6 +205,7 @@ export function registerTokenTransferHealthchecks(deps) {
193
205
  expect(senderFinalTxHistory.length).toBeGreaterThanOrEqual(senderInitialTxHistory.length + 1);
194
206
  expectSenderShieldedTxHistory(senderTxEntry);
195
207
  expectSenderUnshieldedTxHistory(senderTxEntry);
208
+ await waitForFacadePendingClear(receiver.wallet);
196
209
  const receiverFinalState = await receiver.wallet.waitForSyncedState();
197
210
  const receiverFinalShieldedBalance = receiverFinalState.shielded.balances[shieldedTokenRaw] ?? 0n;
198
211
  const receiverFinalUnshieldedBalance = receiverFinalState.unshielded.balances[unshieldedTokenRaw] ?? 0n;
@@ -1,9 +1,35 @@
1
+ import * as rx from 'rxjs';
1
2
  import type * as ledger from '@midnightntwrk/ledger-v9';
2
3
  import { type ShieldedWalletAPI } from '@midnightntwrk/wallet-sdk-shielded';
3
4
  import { type UnshieldedWallet } from '@midnightntwrk/wallet-sdk-unshielded-wallet';
4
5
  import { type WalletFacade, type WalletEntry } from '@midnightntwrk/wallet-sdk-facade';
5
6
  /** Default dust-balance threshold to wait for: 7 * 10^14. */
6
7
  export declare const DEFAULT_DUST_BALANCE_THRESHOLD: bigint;
8
+ /**
9
+ * Resolves with the wallet's current state once `predicate` has held continuously for `settleMs`.
10
+ *
11
+ * A settle window is needed because conditions like `pendingCoins.length === 0` are also the _resting_ state before a
12
+ * transaction is submitted: a waiter that resolved on the first matching emission would hand back the stale
13
+ * pre-transaction state.
14
+ *
15
+ * The window has to be applied to the predicate rather than to the source. Debouncing the source waits for the wallet
16
+ * to stop emitting altogether, which never happens while a sub-wallet syncs in the background — a Dust wallet syncing
17
+ * from projections emits about every 5 seconds indefinitely — so the predicate is never evaluated and the wait runs to
18
+ * the test timeout while logging that its condition is already satisfied.
19
+ *
20
+ * `distinctUntilChanged` is load-bearing: without it, `switchMap` would cancel and restart the timer on every emission
21
+ * and the timer would never elapse, reproducing the same starvation.
22
+ *
23
+ * The value is carried through the pipeline rather than re-read from `source` once the window elapses, because `source`
24
+ * must only be subscribed once. The facade's `state()` replays its current value on subscribe, but a sub-wallet's
25
+ * `state` does not: a second subscription there receives nothing until the next change, so on an already-settled wallet
26
+ * it would never produce a value and the wait would hang. `shareReplay` is what reconciles that with returning the
27
+ * _latest_ matching value rather than the one from the moment the condition began to hold: it multicasts a single
28
+ * upstream subscription, so the window and the value read at the end of it come from the same one. Returning the
29
+ * opening value would defeat the point of the window — `pendingCoins.length === 0` is equally true either side of a
30
+ * balance arriving, so the waiter would hand back the stale pre-transaction state it exists to avoid.
31
+ */
32
+ export declare const waitForStableState: <T>(source: rx.Observable<T>, predicate: (value: T) => boolean, description: string, settleMs?: number, timeoutMs?: number) => Promise<T>;
7
33
  export declare const waitForSyncUnshielded: (wallet: UnshieldedWallet) => Promise<import("@midnightntwrk/wallet-sdk-unshielded-wallet").UnshieldedWalletState<string>>;
8
34
  export declare const waitForFacadePending: (wallet: WalletFacade) => Promise<import("@midnightntwrk/wallet-sdk-facade").FacadeState>;
9
35
  export declare const waitForFacadePendingClear: (wallet: WalletFacade) => Promise<import("@midnightntwrk/wallet-sdk-facade").FacadeState>;
@@ -16,6 +16,45 @@ import { expect } from 'vitest';
16
16
  import { logger } from './logger.js';
17
17
  /** Default dust-balance threshold to wait for: 7 * 10^14. */
18
18
  export const DEFAULT_DUST_BALANCE_THRESHOLD = 7n * 10n ** 14n;
19
+ /** How long a condition must hold before a settling waiter accepts it. */
20
+ const DEFAULT_SETTLE_MS = 10_000;
21
+ /** How long a settling waiter waits for its condition before failing rather than running to the test timeout. */
22
+ const DEFAULT_SETTLE_TIMEOUT_MS = 180_000;
23
+ /**
24
+ * Resolves with the wallet's current state once `predicate` has held continuously for `settleMs`.
25
+ *
26
+ * A settle window is needed because conditions like `pendingCoins.length === 0` are also the _resting_ state before a
27
+ * transaction is submitted: a waiter that resolved on the first matching emission would hand back the stale
28
+ * pre-transaction state.
29
+ *
30
+ * The window has to be applied to the predicate rather than to the source. Debouncing the source waits for the wallet
31
+ * to stop emitting altogether, which never happens while a sub-wallet syncs in the background — a Dust wallet syncing
32
+ * from projections emits about every 5 seconds indefinitely — so the predicate is never evaluated and the wait runs to
33
+ * the test timeout while logging that its condition is already satisfied.
34
+ *
35
+ * `distinctUntilChanged` is load-bearing: without it, `switchMap` would cancel and restart the timer on every emission
36
+ * and the timer would never elapse, reproducing the same starvation.
37
+ *
38
+ * The value is carried through the pipeline rather than re-read from `source` once the window elapses, because `source`
39
+ * must only be subscribed once. The facade's `state()` replays its current value on subscribe, but a sub-wallet's
40
+ * `state` does not: a second subscription there receives nothing until the next change, so on an already-settled wallet
41
+ * it would never produce a value and the wait would hang. `shareReplay` is what reconciles that with returning the
42
+ * _latest_ matching value rather than the one from the moment the condition began to hold: it multicasts a single
43
+ * upstream subscription, so the window and the value read at the end of it come from the same one. Returning the
44
+ * opening value would defeat the point of the window — `pendingCoins.length === 0` is equally true either side of a
45
+ * balance arriving, so the waiter would hand back the stale pre-transaction state it exists to avoid.
46
+ */
47
+ export const waitForStableState = (source, predicate, description, settleMs = DEFAULT_SETTLE_MS, timeoutMs = DEFAULT_SETTLE_TIMEOUT_MS) => {
48
+ const observed = source.pipe(rx.map((value) => ({ value, holds: predicate(value) })), rx.shareReplay({ bufferSize: 1, refCount: true }));
49
+ return rx.firstValueFrom(observed.pipe(rx.distinctUntilChanged((a, b) => a.holds === b.holds), rx.switchMap(({ holds }) => (holds ? rx.timer(settleMs) : rx.NEVER)),
50
+ // The window has elapsed, so take the newest value rather than the one that opened it. That value still satisfies
51
+ // the predicate: a value that did not would have been a `holds` transition, and `switchMap` would have cancelled
52
+ // this timer before it could fire.
53
+ rx.withLatestFrom(observed), rx.map(([, latest]) => latest.value), rx.take(1), rx.timeout({
54
+ first: timeoutMs,
55
+ with: () => rx.throwError(() => new Error(`${description}: condition never held continuously for ${settleMs}ms within ${timeoutMs}ms. It may have been met repeatedly and simply never stayed met for that long — a wallet still syncing keeps breaking the window.`)),
56
+ })));
57
+ };
19
58
  export const waitForSyncUnshielded = (wallet) => rx.firstValueFrom(wallet.state.pipe(rx.throttleTime(5_000), rx.tap((state) => {
20
59
  const applyGap = state.state.progress.highestTransactionId - state.state.progress.appliedId;
21
60
  logger.info(`Wallet behind by ${applyGap} indices`);
@@ -32,32 +71,21 @@ rx.timeout({
32
71
  first: 120_000,
33
72
  with: () => rx.throwError(() => new Error('waitForFacadePending: no pending coins observed within 120s — the pending window was likely missed (tx confirmed before it was observed) or the transaction was never submitted.')),
34
73
  })));
35
- export const waitForFacadePendingClear = (wallet) => rx.firstValueFrom(wallet.state().pipe(rx.tap((state) => {
36
- const shieldedPending = state.shielded.pendingCoins.length;
37
- logger.info(`Shielded wallet pending coins: ${shieldedPending}, waiting for pending coins to clear...`);
38
- const unshieldedPending = state.unshielded.pendingCoins.length;
39
- logger.info(`Unshielded wallet pending coins: ${unshieldedPending}, waiting for pending coins to clear...`);
40
- const dustPending = state.dust.pendingCoins.length;
41
- logger.info(`Dust wallet pending coins: ${dustPending}, waiting for pending coins to clear...`);
42
- }), rx.debounceTime(10_000), rx.filter((state) => state.shielded.pendingCoins.length == 0 &&
43
- state.unshielded.pendingCoins.length == 0 &&
44
- state.dust.pendingCoins.length == 0)));
74
+ export const waitForFacadePendingClear = (wallet) => waitForStableState(wallet.state().pipe(rx.tap((state) => {
75
+ logger.info(`Pending coins — shielded: ${state.shielded.pendingCoins.length}, unshielded: ${state.unshielded.pendingCoins.length}, dust: ${state.dust.pendingCoins.length}; waiting for all to clear...`);
76
+ })), (state) => state.shielded.pendingCoins.length === 0 &&
77
+ state.unshielded.pendingCoins.length === 0 &&
78
+ state.dust.pendingCoins.length === 0, 'waitForFacadePendingClear');
45
79
  export const waitForDustBalance = (wallet, threshold = DEFAULT_DUST_BALANCE_THRESHOLD) => rx.firstValueFrom(wallet.state().pipe(rx.tap((state) => {
46
80
  const dustBalance = state.dust.balance(new Date());
47
81
  logger.info(`Dust balance: ${dustBalance}, waiting for dust balance > ${threshold}...`);
48
82
  }), rx.filter((state) => state.dust.balance(new Date()) > threshold)));
49
- export const waitForFinalizedShieldedBalance = (wallet) => rx.firstValueFrom(wallet.state.pipe(rx.tap((state) => {
50
- const pending = state.pendingCoins.length;
51
- logger.info(`Wallet pending coins: ${pending}, waiting for pending coins cleared...`);
52
- }),
53
- // `pendingCoins.length === 0` is also the resting condition, so without a settle window `firstValueFrom` resolves
54
- // on the pre-transaction emission. Debounce until the state stops changing so we capture the settled post-tx
55
- // state rather than the stale one.
56
- rx.debounceTime(10_000), rx.filter((state) => state.pendingCoins.length === 0)));
57
- export const waitForUnshieldedCoinUpdate = (wallet, initialNumAvailableCoins) => rx.firstValueFrom(wallet.state().pipe(rx.tap((state) => {
58
- const currentNumAvailableCoins = state.unshielded.availableCoins.length;
59
- logger.info(`Unshielded available coins: ${currentNumAvailableCoins}, waiting for more than ${initialNumAvailableCoins}...synced = ${state.isSynced}`);
60
- }), rx.debounceTime(10_000), rx.filter((s) => s.isSynced), rx.filter((s) => s.unshielded.availableCoins.length > initialNumAvailableCoins)));
83
+ export const waitForFinalizedShieldedBalance = (wallet) => waitForStableState(wallet.state.pipe(rx.tap((state) => {
84
+ logger.info(`Wallet pending coins: ${state.pendingCoins.length}, waiting for pending coins cleared...`);
85
+ })), (state) => state.pendingCoins.length === 0, 'waitForFinalizedShieldedBalance');
86
+ export const waitForUnshieldedCoinUpdate = (wallet, initialNumAvailableCoins) => waitForStableState(wallet.state().pipe(rx.tap((state) => {
87
+ logger.info(`Unshielded available coins: ${state.unshielded.availableCoins.length}, waiting for more than ${initialNumAvailableCoins}...synced = ${state.isSynced}`);
88
+ })), (state) => state.isSynced && state.unshielded.availableCoins.length > initialNumAvailableCoins, 'waitForUnshieldedCoinUpdate');
61
89
  export const waitForStateAfterDustRegistration = (wallet, finalizedTx) => rx.firstValueFrom(wallet.state().pipe(rx.mergeMap(async (state) => {
62
90
  const txInHistory = await wallet.queryTxHistoryByHash(finalizedTx.transactionHash());
63
91
  return {
package/dist/wallet.d.ts CHANGED
@@ -3,6 +3,7 @@ import * as ledger from '@midnightntwrk/ledger-v9';
3
3
  import { WalletFacade } from '@midnightntwrk/wallet-sdk-facade';
4
4
  import { type UnshieldedKeystore } from '@midnightntwrk/wallet-sdk-unshielded-wallet';
5
5
  import { type WalletTestEnvironment } from './types.js';
6
+ import { type DustSnapshotModel, type DustWalletFactory } from './dust-sync.js';
6
7
  export type WalletInit = {
7
8
  wallet: WalletFacade;
8
9
  shieldedSecretKeys: ledger.ZswapSecretKeys;
@@ -10,6 +11,12 @@ export type WalletInit = {
10
11
  /** The three per-wallet seeds, which is what the facade is started and stepped with. */
11
12
  seeds: WalletSeeds;
12
13
  unshieldedKeystore: UnshieldedKeystore;
14
+ /**
15
+ * The dust sync model this wallet was actually built with, whether it came from an explicit option or from the
16
+ * environment. {@link saveState} reads it so a snapshot is always written to the namespace of the model that produced
17
+ * it.
18
+ */
19
+ dustSyncModel: DustSnapshotModel;
13
20
  };
14
21
  /** Options for {@link provideWallet}. */
15
22
  export interface ProvideWalletOptions {
@@ -23,7 +30,36 @@ export interface ProvideWalletOptions {
23
30
  syncCacheDir?: string | undefined;
24
31
  /** Filename suffix for the three serialized state files. Required when `syncCacheDir` is set. */
25
32
  filename?: string | undefined;
33
+ /**
34
+ * Replaces the dust sub-wallet factory. Defaults to whatever `DUST_SYNC` selects (the event-based `DustWallet` when
35
+ * unset) — see `dustWalletFromEnv` — so a lane can be switched between sync models by configuration while a test that
36
+ * needs a specific model pins it here. Applies to both the restored and the built-from-scratch paths, so a cold cache
37
+ * cannot silently fall back to a different sync model.
38
+ */
39
+ dustWallet?: DustWalletFactory | undefined;
40
+ /**
41
+ * Starts the facade without background syncing, leaving the caller to drive `facade.doSync()`. Defaults to `false`
42
+ * (background sync), which is what every existing scenario relies on.
43
+ */
44
+ manualSync?: boolean | undefined;
26
45
  }
46
+ /**
47
+ * Scenario wallet options with the projections default filled in **per field** rather than wholesale.
48
+ *
49
+ * A whole-parameter default is lost the moment a caller passes any `walletOptions` at all: `{ manualSync: true }`
50
+ * leaves `dustWallet` undefined, {@link provideWallet} falls back to the `events` model, and a scenario silently stops
51
+ * monitoring the sync model it exists to monitor. Filling the field in only when the caller supplied one keeps
52
+ * `DUST_SYNC` in charge of the model and the caller's own choices intact.
53
+ *
54
+ * An explicit `dustWallet: undefined` counts as not supplying one, so it is filled in too. The field is declared `|
55
+ * undefined` precisely so an optional factory can be forwarded under `exactOptionalPropertyTypes`, which means a caller
56
+ * forwarding an absent one type-checks; taking that as a choice of the `events` model would drop the scenario onto the
57
+ * sync it exists to stop monitoring, without the caller having named a model at all.
58
+ *
59
+ * @param walletOptions What the caller passed, if anything.
60
+ * @returns The options to hand {@link provideWallet}.
61
+ */
62
+ export declare const withProjectionsDefault: (walletOptions: Pick<ProvideWalletOptions, "dustWallet" | "manualSync"> | undefined) => Pick<ProvideWalletOptions, "dustWallet" | "manualSync">;
27
63
  /**
28
64
  * Builds a fully-started {@link WalletFacade} (shielded + unshielded + dust) for the given seed.
29
65
  *
@@ -31,7 +67,31 @@ export interface ProvideWalletOptions {
31
67
  * otherwise (or on any restore failure) builds from scratch via {@link initWalletWithSeed}.
32
68
  */
33
69
  export declare const provideWallet: (env: WalletTestEnvironment, options: ProvideWalletOptions) => Promise<WalletInit>;
34
- /** Serializes all three sub-wallet states into `syncCacheDir`, keyed by `filename`. */
35
- export declare const saveState: (wallet: WalletFacade, syncCacheDir: string, filename: string) => Promise<void>;
70
+ /**
71
+ * Whether the test that has just finished is safe to persist wallet state from.
72
+ *
73
+ * State written by a failed or timed-out test is worse than no state at all: the next run restores it, resumes from
74
+ * whatever partial position the failure left behind, and fails in turn — so one transient failure becomes permanent and
75
+ * every later run inherits it. Only a test that passed is known to have left its wallets in a position the chain can be
76
+ * resumed from.
77
+ *
78
+ * Pass the `afterEach` context: `afterEach((ctx) => { if (shouldPersistState(ctx)) await saveState(...) })`.
79
+ */
80
+ export declare const shouldPersistState: (context: {
81
+ readonly task: {
82
+ readonly result?: {
83
+ readonly state?: string;
84
+ };
85
+ };
86
+ }) => boolean;
87
+ /**
88
+ * Serializes all three sub-wallet states into `syncCacheDir`, keyed by `filename`.
89
+ *
90
+ * Takes the whole {@link WalletInit} rather than the facade alone so the dust snapshot is always written to the
91
+ * namespace of the sync model that produced it — a model and a facade passed separately could disagree.
92
+ */
93
+ export declare const saveState: (walletInit: Pick<WalletInit, "wallet" | "dustSyncModel">, syncCacheDir: string, filename: string) => Promise<void>;
94
+ /** Options for {@link initWalletWithSeed}, a subset of {@link ProvideWalletOptions}. */
95
+ export type InitWalletOptions = Pick<ProvideWalletOptions, 'dustWallet' | 'manualSync'>;
36
96
  /** Builds and starts a fresh {@link WalletFacade} from `seed`, with no disk persistence. */
37
- export declare const initWalletWithSeed: (env: WalletTestEnvironment, seed: string) => Promise<WalletInit>;
97
+ export declare const initWalletWithSeed: (env: WalletTestEnvironment, seed: string, options?: InitWalletOptions) => Promise<WalletInit>;
package/dist/wallet.js CHANGED
@@ -18,9 +18,9 @@ import { InMemoryTransactionHistoryStorage, } from '@midnightntwrk/wallet-sdk-ab
18
18
  import { ShieldedWallet } from '@midnightntwrk/wallet-sdk-shielded';
19
19
  import { WalletFacade, WalletEntrySchema, mergeWalletEntries } from '@midnightntwrk/wallet-sdk-facade';
20
20
  import { createKeystore, PublicKey, UnshieldedWallet, } from '@midnightntwrk/wallet-sdk-unshielded-wallet';
21
- import { DustWallet } from '@midnightntwrk/wallet-sdk-dust-wallet';
22
21
  import { logger } from './logger.js';
23
22
  import { getDustSeed, getShieldedSeed, getUnshieldedSeed } from './seeds.js';
23
+ import { dustSnapshotPath, dustSyncModelOf, dustWalletFromEnv, } from './dust-sync.js';
24
24
  const waitForSyncProgress = async (wallet) => await rx.firstValueFrom(wallet.state().pipe(rx.throttleTime(5000), rx.tap((state) => {
25
25
  const applyGap = state.unshielded.progress.highestTransactionId - state.unshielded.progress.appliedId;
26
26
  logger.info(`Wallet facade behind by ${applyGap}`);
@@ -65,11 +65,11 @@ const restoreUnshieldedWallet = async (path, seed, env, readIfExists, txHistoryS
65
65
  }
66
66
  return undefined;
67
67
  };
68
- const restoreDustWallet = async (path, walletConfig, readIfExists) => {
68
+ const restoreDustWallet = async (path, walletConfig, readIfExists, dustWallet) => {
69
69
  try {
70
70
  const serialized = await readIfExists(path);
71
71
  if (serialized) {
72
- const DustInstance = DustWallet({
72
+ const DustInstance = dustWallet({
73
73
  ...walletConfig,
74
74
  costParameters: walletConfig?.costParameters ?? {
75
75
  feeBlocksMargin: 5,
@@ -86,6 +86,26 @@ const restoreDustWallet = async (path, walletConfig, readIfExists) => {
86
86
  }
87
87
  return undefined;
88
88
  };
89
+ /**
90
+ * Scenario wallet options with the projections default filled in **per field** rather than wholesale.
91
+ *
92
+ * A whole-parameter default is lost the moment a caller passes any `walletOptions` at all: `{ manualSync: true }`
93
+ * leaves `dustWallet` undefined, {@link provideWallet} falls back to the `events` model, and a scenario silently stops
94
+ * monitoring the sync model it exists to monitor. Filling the field in only when the caller supplied one keeps
95
+ * `DUST_SYNC` in charge of the model and the caller's own choices intact.
96
+ *
97
+ * An explicit `dustWallet: undefined` counts as not supplying one, so it is filled in too. The field is declared `|
98
+ * undefined` precisely so an optional factory can be forwarded under `exactOptionalPropertyTypes`, which means a caller
99
+ * forwarding an absent one type-checks; taking that as a choice of the `events` model would drop the scenario onto the
100
+ * sync it exists to stop monitoring, without the caller having named a model at all.
101
+ *
102
+ * @param walletOptions What the caller passed, if anything.
103
+ * @returns The options to hand {@link provideWallet}.
104
+ */
105
+ export const withProjectionsDefault = (walletOptions) => ({
106
+ ...walletOptions,
107
+ dustWallet: walletOptions?.dustWallet ?? dustWalletFromEnv(process.env, 'projections'),
108
+ });
89
109
  /**
90
110
  * Builds a fully-started {@link WalletFacade} (shielded + unshielded + dust) for the given seed.
91
111
  *
@@ -93,10 +113,15 @@ const restoreDustWallet = async (path, walletConfig, readIfExists) => {
93
113
  * otherwise (or on any restore failure) builds from scratch via {@link initWalletWithSeed}.
94
114
  */
95
115
  export const provideWallet = async (env, options) => {
96
- const { seed, syncCacheDir, filename } = options;
116
+ const { seed, syncCacheDir, filename, manualSync } = options;
117
+ // Resolved once, here, so the restore path, the snapshot namespace and every from-scratch fallback below all agree on
118
+ // which sync model was asked for.
119
+ const dustWallet = options.dustWallet ?? dustWalletFromEnv();
120
+ const dustSyncModel = dustSyncModelOf(dustWallet);
121
+ const fromScratch = { dustWallet, manualSync };
97
122
  if (!syncCacheDir || !filename) {
98
123
  logger.info('No sync cache configured; building wallet facade from scratch');
99
- return initWalletWithSeed(env, seed);
124
+ return initWalletWithSeed(env, seed, fromScratch);
100
125
  }
101
126
  // Single shared tx-history storage so all three sub-wallets and the facade read/write
102
127
  // the same instance; otherwise shielded/unshielded writes go to a storage the facade
@@ -127,11 +152,11 @@ export const provideWallet = async (env, options) => {
127
152
  const [restoredShielded, restoredUnshielded, restoredDust] = await Promise.all([
128
153
  restoreShieldedWallet(`${syncCacheDir}/shielded-${filename}`, Wallet, readIfExists),
129
154
  restoreUnshieldedWallet(`${syncCacheDir}/unshielded-${filename}`, seed, env, readIfExists, txHistoryStorage),
130
- restoreDustWallet(`${syncCacheDir}/dust-${filename}`, { ...walletConfig, ...dustWalletConfig }, readIfExists),
155
+ restoreDustWallet(dustSnapshotPath(syncCacheDir, filename, dustSyncModel), { ...walletConfig, ...dustWalletConfig }, readIfExists, dustWallet),
131
156
  ]);
132
157
  if (!restoredShielded || !restoredUnshielded || !restoredDust) {
133
158
  logger.info('Building wallet facade from scratch');
134
- return initWalletWithSeed(env, seed);
159
+ return initWalletWithSeed(env, seed, fromScratch);
135
160
  }
136
161
  else {
137
162
  const restoredWallet = await WalletFacade.init({
@@ -143,7 +168,7 @@ export const provideWallet = async (env, options) => {
143
168
  unshielded: () => restoredUnshielded,
144
169
  dust: () => restoredDust,
145
170
  });
146
- await restoredWallet.start(seeds);
171
+ await restoredWallet.start(seeds, { manualSync: manualSync ?? false });
147
172
  // check if wallet is syncing correctly
148
173
  await waitForSyncProgress(restoredWallet);
149
174
  const restoredWalletState = await rx.firstValueFrom(restoredWallet.state());
@@ -152,33 +177,49 @@ export const provideWallet = async (env, options) => {
152
177
  if ((applyGap ?? 0) < 0) {
153
178
  logger.warn('Unable to sync restored wallet. Building wallet facade from scratch');
154
179
  await restoredWallet.stop();
155
- return initWalletWithSeed(env, seed);
180
+ return initWalletWithSeed(env, seed, fromScratch);
156
181
  }
157
182
  else {
158
183
  logger.info('Successfully restored wallet facade.');
159
- return { wallet: restoredWallet, shieldedSecretKeys, dustSecretKey, seeds, unshieldedKeystore };
184
+ return { wallet: restoredWallet, shieldedSecretKeys, dustSecretKey, seeds, unshieldedKeystore, dustSyncModel };
160
185
  }
161
186
  }
162
187
  };
163
- /** Serializes all three sub-wallet states into `syncCacheDir`, keyed by `filename`. */
164
- export const saveState = async (wallet, syncCacheDir, filename) => {
188
+ /**
189
+ * Whether the test that has just finished is safe to persist wallet state from.
190
+ *
191
+ * State written by a failed or timed-out test is worse than no state at all: the next run restores it, resumes from
192
+ * whatever partial position the failure left behind, and fails in turn — so one transient failure becomes permanent and
193
+ * every later run inherits it. Only a test that passed is known to have left its wallets in a position the chain can be
194
+ * resumed from.
195
+ *
196
+ * Pass the `afterEach` context: `afterEach((ctx) => { if (shouldPersistState(ctx)) await saveState(...) })`.
197
+ */
198
+ export const shouldPersistState = (context) => context.task.result?.state === 'pass';
199
+ /**
200
+ * Serializes all three sub-wallet states into `syncCacheDir`, keyed by `filename`.
201
+ *
202
+ * Takes the whole {@link WalletInit} rather than the facade alone so the dust snapshot is always written to the
203
+ * namespace of the sync model that produced it — a model and a facade passed separately could disagree.
204
+ */
205
+ export const saveState = async (walletInit, syncCacheDir, filename) => {
165
206
  logger.info(`Saving state in ${syncCacheDir}/${filename}`);
166
207
  try {
167
208
  await fsAsync.mkdir(syncCacheDir, { recursive: true });
168
209
  // Serialize all three states
169
210
  const [shieldedSerializedState, unshieldedSerializedState, dustSerializedState] = await Promise.all([
170
- wallet.shielded.serializeState(),
171
- wallet.unshielded.serializeState(),
172
- wallet.dust.serializeState(),
211
+ walletInit.wallet.shielded.serializeState(),
212
+ walletInit.wallet.unshielded.serializeState(),
213
+ walletInit.wallet.dust.serializeState(),
173
214
  ]);
174
215
  const files = [
175
- { suffix: 'shielded-', data: shieldedSerializedState },
176
- { suffix: 'unshielded-', data: unshieldedSerializedState },
177
- { suffix: 'dust-', data: dustSerializedState },
216
+ { path: `${syncCacheDir}/shielded-${filename}`, data: shieldedSerializedState },
217
+ { path: `${syncCacheDir}/unshielded-${filename}`, data: unshieldedSerializedState },
218
+ { path: dustSnapshotPath(syncCacheDir, filename, walletInit.dustSyncModel), data: dustSerializedState },
178
219
  ];
179
- const results = await Promise.allSettled(files.map((f) => fsAsync.writeFile(`${syncCacheDir}/${f.suffix}${filename}`, f.data, 'utf-8')));
220
+ const results = await Promise.allSettled(files.map((f) => fsAsync.writeFile(f.path, f.data, 'utf-8')));
180
221
  for (const [i, res] of results.entries()) {
181
- const pathWritten = `${syncCacheDir}/${files[i].suffix}${filename}`;
222
+ const pathWritten = files[i].path;
182
223
  if (res.status === 'fulfilled') {
183
224
  logger.info(`State written to file ${pathWritten}`);
184
225
  }
@@ -200,7 +241,8 @@ export const saveState = async (wallet, syncCacheDir, filename) => {
200
241
  }
201
242
  };
202
243
  /** Builds and starts a fresh {@link WalletFacade} from `seed`, with no disk persistence. */
203
- export const initWalletWithSeed = async (env, seed) => {
244
+ export const initWalletWithSeed = async (env, seed, options = {}) => {
245
+ const dustWalletClass = options.dustWallet ?? dustWalletFromEnv();
204
246
  const walletConfig = env.getWalletConfig();
205
247
  const seeds = {
206
248
  shielded: getShieldedSeed(seed),
@@ -218,8 +260,15 @@ export const initWalletWithSeed = async (env, seed) => {
218
260
  },
219
261
  shielded: (config) => ShieldedWallet(config).startWithSeed(getShieldedSeed(seed)),
220
262
  unshielded: (config) => UnshieldedWallet(config).startWithPublicKey(PublicKey.fromKeyStore(unshieldedKeystore)),
221
- dust: (config) => DustWallet(config).startWithSeed(getDustSeed(seed)),
263
+ dust: (config) => dustWalletClass(config).startWithSeed(getDustSeed(seed)),
222
264
  });
223
- await facade.start(seeds);
224
- return { wallet: facade, shieldedSecretKeys, dustSecretKey, seeds, unshieldedKeystore };
265
+ await facade.start(seeds, { manualSync: options.manualSync ?? false });
266
+ return {
267
+ wallet: facade,
268
+ shieldedSecretKeys,
269
+ dustSecretKey,
270
+ seeds,
271
+ unshieldedKeystore,
272
+ dustSyncModel: dustSyncModelOf(dustWalletClass),
273
+ };
225
274
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@midnightntwrk/wallet-sdk-testkit",
3
3
  "description": "Reusable wallet-SDK test harness: environment provisioning, wallet bootstrapping, sync waiters and tx-history assertions",
4
- "version": "1.0.0-beta.3",
4
+ "version": "1.0.0-beta.4",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -39,16 +39,16 @@
39
39
  }
40
40
  },
41
41
  "dependencies": {
42
- "@midnightntwrk/ledger-v9": "1.0.0-rc.4",
43
- "@midnightntwrk/wallet-sdk-abstractions": "3.0.0-beta.1",
44
- "@midnightntwrk/wallet-sdk-address-format": "4.0.0-beta.3",
45
- "@midnightntwrk/wallet-sdk-capabilities": "4.0.0-beta.3",
46
- "@midnightntwrk/wallet-sdk-dust-wallet": "5.0.0-beta.3",
47
- "@midnightntwrk/wallet-sdk-facade": "5.0.0-beta.3",
48
- "@midnightntwrk/wallet-sdk-hd": "3.1.0-beta.2",
42
+ "@midnightntwrk/ledger-v9": "1.0.0-rc.5",
43
+ "@midnightntwrk/wallet-sdk-abstractions": "3.0.0-beta.2",
44
+ "@midnightntwrk/wallet-sdk-address-format": "4.0.0-beta.4",
45
+ "@midnightntwrk/wallet-sdk-capabilities": "4.0.0-beta.4",
46
+ "@midnightntwrk/wallet-sdk-dust-wallet": "5.0.0-beta.4",
47
+ "@midnightntwrk/wallet-sdk-facade": "5.0.0-beta.4",
48
+ "@midnightntwrk/wallet-sdk-hd": "3.1.0-beta.3",
49
49
  "@midnightntwrk/wallet-sdk-indexer-client": "2.0.0-beta.2",
50
- "@midnightntwrk/wallet-sdk-shielded": "4.0.0-beta.3",
51
- "@midnightntwrk/wallet-sdk-unshielded-wallet": "4.0.0-beta.3",
50
+ "@midnightntwrk/wallet-sdk-shielded": "4.0.0-beta.4",
51
+ "@midnightntwrk/wallet-sdk-unshielded-wallet": "4.0.0-beta.4",
52
52
  "pino": "^10.3.1",
53
53
  "pino-pretty": "^13.1.3",
54
54
  "rxjs": "^7.8.2"
@@ -71,10 +71,13 @@
71
71
  "rimraf": "6.1.3",
72
72
  "testcontainers": "11.14.0",
73
73
  "typescript": "5.9.3",
74
- "vitest": "4.1.10"
74
+ "vitest": "4.1.11"
75
75
  },
76
76
  "scripts": {
77
77
  "typecheck": "tsc -b ./tsconfig.json --noEmit --force",
78
+ "test": "vitest run",
79
+ "test:unit": "vitest run --project unit --passWithNoTests",
80
+ "test:integration": "vitest run --project integration --passWithNoTests",
78
81
  "lint": "eslint --max-warnings 0",
79
82
  "format": "prettier --write \"**/*.{ts,js,json,yaml,yml,md}\"",
80
83
  "format:check": "prettier --check \"**/*.{ts,js,json,yaml,yml,md}\"",