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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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, WireFormatError, } 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,26 @@ 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
+ deserializers: {
118
+ Unproven: (bytes) => ledgerV9.Transaction.deserialize('signature', 'pre-proof', 'pre-binding', bytes),
119
+ Unbound: (bytes) => ledgerV9.Transaction.deserialize('signature', 'proof', 'pre-binding', bytes),
120
+ Finalized: (bytes) => ledgerV9.Transaction.deserialize('signature', 'proof', 'binding', bytes),
121
+ },
122
+ };
123
+ const v8Authoring = {
124
+ sampleSigningKey: () => ledgerV8.sampleSigningKey(),
125
+ signatureVerifyingKey: (signingKey) => Signatures.liftSignatureVerifyingKey(ledgerV8.signatureVerifyingKey(signingKey)),
126
+ signData: (signingKey, data) => Signatures.liftSignature(ledgerV8.signData(signingKey, data)),
127
+ deserializers: {
128
+ Unproven: (bytes) => ledgerV8.Transaction.deserialize('signature', 'pre-proof', 'pre-binding', bytes),
129
+ Unbound: (bytes) => ledgerV8.Transaction.deserialize('signature', 'proof', 'pre-binding', bytes),
130
+ Finalized: (bytes) => ledgerV8.Transaction.deserialize('signature', 'proof', 'binding', bytes),
131
+ },
132
+ };
102
133
  const TokenKindsToBalance = new (class {
103
134
  allTokenKinds = ['shielded', 'unshielded', 'dust'];
104
135
  toFlags = (tokenKinds) => {
@@ -132,21 +163,132 @@ export const BalancingRecipe = {
132
163
  }
133
164
  },
134
165
  };
166
+ /**
167
+ * The lowest of the protocol versions the three wallets have reached.
168
+ *
169
+ * @remarks
170
+ * The three wallets follow the same chain but not in lock-step: each recognises a protocol version change when its own
171
+ * synchronization reaches it, so around a fork they disagree for a while. A transaction spans all three, so the one
172
+ * still behind is what bounds the facade as a whole — the highest version every wallet is known to be at.
173
+ * @param versions The version each wallet has reached.
174
+ * @returns The lowest of the three.
175
+ */
176
+ export const lowestProtocolVersion = (versions) => [versions.shielded, versions.unshielded, versions.dust].reduce((lowest, candidate) => candidate < lowest ? candidate : lowest);
177
+ /**
178
+ * The ledger-v9 key objects the wallets' own `start` takes, from whichever material the caller supplied.
179
+ *
180
+ * @remarks
181
+ * Only the ledger-v9 side is needed here: a wallet built from seeds or from both versions' keys already holds what its
182
+ * V1 variant needs, retained when it was built. What `start` supplies is the side the wallet's own API speaks.
183
+ */
184
+ const v9KeysOf = (material) => 'v9' in material
185
+ ? material.v9
186
+ : {
187
+ shielded: ledgerV9.ZswapSecretKeys.fromSeed(material.shielded),
188
+ dust: ledgerV9.DustSecretKey.fromSeed(material.dust),
189
+ };
190
+ /** The three wallets in a fixed order, so {@link protocolPhaseOf} reports them the same way every time. */
191
+ const walletKinds = ['shielded', 'unshielded', 'dust'];
192
+ /**
193
+ * Reads whether the wallets are settled on one side of a protocol boundary, or still crossing it.
194
+ *
195
+ * @remarks
196
+ * Derived entirely from the version each wallet has reported and where the boundary lies — the same two facts every
197
+ * other version-routing decision in the SDK is made from, so this reading cannot disagree with them.
198
+ * @param versions The version each wallet has reached.
199
+ * @param forkVersion The version at which the chain hands over to the next ledger version.
200
+ * @returns The reading. See {@link ProtocolPhase}.
201
+ */
202
+ export const protocolPhaseOf = (versions, forkVersion) => {
203
+ const reported = walletKinds.map((kind) => [kind, versions[kind]]);
204
+ const from = lowestProtocolVersion(versions);
205
+ const epoch = ProtocolVersion.epochOf(from, forkVersion);
206
+ const crossed = reported.filter(([, version]) => !ProtocolVersion.withinRange(version, epoch));
207
+ if (crossed.length === 0)
208
+ return { _tag: 'Settled', version: from };
209
+ return {
210
+ _tag: 'Crossing',
211
+ from,
212
+ to: crossed.reduce((highest, [, version]) => (version > highest ? version : highest), crossed[0][1]),
213
+ behind: reported.filter(([, version]) => ProtocolVersion.withinRange(version, epoch)).map(([kind]) => kind),
214
+ };
215
+ };
216
+ /** Reads what has become of a transaction from the verdict the pending set holds, if it holds one. */
217
+ const pendingStatusOf = (result) => {
218
+ if (result === undefined)
219
+ return { _tag: 'Submitted' };
220
+ switch (result.status) {
221
+ case 'SUCCESS':
222
+ return { _tag: 'Confirmed', segments: result.segments };
223
+ case 'FAILURE':
224
+ case 'PARTIAL_SUCCESS':
225
+ // A transaction only some of whose segments succeeded is still not a transaction that happened as submitted.
226
+ return { _tag: 'Rejected', segments: result.segments };
227
+ case 'ORPHANED_BY_FORK':
228
+ return { _tag: 'Orphaned', authoredFor: result.authoredFor, chainNow: result.chainNow };
229
+ }
230
+ };
231
+ /**
232
+ * The pending transactions as an application reads them.
233
+ *
234
+ * @remarks
235
+ * A projection over the pending set the services keep, not a second copy of it: the facts are the same, stated as a
236
+ * list of transactions with a status each rather than as the bag the machinery works in.
237
+ * @param pending The pending set.
238
+ * @returns One entry per transaction, in the order the wallet recorded them.
239
+ */
240
+ export const pendingTransactionsOf = (pending) => pending.all.map((item) => ({
241
+ transaction: item.tx,
242
+ submittedAt: item.creationTime,
243
+ authoredFor: item.protocolVersion,
244
+ status: pendingStatusOf('result' in item ? item.result : undefined),
245
+ }));
135
246
  export class FacadeState {
136
247
  shielded;
137
248
  unshielded;
138
249
  dust;
139
250
  pending;
251
+ /** The protocol version each of the three wallets has reached. */
252
+ get protocolVersion() {
253
+ return {
254
+ shielded: this.shielded.protocolVersion,
255
+ unshielded: this.unshielded.protocolVersion,
256
+ dust: this.dust.protocolVersion,
257
+ };
258
+ }
259
+ /**
260
+ * The protocol version the facade as a whole can act at: the lowest the three wallets have reached.
261
+ *
262
+ * @remarks
263
+ * Around a protocol boundary the three wallets cross at slightly different moments, and a transaction needs all
264
+ * three. This is the version every one of them is known to understand.
265
+ */
266
+ get activeProtocolVersion() {
267
+ return lowestProtocolVersion(this.protocolVersion);
268
+ }
269
+ /**
270
+ * Whether the three wallets are settled on one side of the protocol boundary, or still crossing it.
271
+ *
272
+ * @remarks
273
+ * Additive, and the reading `protocolVersion` alone cannot give: three versions that differ tell an application
274
+ * nothing about whether the difference matters. See {@link ProtocolPhase}.
275
+ */
276
+ get protocol() {
277
+ return protocolPhaseOf(this.protocolVersion, this.#forkVersion);
278
+ }
140
279
  get isSynced() {
141
280
  return (this.shielded.state.progress.isStrictlyComplete() &&
142
281
  this.dust.state.progress.isStrictlyComplete() &&
143
282
  this.unshielded.progress.isStrictlyComplete());
144
283
  }
145
- constructor(shielded, unshielded, dust, pending) {
284
+ /** Where the chain hands over from one ledger version to the next, which is what {@link protocol} is read against. */
285
+ #forkVersion;
286
+ constructor(shielded, unshielded, dust, pending, forkVersion = ProtocolVersion.MinSupportedVersion) {
146
287
  this.shielded = shielded;
147
288
  this.unshielded = unshielded;
148
289
  this.dust = dust;
149
- this.pending = pending;
290
+ this.pending = pendingTransactionsOf(pending);
291
+ this.#forkVersion = forkVersion;
150
292
  }
151
293
  }
152
294
  const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -161,29 +303,65 @@ const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
161
303
  * without a circular dependency.
162
304
  */
163
305
  export { Clock };
306
+ /**
307
+ * The fork schedule the facade presets when a configuration names none: {@link ProtocolVersion.V9NativeForkSchedule},
308
+ * ledger-v9 from the version a 2.x node reports.
309
+ *
310
+ * @remarks
311
+ * Where a chain forks is a fact about the chain, which is why each wallet package requires `forks` and presets nothing.
312
+ * The facade is the one place a preset decides nothing the SDK has not already decided: every chain the 2.x node line
313
+ * runs hands over at this version, so an application copying the constant into its configuration only restates it. A
314
+ * chain that hands over elsewhere states its own `forks`, which wins.
315
+ */
316
+ export const DefaultForkSchedule = ProtocolVersion.V9NativeForkSchedule;
164
317
  // `BlockData` is not re-exported from the facade to avoid a name collision with the
165
318
  // `@midnight-ntwrk/wallet-sdk-dust-wallet` export. The two are structurally identical; users can name the type via
166
319
  // `@midnight-ntwrk/wallet-sdk-dust-wallet` or `@midnight-ntwrk/wallet-sdk-capabilities/validation`.
167
320
  export { ValidationFetchError, WellFormedError, };
168
321
  export class WalletFacade {
169
322
  static makeDefaultSubmissionService(config) {
323
+ // A handle serializes itself, which is all submission needs of a transaction — and the one thing every ledger
324
+ // version's transaction does identically.
170
325
  return makeDefaultSubmissionService(config);
171
326
  }
172
327
  static makeDefaultPendingTransactionsService(config) {
173
328
  return PendingTransactionsServiceImpl.init({
174
329
  configuration: config,
175
- txTrait: finalizedTransactionTrait,
330
+ txTraits: finalizedTransactionTraits(config.forks.v9),
176
331
  });
177
332
  }
333
+ /**
334
+ * Builds the proving service a configuration describes.
335
+ *
336
+ * @remarks
337
+ * Handed the same fork schedule the wallets are built with, because a proving backend is chosen by the epoch a
338
+ * transaction belongs to and the two ends of that question must not be able to compute the boundary differently.
339
+ */
178
340
  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
- }
341
+ return Either.getOrThrowWith(makeDefaultVersionedProvingService(config, config.forks), (error) => new Error(error.message));
342
+ }
343
+ /**
344
+ * Fills in what the facade presets — `forks`, as {@link DefaultForkSchedule} — so that the result is a configuration a
345
+ * wallet package can be built from.
346
+ *
347
+ * @remarks
348
+ * {@link WalletFacade.init} does this itself, once and before anything is built, so the wallets, the default services
349
+ * and the facade's own boundary cannot be given different schedules, and no factory needs to call it. It is public
350
+ * for code outside a factory that needs the configuration the facade will use: an application building a wallet
351
+ * package directly, or reading `forks` back to author a transaction for the right ledger version. Resolving twice
352
+ * is the same as resolving once, so `init` may be handed the result.
353
+ * @example
354
+ * ```typescript
355
+ * const configuration = WalletFacade.resolveConfiguration({ networkId: 'undeployed', ... });
356
+ * const facade = await WalletFacade.init({ configuration, shielded: (config) => ShieldedWallet(config), ... });
357
+ * const authoredForV9 = protocolVersion >= configuration.forks.v9;
358
+ * ```;
359
+ *
360
+ * @param configuration The configuration as given, with or without `forks`.
361
+ * @returns The same configuration with `forks` present: the schedule it named, or {@link DefaultForkSchedule}.
362
+ */
363
+ static resolveConfiguration(configuration) {
364
+ return { ...configuration, forks: configuration.forks ?? DefaultForkSchedule };
187
365
  }
188
366
  /**
189
367
  * Fetches the current Terms and Conditions from the network indexer.
@@ -215,7 +393,9 @@ export class WalletFacade {
215
393
  * and initialization of necessary components. Specifically - it requires following fields:
216
394
  *
217
395
  * - `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
396
+ * convey use-case-specific settings in the same way, as the SDK works by default. `forks` may be left out; every
397
+ * factory below is handed a {@link ResolvedConfiguration}, which always has it — what
398
+ * {@link WalletFacade.resolveConfiguration} returns
219
399
  * - `shielded` - a function taking the configuration and returning shielded wallet (or a promise with such)
220
400
  * implementing {@link ShieldedWalletAPI}
221
401
  * - `unshielded` - a function taking the configuration and returning unshielded wallet (or a promise with such)
@@ -224,38 +404,38 @@ export class WalletFacade {
224
404
  * {@link DustWalletAPI} There are some optional services/abstractions to provide, too. If not provided - default
225
405
  * implementations will be used, each of them is initialized by a function taking the configuration and returning
226
406
  * proper implementation (wrapped in a {@link Promise} or not).
227
- * - `submissionService` - needs to implement {@link SubmissionService} for a {@link ledger.FinalizedTransaction} to
407
+ * - `submissionService` - needs to implement {@link SubmissionService} for a {@link ledgerV9.FinalizedTransaction} to
228
408
  * submit transactions to the network, default uses Node RPC connection
229
409
  * - `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
410
+ * {@link ledgerV9.FinalizedTransaction} to keep track of pending transactions, default uses in-memory
411
+ * implementation
412
+ * - `provingService` - needs to implement {@link VersionedProvingService} to prove it, default uses proving server
232
413
  * - `clock` - needs to implement {@link Clock.Clock} for getting current time, default uses system clock
233
414
  */
234
415
  static async init(initParams) {
416
+ const configuration = WalletFacade.resolveConfiguration(initParams.configuration);
235
417
  const submissionService = await Promise.resolve(initParams.submissionService
236
- ? initParams.submissionService(initParams.configuration)
237
- : WalletFacade.makeDefaultSubmissionService(initParams.configuration));
418
+ ? initParams.submissionService(configuration)
419
+ : WalletFacade.makeDefaultSubmissionService(configuration));
238
420
  const pendingTransactionsService = await Promise.resolve(initParams.pendingTransactionsService
239
- ? initParams.pendingTransactionsService(initParams.configuration)
240
- : WalletFacade.makeDefaultPendingTransactionsService(initParams.configuration));
421
+ ? initParams.pendingTransactionsService(configuration)
422
+ : WalletFacade.makeDefaultPendingTransactionsService(configuration));
241
423
  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));
424
+ ? initParams.provingService(configuration)
425
+ : WalletFacade.makeDefaultProvingService(configuration));
426
+ const shielded = await Promise.resolve(initParams.shielded(configuration));
427
+ const unshielded = await Promise.resolve(initParams.unshielded(configuration));
428
+ const dust = await Promise.resolve(initParams.dust(configuration));
429
+ const clock = await Promise.resolve(initParams.clock ? initParams.clock(configuration) : Clock.systemClock);
430
+ const fetchBlockData = await Promise.resolve(initParams.fetchBlockData ? initParams.fetchBlockData(configuration) : makeDefaultBlockDataFetcher(configuration));
251
431
  const validationService = await Promise.resolve(initParams.validationService
252
- ? initParams.validationService(initParams.configuration, { fetchBlockData, clock })
253
- : makeDefaultValidationService({
432
+ ? initParams.validationService(configuration, { fetchBlockData, clock })
433
+ : makeDefaultVersionedValidationService({
254
434
  fetchBlockData,
255
- networkId: initParams.configuration.networkId,
435
+ networkId: configuration.networkId,
256
436
  clock,
257
- }));
258
- return new WalletFacade(shielded, unshielded, dust, submissionService, pendingTransactionsService, provingService, validationService, initParams.configuration.txHistoryStorage, clock);
437
+ }, configuration.forks.v9));
438
+ return new WalletFacade(shielded, unshielded, dust, submissionService, pendingTransactionsService, provingService, validationService, configuration.txHistoryStorage, configuration.forks.v9, clock);
259
439
  }
260
440
  shielded;
261
441
  unshielded;
@@ -267,13 +447,24 @@ export class WalletFacade {
267
447
  #txHistoryStorage;
268
448
  clock;
269
449
  #pendingSubscription;
450
+ #protocolVersionSubscription;
451
+ /**
452
+ * The protocol version the wallets have all reached, as last observed.
453
+ *
454
+ * @remarks
455
+ * `Option.none()` until the three wallets have each emitted once. A transaction stamped with `none` is never
456
+ * orphaned, so an unobserved version costs a transaction nothing but the ability to be given up on early.
457
+ */
458
+ #observedProtocolVersion = new BehaviorSubject(Option.none());
459
+ /** Where the chain hands over from one ledger version to the next, which is what divides the two epochs. */
460
+ #forkVersion;
270
461
  /**
271
462
  * Constructor is private on purpose - much of initialization of the facade is potentially asynchronous, and adding
272
463
  * new parameters is a breaking change to the users Use {@link WalletFacade.init} instead
273
464
  *
274
465
  * @private
275
466
  */
276
- constructor(shieldedWallet, unshieldedWallet, dustWallet, submissionService, pendingTransactionsService, provingService, validationService, txHistoryStorage, clock = Clock.systemClock) {
467
+ constructor(shieldedWallet, unshieldedWallet, dustWallet, submissionService, pendingTransactionsService, provingService, validationService, txHistoryStorage, forkVersion, clock = Clock.systemClock) {
277
468
  this.shielded = shieldedWallet;
278
469
  this.unshielded = unshieldedWallet;
279
470
  this.dust = dustWallet;
@@ -282,12 +473,126 @@ export class WalletFacade {
282
473
  this.provingService = provingService;
283
474
  this.validationService = validationService;
284
475
  this.#txHistoryStorage = txHistoryStorage;
476
+ this.#forkVersion = forkVersion;
285
477
  this.clock = clock;
286
478
  this.#pendingSubscription = this.pendingTransactionsService
287
479
  .state()
288
- .pipe(concatMap((pending) => PendingTransactions.allFailed(pending)), concatMap((item) => this.revert(item.tx)))
480
+ .pipe(concatMap((pending) => PendingTransactions.allRejected(pending)), concatMap(async (item) => {
481
+ if (!(await this.reconcileFinalizedTransaction(item.tx, await this.#txHistoryStorage.getAll()))) {
482
+ await this.revert(item.tx, rejectionReason(item.result));
483
+ }
484
+ }))
485
+ .subscribe();
486
+ // Deliberately built from the wallets' own states rather than from `state()`: `state()` includes the pending set,
487
+ // and orphaning writes to it, so feeding that back here would be a cycle.
488
+ this.#protocolVersionSubscription = combineLatest([this.shielded.state, this.unshielded.state, this.dust.state])
489
+ .pipe(map(([shieldedState, unshieldedState, dustState]) => lowestProtocolVersion({
490
+ shielded: shieldedState.protocolVersion,
491
+ unshielded: unshieldedState.protocolVersion,
492
+ dust: dustState.protocolVersion,
493
+ })), distinctUntilChanged(), tap((version) => this.#observedProtocolVersion.next(Option.some(version))),
494
+ // Nothing fallible belongs here: this chain is the only writer of the observed version, and an orphaned
495
+ // transaction sync already saw included is reconciled by the pending subscription like any other verdict.
496
+ concatMap((version) => this.pendingTransactionsService.orphanBeyond(version)))
289
497
  .subscribe();
290
498
  }
499
+ /**
500
+ * The protocol version the facade is currently acting at.
501
+ *
502
+ * @remarks
503
+ * The lowest the three wallets have reached, once they have all reported; the minimum supported version until then,
504
+ * which is the epoch a wallet with no history belongs to. It is what a transaction the facade builds is stamped
505
+ * with, and what the side of the boundary it accepts transactions from is measured against.
506
+ */
507
+ currentVersion() {
508
+ return Option.getOrElse(this.#observedProtocolVersion.getValue(), () => ProtocolVersion.MinSupportedVersion);
509
+ }
510
+ /** The range of protocol versions on the same side of the boundary as a given one. */
511
+ epochOf(version) {
512
+ return ProtocolVersion.epochOf(version, this.#forkVersion);
513
+ }
514
+ /** The range of protocol versions on the facade's current side of the boundary. */
515
+ currentEpoch() {
516
+ return this.epochOf(this.currentVersion());
517
+ }
518
+ /**
519
+ * Reads a transaction an application handed in, refusing one built on the other side of the boundary.
520
+ *
521
+ * @remarks
522
+ * The enforcement point for everything that enters the facade — a transaction the wallets built, or one an
523
+ * application authored and sealed with `WalletTransaction.adopt`. Only the current epoch is accepted, because a
524
+ * transaction of the other one cannot be merged, proved or submitted alongside anything the facade would build for
525
+ * it. Both a stranded ledger-v8 transaction after the crossing and a ledger-v9 transaction offered before it are
526
+ * refused here, by name, with the versions written down.
527
+ * @param handle The handle to read.
528
+ * @returns The carried transaction.
529
+ * @throws {@link ProtocolVersionMismatchError} When the transaction was built on the other side of the boundary.
530
+ */
531
+ accept(handle) {
532
+ return Either.getOrThrowWith(WalletTransaction.unwrapWithin(handle, this.currentEpoch()), (error) => error);
533
+ }
534
+ /**
535
+ * Seals a transaction the facade or a wallet produced, at the version its bytes were fixed by.
536
+ *
537
+ * @remarks
538
+ * The version is the caller's to state, never read from the facade here, because the two can differ: a crossing can
539
+ * land while a transaction is at the prover, and what comes back is still the bytes of the ledger version that made
540
+ * them. A caller sealing something it has just built passes the version it is building at; a caller sealing
541
+ * something that has travelled through an await passes the version stamped on the handle it started from.
542
+ * @param stage How far along the building of the transaction the sealed handle is.
543
+ * @param transaction The transaction to seal.
544
+ * @param protocolVersion The version the transaction's bytes were fixed by.
545
+ * @returns The sealed handle.
546
+ */
547
+ seal(stage, transaction, protocolVersion) {
548
+ return WalletTransaction.adopt(stage, transaction, protocolVersion);
549
+ }
550
+ /** The ledger primitives the facade signs with on its current side of the boundary. */
551
+ authoring() {
552
+ return this.currentVersion() < this.#forkVersion ? v8Authoring : v9Authoring;
553
+ }
554
+ /**
555
+ * Reads a transaction from bytes that name no protocol version, at the version the facade is acting at.
556
+ *
557
+ * @remarks
558
+ * For bytes that reach the wallet from outside the SDK — a dApp connector's serialized transaction above all, whose
559
+ * contract carries no version alongside them. The bytes cannot say which ledger version wrote them, so this answers
560
+ * with the only version the facade could act at anyway, and reads them with that version's ledger. Bytes the other
561
+ * ledger version wrote are refused here rather than misread, because the markers a stage names are part of what the
562
+ * ledger checks as it reads.
563
+ *
564
+ * The version is the facade's rather than the caller's, which is the point: a caller that reads
565
+ * {@link FacadeState.activeProtocolVersion} and seals a handle itself is answering the same question a moment
566
+ * earlier, with a crossing able to land in between. It is still only the version at the moment of the call — a
567
+ * crossing can land before the handle is used, and {@link ProtocolVersionMismatchError} from the operation that uses
568
+ * it is what says so.
569
+ * @example
570
+ * Balancing a transaction a dApp connector handed over as hex:
571
+ *
572
+ * ```typescript
573
+ * const handle = facade.adoptTransaction(Buffer.from(txHex, 'hex'), 'Unbound');
574
+ * const recipe = await facade.balanceUnboundTransaction(handle, { ttl });
575
+ * ```;
576
+ *
577
+ * @param bytes The transaction's own serialization, as the ledger version that wrote it produced it.
578
+ * @param stage How far along the building of the transaction these bytes are, which decides the markers they are read
579
+ * with.
580
+ * @returns The sealed handle, stamped with the protocol version the facade is acting at.
581
+ * @throws {@link WireFormatError} When the bytes are not a transaction of that version at that stage — which is what
582
+ * a transaction authored on the other side of a protocol boundary looks like from here.
583
+ */
584
+ adoptTransaction(bytes, stage) {
585
+ const protocolVersion = this.currentVersion();
586
+ return Either.getOrThrowWith(Either.try({
587
+ try: () => this.authoring().deserializers[stage](bytes),
588
+ catch: (cause) => new WireFormatError({
589
+ message: `These bytes are not a transaction of protocol version ${protocolVersion} at stage ${stage} — that is ` +
590
+ `the version this wallet is acting at, and a transaction built for another one has to be built again ` +
591
+ `for it.`,
592
+ cause,
593
+ }),
594
+ }).pipe(Either.map((transaction) => WalletTransaction.adopt(stage, transaction, protocolVersion))), (error) => error);
595
+ }
291
596
  defaultTtl() {
292
597
  return new Date(this.clock.now().getTime() + DEFAULT_TTL_MS);
293
598
  }
@@ -337,11 +642,21 @@ export class WalletFacade {
337
642
  * @throws {@link ValidationFetchError} If the block-data fetch fails.
338
643
  */
339
644
  async validateTransaction(tx, options) {
340
- return this.validationService.validateTx(tx, options);
645
+ // Checked at the version the transaction says it was authored for, and never at the version the chain has since
646
+ // reached: well-formedness asks whether the ledger that produced these bytes would accept them, and a fork landing
647
+ // afterwards cannot change that answer. The transaction is read at its own epoch for the same reason.
648
+ return this.validationService.validateTx(Either.getOrThrowWith(WalletTransaction.unwrapWithin(tx, ProtocolVersion.epochOf(tx.protocolVersion, this.#forkVersion)), (error) => error), tx.protocolVersion, options);
341
649
  }
650
+ /**
651
+ * Merges two unproven transactions of the same epoch, or returns whichever there is.
652
+ *
653
+ * @remarks
654
+ * Both are unwrapped before merging, which is where a pair from different epochs is refused: a merge across the
655
+ * boundary is not a failure to compute, it is unrepresentable, and saying so is the whole point of the stamp.
656
+ */
342
657
  mergeUnprovenTransactions(a, b) {
343
658
  if (a && b)
344
- return a.merge(b);
659
+ return this.seal('Unproven', this.accept(a).merge(this.accept(b)), this.currentVersion());
345
660
  return a ?? b;
346
661
  }
347
662
  async createDustActionTransaction(action, nightUtxos, nightVerifyingKey, signDustRegistration) {
@@ -390,6 +705,11 @@ export class WalletFacade {
390
705
  // on-chain with BalanceCheckOverspend. Skip for re-registration (all guaranteed UTxOs already
391
706
  // registered) since `feePayment` is 0 by design and the caller is expected to balance the fee
392
707
  // externally via `balanceUnprovenTransaction({ tokenKindsToBalance: ['dust'] })`.
708
+ //
709
+ // `registeredForDustGeneration` is the indexer's answer as of the chain's current dust epoch, so it is the one
710
+ // authority on which of the two a registration is. Night carried across the v8 -> v9 fork arrives with it set to
711
+ // `false` — the fork wipes dust generation state and the unshielded crossing carries the flag accordingly — so a
712
+ // re-registration on ledger-v9 is correctly treated as first-time.
393
713
  const hasUnregisteredGuaranteed = split.guaranteedUtxos.some((u) => !u.utxo.registeredForDustGeneration);
394
714
  if (isRegistration && hasUnregisteredGuaranteed) {
395
715
  const fee = await this.dust.calculateFee([txWithDustActions]);
@@ -402,7 +722,7 @@ export class WalletFacade {
402
722
  // Step 5 — Sign via the standard signRecipe pathway, which now stamps both the unshielded
403
723
  // offers and the dust registration. Signing failures also need to release the booking.
404
724
  try {
405
- const signedRecipe = await this.signRecipe({ type: 'UNPROVEN_TRANSACTION', transaction: txWithDustActions }, signDustRegistration);
725
+ const signedRecipe = await this.signRecipe({ type: 'UNPROVEN_TRANSACTION', protocolVersion: this.currentVersion(), transaction: txWithDustActions }, signDustRegistration);
406
726
  if (signedRecipe.type !== 'UNPROVEN_TRANSACTION') {
407
727
  throw Error('signRecipe returned unexpected recipe type for dust action transaction.');
408
728
  }
@@ -419,7 +739,7 @@ export class WalletFacade {
419
739
  this.unshielded.state,
420
740
  this.dust.state,
421
741
  this.pendingTransactionsService.state(),
422
- ]).pipe(map(([shieldedState, unshieldedState, dustState, pending]) => new FacadeState(shieldedState, unshieldedState, dustState, pending)));
742
+ ]).pipe(map(([shieldedState, unshieldedState, dustState, pending]) => new FacadeState(shieldedState, unshieldedState, dustState, pending, this.#forkVersion)));
423
743
  }
424
744
  async waitForSyncedState() {
425
745
  const [shieldedState, unshieldedState, dustState, pending] = await Promise.all([
@@ -428,7 +748,7 @@ export class WalletFacade {
428
748
  this.dust.waitForSyncedState(),
429
749
  firstValueFrom(this.pendingTransactionsService.state()),
430
750
  ]);
431
- return new FacadeState(shieldedState, unshieldedState, dustState, pending);
751
+ return new FacadeState(shieldedState, unshieldedState, dustState, pending, this.#forkVersion);
432
752
  }
433
753
  /**
434
754
  * Submits a finalized transaction to the network and tracks it as pending until finalized or discarded.
@@ -441,12 +761,13 @@ export class WalletFacade {
441
761
  * @throws {@link WellFormedError} — call {@link validateTransaction} first to get this error early.
442
762
  */
443
763
  async submitTransaction(tx) {
444
- const identifiers = tx.identifiers();
764
+ const carried = this.accept(tx);
765
+ const identifiers = carried.identifiers();
445
766
  try {
446
- await this.pendingTransactionsService.addPendingTransaction(tx);
767
+ await this.pendingTransactionsService.addPendingTransaction(tx, this.#observedProtocolVersion.getValue());
447
768
  // Insert before awaiting submission so the entry exists while the tx is in flight — the per-wallet sync
448
769
  // handlers' gotFinalized call clears the pending entry on confirmation.
449
- const key = submitTxHistoryKey(tx);
770
+ const key = submitTxHistoryKey(carried);
450
771
  await this.#txHistoryStorage.gotPending({ ...key, submittedAt: this.clock.now() });
451
772
  await this.submissionService.submitTransaction(tx, 'Finalized');
452
773
  return identifiers.at(-1);
@@ -468,22 +789,19 @@ export class WalletFacade {
468
789
  * @param options - TTL for the balancing transaction, and optional subset of token kinds to balance.
469
790
  * @returns A {@link FinalizedTransactionRecipe} containing the original and balancing transactions.
470
791
  */
471
- async balanceFinalizedTransaction(tx, secretKeys, options) {
472
- const { shieldedSecretKeys, dustSecretKey } = secretKeys;
792
+ async balanceFinalizedTransaction(tx, options) {
473
793
  const { ttl, tokenKindsToBalance = 'all' } = options;
474
794
  const { shouldBalanceDust, shouldBalanceShielded, shouldBalanceUnshielded } = TokenKindsToBalance.toFlags(tokenKindsToBalance);
475
795
  // Step 1: Run unshielded and shielded balancing
476
796
  const unshieldedBalancingTx = shouldBalanceUnshielded
477
797
  ? await this.unshielded.balanceFinalizedTransaction(tx)
478
798
  : undefined;
479
- const shieldedBalancingTx = shouldBalanceShielded
480
- ? await this.shielded.balanceTransaction(shieldedSecretKeys, tx)
481
- : undefined;
799
+ const shieldedBalancingTx = shouldBalanceShielded ? await this.shielded.balanceTransaction(tx) : undefined;
482
800
  // Step 2: Merge unshielded and shielded balancing
483
801
  const mergedBalancingTx = this.mergeUnprovenTransactions(shieldedBalancingTx, unshieldedBalancingTx);
484
802
  // Step 3: Conditionally add dust/fee balancing
485
803
  const dustResult = shouldBalanceDust
486
- ? await this.dust.balanceTransactions(dustSecretKey, mergedBalancingTx ? [tx, mergedBalancingTx] : [tx], ttl)
804
+ ? await this.dust.balanceTransactions(mergedBalancingTx ? [tx, mergedBalancingTx] : [tx], ttl)
487
805
  : undefined;
488
806
  const feeBalancingTx = dustResult?.transaction;
489
807
  // Step 4: Merge fee balancing and create final recipe
@@ -493,6 +811,7 @@ export class WalletFacade {
493
811
  }
494
812
  return {
495
813
  type: 'FINALIZED_TRANSACTION',
814
+ protocolVersion: this.currentVersion(),
496
815
  originalTransaction: tx,
497
816
  balancingTransaction: balancingTx,
498
817
  ...(dustResult ? { blockData: dustResult.blockData } : {}),
@@ -510,14 +829,11 @@ export class WalletFacade {
510
829
  * @param options - TTL for the balancing transaction, and optional subset of token kinds to balance.
511
830
  * @returns An {@link UnboundTransactionRecipe} containing the base and optional balancing transactions.
512
831
  */
513
- async balanceUnboundTransaction(tx, secretKeys, options) {
514
- const { shieldedSecretKeys, dustSecretKey } = secretKeys;
832
+ async balanceUnboundTransaction(tx, options) {
515
833
  const { ttl, tokenKindsToBalance = 'all' } = options;
516
834
  const { shouldBalanceDust, shouldBalanceShielded, shouldBalanceUnshielded } = TokenKindsToBalance.toFlags(tokenKindsToBalance);
517
835
  // Step 1: Run unshielded and shielded balancing
518
- const shieldedBalancingTx = shouldBalanceShielded
519
- ? await this.shielded.balanceTransaction(shieldedSecretKeys, tx)
520
- : undefined;
836
+ const shieldedBalancingTx = shouldBalanceShielded ? await this.shielded.balanceTransaction(tx) : undefined;
521
837
  // For unbound transactions, unshielded balancing happens in place not with a balancing transaction
522
838
  const balancedUnshieldedTx = shouldBalanceUnshielded
523
839
  ? await this.unshielded.balanceUnboundTransaction(tx)
@@ -526,7 +842,7 @@ export class WalletFacade {
526
842
  const baseTx = balancedUnshieldedTx ?? tx;
527
843
  // Step 3: Conditionally add dust/fee balancing
528
844
  const dustResult = shouldBalanceDust
529
- ? await this.dust.balanceTransactions(dustSecretKey, shieldedBalancingTx ? [baseTx, shieldedBalancingTx] : [baseTx], ttl)
845
+ ? await this.dust.balanceTransactions(shieldedBalancingTx ? [baseTx, shieldedBalancingTx] : [baseTx], ttl)
530
846
  : undefined;
531
847
  const feeBalancingTransaction = dustResult?.transaction;
532
848
  // Step 4: Create the final balancing transaction
@@ -537,6 +853,7 @@ export class WalletFacade {
537
853
  }
538
854
  return {
539
855
  type: 'UNBOUND_TRANSACTION',
856
+ protocolVersion: this.currentVersion(),
540
857
  baseTransaction: baseTx,
541
858
  balancingTransaction: balancingTransaction ?? undefined,
542
859
  ...(dustResult ? { blockData: dustResult.blockData } : {}),
@@ -554,14 +871,11 @@ export class WalletFacade {
554
871
  * @param options - TTL for the balancing transaction, and optional subset of token kinds to balance.
555
872
  * @returns An {@link UnprovenTransactionRecipe} containing the balanced transaction.
556
873
  */
557
- async balanceUnprovenTransaction(tx, secretKeys, options) {
558
- const { shieldedSecretKeys, dustSecretKey } = secretKeys;
874
+ async balanceUnprovenTransaction(tx, options) {
559
875
  const { ttl, tokenKindsToBalance = 'all' } = options;
560
876
  const { shouldBalanceDust, shouldBalanceShielded, shouldBalanceUnshielded } = TokenKindsToBalance.toFlags(tokenKindsToBalance);
561
877
  // Step 1: Run unshielded and shielded balancing
562
- const shieldedBalancingTx = shouldBalanceShielded
563
- ? await this.shielded.balanceTransaction(shieldedSecretKeys, tx)
564
- : undefined;
878
+ const shieldedBalancingTx = shouldBalanceShielded ? await this.shielded.balanceTransaction(tx) : undefined;
565
879
  // For unproven transactions, unshielded balancing happens in place
566
880
  const balancedUnshieldedTx = shouldBalanceUnshielded
567
881
  ? await this.unshielded.balanceUnprovenTransaction(tx)
@@ -571,14 +885,13 @@ export class WalletFacade {
571
885
  // Step 3: Merge shielded balancing into base tx if present
572
886
  const mergedTx = this.mergeUnprovenTransactions(baseTx, shieldedBalancingTx);
573
887
  // Step 4: Conditionally add dust/fee balancing
574
- const dustResult = shouldBalanceDust
575
- ? await this.dust.balanceTransactions(dustSecretKey, [mergedTx], ttl)
576
- : undefined;
888
+ const dustResult = shouldBalanceDust ? await this.dust.balanceTransactions([mergedTx], ttl) : undefined;
577
889
  const feeBalancingTx = dustResult?.transaction;
578
890
  // Step 5: Merge fee balancing if present
579
891
  const balancedTx = this.mergeUnprovenTransactions(mergedTx, feeBalancingTx);
580
892
  return {
581
893
  type: 'UNPROVEN_TRANSACTION',
894
+ protocolVersion: this.currentVersion(),
582
895
  transaction: balancedTx,
583
896
  ...(dustResult ? { blockData: dustResult.blockData } : {}),
584
897
  };
@@ -589,14 +902,16 @@ export class WalletFacade {
589
902
  switch (recipe.type) {
590
903
  case 'FINALIZED_TRANSACTION': {
591
904
  const finalizedBalancing = await this.finalizeTransaction(recipe.balancingTransaction);
592
- return recipe.originalTransaction.merge(finalizedBalancing);
905
+ return this.seal('Finalized', this.accept(recipe.originalTransaction).merge(this.accept(finalizedBalancing)), recipe.originalTransaction.protocolVersion);
593
906
  }
594
907
  case 'UNBOUND_TRANSACTION': {
595
908
  const finalizedBalancingTx = recipe.balancingTransaction
596
909
  ? await this.finalizeTransaction(recipe.balancingTransaction)
597
910
  : undefined;
598
- const finalizedTransaction = recipe.baseTransaction.bind();
599
- return finalizedBalancingTx ? finalizedTransaction.merge(finalizedBalancingTx) : finalizedTransaction;
911
+ const finalizedTransaction = this.accept(recipe.baseTransaction).bind();
912
+ return this.seal('Finalized', finalizedBalancingTx
913
+ ? finalizedTransaction.merge(this.accept(finalizedBalancingTx))
914
+ : finalizedTransaction, recipe.baseTransaction.protocolVersion);
600
915
  }
601
916
  case 'UNPROVEN_TRANSACTION': {
602
917
  return await this.finalizeTransaction(recipe.transaction);
@@ -604,7 +919,7 @@ export class WalletFacade {
604
919
  }
605
920
  })
606
921
  .then(async (finalizedTx) => {
607
- await this.pendingTransactionsService.addPendingTransaction(finalizedTx);
922
+ await this.pendingTransactionsService.addPendingTransaction(finalizedTx, Option.some(finalizedTx.protocolVersion));
608
923
  return finalizedTx;
609
924
  });
610
925
  }
@@ -615,6 +930,7 @@ export class WalletFacade {
615
930
  const withDustSig = await this.#signDustRegistrationIfPresent(signedBalancingTx, signSegment);
616
931
  return {
617
932
  type: 'FINALIZED_TRANSACTION',
933
+ protocolVersion: recipe.protocolVersion,
618
934
  originalTransaction: recipe.originalTransaction,
619
935
  balancingTransaction: withDustSig,
620
936
  ...(recipe.blockData ? { blockData: recipe.blockData } : {}),
@@ -627,6 +943,7 @@ export class WalletFacade {
627
943
  const signedBaseTx = await this.signUnboundTransaction(recipe.baseTransaction, signSegment);
628
944
  return {
629
945
  type: 'UNBOUND_TRANSACTION',
946
+ protocolVersion: recipe.protocolVersion,
630
947
  baseTransaction: signedBaseTx,
631
948
  balancingTransaction: signedBalancingTx,
632
949
  ...(recipe.blockData ? { blockData: recipe.blockData } : {}),
@@ -637,6 +954,7 @@ export class WalletFacade {
637
954
  const withDustSig = await this.#signDustRegistrationIfPresent(signedTx, signSegment);
638
955
  return {
639
956
  type: 'UNPROVEN_TRANSACTION',
957
+ protocolVersion: recipe.protocolVersion,
640
958
  transaction: withDustSig,
641
959
  ...(recipe.blockData ? { blockData: recipe.blockData } : {}),
642
960
  };
@@ -644,7 +962,7 @@ export class WalletFacade {
644
962
  }
645
963
  }
646
964
  async #signDustRegistrationIfPresent(tx, signSegment) {
647
- const intent = tx.intents?.get(1);
965
+ const intent = this.accept(tx).intents?.get(1);
648
966
  const registrations = intent?.dustActions?.registrations ?? [];
649
967
  if (!intent || registrations.length === 0) {
650
968
  return tx;
@@ -658,11 +976,34 @@ export class WalletFacade {
658
976
  async signUnboundTransaction(tx, signSegment) {
659
977
  return await this.unshielded.signUnboundTransaction(tx, signSegment);
660
978
  }
979
+ /**
980
+ * Proves and binds an unproven transaction, and records it as pending.
981
+ *
982
+ * @remarks
983
+ * Proved at the version stamped on the transaction itself, which is the version that fixed its bytes, and sealed at
984
+ * that same version: proving is a long await, so the wallets can move under it, and neither the prover a
985
+ * transaction is routed to nor the stamp it comes back with may be decided by where they have moved to.
986
+ *
987
+ * Where they have moved to decides one thing only, and it is decided again once the proof is back: a crossing while
988
+ * the transaction was in flight leaves it belonging to an epoch the wallets have left, and no chain they are on can
989
+ * include it. Such a transaction is refused rather than sealed, and nothing is recorded as pending for it — a
990
+ * pending entry would only wait out a TTL for an inclusion that cannot happen — while the reservations it made in
991
+ * the three wallets are given back like those of any other failure here.
992
+ * @param tx The unproven transaction.
993
+ * @returns The finalized transaction, stamped with the version it was authored at.
994
+ * @throws {@link ProtocolVersionMismatchError} When the wallets left the transaction's epoch while it was being
995
+ * proved, or were never in it.
996
+ */
661
997
  async finalizeTransaction(tx) {
662
998
  try {
663
- const unboundTx = await this.provingService.prove(tx);
664
- const finalizedTx = unboundTx.bind();
665
- await this.pendingTransactionsService.addPendingTransaction(finalizedTx);
999
+ // Named as the prover's input rather than its output: the router hands the transaction to the prover registered
1000
+ // for the version it was authored at, and what comes back is that ledger version's unbound transaction.
1001
+ const unboundTx = await this.provingService.prove(this.accept(tx), tx.protocolVersion);
1002
+ // Read again on the far side of the proof: the epoch is the one thing about the transaction that a crossing
1003
+ // during proving can change, and this is where it is caught.
1004
+ this.accept(tx);
1005
+ const finalizedTx = this.seal('Finalized', unboundTx.bind(), tx.protocolVersion);
1006
+ await this.pendingTransactionsService.addPendingTransaction(finalizedTx, Option.some(finalizedTx.protocolVersion));
666
1007
  return finalizedTx;
667
1008
  }
668
1009
  catch (error) {
@@ -679,12 +1020,11 @@ export class WalletFacade {
679
1020
  return await this.dust.calculateFee([tx]);
680
1021
  }
681
1022
  /** Calculates the total fee for the given transaction plus the fee of the balancing transaction. */
682
- async estimateTransactionFee(tx, secretKey, options) {
1023
+ async estimateTransactionFee(tx, options) {
683
1024
  const ttl = options?.ttl ?? this.defaultTtl();
684
- return await this.dust.estimateFee(secretKey, [tx], ttl, options?.currentTime);
1025
+ return await this.dust.estimateFee([tx], ttl, options?.currentTime);
685
1026
  }
686
- async transferTransaction(outputs, secretKeys, options) {
687
- const { shieldedSecretKeys, dustSecretKey } = secretKeys;
1027
+ async transferTransaction(outputs, options) {
688
1028
  const { ttl, payFees = true } = options;
689
1029
  const unshieldedOutputs = outputs
690
1030
  .filter((output) => output.type === 'unshielded')
@@ -693,17 +1033,16 @@ export class WalletFacade {
693
1033
  if (unshieldedOutputs.length === 0 && shieldedOutputs.length === 0) {
694
1034
  throw Error('At least one shielded or unshielded output is required.');
695
1035
  }
696
- const shieldedTx = shieldedOutputs.length > 0
697
- ? await this.shielded.transferTransaction(shieldedSecretKeys, shieldedOutputs)
698
- : undefined;
1036
+ const shieldedTx = shieldedOutputs.length > 0 ? await this.shielded.transferTransaction(shieldedOutputs) : undefined;
699
1037
  const unshieldedTx = unshieldedOutputs.length > 0 ? await this.unshielded.transferTransaction(unshieldedOutputs, ttl) : undefined;
700
1038
  const mergedTxs = this.mergeUnprovenTransactions(shieldedTx, unshieldedTx);
701
1039
  // Add fee payment
702
- const dustResult = payFees ? await this.dust.balanceTransactions(dustSecretKey, [mergedTxs], ttl) : undefined;
1040
+ const dustResult = payFees ? await this.dust.balanceTransactions([mergedTxs], ttl) : undefined;
703
1041
  const feeBalancingTx = dustResult?.transaction;
704
1042
  const finalTx = this.mergeUnprovenTransactions(mergedTxs, feeBalancingTx);
705
1043
  return {
706
1044
  type: 'UNPROVEN_TRANSACTION',
1045
+ protocolVersion: this.currentVersion(),
707
1046
  transaction: finalTx,
708
1047
  ...(dustResult ? { blockData: dustResult.blockData } : {}),
709
1048
  };
@@ -722,9 +1061,12 @@ export class WalletFacade {
722
1061
  ...utxo,
723
1062
  ctime: meta.ctime,
724
1063
  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);
1064
+ })), (utxosWithMeta) => dustState.estimateDustGeneration(utxosWithMeta, now), (estimatedUtxos) => dustState.splitNightUtxos(estimatedUtxos), (split) => split.guaranteed);
1065
+ const authoring = this.authoring();
1066
+ // Type cast required because: a signing key belongs to one ledger version's runtime and the two are nominally
1067
+ // distinct, so the epoch's own primitives are the only things that may touch it; it never leaves this block.
1068
+ const fakeSigningKey = authoring.sampleSigningKey();
1069
+ const fakeVerifyingKey = authoring.signatureVerifyingKey(fakeSigningKey);
728
1070
  // Use the legacy dust-only construction path here so estimation does NOT book real UTxOs in the
729
1071
  // unshielded wallet state. (The race-fix path in createDustActionTransaction books on purpose;
730
1072
  // estimation is meant to be observation-only.)
@@ -734,22 +1076,20 @@ export class WalletFacade {
734
1076
  ctime: meta.ctime,
735
1077
  registeredForDustGeneration: meta.registeredForDustGeneration,
736
1078
  })), fakeVerifyingKey, dustState.address);
737
- const intent = fakeUnsignedTx.intents?.get(1);
1079
+ const intent = this.accept(fakeUnsignedTx).intents?.get(1);
738
1080
  if (!intent) {
739
1081
  throw Error('Dust generation transaction is missing intent segment 1.');
740
1082
  }
741
- const signatureData = intent.signatureData(1);
742
- const signature = ledger.signData(fakeSigningKey, signatureData);
1083
+ const signature = authoring.signData(fakeSigningKey, intent.signatureData(1));
743
1084
  const fakeSignedTx = await this.dust.addDustGenerationSignature(fakeUnsignedTx, signature);
744
- const finalizedFakeTx = fakeSignedTx.mockProve().bind();
1085
+ const finalizedFakeTx = this.seal('Finalized', this.accept(fakeSignedTx).mockProve().bind(), this.currentVersion());
745
1086
  const fee = await this.calculateTransactionFee(finalizedFakeTx);
746
1087
  return {
747
1088
  fee,
748
1089
  dustGenerationEstimations,
749
1090
  };
750
1091
  }
751
- async initSwap(desiredInputs, desiredOutputs, secretKeys, options) {
752
- const { shieldedSecretKeys, dustSecretKey } = secretKeys;
1092
+ async initSwap(desiredInputs, desiredOutputs, options) {
753
1093
  const { ttl, payFees = false } = options;
754
1094
  const { shielded: shieldedInputs, unshielded: unshieldedInputs } = desiredInputs;
755
1095
  const shieldedOutputs = desiredOutputs
@@ -763,21 +1103,23 @@ export class WalletFacade {
763
1103
  if (!hasShieldedPart && !hasUnshieldedPart) {
764
1104
  throw Error('At least one shielded or unshielded swap is required.');
765
1105
  }
766
- const shieldedTx = hasShieldedPart && shieldedInputs !== undefined
767
- ? await this.shielded.initSwap(shieldedSecretKeys, shieldedInputs, shieldedOutputs)
1106
+ // Build each leg if the swap involves that token kind, even when it has outputs but no inputs.
1107
+ const shieldedTx = hasShieldedPart
1108
+ ? await this.shielded.initSwap(shieldedInputs ?? {}, shieldedOutputs)
768
1109
  : undefined;
769
- const unshieldedTx = hasUnshieldedPart && unshieldedInputs !== undefined
770
- ? await this.unshielded.initSwap(unshieldedInputs, unshieldedOutputs, ttl)
1110
+ const unshieldedTx = hasUnshieldedPart
1111
+ ? await this.unshielded.initSwap(unshieldedInputs ?? {}, unshieldedOutputs, ttl)
771
1112
  : undefined;
772
1113
  const combinedTx = this.mergeUnprovenTransactions(shieldedTx, unshieldedTx);
773
1114
  if (!combinedTx) {
774
1115
  throw Error('Unexpected transaction state.');
775
1116
  }
776
- const dustResult = payFees ? await this.dust.balanceTransactions(dustSecretKey, [combinedTx], ttl) : undefined;
1117
+ const dustResult = payFees ? await this.dust.balanceTransactions([combinedTx], ttl) : undefined;
777
1118
  const feeBalancingTx = dustResult?.transaction;
778
1119
  const finalTx = this.mergeUnprovenTransactions(combinedTx, feeBalancingTx);
779
1120
  return {
780
1121
  type: 'UNPROVEN_TRANSACTION',
1122
+ protocolVersion: this.currentVersion(),
781
1123
  transaction: finalTx,
782
1124
  ...(dustResult ? { blockData: dustResult.blockData } : {}),
783
1125
  };
@@ -790,6 +1132,7 @@ export class WalletFacade {
790
1132
  const dustRegistrationTx = await this.createDustActionTransaction({ type: 'registration', dustReceiverAddress: receiverAddress }, nightUtxos, nightVerifyingKey, signDustRegistration);
791
1133
  return {
792
1134
  type: 'UNPROVEN_TRANSACTION',
1135
+ protocolVersion: this.currentVersion(),
793
1136
  transaction: dustRegistrationTx,
794
1137
  };
795
1138
  }
@@ -814,37 +1157,111 @@ export class WalletFacade {
814
1157
  const dustDeregistrationTx = await this.createDustActionTransaction({ type: 'deregistration' }, nightUtxos, nightVerifyingKey, signDustRegistration);
815
1158
  return {
816
1159
  type: 'UNPROVEN_TRANSACTION',
1160
+ protocolVersion: this.currentVersion(),
817
1161
  transaction: dustDeregistrationTx,
818
1162
  };
819
1163
  }
820
- async revert(txOrRecipe) {
1164
+ async revert(txOrRecipe, reason) {
821
1165
  // avoid instanceof check
822
1166
  const transactionsToRevert = BalancingRecipe.isRecipe(txOrRecipe)
823
1167
  ? BalancingRecipe.getTransactions(txOrRecipe)
824
1168
  : [txOrRecipe];
825
- await Promise.all(transactionsToRevert.map((tx) => this.revertTransaction(tx)));
1169
+ await Promise.all(transactionsToRevert.map((tx) => this.revertTransaction(tx, reason)));
826
1170
  }
827
- async revertTransaction(tx) {
1171
+ /**
1172
+ * The finalized entry recording `tx`, if sync has written one, even under the hash aggregation gave it on-chain. A
1173
+ * transaction whose stamp no epoch can read has no entry, rather than an error, so a verdict on it still resolves.
1174
+ */
1175
+ finalizedEntryFor(tx, history) {
1176
+ return Option.match(Either.getRight(WalletTransaction.unwrapWithin(tx, this.epochOf(tx.protocolVersion))), {
1177
+ onNone: () => undefined,
1178
+ onSome: (carried) => {
1179
+ const key = submitTxHistoryKey(carried);
1180
+ return history.find((entry) => isFinalizedWalletEntry(entry) && TransactionHistoryStorage.coversTransaction(entry, key));
1181
+ },
1182
+ });
1183
+ }
1184
+ /** Sync can record inclusion before the independent pending-status poller observes it. */
1185
+ async reconcileFinalizedTransaction(tx, history) {
1186
+ const entry = this.finalizedEntryFor(tx, history);
1187
+ if (entry === undefined)
1188
+ return false;
1189
+ // Included failures still need their unexecuted coin reservations released.
1190
+ if (entry.status === 'FAILURE' || entry.status === 'PARTIAL_SUCCESS') {
1191
+ await this.revertTransaction(tx);
1192
+ }
1193
+ else {
1194
+ await this.pendingTransactionsService.clear(tx);
1195
+ }
1196
+ return true;
1197
+ }
1198
+ async revertTransaction(tx, reason) {
828
1199
  await Promise.all([
829
1200
  this.shielded.revertTransaction(tx),
830
1201
  this.unshielded.revertTransaction(tx),
831
1202
  this.dust.revertTransaction(tx),
832
1203
  ]).then(async () => {
1204
+ // Reverting is total over the stages: a transaction at any of them may have booked coins, and the pending set
1205
+ // recognises only the finalized ones. Narrowing rather than casting is what says so.
833
1206
  await this.pendingTransactionsService.clear(tx);
834
- const key = revertTxHistoryKey(tx);
1207
+ // Read at the epoch the transaction was built for, not the one the facade now acts at. A verdict on a
1208
+ // transaction submitted before a protocol boundary can only arrive after it — a chain rejection, a TTL run out,
1209
+ // or the wallet giving up on bytes that can never be included — and by then the facade has crossed. The pending
1210
+ // entry that verdict has to land on was written by this same session before the crossing, and it is the only
1211
+ // record an application has that the transaction will never be included; keying it against the current epoch
1212
+ // would leave it saying `pending` for the rest of the session. The stamp is what chooses the reader, which is
1213
+ // what the stamp is for.
1214
+ const key = Option.match(Either.getRight(WalletTransaction.unwrapWithin(tx, this.epochOf(tx.protocolVersion))), { onNone: () => undefined, onSome: revertTxHistoryKey });
835
1215
  if (key !== undefined) {
836
- await this.#txHistoryStorage.gotRejected({ ...key, rejectedAt: this.clock.now() });
1216
+ await this.#txHistoryStorage.gotRejected({
1217
+ ...key,
1218
+ rejectedAt: this.clock.now(),
1219
+ ...(reason !== undefined ? { reason } : {}),
1220
+ });
837
1221
  }
838
1222
  });
839
1223
  }
840
- async start(shieldedSecretKeys, dustSecretKey) {
1224
+ /**
1225
+ * Starts the wallets and their background synchronization.
1226
+ *
1227
+ * @remarks
1228
+ * Seeds, not key objects. A seed is the only key material that crosses a protocol boundary — every ledger version
1229
+ * derives its own keys from the same seed and arrives at the same identity — so a wallet started from seeds can
1230
+ * follow the chain across a fork, and one started from key objects of a single ledger version cannot. Derive them
1231
+ * once with `WalletSeeds.fromMasterSeed` and hand them over.
1232
+ *
1233
+ * {@link FacadeKeysByEpoch} is the escape hatch for a caller that will not part with a seed. It requires **both**
1234
+ * ledger versions' key objects, because a wallet holding one side could not read the other side of the chain, and
1235
+ * it costs the caller an import of both ledger packages and the same derivation performed twice. A seed costs
1236
+ * neither.
1237
+ * @example
1238
+ * ```typescript
1239
+ * await facade.start(WalletSeeds.fromMasterSeed(masterSeed));
1240
+ * ```;
1241
+ *
1242
+ * @param material The three wallets' seeds, or both ledger versions' key objects.
1243
+ * @param options `manualSync` leaves the dust wallet unstarted in the background; drive it explicitly with
1244
+ * {@link doSync} instead (requires a dust wallet built with the projections sync service, see
1245
+ * `makeEventLessSyncService`).
1246
+ */
1247
+ async start(material, options = {}) {
1248
+ const keys = v9KeysOf(material);
841
1249
  await Promise.all([
842
- this.shielded.start(shieldedSecretKeys),
1250
+ this.shielded.start(keys.shielded),
843
1251
  this.unshielded.start(),
844
- this.dust.start(dustSecretKey),
1252
+ !options.manualSync ? this.dust.start(keys.dust) : undefined,
845
1253
  this.pendingTransactionsService.start(),
846
1254
  ]);
847
1255
  }
1256
+ /**
1257
+ * Runs a single dust synchronization pass and resolves when it completes. Only the dust wallet supports manual sync;
1258
+ * the shielded and unshielded wallets keep syncing in the background via {@link start}.
1259
+ *
1260
+ * @param material The same material {@link start} was given.
1261
+ */
1262
+ async doSync(material) {
1263
+ await this.dust.stepSync(v9KeysOf(material).dust);
1264
+ }
848
1265
  async stop() {
849
1266
  await Promise.all([
850
1267
  this.shielded.stop(),
@@ -853,6 +1270,7 @@ export class WalletFacade {
853
1270
  this.submissionService.close(),
854
1271
  this.pendingTransactionsService.stop(),
855
1272
  Promise.resolve(this.#pendingSubscription?.unsubscribe()),
1273
+ Promise.resolve(this.#protocolVersionSubscription?.unsubscribe()),
856
1274
  ]);
857
1275
  }
858
1276
  async queryTxHistoryByHash(hash) {