Skip to content

Commit

Permalink
feat!: txBuilder delegatePortfolio
Browse files Browse the repository at this point in the history
TxBuilder has new method `delegatePortfolio` that derives more stake keys if needed, and
creates the certificates that satisfy the CIP17 multi-delegation intent.

BREAKING CHANGE: TxBuilderProviders.rewardAccounts expects RewardAccountWithPoolId type,
  instead of Omit<RewardAccount, 'delegatee'>
  • Loading branch information
mirceahasegan committed Jun 21, 2023
1 parent f2691dc commit ec0860e
Show file tree
Hide file tree
Showing 8 changed files with 574 additions and 49 deletions.
15 changes: 14 additions & 1 deletion packages/core/src/Cardano/types/DelegationsAndRewards.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Lovelace } from './Value';
import { PoolId, StakePool } from './StakePool';
import { PoolId, PoolIdHex, StakePool } from './StakePool';
import { RewardAccount } from '../Address';

export interface DelegationsAndRewards {
Expand Down Expand Up @@ -31,3 +31,16 @@ export interface RewardAccountInfo {
rewardBalance: Lovelace;
// Maybe add rewardsHistory for each reward account too
}

export interface Cip17Pool {
id: PoolIdHex;
weight: number;
name?: string;
ticker?: string;
}
export interface Cip17DelegationPortfolio {
name: string;
pools: Cip17Pool[];
description?: string;
author?: string;
}
Original file line number Diff line number Diff line change
@@ -1,34 +1,21 @@
import { AddressType } from '@cardano-sdk/key-management';
import { Cardano } from '@cardano-sdk/core';
import { DelegatedStake, PersonalWallet, createUtxoBalanceByAddressTracker } from '@cardano-sdk/wallet';
import { MINUTE, firstValueFromTimed, getWallet, submitAndConfirm, walletReady } from '../../../src';
import { Observable, filter, firstValueFrom, map, tap } from 'rxjs';
import { Percent } from '@cardano-sdk/util';
import { createLogger } from '@cardano-sdk/util-dev';
import { getEnv, walletVariables } from '../../../src/environment';
import delay from 'delay';

const env = getEnv(walletVariables);
const logger = createLogger();
const TEST_FUNDS = 1_000_000_000n;
const POOLS_COUNT = 5;
const distributionMessage = 'ObservableWallet.delegation.distribution$:';

const deriveStakeKeys = async (wallet: PersonalWallet) => {
await walletReady(wallet, 0n);
// Add 4 new addresses with different stake keys.
for (let i = 1; i < 5; ++i) {
await wallet.keyAgent.deriveAddress({ index: 0, type: AddressType.External }, i);
}
// Allow status tracker to change status with debounce.
// Otherwise the updates are be debounced and next calls find the wallet ready before it had a chance to update the status.
await delay(2);
};

/** Distribute the wallet funds evenly across all its addresses */
const distributeFunds = async (wallet: PersonalWallet) => {
await walletReady(wallet, 0n);
const addresses = await firstValueFrom(wallet.addresses$);
expect(addresses.length).toBeGreaterThan(1);

// Check that we have enough funds. Otherwise, fund it from wallet account at index 0
let { coins: totalCoins } = await firstValueFrom(wallet.balance.utxo.available$);
Expand Down Expand Up @@ -97,7 +84,7 @@ const deregisterAllStakeKeys = async (wallet: PersonalWallet): Promise<void> =>
} catch {
// Some stake keys are registered. Deregister them
const txBuilder = wallet.createTxBuilder();
txBuilder.delegate();
txBuilder.delegatePortfolio(null);
const { tx: deregTx } = await txBuilder.build().sign();
await submitAndConfirm(wallet, deregTx);

Expand All @@ -109,44 +96,47 @@ const deregisterAllStakeKeys = async (wallet: PersonalWallet): Promise<void> =>
}
};

const getPoolIds = async (wallet: PersonalWallet, count: number): Promise<Cardano.StakePool[]> => {
const getPoolIds = async (wallet: PersonalWallet): Promise<Cardano.StakePool[]> => {
const activePools = await wallet.stakePoolProvider.queryStakePools({
filters: { status: [Cardano.StakePoolStatus.Active] },
pagination: { limit: count, startAt: 0 }
pagination: { limit: POOLS_COUNT, startAt: 0 }
});
expect(activePools.pageResults.length).toBeGreaterThanOrEqual(count);
return Array.from({ length: count }).map((_, index) => activePools.pageResults[index]);
expect(activePools.pageResults.length).toBeGreaterThanOrEqual(POOLS_COUNT);
return Array.from({ length: POOLS_COUNT }).map((_, index) => activePools.pageResults[index]);
};

const delegateToMultiplePools = async (wallet: PersonalWallet) => {
// Delegating to multiple pools should be added in TxBuilder. Doing it manually for now.
// Prepare stakeKey registration certificates
const rewardAccounts = await firstValueFrom(wallet.delegation.rewardAccounts$);
const stakeKeyRegCertificates = rewardAccounts.map(({ address }) => Cardano.createStakeKeyRegistrationCert(address));
const poolIds = await getPoolIds(wallet);
const portfolio: Pick<Cardano.Cip17DelegationPortfolio, 'pools'> = {
pools: poolIds.map(({ hexId: id }) => ({ id, weight: 1 }))
};
logger.debug('Delegating portfolio', portfolio);

const poolIds = await getPoolIds(wallet, rewardAccounts.length);
const delegationCertificates = rewardAccounts.map(({ address }, index) =>
Cardano.createDelegationCert(address, poolIds[index].id)
);
const { tx } = await wallet.createTxBuilder().delegatePortfolio(portfolio).build().sign();
await submitAndConfirm(wallet, tx);
return poolIds;
};

logger.debug(
`Delegating to pools ${poolIds.map(({ id }) => id)} and registering ${stakeKeyRegCertificates.length} stake keys`
const delegateAllToSinglePool = async (wallet: PersonalWallet): Promise<void> => {
// This is a negative testcase, simulating an HD wallet that has multiple stake keys delegated
// to the same stake pool. txBuilder.delegatePortfolio does not support this scenario.
const [{ id: poolId }] = await getPoolIds(wallet);
const txBuilder = wallet.createTxBuilder();
const rewardAccounts = await firstValueFrom(wallet.delegation.rewardAccounts$);
txBuilder.partialTxBody.certificates = rewardAccounts.map(({ address }) =>
Cardano.createDelegationCert(address, poolId)
);

const txBuilder = wallet.createTxBuilder();
// Artificially add the certificates in TxBuilder. An api improvement will make the UX better
txBuilder.partialTxBody.certificates = [...stakeKeyRegCertificates, ...delegationCertificates];
logger.debug(`Delegating all stake keys to pool ${poolId}`);
const { tx } = await txBuilder.build().sign();
await submitAndConfirm(wallet, tx);
return poolIds;
};

describe('PersonalWallet/delegationDistribution', () => {
let wallet: PersonalWallet;

beforeAll(async () => {
wallet = (await getWallet({ env, idx: 3, logger, name: 'Wallet', polling: { interval: 50 } })).wallet;
await deriveStakeKeys(wallet);
await deregisterAllStakeKeys(wallet);
await distributeFunds(wallet);
});
Expand All @@ -158,17 +148,17 @@ describe('PersonalWallet/delegationDistribution', () => {
it('reports observable wallet multi delegation as delegationDistribution by pool', async () => {
await walletReady(wallet);

const walletAddresses = await firstValueFromTimed(wallet.addresses$);
const rewardAccounts = await firstValueFrom(wallet.delegation.rewardAccounts$);

expect(rewardAccounts.length).toBe(5);

// No stake distribution initially
const delegationDistribution = await firstValueFrom(wallet.delegation.distribution$);
logger.info('Empty delegation distribution initially');
expect(delegationDistribution).toEqual(new Map());

const poolIds = await delegateToMultiplePools(wallet);
const walletAddresses = await firstValueFromTimed(wallet.addresses$);
const rewardAccounts = await firstValueFrom(wallet.delegation.rewardAccounts$);

expect(rewardAccounts.length).toBe(POOLS_COUNT);

// Redistribute the funds because delegation costs send change to the first account, messing up the uniform distribution
await distributeFunds(wallet);

Expand Down Expand Up @@ -206,7 +196,7 @@ describe('PersonalWallet/delegationDistribution', () => {

// Send all coins to the last address. Check that stake distribution is 100 for that address and 0 for the rest
const { coins: totalCoins } = await firstValueFrom(wallet.balance.utxo.total$);
let txBuilder = wallet.createTxBuilder();
const txBuilder = wallet.createTxBuilder();
const { tx: txMoveFunds } = await txBuilder
.addOutput(
txBuilder
Expand Down Expand Up @@ -246,9 +236,7 @@ describe('PersonalWallet/delegationDistribution', () => {
);

// Delegate all reward accounts to the same pool. delegationDistribution$ should have 1 entry with 100% distribution
txBuilder = wallet.createTxBuilder();
const { tx: txDelegateTo1Pool } = await txBuilder.delegate(poolIds[0].id).build().sign();
await submitAndConfirm(wallet, txDelegateTo1Pool);
await delegateAllToSinglePool(wallet);
simplifiedDelegationDistribution = await firstValueFrom(
wallet.delegation.distribution$.pipe(
tap((distribution) => {
Expand Down
109 changes: 107 additions & 2 deletions packages/tx-construction/src/tx-builder/TxBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Crypto from '@cardano-sdk/crypto';
import { Cardano, HandleProvider, HandleResolution } from '@cardano-sdk/core';
import { Logger } from 'ts-log';
import {
InsufficientRewardAccounts,
OutputBuilderTxOut,
PartialTx,
PartialTxOut,
Expand All @@ -13,9 +14,11 @@ import {
TxOutValidationError,
UnsignedTx
} from './types';
import { Logger } from 'ts-log';
import { OutputBuilderValidator, TxOutputBuilder } from './OutputBuilder';
import { RewardAccountWithPoolId } from '../types';
import { SelectionSkeleton } from '@cardano-sdk/input-selection';
import { SignTransactionOptions, TransactionSigner } from '@cardano-sdk/key-management';
import { SignTransactionOptions, TransactionSigner, util } from '@cardano-sdk/key-management';
import { contextLogger, deepEquals } from '@cardano-sdk/util';
import { createOutputValidator } from '../output-validation';
import { finalizeTx } from './finalizeTx';
Expand Down Expand Up @@ -51,6 +54,8 @@ interface LazySignerProps {
signer: Signer;
}

type TxBuilderStakePool = Omit<Cardano.Cip17Pool, 'id'> & { id: Cardano.PoolId };

class LazyTxSigner implements UnsignedTx {
#built?: BuiltTx;
#signer: Signer;
Expand Down Expand Up @@ -88,6 +93,7 @@ export class GenericTxBuilder implements TxBuilder {
#dependencies: TxBuilderDependencies;
#outputValidator: OutputBuilderValidator;
#delegateConfig: DelegateConfig;
#requestedPortfolio?: TxBuilderStakePool[];
#logger: Logger;
#handleProvider?: HandleProvider;
#handles: HandleResolution[];
Expand Down Expand Up @@ -146,6 +152,17 @@ export class GenericTxBuilder implements TxBuilder {
return this;
}

delegatePortfolio(portfolio: Pick<Cardano.Cip17DelegationPortfolio, 'pools'> | null): TxBuilder {
if (portfolio?.pools.length === 0) {
throw new Error('Portfolio should define at least one delegation pool.');
}
this.#requestedPortfolio = (portfolio?.pools ?? []).map((pool) => ({
...pool,
id: Cardano.PoolId.fromKeyHash(pool.id as unknown as Crypto.Ed25519KeyHashHex)
}));
return this;
}

metadata(metadata: Cardano.TxMetadata): TxBuilder {
this.partialAuxiliaryData = { ...this.partialAuxiliaryData, blob: new Map(metadata) };
return this;
Expand All @@ -168,6 +185,7 @@ export class GenericTxBuilder implements TxBuilder {
this.#logger.debug('Building');
try {
await this.#addDelegationCertificates();
await this.#delegatePortfolio();
await this.#validateOutputs();
// Take a snapshot of returned properties,
// so that they don't change while `initializeTx` is resolving
Expand Down Expand Up @@ -278,4 +296,91 @@ export class GenericTxBuilder implements TxBuilder {
}
}
}

async #getOrCreateRewardAccounts(): Promise<RewardAccountWithPoolId[]> {
if (this.#requestedPortfolio) {
await util.ensureStakeKeys({
count: this.#requestedPortfolio.length,
keyAgent: this.#dependencies.keyAgent,
logger: contextLogger(this.#logger, 'getOrCreateRewardAccounts')
});
}

return this.#dependencies.txBuilderProviders.rewardAccounts();
}

async #delegatePortfolio(): Promise<void> {
if (!this.#requestedPortfolio) {
// Delegation using CIP17 portfolio was not requested
return;
}

// Create stake keys to match number of requested pools
const rewardAccounts = await this.#getOrCreateRewardAccounts();

// New poolIds will be allocated to un-delegated stake keys
const newPoolIds = this.#requestedPortfolio
.filter((cip17Pool) =>
rewardAccounts.every((rewardAccount) => rewardAccount.delegatee?.nextNextEpoch?.id !== cip17Pool.id)
)
.map(({ id }) => id)
.reverse();

this.#logger.debug('New poolIds requested in portfolio:', newPoolIds);

// Reward accounts which don't have the stake key registered or that were delegated but should not be anymore
const availableRewardAccounts = rewardAccounts
.filter(
(rewardAccount) =>
rewardAccount.keyStatus === Cardano.StakeKeyStatus.Unregistered ||
!rewardAccount.delegatee?.nextNextEpoch ||
this.#requestedPortfolio?.every(({ id }) => id !== rewardAccount.delegatee?.nextNextEpoch?.id)
)
.sort(GenericTxBuilder.#sortRewardAccountsDelegatedFirst)
.reverse(); // items will be popped from this array, so we want the most suitable at the end of the array

if (newPoolIds.length > availableRewardAccounts.length) {
throw new InsufficientRewardAccounts(
newPoolIds,
availableRewardAccounts.map(({ address }) => address)
);
}

// Code below will pop items one by one (poolId)-(available stake key)
const certificates: Cardano.Certificate[] = [];
while (newPoolIds.length > 0 && availableRewardAccounts.length > 0) {
const newPoolId = newPoolIds.pop()!;
const rewardAccount = availableRewardAccounts.pop()!;
this.#logger.debug(`Building delegation certificate for ${newPoolId} ${rewardAccount}`);
if (rewardAccount.keyStatus !== Cardano.StakeKeyStatus.Registered) {
certificates.push(Cardano.createStakeKeyRegistrationCert(rewardAccount.address));
}
certificates.push(Cardano.createDelegationCert(rewardAccount.address, newPoolId));
}

// Deregister stake keys no longer needed
this.#logger.debug(`De-registering ${availableRewardAccounts.length} stake keys`);
for (const rewardAccount of availableRewardAccounts) {
if (rewardAccount.keyStatus === Cardano.StakeKeyStatus.Registered) {
certificates.push(Cardano.createStakeKeyDeregistrationCert(rewardAccount.address));
}
}
this.partialTxBody = { ...this.partialTxBody, certificates };
}

/** Registered and delegated < Registered < Unregistered */
static #sortRewardAccountsDelegatedFirst(a: RewardAccountWithPoolId, b: RewardAccountWithPoolId): number {
const getScore = (acct: RewardAccountWithPoolId) => {
let score = 2;
if (acct.keyStatus === Cardano.StakeKeyStatus.Registered) {
score = 1;
if (acct.delegatee?.nextNextEpoch) {
score = 0;
}
}
return score;
};

return getScore(a) - getScore(b);
}
}
19 changes: 19 additions & 0 deletions packages/tx-construction/src/tx-builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export class HandleNotFoundError extends CustomError {
}
}

export class InsufficientRewardAccounts extends CustomError {
public constructor(poolIds: Cardano.PoolId[], rewardAccounts: Cardano.RewardAccount[]) {
const msg = `Internal error: insufficient stake keys: ${rewardAccounts.length}. Required: ${poolIds.length}.
Pool ids: ${poolIds.join(',')}; Reward accounts: ${rewardAccounts.length}`;
super(msg);
}
}

export class OutputValidationMissingRequiredError extends CustomError {
public constructor(public txOut: PartialTxOut) {
super(TxOutputFailure.MissingRequiredFields);
Expand Down Expand Up @@ -182,10 +190,21 @@ export interface TxBuilder {
* StakeKeyRegistration certificates are added in the transaction body.
* - Stake key deregister is done by not providing the `poolId` parameter: `delegate()`.
* - If wallet contains multiple reward accounts, it will create certificates for all of them.
* - It cannot be used in conjunction with {@link delegatePortfolio}
*
* @param poolId Pool Id to delegate to. If undefined, stake key deregistration will be done.
* @throws exception if used in conjunction with {@link delegatePortfolio}.
*/
delegate(poolId?: Cardano.PoolId): TxBuilder;
/**
* Configure the transaction to include all certificates needed to delegate to the pools from the portfolio.
* - It cannot be used in conjunction with {@link delegate}.
*
* @param portfolio the CIP17 delegation portfolio to apply. Using `null` will deregister all stake keys,
* reclaiming the deposits.
* @throws exception if used in conjunction with {@link delegate} call.
*/
delegatePortfolio(portfolio: Pick<Cardano.Cip17DelegationPortfolio, 'pools'> | null): TxBuilder;
/** Sets TxMetadata in {@link auxiliaryData} */
metadata(metadata: Cardano.TxMetadata): TxBuilder;
/** Sets extra signers in {@link extraSigners} */
Expand Down
6 changes: 5 additions & 1 deletion packages/tx-construction/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import { MinimumCoinQuantityPerOutput } from './output-validation';

export type InitializeTxResult = Cardano.TxBodyWithHash & { inputSelection: SelectionSkeleton };

export type RewardAccountWithPoolId = Omit<Cardano.RewardAccountInfo, 'delegatee'> & {
delegatee?: { nextNextEpoch?: { id: Cardano.PoolId } };
};

export interface TxBuilderProviders {
tip: () => Promise<Cardano.Tip>;
protocolParameters: () => Promise<Cardano.ProtocolParameters>;
genesisParameters: () => Promise<Cardano.CompactGenesis>;
rewardAccounts: () => Promise<Omit<Cardano.RewardAccountInfo, 'delegatee'>[]>;
rewardAccounts: () => Promise<RewardAccountWithPoolId[]>;
utxoAvailable: () => Promise<Cardano.Utxo[]>;
}

Expand Down
Loading

0 comments on commit ec0860e

Please sign in to comment.