From 75739385ea422a0621ded87f2b72c5878e3fcf81 Mon Sep 17 00:00:00 2001 From: Angel Castillo Date: Tue, 19 Sep 2023 14:15:34 +0800 Subject: [PATCH] feat!: delegation distribution portfolio is now persisted on chain and taken into account during change distribution BREAKING CHANGES: - TxBuilder delegatePortfolio now takes a full Cip17DelegationPortfolio object rather than a list of pools --- .../Cardano/types/DelegationsAndRewards.ts | 30 +++ .../types/DelegationAndRewards.test.ts | 98 +++++++++ .../long-running/delegation-rewards.test.ts | 1 + .../delegationDistribution.test.ts | 17 +- .../src/tx-builder/TxBuilder.ts | 21 +- .../TxBuilderDelegatePortfolio.test.ts | 54 ++++- .../src/PersonalWallet/PersonalWallet.ts | 35 ++- .../DynamicChangeAddressResolver.ts | 35 +-- .../DelegationTracker/DelegationTracker.ts | 23 ++ packages/wallet/src/services/types.ts | 1 + .../DynamicChangeAddressResolver.test.ts | 208 +++++++++++++++++- .../DelegationTracker.test.ts | 182 ++++++++++++++- .../services/DelegationTracker/stub-tx.ts | 28 ++- .../src/observableWallet/util.ts | 1 + 14 files changed, 672 insertions(+), 62 deletions(-) create mode 100644 packages/core/test/Cardano/types/DelegationAndRewards.test.ts diff --git a/packages/core/src/Cardano/types/DelegationsAndRewards.ts b/packages/core/src/Cardano/types/DelegationsAndRewards.ts index 7d1bcba15bd..ba0bfb90972 100644 --- a/packages/core/src/Cardano/types/DelegationsAndRewards.ts +++ b/packages/core/src/Cardano/types/DelegationsAndRewards.ts @@ -1,6 +1,8 @@ import { Lovelace } from './Value'; +import { Metadatum } from './AuxiliaryData'; import { PoolId, PoolIdHex, StakePool } from './StakePool'; import { RewardAccount } from '../Address'; +import { metadatumToJson } from '../../util/metadatum'; export interface DelegationsAndRewards { delegate?: PoolId; @@ -44,3 +46,31 @@ export interface Cip17DelegationPortfolio { description?: string; author?: string; } + +// On chain portfolio metadata +export const DelegationMetadataLabel = 6862n; // 0x1ace +export type DelegationPortfolioMetadata = Exclude & { + pools: Pick[]; +}; + +export const portfolioMetadataFromCip17 = (cip17: Cip17DelegationPortfolio): DelegationPortfolioMetadata => { + const portfolio = { ...cip17 }; + + portfolio.pools = cip17.pools.map((pool) => ({ + id: pool.id, + weight: pool.weight + })); + + return portfolio as DelegationPortfolioMetadata; +}; + +export const cip17FromMetadatum = (portfolio: Metadatum): Cip17DelegationPortfolio => { + const cip17 = metadatumToJson(portfolio); + + for (const pool of cip17.pools) { + // Metadatum serializes/deserializes numbers as bigints + pool.weight = Number(pool.weight); + } + + return cip17 as Cip17DelegationPortfolio; +}; diff --git a/packages/core/test/Cardano/types/DelegationAndRewards.test.ts b/packages/core/test/Cardano/types/DelegationAndRewards.test.ts new file mode 100644 index 00000000000..4f82c2a7ecc --- /dev/null +++ b/packages/core/test/Cardano/types/DelegationAndRewards.test.ts @@ -0,0 +1,98 @@ +import * as Cardano from '../../../src/Cardano'; + +describe('portfolioMetadataFromCip17', () => { + const poolIds: Cardano.PoolId[] = [ + Cardano.PoolId('pool1zuevzm3xlrhmwjw87ec38mzs02tlkwec9wxpgafcaykmwg7efhh'), + Cardano.PoolId('pool1t9xlrjyk76c96jltaspgwcnulq6pdkmhnge8xgza8ku7qvpsy9r'), + Cardano.PoolId('pool1la4ghj4w4f8p4yk4qmx0qvqmzv6592ee9rs0vgla5w6lc2nc8w5') + ]; + + const portfolio = { + // eslint-disable-next-line sonarjs/no-duplicate-string + name: 'Tests Portfolio', + pools: [ + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), + name: 'A', + ticker: 'At', + weight: 1 + }, + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[1])), + name: 'B', + ticker: 'Bt', + weight: 1 + }, + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[2])), + name: 'C', + ticker: 'Ct', + weight: 1 + } + ] + }; + + it('can get portfolio metadata from CIP-17', () => { + const portfolioMetadata = Cardano.portfolioMetadataFromCip17(portfolio); + + expect(portfolioMetadata).toEqual({ + name: 'Tests Portfolio', + pools: [ + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), + weight: 1 + }, + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[1])), + weight: 1 + }, + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[2])), + weight: 1 + } + ] + }); + }); + + it('can get portfolio metadata from CIP-17', () => { + const metadatum: Cardano.Metadatum = new Map([ + ['name', 'Tests Portfolio'], + [ + 'pools', + [ + new Map([ + ['id', Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0]))], + ['weight', 1n] + ]), + new Map([ + ['id', Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[1]))], + ['weight', 1n] + ]), + new Map([ + ['id', Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[2]))], + ['weight', 1n] + ]) + ] + ] + ]); + + const cip17 = Cardano.cip17FromMetadatum(metadatum); + expect(cip17).toEqual({ + name: 'Tests Portfolio', + pools: [ + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), + weight: 1 + }, + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[1])), + weight: 1 + }, + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[2])), + weight: 1 + } + ] + }); + }); +}); diff --git a/packages/e2e/test/long-running/delegation-rewards.test.ts b/packages/e2e/test/long-running/delegation-rewards.test.ts index 763eff234a6..b7a49e3bbe5 100644 --- a/packages/e2e/test/long-running/delegation-rewards.test.ts +++ b/packages/e2e/test/long-running/delegation-rewards.test.ts @@ -21,6 +21,7 @@ const submitDelegationTx = async (wallet: PersonalWallet, pools: Cardano.PoolId[ const { tx: signedTx } = await wallet .createTxBuilder() .delegatePortfolio({ + name: 'Test Portfolio', pools: pools.map((poolId) => ({ id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolId)), weight: 1 })) }) .build() diff --git a/packages/e2e/test/wallet/PersonalWallet/delegationDistribution.test.ts b/packages/e2e/test/wallet/PersonalWallet/delegationDistribution.test.ts index abdea959a42..88f97a90e9d 100644 --- a/packages/e2e/test/wallet/PersonalWallet/delegationDistribution.test.ts +++ b/packages/e2e/test/wallet/PersonalWallet/delegationDistribution.test.ts @@ -95,14 +95,15 @@ const delegateToMultiplePools = async ( weights = Array.from({ length: POOLS_COUNT }).map(() => 1) ) => { const poolIds = await getPoolIds(wallet); - const portfolio: Pick = { + const portfolio: Cardano.Cip17DelegationPortfolio = { + name: 'Test Portfolio', pools: poolIds.map(({ hexId: id }, idx) => ({ id, weight: weights[idx] })) }; logger.debug('Delegating portfolio', portfolio); const { tx } = await wallet.createTxBuilder().delegatePortfolio(portfolio).build().sign(); await submitAndConfirm(wallet, tx); - return poolIds; + return { poolIds, portfolio }; }; const delegateAllToSinglePool = async (wallet: PersonalWallet): Promise => { @@ -138,10 +139,12 @@ describe('PersonalWallet/delegationDistribution', () => { // No stake distribution initially const delegationDistribution = await firstValueFrom(wallet.delegation.distribution$); + const delegationPortfolio = await firstValueFrom(wallet.delegation.portfolio$); logger.info('Empty delegation distribution initially'); expect(delegationDistribution).toEqual(new Map()); + expect(delegationPortfolio).toEqual(null); - const poolIds = await delegateToMultiplePools(wallet); + const { poolIds, portfolio } = await delegateToMultiplePools(wallet); const walletAddresses = await firstValueFromTimed(wallet.addresses$); const rewardAccounts = await firstValueFrom(wallet.delegation.rewardAccounts$); @@ -173,11 +176,13 @@ describe('PersonalWallet/delegationDistribution', () => { stake: perAddrBalance[index] })); const actualDelegationDistribution = await firstValueFrom(wallet.delegation.distribution$); + const actualPortfolio = await firstValueFrom(wallet.delegation.portfolio$); logger.info('Funds were distributed evenly across the addresses.'); logger.info(distributionMessage, actualDelegationDistribution); expect([...actualDelegationDistribution.values()]).toEqual(expectedDelegationDistribution); + expect(portfolio).toEqual(actualPortfolio); // Delegate so that last address has all funds await delegateToMultiplePools( @@ -229,6 +234,7 @@ describe('PersonalWallet/delegationDistribution', () => { ) ) ); + expect(simplifiedDelegationDistribution).toEqual([ { id: poolIds[0].id, @@ -237,5 +243,10 @@ describe('PersonalWallet/delegationDistribution', () => { rewardAccounts: rewardAccounts.map(({ address }) => address) } ]); + + // The simplified portfolio transaction doesn't send a portfolio but rather simply delegates all funds to one pool manually. + // This emulates the case where a user syncs its seeds phrases into another wallet and delegates to a single pool (without attaching metadata), + // this action undoes the portfolio, we should get null here. + expect(await firstValueFrom(wallet.delegation.portfolio$)).toBe(null); }); }); diff --git a/packages/tx-construction/src/tx-builder/TxBuilder.ts b/packages/tx-construction/src/tx-builder/TxBuilder.ts index dfac0f01c6e..21c0562070d 100644 --- a/packages/tx-construction/src/tx-builder/TxBuilder.ts +++ b/packages/tx-construction/src/tx-builder/TxBuilder.ts @@ -1,5 +1,5 @@ import * as Crypto from '@cardano-sdk/crypto'; -import { Cardano, HandleProvider, HandleResolution } from '@cardano-sdk/core'; +import { Cardano, HandleProvider, HandleResolution, metadatum } from '@cardano-sdk/core'; import { GreedyInputSelector, SelectionSkeleton } from '@cardano-sdk/input-selection'; import { GroupedAddress, SignTransactionOptions, TransactionSigner, util } from '@cardano-sdk/key-management'; import { @@ -144,7 +144,7 @@ export class GenericTxBuilder implements TxBuilder { }); } - delegatePortfolio(portfolio: Pick | null): TxBuilder { + delegatePortfolio(portfolio: Cardano.Cip17DelegationPortfolio | null): TxBuilder { if (portfolio?.pools.length === 0) { throw new Error('Portfolio should define at least one delegation pool.'); } @@ -152,6 +152,23 @@ export class GenericTxBuilder implements TxBuilder { ...pool, id: Cardano.PoolId.fromKeyHash(pool.id as unknown as Crypto.Ed25519KeyHashHex) })); + + if (portfolio) { + if (this.partialAuxiliaryData?.blob) { + this.partialAuxiliaryData.blob.set( + Cardano.DelegationMetadataLabel, + metadatum.jsonToMetadatum(Cardano.portfolioMetadataFromCip17(portfolio)) + ); + } else { + this.partialAuxiliaryData = { + ...this.partialAuxiliaryData, + blob: new Map([ + [Cardano.DelegationMetadataLabel, metadatum.jsonToMetadatum(Cardano.portfolioMetadataFromCip17(portfolio))] + ]) + }; + } + } + return this; } diff --git a/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts b/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts index 6bacabf2fc7..c65b7f3f417 100644 --- a/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts +++ b/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/no-duplicate-string */ import * as Crypto from '@cardano-sdk/crypto'; import { AddressType, GroupedAddress, InMemoryKeyAgent, util } from '@cardano-sdk/key-management'; import { CML, Cardano } from '@cardano-sdk/core'; @@ -146,7 +147,10 @@ describe('TxBuilder/delegatePortfolio', () => { it('uses random improve input selector when delegating to a single pool', async () => { const tx = await txBuilder - .delegatePortfolio({ pools: [{ id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), weight: 1 }] }) + .delegatePortfolio({ + name: 'Tests Portfolio', + pools: [{ id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), weight: 1 }] + }) .build() .inspect(); @@ -182,6 +186,7 @@ describe('TxBuilder/delegatePortfolio', () => { it('uses roundRobinRandomImprove when only one reward account is registered', async () => { await txBuilder .delegatePortfolio({ + name: 'Tests Portfolio', pools: [ { id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), @@ -225,6 +230,7 @@ describe('TxBuilder/delegatePortfolio', () => { output = { address: groupedAddresses[3].address, value: { coins: 10n } }; tx = await txBuilder .delegatePortfolio({ + name: 'Tests Portfolio', pools: [ { id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), @@ -305,6 +311,7 @@ describe('TxBuilder/delegatePortfolio', () => { it('does not change delegations when portfolio already satisfied, but updates distribution', async () => { const tx = await txBuilder .delegatePortfolio({ + name: 'Tests Portfolio', pools: [ { id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), @@ -333,6 +340,7 @@ describe('TxBuilder/delegatePortfolio', () => { and updates change addresses distribution`, async () => { const tx = await txBuilder .delegatePortfolio({ + name: 'Tests Portfolio', pools: [ { id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[2])), @@ -365,6 +373,7 @@ describe('TxBuilder/delegatePortfolio', () => { and configures change addresses so funds go to the delegated address`, async () => { const tx = await txBuilder .delegatePortfolio({ + name: 'Tests Portfolio', pools: [ { id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[1])), @@ -384,13 +393,14 @@ describe('TxBuilder/delegatePortfolio', () => { }); it('portfolio with empty pools array is not a valid CIP17 portfolio', () => { - expect(() => txBuilder.delegatePortfolio({ pools: [] })).toThrow(); + expect(() => txBuilder.delegatePortfolio({ name: 'Test Portfolio', pools: [] })).toThrow(); }); it('derives more stake keys when portfolio has more pools than available keys', async () => { const pools = poolIds.slice(0, 3); const tx = await txBuilder .delegatePortfolio({ + name: 'Tests Portfolio', pools: pools.map((pool) => ({ id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(pool)), weight: 1 })) }) .build() @@ -421,6 +431,7 @@ describe('TxBuilder/delegatePortfolio', () => { it('portfolio is superset: adds certificates for the new delegations', async () => { const tx = await txBuilder .delegatePortfolio({ + name: 'Tests Portfolio', pools: [ { id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), @@ -451,6 +462,7 @@ describe('TxBuilder/delegatePortfolio', () => { it('changes delegation and deregisters stake keys that are not delegated', async () => { const tx = await txBuilder .delegatePortfolio({ + name: 'Tests Portfolio', pools: [ { id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[2])), @@ -474,7 +486,8 @@ describe('TxBuilder/delegatePortfolio', () => { }); describe('rewardAccount selection', () => { - const portfolio: Pick = { + const portfolio: Cardano.Cip17DelegationPortfolio = { + name: 'Tests Portfolio', pools: [{ id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), weight: 1 }] }; @@ -535,6 +548,7 @@ describe('TxBuilder/delegatePortfolio', () => { const tx = await txBuilder .delegatePortfolio({ + name: 'Tests Portfolio', pools: [ { id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[2])), weight: 1 }, { id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[3])), weight: 1 } @@ -589,6 +603,30 @@ describe('TxBuilder/delegatePortfolio', () => { Cardano.createStakeKeyDeregistrationCert(groupedAddresses[0].rewardAccount) ]); }); + + it('attaches the portfolio as tx metadata', async () => { + const txBuilderFactory = await createTxBuilder({ + keyAgent, + stakeKeyDelegations: [ + { keyStatus: Cardano.StakeKeyStatus.Registered }, + { keyStatus: Cardano.StakeKeyStatus.Registered, poolId: poolIds[1] } + ] + }); + groupedAddresses = txBuilderFactory.groupedAddresses; + txBuilder = txBuilderFactory.txBuilder; + + const tx = await txBuilder.delegatePortfolio(portfolio).build().inspect(); + + const certs = tx.body.certificates as Cardano.StakeDelegationCertificate[]; + expect(certs.length).toBe(2); + expect(certs).toEqual([ + Cardano.createDelegationCert(groupedAddresses[1].rewardAccount, poolIds[0]), + Cardano.createStakeKeyDeregistrationCert(groupedAddresses[0].rewardAccount) + ]); + + const metadata = tx.auxiliaryData!.blob!.get(Cardano.DelegationMetadataLabel); + expect(Cardano.cip17FromMetadatum(metadata!)).toEqual(portfolio); + }); }); describe('rewardAccount syncing', () => { @@ -619,7 +657,10 @@ describe('TxBuilder/delegatePortfolio', () => { await expect( txBuilder - .delegatePortfolio({ pools: [{ id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), weight: 1 }] }) + .delegatePortfolio({ + name: 'Test Portfolio', + pools: [{ id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), weight: 1 }] + }) .build() .inspect() ).resolves.toBeTruthy(); @@ -641,7 +682,10 @@ describe('TxBuilder/delegatePortfolio', () => { await expect( txBuilder - .delegatePortfolio({ pools: [{ id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), weight: 1 }] }) + .delegatePortfolio({ + name: 'Test Portfolio', + pools: [{ id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), weight: 1 }] + }) .build() .inspect() ).rejects.toThrow(OutOfSyncRewardAccounts); diff --git a/packages/wallet/src/PersonalWallet/PersonalWallet.ts b/packages/wallet/src/PersonalWallet/PersonalWallet.ts index 63e1703c8d6..77396f6a0b7 100644 --- a/packages/wallet/src/PersonalWallet/PersonalWallet.ts +++ b/packages/wallet/src/PersonalWallet/PersonalWallet.ts @@ -6,6 +6,7 @@ import { ConnectionStatus, ConnectionStatusTracker, DelegationTracker, + DynamicChangeAddressResolver, FailedTx, HDSequentialDiscovery, OutgoingTx, @@ -84,12 +85,7 @@ import { tap, throwError } from 'rxjs'; -import { - ChangeAddressResolver, - InputSelector, - StaticChangeAddressResolver, - roundRobinRandomImprove -} from '@cardano-sdk/input-selection'; +import { ChangeAddressResolver, InputSelector, roundRobinRandomImprove } from '@cardano-sdk/input-selection'; import { Cip30DataSignature } from '@cardano-sdk/dapp-connector'; import { Ed25519PublicKeyHex } from '@cardano-sdk/crypto'; import { @@ -328,19 +324,6 @@ export class PersonalWallet implements ObservableWallet { ) ); - this.#inputSelector = inputSelector - ? inputSelector - : roundRobinRandomImprove({ - changeAddressResolver: new StaticChangeAddressResolver(() => - firstValueFrom( - this.syncStatus.isSettled$.pipe( - filter((isSettled) => isSettled), - switchMap(() => this.addresses$) - ) - ) - ) - }); - this.#tip$ = this.tip$ = new TipTracker({ connectionStatus$: connectionStatusTracker$, logger: contextLogger(this.#logger, 'tip$'), @@ -483,6 +466,20 @@ export class PersonalWallet implements ObservableWallet { utxoTracker: this.utxo }); + this.#inputSelector = inputSelector + ? inputSelector + : roundRobinRandomImprove({ + changeAddressResolver: new DynamicChangeAddressResolver( + this.syncStatus.isSettled$.pipe( + filter((isSettled) => isSettled), + switchMap(() => this.addresses$) + ), + this.delegation.distribution$, + () => firstValueFrom(this.delegation.portfolio$), + logger + ) + }); + this.activePublicStakeKeys$ = createActivePublicStakeKeysTracker({ addresses$: this.addresses$, keyAgent: this.keyAgent, diff --git a/packages/wallet/src/services/ChangeAddress/DynamicChangeAddressResolver.ts b/packages/wallet/src/services/ChangeAddress/DynamicChangeAddressResolver.ts index 8e51765bdc1..35a9c5e4edc 100644 --- a/packages/wallet/src/services/ChangeAddress/DynamicChangeAddressResolver.ts +++ b/packages/wallet/src/services/ChangeAddress/DynamicChangeAddressResolver.ts @@ -267,7 +267,7 @@ const distributeChange = (changeOutputs: Array, prefilledBuckets: * @param distribution the distribution. * @returns true if both matches, otherwise; false. */ -const delegationMatchesPortfolio = ( +export const delegationMatchesPortfolio = ( portfolio: Cardano.Cip17DelegationPortfolio, distribution: DelegatedStake[] ): boolean => { @@ -316,27 +316,14 @@ export class DynamicChangeAddressResolver implements ChangeAddressResolver { */ async resolve(selection: Selection): Promise> { const delegationDistribution = [...(await firstValueFrom(this.#delegationDistribution)).values()]; - const portfolio = await this.#getDelegationPortfolio(); + 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 no portfolio is found, assign all change to the first address. - if (!portfolio) { - updatedChange = updatedChange.map((txOut) => { - txOut.address = addresses[0].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 delegating to the first address and log a warning. - if (!delegationMatchesPortfolio(portfolio, delegationDistribution)) { - this.#logger.warn('The portfolio doesnt match current wallet delegation.'); - + // 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; @@ -367,6 +354,20 @@ export class DynamicChangeAddressResolver implements ChangeAddressResolver { 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); diff --git a/packages/wallet/src/services/DelegationTracker/DelegationTracker.ts b/packages/wallet/src/services/DelegationTracker/DelegationTracker.ts index cd191f6c077..0f041e65128 100644 --- a/packages/wallet/src/services/DelegationTracker/DelegationTracker.ts +++ b/packages/wallet/src/services/DelegationTracker/DelegationTracker.ts @@ -70,6 +70,23 @@ export const certificateTransactionsWithEpochs = ( transactions.map((tx) => ({ epoch: slotEpochCalc(tx.blockHeader.slot), tx })) ) ); + +export const createDelegationPortfolioTracker = (delegationTransactions: Observable) => + delegationTransactions.pipe( + map((transactionsWithEpochs) => { + const txSorted = transactionsWithEpochs.sort((lhs, rhs) => lhs.epoch - rhs.epoch); + const latestDelegation = txSorted.pop(); + if (!latestDelegation || !latestDelegation.tx.auxiliaryData || !latestDelegation.tx.auxiliaryData.blob) + return null; + + const portfolio = latestDelegation.tx.auxiliaryData.blob.get(Cardano.DelegationMetadataLabel); + + if (!portfolio) return null; + + return Cardano.cip17FromMetadatum(portfolio); + }) + ); + export const createDelegationTracker = ({ rewardAccountAddresses$, epoch$, @@ -111,6 +128,7 @@ export const createDelegationTracker = ({ Cardano.CertificateType.StakeKeyDeregistration ] ).pipe(tap((transactionsWithEpochs) => logger.debug(`Found ${transactionsWithEpochs.length} staking transactions`))); + const rewardsHistory$ = new TrackerSubject( createRewardsHistoryTracker( transactions$, @@ -121,6 +139,9 @@ export const createDelegationTracker = ({ onFatalError ) ); + + const portfolio$ = new TrackerSubject(createDelegationPortfolioTracker(transactions$)); + const rewardAccounts$ = new TrackerSubject( createRewardAccountsTracker({ balancesStore: stores.rewardsBalances, @@ -137,11 +158,13 @@ export const createDelegationTracker = ({ ); return { distribution$, + portfolio$, rewardAccounts$, rewardsHistory$, shutdown: () => { rewardAccounts$.complete(); rewardsHistory$.complete(); + portfolio$.complete(); logger.debug('Shutdown'); } }; diff --git a/packages/wallet/src/services/types.ts b/packages/wallet/src/services/types.ts index a6b8171153f..fc221714cb3 100644 --- a/packages/wallet/src/services/types.ts +++ b/packages/wallet/src/services/types.ts @@ -116,4 +116,5 @@ export interface DelegationTracker { rewardsHistory$: Observable; rewardAccounts$: Observable; distribution$: Observable>; + portfolio$: Observable; } diff --git a/packages/wallet/test/services/ChangeAddress/DynamicChangeAddressResolver.test.ts b/packages/wallet/test/services/ChangeAddress/DynamicChangeAddressResolver.test.ts index fdf03d83c77..7e66c17f344 100644 --- a/packages/wallet/test/services/ChangeAddress/DynamicChangeAddressResolver.test.ts +++ b/packages/wallet/test/services/ChangeAddress/DynamicChangeAddressResolver.test.ts @@ -1,5 +1,5 @@ import { Cardano } from '@cardano-sdk/core'; -import { DelegatedStake, DynamicChangeAddressResolver } from '../../../src'; +import { DelegatedStake, DynamicChangeAddressResolver, delegationMatchesPortfolio } from '../../../src'; import { InvalidStateError, Percent } from '@cardano-sdk/util'; import { address_0_0, @@ -22,14 +22,88 @@ import { poolId2, poolId3, poolId4, + rewardAccount_0, rewardAccount_1, rewardAccount_2, rewardAccount_3 } from './testData'; import { logger } from '@cardano-sdk/util-dev'; +describe('delegationMatchesPortfolio', () => { + const poolIds: Cardano.PoolId[] = [ + Cardano.PoolId('pool1zuevzm3xlrhmwjw87ec38mzs02tlkwec9wxpgafcaykmwg7efhh'), + Cardano.PoolId('pool1t9xlrjyk76c96jltaspgwcnulq6pdkmhnge8xgza8ku7qvpsy9r'), + Cardano.PoolId('pool1la4ghj4w4f8p4yk4qmx0qvqmzv6592ee9rs0vgla5w6lc2nc8w5') + ]; + + const delegation1: DelegatedStake[] = [ + { + pool: { + hexId: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), + status: Cardano.StakePoolStatus.Active + } + } as unknown as DelegatedStake, + { + pool: { + hexId: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[1])), + status: Cardano.StakePoolStatus.Active + }, + rewardAccounts: [] + } as unknown as DelegatedStake, + { + pool: { + hexId: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[2])), + status: Cardano.StakePoolStatus.Active + }, + rewardAccounts: [] + } as unknown as DelegatedStake + ]; + + const delegation2: DelegatedStake[] = [ + { + pool: { + hexId: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), + status: Cardano.StakePoolStatus.Active + } + } as unknown as DelegatedStake, + { + pool: { + hexId: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[1])), + status: Cardano.StakePoolStatus.Active + }, + rewardAccounts: [] + } as unknown as DelegatedStake + ]; + + const portfolio = { + name: 'Portfolio', + pools: [ + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[0])), + weight: 1 + }, + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[1])), + weight: 1 + }, + { + id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolIds[2])), + weight: 1 + } + ] + }; + + it('returns true if portfolio matches current delegation', () => { + expect(delegationMatchesPortfolio(portfolio, delegation1)).toBeTruthy(); + }); + + it('returns false if portfolio doesnt matches current delegation', () => { + expect(delegationMatchesPortfolio(portfolio, delegation2)).toBeFalsy(); + }); +}); + describe('DynamicChangeAddressResolver', () => { - it('resolves to the first address in the knownAddresses if no portfolio is given', async () => { + it('assigns ownership of all change outputs to the address containing the stake credential, if delegating to one pool', async () => { const changeAddressResolver = new DynamicChangeAddressResolver( knownAddresses$, createMockDelegateTracker( @@ -39,7 +113,7 @@ describe('DynamicChangeAddressResolver', () => { { percentage: Percent(0), pool: pool1, - rewardAccounts: [], + rewardAccounts: [rewardAccount_3], stake: 0n } ] @@ -57,11 +131,11 @@ describe('DynamicChangeAddressResolver', () => { }, { address: '_' as Cardano.PaymentAddress, - value: { coins: 20n } + value: { coins: 10n } }, { address: '_' as Cardano.PaymentAddress, - value: { coins: 30n } + value: { coins: 10n } } ], fee: 0n, @@ -70,10 +144,115 @@ describe('DynamicChangeAddressResolver', () => { }; const updatedChange = await changeAddressResolver.resolve(selection); + + expect(updatedChange).toEqual([ + { address: address_0_3, value: { coins: 10n } }, + { address: address_0_3, value: { coins: 10n } }, + { address: address_0_3, value: { coins: 10n } } + ]); + }); + + it('adds all change outputs at payment_stake address 0 if the wallet is currently not delegating to any pool', async () => { + const changeAddressResolver = new DynamicChangeAddressResolver( + knownAddresses$, + createMockDelegateTracker(new Map([])).distribution$, + getNullDelegationPortfolio, + logger + ); + + const selection = { + change: [ + { + address: '_' as Cardano.PaymentAddress, + value: { coins: 10n } + }, + { + address: '_' as Cardano.PaymentAddress, + value: { coins: 10n } + }, + { + address: '_' as Cardano.PaymentAddress, + value: { coins: 10n } + } + ], + fee: 0n, + inputs: new Set(), + outputs: new Set() + }; + + const updatedChange = await changeAddressResolver.resolve(selection); + expect(updatedChange).toEqual([ { address: address_0_0, value: { coins: 10n } }, - { address: address_0_0, value: { coins: 20n } }, - { address: address_0_0, value: { coins: 30n } } + { address: address_0_0, value: { coins: 10n } }, + { address: address_0_0, value: { coins: 10n } } + ]); + }); + + it('distributes change equally between the currently delegated addresses if no portfolio is given, ', async () => { + const changeAddressResolver = new DynamicChangeAddressResolver( + knownAddresses$, + createMockDelegateTracker( + new Map([ + [ + poolId1, + { + percentage: Percent(0), + pool: pool1, + rewardAccounts: [rewardAccount_1], + stake: 0n + } + ], + [ + poolId2, + { + percentage: Percent(0), + pool: pool2, + rewardAccounts: [rewardAccount_2], + stake: 0n + } + ], + [ + poolId3, + { + percentage: Percent(0), + pool: pool2, + rewardAccounts: [rewardAccount_3], + stake: 0n + } + ] + ]) + ).distribution$, + getNullDelegationPortfolio, + logger + ); + + const selection = { + change: [ + { + address: '_' as Cardano.PaymentAddress, + value: { coins: 10n } + }, + { + address: '_' as Cardano.PaymentAddress, + value: { coins: 10n } + }, + { + address: '_' as Cardano.PaymentAddress, + value: { coins: 10n } + } + ], + fee: 0n, + inputs: new Set(), + outputs: new Set() + }; + + const updatedChange = await changeAddressResolver.resolve(selection); + + expect(updatedChange).toEqual([ + { address: address_0_3, value: { coins: 10n } }, + { address: address_0_2, value: { coins: 10n } }, + { address: address_0_1, value: { coins: 10n } } ]); }); @@ -122,7 +301,7 @@ describe('DynamicChangeAddressResolver', () => { ); }); - it('resolves to the first address in the knownAddresses if portfolio doesnt match current delegation', async () => { + it('distributes change equally between the currently delegated addresses if portfolio doesnt match current delegation', async () => { const changeAddressResolver = new DynamicChangeAddressResolver( knownAddresses$, createMockDelegateTracker( @@ -132,7 +311,7 @@ describe('DynamicChangeAddressResolver', () => { { percentage: Percent(0), pool: pool1, - rewardAccounts: [], + rewardAccounts: [rewardAccount_0], stake: 0n } ], @@ -141,7 +320,7 @@ describe('DynamicChangeAddressResolver', () => { { percentage: Percent(0), pool: pool2, - rewardAccounts: [], + rewardAccounts: [rewardAccount_1], stake: 0n } ] @@ -162,6 +341,10 @@ describe('DynamicChangeAddressResolver', () => { const selection = { change: [ + { + address: '_' as Cardano.PaymentAddress, + value: { coins: 10n } + }, { address: '_' as Cardano.PaymentAddress, value: { coins: 10n } @@ -173,7 +356,10 @@ describe('DynamicChangeAddressResolver', () => { }; const updatedChange = await changeAddressResolver.resolve(selection); - expect(updatedChange).toEqual([{ address: address_0_0, value: { coins: 10n } }]); + expect(updatedChange).toEqual([ + { address: address_0_1, value: { coins: 10n } }, + { address: address_0_0, value: { coins: 10n } } + ]); }); it('delegates to a single reward account', async () => { diff --git a/packages/wallet/test/services/DelegationTracker/DelegationTracker.test.ts b/packages/wallet/test/services/DelegationTracker/DelegationTracker.test.ts index be55a36c553..a5828837ed2 100644 --- a/packages/wallet/test/services/DelegationTracker/DelegationTracker.test.ts +++ b/packages/wallet/test/services/DelegationTracker/DelegationTracker.test.ts @@ -1,9 +1,9 @@ -import { Cardano, ChainHistoryProvider } from '@cardano-sdk/core'; +import { Cardano, ChainHistoryProvider, metadatum } from '@cardano-sdk/core'; import { RetryBackoffConfig } from 'backoff-rxjs'; -import { TransactionsTracker } from '../../../src/services'; +import { TransactionsTracker, createDelegationPortfolioTracker } from '../../../src/services'; import { certificateTransactionsWithEpochs, createBlockEpochProvider } from '../../../src/services/DelegationTracker'; import { coldObservableProvider } from '@cardano-sdk/util-rxjs'; -import { createStubTxWithCertificates } from './stub-tx'; +import { createStubTxWithCertificates, createStubTxWithEpoch } from './stub-tx'; import { createTestScheduler } from '@cardano-sdk/util-dev'; jest.mock('@cardano-sdk/util-rxjs', () => { @@ -128,4 +128,180 @@ describe('DelegationTracker', () => { }); }); }); + + describe('delegationPortfolio', () => { + const cip17DelegationPortfolio: Cardano.Cip17DelegationPortfolio = { + author: 'me', + name: 'My portfolio', + pools: [ + { + id: '10000000000000000000000000000000000000000000000000000000' as Cardano.PoolIdHex, + weight: 1 + }, + { + id: '20000000000000000000000000000000000000000000000000000000' as Cardano.PoolIdHex, + weight: 1 + } + ] + }; + + const cip17DelegationPortfolio2: Cardano.Cip17DelegationPortfolio = { + author: 'me', + name: 'My portfolio 2', + pools: [ + { + id: '11000000000000000000000000000000000000000000000000000011' as Cardano.PoolIdHex, + weight: 1 + } + ] + }; + + it('always returns the latest portfolio', () => { + createTestScheduler().run(({ cold, expectObservable }) => { + const rewardAccount = Cardano.RewardAccount('stake_test1upqykkjq3zhf4085s6n70w8cyp57dl87r0ezduv9rnnj2uqk5zmdv'); + + const transactions$ = cold('a-b-c-d', { + a: [ + createStubTxWithEpoch(284, [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ]) + ], + b: [ + createStubTxWithEpoch(284, [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ]), + createStubTxWithEpoch( + 285, + [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ], + { + blob: new Map([[Cardano.DelegationMetadataLabel, metadatum.jsonToMetadatum(cip17DelegationPortfolio)]]) + } + ) + ], + c: [ + createStubTxWithEpoch(284, [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ]), + createStubTxWithEpoch( + 285, + [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ], + { + blob: new Map([[Cardano.DelegationMetadataLabel, metadatum.jsonToMetadatum(cip17DelegationPortfolio)]]) + } + ), + createStubTxWithEpoch(286, [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ]) + ], + d: [ + createStubTxWithEpoch(284, [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ]), + createStubTxWithEpoch( + 285, + [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ], + { + blob: new Map([[Cardano.DelegationMetadataLabel, metadatum.jsonToMetadatum(cip17DelegationPortfolio)]]) + } + ), + createStubTxWithEpoch(286, [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ]), + createStubTxWithEpoch( + 287, + [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ], + { + blob: new Map([[Cardano.DelegationMetadataLabel, metadatum.jsonToMetadatum(cip17DelegationPortfolio2)]]) + } + ) + ] + }); + + const portfolio$ = createDelegationPortfolioTracker(transactions$); + + expectObservable(portfolio$).toBe('a-b-c-d', { + a: null, + b: cip17DelegationPortfolio, + c: null, + d: cip17DelegationPortfolio2 + }); + }); + }); + + it('returns null if the most recent transaction does not have the metadata', () => { + createTestScheduler().run(({ cold, expectObservable }) => { + const rewardAccount = Cardano.RewardAccount('stake_test1upqykkjq3zhf4085s6n70w8cyp57dl87r0ezduv9rnnj2uqk5zmdv'); + + const transactions$ = cold('a-b', { + a: [ + createStubTxWithEpoch( + 284, + [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ], + { + blob: new Map([[Cardano.DelegationMetadataLabel, metadatum.jsonToMetadatum(cip17DelegationPortfolio)]]) + } + ) + ], + b: [ + createStubTxWithEpoch(286, [ + { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash: Cardano.RewardAccount.toHash(rewardAccount) + } + ]) + ] + }); + + const portfolio$ = createDelegationPortfolioTracker(transactions$); + + expectObservable(portfolio$).toBe('a-b', { + a: cip17DelegationPortfolio, + b: null + }); + }); + }); + }); }); diff --git a/packages/wallet/test/services/DelegationTracker/stub-tx.ts b/packages/wallet/test/services/DelegationTracker/stub-tx.ts index 143abfcc5b6..1415a213d62 100644 --- a/packages/wallet/test/services/DelegationTracker/stub-tx.ts +++ b/packages/wallet/test/services/DelegationTracker/stub-tx.ts @@ -1,8 +1,14 @@ import { Cardano } from '@cardano-sdk/core'; +import { TxWithEpoch } from '../../../src/services/DelegationTracker/types'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const createStubTxWithCertificates = (certificates?: Cardano.Certificate[], commonCertProps?: any) => +export const createStubTxWithCertificates = ( + certificates?: Cardano.Certificate[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + commonCertProps?: any, + auxData?: Cardano.AuxiliaryData +) => ({ + auxiliaryData: auxData, blockHeader: { slot: Cardano.Slot(37_834_496) }, @@ -10,3 +16,21 @@ export const createStubTxWithCertificates = (certificates?: Cardano.Certificate[ certificates: certificates?.map((cert) => ({ ...cert, ...commonCertProps })) } } as Cardano.HydratedTx); + +export const createStubTxWithEpoch = ( + epoch: number, + certificates?: Cardano.Certificate[], + auxData?: Cardano.AuxiliaryData +) => + ({ + epoch: Cardano.EpochNo(epoch), + tx: { + auxiliaryData: auxData, + blockHeader: { + slot: Cardano.Slot(37_834_496) + }, + body: { + certificates: certificates?.map((cert) => ({ ...cert })) + } + } as Cardano.HydratedTx + } as TxWithEpoch); diff --git a/packages/web-extension/src/observableWallet/util.ts b/packages/web-extension/src/observableWallet/util.ts index 798a8f633b9..b6bd90d2702 100644 --- a/packages/web-extension/src/observableWallet/util.ts +++ b/packages/web-extension/src/observableWallet/util.ts @@ -98,6 +98,7 @@ export const observableWalletProperties: RemoteApiProperties = currentEpoch$: RemoteApiPropertyType.HotObservable, delegation: { distribution$: RemoteApiPropertyType.HotObservable, + portfolio$: RemoteApiPropertyType.HotObservable, rewardAccounts$: RemoteApiPropertyType.HotObservable, rewardsHistory$: RemoteApiPropertyType.HotObservable },