@midnight-ntwrk/wallet-sdk-facade 5.0.0-rc.0 → 5.0.0-rc.1

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.d.ts CHANGED
@@ -7,13 +7,18 @@ import { type DefaultShieldedConfiguration, type ShieldedWalletAPI, type Shielde
7
7
  import type { DefaultUnshieldedConfiguration, SignSegment, UnshieldedWalletAPI } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
8
8
  import { type UnshieldedWalletState } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
9
9
  import { Clock } from '@midnight-ntwrk/wallet-sdk-utilities';
10
- import { type DateTime, Option, Schema } from 'effect';
10
+ import { DateTime, Option, Schema } from 'effect';
11
11
  import { type AnyTx, type FinalizedTx, ProtocolVersion, TransactionHistoryStorage, type UnboundTx, type UnprovenTx, WalletTransaction } from '@midnight-ntwrk/wallet-sdk-abstractions';
12
12
  import * as ledgerV8 from '@midnight-ntwrk/ledger-v8';
13
13
  import { type WalletSeeds } from '@midnight-ntwrk/wallet-sdk-hd';
14
14
  import { type Observable } from 'rxjs';
15
15
  import { type DefaultPendingTransactionsServiceConfiguration, PendingTransactions, type PendingTransactionsService } from '@midnight-ntwrk/wallet-sdk-capabilities';
16
16
  import { type AnyLedgerParameters, type AnyVersionValidatableTransaction, type BlockData, type BlockDataFetcher, type ValidateTxOptions, ValidationFetchError, type VersionedValidationService, WellFormedError, type WellFormedStrictnessFlags } from '@midnight-ntwrk/wallet-sdk-capabilities/validation';
17
+ /**
18
+ * The traits pending transactions are read with, exported so a test that restores a stored pending-transaction payload
19
+ * reads it exactly as the wallet does. Building a second trait for that would be testing the copy, not the wallet.
20
+ */
21
+ export { finalizedTransactionTraits } from './transaction.js';
17
22
  import { type DustAddress, type ShieldedAddress, type UnshieldedAddress } from '@midnight-ntwrk/wallet-sdk-address-format';
18
23
  /**
19
24
  * Full entry schema for transaction history. The common entry data and wallet-specific sections (`shielded`,
@@ -32,11 +37,11 @@ export declare const WalletEntrySchema: Schema.Struct<Readonly<{
32
37
  submittedAt: typeof Schema.Date;
33
38
  }>, Schema.Struct<{
34
39
  status: Schema.Literal<["finalized"]>;
35
- finalizedBlock: Schema.Struct<{
40
+ finalizedBlock: Schema.optional<Schema.Struct<{
36
41
  hash: typeof Schema.String;
37
42
  height: typeof Schema.Number;
38
43
  timestamp: typeof Schema.Date;
39
- }>;
44
+ }>>;
40
45
  }>, Schema.Struct<{
41
46
  status: Schema.Literal<["rejected"]>;
42
47
  rejectedAt: typeof Schema.Date;
@@ -786,6 +791,35 @@ export declare class WalletFacade {
786
791
  ttl: Date;
787
792
  payFees?: boolean;
788
793
  }): Promise<UnprovenTransactionRecipe>;
794
+ /**
795
+ * Builds and signs a transaction registering `nightUtxos` to generate dust for `dustReceiverAddress`.
796
+ *
797
+ * @remarks
798
+ * A first-time registration pays its own fee out of the dust its Night has generated so far. When that falls short,
799
+ * the Night UTxOs booked for the transaction are released before the call rejects, so it can be retried as is.
800
+ * @example
801
+ * ```ts
802
+ * const register = () => wallet.registerNightUtxosForDustGeneration(nightUtxos, verifyingKey, signData);
803
+ * const recipe = await register().catch(async (error: unknown) => {
804
+ * // An unreachable fee will not be covered by waiting, so only a reachable one is waited out.
805
+ * if (!isInsufficientDustForFeeError(error) || error.estimate._tag !== 'Reachable') throw error;
806
+ * const timeoutMs = Number(error.estimate.seconds) * 1000 + 60_000;
807
+ * await wallet.waitForGeneratedDust(nightUtxos, error.fee, { timeoutMs });
808
+ * return register();
809
+ * });
810
+ * ```;
811
+ *
812
+ * @param nightUtxos - The Night UTxOs to register. Must be non-empty.
813
+ * @param nightVerifyingKey - The verifying key that owns them.
814
+ * @param signDustRegistration - Signs the registration and the unshielded offers.
815
+ * @param dustReceiverAddress - Where the generated dust goes. Defaults to this wallet's own dust address.
816
+ * @returns The unproven registration transaction, signed.
817
+ * @throws Error if `nightUtxos` is empty.
818
+ * @throws InsufficientDustForFeeError (`_tag` `'Wallet.InsufficientDustForFee'`, recognised by
819
+ * `isInsufficientDustForFeeError` from `@midnight-ntwrk/wallet-sdk`) when the generated dust does not yet cover the
820
+ * fee. It carries `claimableFeePayment`, `fee`, `shortfall` and an `estimate` of when it will; when it is
821
+ * reachable, wait with {@link waitForGeneratedDust} for at least that long and retry.
822
+ */
789
823
  registerNightUtxosForDustGeneration(nightUtxos: readonly UtxoWithMeta[], nightVerifyingKey: ledgerV9.SignatureVerifyingKey, signDustRegistration: SignSegment, dustReceiverAddress?: DustAddress): Promise<UnprovenTransactionRecipe>;
790
824
  /**
791
825
  * Waits until the dust projected to be generated by the given Night UTxOs reaches `requiredAmount`, re-checking every
package/dist/index.js CHANGED
@@ -15,18 +15,25 @@ import { makeDefaultSubmissionService, } from '@midnight-ntwrk/wallet-sdk-capabi
15
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
+ import { TransactionOps as V1UnshieldedTransactionOps } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet/v1';
19
+ import { TransactionOps as V2UnshieldedTransactionOps } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet/v2';
18
20
  import { DustSectionSchema, mergeDustSections } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
19
21
  import { Clock } from '@midnight-ntwrk/wallet-sdk-utilities';
20
22
  import { FetchTermsAndConditions as FetchTermsAndConditionsQuery } from '@midnight-ntwrk/wallet-sdk-indexer-client';
21
23
  import { QueryRunner } from '@midnight-ntwrk/wallet-sdk-indexer-client/effect';
22
- import { Array as Arr, Either, Option, pipe, Schema } from 'effect';
24
+ import { Array as Arr, DateTime, Either, Option, pipe, Schema } from 'effect';
23
25
  import { ProtocolVersion, TransactionHistoryStorage, WalletTransaction, WireFormatError, } from '@midnight-ntwrk/wallet-sdk-abstractions';
24
26
  import * as ledgerV8 from '@midnight-ntwrk/ledger-v8';
25
27
  import * as Signatures from '@midnight-ntwrk/wallet-sdk-capabilities/signatures';
26
- import { BehaviorSubject, combineLatest, concatMap, distinctUntilChanged, firstValueFrom, map, tap, } from 'rxjs';
28
+ import { BehaviorSubject, combineLatest, concatMap, distinctUntilChanged, filter, firstValueFrom, map, take, tap, } from 'rxjs';
27
29
  import { PendingTransactions, PendingTransactionsServiceImpl, } from '@midnight-ntwrk/wallet-sdk-capabilities';
28
30
  import { makeDefaultBlockDataFetcher, makeDefaultVersionedValidationService, ValidationFetchError, WellFormedError, } from '@midnight-ntwrk/wallet-sdk-capabilities/validation';
29
31
  import { finalizedTransactionTraits, txHistoryHash } from './transaction.js';
32
+ /**
33
+ * The traits pending transactions are read with, exported so a test that restores a stored pending-transaction payload
34
+ * reads it exactly as the wallet does. Building a second trait for that would be testing the copy, not the wallet.
35
+ */
36
+ export { finalizedTransactionTraits } from './transaction.js';
30
37
  /**
31
38
  * Why the wallet gave up on a transaction, for the history entry.
32
39
  *
@@ -292,6 +299,8 @@ export class FacadeState {
292
299
  }
293
300
  }
294
301
  const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
302
+ /** How a coin is named wherever it is referred to without being carried: in a reservation, and in a release by id. */
303
+ const coinIdsOf = (utxos) => utxos.map((utxo) => `${utxo.intentHash}#${utxo.outputNo}`);
295
304
  /**
296
305
  * Clock abstraction for obtaining the current time. By default, the facade uses the system clock
297
306
  * ({@link Clock.systemClock}); for testing with a simulator, inject a custom clock (e.g. one backed by the simulator's
@@ -447,6 +456,8 @@ export class WalletFacade {
447
456
  #txHistoryStorage;
448
457
  clock;
449
458
  #pendingSubscription;
459
+ #expiredReservationSubscription;
460
+ #restoredBookingSubscription;
450
461
  #protocolVersionSubscription;
451
462
  /**
452
463
  * The protocol version the wallets have all reached, as last observed.
@@ -483,6 +494,38 @@ export class WalletFacade {
483
494
  }
484
495
  }))
485
496
  .subscribe();
497
+ // A reservation past its TTL stands for a transaction the ledger will not accept, so the record has done its job
498
+ // and is dropped. It does not release the coins: the wallet books each one with the same TTL and sweeps them
499
+ // itself, so one mechanism owns release.
500
+ //
501
+ // Releasing from here as well would be a second sweep on a different clock, and a coin the wallet has already
502
+ // freed can be booked again for another transaction before this one fires. The ids a reservation holds say
503
+ // nothing about which booking currently holds them, so that release would free the wrong one.
504
+ this.#expiredReservationSubscription = this.pendingTransactionsService
505
+ .state()
506
+ .pipe(concatMap((pending) => PendingTransactions.allExpiredReservations(pending)), concatMap((reservation) => this.pendingTransactionsService.clearReservation(reservation.identifiers)))
507
+ .subscribe();
508
+ // Coins a previous process left booked would otherwise sit until their transactions' TTLs, which can be an hour.
509
+ // Once sync reaches the tip, every transaction this address is party to has been applied, so a coin still booked
510
+ // was never spent — unless a reservation or a transaction being tracked says the spend is still out there. Both
511
+ // halves matter: registering a transaction drops the reservation standing in for it, so from submission onwards
512
+ // the tracked transaction is the only thing saying its coins are spoken for.
513
+ //
514
+ // This only sees what the services were given. A pending-transactions service restored from storage covers the
515
+ // spends a previous process submitted; one started empty covers none of them, and a restored booking whose
516
+ // transaction is still in the mempool is released here.
517
+ this.#restoredBookingSubscription = this.unshielded.state
518
+ .pipe(filter((unshielded) => unshielded.state.progress.isStrictlyComplete()),
519
+ // Only bookings restored at startup are candidates, and this releases all of them at once, so the first time
520
+ // sync reaches the tip is the only time there is anything to do. Acting once also keeps the reaction from
521
+ // feeding itself: releasing publishes new state, which would otherwise satisfy this same condition again.
522
+ take(1), concatMap(async () => {
523
+ const { publicKey } = (await firstValueFrom(this.unshielded.state)).state;
524
+ const pending = await firstValueFrom(this.pendingTransactionsService.state());
525
+ const stillSpokenFor = PendingTransactions.coveredUnshieldedIds(pending, (tx) => this.#ownInputIds(tx, publicKey.publicKey));
526
+ await this.unshielded.releaseRestoredPending(stillSpokenFor);
527
+ }))
528
+ .subscribe();
486
529
  // Deliberately built from the wallets' own states rather than from `state()`: `state()` includes the pending set,
487
530
  // and orphaning writes to it, so feeding that back here would be a cycle.
488
531
  this.#protocolVersionSubscription = combineLatest([this.shielded.state, this.unshielded.state, this.dust.state])
@@ -593,6 +636,107 @@ export class WalletFacade {
593
636
  }),
594
637
  }).pipe(Either.map((transaction) => WalletTransaction.adopt(stage, transaction, protocolVersion))), (error) => error);
595
638
  }
639
+ /**
640
+ * Reads the transaction a handle carries, with the ledger version the handle itself names.
641
+ *
642
+ * @remarks
643
+ * The counterpart of {@link accept}, which reads at the version the facade is acting at and refuses the rest. This one
644
+ * asks nothing of the facade's position: the handle was stamped by whichever ledger version built it, and that
645
+ * version is the one that can read its bytes. It is how a transaction tracked across a crossing can still name its
646
+ * coins afterwards.
647
+ * @param handle The handle to read.
648
+ * @returns The carried transaction.
649
+ */
650
+ #carried(handle) {
651
+ return Either.getOrThrowWith(WalletTransaction.unwrapWithin(handle, this.epochOf(handle.protocolVersion)), (error) => error);
652
+ }
653
+ /**
654
+ * The ids of this wallet's own coins that `handle` spends, read with the ledger version that built it.
655
+ *
656
+ * @remarks
657
+ * The identity is this wallet's in ledger-v9's shape; a transaction built below the boundary is read with ledger-v8,
658
+ * whose verifying key is the bare bytes of the one scheme it has, so the key is narrowed to match. Only a schnorr
659
+ * identity ever has a ledger-v8 transaction to read, because the wallet refuses to start that variant with any
660
+ * other, so the narrowing cannot fail here and a failure is reported as the defect it would be.
661
+ * @param handle The transaction to read.
662
+ * @param publicKey This wallet's verifying key, in the shape of whichever variant is running: ledger-v8's bare hex
663
+ * below the boundary, ledger-v9's tagged record from it.
664
+ * @returns Every coin of this wallet's the transaction spends, each `intentHash#outputNo`.
665
+ */
666
+ #ownInputIds(handle, publicKey) {
667
+ // Lifted once so both branches start from ledger-v9's shape; ledger-v8's key is the bare string, so the check is exact.
668
+ const verifyingKey = typeof publicKey === 'string' ? Signatures.liftSignatureVerifyingKey(publicKey) : publicKey;
669
+ const ownInputs = handle.protocolVersion < this.#forkVersion
670
+ ? V1UnshieldedTransactionOps.extractOwnInputs(this.#carried(handle), Either.getOrThrowWith(Signatures.lowerSignatureVerifyingKey(verifyingKey), (error) => error))
671
+ : V2UnshieldedTransactionOps.extractOwnInputs(this.#carried(handle), verifyingKey);
672
+ return coinIdsOf(ownInputs);
673
+ }
674
+ /**
675
+ * Records that a just-balanced transaction has unshielded coins reserved.
676
+ *
677
+ * Balancing books those coins, but nothing tracks the transaction until it is submitted, so a caller that abandons it
678
+ * in between leaves the coins spoken for with no record saying so. The reservation is that record, and it is matched
679
+ * to the transaction that eventually arrives by identifier, which proving and binding leave unchanged.
680
+ *
681
+ * Takes the transaction the unshielded wallet produced rather than the merged one: its inputs are exactly the coins
682
+ * booked here, and its identifiers are a subset of whatever the merged transaction ends up carrying.
683
+ */
684
+ async #reserve(unshieldedTx, ttl) {
685
+ if (!unshieldedTx) {
686
+ return;
687
+ }
688
+ const { publicKey } = (await firstValueFrom(this.unshielded.state)).state;
689
+ await this.#reserveIds(unshieldedTx, this.#ownInputIds(unshieldedTx, publicKey.publicKey), ttl);
690
+ }
691
+ /** Records `bookedIds` against `unshieldedTx`, or nothing at all when the transaction booked no coins. */
692
+ async #reserveIds(unshieldedTx, bookedIds, ttl) {
693
+ if (bookedIds.length === 0) {
694
+ return;
695
+ }
696
+ const identity = this.#carried(unshieldedTx);
697
+ await this.pendingTransactionsService.addReservation({
698
+ identifiers: identity.identifiers(),
699
+ intentHashes: [...(identity.intents?.entries() ?? [])].map(([segment, intent]) => intent.intentHash(segment)),
700
+ inputs: { unshielded: bookedIds },
701
+ ttl,
702
+ createdAt: DateTime.unsafeFromDate(this.clock.now()),
703
+ expired: false,
704
+ });
705
+ }
706
+ /**
707
+ * Balances a transaction in place and records only the coins the wallet itself booked doing so.
708
+ *
709
+ * In-place balancing hands back the caller's own transaction with the wallet's inputs added, so the result names
710
+ * coins from two sources: the ones coin selection just took, and any the caller had already put there. Only the first
711
+ * are this wallet's booking, and they are exactly the inputs of this wallet's that the balanced transaction carries
712
+ * and the caller's did not.
713
+ *
714
+ * Read off the two transactions, not off the wallet's published state: the wallet publishes its state on its own
715
+ * fiber, so a read taken as soon as the balancing call returns can still show the coins as available, and the booking
716
+ * would go unrecorded.
717
+ */
718
+ async #balanceInPlaceAndReserve(tx, balance, ttl) {
719
+ const { publicKey } = (await firstValueFrom(this.unshielded.state)).state;
720
+ const inputsBefore = new Set(this.#ownInputIds(tx, publicKey.publicKey));
721
+ const balanced = await balance();
722
+ if (balanced === undefined) {
723
+ return balanced;
724
+ }
725
+ const newlyBooked = this.#ownInputIds(balanced, publicKey.publicKey).filter((id) => !inputsBefore.has(id));
726
+ await this.#reserveIds(balanced, newlyBooked, ttl);
727
+ return balanced;
728
+ }
729
+ /**
730
+ * Undoes a booking taken while balancing, together with the record that stood for it.
731
+ *
732
+ * The two always go together. A record left behind outlives the coins it named, and until its TTL passes it tells
733
+ * anything that reads it that a spend is still out there — holding coins at the next reconciliation that nothing is
734
+ * actually holding.
735
+ */
736
+ async #revertUnshieldedBooking(tx) {
737
+ await this.unshielded.revertTransaction(tx);
738
+ await this.pendingTransactionsService.clearReservation([...this.#carried(tx).identifiers()]);
739
+ }
596
740
  defaultTtl() {
597
741
  return new Date(this.clock.now().getTime() + DEFAULT_TTL_MS);
598
742
  }
@@ -666,11 +810,12 @@ export class WalletFacade {
666
810
  const dustReceiverAddress = isRegistration ? action.dustReceiverAddress : undefined;
667
811
  // Step 1 — Dust decides which Night UTxO belongs in the guaranteed slot (the one whose dust
668
812
  // generation can pay the fee) and computes the fee-payment allowance.
669
- const split = await this.dust.splitNightUtxosForDustRegistration(now, nightUtxos.map(({ utxo, meta }) => ({
813
+ const nightUtxosWithMeta = nightUtxos.map(({ utxo, meta }) => ({
670
814
  ...utxo,
671
815
  ctime: meta.ctime,
672
816
  registeredForDustGeneration: meta.registeredForDustGeneration,
673
- })), isRegistration);
817
+ }));
818
+ const split = await this.dust.splitNightUtxosForDustRegistration(now, nightUtxosWithMeta, isRegistration);
674
819
  const toUnshieldedUtxoWithMeta = (u) => ({
675
820
  utxo: {
676
821
  value: u.utxo.value,
@@ -690,6 +835,7 @@ export class WalletFacade {
690
835
  // with the two offers. After this point, a concurrent build call that wants any of these UTxOs
691
836
  // will fail fast.
692
837
  const txWithOffers = await this.unshielded.rotateUtxos(guaranteedForUnshielded, fallibleForUnshielded, nightVerifyingKey, ttl);
838
+ await this.#reserve(txWithOffers, ttl);
693
839
  // Step 3 — Dust attaches its DustActions onto the intent the unshielded wallet just built.
694
840
  // If this fails we must unbook the UTxOs so the caller can retry.
695
841
  let txWithDustActions;
@@ -697,7 +843,7 @@ export class WalletFacade {
697
843
  txWithDustActions = await this.dust.attachDustRegistration(txWithOffers, now, nightVerifyingKey, dustReceiverAddress, split.feePayment);
698
844
  }
699
845
  catch (error) {
700
- await this.unshielded.revertTransaction(txWithOffers);
846
+ await this.#revertUnshieldedBooking(txWithOffers);
701
847
  throw error;
702
848
  }
703
849
  // Step 4 (first-time registration only) — Fail fast if the dust generated so far by the
@@ -713,10 +859,12 @@ export class WalletFacade {
713
859
  const hasUnregisteredGuaranteed = split.guaranteedUtxos.some((u) => !u.utxo.registeredForDustGeneration);
714
860
  if (isRegistration && hasUnregisteredGuaranteed) {
715
861
  const fee = await this.dust.calculateFee([txWithDustActions]);
716
- if (split.feePayment < fee) {
717
- await this.unshielded.revertTransaction(txWithOffers);
718
- throw Error(`Insufficient generated dust to cover registration fee (have ${split.feePayment}, need ${fee}). ` +
719
- `Use WalletFacade.waitForGeneratedDust(utxos, ${fee}) before retrying.`);
862
+ try {
863
+ await this.dust.ensureFeeCoverage(now, nightUtxosWithMeta, split.feePayment, fee);
864
+ }
865
+ catch (error) {
866
+ await this.#revertUnshieldedBooking(txWithOffers);
867
+ throw error;
720
868
  }
721
869
  }
722
870
  // Step 5 — Sign via the standard signRecipe pathway, which now stamps both the unshielded
@@ -729,7 +877,7 @@ export class WalletFacade {
729
877
  return signedRecipe.transaction;
730
878
  }
731
879
  catch (error) {
732
- await this.unshielded.revertTransaction(txWithOffers);
880
+ await this.#revertUnshieldedBooking(txWithOffers);
733
881
  throw error;
734
882
  }
735
883
  }
@@ -796,6 +944,7 @@ export class WalletFacade {
796
944
  const unshieldedBalancingTx = shouldBalanceUnshielded
797
945
  ? await this.unshielded.balanceFinalizedTransaction(tx)
798
946
  : undefined;
947
+ await this.#reserve(unshieldedBalancingTx, ttl);
799
948
  const shieldedBalancingTx = shouldBalanceShielded ? await this.shielded.balanceTransaction(tx) : undefined;
800
949
  // Step 2: Merge unshielded and shielded balancing
801
950
  const mergedBalancingTx = this.mergeUnprovenTransactions(shieldedBalancingTx, unshieldedBalancingTx);
@@ -836,7 +985,7 @@ export class WalletFacade {
836
985
  const shieldedBalancingTx = shouldBalanceShielded ? await this.shielded.balanceTransaction(tx) : undefined;
837
986
  // For unbound transactions, unshielded balancing happens in place not with a balancing transaction
838
987
  const balancedUnshieldedTx = shouldBalanceUnshielded
839
- ? await this.unshielded.balanceUnboundTransaction(tx)
988
+ ? await this.#balanceInPlaceAndReserve(tx, () => this.unshielded.balanceUnboundTransaction(tx), ttl)
840
989
  : undefined;
841
990
  // Step 2: Unbound unshielded tx are balanced in place, use it as base tx if present
842
991
  const baseTx = balancedUnshieldedTx ?? tx;
@@ -878,7 +1027,7 @@ export class WalletFacade {
878
1027
  const shieldedBalancingTx = shouldBalanceShielded ? await this.shielded.balanceTransaction(tx) : undefined;
879
1028
  // For unproven transactions, unshielded balancing happens in place
880
1029
  const balancedUnshieldedTx = shouldBalanceUnshielded
881
- ? await this.unshielded.balanceUnprovenTransaction(tx)
1030
+ ? await this.#balanceInPlaceAndReserve(tx, () => this.unshielded.balanceUnprovenTransaction(tx), ttl)
882
1031
  : undefined;
883
1032
  // Step 2: Use the balanced unshielded tx if present, otherwise use the original tx
884
1033
  const baseTx = balancedUnshieldedTx ?? tx;
@@ -1009,7 +1158,7 @@ export class WalletFacade {
1009
1158
  catch (error) {
1010
1159
  await Promise.allSettled([
1011
1160
  this.shielded.revertTransaction(tx),
1012
- this.unshielded.revertTransaction(tx),
1161
+ this.#revertUnshieldedBooking(tx),
1013
1162
  this.dust.revertTransaction(tx),
1014
1163
  ]);
1015
1164
  throw error;
@@ -1035,6 +1184,7 @@ export class WalletFacade {
1035
1184
  }
1036
1185
  const shieldedTx = shieldedOutputs.length > 0 ? await this.shielded.transferTransaction(shieldedOutputs) : undefined;
1037
1186
  const unshieldedTx = unshieldedOutputs.length > 0 ? await this.unshielded.transferTransaction(unshieldedOutputs, ttl) : undefined;
1187
+ await this.#reserve(unshieldedTx, ttl);
1038
1188
  const mergedTxs = this.mergeUnprovenTransactions(shieldedTx, unshieldedTx);
1039
1189
  // Add fee payment
1040
1190
  const dustResult = payFees ? await this.dust.balanceTransactions([mergedTxs], ttl) : undefined;
@@ -1110,6 +1260,7 @@ export class WalletFacade {
1110
1260
  const unshieldedTx = hasUnshieldedPart
1111
1261
  ? await this.unshielded.initSwap(unshieldedInputs ?? {}, unshieldedOutputs, ttl)
1112
1262
  : undefined;
1263
+ await this.#reserve(unshieldedTx, ttl);
1113
1264
  const combinedTx = this.mergeUnprovenTransactions(shieldedTx, unshieldedTx);
1114
1265
  if (!combinedTx) {
1115
1266
  throw Error('Unexpected transaction state.');
@@ -1124,6 +1275,35 @@ export class WalletFacade {
1124
1275
  ...(dustResult ? { blockData: dustResult.blockData } : {}),
1125
1276
  };
1126
1277
  }
1278
+ /**
1279
+ * Builds and signs a transaction registering `nightUtxos` to generate dust for `dustReceiverAddress`.
1280
+ *
1281
+ * @remarks
1282
+ * A first-time registration pays its own fee out of the dust its Night has generated so far. When that falls short,
1283
+ * the Night UTxOs booked for the transaction are released before the call rejects, so it can be retried as is.
1284
+ * @example
1285
+ * ```ts
1286
+ * const register = () => wallet.registerNightUtxosForDustGeneration(nightUtxos, verifyingKey, signData);
1287
+ * const recipe = await register().catch(async (error: unknown) => {
1288
+ * // An unreachable fee will not be covered by waiting, so only a reachable one is waited out.
1289
+ * if (!isInsufficientDustForFeeError(error) || error.estimate._tag !== 'Reachable') throw error;
1290
+ * const timeoutMs = Number(error.estimate.seconds) * 1000 + 60_000;
1291
+ * await wallet.waitForGeneratedDust(nightUtxos, error.fee, { timeoutMs });
1292
+ * return register();
1293
+ * });
1294
+ * ```;
1295
+ *
1296
+ * @param nightUtxos - The Night UTxOs to register. Must be non-empty.
1297
+ * @param nightVerifyingKey - The verifying key that owns them.
1298
+ * @param signDustRegistration - Signs the registration and the unshielded offers.
1299
+ * @param dustReceiverAddress - Where the generated dust goes. Defaults to this wallet's own dust address.
1300
+ * @returns The unproven registration transaction, signed.
1301
+ * @throws Error if `nightUtxos` is empty.
1302
+ * @throws InsufficientDustForFeeError (`_tag` `'Wallet.InsufficientDustForFee'`, recognised by
1303
+ * `isInsufficientDustForFeeError` from `@midnight-ntwrk/wallet-sdk`) when the generated dust does not yet cover the
1304
+ * fee. It carries `claimableFeePayment`, `fee`, `shortfall` and an `estimate` of when it will; when it is
1305
+ * reachable, wait with {@link waitForGeneratedDust} for at least that long and retry.
1306
+ */
1127
1307
  async registerNightUtxosForDustGeneration(nightUtxos, nightVerifyingKey, signDustRegistration, dustReceiverAddress) {
1128
1308
  if (nightUtxos.length === 0) {
1129
1309
  throw Error('At least one Night UTXO is required.');
@@ -1271,6 +1451,8 @@ export class WalletFacade {
1271
1451
  this.pendingTransactionsService.stop(),
1272
1452
  Promise.resolve(this.#pendingSubscription?.unsubscribe()),
1273
1453
  Promise.resolve(this.#protocolVersionSubscription?.unsubscribe()),
1454
+ Promise.resolve(this.#expiredReservationSubscription?.unsubscribe()),
1455
+ Promise.resolve(this.#restoredBookingSubscription?.unsubscribe()),
1274
1456
  ]);
1275
1457
  }
1276
1458
  async queryTxHistoryByHash(hash) {
@@ -1,5 +1,7 @@
1
1
  import { type PendingTransactions } from '@midnight-ntwrk/wallet-sdk-capabilities';
2
2
  import { ProtocolVersion, type FinalizedTx } from '@midnight-ntwrk/wallet-sdk-abstractions';
3
+ import * as ledgerV8 from '@midnight-ntwrk/ledger-v8';
4
+ import * as ledgerV9 from '@midnightntwrk/ledger-v9';
3
5
  /**
4
6
  * The key under which a transaction's history entry is stored.
5
7
  *
@@ -20,6 +22,16 @@ export declare const txHistoryHash: (tx: {
20
22
  transactionHash: () => unknown;
21
23
  serialize: () => Uint8Array;
22
24
  }) => string;
25
+ export declare const v9FinalizedTransactionTrait: PendingTransactions.TransactionTrait<ledgerV9.FinalizedTransaction>;
26
+ /**
27
+ * The ledger-v8's reading of a pending transaction.
28
+ *
29
+ * @remarks
30
+ * Structurally the same as ledger-v9's — identifiers, a TTL, bytes — against a different ledger version's classes. It
31
+ * is the classes that make this a separate trait: `instanceof` distinguishes them, each deserializer refuses the
32
+ * other's bytes, and a grace period is read off that version's own initial parameters.
33
+ */
34
+ export declare const v8FinalizedTransactionTrait: PendingTransactions.TransactionTrait<ledgerV8.FinalizedTransaction>;
23
35
  /**
24
36
  * The traits pending transactions are read with, split at the protocol version this chain forks at.
25
37
  *
@@ -40,7 +40,29 @@ export const txHistoryHash = (tx) => {
40
40
  return Buffer.from(tx.serialize()).toString('hex');
41
41
  }
42
42
  };
43
- const v9FinalizedTransactionTrait = {
43
+ /**
44
+ * The one TTL rule, closed over the ledger whose grace period it reads: a transaction has expired once the earliest of
45
+ * its intent TTLs has passed, or, when it spends dust, carries shielded offers, or carries no intent at all, once the
46
+ * dust grace period after its creation has. The grace period is the wallet's own backstop rather than a ledger rule; it
47
+ * is what lets a rewards or bridge claim, which has no intents and no offers and which the indexer never reports a
48
+ * status for, leave the pending set at all.
49
+ */
50
+ const hasTTLExpiredUnder = (dustGracePeriodSeconds) => (tx, creationTime, now) => {
51
+ const intentTTLs = pipe(tx.intents?.values().toArray() ?? [], Arr.map((i) => i.ttl), Arr.filterMap(DateTime.make));
52
+ const hasDustPayments = pipe(tx.intents?.values().toArray() ?? [], Arr.flatMap((i) => i.dustActions?.spends ?? []), Arr.isNonEmptyArray);
53
+ const hasShieldedOffers = tx.guaranteedOffer != null || (tx.fallibleOffer?.size ?? 0) > 0;
54
+ const hasNoDeadlineOfItsOwn = Arr.isEmptyReadonlyArray(intentTTLs);
55
+ const maybeShieldedTTL = hasDustPayments || hasShieldedOffers || hasNoDeadlineOfItsOwn
56
+ ? pipe(creationTime, DateTime.addDuration(Duration.seconds(Number(dustGracePeriodSeconds()))), Arr.of)
57
+ : Arr.empty();
58
+ return pipe(intentTTLs, Arr.appendAll(maybeShieldedTTL), (arr) => Arr.isNonEmptyReadonlyArray(arr)
59
+ ? Option.some(Arr.min(arr, Order.mapInput(Order.Date, DateTime.toDate)))
60
+ : Option.none(), Option.match({
61
+ onNone: () => false,
62
+ onSome: (finalTTL) => DateTime.distance(finalTTL, now) > 0,
63
+ }));
64
+ };
65
+ export const v9FinalizedTransactionTrait = {
44
66
  areAllTxIdsIncluded(tx, txIds) {
45
67
  const txIdsSet = HashSet.fromIterable(tx.identifiers());
46
68
  const expectedIdSet = HashSet.fromIterable(txIds);
@@ -52,21 +74,7 @@ const v9FinalizedTransactionTrait = {
52
74
  firstId(tx) {
53
75
  return tx.identifiers()[0];
54
76
  },
55
- hasTTLExpired(tx, creationTime, now) {
56
- const defaultShieldedGracePeriod = ledgerV9.LedgerParameters.initialParameters().dust.dustGracePeriodSeconds;
57
- const intentTTLs = pipe(tx.intents?.values().toArray() ?? [], Arr.map((i) => i.ttl), Arr.filterMap(DateTime.make));
58
- const hasDustPayments = pipe(tx.intents?.values().toArray() ?? [], Arr.flatMap((i) => i.dustActions?.spends ?? []), Arr.isNonEmptyArray);
59
- const hasShieldedOffers = tx.guaranteedOffer != null || (tx.fallibleOffer?.size ?? 0) == 0;
60
- const maybeShieldedTTL = hasDustPayments || hasShieldedOffers
61
- ? pipe(creationTime, DateTime.addDuration(Duration.seconds(Number(defaultShieldedGracePeriod))), Arr.of)
62
- : Arr.empty();
63
- return pipe(intentTTLs, Arr.appendAll(maybeShieldedTTL), (arr) => Arr.isNonEmptyReadonlyArray(arr)
64
- ? Option.some(Arr.min(arr, Order.mapInput(Order.Date, DateTime.toDate)))
65
- : Option.none(), Option.match({
66
- onNone: () => false,
67
- onSome: (finalTTL) => DateTime.distance(finalTTL, now) > 0,
68
- }));
69
- },
77
+ hasTTLExpired: hasTTLExpiredUnder(() => ledgerV9.LedgerParameters.initialParameters().dust.dustGracePeriodSeconds),
70
78
  ids(tx) {
71
79
  return tx.identifiers();
72
80
  },
@@ -92,7 +100,7 @@ const v9FinalizedTransactionTrait = {
92
100
  * is the classes that make this a separate trait: `instanceof` distinguishes them, each deserializer refuses the
93
101
  * other's bytes, and a grace period is read off that version's own initial parameters.
94
102
  */
95
- const v8FinalizedTransactionTrait = {
103
+ export const v8FinalizedTransactionTrait = {
96
104
  areAllTxIdsIncluded(tx, txIds) {
97
105
  return HashSet.isSubset(HashSet.fromIterable(tx.identifiers()), HashSet.fromIterable(txIds));
98
106
  },
@@ -102,21 +110,7 @@ const v8FinalizedTransactionTrait = {
102
110
  firstId(tx) {
103
111
  return tx.identifiers()[0];
104
112
  },
105
- hasTTLExpired(tx, creationTime, now) {
106
- const defaultShieldedGracePeriod = ledgerV8.LedgerParameters.initialParameters().dust.dustGracePeriodSeconds;
107
- const intentTTLs = pipe(tx.intents?.values().toArray() ?? [], Arr.map((i) => i.ttl), Arr.filterMap(DateTime.make));
108
- const hasDustPayments = pipe(tx.intents?.values().toArray() ?? [], Arr.flatMap((i) => i.dustActions?.spends ?? []), Arr.isNonEmptyArray);
109
- const hasShieldedOffers = tx.guaranteedOffer != null || (tx.fallibleOffer?.size ?? 0) == 0;
110
- const maybeShieldedTTL = hasDustPayments || hasShieldedOffers
111
- ? pipe(creationTime, DateTime.addDuration(Duration.seconds(Number(defaultShieldedGracePeriod))), Arr.of)
112
- : Arr.empty();
113
- return pipe(intentTTLs, Arr.appendAll(maybeShieldedTTL), (arr) => Arr.isNonEmptyReadonlyArray(arr)
114
- ? Option.some(Arr.min(arr, Order.mapInput(Order.Date, DateTime.toDate)))
115
- : Option.none(), Option.match({
116
- onNone: () => false,
117
- onSome: (finalTTL) => DateTime.distance(finalTTL, now) > 0,
118
- }));
119
- },
113
+ hasTTLExpired: hasTTLExpiredUnder(() => ledgerV8.LedgerParameters.initialParameters().dust.dustGracePeriodSeconds),
120
114
  ids(tx) {
121
115
  return tx.identifiers();
122
116
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@midnight-ntwrk/wallet-sdk-facade",
3
- "version": "5.0.0-rc.0",
3
+ "version": "5.0.0-rc.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -27,13 +27,13 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@midnightntwrk/ledger-v9": "1.0.0-rc.5",
30
- "@midnight-ntwrk/wallet-sdk-abstractions": "3.0.0-rc.0",
30
+ "@midnight-ntwrk/wallet-sdk-abstractions": "3.0.0-rc.1",
31
31
  "@midnight-ntwrk/wallet-sdk-address-format": "4.0.0-rc.0",
32
- "@midnight-ntwrk/wallet-sdk-capabilities": "4.0.0-rc.0",
33
- "@midnight-ntwrk/wallet-sdk-dust-wallet": "5.0.0-rc.0",
34
- "@midnight-ntwrk/wallet-sdk-indexer-client": "2.0.0-rc.0",
35
- "@midnight-ntwrk/wallet-sdk-shielded": "4.0.0-rc.0",
36
- "@midnight-ntwrk/wallet-sdk-unshielded-wallet": "4.0.0-rc.0",
32
+ "@midnight-ntwrk/wallet-sdk-capabilities": "4.0.0-rc.1",
33
+ "@midnight-ntwrk/wallet-sdk-dust-wallet": "5.0.0-rc.1",
34
+ "@midnight-ntwrk/wallet-sdk-indexer-client": "2.0.0-rc.1",
35
+ "@midnight-ntwrk/wallet-sdk-shielded": "4.0.0-rc.1",
36
+ "@midnight-ntwrk/wallet-sdk-unshielded-wallet": "4.0.0-rc.1",
37
37
  "effect": "^3.19.19",
38
38
  "rxjs": "^7.8.2"
39
39
  },