@midnight-ntwrk/wallet-sdk-dust-wallet 4.1.0 → 4.2.0

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
@@ -1,3 +1,10 @@
1
+ > [!IMPORTANT]
2
+ > **This package has moved.** The `@midnight-ntwrk` scope is published only
3
+ > during the migration window and will stop receiving updates. Please migrate to
4
+ > [`@midnightntwrk/wallet-sdk-dust-wallet`](https://www.npmjs.com/package/@midnightntwrk/wallet-sdk-dust-wallet).
5
+
6
+ ---
7
+
1
8
  # @midnight-ntwrk/wallet-sdk-dust-wallet
2
9
 
3
10
  Manages dust (transaction fees) on the Midnight network.
@@ -2,6 +2,7 @@ import { type DustParameters, type DustPublicKey, DustSecretKey, type FinalizedT
2
2
  import { type ProtocolState, ProtocolVersion, type SyncProgress } from '@midnight-ntwrk/wallet-sdk-abstractions';
3
3
  import { type DustAddress } from '@midnight-ntwrk/wallet-sdk-address-format';
4
4
  import { type Variant, type VariantBuilder, type WalletLike } from '@midnight-ntwrk/wallet-sdk-runtime/abstractions';
5
+ import { type Clock } from '@midnight-ntwrk/wallet-sdk-utilities';
5
6
  import * as rx from 'rxjs';
6
7
  import { type Balance, type CoinsAndBalancesCapability, type UtxoWithFullDustDetails } from './v1/CoinsAndBalances.js';
7
8
  import { CoreWallet } from './v1/CoreWallet.js';
@@ -57,6 +58,29 @@ export type DustWalletAPI<TStartAux = DustSecretKey, TSerialized = string> = {
57
58
  balanceTransactions(secretKey: DustSecretKey, transactions: ReadonlyArray<AnyTransaction>, ttl: Date, currentTime?: Date): Promise<UnprovenTransaction>;
58
59
  serializeState(): Promise<TSerialized>;
59
60
  waitForSyncedState(allowedGap?: bigint): Promise<DustWalletState<TSerialized>>;
61
+ /**
62
+ * Resolves when the dust projected to be generated by the single highest-generation unregistered Night UTxO reaches
63
+ * `requiredAmount`. The projection is re-evaluated every second so the wait advances even when the dust state stream
64
+ * is quiet. Tracks the same quantity used as `allow_fee_payment` for the registration (the maximum across the UTxOs,
65
+ * not their sum, since `splitNightUtxos` puts only one UTxO in the guaranteed slot), so pairing with
66
+ * `WalletFacade.estimateRegistration` to pick `requiredAmount` guarantees the subsequent
67
+ * `registerNightUtxosForDustGeneration` will pass its fee-coverage guard.
68
+ *
69
+ * @param nightUtxos - UTxOs to project generation for; same set passed to `registerNightUtxosForDustGeneration`.
70
+ * Already-registered UTxOs are ignored. Must be non-empty.
71
+ * @param requiredAmount - Threshold to wait for, as a Dust amount. Resolves immediately if `<= 0n`.
72
+ * @param clock - Source of current time, read on every tick. Required, and a {@link Clock.Clock} rather than a
73
+ * snapshot `Date` like the other methods' `currentTime`: the projection only advances because the time is re-read
74
+ * each tick, and callers must inject their own clock so simulator-driven tests respect simulator time.
75
+ * @param opts.timeoutMs - Deadline, in ms from subscription, for `requiredAmount` to be reached; rejects if it is
76
+ * not. Default `300_000`.
77
+ * @returns A promise that resolves once the projected dust reaches `requiredAmount`.
78
+ * @throws Error if `nightUtxos` is empty.
79
+ * @throws TimeoutError if `requiredAmount` is not reached within `opts.timeoutMs`.
80
+ */
81
+ waitForGeneratedDust(nightUtxos: ReadonlyArray<UtxoWithMeta>, requiredAmount: bigint, clock: Clock.Clock, opts?: {
82
+ timeoutMs?: number;
83
+ }): Promise<void>;
60
84
  revertTransaction(transaction: AnyTransaction): Promise<void>;
61
85
  getAddress(): Promise<DustAddress>;
62
86
  stop(): Promise<void>;
@@ -158,6 +158,30 @@ export function CustomDustWallet(configuration, builder) {
158
158
  waitForSyncedState(allowedGap = 0n) {
159
159
  return rx.firstValueFrom(this.state.pipe(rx.filter((state) => state.state.progress.isCompleteWithin(allowedGap))));
160
160
  }
161
+ async waitForGeneratedDust(nightUtxos, requiredAmount, clock, opts) {
162
+ if (nightUtxos.length === 0) {
163
+ throw Error('At least one Night UTXO is required.');
164
+ }
165
+ if (requiredAmount <= 0n) {
166
+ return;
167
+ }
168
+ const timeoutMs = opts?.timeoutMs ?? 300_000;
169
+ // Combine the dust state stream with a 1 s tick — the dust state only emits when sync
170
+ // updates apply, but the generation projection depends on a current-time reading, which
171
+ // advances continuously. Without a periodic tick the filter would never re-run between
172
+ // state emissions on a quiet wallet, and the wait would hang.
173
+ await rx.firstValueFrom(rx.combineLatest([this.state, rx.timer(0, 1000)]).pipe(rx.filter(([dustState]) => {
174
+ // The registration's allow_fee_payment is capped at the single highest-generation
175
+ // unregistered Night UTxO (splitNightUtxos puts only 1 UTxO in the guaranteed slot),
176
+ // so the wait must track that same quantity — summing across UTxOs would resolve
177
+ // optimistically and the registration would still fail the fee check.
178
+ const maxGeneratedNow = dustState
179
+ .estimateDustGeneration(nightUtxos, clock.now())
180
+ .filter((u) => !u.utxo.registeredForDustGeneration)
181
+ .reduce((max, u) => (u.dust.generatedNow > max ? u.dust.generatedNow : max), 0n);
182
+ return maxGeneratedNow >= requiredAmount;
183
+ }), rx.timeout({ first: timeoutMs })));
184
+ }
161
185
  serializeState() {
162
186
  return rx.firstValueFrom(this.state).then((state) => state.serialize());
163
187
  }
@@ -71,7 +71,14 @@ export class RunningV1Variant {
71
71
  message: 'Error while applying sync update',
72
72
  cause: err,
73
73
  }),
74
- })).pipe(Effect.flatMap(({ changes, protocolVersion }) => pipe(Effect.forEach(changes, (change) => pipe(this.#v1Context.transactionHistoryService.getTransactionDetails(change.source), Effect.flatMap((metadata) => this.#v1Context.transactionHistoryService.put(change, metadata, protocolVersion)), Effect.catchAllCause((cause) => Console.error('Error processing tx history metadata', cause))), { discard: true, concurrency: 'unbounded' }), Effect.forkScoped)), Effect.provideService(Scope.Scope, this.#scope))), Stream.tapError((error) => Console.error(error)), Stream.retry(pipe(Schedule.exponential(Duration.seconds(1), 2), Schedule.map((delay) => {
74
+ })).pipe(Effect.flatMap(({ changes, protocolVersion }) =>
75
+ // Skip the tx-history fork entirely when there are no changes.
76
+ // Forking unconditionally allocates a fiber per apply call (one
77
+ // for every batch the sync emits, even the all-progress ones),
78
+ // which adds up fast during catch-up.
79
+ changes.length === 0
80
+ ? Effect.void
81
+ : pipe(Effect.forEach(changes, (change) => pipe(this.#v1Context.transactionHistoryService.getTransactionDetails(change.source), Effect.flatMap((metadata) => this.#v1Context.transactionHistoryService.put(change, metadata, protocolVersion)), Effect.catchAllCause((cause) => Console.error('Error processing tx history metadata', cause))), { discard: true, concurrency: 'unbounded' }), Effect.forkScoped)), Effect.provideService(Scope.Scope, this.#scope))), Stream.tapError((error) => Console.error(error)), Stream.retry(pipe(Schedule.exponential(Duration.seconds(1), 2), Schedule.map((delay) => {
75
82
  const maxDelay = Duration.minutes(2);
76
83
  const jitter = Duration.millis(Math.floor(Math.random() * 1000));
77
84
  const delayWithJitter = Duration.toMillis(delay) + Duration.toMillis(jitter);
package/dist/v1/Sync.d.ts CHANGED
@@ -26,6 +26,10 @@ export type IndexerClientConnection = {
26
26
  indexerHttpUrl: string;
27
27
  indexerWsUrl?: string;
28
28
  keepAlive?: number;
29
+ /** Cap on the in-flight event queue between the WebSocket push and the apply loop. Default: 10000. */
30
+ bufferSize?: number;
31
+ /** In-flight count at which the disposed WS subscription is reopened. Default: 100. */
32
+ resumeThreshold?: number;
29
33
  };
30
34
  export type BatchUpdatesConfig = {
31
35
  /**
package/dist/v1/Sync.js CHANGED
@@ -33,19 +33,13 @@ const LedgerEventSchema = Schema.declare((input) => input instanceof LedgerEvent
33
33
  identifier: 'ledger.Event',
34
34
  });
35
35
  const LedgerEventFromUInt8Array = Schema.asSchema(Schema.transformOrFail(Uint8ArraySchema, LedgerEventSchema, {
36
- encode: (e) => {
37
- return Effect.try({
38
- try: () => e.serialize(),
39
- catch: (err) => {
40
- return new ParseResult.Unexpected(err, 'Could not serialize Ledger Event');
41
- },
42
- });
43
- },
36
+ encode: (e) => Effect.try({
37
+ try: () => e.serialize(),
38
+ catch: (err) => new ParseResult.Unexpected(err, 'Could not serialize Ledger Event'),
39
+ }),
44
40
  decode: (bytes) => Effect.try({
45
41
  try: () => LedgerEvent.deserialize(bytes),
46
- catch: (err) => {
47
- return new ParseResult.Unexpected(err, 'Could not deserialize Ledger Event');
48
- },
42
+ catch: (err) => new ParseResult.Unexpected(err, 'Could not deserialize Ledger Event'),
49
43
  }),
50
44
  }));
51
45
  const HexedEvent = pipe(Schema.Uint8ArrayFromHex, Schema.compose(LedgerEventFromUInt8Array));
@@ -110,8 +104,32 @@ export const makeIndexerSyncService = (config) => {
110
104
  },
111
105
  subscribeWallet(state) {
112
106
  const { appliedIndex } = state.progress;
113
- return pipe(DustLedgerEvents.run({
114
- id: Number(appliedIndex),
107
+ const bufferSize = config.indexerClientConnection.bufferSize ?? 10000;
108
+ const resumeThreshold = config.indexerClientConnection.resumeThreshold ?? 100;
109
+ // The boundary is load-bearing, not waste: this subscription emits only events (no tip/progress
110
+ // sentinel), and `isConnected`/the tip (`maxId`) are set only when an event is received. So the
111
+ // cursor must stay `<= appliedIndex` — never `appliedIndex + 1`. Requesting one event later would
112
+ // deliver nothing to a wallet already at the tip, so `applyUpdate` would never run and sync would
113
+ // hang.
114
+ //
115
+ // A fresh wallet has `appliedIndex === 0n` (the "nothing applied yet" sentinel), so `resumeFrom`
116
+ // is `-1n` and the `variables` mapping below opens the subscription with no `id` — the indexer
117
+ // streams from the very start. A restored wallet has `appliedIndex >= 1`, so `resumeFrom` is
118
+ // `appliedIndex - 1` and the inclusive cursor re-delivers the boundary event.
119
+ const resumeFrom = appliedIndex - 1n;
120
+ return pipe(
121
+ // Backpressure caps the in-flight queue between the WS push and the
122
+ // apply loop. Without it the JS heap grows linearly with catch-up
123
+ // depth, since `Stream.asyncPush({ bufferSize: 'unbounded' })`
124
+ // buffers every event the indexer pushes regardless of apply rate.
125
+ DustLedgerEvents.runWithBackpressure({
126
+ bufferSize,
127
+ resumeThreshold,
128
+ from: resumeFrom,
129
+ // `resumeFrom < 0n` means a fresh wallet: send no `id` so the indexer streams from the very
130
+ // start, rather than relying on `id: 0` sorting below the first real event id.
131
+ variables: (cursor) => ({ id: cursor < 0n ? null : Number(cursor) }),
132
+ key: (r) => BigInt(r.dustLedgerEvents.id),
115
133
  }), Stream.mapEffect((subscription) => pipe(Schema.decodeUnknownEither(SyncEventsUpdateSchema)(subscription.dustLedgerEvents), Either.mapLeft((err) => new SyncWalletError(err)), EitherOps.toEffect)), Stream.mapError((error) => new SyncWalletError(error)));
116
134
  },
117
135
  };
@@ -124,21 +142,14 @@ export const makeDefaultSyncCapability = () => {
124
142
  if (updates.length === 0) {
125
143
  return [state, { changes: [], protocolVersion: Number(state.protocolVersion) }];
126
144
  }
127
- const lastUpdate = updates.at(-1);
128
- const nextIndex = BigInt(lastUpdate.id);
129
- const highestRelevantWalletIndex = BigInt(lastUpdate.maxId);
130
- // in case the nextIndex is less than or equal to the current appliedIndex
131
- // just update highestRelevantWalletIndex
132
- if (nextIndex <= state.progress.appliedIndex) {
133
- return [
134
- CoreWallet.updateProgress(state, { highestRelevantWalletIndex, isConnected: true }),
135
- { changes: [], protocolVersion: Number(state.protocolVersion) },
136
- ];
137
- }
138
- const events = updates.map((u) => u.raw).filter((event) => event !== null);
139
- const [newState, changes] = CoreWallet.applyEventsWithChanges(state, secretKey, events, wrappedUpdate.timestamp);
145
+ const appliedIndex = state.progress.appliedIndex;
146
+ const freshUpdates = updates.filter((u) => BigInt(u.id) > appliedIndex);
147
+ const highestRelevantWalletIndex = BigInt(updates.at(-1).maxId);
148
+ const [newState, changes] = freshUpdates.length === 0
149
+ ? [state, []]
150
+ : CoreWallet.applyEventsWithChanges(state, secretKey, freshUpdates.map((u) => u.raw), wrappedUpdate.timestamp);
140
151
  const updatedState = CoreWallet.updateProgress(newState, {
141
- appliedIndex: nextIndex,
152
+ appliedIndex: freshUpdates.length === 0 ? appliedIndex : BigInt(freshUpdates.at(-1).id),
142
153
  highestRelevantWalletIndex,
143
154
  isConnected: true,
144
155
  });
@@ -9,3 +9,4 @@ export * from './V1Builder.js';
9
9
  export * from './types/index.js';
10
10
  export * as CoinsAndBalances from './CoinsAndBalances.js';
11
11
  export * as TransactionHistory from './TransactionHistory.js';
12
+ export * as WalletError from './WalletError.js';
package/dist/v1/index.js CHANGED
@@ -21,3 +21,4 @@ export * from './V1Builder.js';
21
21
  export * from './types/index.js';
22
22
  export * as CoinsAndBalances from './CoinsAndBalances.js';
23
23
  export * as TransactionHistory from './TransactionHistory.js';
24
+ export * as WalletError from './WalletError.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@midnight-ntwrk/wallet-sdk-dust-wallet",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -8,14 +8,16 @@
8
8
  "author": "Midnight Foundation",
9
9
  "license": "Apache-2.0",
10
10
  "publishConfig": {
11
- "registry": "https://npm.pkg.github.com/"
11
+ "registry": "https://registry.npmjs.org/",
12
+ "access": "public"
12
13
  },
13
14
  "files": [
14
15
  "dist/"
15
16
  ],
16
17
  "repository": {
17
18
  "type": "git",
18
- "url": "git+https://github.com/midnight-ntwrk/artifacts.git"
19
+ "url": "git+https://github.com/midnightntwrk/midnight-wallet.git",
20
+ "directory": "packages/dust-wallet"
19
21
  },
20
22
  "exports": {
21
23
  ".": {
@@ -32,8 +34,8 @@
32
34
  "@midnight-ntwrk/wallet-sdk-abstractions": "^2.1.0",
33
35
  "@midnight-ntwrk/wallet-sdk-address-format": "^3.1.2",
34
36
  "@midnight-ntwrk/wallet-sdk-capabilities": "^3.3.1",
35
- "@midnight-ntwrk/wallet-sdk-indexer-client": "^1.2.2",
36
- "@midnight-ntwrk/wallet-sdk-runtime": "^1.0.4",
37
+ "@midnight-ntwrk/wallet-sdk-indexer-client": "^1.2.3",
38
+ "@midnight-ntwrk/wallet-sdk-runtime": "^1.0.5",
37
39
  "@midnight-ntwrk/wallet-sdk-utilities": "^1.2.0",
38
40
  "effect": "^3.19.19",
39
41
  "rxjs": "^7.8.2"
@@ -50,6 +52,6 @@
50
52
  "publint": "publint --strict"
51
53
  },
52
54
  "devDependencies": {
53
- "@midnight-ntwrk/wallet-sdk-hd": "^3.0.2"
55
+ "@midnight-ntwrk/wallet-sdk-hd": "^3.0.3"
54
56
  }
55
57
  }