@midnightntwrk/wallet-sdk-dust-wallet 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) Midnight Foundation
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // You may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+ import { Effect, Either, Layer, ParseResult, pipe, Schema, Stream, Duration, Chunk, Schedule, } from 'effect';
14
+ import { Event as LedgerEvent, LedgerParameters, } from '@midnight-ntwrk/ledger-v8';
15
+ import { BlockHash, DustLedgerEvents } from '@midnightntwrk/wallet-sdk-indexer-client';
16
+ import { WsSubscriptionClient, HttpQueryClient, ConnectionHelper, } from '@midnightntwrk/wallet-sdk-indexer-client/effect';
17
+ import { EitherOps, LedgerOps } from '@midnightntwrk/wallet-sdk-utilities';
18
+ import { WsURL } from '@midnightntwrk/wallet-sdk-utilities/networking';
19
+ import { OtherWalletError, SyncWalletError } from './WalletError.js';
20
+ import { getBlockEventsFrom, getLastBlock, } from '@midnightntwrk/wallet-sdk-capabilities/simulation';
21
+ import { CoreWallet } from './CoreWallet.js';
22
+ import { Uint8ArraySchema } from './Serialization.js';
23
+ export const SecretKeysResource = {
24
+ create: (secretKey) => {
25
+ return (cb) => {
26
+ const result = cb(secretKey);
27
+ secretKey.clear();
28
+ return result;
29
+ };
30
+ },
31
+ };
32
+ const LedgerEventSchema = Schema.declare((input) => input instanceof LedgerEvent).annotations({
33
+ identifier: 'ledger.Event',
34
+ });
35
+ const LedgerEventFromUInt8Array = Schema.asSchema(Schema.transformOrFail(Uint8ArraySchema, LedgerEventSchema, {
36
+ encode: (e) => Effect.try({
37
+ try: () => e.serialize(),
38
+ catch: (err) => new ParseResult.Unexpected(err, 'Could not serialize Ledger Event'),
39
+ }),
40
+ decode: (bytes) => Effect.try({
41
+ try: () => LedgerEvent.deserialize(bytes),
42
+ catch: (err) => new ParseResult.Unexpected(err, 'Could not deserialize Ledger Event'),
43
+ }),
44
+ }));
45
+ const HexedEvent = pipe(Schema.Uint8ArrayFromHex, Schema.compose(LedgerEventFromUInt8Array));
46
+ export const SyncEventsUpdateSchema = Schema.Struct({
47
+ id: Schema.Number,
48
+ raw: HexedEvent,
49
+ maxId: Schema.Number,
50
+ });
51
+ export const WalletSyncUpdate = {
52
+ create: (updates, secretKey, timestamp) => {
53
+ return {
54
+ updates,
55
+ secretKey,
56
+ timestamp,
57
+ };
58
+ },
59
+ };
60
+ export const makeDefaultSyncService = (config) => {
61
+ const indexerSyncService = makeIndexerSyncService(config);
62
+ return {
63
+ updates: (state, secretKey) => {
64
+ const batchSize = config.batchUpdates?.size ?? 10;
65
+ const batchTimeout = Duration.millis(config.batchUpdates?.timeout ?? 1);
66
+ const batchSpacing = config.batchUpdates?.spacing ?? 4;
67
+ return pipe(indexerSyncService.subscribeWallet(state), Stream.groupedWithin(batchSize, batchTimeout), Stream.map(Chunk.toArray), Stream.map((data) => WalletSyncUpdate.create(data, secretKey, new Date())), batchSpacing > 0
68
+ ? Stream.schedule(Schedule.spaced(Duration.millis(batchSpacing)))
69
+ : (eventsStream) => eventsStream, Stream.provideSomeLayer(indexerSyncService.connectionLayer()));
70
+ },
71
+ blockData: () => {
72
+ return Effect.gen(function* () {
73
+ const query = yield* BlockHash;
74
+ const result = yield* query({ offset: null });
75
+ return result.block;
76
+ }).pipe(Effect.provide(indexerSyncService.queryClient()), Effect.scoped, Effect.catchAll((err) => Effect.fail(new OtherWalletError({ message: `Encountered unexpected error: ${err.message}`, cause: err }))), Effect.flatMap((blockData) => {
77
+ if (!blockData) {
78
+ throw new OtherWalletError({ message: 'Unable to fetch block data' });
79
+ }
80
+ // TODO: convert to schema
81
+ return LedgerOps.ledgerTry(() => ({
82
+ hash: blockData.hash,
83
+ height: blockData.height,
84
+ ledgerParameters: LedgerParameters.deserialize(Buffer.from(blockData.ledgerParameters, 'hex')),
85
+ timestamp: new Date(blockData.timestamp),
86
+ }));
87
+ }));
88
+ },
89
+ };
90
+ };
91
+ export const makeIndexerSyncService = (config) => {
92
+ return {
93
+ queryClient() {
94
+ return pipe(HttpQueryClient.layer({
95
+ url: config.indexerClientConnection.indexerHttpUrl,
96
+ }), Layer.mapError((error) => new OtherWalletError(error)));
97
+ },
98
+ connectionLayer() {
99
+ const { indexerClientConnection } = config;
100
+ return ConnectionHelper.createWebSocketUrl(indexerClientConnection.indexerHttpUrl, indexerClientConnection.indexerWsUrl).pipe(Either.flatMap((url) => WsURL.make(url)), Either.match({
101
+ onLeft: (error) => Layer.fail(error),
102
+ onRight: (url) => WsSubscriptionClient.layer({ url, keepAlive: indexerClientConnection.keepAlive }),
103
+ }), Layer.mapError((e) => new SyncWalletError({ message: 'Failed to to obtain correct indexer URLs', cause: e })));
104
+ },
105
+ subscribeWallet(state) {
106
+ const { appliedIndex } = state.progress;
107
+ const bufferSize = config.indexerClientConnection.bufferSize ?? 10000;
108
+ const resumeThreshold = config.indexerClientConnection.resumeThreshold ?? 100;
109
+ // The boundary is load-bearing, not waste: this subscription emits only events (no tip/progress
110
+ // sentinel), and `isConnected`/the tip (`maxId`) are set only when an event is received. So the
111
+ // cursor must stay `<= appliedIndex` — never `appliedIndex + 1`. Requesting one event later would
112
+ // deliver nothing to a wallet already at the tip, so `applyUpdate` would never run and sync would
113
+ // hang.
114
+ //
115
+ // A fresh wallet has `appliedIndex === 0n` (the "nothing applied yet" sentinel), so `resumeFrom`
116
+ // is `-1n` and the `variables` mapping below opens the subscription with no `id` — the indexer
117
+ // streams from the very start. A restored wallet has `appliedIndex >= 1`, so `resumeFrom` is
118
+ // `appliedIndex - 1` and the inclusive cursor re-delivers the boundary event.
119
+ const resumeFrom = appliedIndex - 1n;
120
+ return pipe(
121
+ // Backpressure caps the in-flight queue between the WS push and the
122
+ // apply loop. Without it the JS heap grows linearly with catch-up
123
+ // depth, since `Stream.asyncPush({ bufferSize: 'unbounded' })`
124
+ // buffers every event the indexer pushes regardless of apply rate.
125
+ DustLedgerEvents.runWithBackpressure({
126
+ bufferSize,
127
+ resumeThreshold,
128
+ from: resumeFrom,
129
+ // `resumeFrom < 0n` means a fresh wallet: send no `id` so the indexer streams from the very
130
+ // start, rather than relying on `id: 0` sorting below the first real event id.
131
+ variables: (cursor) => ({ id: cursor < 0n ? null : Number(cursor) }),
132
+ key: (r) => BigInt(r.dustLedgerEvents.id),
133
+ }), Stream.mapEffect((subscription) => pipe(Schema.decodeUnknownEither(SyncEventsUpdateSchema)(subscription.dustLedgerEvents), Either.mapLeft((err) => new SyncWalletError(err)), EitherOps.toEffect)), Stream.mapError((error) => new SyncWalletError(error)));
134
+ },
135
+ };
136
+ };
137
+ export const makeDefaultSyncCapability = () => {
138
+ return {
139
+ applyUpdate(state, wrappedUpdate) {
140
+ const { updates, secretKey } = wrappedUpdate;
141
+ // Nothing to update yet
142
+ if (updates.length === 0) {
143
+ return [state, { changes: [], protocolVersion: Number(state.protocolVersion) }];
144
+ }
145
+ const appliedIndex = state.progress.appliedIndex;
146
+ const freshUpdates = updates.filter((u) => BigInt(u.id) > appliedIndex);
147
+ const highestRelevantWalletIndex = BigInt(updates.at(-1).maxId);
148
+ const [newState, changes] = freshUpdates.length === 0
149
+ ? [state, []]
150
+ : CoreWallet.applyEventsWithChanges(state, secretKey, freshUpdates.map((u) => u.raw), wrappedUpdate.timestamp);
151
+ const updatedState = CoreWallet.updateProgress(newState, {
152
+ appliedIndex: freshUpdates.length === 0 ? appliedIndex : BigInt(freshUpdates.at(-1).id),
153
+ highestRelevantWalletIndex,
154
+ isConnected: true,
155
+ });
156
+ return [updatedState, { changes, protocolVersion: Number(updatedState.protocolVersion) }];
157
+ },
158
+ };
159
+ };
160
+ export const makeSimulatorSyncService = (config) => {
161
+ return {
162
+ updates: (_state, secretKey) => {
163
+ // Get the initial state immediately to ensure we process the genesis block.
164
+ // Then subscribe to state$ for subsequent changes, but deduplicate by block number
165
+ // to avoid processing the same block twice.
166
+ let lastSeenBlockNumber;
167
+ return pipe(Stream.fromEffect(config.simulator.getLatestState()), Stream.concat(config.simulator.state$), Stream.filter((state) => {
168
+ const lastBlock = getLastBlock(state);
169
+ if (lastBlock === undefined) {
170
+ return false; // Skip blank state
171
+ }
172
+ const blockNumber = lastBlock.number;
173
+ // Skip if we've already seen this block (deduplication)
174
+ if (lastSeenBlockNumber !== undefined && blockNumber <= lastSeenBlockNumber) {
175
+ return false;
176
+ }
177
+ lastSeenBlockNumber = blockNumber;
178
+ return true;
179
+ }), Stream.map((state) => ({ update: state, secretKey })));
180
+ },
181
+ blockData: () => {
182
+ return Effect.gen(function* () {
183
+ const state = yield* config.simulator.getLatestState();
184
+ const lastBlock = getLastBlock(state);
185
+ // Use currentTime instead of lastBlock.timestamp for time-sensitive operations
186
+ // (e.g., Dust generation calculation). The currentTime reflects any fast-forwarding
187
+ // that has been done, while lastBlock.timestamp only reflects when the block was produced.
188
+ return {
189
+ hash: lastBlock.hash,
190
+ height: Number(lastBlock.number),
191
+ ledgerParameters: state.ledger.parameters,
192
+ timestamp: state.currentTime,
193
+ };
194
+ });
195
+ },
196
+ };
197
+ };
198
+ export const makeSimulatorSyncCapability = () => {
199
+ return {
200
+ applyUpdate: (state, update) => {
201
+ const lastBlock = getLastBlock(update.update);
202
+ // If no block exists yet (blank simulator), skip update
203
+ if (lastBlock === undefined) {
204
+ return [state, { changes: [], protocolVersion: Number(state.protocolVersion) }];
205
+ }
206
+ // Get all events from blocks starting at appliedIndex (the next block to process).
207
+ // appliedIndex semantics: the first block number we haven't processed yet.
208
+ // Initial: appliedIndex = 0 (haven't processed any blocks)
209
+ // After processing block N: appliedIndex = N + 1 (next block to process)
210
+ const events = [...getBlockEventsFrom(update.update, state.progress.appliedIndex)];
211
+ const [newState, changes] = CoreWallet.applyEventsWithChanges(state, update.secretKey, events, lastBlock.timestamp);
212
+ const updatedState = CoreWallet.updateProgress(newState, {
213
+ appliedIndex: lastBlock.number + 1n,
214
+ });
215
+ return [updatedState, { changes, protocolVersion: Number(updatedState.protocolVersion) }];
216
+ },
217
+ };
218
+ };
@@ -0,0 +1,97 @@
1
+ import { Either, Option } from 'effect';
2
+ import { type DustSecretKey, type Signature, type SignatureVerifyingKey, type FinalizedTransaction, type ProofErasedTransaction, type UnprovenTransaction, type LedgerParameters } from '@midnight-ntwrk/ledger-v8';
3
+ import { type DustAddress } from '@midnightntwrk/wallet-sdk-address-format';
4
+ import { type WalletError } from './WalletError.js';
5
+ import { CoreWallet } from './CoreWallet.js';
6
+ import { type AnyTransaction, type Dust, type NetworkId, type TotalCostParameters } from './types/index.js';
7
+ import { type CoinsAndBalancesCapability, type CoinSelection, type CoinWithValue, type UtxoWithFullDustDetails } from './CoinsAndBalances.js';
8
+ import { type KeysCapability } from './Keys.js';
9
+ /**
10
+ * Result of splitting Night UTxOs into the guaranteed/fallible sections of a Dust registration or deregistration
11
+ * intent, together with the fee-payment allowance the registration may claim from dust generated by the guaranteed
12
+ * UTxO.
13
+ *
14
+ * For deregistration, `feePayment` is always `0n`.
15
+ */
16
+ export type NightUtxoSplitForDustRegistration = {
17
+ readonly guaranteedUtxos: ReadonlyArray<UtxoWithFullDustDetails>;
18
+ readonly fallibleUtxos: ReadonlyArray<UtxoWithFullDustDetails>;
19
+ readonly feePayment: bigint;
20
+ };
21
+ export interface TransactingCapability<TSecrets, TState, _TTransaction> {
22
+ readonly networkId: NetworkId;
23
+ readonly costParams: TotalCostParameters;
24
+ createDustGenerationTransaction(currentTime: Date, ttl: Date, nightUtxos: ReadonlyArray<UtxoWithFullDustDetails>, nightVerifyingKey: SignatureVerifyingKey, dustReceiverAddress: DustAddress | undefined): Either.Either<UnprovenTransaction, WalletError>;
25
+ /**
26
+ * Decides which Night UTxOs belong in the guaranteed vs fallible section of a dust registration/deregistration
27
+ * transaction, and computes the fee-payment allowance the registration may claim from dust generated by the
28
+ * guaranteed UTxO.
29
+ *
30
+ * For deregistration (`isRegistration === false`) the fee-payment allowance is always `0n`.
31
+ */
32
+ splitNightUtxosForDustRegistration(utxosWithDustValue: ReadonlyArray<UtxoWithFullDustDetails>, isRegistration: boolean): NightUtxoSplitForDustRegistration;
33
+ /**
34
+ * Attaches a Dust registration / deregistration action onto the intent (at segment 1) of an unproven transaction
35
+ * whose offers have already been built (typically by the unshielded wallet's `rotateUtxos`). The Night UTxOs are
36
+ * assumed to already appear in the intent's guaranteed/fallible offers and to already be booked in the unshielded
37
+ * wallet state.
38
+ */
39
+ attachDustRegistration(transaction: UnprovenTransaction, currentTime: Date, nightVerifyingKey: SignatureVerifyingKey, dustReceiverAddress: DustAddress | undefined, feePayment: bigint): Either.Either<UnprovenTransaction, WalletError>;
40
+ addDustGenerationSignature(transaction: UnprovenTransaction, signature: Signature): Either.Either<UnprovenTransaction, WalletError>;
41
+ /**
42
+ * Attaches a signature to the DustRegistration in segment 1's `dustActions` only. Unlike
43
+ * {@link addDustGenerationSignature}, this does NOT touch the unshielded offers — those are expected to be signed via
44
+ * the unshielded-wallet signing path. Use this when the caller orchestrates signing across both packages (e.g. the
45
+ * facade's `signRecipe`).
46
+ */
47
+ addDustRegistrationSignature(transaction: UnprovenTransaction, signature: Signature): Either.Either<UnprovenTransaction, WalletError>;
48
+ calculateFee(transaction: AnyTransaction, ledgerParams: LedgerParameters): bigint;
49
+ estimateFee(secretKey: TSecrets, state: TState, transactions: ReadonlyArray<AnyTransaction>, ttl: Date, currentTime: Date, ledgerParams: LedgerParameters): Either.Either<bigint, WalletError>;
50
+ balanceTransactions(secretKey: TSecrets, state: TState, transactions: ReadonlyArray<AnyTransaction>, ttl: Date, currentTime: Date, ledgerParams: LedgerParameters): Either.Either<[UnprovenTransaction, TState], WalletError>;
51
+ revertTransaction(state: TState, transaction: AnyTransaction): Either.Either<TState, WalletError>;
52
+ }
53
+ export type DefaultTransactingConfiguration = {
54
+ networkId: NetworkId;
55
+ costParameters: TotalCostParameters;
56
+ };
57
+ export type DefaultTransactingContext = {
58
+ coinSelection: CoinSelection;
59
+ coinsAndBalancesCapability: CoinsAndBalancesCapability<CoreWallet>;
60
+ keysCapability: KeysCapability<CoreWallet>;
61
+ };
62
+ export declare const makeDefaultTransactingCapability: (config: DefaultTransactingConfiguration, getContext: () => DefaultTransactingContext) => TransactingCapability<DustSecretKey, CoreWallet, FinalizedTransaction>;
63
+ export declare const makeSimulatorTransactingCapability: (config: DefaultTransactingConfiguration, getContext: () => DefaultTransactingContext) => TransactingCapability<DustSecretKey, CoreWallet, ProofErasedTransaction>;
64
+ /**
65
+ * Distributes the fee across multiple inputs, draining smaller inputs first when the fee exceeds any single input's
66
+ * value. Finds the next available intent segment id in a transaction.
67
+ *
68
+ * Fallible intent segments occupy the range `[1, 65535]`; segment `0` is reserved for the guaranteed section and is
69
+ * never returned.
70
+ *
71
+ * @param transaction - Transaction whose intent map is inspected.
72
+ * @returns `Some(segmentId)` with the lowest unused id, or `None` if all 65535 fallible segments are taken.
73
+ */
74
+ export declare const findAvailableSegmentId: (transaction: AnyTransaction) => Option.Option<number>;
75
+ export declare class TransactingCapabilityImplementation<TTransaction extends AnyTransaction> implements TransactingCapability<DustSecretKey, CoreWallet, TTransaction> {
76
+ readonly networkId: string;
77
+ readonly costParams: TotalCostParameters;
78
+ readonly getCoinSelection: () => CoinSelection;
79
+ readonly getCoins: () => CoinsAndBalancesCapability<CoreWallet>;
80
+ readonly getKeys: () => KeysCapability<CoreWallet>;
81
+ constructor(networkId: NetworkId, costParams: TotalCostParameters, getCoinSelection: () => CoinSelection, getCoins: () => CoinsAndBalancesCapability<CoreWallet>, getKeys: () => KeysCapability<CoreWallet>);
82
+ createDustGenerationTransaction(currentTime: Date, ttl: Date, nightUtxos: ReadonlyArray<UtxoWithFullDustDetails>, nightVerifyingKey: SignatureVerifyingKey, dustReceiverAddress: DustAddress | undefined): Either.Either<UnprovenTransaction, WalletError>;
83
+ splitNightUtxosForDustRegistration(utxosWithDustValue: ReadonlyArray<UtxoWithFullDustDetails>, isRegistration: boolean): NightUtxoSplitForDustRegistration;
84
+ attachDustRegistration(transaction: UnprovenTransaction, currentTime: Date, nightVerifyingKey: SignatureVerifyingKey, dustReceiverAddress: DustAddress | undefined, feePayment: bigint): Either.Either<UnprovenTransaction, WalletError>;
85
+ addDustRegistrationSignature(transaction: UnprovenTransaction, signatureData: Signature): Either.Either<UnprovenTransaction, WalletError>;
86
+ addDustGenerationSignature(transaction: UnprovenTransaction, signatureData: Signature): Either.Either<UnprovenTransaction, WalletError>;
87
+ calculateFee(transaction: AnyTransaction, ledgerParams: LedgerParameters): bigint;
88
+ dryRunFee(recipeInputs: ReadonlyArray<CoinWithValue<Dust>>, transactions: ReadonlyArray<FinalizedTransaction | UnprovenTransaction>, secretKey: DustSecretKey, state: CoreWallet, ttl: Date, currentTime: Date, ledgerParams: LedgerParameters): bigint;
89
+ static feeImbalance(transaction: AnyTransaction, totalFee: bigint): bigint;
90
+ computeBalancingRecipe(secretKey: DustSecretKey, state: CoreWallet, transactions: ReadonlyArray<FinalizedTransaction | UnprovenTransaction>, ttl: Date, currentTime: Date, ledgerParams: LedgerParameters): Either.Either<{
91
+ fee: bigint;
92
+ recipeInputs: ReadonlyArray<CoinWithValue<Dust>>;
93
+ }, WalletError>;
94
+ estimateFee(secretKey: DustSecretKey, state: CoreWallet, transactions: ReadonlyArray<FinalizedTransaction | UnprovenTransaction>, ttl: Date, currentTime: Date, ledgerParams: LedgerParameters): Either.Either<bigint, WalletError>;
95
+ balanceTransactions(secretKey: DustSecretKey, state: CoreWallet, transactions: ReadonlyArray<FinalizedTransaction | UnprovenTransaction>, ttl: Date, currentTime: Date, ledgerParams: LedgerParameters): Either.Either<[UnprovenTransaction, CoreWallet], WalletError>;
96
+ revertTransaction(state: CoreWallet, transaction: UnprovenTransaction | TTransaction): Either.Either<CoreWallet, WalletError>;
97
+ }
@@ -0,0 +1,327 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) Midnight Foundation
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // You may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+ import { Effect, Either, pipe, BigInt as BigIntOps, Iterable as IterableOps, Option } from 'effect';
14
+ import { DustActions, DustRegistration, Intent, SignatureEnabled, Transaction, UnshieldedOffer, addressFromKey, nativeToken, } from '@midnight-ntwrk/ledger-v8';
15
+ import { OtherWalletError, TransactingError, InsufficientFundsError } from './WalletError.js';
16
+ import { LedgerOps } from '@midnightntwrk/wallet-sdk-utilities';
17
+ import { CoreWallet } from './CoreWallet.js';
18
+ import { BindingMarker, ProofMarker, SignatureMarker } from './Utils.js';
19
+ import { getBalanceRecipe, Imbalances as CapImbalances, InsufficientFundsError as BalancingInsufficientFundsError, } from '@midnightntwrk/wallet-sdk-capabilities';
20
+ export const makeDefaultTransactingCapability = (config, getContext) => {
21
+ return new TransactingCapabilityImplementation(config.networkId, config.costParameters, () => getContext().coinSelection, () => getContext().coinsAndBalancesCapability, () => getContext().keysCapability);
22
+ };
23
+ export const makeSimulatorTransactingCapability = (config, getContext) => {
24
+ return new TransactingCapabilityImplementation(config.networkId, config.costParameters, () => getContext().coinSelection, () => getContext().coinsAndBalancesCapability, () => getContext().keysCapability);
25
+ };
26
+ /**
27
+ * Distributes the fee across multiple inputs, draining smaller inputs first when the fee exceeds any single input's
28
+ * value. Finds the next available intent segment id in a transaction.
29
+ *
30
+ * Fallible intent segments occupy the range `[1, 65535]`; segment `0` is reserved for the guaranteed section and is
31
+ * never returned.
32
+ *
33
+ * @param transaction - Transaction whose intent map is inspected.
34
+ * @returns `Some(segmentId)` with the lowest unused id, or `None` if all 65535 fallible segments are taken.
35
+ */
36
+ export const findAvailableSegmentId = (transaction) => {
37
+ const used = new Set(transaction.intents?.keys() ?? []);
38
+ return pipe(IterableOps.range(1, 65535), IterableOps.findFirst((segmentId) => !used.has(segmentId)));
39
+ };
40
+ /**
41
+ * Distributes the fee across multiple inputs, draining smaller inputs first when the fee exceeds any single input's
42
+ * value.
43
+ */
44
+ const distributeFeeAcrossInputs = (inputs, fee) => inputs.reduce(({ result, remaining }, input) => {
45
+ const deduction = remaining >= input.value ? input.value : remaining;
46
+ return { result: [...result, { ...input, value: deduction }], remaining: remaining - deduction };
47
+ }, { result: [], remaining: fee }).result;
48
+ export class TransactingCapabilityImplementation {
49
+ networkId;
50
+ costParams;
51
+ getCoinSelection;
52
+ getCoins;
53
+ getKeys;
54
+ constructor(networkId, costParams, getCoinSelection, getCoins, getKeys) {
55
+ this.getCoins = getCoins;
56
+ this.networkId = networkId;
57
+ this.costParams = costParams;
58
+ this.getCoinSelection = getCoinSelection;
59
+ this.getKeys = getKeys;
60
+ }
61
+ createDustGenerationTransaction(currentTime, ttl, nightUtxos, nightVerifyingKey, dustReceiverAddress) {
62
+ const makeOffer = (utxos) => {
63
+ if (utxos.length === 0) {
64
+ return Option.none();
65
+ }
66
+ const totalValue = pipe(utxos, IterableOps.map((coin) => coin.utxo.value), BigIntOps.sumAll);
67
+ const inputs = utxos.map(({ utxo }) => ({
68
+ ...utxo,
69
+ owner: nightVerifyingKey,
70
+ }));
71
+ const output = {
72
+ owner: addressFromKey(nightVerifyingKey),
73
+ type: nativeToken().raw,
74
+ value: totalValue,
75
+ };
76
+ return Option.some(UnshieldedOffer.new(inputs, [output], []));
77
+ };
78
+ return Either.gen(this, function* () {
79
+ const receiver = dustReceiverAddress ? dustReceiverAddress.data : undefined;
80
+ return yield* LedgerOps.ledgerTry(() => {
81
+ const network = this.networkId;
82
+ const splitResult = this.getCoins().splitNightUtxos(nightUtxos);
83
+ // if receiver is `undefined`, it means the coin(s) are being deregistered so the allowFeePayment should not be used
84
+ const feePayment = receiver
85
+ ? pipe(splitResult.guaranteed, IterableOps.filter((coin) => !coin.utxo.registeredForDustGeneration), IterableOps.map((coin) => coin.dust.generatedNow), BigIntOps.sumAll)
86
+ : 0n;
87
+ const maybeGuaranteedOffer = makeOffer(splitResult.guaranteed);
88
+ const maybeFallibleOffer = makeOffer(splitResult.fallible);
89
+ const dustRegistration = new DustRegistration(SignatureMarker.signature, nightVerifyingKey, receiver, feePayment);
90
+ const dustActions = new DustActions(SignatureMarker.signature, ProofMarker.preProof, currentTime, [], [dustRegistration]);
91
+ const intent = pipe(Intent.new(ttl), (intent) => Option.match(maybeGuaranteedOffer, {
92
+ onNone: () => intent,
93
+ onSome: (guaranteedOffer) => {
94
+ intent.guaranteedUnshieldedOffer = guaranteedOffer;
95
+ return intent;
96
+ },
97
+ }), (intent) => Option.match(maybeFallibleOffer, {
98
+ onNone: () => intent,
99
+ onSome: (fallibleOffer) => {
100
+ intent.fallibleUnshieldedOffer = fallibleOffer;
101
+ return intent;
102
+ },
103
+ }), (intent) => {
104
+ intent.dustActions = dustActions;
105
+ return intent;
106
+ });
107
+ return Transaction.fromParts(network, undefined, undefined, intent);
108
+ });
109
+ });
110
+ }
111
+ splitNightUtxosForDustRegistration(utxosWithDustValue, isRegistration) {
112
+ const splitResult = this.getCoins().splitNightUtxos(utxosWithDustValue);
113
+ // Deregistration must not claim dust as fee payment (matches the historical
114
+ // `dustReceiverAddress === undefined` branch of createDustGenerationTransaction).
115
+ const feePayment = isRegistration
116
+ ? pipe(splitResult.guaranteed, IterableOps.filter((coin) => !coin.utxo.registeredForDustGeneration), IterableOps.map((coin) => coin.dust.generatedNow), BigIntOps.sumAll)
117
+ : 0n;
118
+ return {
119
+ guaranteedUtxos: splitResult.guaranteed,
120
+ fallibleUtxos: splitResult.fallible,
121
+ feePayment,
122
+ };
123
+ }
124
+ attachDustRegistration(transaction, currentTime, nightVerifyingKey, dustReceiverAddress, feePayment) {
125
+ return Either.gen(this, function* () {
126
+ const intent = transaction.intents?.get(1);
127
+ if (!intent) {
128
+ return yield* Either.left(new TransactingError({
129
+ message: 'No intent found at segment 1; expected an intent built by rotateUtxos',
130
+ }));
131
+ }
132
+ if (intent.dustActions !== undefined && intent.dustActions.registrations.length > 0) {
133
+ return yield* Either.left(new TransactingError({ message: 'Intent at segment 1 already has a dust registration attached' }));
134
+ }
135
+ return yield* LedgerOps.ledgerTry(() => {
136
+ const receiver = dustReceiverAddress ? dustReceiverAddress.data : undefined;
137
+ const dustRegistration = new DustRegistration(SignatureMarker.signature, nightVerifyingKey, receiver, feePayment);
138
+ const dustActions = new DustActions(SignatureMarker.signature, ProofMarker.preProof, currentTime, [], [dustRegistration]);
139
+ // Intents fetched from `transaction.intents.get(...)` behave like value copies — assigning
140
+ // to a field doesn't propagate back. Match the addDustGenerationSignature pattern: copy the
141
+ // intent via serialize/deserialize, mutate the copy, then set it back into a fresh
142
+ // transaction's intents map.
143
+ const newIntent = Intent.deserialize(SignatureMarker.signature, ProofMarker.preProof, BindingMarker.preBinding, intent.serialize());
144
+ newIntent.dustActions = dustActions;
145
+ const newTransaction = Transaction.deserialize(SignatureMarker.signature, ProofMarker.preProof, BindingMarker.preBinding, transaction.serialize());
146
+ newTransaction.intents = newTransaction.intents.set(1, newIntent);
147
+ return newTransaction;
148
+ });
149
+ });
150
+ }
151
+ addDustRegistrationSignature(transaction, signatureData) {
152
+ return Either.gen(this, function* () {
153
+ const intent = transaction.intents?.get(1);
154
+ if (!intent) {
155
+ return yield* Either.left(new TransactingError({ message: 'No intent found in the transaction intents with segment = 1' }));
156
+ }
157
+ const { dustActions } = intent;
158
+ if (!dustActions) {
159
+ return yield* Either.left(new TransactingError({ message: 'No dustActions found in intent' }));
160
+ }
161
+ const [registration, ...restRegistrations] = dustActions.registrations;
162
+ if (!registration) {
163
+ return yield* Either.left(new TransactingError({ message: 'No registrations found in dustActions' }));
164
+ }
165
+ return yield* LedgerOps.ledgerTry(() => {
166
+ const signature = new SignatureEnabled(signatureData);
167
+ const registrationWithSignature = new DustRegistration(signature.instance, registration.nightKey, registration.dustAddress, registration.allowFeePayment, signature);
168
+ const newDustActions = new DustActions(signature.instance, ProofMarker.preProof, dustActions.ctime, dustActions.spends, [registrationWithSignature, ...restRegistrations]);
169
+ const newIntent = Intent.deserialize(signature.instance, ProofMarker.preProof, BindingMarker.preBinding, intent.serialize());
170
+ newIntent.dustActions = newDustActions;
171
+ const newTransaction = Transaction.deserialize(signature.instance, ProofMarker.preProof, BindingMarker.preBinding, transaction.serialize());
172
+ newTransaction.intents = newTransaction.intents.set(1, newIntent);
173
+ return newTransaction;
174
+ });
175
+ });
176
+ }
177
+ addDustGenerationSignature(transaction, signatureData) {
178
+ return Either.gen(this, function* () {
179
+ const intent = transaction.intents?.get(1);
180
+ if (!intent) {
181
+ return yield* Either.left(new TransactingError({ message: 'No intent found in the transaction intents with segment = 1' }));
182
+ }
183
+ const { dustActions, guaranteedUnshieldedOffer, fallibleUnshieldedOffer } = intent;
184
+ if (!dustActions) {
185
+ return yield* Either.left(new TransactingError({ message: 'No dustActions found in intent' }));
186
+ }
187
+ if (!guaranteedUnshieldedOffer) {
188
+ return yield* Either.left(new TransactingError({ message: 'No guaranteedUnshieldedOffer found in intent' }));
189
+ }
190
+ const [registration, ...restRegistrations] = dustActions.registrations;
191
+ if (!registration) {
192
+ return yield* Either.left(new TransactingError({ message: 'No registrations found in dustActions' }));
193
+ }
194
+ return yield* LedgerOps.ledgerTry(() => {
195
+ const signature = new SignatureEnabled(signatureData);
196
+ const registrationWithSignature = new DustRegistration(signature.instance, registration.nightKey, registration.dustAddress, registration.allowFeePayment, signature);
197
+ const newDustActions = new DustActions(signature.instance, ProofMarker.preProof, dustActions.ctime, dustActions.spends, [registrationWithSignature, ...restRegistrations]);
198
+ // make a copy of intent to avoid mutation
199
+ const newIntent = Intent.deserialize(signature.instance, ProofMarker.preProof, BindingMarker.preBinding, intent.serialize());
200
+ newIntent.dustActions = newDustActions;
201
+ const inputsLen = guaranteedUnshieldedOffer.inputs.length;
202
+ const signatures = [];
203
+ for (let i = 0; i < inputsLen; ++i) {
204
+ signatures.push(guaranteedUnshieldedOffer.signatures.at(i) ?? signatureData);
205
+ }
206
+ newIntent.guaranteedUnshieldedOffer = guaranteedUnshieldedOffer.addSignatures(signatures);
207
+ if (fallibleUnshieldedOffer) {
208
+ const inputsLen = fallibleUnshieldedOffer.inputs.length;
209
+ const signatures = [];
210
+ for (let i = 0; i < inputsLen; ++i) {
211
+ signatures.push(fallibleUnshieldedOffer.signatures.at(i) ?? signatureData);
212
+ }
213
+ newIntent.fallibleUnshieldedOffer = fallibleUnshieldedOffer.addSignatures(signatures);
214
+ }
215
+ // make a copy of transaction to avoid mutation
216
+ const newTransaction = Transaction.deserialize(signature.instance, ProofMarker.preProof, BindingMarker.preBinding, transaction.serialize());
217
+ newTransaction.intents = newTransaction.intents.set(1, newIntent);
218
+ return newTransaction;
219
+ });
220
+ });
221
+ }
222
+ calculateFee(transaction, ledgerParams) {
223
+ return (transaction.feesWithMargin(ledgerParams, this.costParams.feeBlocksMargin) +
224
+ (this.costParams.additionalFeeOverhead ?? 0n));
225
+ }
226
+ dryRunFee(recipeInputs, transactions, secretKey, state, ttl, currentTime, ledgerParams) {
227
+ const network = this.networkId;
228
+ // Create a balancing tx from recipe inputs without persisting state changes
229
+ const [spends] = CoreWallet.spendCoins(state, secretKey, recipeInputs, currentTime);
230
+ const intent = Intent.new(ttl);
231
+ intent.dustActions = new DustActions(SignatureMarker.signature, ProofMarker.preProof, currentTime, [...spends], []);
232
+ // Merge existing transactions first so we can pick a segment that doesn't collide
233
+ const [first, ...rest] = transactions.map((tx) => tx.eraseProofs());
234
+ const mergedExisting = first ? rest.reduce((acc, tx) => acc.merge(tx), first) : undefined;
235
+ const segmentId = mergedExisting ? Option.getOrElse(findAvailableSegmentId(mergedExisting), () => 1) : 1;
236
+ const balancingTx = Transaction.fromParts(network).addIntent({ tag: 'specific', value: segmentId }, intent);
237
+ const erasedBalancing = balancingTx.eraseProofs();
238
+ const mergedTx = mergedExisting ? mergedExisting.merge(erasedBalancing) : erasedBalancing;
239
+ return this.calculateFee(mergedTx, ledgerParams);
240
+ }
241
+ static feeImbalance(transaction, totalFee) {
242
+ const [_, imbalance] = transaction
243
+ .imbalances(0, totalFee)
244
+ .entries()
245
+ .find(([tt, _]) => tt.tag === 'dust') ?? [];
246
+ return imbalance ?? 0n;
247
+ }
248
+ computeBalancingRecipe(secretKey, state, transactions, ttl, currentTime, ledgerParams) {
249
+ const initialFees = transactions.reduce((total, transaction) => total +
250
+ TransactingCapabilityImplementation.feeImbalance(transaction, this.calculateFee(transaction, ledgerParams)), 0n);
251
+ const dust = this.getCoins().getAvailableCoinsWithGeneratedDust(state, currentTime);
252
+ return pipe(Effect.iterate({ currentFee: initialFees, recipeInputs: [], converged: false }, {
253
+ while: (s) => !s.converged,
254
+ body: ({ currentFee }) => Effect.try({
255
+ try: () => {
256
+ const recipe = getBalanceRecipe({
257
+ coins: dust.map((coin) => ({
258
+ type: 'dust',
259
+ value: coin.value,
260
+ token: coin.token,
261
+ })),
262
+ initialImbalances: CapImbalances.fromEntry('dust', currentFee),
263
+ feeTokenType: 'dust',
264
+ coinSelection: this.getCoinSelection(),
265
+ transactionCostModel: {
266
+ inputFeeOverhead: 0n,
267
+ outputFeeOverhead: 0n,
268
+ },
269
+ createOutput: (coin) => coin,
270
+ isCoinEqual: (a, b) => a.token.nonce === b.token.nonce,
271
+ });
272
+ const recipeInputs = recipe.inputs.map(({ token, value }) => ({ token, value }));
273
+ const newFee = this.dryRunFee(recipeInputs, transactions, secretKey, state, ttl, currentTime, ledgerParams);
274
+ const recipeAmountCoverage = recipeInputs.reduce((sum, input) => sum + input.value, 0n);
275
+ return { currentFee: newFee, recipeInputs, converged: newFee <= recipeAmountCoverage };
276
+ },
277
+ catch: (err) => {
278
+ if (err instanceof BalancingInsufficientFundsError) {
279
+ return new InsufficientFundsError({
280
+ message: err.message,
281
+ tokenType: err.tokenType,
282
+ });
283
+ }
284
+ else {
285
+ return new OtherWalletError({
286
+ message: err instanceof Error ? err.message : 'Dust balancing failed',
287
+ cause: err,
288
+ });
289
+ }
290
+ },
291
+ }),
292
+ }), Effect.either, Effect.runSync, Either.map(({ currentFee, recipeInputs }) => ({
293
+ fee: currentFee,
294
+ recipeInputs: distributeFeeAcrossInputs(recipeInputs, currentFee),
295
+ })));
296
+ }
297
+ estimateFee(secretKey, state, transactions, ttl, currentTime, ledgerParams) {
298
+ return pipe(this.computeBalancingRecipe(secretKey, state, transactions, ttl, currentTime, ledgerParams), Either.map(({ fee }) => fee));
299
+ }
300
+ balanceTransactions(secretKey, state, transactions, ttl, currentTime, ledgerParams) {
301
+ const networkId = this.networkId;
302
+ return pipe(this.computeBalancingRecipe(secretKey, state, transactions, ttl, currentTime, ledgerParams), Either.flatMap(({ recipeInputs }) => {
303
+ return LedgerOps.ledgerTry(() => {
304
+ const intent = Intent.new(ttl);
305
+ const [spends, updatedState] = CoreWallet.spendCoins(state, secretKey, recipeInputs, currentTime);
306
+ intent.dustActions = new DustActions(SignatureMarker.signature, ProofMarker.preProof, currentTime, [...spends], []);
307
+ // Merge existing transactions first so we can pick a segment that doesn't collide
308
+ const [first, ...rest] = transactions.map((tx) => tx.eraseProofs());
309
+ const mergedExisting = first ? rest.reduce((acc, tx) => acc.merge(tx), first) : undefined;
310
+ const segmentId = mergedExisting ? Option.getOrElse(findAvailableSegmentId(mergedExisting), () => 1) : 1;
311
+ const feeTransaction = Transaction.fromParts(networkId).addIntent({ tag: 'specific', value: segmentId }, intent);
312
+ return [feeTransaction, updatedState];
313
+ });
314
+ }));
315
+ }
316
+ revertTransaction(state, transaction) {
317
+ return Either.try({
318
+ try: () => CoreWallet.revertTransaction(state, transaction),
319
+ catch: (err) => {
320
+ return new OtherWalletError({
321
+ message: `Error while reverting transaction ${transaction.identifiers().at(0)}`,
322
+ cause: err,
323
+ });
324
+ },
325
+ });
326
+ }
327
+ }