-
Notifications
You must be signed in to change notification settings - Fork 54
/
DynamicChangeAddressResolver.ts
380 lines (326 loc) · 15.4 KB
/
DynamicChangeAddressResolver.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
import * as Crypto from '@cardano-sdk/crypto';
import { BigNumber } from 'bignumber.js';
import { Cardano } from '@cardano-sdk/core';
import { ChangeAddressResolver, Selection } from '@cardano-sdk/input-selection';
import { DelegatedStake, DelegationTracker } from '../types';
import { GroupedAddress } from '@cardano-sdk/key-management';
import { InvalidStateError } from '@cardano-sdk/util';
import { Logger } from 'ts-log';
import { Observable, firstValueFrom } from 'rxjs';
import isEqual from 'lodash/isEqual';
import uniq from 'lodash/uniq';
/**
* We are using buckets as an analogy to stake keys which are delegated to specific
* pools with a specific amount of the total balance, and a target amount of balance.
*
* Buckets are considered filled if their current balance + allocated change is => than
* their capacity and unfilled if their current balance + allocated change is < than their capacity.
*
* The bucket capacity is defined as a % (as specified in the delegation portfolio) of the total staked balance,
* I.E if the total balance is 100 lovelace, and the portfolio % is 0.1, then capacity is set as 10 lovelace.
*/
type Bucket = {
address: Cardano.PaymentAddress;
change: Array<Cardano.TxOut>;
capacity: bigint;
filledAmount: bigint;
};
/**
* Gets the coalesced coin amount from the given TxOut array.
*
* @param txOuts The TxOuts to coalesce.
* @returns The coin value coalesced.
*/
const getBalanceInTxOuts = (txOuts: Array<Cardano.TxOut>) => {
let balanceInBuckets = 0n;
for (const txOut of txOuts) {
balanceInBuckets += txOut.value.coins;
}
return balanceInBuckets;
};
/**
* Aggregates all the coin balance in the input set from any input in the selection that belongs to us.
*
* @param knownAddresses The wallet know addresses.
* @param inputs The inputs.
*/
const getSpentInInputs = (knownAddresses: Array<GroupedAddress>, inputs: Array<Cardano.Utxo>) =>
inputs
.filter((utxo) => knownAddresses.some((groupedAddress) => groupedAddress.address === utxo[0].address))
.map((utxo) => utxo[1].value.coins)
.reduce((a, b) => a + b, 0n);
/**
* Aggregates all the coin balance in the output set from any output in the selection that belongs to us.
*
* @param knownAddresses The wallet know addresses.
* @param outputs The outputs.
*/
const getBalanceFromOutputs = (knownAddresses: Array<GroupedAddress>, outputs: Array<Cardano.TxOut>) =>
outputs
.filter((out) => knownAddresses.some((groupedAddress) => groupedAddress.address === out.address))
.map((out) => out.value.coins)
.reduce((a, b) => a + b, 0n);
/**
* Gets the spent amount by this selection from a given reward account.
*
* @param rewardAccount The reward account that we want to query.
* @param inputs The inputs in the selection.
*/
const getSpentFromRewardAccount = (rewardAccount: Cardano.RewardAccount, inputs: Array<Cardano.Utxo>) =>
inputs
.filter((utxo) => {
const address = Cardano.Address.fromString(utxo[0].address)?.asBase();
// Address may not have stake credential.
if (!address) return false;
return (
(Cardano.RewardAccount.toHash(rewardAccount) as unknown as Crypto.Hash28ByteBase16) ===
address.getStakeCredential().hash
);
})
.map((utxo) => utxo[1].value.coins)
.reduce((a, b) => a + b, 0n);
/**
* Gets the deposited amount by this selection to a given reward account.
*
* @param rewardAccount The reward account that we want to query.
* @param outputs The outputs in the selection.
*/
const getDepositToRewardAccount = (rewardAccount: Cardano.RewardAccount, outputs: Array<Cardano.TxOut>) =>
outputs
.filter((txOut) => {
const address = Cardano.Address.fromString(txOut.address)?.asBase();
// Address may not have stake credential.
if (!address) return false;
return (
(Cardano.RewardAccount.toHash(rewardAccount) as unknown as Crypto.Hash28ByteBase16) ===
address.getStakeCredential().hash
);
})
.map((txOut) => txOut.value.coins)
.reduce((a, b) => a + b, 0n);
/**
* Gets a payment address from our known addresses that match the given reward account.
*
* @param knownAddresses The wallet know addresses.
* @param account The reward account we are looking for.
*/
const getAddressForRewardAccount = (knownAddresses: Array<GroupedAddress>, account: Cardano.RewardAccount) =>
knownAddresses.find((groupedAddress) => groupedAddress.rewardAccount === account);
/**
* Creates a list of buckets (one for each entry in the delegation portfolio). It will also compute the updated delegated amounts
* and percentages after applying the changes of the selection to the wallet state.
*
* @param selection The current selection of inputs to satisfy the outputs in the transaction.
* @param delegateAmounts The delegated stake amounts to each pool.
* @param portfolio The current delegation portfolio.
* @param knownAddresses The list of known addresses.
*/
const createBuckets = (
selection: Selection,
delegateAmounts: Array<DelegatedStake>,
portfolio: Cardano.Cip17DelegationPortfolio,
knownAddresses: Array<GroupedAddress>
): Array<Bucket> => {
const buckets = new Array<Bucket>();
const inputs = [...selection.inputs];
const outputs = [...selection.outputs];
// We need to 'apply' the transaction, by deducting the balance used in inputs from our addresses and adding any balance
// in the outputs that are going to our addresses before distributing the change.
const totalStakeDelegatedBeforeTx = delegateAmounts.map((delegated) => delegated.stake).reduce((a, b) => a + b, 0n);
const balanceInChange = getBalanceInTxOuts(selection.change);
const negativeBalance = getSpentInInputs(knownAddresses, inputs) + selection.fee;
const positiveBalance = getBalanceFromOutputs(knownAddresses, outputs) + balanceInChange;
const totalStake = totalStakeDelegatedBeforeTx + positiveBalance - negativeBalance;
const totalWeight = portfolio.pools.map((pool) => pool.weight).reduce((sum, current) => sum + current, 0);
const weightsAsPercent = new Map(portfolio.pools.map((pool) => [pool.id, pool.weight / totalWeight]));
for (const delegated of delegateAmounts) {
if (delegated.rewardAccounts.length === 0)
throw new InvalidStateError(`No reward accounts delegating to pool '${delegated.pool.id}'.`);
const account = delegated.rewardAccounts[0];
const groupedAddress = getAddressForRewardAccount(knownAddresses, account);
if (!groupedAddress) throw new InvalidStateError(`Reward account '${account}' unknown.`);
const adjustedStake =
delegated.stake - getSpentFromRewardAccount(account, inputs) + getDepositToRewardAccount(account, outputs);
const percentageForPool = weightsAsPercent.get(delegated.pool.hexId);
if (percentageForPool === undefined)
throw new InvalidStateError(`Pool '${delegated.pool.id}' not found in the portfolio.`); // Shouldn't happen.
buckets.push({
address: groupedAddress.address,
capacity: BigInt(new BigNumber(totalStake.toString()).multipliedBy(percentageForPool).toFixed(0, 0)),
change: new Array<Cardano.TxOut>(),
filledAmount: adjustedStake
});
}
return buckets;
};
/**
* Computes the gap of the given bucket. The gap is defined as the difference between bucket capacity and the currently filled
* quantity, normalized as a number between 0 and 1.
*
* @param bucket The bucket to compute the gap for.
*/
const getBucketGap = (bucket: Bucket) => {
// We need to avoid a division by 0 here. If capacity is 0, we just return a gap of 0.
if (bucket.capacity === 0n) return new BigNumber('0');
const capacity = new BigNumber(bucket.capacity.toString());
const filledAmount = new BigNumber(bucket.filledAmount.toString());
return capacity.minus(filledAmount).dividedBy(capacity);
};
/**
* Picks the next bucket to fill, the bucket with the biggest gap will be chosen.
*
* @param buckets The list of available buckets.
*/
const pickBucket = (buckets: Array<Bucket>) =>
buckets.sort((a, b) => {
const gapA = getBucketGap(a);
const gapB = getBucketGap(b);
if (gapB.isGreaterThan(gapA)) {
return 1;
} else if (gapB.isLessThan(gapA)) {
return -1;
}
return 0;
})[0];
/**
* Distributing change is related to a class of problems called "multiway number partitioning", which is NP-hard. If the problem
* size is small (such as is our case), more exact methods such as dynamic programming or integer programming could be used,
* but since this is still a POC, for simplicity’s sake, we are implementing a simplified and iterative approach to
* solve this:
*
* 1 - Sort the change list in descending order. The reason to sort is to allocate the largest change outputs first to
* the 'buckets' with the largest gap, and the smaller outputs to the buckets with smaller gaps, reducing wasted change (over flow).
* 2 - For each change output, add it to the bucket that currently has the largest gap and is not overflowed.
* 3 - Repeat until all change outputs are in a bucket.
*
* This will yield a reasonable approximation to the optimal distribution of change, giving priority to buckets with larger
* gaps, but at the same time making sure we are not 'wasting' change by overflowing a single bucket too much.
*
* TODO: Explore more exact algorithms for change distribution. The approach we have followed here is a greedy heuristic, which can
* provide reasonable solutions quickly but is not guaranteed to find the optimal solution. It might be feasible to use a more
* rigorous approach such as integer programming, simulated annealing, or a complete search, which could examine all possible
* assignments of change outputs to buckets and select the one that most closely matches the desired proportions. However,
* these methods can be computationally intensive and may not be practical, and probably overkill for our use case (To be determined).
*
* @param changeOutputs The list of change outputs to be distributed.
* @param prefilledBuckets The list of buckets, we should have one bucket per entry in the delegation distribution portfolio.
* @returns A list of buckets with the given change outputs distributed in a way that closely matches the expected distribution (best effort).
*/
const distributeChange = (changeOutputs: Array<Cardano.TxOut>, prefilledBuckets: Array<Bucket>): Array<Bucket> => {
const buckets = [...prefilledBuckets];
const sortedOutputs = changeOutputs.sort((a, b) => {
if (a.value.coins > b.value.coins) {
return 1;
} else if (a.value.coins < b.value.coins) {
return -1;
}
return 0;
});
while (sortedOutputs.length > 0) {
const bucket = pickBucket(buckets);
const selected = sortedOutputs.splice(0, 1)[0];
bucket.change.push(selected);
bucket.filledAmount += selected.value.coins;
}
for (const bucket of buckets) {
bucket.change = bucket.change.map((txOut) => {
txOut.address = bucket.address;
return txOut;
});
}
return buckets;
};
/**
* Gets whether the portfolio pools matches the current stake distribution pools.
*
* @param portfolio The staking portfolio.
* @param distribution the distribution.
* @returns true if both matches, otherwise; false.
*/
export const delegationMatchesPortfolio = (
portfolio: Cardano.Cip17DelegationPortfolio,
distribution: DelegatedStake[]
): boolean => {
const portfolioPools = uniq(portfolio.pools.map((cip17Pool) => cip17Pool.id)).sort();
const delegationPools = uniq(distribution.map((delegatedStake) => delegatedStake.pool.hexId)).sort();
return isEqual(portfolioPools, delegationPools);
};
/** Gets the current delegation portfolio. */
export type GetDelegationPortfolio = () => Promise<Cardano.Cip17DelegationPortfolio | null>;
/** Resolves the address to be used for change. */
export class DynamicChangeAddressResolver implements ChangeAddressResolver {
readonly #getDelegationPortfolio: GetDelegationPortfolio;
readonly #delegationDistribution: DelegationTracker['distribution$'];
readonly #addresses$: Observable<GroupedAddress[]>;
readonly #logger: Logger;
/**
* Initializes a new instance of the StakeDistributionChangeAddressResolver.
*
* @param addresses$ The wallet known addresses.
* @param delegationDistribution The wallet delegation distribution observable.
* @param getDelegationPortfolio The current delegation portfolio.
* @param logger The logger instance.
*/
constructor(
addresses$: Observable<GroupedAddress[]>,
delegationDistribution: DelegationTracker['distribution$'],
getDelegationPortfolio: GetDelegationPortfolio,
logger: Logger
) {
this.#getDelegationPortfolio = getDelegationPortfolio;
this.#delegationDistribution = delegationDistribution;
this.#addresses$ = addresses$;
this.#logger = logger;
}
/** Resolve the change addresses for change outputs that better maintains the desired stake distribution. */
async resolve(selection: Selection): Promise<Array<Cardano.TxOut>> {
const delegationDistribution = [...(await firstValueFrom(this.#delegationDistribution)).values()];
let portfolio = await this.#getDelegationPortfolio();
const addresses = await firstValueFrom(this.#addresses$);
let updatedChange = [...selection.change];
if (addresses.length === 0) throw new InvalidStateError('The wallet has no known addresses.');
// If the wallet is not delegating to any pool, fall back to giving all change to the first derived address.
if (delegationDistribution.length === 0) {
updatedChange = updatedChange.map((txOut) => {
txOut.address = addresses[0].address;
return txOut;
});
return updatedChange;
}
// If only one delegation is found, assign all change to that reward account.
if (delegationDistribution.length === 1) {
if (delegationDistribution[0].rewardAccounts.length === 0)
throw new InvalidStateError(`No reward accounts delegating to pool '${delegationDistribution[0].pool.id}'.`);
// Several reward accounts could be delegated to the same pool, in this case it doesn't
// matter which one we chose to place the stake at.
const groupedAddress = addresses.find(
(address) => address.rewardAccount === delegationDistribution[0].rewardAccounts[0]
);
if (!groupedAddress)
throw new InvalidStateError(`Reward account '${delegationDistribution[0].rewardAccounts[0]}' unknown.`);
updatedChange = updatedChange.map((txOut) => {
txOut.address = groupedAddress.address;
return txOut;
});
return updatedChange;
}
// If the portfolio doesn't match the current delegation (same pools), this strategy won't work, we can't guess
// where to put the balance, we will fall back to even distribution and log a warning.
if (!portfolio || !delegationMatchesPortfolio(portfolio, delegationDistribution)) {
this.#logger.warn('The portfolio doesnt match current wallet delegation.');
this.#logger.warn(`Portfolio: ${portfolio}`);
const pools = delegationDistribution.map((stake) => ({
id: stake.pool.hexId,
weight: 1 / delegationDistribution.length
}));
portfolio = { name: 'Default Portfolio', pools };
}
const buckets = createBuckets(selection, delegationDistribution, portfolio, addresses);
const updatedBuckets = distributeChange(selection.change, buckets);
updatedChange = new Array<Cardano.TxOut>();
for (const bucket of updatedBuckets) {
updatedChange = [...updatedChange, ...bucket.change];
}
return updatedChange;
}
}