@midnight-ntwrk/wallet-sdk-facade 5.0.0-beta.2 → 5.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -10,21 +10,31 @@
10
10
  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
- import * as ledger from '@midnightntwrk/ledger-v9';
13
+ import * as ledgerV9 from '@midnightntwrk/ledger-v9';
14
14
  import { makeDefaultSubmissionService, } from '@midnight-ntwrk/wallet-sdk-capabilities';
15
- import { makeDefaultProvingService, } from '@midnight-ntwrk/wallet-sdk-capabilities/proving';
15
+ import { makeDefaultVersionedProvingService, } from '@midnight-ntwrk/wallet-sdk-capabilities/proving';
16
16
  import { ShieldedSectionSchema, mergeShieldedSections, } from '@midnight-ntwrk/wallet-sdk-shielded';
17
17
  import { UnshieldedSectionSchema, mergeUnshieldedSections, } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
18
18
  import { DustSectionSchema, mergeDustSections } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
19
19
  import { Clock } from '@midnight-ntwrk/wallet-sdk-utilities';
20
20
  import { FetchTermsAndConditions as FetchTermsAndConditionsQuery } from '@midnight-ntwrk/wallet-sdk-indexer-client';
21
21
  import { QueryRunner } from '@midnight-ntwrk/wallet-sdk-indexer-client/effect';
22
- import { Array as Arr, pipe, Schema } from 'effect';
23
- import { TransactionHistoryStorage } from '@midnight-ntwrk/wallet-sdk-abstractions';
24
- import { combineLatest, map, firstValueFrom, concatMap } from 'rxjs';
22
+ import { Array as Arr, Either, Option, pipe, Schema } from 'effect';
23
+ import { ProtocolVersion, TransactionHistoryStorage, WalletTransaction, } from '@midnight-ntwrk/wallet-sdk-abstractions';
24
+ import * as ledgerV8 from '@midnight-ntwrk/ledger-v8';
25
+ import * as Signatures from '@midnight-ntwrk/wallet-sdk-capabilities/signatures';
26
+ import { BehaviorSubject, combineLatest, concatMap, distinctUntilChanged, firstValueFrom, map, tap, } from 'rxjs';
25
27
  import { PendingTransactions, PendingTransactionsServiceImpl, } from '@midnight-ntwrk/wallet-sdk-capabilities';
26
- import { makeDefaultBlockDataFetcher, makeDefaultValidationService, ValidationFetchError, WellFormedError, } from '@midnight-ntwrk/wallet-sdk-capabilities/validation';
27
- import { finalizedTransactionTrait, txHistoryHash } from './transaction.js';
28
+ import { makeDefaultBlockDataFetcher, makeDefaultVersionedValidationService, ValidationFetchError, WellFormedError, } from '@midnight-ntwrk/wallet-sdk-capabilities/validation';
29
+ import { finalizedTransactionTraits, txHistoryHash } from './transaction.js';
30
+ /**
31
+ * Why the wallet gave up on a transaction, for the history entry.
32
+ *
33
+ * @remarks
34
+ * A chain rejection speaks for itself through the entry's status; an orphaned transaction has no chain verdict at all,
35
+ * so the entry is the only place the reason can be recorded.
36
+ */
37
+ const rejectionReason = (result) => result.status === 'ORPHANED_BY_FORK' ? 'orphaned-by-protocol-upgrade' : undefined;
28
38
  /**
29
39
  * Full entry schema for transaction history. The common entry data and wallet-specific sections (`shielded`,
30
40
  * `unshielded`, `dust`) live on every entry regardless of lifecycle; the `lifecycle` field is the only discriminator.
@@ -44,7 +54,7 @@ export const isFinalizedWalletEntry = (entry) => entry.lifecycle.status === 'fin
44
54
  * wallet has set the value, later writes are no-ops for these fields. This is correct because the value is the same
45
55
  * across all wallets (it's a property of the on-chain tx, not the wallet's view of it).
46
56
  * - **`identifiers`** — unioned (each wallet may surface a different identifier subset).
47
- * - **`lifecycle`** — incoming wins (this is how `pending → finalized` transitions are recorded).
57
+ * - **`lifecycle`** — incoming wins, except that a late rejection cannot overwrite recorded on-chain inclusion.
48
58
  * - **Wallet sections** (`shielded`, `unshielded`, `dust`) — combined via per-section merge when both sides have them;
49
59
  * otherwise whichever side is present is used.
50
60
  */
@@ -73,8 +83,9 @@ export function mergeWalletEntries(existing, incoming) {
73
83
  status: existing.status ?? incoming.status,
74
84
  timestamp: existing.timestamp ?? incoming.timestamp,
75
85
  fees: existing.fees ?? incoming.fees,
76
- // lifecycle: incoming wins — this is how pending → finalized/rejected transitions are recorded
77
- lifecycle: incoming.lifecycle,
86
+ lifecycle: existing.lifecycle.status === 'finalized' && incoming.lifecycle.status === 'rejected'
87
+ ? existing.lifecycle
88
+ : incoming.lifecycle,
78
89
  ...(shielded !== undefined ? { shielded } : {}),
79
90
  ...(unshielded !== undefined ? { unshielded } : {}),
80
91
  ...(dust !== undefined ? { dust } : {}),
@@ -99,6 +110,16 @@ const revertTxHistoryKey = (tx) => {
99
110
  return undefined;
100
111
  return { hash: txHistoryHash(tx), identifiers };
101
112
  };
113
+ const v9Authoring = {
114
+ sampleSigningKey: () => ledgerV9.sampleSigningKey(),
115
+ signatureVerifyingKey: (signingKey) => ledgerV9.signatureVerifyingKey(signingKey),
116
+ signData: (signingKey, data) => ledgerV9.signData(signingKey, data),
117
+ };
118
+ const v8Authoring = {
119
+ sampleSigningKey: () => ledgerV8.sampleSigningKey(),
120
+ signatureVerifyingKey: (signingKey) => Signatures.liftSignatureVerifyingKey(ledgerV8.signatureVerifyingKey(signingKey)),
121
+ signData: (signingKey, data) => Signatures.liftSignature(ledgerV8.signData(signingKey, data)),
122
+ };
102
123
  const TokenKindsToBalance = new (class {
103
124
  allTokenKinds = ['shielded', 'unshielded', 'dust'];
104
125
  toFlags = (tokenKinds) => {
@@ -132,21 +153,132 @@ export const BalancingRecipe = {
132
153
  }
133
154
  },
134
155
  };
156
+ /**
157
+ * The lowest of the protocol versions the three wallets have reached.
158
+ *
159
+ * @remarks
160
+ * The three wallets follow the same chain but not in lock-step: each recognises a protocol version change when its own
161
+ * synchronization reaches it, so around a fork they disagree for a while. A transaction spans all three, so the one
162
+ * still behind is what bounds the facade as a whole — the highest version every wallet is known to be at.
163
+ * @param versions The version each wallet has reached.
164
+ * @returns The lowest of the three.
165
+ */
166
+ export const lowestProtocolVersion = (versions) => [versions.shielded, versions.unshielded, versions.dust].reduce((lowest, candidate) => candidate < lowest ? candidate : lowest);
167
+ /**
168
+ * The ledger-v9 key objects the wallets' own `start` takes, from whichever material the caller supplied.
169
+ *
170
+ * @remarks
171
+ * Only the ledger-v9 side is needed here: a wallet built from seeds or from both versions' keys already holds what its
172
+ * V1 variant needs, retained when it was built. What `start` supplies is the side the wallet's own API speaks.
173
+ */
174
+ const v9KeysOf = (material) => 'v9' in material
175
+ ? material.v9
176
+ : {
177
+ shielded: ledgerV9.ZswapSecretKeys.fromSeed(material.shielded),
178
+ dust: ledgerV9.DustSecretKey.fromSeed(material.dust),
179
+ };
180
+ /** The three wallets in a fixed order, so {@link protocolPhaseOf} reports them the same way every time. */
181
+ const walletKinds = ['shielded', 'unshielded', 'dust'];
182
+ /**
183
+ * Reads whether the wallets are settled on one side of a protocol boundary, or still crossing it.
184
+ *
185
+ * @remarks
186
+ * Derived entirely from the version each wallet has reported and where the boundary lies — the same two facts every
187
+ * other version-routing decision in the SDK is made from, so this reading cannot disagree with them.
188
+ * @param versions The version each wallet has reached.
189
+ * @param forkVersion The version at which the chain hands over to the next ledger version.
190
+ * @returns The reading. See {@link ProtocolPhase}.
191
+ */
192
+ export const protocolPhaseOf = (versions, forkVersion) => {
193
+ const reported = walletKinds.map((kind) => [kind, versions[kind]]);
194
+ const from = lowestProtocolVersion(versions);
195
+ const epoch = ProtocolVersion.epochOf(from, forkVersion);
196
+ const crossed = reported.filter(([, version]) => !ProtocolVersion.withinRange(version, epoch));
197
+ if (crossed.length === 0)
198
+ return { _tag: 'Settled', version: from };
199
+ return {
200
+ _tag: 'Crossing',
201
+ from,
202
+ to: crossed.reduce((highest, [, version]) => (version > highest ? version : highest), crossed[0][1]),
203
+ behind: reported.filter(([, version]) => ProtocolVersion.withinRange(version, epoch)).map(([kind]) => kind),
204
+ };
205
+ };
206
+ /** Reads what has become of a transaction from the verdict the pending set holds, if it holds one. */
207
+ const pendingStatusOf = (result) => {
208
+ if (result === undefined)
209
+ return { _tag: 'Submitted' };
210
+ switch (result.status) {
211
+ case 'SUCCESS':
212
+ return { _tag: 'Confirmed', segments: result.segments };
213
+ case 'FAILURE':
214
+ case 'PARTIAL_SUCCESS':
215
+ // A transaction only some of whose segments succeeded is still not a transaction that happened as submitted.
216
+ return { _tag: 'Rejected', segments: result.segments };
217
+ case 'ORPHANED_BY_FORK':
218
+ return { _tag: 'Orphaned', authoredFor: result.authoredFor, chainNow: result.chainNow };
219
+ }
220
+ };
221
+ /**
222
+ * The pending transactions as an application reads them.
223
+ *
224
+ * @remarks
225
+ * A projection over the pending set the services keep, not a second copy of it: the facts are the same, stated as a
226
+ * list of transactions with a status each rather than as the bag the machinery works in.
227
+ * @param pending The pending set.
228
+ * @returns One entry per transaction, in the order the wallet recorded them.
229
+ */
230
+ export const pendingTransactionsOf = (pending) => pending.all.map((item) => ({
231
+ transaction: item.tx,
232
+ submittedAt: item.creationTime,
233
+ authoredFor: item.protocolVersion,
234
+ status: pendingStatusOf('result' in item ? item.result : undefined),
235
+ }));
135
236
  export class FacadeState {
136
237
  shielded;
137
238
  unshielded;
138
239
  dust;
139
240
  pending;
241
+ /** The protocol version each of the three wallets has reached. */
242
+ get protocolVersion() {
243
+ return {
244
+ shielded: this.shielded.protocolVersion,
245
+ unshielded: this.unshielded.protocolVersion,
246
+ dust: this.dust.protocolVersion,
247
+ };
248
+ }
249
+ /**
250
+ * The protocol version the facade as a whole can act at: the lowest the three wallets have reached.
251
+ *
252
+ * @remarks
253
+ * Around a protocol boundary the three wallets cross at slightly different moments, and a transaction needs all
254
+ * three. This is the version every one of them is known to understand.
255
+ */
256
+ get activeProtocolVersion() {
257
+ return lowestProtocolVersion(this.protocolVersion);
258
+ }
259
+ /**
260
+ * Whether the three wallets are settled on one side of the protocol boundary, or still crossing it.
261
+ *
262
+ * @remarks
263
+ * Additive, and the reading `protocolVersion` alone cannot give: three versions that differ tell an application
264
+ * nothing about whether the difference matters. See {@link ProtocolPhase}.
265
+ */
266
+ get protocol() {
267
+ return protocolPhaseOf(this.protocolVersion, this.#forkVersion);
268
+ }
140
269
  get isSynced() {
141
270
  return (this.shielded.state.progress.isStrictlyComplete() &&
142
271
  this.dust.state.progress.isStrictlyComplete() &&
143
272
  this.unshielded.progress.isStrictlyComplete());
144
273
  }
145
- constructor(shielded, unshielded, dust, pending) {
274
+ /** Where the chain hands over from one ledger version to the next, which is what {@link protocol} is read against. */
275
+ #forkVersion;
276
+ constructor(shielded, unshielded, dust, pending, forkVersion = ProtocolVersion.MinSupportedVersion) {
146
277
  this.shielded = shielded;
147
278
  this.unshielded = unshielded;
148
279
  this.dust = dust;
149
- this.pending = pending;
280
+ this.pending = pendingTransactionsOf(pending);
281
+ this.#forkVersion = forkVersion;
150
282
  }
151
283
  }
152
284
  const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -161,29 +293,65 @@ const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
161
293
  * without a circular dependency.
162
294
  */
163
295
  export { Clock };
296
+ /**
297
+ * The fork schedule the facade presets when a configuration names none: {@link ProtocolVersion.V9NativeForkSchedule},
298
+ * ledger-v9 from the version a 2.x node reports.
299
+ *
300
+ * @remarks
301
+ * Where a chain forks is a fact about the chain, which is why each wallet package requires `forks` and presets nothing.
302
+ * The facade is the one place a preset decides nothing the SDK has not already decided: every chain the 2.x node line
303
+ * runs hands over at this version, so an application copying the constant into its configuration only restates it. A
304
+ * chain that hands over elsewhere states its own `forks`, which wins.
305
+ */
306
+ export const DefaultForkSchedule = ProtocolVersion.V9NativeForkSchedule;
164
307
  // `BlockData` is not re-exported from the facade to avoid a name collision with the
165
308
  // `@midnight-ntwrk/wallet-sdk-dust-wallet` export. The two are structurally identical; users can name the type via
166
309
  // `@midnight-ntwrk/wallet-sdk-dust-wallet` or `@midnight-ntwrk/wallet-sdk-capabilities/validation`.
167
310
  export { ValidationFetchError, WellFormedError, };
168
311
  export class WalletFacade {
169
312
  static makeDefaultSubmissionService(config) {
313
+ // A handle serializes itself, which is all submission needs of a transaction — and the one thing every ledger
314
+ // version's transaction does identically.
170
315
  return makeDefaultSubmissionService(config);
171
316
  }
172
317
  static makeDefaultPendingTransactionsService(config) {
173
318
  return PendingTransactionsServiceImpl.init({
174
319
  configuration: config,
175
- txTrait: finalizedTransactionTrait,
320
+ txTraits: finalizedTransactionTraits(config.forks.v9),
176
321
  });
177
322
  }
323
+ /**
324
+ * Builds the proving service a configuration describes.
325
+ *
326
+ * @remarks
327
+ * Handed the same fork schedule the wallets are built with, because a proving backend is chosen by the epoch a
328
+ * transaction belongs to and the two ends of that question must not be able to compute the boundary differently.
329
+ */
178
330
  static makeDefaultProvingService(config) {
179
- if (config.provingServerUrl) {
180
- return makeDefaultProvingService({
181
- provingServerUrl: config.provingServerUrl,
182
- });
183
- }
184
- else {
185
- throw new Error("Missing required configuration: 'provingServerUrl' must be set in config, or provide a custom provingService in init parameters.");
186
- }
331
+ return Either.getOrThrowWith(makeDefaultVersionedProvingService(config, config.forks), (error) => new Error(error.message));
332
+ }
333
+ /**
334
+ * Fills in what the facade presets — `forks`, as {@link DefaultForkSchedule} — so that the result is a configuration a
335
+ * wallet package can be built from.
336
+ *
337
+ * @remarks
338
+ * {@link WalletFacade.init} does this itself, once and before anything is built, so the wallets, the default services
339
+ * and the facade's own boundary cannot be given different schedules, and no factory needs to call it. It is public
340
+ * for code outside a factory that needs the configuration the facade will use: an application building a wallet
341
+ * package directly, or reading `forks` back to author a transaction for the right ledger version. Resolving twice
342
+ * is the same as resolving once, so `init` may be handed the result.
343
+ * @example
344
+ * ```typescript
345
+ * const configuration = WalletFacade.resolveConfiguration({ networkId: 'undeployed', ... });
346
+ * const facade = await WalletFacade.init({ configuration, shielded: (config) => ShieldedWallet(config), ... });
347
+ * const authoredForV9 = protocolVersion >= configuration.forks.v9;
348
+ * ```;
349
+ *
350
+ * @param configuration The configuration as given, with or without `forks`.
351
+ * @returns The same configuration with `forks` present: the schedule it named, or {@link DefaultForkSchedule}.
352
+ */
353
+ static resolveConfiguration(configuration) {
354
+ return { ...configuration, forks: configuration.forks ?? DefaultForkSchedule };
187
355
  }
188
356
  /**
189
357
  * Fetches the current Terms and Conditions from the network indexer.
@@ -215,7 +383,9 @@ export class WalletFacade {
215
383
  * and initialization of necessary components. Specifically - it requires following fields:
216
384
  *
217
385
  * - `configuration` - holding a configuration, which needs to extend {@link DefaultConfiguration} - this way allows to
218
- * convey use-case-specific settings in the same way, as the SDK works by default
386
+ * convey use-case-specific settings in the same way, as the SDK works by default. `forks` may be left out; every
387
+ * factory below is handed a {@link ResolvedConfiguration}, which always has it — what
388
+ * {@link WalletFacade.resolveConfiguration} returns
219
389
  * - `shielded` - a function taking the configuration and returning shielded wallet (or a promise with such)
220
390
  * implementing {@link ShieldedWalletAPI}
221
391
  * - `unshielded` - a function taking the configuration and returning unshielded wallet (or a promise with such)
@@ -224,38 +394,38 @@ export class WalletFacade {
224
394
  * {@link DustWalletAPI} There are some optional services/abstractions to provide, too. If not provided - default
225
395
  * implementations will be used, each of them is initialized by a function taking the configuration and returning
226
396
  * proper implementation (wrapped in a {@link Promise} or not).
227
- * - `submissionService` - needs to implement {@link SubmissionService} for a {@link ledger.FinalizedTransaction} to
397
+ * - `submissionService` - needs to implement {@link SubmissionService} for a {@link ledgerV9.FinalizedTransaction} to
228
398
  * submit transactions to the network, default uses Node RPC connection
229
399
  * - `pendingTransactionsService` - needs to implement {@link PendingTransactionsService} for a
230
- * {@link ledger.FinalizedTransaction} to keep track of pending transactions, default uses in-memory implementation
231
- * - `provingService` - needs to implement {@link ProvingService} to prove it, default uses proving server
400
+ * {@link ledgerV9.FinalizedTransaction} to keep track of pending transactions, default uses in-memory
401
+ * implementation
402
+ * - `provingService` - needs to implement {@link VersionedProvingService} to prove it, default uses proving server
232
403
  * - `clock` - needs to implement {@link Clock.Clock} for getting current time, default uses system clock
233
404
  */
234
405
  static async init(initParams) {
406
+ const configuration = WalletFacade.resolveConfiguration(initParams.configuration);
235
407
  const submissionService = await Promise.resolve(initParams.submissionService
236
- ? initParams.submissionService(initParams.configuration)
237
- : WalletFacade.makeDefaultSubmissionService(initParams.configuration));
408
+ ? initParams.submissionService(configuration)
409
+ : WalletFacade.makeDefaultSubmissionService(configuration));
238
410
  const pendingTransactionsService = await Promise.resolve(initParams.pendingTransactionsService
239
- ? initParams.pendingTransactionsService(initParams.configuration)
240
- : WalletFacade.makeDefaultPendingTransactionsService(initParams.configuration));
411
+ ? initParams.pendingTransactionsService(configuration)
412
+ : WalletFacade.makeDefaultPendingTransactionsService(configuration));
241
413
  const provingService = await Promise.resolve(initParams.provingService
242
- ? initParams.provingService(initParams.configuration)
243
- : WalletFacade.makeDefaultProvingService(initParams.configuration));
244
- const shielded = await Promise.resolve(initParams.shielded(initParams.configuration));
245
- const unshielded = await Promise.resolve(initParams.unshielded(initParams.configuration));
246
- const dust = await Promise.resolve(initParams.dust(initParams.configuration));
247
- const clock = await Promise.resolve(initParams.clock ? initParams.clock(initParams.configuration) : Clock.systemClock);
248
- const fetchBlockData = await Promise.resolve(initParams.fetchBlockData
249
- ? initParams.fetchBlockData(initParams.configuration)
250
- : makeDefaultBlockDataFetcher(initParams.configuration));
414
+ ? initParams.provingService(configuration)
415
+ : WalletFacade.makeDefaultProvingService(configuration));
416
+ const shielded = await Promise.resolve(initParams.shielded(configuration));
417
+ const unshielded = await Promise.resolve(initParams.unshielded(configuration));
418
+ const dust = await Promise.resolve(initParams.dust(configuration));
419
+ const clock = await Promise.resolve(initParams.clock ? initParams.clock(configuration) : Clock.systemClock);
420
+ const fetchBlockData = await Promise.resolve(initParams.fetchBlockData ? initParams.fetchBlockData(configuration) : makeDefaultBlockDataFetcher(configuration));
251
421
  const validationService = await Promise.resolve(initParams.validationService
252
- ? initParams.validationService(initParams.configuration, { fetchBlockData, clock })
253
- : makeDefaultValidationService({
422
+ ? initParams.validationService(configuration, { fetchBlockData, clock })
423
+ : makeDefaultVersionedValidationService({
254
424
  fetchBlockData,
255
- networkId: initParams.configuration.networkId,
425
+ networkId: configuration.networkId,
256
426
  clock,
257
- }));
258
- return new WalletFacade(shielded, unshielded, dust, submissionService, pendingTransactionsService, provingService, validationService, initParams.configuration.txHistoryStorage, clock);
427
+ }, configuration.forks.v9));
428
+ return new WalletFacade(shielded, unshielded, dust, submissionService, pendingTransactionsService, provingService, validationService, configuration.txHistoryStorage, configuration.forks.v9, clock);
259
429
  }
260
430
  shielded;
261
431
  unshielded;
@@ -267,13 +437,24 @@ export class WalletFacade {
267
437
  #txHistoryStorage;
268
438
  clock;
269
439
  #pendingSubscription;
440
+ #protocolVersionSubscription;
441
+ /**
442
+ * The protocol version the wallets have all reached, as last observed.
443
+ *
444
+ * @remarks
445
+ * `Option.none()` until the three wallets have each emitted once. A transaction stamped with `none` is never
446
+ * orphaned, so an unobserved version costs a transaction nothing but the ability to be given up on early.
447
+ */
448
+ #observedProtocolVersion = new BehaviorSubject(Option.none());
449
+ /** Where the chain hands over from one ledger version to the next, which is what divides the two epochs. */
450
+ #forkVersion;
270
451
  /**
271
452
  * Constructor is private on purpose - much of initialization of the facade is potentially asynchronous, and adding
272
453
  * new parameters is a breaking change to the users Use {@link WalletFacade.init} instead
273
454
  *
274
455
  * @private
275
456
  */
276
- constructor(shieldedWallet, unshieldedWallet, dustWallet, submissionService, pendingTransactionsService, provingService, validationService, txHistoryStorage, clock = Clock.systemClock) {
457
+ constructor(shieldedWallet, unshieldedWallet, dustWallet, submissionService, pendingTransactionsService, provingService, validationService, txHistoryStorage, forkVersion, clock = Clock.systemClock) {
277
458
  this.shielded = shieldedWallet;
278
459
  this.unshielded = unshieldedWallet;
279
460
  this.dust = dustWallet;
@@ -282,11 +463,83 @@ export class WalletFacade {
282
463
  this.provingService = provingService;
283
464
  this.validationService = validationService;
284
465
  this.#txHistoryStorage = txHistoryStorage;
466
+ this.#forkVersion = forkVersion;
285
467
  this.clock = clock;
286
468
  this.#pendingSubscription = this.pendingTransactionsService
287
469
  .state()
288
- .pipe(concatMap((pending) => PendingTransactions.allFailed(pending)), concatMap((item) => this.revert(item.tx)))
470
+ .pipe(concatMap((pending) => PendingTransactions.allRejected(pending)), concatMap(async (item) => {
471
+ if (!(await this.reconcileFinalizedTransaction(item.tx, await this.#txHistoryStorage.getAll()))) {
472
+ await this.revert(item.tx, rejectionReason(item.result));
473
+ }
474
+ }))
289
475
  .subscribe();
476
+ // Deliberately built from the wallets' own states rather than from `state()`: `state()` includes the pending set,
477
+ // and orphaning writes to it, so feeding that back here would be a cycle.
478
+ this.#protocolVersionSubscription = combineLatest([this.shielded.state, this.unshielded.state, this.dust.state])
479
+ .pipe(map(([shieldedState, unshieldedState, dustState]) => lowestProtocolVersion({
480
+ shielded: shieldedState.protocolVersion,
481
+ unshielded: unshieldedState.protocolVersion,
482
+ dust: dustState.protocolVersion,
483
+ })), distinctUntilChanged(), tap((version) => this.#observedProtocolVersion.next(Option.some(version))),
484
+ // Nothing fallible belongs here: this chain is the only writer of the observed version, and an orphaned
485
+ // transaction sync already saw included is reconciled by the pending subscription like any other verdict.
486
+ concatMap((version) => this.pendingTransactionsService.orphanBeyond(version)))
487
+ .subscribe();
488
+ }
489
+ /**
490
+ * The protocol version the facade is currently acting at.
491
+ *
492
+ * @remarks
493
+ * The lowest the three wallets have reached, once they have all reported; the minimum supported version until then,
494
+ * which is the epoch a wallet with no history belongs to. It is what a transaction the facade builds is stamped
495
+ * with, and what the side of the boundary it accepts transactions from is measured against.
496
+ */
497
+ currentVersion() {
498
+ return Option.getOrElse(this.#observedProtocolVersion.getValue(), () => ProtocolVersion.MinSupportedVersion);
499
+ }
500
+ /** The range of protocol versions on the same side of the boundary as a given one. */
501
+ epochOf(version) {
502
+ return ProtocolVersion.epochOf(version, this.#forkVersion);
503
+ }
504
+ /** The range of protocol versions on the facade's current side of the boundary. */
505
+ currentEpoch() {
506
+ return this.epochOf(this.currentVersion());
507
+ }
508
+ /**
509
+ * Reads a transaction an application handed in, refusing one built on the other side of the boundary.
510
+ *
511
+ * @remarks
512
+ * The enforcement point for everything that enters the facade — a transaction the wallets built, or one an
513
+ * application authored and sealed with `WalletTransaction.adopt`. Only the current epoch is accepted, because a
514
+ * transaction of the other one cannot be merged, proved or submitted alongside anything the facade would build for
515
+ * it. Both a stranded ledger-v8 transaction after the crossing and a ledger-v9 transaction offered before it are
516
+ * refused here, by name, with the versions written down.
517
+ * @param handle The handle to read.
518
+ * @returns The carried transaction.
519
+ * @throws {@link ProtocolVersionMismatchError} When the transaction was built on the other side of the boundary.
520
+ */
521
+ accept(handle) {
522
+ return Either.getOrThrowWith(WalletTransaction.unwrapWithin(handle, this.currentEpoch()), (error) => error);
523
+ }
524
+ /**
525
+ * Seals a transaction the facade or a wallet produced, at the version its bytes were fixed by.
526
+ *
527
+ * @remarks
528
+ * The version is the caller's to state, never read from the facade here, because the two can differ: a crossing can
529
+ * land while a transaction is at the prover, and what comes back is still the bytes of the ledger version that made
530
+ * them. A caller sealing something it has just built passes the version it is building at; a caller sealing
531
+ * something that has travelled through an await passes the version stamped on the handle it started from.
532
+ * @param stage How far along the building of the transaction the sealed handle is.
533
+ * @param transaction The transaction to seal.
534
+ * @param protocolVersion The version the transaction's bytes were fixed by.
535
+ * @returns The sealed handle.
536
+ */
537
+ seal(stage, transaction, protocolVersion) {
538
+ return WalletTransaction.adopt(stage, transaction, protocolVersion);
539
+ }
540
+ /** The ledger primitives the facade signs with on its current side of the boundary. */
541
+ authoring() {
542
+ return this.currentVersion() < this.#forkVersion ? v8Authoring : v9Authoring;
290
543
  }
291
544
  defaultTtl() {
292
545
  return new Date(this.clock.now().getTime() + DEFAULT_TTL_MS);
@@ -337,11 +590,21 @@ export class WalletFacade {
337
590
  * @throws {@link ValidationFetchError} If the block-data fetch fails.
338
591
  */
339
592
  async validateTransaction(tx, options) {
340
- return this.validationService.validateTx(tx, options);
593
+ // Checked at the version the transaction says it was authored for, and never at the version the chain has since
594
+ // reached: well-formedness asks whether the ledger that produced these bytes would accept them, and a fork landing
595
+ // afterwards cannot change that answer. The transaction is read at its own epoch for the same reason.
596
+ return this.validationService.validateTx(Either.getOrThrowWith(WalletTransaction.unwrapWithin(tx, ProtocolVersion.epochOf(tx.protocolVersion, this.#forkVersion)), (error) => error), tx.protocolVersion, options);
341
597
  }
598
+ /**
599
+ * Merges two unproven transactions of the same epoch, or returns whichever there is.
600
+ *
601
+ * @remarks
602
+ * Both are unwrapped before merging, which is where a pair from different epochs is refused: a merge across the
603
+ * boundary is not a failure to compute, it is unrepresentable, and saying so is the whole point of the stamp.
604
+ */
342
605
  mergeUnprovenTransactions(a, b) {
343
606
  if (a && b)
344
- return a.merge(b);
607
+ return this.seal('Unproven', this.accept(a).merge(this.accept(b)), this.currentVersion());
345
608
  return a ?? b;
346
609
  }
347
610
  async createDustActionTransaction(action, nightUtxos, nightVerifyingKey, signDustRegistration) {
@@ -390,6 +653,11 @@ export class WalletFacade {
390
653
  // on-chain with BalanceCheckOverspend. Skip for re-registration (all guaranteed UTxOs already
391
654
  // registered) since `feePayment` is 0 by design and the caller is expected to balance the fee
392
655
  // externally via `balanceUnprovenTransaction({ tokenKindsToBalance: ['dust'] })`.
656
+ //
657
+ // `registeredForDustGeneration` is the indexer's answer as of the chain's current dust epoch, so it is the one
658
+ // authority on which of the two a registration is. Night carried across the v8 -> v9 fork arrives with it set to
659
+ // `false` — the fork wipes dust generation state and the unshielded crossing carries the flag accordingly — so a
660
+ // re-registration on ledger-v9 is correctly treated as first-time.
393
661
  const hasUnregisteredGuaranteed = split.guaranteedUtxos.some((u) => !u.utxo.registeredForDustGeneration);
394
662
  if (isRegistration && hasUnregisteredGuaranteed) {
395
663
  const fee = await this.dust.calculateFee([txWithDustActions]);
@@ -402,7 +670,7 @@ export class WalletFacade {
402
670
  // Step 5 — Sign via the standard signRecipe pathway, which now stamps both the unshielded
403
671
  // offers and the dust registration. Signing failures also need to release the booking.
404
672
  try {
405
- const signedRecipe = await this.signRecipe({ type: 'UNPROVEN_TRANSACTION', transaction: txWithDustActions }, signDustRegistration);
673
+ const signedRecipe = await this.signRecipe({ type: 'UNPROVEN_TRANSACTION', protocolVersion: this.currentVersion(), transaction: txWithDustActions }, signDustRegistration);
406
674
  if (signedRecipe.type !== 'UNPROVEN_TRANSACTION') {
407
675
  throw Error('signRecipe returned unexpected recipe type for dust action transaction.');
408
676
  }
@@ -419,7 +687,7 @@ export class WalletFacade {
419
687
  this.unshielded.state,
420
688
  this.dust.state,
421
689
  this.pendingTransactionsService.state(),
422
- ]).pipe(map(([shieldedState, unshieldedState, dustState, pending]) => new FacadeState(shieldedState, unshieldedState, dustState, pending)));
690
+ ]).pipe(map(([shieldedState, unshieldedState, dustState, pending]) => new FacadeState(shieldedState, unshieldedState, dustState, pending, this.#forkVersion)));
423
691
  }
424
692
  async waitForSyncedState() {
425
693
  const [shieldedState, unshieldedState, dustState, pending] = await Promise.all([
@@ -428,7 +696,7 @@ export class WalletFacade {
428
696
  this.dust.waitForSyncedState(),
429
697
  firstValueFrom(this.pendingTransactionsService.state()),
430
698
  ]);
431
- return new FacadeState(shieldedState, unshieldedState, dustState, pending);
699
+ return new FacadeState(shieldedState, unshieldedState, dustState, pending, this.#forkVersion);
432
700
  }
433
701
  /**
434
702
  * Submits a finalized transaction to the network and tracks it as pending until finalized or discarded.
@@ -441,12 +709,13 @@ export class WalletFacade {
441
709
  * @throws {@link WellFormedError} — call {@link validateTransaction} first to get this error early.
442
710
  */
443
711
  async submitTransaction(tx) {
444
- const identifiers = tx.identifiers();
712
+ const carried = this.accept(tx);
713
+ const identifiers = carried.identifiers();
445
714
  try {
446
- await this.pendingTransactionsService.addPendingTransaction(tx);
715
+ await this.pendingTransactionsService.addPendingTransaction(tx, this.#observedProtocolVersion.getValue());
447
716
  // Insert before awaiting submission so the entry exists while the tx is in flight — the per-wallet sync
448
717
  // handlers' gotFinalized call clears the pending entry on confirmation.
449
- const key = submitTxHistoryKey(tx);
718
+ const key = submitTxHistoryKey(carried);
450
719
  await this.#txHistoryStorage.gotPending({ ...key, submittedAt: this.clock.now() });
451
720
  await this.submissionService.submitTransaction(tx, 'Finalized');
452
721
  return identifiers.at(-1);
@@ -468,22 +737,19 @@ export class WalletFacade {
468
737
  * @param options - TTL for the balancing transaction, and optional subset of token kinds to balance.
469
738
  * @returns A {@link FinalizedTransactionRecipe} containing the original and balancing transactions.
470
739
  */
471
- async balanceFinalizedTransaction(tx, secretKeys, options) {
472
- const { shieldedSecretKeys, dustSecretKey } = secretKeys;
740
+ async balanceFinalizedTransaction(tx, options) {
473
741
  const { ttl, tokenKindsToBalance = 'all' } = options;
474
742
  const { shouldBalanceDust, shouldBalanceShielded, shouldBalanceUnshielded } = TokenKindsToBalance.toFlags(tokenKindsToBalance);
475
743
  // Step 1: Run unshielded and shielded balancing
476
744
  const unshieldedBalancingTx = shouldBalanceUnshielded
477
745
  ? await this.unshielded.balanceFinalizedTransaction(tx)
478
746
  : undefined;
479
- const shieldedBalancingTx = shouldBalanceShielded
480
- ? await this.shielded.balanceTransaction(shieldedSecretKeys, tx)
481
- : undefined;
747
+ const shieldedBalancingTx = shouldBalanceShielded ? await this.shielded.balanceTransaction(tx) : undefined;
482
748
  // Step 2: Merge unshielded and shielded balancing
483
749
  const mergedBalancingTx = this.mergeUnprovenTransactions(shieldedBalancingTx, unshieldedBalancingTx);
484
750
  // Step 3: Conditionally add dust/fee balancing
485
751
  const dustResult = shouldBalanceDust
486
- ? await this.dust.balanceTransactions(dustSecretKey, mergedBalancingTx ? [tx, mergedBalancingTx] : [tx], ttl)
752
+ ? await this.dust.balanceTransactions(mergedBalancingTx ? [tx, mergedBalancingTx] : [tx], ttl)
487
753
  : undefined;
488
754
  const feeBalancingTx = dustResult?.transaction;
489
755
  // Step 4: Merge fee balancing and create final recipe
@@ -493,6 +759,7 @@ export class WalletFacade {
493
759
  }
494
760
  return {
495
761
  type: 'FINALIZED_TRANSACTION',
762
+ protocolVersion: this.currentVersion(),
496
763
  originalTransaction: tx,
497
764
  balancingTransaction: balancingTx,
498
765
  ...(dustResult ? { blockData: dustResult.blockData } : {}),
@@ -510,14 +777,11 @@ export class WalletFacade {
510
777
  * @param options - TTL for the balancing transaction, and optional subset of token kinds to balance.
511
778
  * @returns An {@link UnboundTransactionRecipe} containing the base and optional balancing transactions.
512
779
  */
513
- async balanceUnboundTransaction(tx, secretKeys, options) {
514
- const { shieldedSecretKeys, dustSecretKey } = secretKeys;
780
+ async balanceUnboundTransaction(tx, options) {
515
781
  const { ttl, tokenKindsToBalance = 'all' } = options;
516
782
  const { shouldBalanceDust, shouldBalanceShielded, shouldBalanceUnshielded } = TokenKindsToBalance.toFlags(tokenKindsToBalance);
517
783
  // Step 1: Run unshielded and shielded balancing
518
- const shieldedBalancingTx = shouldBalanceShielded
519
- ? await this.shielded.balanceTransaction(shieldedSecretKeys, tx)
520
- : undefined;
784
+ const shieldedBalancingTx = shouldBalanceShielded ? await this.shielded.balanceTransaction(tx) : undefined;
521
785
  // For unbound transactions, unshielded balancing happens in place not with a balancing transaction
522
786
  const balancedUnshieldedTx = shouldBalanceUnshielded
523
787
  ? await this.unshielded.balanceUnboundTransaction(tx)
@@ -526,7 +790,7 @@ export class WalletFacade {
526
790
  const baseTx = balancedUnshieldedTx ?? tx;
527
791
  // Step 3: Conditionally add dust/fee balancing
528
792
  const dustResult = shouldBalanceDust
529
- ? await this.dust.balanceTransactions(dustSecretKey, shieldedBalancingTx ? [baseTx, shieldedBalancingTx] : [baseTx], ttl)
793
+ ? await this.dust.balanceTransactions(shieldedBalancingTx ? [baseTx, shieldedBalancingTx] : [baseTx], ttl)
530
794
  : undefined;
531
795
  const feeBalancingTransaction = dustResult?.transaction;
532
796
  // Step 4: Create the final balancing transaction
@@ -537,6 +801,7 @@ export class WalletFacade {
537
801
  }
538
802
  return {
539
803
  type: 'UNBOUND_TRANSACTION',
804
+ protocolVersion: this.currentVersion(),
540
805
  baseTransaction: baseTx,
541
806
  balancingTransaction: balancingTransaction ?? undefined,
542
807
  ...(dustResult ? { blockData: dustResult.blockData } : {}),
@@ -554,14 +819,11 @@ export class WalletFacade {
554
819
  * @param options - TTL for the balancing transaction, and optional subset of token kinds to balance.
555
820
  * @returns An {@link UnprovenTransactionRecipe} containing the balanced transaction.
556
821
  */
557
- async balanceUnprovenTransaction(tx, secretKeys, options) {
558
- const { shieldedSecretKeys, dustSecretKey } = secretKeys;
822
+ async balanceUnprovenTransaction(tx, options) {
559
823
  const { ttl, tokenKindsToBalance = 'all' } = options;
560
824
  const { shouldBalanceDust, shouldBalanceShielded, shouldBalanceUnshielded } = TokenKindsToBalance.toFlags(tokenKindsToBalance);
561
825
  // Step 1: Run unshielded and shielded balancing
562
- const shieldedBalancingTx = shouldBalanceShielded
563
- ? await this.shielded.balanceTransaction(shieldedSecretKeys, tx)
564
- : undefined;
826
+ const shieldedBalancingTx = shouldBalanceShielded ? await this.shielded.balanceTransaction(tx) : undefined;
565
827
  // For unproven transactions, unshielded balancing happens in place
566
828
  const balancedUnshieldedTx = shouldBalanceUnshielded
567
829
  ? await this.unshielded.balanceUnprovenTransaction(tx)
@@ -571,14 +833,13 @@ export class WalletFacade {
571
833
  // Step 3: Merge shielded balancing into base tx if present
572
834
  const mergedTx = this.mergeUnprovenTransactions(baseTx, shieldedBalancingTx);
573
835
  // Step 4: Conditionally add dust/fee balancing
574
- const dustResult = shouldBalanceDust
575
- ? await this.dust.balanceTransactions(dustSecretKey, [mergedTx], ttl)
576
- : undefined;
836
+ const dustResult = shouldBalanceDust ? await this.dust.balanceTransactions([mergedTx], ttl) : undefined;
577
837
  const feeBalancingTx = dustResult?.transaction;
578
838
  // Step 5: Merge fee balancing if present
579
839
  const balancedTx = this.mergeUnprovenTransactions(mergedTx, feeBalancingTx);
580
840
  return {
581
841
  type: 'UNPROVEN_TRANSACTION',
842
+ protocolVersion: this.currentVersion(),
582
843
  transaction: balancedTx,
583
844
  ...(dustResult ? { blockData: dustResult.blockData } : {}),
584
845
  };
@@ -589,14 +850,16 @@ export class WalletFacade {
589
850
  switch (recipe.type) {
590
851
  case 'FINALIZED_TRANSACTION': {
591
852
  const finalizedBalancing = await this.finalizeTransaction(recipe.balancingTransaction);
592
- return recipe.originalTransaction.merge(finalizedBalancing);
853
+ return this.seal('Finalized', this.accept(recipe.originalTransaction).merge(this.accept(finalizedBalancing)), recipe.originalTransaction.protocolVersion);
593
854
  }
594
855
  case 'UNBOUND_TRANSACTION': {
595
856
  const finalizedBalancingTx = recipe.balancingTransaction
596
857
  ? await this.finalizeTransaction(recipe.balancingTransaction)
597
858
  : undefined;
598
- const finalizedTransaction = recipe.baseTransaction.bind();
599
- return finalizedBalancingTx ? finalizedTransaction.merge(finalizedBalancingTx) : finalizedTransaction;
859
+ const finalizedTransaction = this.accept(recipe.baseTransaction).bind();
860
+ return this.seal('Finalized', finalizedBalancingTx
861
+ ? finalizedTransaction.merge(this.accept(finalizedBalancingTx))
862
+ : finalizedTransaction, recipe.baseTransaction.protocolVersion);
600
863
  }
601
864
  case 'UNPROVEN_TRANSACTION': {
602
865
  return await this.finalizeTransaction(recipe.transaction);
@@ -604,7 +867,7 @@ export class WalletFacade {
604
867
  }
605
868
  })
606
869
  .then(async (finalizedTx) => {
607
- await this.pendingTransactionsService.addPendingTransaction(finalizedTx);
870
+ await this.pendingTransactionsService.addPendingTransaction(finalizedTx, Option.some(finalizedTx.protocolVersion));
608
871
  return finalizedTx;
609
872
  });
610
873
  }
@@ -615,6 +878,7 @@ export class WalletFacade {
615
878
  const withDustSig = await this.#signDustRegistrationIfPresent(signedBalancingTx, signSegment);
616
879
  return {
617
880
  type: 'FINALIZED_TRANSACTION',
881
+ protocolVersion: recipe.protocolVersion,
618
882
  originalTransaction: recipe.originalTransaction,
619
883
  balancingTransaction: withDustSig,
620
884
  ...(recipe.blockData ? { blockData: recipe.blockData } : {}),
@@ -627,6 +891,7 @@ export class WalletFacade {
627
891
  const signedBaseTx = await this.signUnboundTransaction(recipe.baseTransaction, signSegment);
628
892
  return {
629
893
  type: 'UNBOUND_TRANSACTION',
894
+ protocolVersion: recipe.protocolVersion,
630
895
  baseTransaction: signedBaseTx,
631
896
  balancingTransaction: signedBalancingTx,
632
897
  ...(recipe.blockData ? { blockData: recipe.blockData } : {}),
@@ -637,6 +902,7 @@ export class WalletFacade {
637
902
  const withDustSig = await this.#signDustRegistrationIfPresent(signedTx, signSegment);
638
903
  return {
639
904
  type: 'UNPROVEN_TRANSACTION',
905
+ protocolVersion: recipe.protocolVersion,
640
906
  transaction: withDustSig,
641
907
  ...(recipe.blockData ? { blockData: recipe.blockData } : {}),
642
908
  };
@@ -644,7 +910,7 @@ export class WalletFacade {
644
910
  }
645
911
  }
646
912
  async #signDustRegistrationIfPresent(tx, signSegment) {
647
- const intent = tx.intents?.get(1);
913
+ const intent = this.accept(tx).intents?.get(1);
648
914
  const registrations = intent?.dustActions?.registrations ?? [];
649
915
  if (!intent || registrations.length === 0) {
650
916
  return tx;
@@ -658,11 +924,34 @@ export class WalletFacade {
658
924
  async signUnboundTransaction(tx, signSegment) {
659
925
  return await this.unshielded.signUnboundTransaction(tx, signSegment);
660
926
  }
927
+ /**
928
+ * Proves and binds an unproven transaction, and records it as pending.
929
+ *
930
+ * @remarks
931
+ * Proved at the version stamped on the transaction itself, which is the version that fixed its bytes, and sealed at
932
+ * that same version: proving is a long await, so the wallets can move under it, and neither the prover a
933
+ * transaction is routed to nor the stamp it comes back with may be decided by where they have moved to.
934
+ *
935
+ * Where they have moved to decides one thing only, and it is decided again once the proof is back: a crossing while
936
+ * the transaction was in flight leaves it belonging to an epoch the wallets have left, and no chain they are on can
937
+ * include it. Such a transaction is refused rather than sealed, and nothing is recorded as pending for it — a
938
+ * pending entry would only wait out a TTL for an inclusion that cannot happen — while the reservations it made in
939
+ * the three wallets are given back like those of any other failure here.
940
+ * @param tx The unproven transaction.
941
+ * @returns The finalized transaction, stamped with the version it was authored at.
942
+ * @throws {@link ProtocolVersionMismatchError} When the wallets left the transaction's epoch while it was being
943
+ * proved, or were never in it.
944
+ */
661
945
  async finalizeTransaction(tx) {
662
946
  try {
663
- const unboundTx = await this.provingService.prove(tx);
664
- const finalizedTx = unboundTx.bind();
665
- await this.pendingTransactionsService.addPendingTransaction(finalizedTx);
947
+ // Named as the prover's input rather than its output: the router hands the transaction to the prover registered
948
+ // for the version it was authored at, and what comes back is that ledger version's unbound transaction.
949
+ const unboundTx = await this.provingService.prove(this.accept(tx), tx.protocolVersion);
950
+ // Read again on the far side of the proof: the epoch is the one thing about the transaction that a crossing
951
+ // during proving can change, and this is where it is caught.
952
+ this.accept(tx);
953
+ const finalizedTx = this.seal('Finalized', unboundTx.bind(), tx.protocolVersion);
954
+ await this.pendingTransactionsService.addPendingTransaction(finalizedTx, Option.some(finalizedTx.protocolVersion));
666
955
  return finalizedTx;
667
956
  }
668
957
  catch (error) {
@@ -679,12 +968,11 @@ export class WalletFacade {
679
968
  return await this.dust.calculateFee([tx]);
680
969
  }
681
970
  /** Calculates the total fee for the given transaction plus the fee of the balancing transaction. */
682
- async estimateTransactionFee(tx, secretKey, options) {
971
+ async estimateTransactionFee(tx, options) {
683
972
  const ttl = options?.ttl ?? this.defaultTtl();
684
- return await this.dust.estimateFee(secretKey, [tx], ttl, options?.currentTime);
973
+ return await this.dust.estimateFee([tx], ttl, options?.currentTime);
685
974
  }
686
- async transferTransaction(outputs, secretKeys, options) {
687
- const { shieldedSecretKeys, dustSecretKey } = secretKeys;
975
+ async transferTransaction(outputs, options) {
688
976
  const { ttl, payFees = true } = options;
689
977
  const unshieldedOutputs = outputs
690
978
  .filter((output) => output.type === 'unshielded')
@@ -693,17 +981,16 @@ export class WalletFacade {
693
981
  if (unshieldedOutputs.length === 0 && shieldedOutputs.length === 0) {
694
982
  throw Error('At least one shielded or unshielded output is required.');
695
983
  }
696
- const shieldedTx = shieldedOutputs.length > 0
697
- ? await this.shielded.transferTransaction(shieldedSecretKeys, shieldedOutputs)
698
- : undefined;
984
+ const shieldedTx = shieldedOutputs.length > 0 ? await this.shielded.transferTransaction(shieldedOutputs) : undefined;
699
985
  const unshieldedTx = unshieldedOutputs.length > 0 ? await this.unshielded.transferTransaction(unshieldedOutputs, ttl) : undefined;
700
986
  const mergedTxs = this.mergeUnprovenTransactions(shieldedTx, unshieldedTx);
701
987
  // Add fee payment
702
- const dustResult = payFees ? await this.dust.balanceTransactions(dustSecretKey, [mergedTxs], ttl) : undefined;
988
+ const dustResult = payFees ? await this.dust.balanceTransactions([mergedTxs], ttl) : undefined;
703
989
  const feeBalancingTx = dustResult?.transaction;
704
990
  const finalTx = this.mergeUnprovenTransactions(mergedTxs, feeBalancingTx);
705
991
  return {
706
992
  type: 'UNPROVEN_TRANSACTION',
993
+ protocolVersion: this.currentVersion(),
707
994
  transaction: finalTx,
708
995
  ...(dustResult ? { blockData: dustResult.blockData } : {}),
709
996
  };
@@ -722,9 +1009,12 @@ export class WalletFacade {
722
1009
  ...utxo,
723
1010
  ctime: meta.ctime,
724
1011
  registeredForDustGeneration: meta.registeredForDustGeneration,
725
- })), (utxosWithMeta) => dustState.estimateDustGeneration(utxosWithMeta, now), (estimatedUtxos) => dustState.capabilities.coinsAndBalances.splitNightUtxos(estimatedUtxos), (split) => split.guaranteed);
726
- const fakeSigningKey = ledger.sampleSigningKey();
727
- const fakeVerifyingKey = ledger.signatureVerifyingKey(fakeSigningKey);
1012
+ })), (utxosWithMeta) => dustState.estimateDustGeneration(utxosWithMeta, now), (estimatedUtxos) => dustState.splitNightUtxos(estimatedUtxos), (split) => split.guaranteed);
1013
+ const authoring = this.authoring();
1014
+ // Type cast required because: a signing key belongs to one ledger version's runtime and the two are nominally
1015
+ // distinct, so the epoch's own primitives are the only things that may touch it; it never leaves this block.
1016
+ const fakeSigningKey = authoring.sampleSigningKey();
1017
+ const fakeVerifyingKey = authoring.signatureVerifyingKey(fakeSigningKey);
728
1018
  // Use the legacy dust-only construction path here so estimation does NOT book real UTxOs in the
729
1019
  // unshielded wallet state. (The race-fix path in createDustActionTransaction books on purpose;
730
1020
  // estimation is meant to be observation-only.)
@@ -734,22 +1024,20 @@ export class WalletFacade {
734
1024
  ctime: meta.ctime,
735
1025
  registeredForDustGeneration: meta.registeredForDustGeneration,
736
1026
  })), fakeVerifyingKey, dustState.address);
737
- const intent = fakeUnsignedTx.intents?.get(1);
1027
+ const intent = this.accept(fakeUnsignedTx).intents?.get(1);
738
1028
  if (!intent) {
739
1029
  throw Error('Dust generation transaction is missing intent segment 1.');
740
1030
  }
741
- const signatureData = intent.signatureData(1);
742
- const signature = ledger.signData(fakeSigningKey, signatureData);
1031
+ const signature = authoring.signData(fakeSigningKey, intent.signatureData(1));
743
1032
  const fakeSignedTx = await this.dust.addDustGenerationSignature(fakeUnsignedTx, signature);
744
- const finalizedFakeTx = fakeSignedTx.mockProve().bind();
1033
+ const finalizedFakeTx = this.seal('Finalized', this.accept(fakeSignedTx).mockProve().bind(), this.currentVersion());
745
1034
  const fee = await this.calculateTransactionFee(finalizedFakeTx);
746
1035
  return {
747
1036
  fee,
748
1037
  dustGenerationEstimations,
749
1038
  };
750
1039
  }
751
- async initSwap(desiredInputs, desiredOutputs, secretKeys, options) {
752
- const { shieldedSecretKeys, dustSecretKey } = secretKeys;
1040
+ async initSwap(desiredInputs, desiredOutputs, options) {
753
1041
  const { ttl, payFees = false } = options;
754
1042
  const { shielded: shieldedInputs, unshielded: unshieldedInputs } = desiredInputs;
755
1043
  const shieldedOutputs = desiredOutputs
@@ -764,7 +1052,7 @@ export class WalletFacade {
764
1052
  throw Error('At least one shielded or unshielded swap is required.');
765
1053
  }
766
1054
  const shieldedTx = hasShieldedPart && shieldedInputs !== undefined
767
- ? await this.shielded.initSwap(shieldedSecretKeys, shieldedInputs, shieldedOutputs)
1055
+ ? await this.shielded.initSwap(shieldedInputs, shieldedOutputs)
768
1056
  : undefined;
769
1057
  const unshieldedTx = hasUnshieldedPart && unshieldedInputs !== undefined
770
1058
  ? await this.unshielded.initSwap(unshieldedInputs, unshieldedOutputs, ttl)
@@ -773,11 +1061,12 @@ export class WalletFacade {
773
1061
  if (!combinedTx) {
774
1062
  throw Error('Unexpected transaction state.');
775
1063
  }
776
- const dustResult = payFees ? await this.dust.balanceTransactions(dustSecretKey, [combinedTx], ttl) : undefined;
1064
+ const dustResult = payFees ? await this.dust.balanceTransactions([combinedTx], ttl) : undefined;
777
1065
  const feeBalancingTx = dustResult?.transaction;
778
1066
  const finalTx = this.mergeUnprovenTransactions(combinedTx, feeBalancingTx);
779
1067
  return {
780
1068
  type: 'UNPROVEN_TRANSACTION',
1069
+ protocolVersion: this.currentVersion(),
781
1070
  transaction: finalTx,
782
1071
  ...(dustResult ? { blockData: dustResult.blockData } : {}),
783
1072
  };
@@ -790,6 +1079,7 @@ export class WalletFacade {
790
1079
  const dustRegistrationTx = await this.createDustActionTransaction({ type: 'registration', dustReceiverAddress: receiverAddress }, nightUtxos, nightVerifyingKey, signDustRegistration);
791
1080
  return {
792
1081
  type: 'UNPROVEN_TRANSACTION',
1082
+ protocolVersion: this.currentVersion(),
793
1083
  transaction: dustRegistrationTx,
794
1084
  };
795
1085
  }
@@ -814,37 +1104,111 @@ export class WalletFacade {
814
1104
  const dustDeregistrationTx = await this.createDustActionTransaction({ type: 'deregistration' }, nightUtxos, nightVerifyingKey, signDustRegistration);
815
1105
  return {
816
1106
  type: 'UNPROVEN_TRANSACTION',
1107
+ protocolVersion: this.currentVersion(),
817
1108
  transaction: dustDeregistrationTx,
818
1109
  };
819
1110
  }
820
- async revert(txOrRecipe) {
1111
+ async revert(txOrRecipe, reason) {
821
1112
  // avoid instanceof check
822
1113
  const transactionsToRevert = BalancingRecipe.isRecipe(txOrRecipe)
823
1114
  ? BalancingRecipe.getTransactions(txOrRecipe)
824
1115
  : [txOrRecipe];
825
- await Promise.all(transactionsToRevert.map((tx) => this.revertTransaction(tx)));
1116
+ await Promise.all(transactionsToRevert.map((tx) => this.revertTransaction(tx, reason)));
1117
+ }
1118
+ /**
1119
+ * The finalized entry recording `tx`, if sync has written one, even under the hash aggregation gave it on-chain. A
1120
+ * transaction whose stamp no epoch can read has no entry, rather than an error, so a verdict on it still resolves.
1121
+ */
1122
+ finalizedEntryFor(tx, history) {
1123
+ return Option.match(Either.getRight(WalletTransaction.unwrapWithin(tx, this.epochOf(tx.protocolVersion))), {
1124
+ onNone: () => undefined,
1125
+ onSome: (carried) => {
1126
+ const key = submitTxHistoryKey(carried);
1127
+ return history.find((entry) => isFinalizedWalletEntry(entry) && TransactionHistoryStorage.coversTransaction(entry, key));
1128
+ },
1129
+ });
1130
+ }
1131
+ /** Sync can record inclusion before the independent pending-status poller observes it. */
1132
+ async reconcileFinalizedTransaction(tx, history) {
1133
+ const entry = this.finalizedEntryFor(tx, history);
1134
+ if (entry === undefined)
1135
+ return false;
1136
+ // Included failures still need their unexecuted coin reservations released.
1137
+ if (entry.status === 'FAILURE' || entry.status === 'PARTIAL_SUCCESS') {
1138
+ await this.revertTransaction(tx);
1139
+ }
1140
+ else {
1141
+ await this.pendingTransactionsService.clear(tx);
1142
+ }
1143
+ return true;
826
1144
  }
827
- async revertTransaction(tx) {
1145
+ async revertTransaction(tx, reason) {
828
1146
  await Promise.all([
829
1147
  this.shielded.revertTransaction(tx),
830
1148
  this.unshielded.revertTransaction(tx),
831
1149
  this.dust.revertTransaction(tx),
832
1150
  ]).then(async () => {
1151
+ // Reverting is total over the stages: a transaction at any of them may have booked coins, and the pending set
1152
+ // recognises only the finalized ones. Narrowing rather than casting is what says so.
833
1153
  await this.pendingTransactionsService.clear(tx);
834
- const key = revertTxHistoryKey(tx);
1154
+ // Read at the epoch the transaction was built for, not the one the facade now acts at. A verdict on a
1155
+ // transaction submitted before a protocol boundary can only arrive after it — a chain rejection, a TTL run out,
1156
+ // or the wallet giving up on bytes that can never be included — and by then the facade has crossed. The pending
1157
+ // entry that verdict has to land on was written by this same session before the crossing, and it is the only
1158
+ // record an application has that the transaction will never be included; keying it against the current epoch
1159
+ // would leave it saying `pending` for the rest of the session. The stamp is what chooses the reader, which is
1160
+ // what the stamp is for.
1161
+ const key = Option.match(Either.getRight(WalletTransaction.unwrapWithin(tx, this.epochOf(tx.protocolVersion))), { onNone: () => undefined, onSome: revertTxHistoryKey });
835
1162
  if (key !== undefined) {
836
- await this.#txHistoryStorage.gotRejected({ ...key, rejectedAt: this.clock.now() });
1163
+ await this.#txHistoryStorage.gotRejected({
1164
+ ...key,
1165
+ rejectedAt: this.clock.now(),
1166
+ ...(reason !== undefined ? { reason } : {}),
1167
+ });
837
1168
  }
838
1169
  });
839
1170
  }
840
- async start(shieldedSecretKeys, dustSecretKey) {
1171
+ /**
1172
+ * Starts the wallets and their background synchronization.
1173
+ *
1174
+ * @remarks
1175
+ * Seeds, not key objects. A seed is the only key material that crosses a protocol boundary — every ledger version
1176
+ * derives its own keys from the same seed and arrives at the same identity — so a wallet started from seeds can
1177
+ * follow the chain across a fork, and one started from key objects of a single ledger version cannot. Derive them
1178
+ * once with `WalletSeeds.fromMasterSeed` and hand them over.
1179
+ *
1180
+ * {@link FacadeKeysByEpoch} is the escape hatch for a caller that will not part with a seed. It requires **both**
1181
+ * ledger versions' key objects, because a wallet holding one side could not read the other side of the chain, and
1182
+ * it costs the caller an import of both ledger packages and the same derivation performed twice. A seed costs
1183
+ * neither.
1184
+ * @example
1185
+ * ```typescript
1186
+ * await facade.start(WalletSeeds.fromMasterSeed(masterSeed));
1187
+ * ```;
1188
+ *
1189
+ * @param material The three wallets' seeds, or both ledger versions' key objects.
1190
+ * @param options `manualSync` leaves the dust wallet unstarted in the background; drive it explicitly with
1191
+ * {@link doSync} instead (requires a dust wallet built with the projections sync service, see
1192
+ * `makeEventLessSyncService`).
1193
+ */
1194
+ async start(material, options = {}) {
1195
+ const keys = v9KeysOf(material);
841
1196
  await Promise.all([
842
- this.shielded.start(shieldedSecretKeys),
1197
+ this.shielded.start(keys.shielded),
843
1198
  this.unshielded.start(),
844
- this.dust.start(dustSecretKey),
1199
+ !options.manualSync ? this.dust.start(keys.dust) : undefined,
845
1200
  this.pendingTransactionsService.start(),
846
1201
  ]);
847
1202
  }
1203
+ /**
1204
+ * Runs a single dust synchronization pass and resolves when it completes. Only the dust wallet supports manual sync;
1205
+ * the shielded and unshielded wallets keep syncing in the background via {@link start}.
1206
+ *
1207
+ * @param material The same material {@link start} was given.
1208
+ */
1209
+ async doSync(material) {
1210
+ await this.dust.stepSync(v9KeysOf(material).dust);
1211
+ }
848
1212
  async stop() {
849
1213
  await Promise.all([
850
1214
  this.shielded.stop(),
@@ -853,6 +1217,7 @@ export class WalletFacade {
853
1217
  this.submissionService.close(),
854
1218
  this.pendingTransactionsService.stop(),
855
1219
  Promise.resolve(this.#pendingSubscription?.unsubscribe()),
1220
+ Promise.resolve(this.#protocolVersionSubscription?.unsubscribe()),
856
1221
  ]);
857
1222
  }
858
1223
  async queryTxHistoryByHash(hash) {