@midnight-ntwrk/wallet-sdk-unshielded-wallet 1.0.0-beta.17 → 1.0.0-beta.19

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.
@@ -11,184 +11,142 @@
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
13
  import * as ledger from '@midnight-ntwrk/ledger-v7';
14
- import { Either, Option, pipe } from 'effect';
14
+ import { Either, Option, pipe, Array as Arr } from 'effect';
15
15
  import { CoreWallet } from './CoreWallet.js';
16
- import { SignError, TransactingError } from './WalletError.js';
17
- import { getBalanceRecipe, Imbalances } from '@midnight-ntwrk/wallet-sdk-capabilities';
18
- import { isIntentBound, TransactionTrait } from './Transaction.js';
16
+ import { InsufficientFundsError, OtherWalletError, SignError, TransactingError } from './WalletError.js';
17
+ import { getBalanceRecipe, Imbalances, InsufficientFundsError as BalancingInsufficientFundsError, } from '@midnight-ntwrk/wallet-sdk-capabilities';
18
+ import { TransactionOps } from './TransactionOps.js';
19
19
  import { MidnightBech32m, UnshieldedAddress } from '@midnight-ntwrk/wallet-sdk-address-format';
20
20
  const GUARANTEED_SEGMENT = 0;
21
- const mergeCounterOffer = (counterOffer, currentOffer) => pipe(Option.fromNullable(currentOffer), Option.match({
22
- onNone: () => Either.right(counterOffer),
23
- onSome: (currentOffer) => Either.try({
24
- try: () => ledger.UnshieldedOffer.new([...currentOffer.inputs, ...counterOffer.inputs], [...currentOffer.outputs, ...counterOffer.outputs], [...currentOffer.signatures, ...counterOffer.signatures]),
25
- catch: (error) => new TransactingError({ message: 'Failed to merge counter offers', cause: error }),
26
- }),
27
- }));
28
21
  export const makeDefaultTransactingCapability = (config, getContext) => {
29
- return new TransactingCapabilityImplementation(config.networkId, () => getContext().coinSelection, () => getContext().coinsAndBalancesCapability, () => getContext().keysCapability, TransactionTrait.default);
22
+ return new TransactingCapabilityImplementation(config.networkId, () => getContext().coinSelection, () => getContext().coinsAndBalancesCapability, () => getContext().keysCapability, TransactionOps);
30
23
  };
31
24
  export class TransactingCapabilityImplementation {
32
25
  networkId;
33
26
  getCoinSelection;
34
- txTrait;
27
+ txOps;
35
28
  getCoins;
36
29
  getKeys;
37
- constructor(networkId, getCoinSelection, getCoins, getKeys, txTrait) {
30
+ constructor(networkId, getCoinSelection, getCoins, getKeys, txOps) {
38
31
  this.getCoins = getCoins;
39
32
  this.networkId = networkId;
40
33
  this.getCoinSelection = getCoinSelection;
41
34
  this.getKeys = getKeys;
42
- this.txTrait = txTrait;
35
+ this.txOps = txOps;
43
36
  }
44
- balanceTransaction(wallet, transaction) {
37
+ /**
38
+ * Balances an unbound transaction
39
+ * Note: Unbound transactions are balanced in place and returned
40
+ * @param wallet - The wallet to balance the transaction with
41
+ * @param transaction - The transaction to balance
42
+ * @returns The balanced transaction and the new wallet state if successful, otherwise an error
43
+ */
44
+ balanceUnboundTransaction(wallet, transaction) {
45
+ return this.#balanceUnboundishTransaction(wallet, transaction);
46
+ }
47
+ /**
48
+ * Balances an unproven transaction
49
+ * Note: This method does the same thing as balanceUnboundTransaction but is provided for convenience and type safety
50
+ * @param wallet - The wallet to balance the transaction with
51
+ * @param transaction - The transaction to balance
52
+ * @returns The balanced transaction and the new wallet state if successful, otherwise an error
53
+ */
54
+ balanceUnprovenTransaction(wallet, transaction) {
55
+ return this.#balanceUnboundishTransaction(wallet, transaction);
56
+ }
57
+ /**
58
+ * Balances a bound transaction
59
+ * Note: In bound transactions we can only balance the guaranteed section in intents
60
+ * @param wallet - The wallet to balance the transaction with
61
+ * @param transaction - The transaction to balance
62
+ * @returns A balancing counterpart transaction (which should be merged with the original transaction )
63
+ * and the new wallet state if successful, otherwise an error
64
+ */
65
+ balanceFinalizedTransaction(wallet, transaction) {
45
66
  return Either.gen(this, function* () {
46
- const segments = TransactionTrait.default.getSegments(transaction);
47
- if (!transaction.intents || !transaction.intents.size || !segments.length) {
48
- return {
49
- newState: wallet,
50
- transaction,
51
- };
52
- }
53
- const { addressHex, publicKey } = wallet.publicKey;
54
- for (const segment of [...segments, GUARANTEED_SEGMENT]) {
55
- const allIntentImbalances = yield* Either.try({
56
- try: () => transaction.imbalances(segment),
57
- catch: (error) => new TransactingError({ message: 'Failed to get intent imbalances', cause: error }),
58
- });
59
- const imbalances = allIntentImbalances
60
- .entries()
61
- .filter(([token, value]) => token.tag === 'unshielded' && value !== 0n)
62
- .map(([token, value]) => [token, value])
63
- .map(([token, value]) => {
64
- return [token.raw, value];
65
- })
66
- .toArray();
67
- // // intent is balanced
68
- if (!imbalances.length)
69
- continue;
70
- const availableCoins = this.getCoins().getAvailableCoins(wallet);
71
- if (!availableCoins.length) {
72
- return yield* Either.left(new TransactingError({ message: 'No available coins to spend' }));
73
- }
74
- // select inputs, receive the change outputs
75
- const { inputs, outputs: changeOutputs } = yield* Either.try({
76
- try: () => getBalanceRecipe({
77
- coins: availableCoins.map(({ utxo }) => utxo),
78
- initialImbalances: Imbalances.fromEntries(imbalances),
79
- feeTokenType: '',
80
- transactionCostModel: {
81
- inputFeeOverhead: 0n,
82
- outputFeeOverhead: 0n,
83
- },
84
- createOutput: (coin) => ({
85
- ...coin,
86
- owner: addressHex,
87
- }),
88
- isCoinEqual: (a, b) => a.intentHash === b.intentHash && a.outputNo === b.outputNo,
89
- }),
90
- catch: (error) => {
91
- const message = error instanceof Error ? error.message : error?.toString() || '';
92
- return new TransactingError({ message });
93
- },
94
- });
95
- // mark the coins as spent
96
- const [spentInputs] = yield* CoreWallet.spendUtxos(wallet, inputs);
97
- const ledgerInputs = spentInputs.map((input) => ({
98
- ...input,
99
- intentHash: input.intentHash,
100
- owner: publicKey,
101
- }));
102
- const counterOffer = yield* Either.try({
103
- try: () => ledger.UnshieldedOffer.new(ledgerInputs, changeOutputs, []),
104
- catch: (error) => new TransactingError({ message: 'Failed to create counter offer', cause: error }),
105
- });
106
- // NOTE: for the segment === 0 we insert the counter-offer into any intent's guaranteed section
107
- if (segment !== GUARANTEED_SEGMENT) {
108
- const intent = transaction.intents.get(segment);
109
- const isBound = isIntentBound(intent);
110
- if (!isBound && intent.fallibleUnshieldedOffer) {
111
- const mergedOffer = yield* mergeCounterOffer(counterOffer, intent.fallibleUnshieldedOffer);
112
- intent.fallibleUnshieldedOffer = mergedOffer;
113
- transaction.intents = transaction.intents.set(segment, intent);
114
- }
115
- else {
116
- // create a new offer if the intent is bound
117
- const nextSegment = Math.max(...TransactionTrait.default.getSegments(transaction)) + 1;
118
- const newIntent = ledger.Intent.new(intent.ttl);
119
- newIntent.fallibleUnshieldedOffer = counterOffer;
120
- transaction.intents = transaction.intents.set(nextSegment, newIntent);
121
- }
122
- }
123
- else {
124
- let ttl = new Date();
125
- let updated = false;
126
- // try to find and modify any unbound intent first
127
- const segments = TransactionTrait.default.getSegments(transaction);
128
- for (const segment of segments) {
129
- const intent = transaction.intents.get(segment);
130
- ttl = intent.ttl;
131
- const isBound = isIntentBound(intent);
132
- if (!isBound) {
133
- const mergedOffer = yield* mergeCounterOffer(counterOffer, intent.guaranteedUnshieldedOffer);
134
- intent.guaranteedUnshieldedOffer = mergedOffer;
135
- transaction.intents = transaction.intents.set(segment, intent);
136
- updated = true;
137
- break;
138
- }
139
- }
140
- // no unbound intents found, insert a new one
141
- if (!updated) {
142
- const nextSegment = Math.max(...segments) + 1;
143
- const newIntent = ledger.Intent.new(ttl);
144
- newIntent.guaranteedUnshieldedOffer = counterOffer;
145
- transaction.intents = transaction.intents.set(nextSegment, newIntent);
146
- }
67
+ // Ensure all intents are bound
68
+ const segments = this.txOps.getSegments(transaction);
69
+ for (const segment of segments) {
70
+ const intent = transaction.intents?.get(segment);
71
+ const isBound = this.txOps.isIntentBound(intent);
72
+ if (!isBound) {
73
+ return yield* Either.left(new TransactingError({ message: `Intent with id ${segment} is not bound` }));
147
74
  }
148
75
  }
149
- return {
150
- newState: wallet,
151
- transaction: transaction,
152
- };
76
+ // get the first intent so we can use its ttl to create the balancing intent
77
+ const intent = transaction.intents?.get(segments[0]);
78
+ const imbalances = this.txOps.getImbalances(transaction, GUARANTEED_SEGMENT);
79
+ // guaranteed section is balanced
80
+ if (imbalances.size === 0) {
81
+ return [undefined, wallet];
82
+ }
83
+ const recipe = yield* this.#balanceSegment(wallet, imbalances, Imbalances.empty(), this.getCoinSelection());
84
+ const { newState, offer } = yield* this.#prepareOffer(wallet, recipe);
85
+ const balancingIntent = ledger.Intent.new(intent.ttl);
86
+ balancingIntent.guaranteedUnshieldedOffer = offer;
87
+ return [ledger.Transaction.fromPartsRandomized(this.networkId, undefined, undefined, balancingIntent), newState];
153
88
  });
154
89
  }
90
+ /**
91
+ * Makes a transfer transaction
92
+ * @param wallet - The wallet to make the transfer with
93
+ * @param outputs - The outputs for the transfer
94
+ * @param ttl - The TTL for the transaction
95
+ * @returns The balanced transfer transaction and the new wallet state if successful, otherwise an error
96
+ */
155
97
  makeTransfer(wallet, outputs, ttl) {
156
- const networkId = this.networkId;
157
- const isValid = outputs.every((output) => output.amount > 0n);
158
- if (!isValid) {
159
- throw new TransactingError({ message: 'The amount needs to be positive' });
160
- }
161
- const ledgerOutputs = outputs.map((output) => {
162
- return {
163
- value: output.amount,
164
- owner: UnshieldedAddress.codec
165
- .decode(networkId, MidnightBech32m.parse(output.receiverAddress))
166
- .data.toString('hex'),
167
- type: output.type,
168
- };
169
- });
170
- return Either.try({
171
- try: () => {
172
- const intent = ledger.Intent.new(ttl);
173
- intent.guaranteedUnshieldedOffer = ledger.UnshieldedOffer.new([], ledgerOutputs, []);
98
+ return Either.gen(this, function* () {
99
+ const { networkId } = this;
100
+ const isValid = outputs.every((output) => output.amount > 0n);
101
+ if (!isValid) {
102
+ return yield* Either.left(new TransactingError({ message: 'The amount of all inputs needs to be positive' }));
103
+ }
104
+ const ledgerOutputs = outputs.map((output) => {
174
105
  return {
175
- newState: wallet,
176
- transaction: ledger.Transaction.fromParts(networkId, undefined, undefined, intent),
106
+ value: output.amount,
107
+ owner: UnshieldedAddress.codec
108
+ .decode(networkId, MidnightBech32m.parse(output.receiverAddress))
109
+ .data.toString('hex'),
110
+ type: output.type,
177
111
  };
178
- },
179
- catch: (error) => new TransactingError({ message: 'Failed to create transaction', cause: error }),
112
+ });
113
+ const recipe = yield* this.#balanceSegment(wallet, Imbalances.empty(), Imbalances.fromEntries(ledgerOutputs.map((output) => [output.type, output.value])), this.getCoinSelection());
114
+ const { newState, offer } = yield* this.#prepareOffer(wallet, {
115
+ inputs: recipe.inputs,
116
+ outputs: [...recipe.outputs, ...ledgerOutputs],
117
+ });
118
+ const intent = ledger.Intent.new(ttl);
119
+ const hasNightOutput = ledgerOutputs.some((output) => output.type === ledger.nativeToken().raw);
120
+ if (hasNightOutput) {
121
+ intent.fallibleUnshieldedOffer = offer;
122
+ }
123
+ else {
124
+ intent.guaranteedUnshieldedOffer = offer;
125
+ }
126
+ return {
127
+ newState,
128
+ transaction: ledger.Transaction.fromParts(networkId, undefined, undefined, intent),
129
+ };
180
130
  });
181
131
  }
132
+ /**
133
+ * Initializes a swap transaction
134
+ * @param wallet - The wallet to initialize the swap for
135
+ * @param desiredInputs - The desired inputs for the swap
136
+ * @param desiredOutputs - The desired outputs for the swap
137
+ * @param ttl - The TTL for the swap
138
+ * @returns The initialized swap transaction and the new wallet state if successful, otherwise an error
139
+ */
182
140
  initSwap(wallet, desiredInputs, desiredOutputs, ttl) {
183
141
  return Either.gen(this, function* () {
184
- const networkId = this.networkId;
142
+ const { networkId } = this;
185
143
  const outputsValid = desiredOutputs.every((output) => output.amount > 0n);
186
144
  if (!outputsValid) {
187
- return yield* Either.left(new TransactingError({ message: 'The amount needs to be positive' }));
145
+ return yield* Either.left(new TransactingError({ message: 'The amount of all outputs needs to be positive' }));
188
146
  }
189
147
  const inputsValid = Object.entries(desiredInputs).every(([, amount]) => amount > 0n);
190
148
  if (!inputsValid) {
191
- return yield* Either.left(new TransactingError({ message: 'The input amounts need to be positive' }));
149
+ return yield* Either.left(new TransactingError({ message: 'The amount of all inputs needs to be positive' }));
192
150
  }
193
151
  const ledgerOutputs = desiredOutputs.map((output) => ({
194
152
  value: output.amount,
@@ -198,55 +156,176 @@ export class TransactingCapabilityImplementation {
198
156
  type: output.type,
199
157
  }));
200
158
  const targetImbalances = Imbalances.fromEntries(Object.entries(desiredInputs));
201
- const availableCoins = this.getCoins().getAvailableCoins(wallet);
202
- const { inputs, outputs: changeOutputs } = yield* Either.try({
203
- try: () => getBalanceRecipe({
204
- coins: availableCoins.map(({ utxo }) => utxo),
205
- initialImbalances: Imbalances.empty(),
206
- feeTokenType: '',
207
- transactionCostModel: {
208
- inputFeeOverhead: 0n,
209
- outputFeeOverhead: 0n,
210
- },
211
- createOutput: (coin) => ({
212
- ...coin,
213
- owner: wallet.publicKey.addressHex,
214
- }),
215
- isCoinEqual: (a, b) => a.intentHash === b.intentHash && a.outputNo === b.outputNo,
216
- targetImbalances,
217
- }),
218
- catch: (error) => {
219
- const message = error instanceof Error ? error.message : error?.toString() || '';
220
- return new TransactingError({ message });
221
- },
159
+ const recipe = yield* this.#balanceSegment(wallet, Imbalances.empty(), targetImbalances, this.getCoinSelection());
160
+ const { newState, offer } = yield* this.#prepareOffer(wallet, {
161
+ inputs: recipe.inputs,
162
+ outputs: [...recipe.outputs, ...ledgerOutputs],
222
163
  });
223
- const [spentInputs, updatedWallet] = yield* CoreWallet.spendUtxos(wallet, inputs);
224
- const ledgerInputs = spentInputs.map((input) => ({
225
- ...input,
226
- owner: wallet.publicKey.publicKey,
227
- }));
228
- const offer = ledger.UnshieldedOffer.new(ledgerInputs, [...changeOutputs, ...ledgerOutputs], []);
229
164
  const intent = ledger.Intent.new(ttl);
230
165
  intent.guaranteedUnshieldedOffer = offer;
231
166
  const tx = ledger.Transaction.fromParts(networkId, undefined, undefined, intent);
232
167
  return {
233
- newState: updatedWallet,
168
+ newState,
234
169
  transaction: tx,
235
170
  };
236
171
  });
237
172
  }
238
- signTransaction(transaction, signSegment) {
239
- return Either.gen(function* () {
240
- const segments = TransactionTrait.default.getSegments(transaction);
173
+ signUnprovenTransaction(transaction, signSegment) {
174
+ return this.#signTransactionInternal(transaction, signSegment);
175
+ }
176
+ signUnboundTransaction(transaction, signSegment) {
177
+ return this.#signTransactionInternal(transaction, signSegment);
178
+ }
179
+ /**
180
+ * Internal method to sign either an unproven or unbound transaction
181
+ * @param transaction - The transaction to sign
182
+ * @param signSegment - The signing function
183
+ * @returns The signed transaction if successful, otherwise an error
184
+ */
185
+ #signTransactionInternal(transaction, signSegment) {
186
+ return Either.gen(this, function* () {
187
+ const segments = this.txOps.getSegments(transaction);
241
188
  if (!segments.length) {
242
189
  throw new SignError({ message: 'No segments found in the provided transaction' });
243
190
  }
244
191
  for (const segment of segments) {
245
- const signedData = yield* TransactionTrait.default.getOfferSignatureData(transaction, segment);
192
+ const signedData = yield* this.txOps.getSignatureData(transaction, segment);
246
193
  const signature = signSegment(signedData);
247
- transaction = yield* TransactionTrait.default.addOfferSignature(transaction, signature, segment);
194
+ transaction = (yield* this.txOps.addSignature(transaction, signature, segment));
248
195
  }
249
196
  return transaction;
250
197
  });
251
198
  }
199
+ /**
200
+ * Reverts a transaction by rolling back all inputs owned by this wallet
201
+ * @param wallet - The wallet to revert the transaction for
202
+ * @param transaction - The transaction to revert (can be FinalizedTransaction, UnboundTransaction, or UnprovenTransaction)
203
+ * @returns The updated wallet with rolled back UTXOs if successful, otherwise an error
204
+ */
205
+ revert(wallet, transaction) {
206
+ return pipe(this.txOps.extractOwnInputs(transaction, wallet.publicKey.publicKey), Arr.reduce(Either.right(wallet), (walletAcc, utxo) => pipe(walletAcc, Either.flatMap((w) => CoreWallet.rollbackUtxo(w, utxo)))));
207
+ }
208
+ /**
209
+ * Balances a segment of a transaction
210
+ * @param wallet - The wallet to balance the segment for
211
+ * @param imbalances - The imbalances to balance the segment for
212
+ * @param targetImbalances - The target imbalances to balance the segment for
213
+ * @param coinSelection - The coin selection to use for the balance recipe
214
+ * @returns The balance recipe if successful, otherwise an error
215
+ */
216
+ #balanceSegment(wallet, imbalances, targetImbalances, coinSelection) {
217
+ return Either.try({
218
+ try: () => getBalanceRecipe({
219
+ coins: this.getCoins()
220
+ .getAvailableCoins(wallet)
221
+ .map(({ utxo }) => utxo),
222
+ initialImbalances: imbalances,
223
+ feeTokenType: '',
224
+ transactionCostModel: {
225
+ inputFeeOverhead: 0n,
226
+ outputFeeOverhead: 0n,
227
+ },
228
+ coinSelection,
229
+ createOutput: (coin) => ({
230
+ ...coin,
231
+ owner: wallet.publicKey.addressHex,
232
+ }),
233
+ isCoinEqual: (a, b) => a.intentHash === b.intentHash && a.outputNo === b.outputNo,
234
+ targetImbalances,
235
+ }),
236
+ catch: (err) => {
237
+ if (err instanceof BalancingInsufficientFundsError) {
238
+ return new InsufficientFundsError({
239
+ message: 'Insufficient funds',
240
+ tokenType: err.tokenType,
241
+ amount: imbalances.get(err.tokenType) ?? 0n,
242
+ });
243
+ }
244
+ else {
245
+ return new OtherWalletError({
246
+ message: 'Balancing unshielded segment failed',
247
+ cause: err,
248
+ });
249
+ }
250
+ },
251
+ });
252
+ }
253
+ /**
254
+ * Prepares an offer for a given balance recipe
255
+ * @param wallet - The wallet to prepare the offer for
256
+ * @param balanceRecipe - The balance recipe to prepare the offer for
257
+ * @returns The prepared offer and the new wallet state if successful, otherwise an error
258
+ */
259
+ #prepareOffer(wallet, balanceRecipe) {
260
+ return Either.gen(function* () {
261
+ const [spentInputs, updatedWallet] = yield* CoreWallet.spendUtxos(wallet, balanceRecipe.inputs);
262
+ const { publicKey } = wallet.publicKey;
263
+ const ledgerInputs = spentInputs.map((input) => ({
264
+ ...input,
265
+ intentHash: input.intentHash,
266
+ owner: publicKey,
267
+ }));
268
+ const counterOffer = yield* Either.try({
269
+ try: () => ledger.UnshieldedOffer.new(ledgerInputs, [...balanceRecipe.outputs], []),
270
+ catch: (error) => new TransactingError({ message: 'Failed to create counter offer', cause: error }),
271
+ });
272
+ return {
273
+ newState: updatedWallet,
274
+ offer: counterOffer,
275
+ };
276
+ });
277
+ }
278
+ #mergeOffers(offerA, offerB) {
279
+ return pipe(Option.fromNullable(offerB), Option.match({
280
+ onNone: () => Either.right(offerA),
281
+ onSome: (offerB) => Either.try({
282
+ try: () => ledger.UnshieldedOffer.new([...offerB.inputs, ...offerA.inputs], [...offerB.outputs, ...offerA.outputs], [...offerB.signatures, ...offerA.signatures]),
283
+ catch: (error) => new TransactingError({ message: 'Failed to merge offers', cause: error }),
284
+ }),
285
+ }));
286
+ }
287
+ /**
288
+ * Balances an unboundish (unproven or unbound) transaction
289
+ * @param wallet - The wallet to balance the transaction with
290
+ * @param transaction - The transaction to balance
291
+ * @returns The balanced transaction and the new wallet state if successful, otherwise an error
292
+ */
293
+ #balanceUnboundishTransaction(wallet, transaction) {
294
+ return Either.gen(this, function* () {
295
+ const segments = this.txOps.getSegments(transaction);
296
+ // no segments to balance
297
+ if (segments.length === 0) {
298
+ return [undefined, wallet];
299
+ }
300
+ for (const segment of [...segments, GUARANTEED_SEGMENT]) {
301
+ const imbalances = this.txOps.getImbalances(transaction, segment);
302
+ // intent is balanced
303
+ if (imbalances.size === 0) {
304
+ continue;
305
+ }
306
+ // if segment is GUARANTEED_SEGMENT, use the first intent to place the balancing offer in the guaranteed section
307
+ const intentSegment = segment === GUARANTEED_SEGMENT ? segments[0] : segment;
308
+ const intent = transaction.intents?.get(intentSegment);
309
+ if (!intent) {
310
+ return yield* Either.left(new TransactingError({ message: `Intent with id ${segment} was not found` }));
311
+ }
312
+ const isBound = this.txOps.isIntentBound(intent);
313
+ if (isBound) {
314
+ return yield* Either.left(new TransactingError({ message: `Intent with id ${segment} is already bound` }));
315
+ }
316
+ const recipe = yield* this.#balanceSegment(wallet, imbalances, Imbalances.empty(), this.getCoinSelection());
317
+ const { offer } = yield* this.#prepareOffer(wallet, recipe);
318
+ const targetOffer = segment !== GUARANTEED_SEGMENT ? intent.fallibleUnshieldedOffer : intent.guaranteedUnshieldedOffer;
319
+ const mergedOffer = yield* this.#mergeOffers(offer, targetOffer);
320
+ if (segment !== GUARANTEED_SEGMENT) {
321
+ intent.fallibleUnshieldedOffer = mergedOffer;
322
+ }
323
+ else {
324
+ intent.guaranteedUnshieldedOffer = mergedOffer;
325
+ }
326
+ transaction.intents = transaction.intents.set(intentSegment, intent);
327
+ }
328
+ return [transaction, wallet];
329
+ });
330
+ }
252
331
  }
@@ -0,0 +1,23 @@
1
+ import { Either } from 'effect';
2
+ import { Imbalances } from '@midnight-ntwrk/wallet-sdk-capabilities';
3
+ import * as ledger from '@midnight-ntwrk/ledger-v7';
4
+ import { WalletError } from './WalletError.js';
5
+ /**
6
+ * Unbound transaction type. This is a transaction that has no signatures and is not bound yet.
7
+ */
8
+ export type UnboundTransaction = ledger.Transaction<ledger.SignatureEnabled, ledger.Proof, ledger.PreBinding>;
9
+ /**
10
+ * Utility type to extract the Intent type from a Transaction type.
11
+ * Maps Transaction<S, P, B> to Intent<S, P, B>.
12
+ */
13
+ export type IntentOf<T> = T extends ledger.Transaction<infer S, infer P, infer B> ? ledger.Intent<S, P, B> : never;
14
+ export type TransactionOps = {
15
+ getSignatureData: (transaction: ledger.Transaction<ledger.SignatureEnabled, ledger.Proofish, ledger.PreBinding>, segment: number) => Either.Either<Uint8Array, WalletError>;
16
+ getSegments(transaction: ledger.Transaction<ledger.SignatureEnabled, ledger.Proofish, ledger.Bindingish>): number[];
17
+ addSignature(transaction: ledger.UnprovenTransaction | UnboundTransaction, signature: ledger.Signature, segment: number): Either.Either<ledger.UnprovenTransaction, WalletError>;
18
+ getImbalances(transaction: ledger.FinalizedTransaction | UnboundTransaction | ledger.UnprovenTransaction, segment: number): Imbalances;
19
+ addSignaturesToOffer(offer: ledger.UnshieldedOffer<ledger.SignatureEnabled>, signature: ledger.Signature, segment: number, offerType: 'guaranteed' | 'fallible'): Either.Either<ledger.UnshieldedOffer<ledger.SignatureEnabled>, WalletError>;
20
+ isIntentBound(intent: ledger.Intent<ledger.SignatureEnabled, ledger.Proofish, ledger.Bindingish>): boolean;
21
+ extractOwnInputs(transaction: ledger.FinalizedTransaction | UnboundTransaction | ledger.UnprovenTransaction, signatureVerifyingKey: ledger.SignatureVerifyingKey): ledger.Utxo[];
22
+ };
23
+ export declare const TransactionOps: TransactionOps;
@@ -0,0 +1,95 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) 2025 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 { Either, pipe, Array as Arr } from 'effect';
14
+ import { Imbalances } from '@midnight-ntwrk/wallet-sdk-capabilities';
15
+ import * as ledger from '@midnight-ntwrk/ledger-v7';
16
+ import { TransactingError } from './WalletError.js';
17
+ export const TransactionOps = {
18
+ getSignatureData(tx, segment) {
19
+ if (!tx.intents) {
20
+ return Either.left(new TransactingError({ message: 'Transaction has no intents' }));
21
+ }
22
+ const intent = tx.intents.get(segment);
23
+ if (!intent) {
24
+ return Either.left(new TransactingError({ message: `Intent with segment ${segment} was not found` }));
25
+ }
26
+ return Either.try({
27
+ try: () => intent.signatureData(segment),
28
+ catch: (error) => new TransactingError({ message: 'Failed to get offer signature data', cause: error }),
29
+ });
30
+ },
31
+ getSegments(transaction) {
32
+ return transaction.intents?.keys().toArray() ?? [];
33
+ },
34
+ addSignature(transaction, signature, segment) {
35
+ return Either.gen(function* () {
36
+ if (!transaction.intents || transaction.intents.size === 0) {
37
+ return yield* Either.left(new TransactingError({ message: 'No intents found in the transaction' }));
38
+ }
39
+ const originalIntent = yield* Either.fromNullable(transaction.intents?.get(segment), () => new TransactingError({ message: 'Intent with a given segment was not found' }));
40
+ if (TransactionOps.isIntentBound(originalIntent)) {
41
+ return yield* Either.left(new TransactingError({ message: `Intent at segment ${segment} is already bound` }));
42
+ }
43
+ const clonedIntent = yield* Either.try({
44
+ try: () => ledger.Intent.deserialize('signature', 'pre-proof', 'pre-binding', originalIntent.serialize()),
45
+ catch: (error) => new TransactingError({ message: 'Failed to clone intent', cause: error }),
46
+ });
47
+ if (clonedIntent.fallibleUnshieldedOffer) {
48
+ clonedIntent.fallibleUnshieldedOffer = yield* TransactionOps.addSignaturesToOffer(clonedIntent.fallibleUnshieldedOffer, signature, segment, 'fallible');
49
+ }
50
+ if (clonedIntent.guaranteedUnshieldedOffer) {
51
+ clonedIntent.guaranteedUnshieldedOffer = yield* TransactionOps.addSignaturesToOffer(clonedIntent.guaranteedUnshieldedOffer, signature, segment, 'guaranteed');
52
+ }
53
+ transaction.intents = transaction.intents?.set(segment, clonedIntent);
54
+ return transaction;
55
+ });
56
+ },
57
+ getImbalances(transaction, segment) {
58
+ const imbalances = transaction
59
+ .imbalances(segment)
60
+ .entries()
61
+ .filter(([token, value]) => token.tag === 'unshielded' && value !== 0n)
62
+ .map(([token, value]) => [token.raw.toString(), value])
63
+ .toArray();
64
+ return Imbalances.fromEntries(imbalances);
65
+ },
66
+ addSignaturesToOffer(offer, signature, segment, offerType) {
67
+ return pipe(offer.inputs, Arr.map((_, i) => offer.signatures.at(i) ?? signature), (signatures) => Either.try({
68
+ try: () => offer.addSignatures(signatures),
69
+ catch: (error) => new TransactingError({
70
+ message: `Failed to add ${offerType} signature at segment ${segment}`,
71
+ cause: error,
72
+ }),
73
+ }));
74
+ },
75
+ isIntentBound(intent) {
76
+ return intent.binding.instance === 'binding';
77
+ },
78
+ extractOwnInputs(transaction, signatureVerifyingKey) {
79
+ const segments = TransactionOps.getSegments(transaction);
80
+ return pipe(segments, Arr.flatMap((segment) => {
81
+ const intent = transaction.intents?.get(segment);
82
+ if (!intent) {
83
+ return [];
84
+ }
85
+ const { guaranteedUnshieldedOffer, fallibleUnshieldedOffer } = intent;
86
+ const ownedInputsfromGuaranteedSection = guaranteedUnshieldedOffer?.inputs
87
+ ? guaranteedUnshieldedOffer.inputs.filter((input) => input.owner === signatureVerifyingKey)
88
+ : [];
89
+ const ownedInputsfromFallibleSection = fallibleUnshieldedOffer
90
+ ? fallibleUnshieldedOffer.inputs.filter((input) => input.owner === signatureVerifyingKey)
91
+ : [];
92
+ return [...ownedInputsfromGuaranteedSection, ...ownedInputsfromFallibleSection];
93
+ }));
94
+ },
95
+ };