diff --git a/.github/workflows/continuous-integration-blockfrost-e2e.yaml b/.github/workflows/continuous-integration-blockfrost-e2e.yaml index b7f19c50493..715a17bfabc 100644 --- a/.github/workflows/continuous-integration-blockfrost-e2e.yaml +++ b/.github/workflows/continuous-integration-blockfrost-e2e.yaml @@ -14,6 +14,8 @@ env: TEST_CLIENT_ASSET_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:3015"}' TEST_CLIENT_CHAIN_HISTORY_PROVIDER: 'blockfrost' TEST_CLIENT_CHAIN_HISTORY_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:3015"}' + TEST_CLIENT_DREP_PROVIDER: 'blockfrost' + TEST_CLIENT_DREP_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:3015"}' TEST_CLIENT_HANDLE_PROVIDER: 'http' TEST_CLIENT_HANDLE_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:4011/"}' TEST_CLIENT_NETWORK_INFO_PROVIDER: 'blockfrost' diff --git a/.github/workflows/continuous-integration-e2e.yaml b/.github/workflows/continuous-integration-e2e.yaml index 435063a37c2..623dbfb4cb8 100644 --- a/.github/workflows/continuous-integration-e2e.yaml +++ b/.github/workflows/continuous-integration-e2e.yaml @@ -14,6 +14,8 @@ env: TEST_CLIENT_ASSET_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:4014/"}' TEST_CLIENT_CHAIN_HISTORY_PROVIDER: 'ws' TEST_CLIENT_CHAIN_HISTORY_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:4000/"}' + TEST_CLIENT_DREP_PROVIDER: 'blockfrost' + TEST_CLIENT_DREP_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:3015"}' TEST_CLIENT_HANDLE_PROVIDER: 'http' TEST_CLIENT_HANDLE_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:4011/"}' TEST_CLIENT_NETWORK_INFO_PROVIDER: 'ws' diff --git a/packages/core/src/Cardano/types/DelegationsAndRewards.ts b/packages/core/src/Cardano/types/DelegationsAndRewards.ts index 5ef8d0540e2..32e0714a547 100644 --- a/packages/core/src/Cardano/types/DelegationsAndRewards.ts +++ b/packages/core/src/Cardano/types/DelegationsAndRewards.ts @@ -1,4 +1,5 @@ -import { DelegateRepresentative } from './Governance'; +import { AlwaysAbstain, AlwaysNoConfidence } from './Governance'; +import { DRepInfo } from '../../Provider'; import { Lovelace } from './Value'; import { Metadatum } from './AuxiliaryData'; import { PoolId, PoolIdHex, StakePool } from './StakePool'; @@ -24,7 +25,7 @@ export enum StakeCredentialStatus { Unregistered = 'UNREGISTERED' } -export type DRepDelegatee = { delegateRepresentative: DelegateRepresentative }; +export type DRepDelegatee = { delegateRepresentative: DRepInfo | AlwaysAbstain | AlwaysNoConfidence }; export interface RewardAccountInfo { address: RewardAccount; diff --git a/packages/core/src/Cardano/types/Governance.ts b/packages/core/src/Cardano/types/Governance.ts index 47d4c482a16..fae6dfc8dfd 100644 --- a/packages/core/src/Cardano/types/Governance.ts +++ b/packages/core/src/Cardano/types/Governance.ts @@ -1,5 +1,6 @@ import * as Crypto from '@cardano-sdk/crypto'; import { Credential, CredentialType, RewardAccount } from '../Address'; +import { DRepInfo } from '../../Provider'; import { EpochNo, Fraction, ProtocolVersion, TransactionId } from '.'; import { Lovelace } from './Value'; import { ProtocolParametersUpdateConway } from './ProtocolParameters'; @@ -187,10 +188,14 @@ export type AlwaysNoConfidence = { export type DelegateRepresentative = Credential | AlwaysAbstain | AlwaysNoConfidence; -export const isDRepCredential = (deleg: DelegateRepresentative): deleg is Credential => !('__typename' in deleg); +export const isDrepInfo = (drep: DelegateRepresentative | DRepInfo): drep is DRepInfo => + 'id' in drep && 'active' in drep; -export const isDRepAlwaysAbstain = (deleg: DelegateRepresentative): deleg is AlwaysAbstain => - !isDRepCredential(deleg) && deleg.__typename === 'AlwaysAbstain'; +export const isDRepCredential = (deleg: DelegateRepresentative | DRepInfo): deleg is Credential => + 'type' in deleg && 'hash' in deleg; -export const isDRepAlwaysNoConfidence = (deleg: DelegateRepresentative): deleg is AlwaysNoConfidence => - !isDRepCredential(deleg) && deleg.__typename === 'AlwaysNoConfidence'; +export const isDRepAlwaysAbstain = (deleg: DelegateRepresentative | DRepInfo): deleg is AlwaysAbstain => + '__typename' in deleg && deleg.__typename === 'AlwaysAbstain'; + +export const isDRepAlwaysNoConfidence = (deleg: DelegateRepresentative | DRepInfo): deleg is AlwaysNoConfidence => + '__typename' in deleg && deleg.__typename === 'AlwaysNoConfidence'; diff --git a/packages/e2e/.env.example b/packages/e2e/.env.example index 050190b50fc..443f5a7f994 100644 --- a/packages/e2e/.env.example +++ b/packages/e2e/.env.example @@ -14,6 +14,8 @@ TEST_CLIENT_ASSET_PROVIDER=http TEST_CLIENT_ASSET_PROVIDER_PARAMS='{"baseUrl":"http://localhost:4014/"}' TEST_CLIENT_CHAIN_HISTORY_PROVIDER=ws TEST_CLIENT_CHAIN_HISTORY_PROVIDER_PARAMS='{"baseUrl":"http://localhost:4000/"}' +TEST_CLIENT_DREP_PROVIDER='blockfrost' +TEST_CLIENT_DREP_PROVIDER_PARAMS='{"baseUrl":"http://localhost:3015"}' TEST_CLIENT_HANDLE_PROVIDER=http TEST_CLIENT_HANDLE_PROVIDER_PARAMS='{"baseUrl":"http://localhost:4011/"}' TEST_CLIENT_NETWORK_INFO_PROVIDER=ws diff --git a/packages/e2e/README.md b/packages/e2e/README.md index bdfcda0f8b2..d80d9b68f76 100644 --- a/packages/e2e/README.md +++ b/packages/e2e/README.md @@ -160,6 +160,8 @@ KEY_MANAGEMENT_PROVIDER=inMemory KEY_MANAGEMENT_PARAMS='{"accountIndex": 0, "chainId":{"networkId": 0, "networkMagic": 888}, "passphrase":"some_passphrase","mnemonic":""}' TEST_CLIENT_ASSET_PROVIDER=http TEST_CLIENT_ASSET_PROVIDER_PARAMS='{"baseUrl":"http://localhost:4000"}' +TEST_CLIENT_DREP_PROVIDER='blockfrost' +TEST_CLIENT_DREP_PROVIDER_PARAMS='{"baseUrl":"http://localhost:3015"}' TEST_CLIENT_CHAIN_HISTORY_PROVIDER=http TEST_CLIENT_CHAIN_HISTORY_PROVIDER_PARAMS='{"baseUrl":"http://localhost:4000"}' TEST_CLIENT_NETWORK_INFO_PROVIDER=http diff --git a/packages/e2e/src/environment.ts b/packages/e2e/src/environment.ts index 701f586ce30..b58af686491 100644 --- a/packages/e2e/src/environment.ts +++ b/packages/e2e/src/environment.ts @@ -92,6 +92,8 @@ const validators = { TEST_CLIENT_ASSET_PROVIDER_PARAMS: providerParams(), TEST_CLIENT_CHAIN_HISTORY_PROVIDER: str(), TEST_CLIENT_CHAIN_HISTORY_PROVIDER_PARAMS: providerParams(), + TEST_CLIENT_DREP_PROVIDER: str({ choices: ['blockfrost'] }), + TEST_CLIENT_DREP_PROVIDER_PARAMS: providerParams(), TEST_CLIENT_HANDLE_PROVIDER: str(), TEST_CLIENT_HANDLE_PROVIDER_PARAMS: providerParams(), TEST_CLIENT_NETWORK_INFO_PROVIDER: str(), @@ -145,6 +147,8 @@ export const walletVariables = [ 'TEST_CLIENT_ASSET_PROVIDER_PARAMS', 'TEST_CLIENT_CHAIN_HISTORY_PROVIDER', 'TEST_CLIENT_CHAIN_HISTORY_PROVIDER_PARAMS', + 'TEST_CLIENT_DREP_PROVIDER', + 'TEST_CLIENT_DREP_PROVIDER_PARAMS', 'TEST_CLIENT_HANDLE_PROVIDER', 'TEST_CLIENT_HANDLE_PROVIDER_PARAMS', 'KEY_MANAGEMENT_PARAMS', diff --git a/packages/e2e/src/factories.ts b/packages/e2e/src/factories.ts index 3f81a5c7ca8..d2940578958 100644 --- a/packages/e2e/src/factories.ts +++ b/packages/e2e/src/factories.ts @@ -18,6 +18,7 @@ import { AssetProvider, Cardano, ChainHistoryProvider, + DRepProvider, HandleProvider, NetworkInfoProvider, ProviderFactory, @@ -39,6 +40,7 @@ import { BlockfrostAssetProvider, BlockfrostChainHistoryProvider, BlockfrostClient, + BlockfrostDRepProvider, BlockfrostNetworkInfoProvider, BlockfrostRewardsProvider, BlockfrostTxSubmitProvider, @@ -81,6 +83,7 @@ export type CreateKeyAgent = (dependencies: KeyAgentDependencies) => Promise(); export const assetProviderFactory = new ProviderFactory(); export const chainHistoryProviderFactory = new ProviderFactory(); +export const drepProviderFactory = new ProviderFactory(); export const networkInfoProviderFactory = new ProviderFactory(); export const rewardsProviderFactory = new ProviderFactory(); export const txSubmitProviderFactory = new ProviderFactory(); @@ -181,6 +184,19 @@ chainHistoryProviderFactory.register(BLOCKFROST_PROVIDER, async (params: any, lo }); }); +drepProviderFactory.register(BLOCKFROST_PROVIDER, async (params: any, logger): Promise => { + if (params.baseUrl === undefined) throw new Error(`${BlockfrostDRepProvider.name}: ${MISSING_URL_PARAM}`); + + return new Promise(async (resolve) => { + resolve( + new BlockfrostDRepProvider( + new BlockfrostClient({ baseUrl: params.baseUrl }, { rateLimiter: { schedule: (task) => task() } }), + logger + ) + ); + }); +}); + networkInfoProviderFactory.register( HTTP_PROVIDER, async (params: any, logger: Logger): Promise => { @@ -483,6 +499,11 @@ export const getWallet = async (props: GetWalletProps) => { env.TEST_CLIENT_CHAIN_HISTORY_PROVIDER_PARAMS, logger ), + drepProvider: await drepProviderFactory.create( + env.TEST_CLIENT_DREP_PROVIDER, + env.TEST_CLIENT_DREP_PROVIDER_PARAMS, + logger + ), handleProvider: await handleProviderFactory.create( env.TEST_CLIENT_HANDLE_PROVIDER, env.TEST_CLIENT_HANDLE_PROVIDER_PARAMS, @@ -571,6 +592,11 @@ export const getSharedWallet = async (props: GetSharedWalletProps) => { env.TEST_CLIENT_CHAIN_HISTORY_PROVIDER_PARAMS, logger ), + drepProvider: await drepProviderFactory.create( + env.TEST_CLIENT_DREP_PROVIDER, + env.TEST_CLIENT_DREP_PROVIDER_PARAMS, + logger + ), handleProvider: await handleProviderFactory.create( env.TEST_CLIENT_HANDLE_PROVIDER, env.TEST_CLIENT_HANDLE_PROVIDER_PARAMS, diff --git a/packages/e2e/src/scripts/generate-dotenv.sh b/packages/e2e/src/scripts/generate-dotenv.sh index 8b6f5137bc6..f4799e8262a 100755 --- a/packages/e2e/src/scripts/generate-dotenv.sh +++ b/packages/e2e/src/scripts/generate-dotenv.sh @@ -40,6 +40,9 @@ TEST_CLIENT_ASSET_PROVIDER=http TEST_CLIENT_ASSET_PROVIDER_PARAMS='{\"baseUrl\":\"${url}\"}' TEST_CLIENT_CHAIN_HISTORY_PROVIDER=ws TEST_CLIENT_CHAIN_HISTORY_PROVIDER_PARAMS='{\"baseUrl\":\"${url}\"}' +TEST_CLIENT_DREP_PROVIDER: 'blockfrost' +# TODO: use blockfrost URL +TEST_CLIENT_DREP_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:3015"}' TEST_CLIENT_HANDLE_PROVIDER=http TEST_CLIENT_HANDLE_PROVIDER_PARAMS='{\"baseUrl\":\"${url}\"}' TEST_CLIENT_NETWORK_INFO_PROVIDER=ws diff --git a/packages/e2e/test/load-test-custom/wallet-init/wallet-init.test.ts b/packages/e2e/test/load-test-custom/wallet-init/wallet-init.test.ts index f24c5541d1c..0dc352531d8 100644 --- a/packages/e2e/test/load-test-custom/wallet-init/wallet-init.test.ts +++ b/packages/e2e/test/load-test-custom/wallet-init/wallet-init.test.ts @@ -16,6 +16,7 @@ import { assetProviderFactory, bip32Ed25519Factory, chainHistoryProviderFactory, + drepProviderFactory, getEnv, getLoadTestScheduler, keyManagementFactory, @@ -55,6 +56,11 @@ const getProviders = async () => ({ env.TEST_CLIENT_CHAIN_HISTORY_PROVIDER_PARAMS, logger ), + drepProvider: await drepProviderFactory.create( + env.TEST_CLIENT_DREP_PROVIDER, + env.TEST_CLIENT_DREP_PROVIDER_PARAMS, + logger + ), networkInfoProvider: await networkInfoProviderFactory.create( env.TEST_CLIENT_NETWORK_INFO_PROVIDER, env.TEST_CLIENT_NETWORK_INFO_PROVIDER_PARAMS, diff --git a/packages/e2e/test/wallet_epoch_3/PersonalWallet/drepRetirement.test.ts b/packages/e2e/test/wallet_epoch_3/PersonalWallet/drepRetirement.test.ts new file mode 100644 index 00000000000..9776d944c54 --- /dev/null +++ b/packages/e2e/test/wallet_epoch_3/PersonalWallet/drepRetirement.test.ts @@ -0,0 +1,296 @@ +/* eslint-disable unicorn/consistent-destructuring */ + +import * as Crypto from '@cardano-sdk/crypto'; +import { BaseWallet } from '@cardano-sdk/wallet'; +import { Cardano, setInConwayEra } from '@cardano-sdk/core'; +import { logger } from '@cardano-sdk/util-dev'; + +import { filter, firstValueFrom, map } from 'rxjs'; +import { + firstValueFromTimed, + getEnv, + getWallet, + submitAndConfirm, + unDelegateWallet, + waitForWalletStateSettle, + walletReady, + walletVariables +} from '../../../src'; + +/* +Test that rewardAccounts$ drepDelegatees are updated when dreps retire, provided one of the following conditions are met: + - a transaction build is attempted + - drepDelegatees change, new ones are added, old ones removed +Both of these actions trigger a refetch for all dreps found in drepDelegatees. + +Setup: + - create three accounts (wallet, drepWallet, drepWallet2) + - drep* wallets register as dreps + - wallet delegates to 2 stake pools, resulting in 2 registered stake keys + - wallet delegates voting power: stakeKey1 & stakeKey2 to drepWallet & drepWallet2 respectively + - Expect to see DRep1 & DRep2 in drepDelegatees + - DRep1 retires - no change in drepDelegatees because a refresh was not triggered + - Build a transaction with wallet, but do not submit + - Expect to see DRep1 removed from drepDelegatees + - DRep2 retires - no change in drepDelegatees because a refresh was not triggered + - Using another instance of the wallet, delegate stakeKey1 to AlwaysAbstain + - Original wallet detects the change of delegatees according to tx history, which triggers a refetch for all dreps + - Expect DRep2 to be removed from drepDelegatees +*/ + +const { CertificateType, CredentialType, RewardAccount, StakePoolStatus } = Cardano; + +const env = getEnv(walletVariables); + +const anchor = { + dataHash: '3e33018e8293d319ef5b3ac72366dd28006bd315b715f7e7cfcbd3004129b80d' as Crypto.Hash32ByteBase16, + url: 'https://testing.this' +}; + +const getTestWallet = async (idx: number, name: string, minCoinBalance?: bigint) => { + const { wallet } = await getWallet({ env, idx, logger, name, polling: { interval: 50 } }); + + await walletReady(wallet, minCoinBalance); + + return wallet; +}; + +describe('PersonalWallet/drepRetirement', () => { + let dRepWallet1: BaseWallet; + let dRepWallet2: BaseWallet; + let delegatingWallet: BaseWallet; + + let dRepCredential1: Cardano.Credential & { type: Cardano.CredentialType.KeyHash }; + let drepId1: Cardano.DRepID; + let dRepCredential2: Cardano.Credential & { type: Cardano.CredentialType.KeyHash }; + let drepId2: Cardano.DRepID; + let poolId1: Cardano.PoolId; + let poolId2: Cardano.PoolId; + let stakeCredential1: Cardano.Credential; + let stakeCredential2: Cardano.Credential; + + let dRepDeposit: bigint; + + const getDRepCredential = async (wallet: BaseWallet) => { + const drepPubKey = await wallet.governance.getPubDRepKey(); + const dRepKeyHash = Crypto.Hash28ByteBase16.fromEd25519KeyHashHex( + (await Crypto.Ed25519PublicKey.fromHex(drepPubKey!).hash()).hex() + ); + + return { hash: dRepKeyHash, type: CredentialType.KeyHash } as typeof dRepCredential1; + }; + + const getDeposits = async () => { + const protocolParameters = await delegatingWallet.networkInfoProvider.protocolParameters(); + + return [ + BigInt(protocolParameters.dRepDeposit!), + BigInt(protocolParameters.governanceActionDeposit!), + BigInt(protocolParameters.stakeKeyDeposit) + ]; + }; + + const getPoolIds = async () => { + const activePools = await delegatingWallet.stakePoolProvider.queryStakePools({ + filters: { status: [StakePoolStatus.Active] }, + pagination: { limit: 2, startAt: 0 } + }); + + return activePools.pageResults.map(({ id }) => id); + }; + + const getStakeCredential = async () => { + const rewardAccounts = await firstValueFrom( + delegatingWallet.addresses$.pipe(map((addresses) => addresses.map(({ rewardAccount }) => rewardAccount))) + ); + + return rewardAccounts.map((rewardAccount) => ({ + hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(RewardAccount.toHash(rewardAccount)), + type: CredentialType.KeyHash + })); + }; + + const feedDRepWallet = async (dRepWallet: BaseWallet, amount: bigint) => { + const balance = await firstValueFrom(dRepWallet.balance.utxo.total$); + + if (balance.coins > amount) return; + + const address = await firstValueFrom(dRepWallet.addresses$.pipe(map((addresses) => addresses[0].address))); + + const signedTx = await delegatingWallet + .createTxBuilder() + .addOutput({ address, value: { coins: amount } }) + .build() + .sign(); + + await submitAndConfirm(delegatingWallet, signedTx.tx, 1); + }; + + const sendDRepRegCert = async (dRepWallet: BaseWallet, register: boolean) => { + const dRepCredential = await getDRepCredential(dRepWallet); + const common = { dRepCredential, deposit: dRepDeposit }; + const dRepUnRegCert: Cardano.UnRegisterDelegateRepresentativeCertificate = { + __typename: CertificateType.UnregisterDelegateRepresentative, + ...common + }; + const dRepRegCert: Cardano.RegisterDelegateRepresentativeCertificate = { + __typename: CertificateType.RegisterDelegateRepresentative, + anchor, + ...common + }; + const certificate = register ? dRepRegCert : dRepUnRegCert; + const signedTx = await dRepWallet + .createTxBuilder() + .customize(({ txBody }) => ({ ...txBody, certificates: [certificate] })) + .build() + .sign(); + await submitAndConfirm(dRepWallet, signedTx.tx, 1); + }; + + const isRegisteredDRep = async (wallet: BaseWallet) => { + await waitForWalletStateSettle(wallet); + return await firstValueFrom(wallet.governance.isRegisteredAsDRep$); + }; + + beforeAll(async () => { + // TODO: remove once mainnet hardforks to conway-era, and this becomes "the norm" + setInConwayEra(true); + + [delegatingWallet, dRepWallet1, dRepWallet2] = await Promise.all([ + getTestWallet(0, 'wallet-delegating', 100_000_000n), + getTestWallet(1, 'wallet-DRep1', 0n), + getTestWallet(2, 'wallet-DRep2', 0n) + ]); + + [dRepCredential1, dRepCredential2, [dRepDeposit], [poolId1, poolId2]] = await Promise.all([ + getDRepCredential(dRepWallet1), + getDRepCredential(dRepWallet2), + getDeposits(), + getPoolIds() + ]); + + drepId1 = Cardano.DRepID.cip129FromCredential(dRepCredential1); + drepId2 = Cardano.DRepID.cip129FromCredential(dRepCredential2); + + await feedDRepWallet(dRepWallet1, dRepDeposit * 2n); + await feedDRepWallet(dRepWallet2, dRepDeposit * 2n); + + if (!(await isRegisteredDRep(dRepWallet1))) await sendDRepRegCert(dRepWallet1, true); + if (!(await isRegisteredDRep(dRepWallet2))) await sendDRepRegCert(dRepWallet2, true); + + const txBuilder = delegatingWallet.createTxBuilder().delegatePortfolio({ + name: 'Test Portfolio', + pools: [ + { id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolId1)), weight: 1 }, + { id: Cardano.PoolIdHex(Cardano.PoolId.toKeyHash(poolId2)), weight: 1 } + ] + }); + const poolDelegationTx = await txBuilder.build().sign(); + await submitAndConfirm(delegatingWallet, poolDelegationTx.tx, 1); + + [stakeCredential1, stakeCredential2] = await getStakeCredential(); + }); + + afterAll(async () => { + if (await isRegisteredDRep(dRepWallet1)) await sendDRepRegCert(dRepWallet1, false); + if (await isRegisteredDRep(dRepWallet2)) await sendDRepRegCert(dRepWallet2, false); + await unDelegateWallet(delegatingWallet); + + delegatingWallet.shutdown(); + dRepWallet1.shutdown(); + dRepWallet2.shutdown(); + + // TODO: remove once mainnet hardforks to conway-era, and this becomes "the norm" + setInConwayEra(false); + }); + + it('emits drepDelegatees after delegating voting power', async () => { + const voteDelegCert1: Cardano.VoteDelegationCertificate = { + __typename: CertificateType.VoteDelegation, + dRep: dRepCredential1, + stakeCredential: stakeCredential1 + }; + const voteDelegCert2: Cardano.VoteDelegationCertificate = { + __typename: CertificateType.VoteDelegation, + dRep: dRepCredential2, + stakeCredential: stakeCredential2 + }; + + const signedTx = await delegatingWallet + .createTxBuilder() + .customize(({ txBody }) => ({ ...txBody, certificates: [voteDelegCert1, voteDelegCert2] })) + .build() + .sign(); + await submitAndConfirm(delegatingWallet, signedTx.tx, 1); + + const drepDelegatees = await firstValueFrom( + delegatingWallet.delegation.rewardAccounts$.pipe( + map((accounts) => accounts.map(({ dRepDelegatee }) => dRepDelegatee)) + ) + ); + + expect(drepDelegatees).toEqual([ + { delegateRepresentative: expect.objectContaining({ active: true, id: drepId1 }) }, + { delegateRepresentative: expect.objectContaining({ active: true, id: drepId2 }) } + ]); + }); + + it('transaction build triggers detection of retired DRep', async () => { + // Retire DRep1 + await sendDRepRegCert(dRepWallet1, false); + + // Only build and inspect to trigger the refetch of drep infos + await delegatingWallet.createTxBuilder().delegatePortfolio(null).build().inspect(); + + const drepDelegatees = await firstValueFrom( + delegatingWallet.delegation.rewardAccounts$.pipe( + map((accounts) => accounts.map(({ dRepDelegatee }) => dRepDelegatee)) + ) + ); + + expect(drepDelegatees).toEqual([ + { delegateRepresentative: expect.objectContaining({ active: false, id: drepId1 }) }, + { delegateRepresentative: expect.objectContaining({ active: true, amount: 0n, hasScript: false, id: drepId2 }) } + ]); + }); + + it('tx history vote delegation change triggers refresh of all delegations', async () => { + // Retire DRep2 + await sendDRepRegCert(dRepWallet2, false); + + // Create a clone of the delegatingWallet and change drep delegation. + // The delegatingWallet tx history will update, which will trigger a refetch of all dreps, including DRep2 which was retired. + const delegatingWalletClone = await getTestWallet(0, 'wallet-delegating-clone', 0n); + const signedTx = await delegatingWalletClone + .createTxBuilder() + .customize(({ txBody }) => ({ + ...txBody, + certificates: [ + { + __typename: CertificateType.VoteDelegation, + dRep: { __typename: 'AlwaysAbstain' }, + stakeCredential: stakeCredential1 + } as Cardano.VoteDelegationCertificate + ] + })) + .build() + .sign(); + await submitAndConfirm(delegatingWalletClone, signedTx.tx, 1); + + const drepDelegatees = await firstValueFromTimed( + delegatingWallet.delegation.rewardAccounts$.pipe( + map((accounts) => accounts.map(({ dRepDelegatee }) => dRepDelegatee)), + filter( + ([firstDelegatee]) => + !!firstDelegatee?.delegateRepresentative && + Cardano.isDRepAlwaysAbstain(firstDelegatee.delegateRepresentative) + ) + ) + ); + + expect(drepDelegatees).toEqual([ + { delegateRepresentative: { __typename: 'AlwaysAbstain' } }, + { delegateRepresentative: expect.objectContaining({ active: false, amount: 0n, hasScript: false, id: drepId2 }) } + ]); + }); +}); diff --git a/packages/tx-construction/src/tx-builder/initializeTx.ts b/packages/tx-construction/src/tx-builder/initializeTx.ts index 27744466a36..5f9b4154461 100644 --- a/packages/tx-construction/src/tx-builder/initializeTx.ts +++ b/packages/tx-construction/src/tx-builder/initializeTx.ts @@ -15,6 +15,14 @@ const dRepPublicKeyHash = async (addressManager?: Bip32Account): Promise { + const drep = drepDelegatee?.delegateRepresentative; + if (!drep || (Cardano.isDrepInfo(drep) && drep.active === false)) { + return false; + } + return true; +}; + /** * Filters and transforms reward accounts based on current protocol version and reward balance. * @@ -37,7 +45,7 @@ const getWithdrawals = ( accounts .filter( (account) => - (version.major >= DREP_REG_REQUIRED_PROTOCOL_VERSION ? !!account.dRepDelegatee : true) && + (version.major >= DREP_REG_REQUIRED_PROTOCOL_VERSION ? isActive(account.dRepDelegatee) : true) && !!account.rewardBalance ) .map(({ rewardBalance: quantity, address: stakeAddress }) => ({ diff --git a/packages/tx-construction/test/tx-builder/TxBuilder.test.ts b/packages/tx-construction/test/tx-builder/TxBuilder.test.ts index 376cd1e3026..d72cb1fc4b9 100644 --- a/packages/tx-construction/test/tx-builder/TxBuilder.test.ts +++ b/packages/tx-construction/test/tx-builder/TxBuilder.test.ts @@ -146,31 +146,44 @@ describe.each([ }), rewardAccounts: jest.fn().mockResolvedValue([ { + // No DRep delegatee address: rewardAccount1, keyStatus: Cardano.StakeCredentialStatus.Registered, rewardBalance: 10n }, { + // Valid DRep delegatee address: rewardAccount2, dRepDelegatee: { - __typename: 'AlwaysAbstain' - }, + delegateRepresentative: { + __typename: 'AlwaysAbstain' + } + } as Cardano.DRepDelegatee, keyStatus: Cardano.StakeCredentialStatus.Registered, rewardBalance: 20n }, { + // Expired DRep delegatee address: rewardAccount3, dRepDelegatee: { - __typename: 'AlwaysAbstain' - }, + delegateRepresentative: { + active: false, + amount: 0n, + hasScript: false, + id: Cardano.DRepID('drep1vpzcgfrlgdh4fft0p0ju70czkxxkuknw0jjztl3x7aqgm9q3hqyaz') + } + } as Cardano.DRepDelegatee, keyStatus: Cardano.StakeCredentialStatus.Registered, rewardBalance: 30n }, { + // Valid DRep delegatee but 0 balance address: rewardAccount4, dRepDelegatee: { - __typename: 'AlwaysAbstain' - }, + delegateRepresentative: { + __typename: 'AlwaysAbstain' + } + } as Cardano.DRepDelegatee, keyStatus: Cardano.StakeCredentialStatus.Registered, rewardBalance: 0n } @@ -191,8 +204,10 @@ describe.each([ { address: rewardAccount2, dRepDelegatee: { - __typename: 'AlwaysAbstain' - }, + delegateRepresentative: { + __typename: 'AlwaysAbstain' + } + } as Cardano.DRepDelegatee, keyStatus: Cardano.StakeCredentialStatus.Registered, rewardBalance: 20n }, @@ -204,8 +219,10 @@ describe.each([ { address: rewardAccount4, dRepDelegatee: { - __typename: 'AlwaysAbstain' - }, + delegateRepresentative: { + __typename: 'AlwaysAbstain' + } + } as Cardano.DRepDelegatee, keyStatus: Cardano.StakeCredentialStatus.Registered, rewardBalance: 0n } @@ -815,10 +832,7 @@ describe.each([ const txProps = await tx.inspect(); - expect(txProps.body.withdrawals).toEqual([ - { quantity: 20n, stakeAddress: rewardAccount2 }, - { quantity: 30n, stakeAddress: rewardAccount3 } - ]); + expect(txProps.body.withdrawals).toEqual([{ quantity: 20n, stakeAddress: rewardAccount2 }]); }); it('adds withdrawals for all registered reward accounts with positive reward balance if protocol version is less than 10', async () => { diff --git a/packages/util-dev/src/mockProviders/index.ts b/packages/util-dev/src/mockProviders/index.ts index ecc910e91be..11abe5ccf5a 100644 --- a/packages/util-dev/src/mockProviders/index.ts +++ b/packages/util-dev/src/mockProviders/index.ts @@ -7,3 +7,4 @@ export * from './mockUtxoProvider'; export * from './mockChainHistoryProvider'; export * from './mockRewardsProvider'; export * from './mockHandleProvider'; +export * from './mockDrepProvider'; diff --git a/packages/util-dev/src/mockProviders/mockDrepProvider.ts b/packages/util-dev/src/mockProviders/mockDrepProvider.ts new file mode 100644 index 00000000000..bc536b7c3bd --- /dev/null +++ b/packages/util-dev/src/mockProviders/mockDrepProvider.ts @@ -0,0 +1,19 @@ +import { DRepInfo, GetDRepInfoArgs, GetDRepsInfoArgs } from '@cardano-sdk/core'; + +export const mockDrepProvider = () => ({ + getDRepInfo: jest + .fn() + .mockImplementation( + ({ id }: GetDRepInfoArgs): Promise => + Promise.resolve({ active: true, amount: 0n, hasScript: false, id }) + ), + getDRepsInfo: jest + .fn() + .mockImplementation( + ({ ids }: GetDRepsInfoArgs): Promise => + Promise.resolve(ids.map((id) => ({ active: true, amount: 0n, hasScript: false, id }))) + ), + healthCheck: jest.fn().mockResolvedValue({ ok: true }) +}); + +export type MockDrepProvider = ReturnType; diff --git a/packages/wallet/src/Wallets/BaseWallet.ts b/packages/wallet/src/Wallets/BaseWallet.ts index 3814c04d701..9e409216878 100644 --- a/packages/wallet/src/Wallets/BaseWallet.ts +++ b/packages/wallet/src/Wallets/BaseWallet.ts @@ -29,6 +29,7 @@ import { TipTracker, TrackedAssetProvider, TrackedChainHistoryProvider, + TrackedDrepProvider, TrackedRewardsProvider, TrackedStakePoolProvider, TrackedTxSubmitProvider, @@ -59,6 +60,7 @@ import { Cardano, CardanoNodeUtil, ChainHistoryProvider, + DRepProvider, EpochInfo, EraSummary, HandleProvider, @@ -105,6 +107,7 @@ import { PubStakeKeyAndStatus, createPublicStakeKeysTracker } from '../services/ import { RetryBackoffConfig } from 'backoff-rxjs'; import { Shutdown, contextLogger, deepEquals } from '@cardano-sdk/util'; import { WalletStores, createInMemoryWalletStores } from '../persistence'; +import { createDrepInfoColdObservable, onlyDistinctBlockRefetch } from '../services/DrepInfoTracker'; import { getScriptAddress } from './internals'; import isEqual from 'lodash/isEqual.js'; import uniq from 'lodash/uniq.js'; @@ -179,6 +182,7 @@ export interface BaseWalletDependencies { readonly logger: Logger; readonly connectionStatusTracker$?: ConnectionStatusTracker; readonly publicCredentialsManager: PublicCredentialsManager; + readonly drepProvider: DRepProvider; } export interface SubmitTxOptions { @@ -252,6 +256,7 @@ export class BaseWallet implements ObservableWallet { #addressTracker: AddressTracker; #publicCredentialsManager: PublicCredentialsManager; #submittingPromises: Partial>> = {}; + #refetchDrepInfo$ = new Subject(); readonly witnesser: Witnesser; readonly currentEpoch$: TrackerSubject; @@ -261,6 +266,7 @@ export class BaseWallet implements ObservableWallet { readonly stakePoolProvider: TrackedStakePoolProvider; readonly assetProvider: TrackedAssetProvider; readonly chainHistoryProvider: TrackedChainHistoryProvider; + readonly drepProvider: TrackedDrepProvider; readonly utxo: UtxoTracker; readonly balance: BalanceTracker; readonly transactions: TransactionsTracker & Shutdown; @@ -313,7 +319,8 @@ export class BaseWallet implements ObservableWallet { inputSelector, publicCredentialsManager, stores = createInMemoryWalletStores(), - connectionStatusTracker$ = createSimpleConnectionStatusTracker() + connectionStatusTracker$ = createSimpleConnectionStatusTracker(), + drepProvider }: BaseWalletDependencies ) { this.#logger = contextLogger(logger, name); @@ -326,11 +333,13 @@ export class BaseWallet implements ObservableWallet { this.assetProvider = new TrackedAssetProvider(assetProvider); this.handleProvider = handleProvider as HandleProvider; this.chainHistoryProvider = new TrackedChainHistoryProvider(chainHistoryProvider); + this.drepProvider = new TrackedDrepProvider(drepProvider); this.rewardsProvider = new TrackedRewardsProvider(rewardsProvider); this.syncStatus = createProviderStatusTracker( { assetProvider: this.assetProvider, chainHistoryProvider: this.chainHistoryProvider, + drepProvider: this.drepProvider, logger: contextLogger(this.#logger, 'syncStatus'), networkInfoProvider: this.networkInfoProvider, rewardsProvider: this.rewardsProvider, @@ -506,8 +515,16 @@ export class BaseWallet implements ObservableWallet { utxoProvider: this.utxoProvider }); + const drepInfo$ = createDrepInfoColdObservable({ + drepProvider: this.drepProvider, + logger: contextLogger(this.#logger, 'drepInfo$'), + refetchTrigger$: onlyDistinctBlockRefetch(this.#refetchDrepInfo$, this.tip$), + retryBackoffConfig + }); + const eraSummaries$ = distinctEraSummaries(this.eraSummaries$); this.delegation = createDelegationTracker({ + drepInfo$, epoch$, eraSummaries$, knownAddresses$: this.addresses$, @@ -752,6 +769,7 @@ export class BaseWallet implements ObservableWallet { this.utxoProvider.stats.shutdown(); this.rewardsProvider.stats.shutdown(); this.chainHistoryProvider.stats.shutdown(); + this.drepProvider.stats.shutdown(); this.currentEpoch$.complete(); this.delegation.shutdown(); this.assetInfo$.complete(); @@ -763,6 +781,7 @@ export class BaseWallet implements ObservableWallet { this.#reemitSubscriptions.unsubscribe(); this.#failedFromReemitter$.complete(); this.publicStakeKeys$.complete(); + this.#refetchDrepInfo$.complete(); this.#logger.debug('Shutdown'); } @@ -806,7 +825,10 @@ export class BaseWallet implements ObservableWallet { }, genesisParameters: () => this.#firstValueFromSettled(this.genesisParameters$), protocolParameters: () => this.#firstValueFromSettled(this.protocolParameters$), - rewardAccounts: () => this.#firstValueFromSettled(this.delegation.rewardAccounts$), + rewardAccounts: () => { + this.#refetchDrepInfo$.next(); + return this.#firstValueFromSettled(this.delegation.rewardAccounts$); + }, tip: () => this.#firstValueFromSettled(this.tip$), utxoAvailable: () => this.#firstValueFromSettled(this.utxo.available$) }, diff --git a/packages/wallet/src/services/DelegationTracker/DelegationTracker.ts b/packages/wallet/src/services/DelegationTracker/DelegationTracker.ts index 6a4312f8686..e5d46ea9902 100644 --- a/packages/wallet/src/services/DelegationTracker/DelegationTracker.ts +++ b/packages/wallet/src/services/DelegationTracker/DelegationTracker.ts @@ -1,4 +1,11 @@ -import { Cardano, ChainHistoryProvider, EraSummary, SlotEpochCalc, createSlotEpochCalc } from '@cardano-sdk/core'; +import { + Cardano, + ChainHistoryProvider, + DRepInfo, + EraSummary, + SlotEpochCalc, + createSlotEpochCalc +} from '@cardano-sdk/core'; import { DelegationTracker, TransactionsTracker, UtxoTracker } from '../types'; import { GroupedAddress } from '@cardano-sdk/key-management'; import { Logger } from 'ts-log'; @@ -36,6 +43,7 @@ export const createBlockEpochProvider = export type BlockEpochProvider = ReturnType; export interface DelegationTrackerProps { + drepInfo$: (drepIds: Cardano.DRepID[]) => Observable; rewardsTracker: TrackedRewardsProvider; rewardAccountAddresses$: Observable; stakePoolProvider: TrackedStakePoolProvider; @@ -105,6 +113,7 @@ export const createDelegationPortfolioTracker = (transactions: Observable -): Observable => +): Observable => certificates$.pipe( switchMap((certs) => { const sortedCerts = [...certs].sort((a, b) => a.epoch - b.epoch); const mostRecent = sortedCerts.pop()?.certificates.pop(); - let dRep; + let dRep: Cardano.DelegateRepresentative | undefined; // Certificates at this point are pre filtered, they are either vote delegation kind or stake key de-registration kind. // If the most recent is not a de-registration, emit found dRep. @@ -297,7 +298,7 @@ export const createDRepDelegateeTracker = ( Cardano.CertificateType.Unregistration ]) ) { - dRep = { delegateRepresentative: mostRecent.dRep }; + dRep = mostRecent.dRep; } return of(dRep); @@ -327,10 +328,14 @@ export const addressDelegatees = ( ) ); -export const addressDRepDelegatees = (addresses: Cardano.RewardAccount[], transactions$: Observable) => +export const addressDRepDelegatees = ( + addresses: Cardano.RewardAccount[], + transactions$: Observable, + drepInfo$: (drepIds: Cardano.DRepID[]) => Observable +) => combineLatest( addresses.map((address) => createDRepDelegateeTracker(accountDRepCertificateTransactions(transactions$, address))) - ); + ).pipe(switchMap((dreps) => drepInfo$(drepsToDrepIds(dreps)).pipe(map(drepsToDelegatees(dreps))))); export const addressRewards = ( rewardAccounts: Cardano.RewardAccount[], @@ -395,6 +400,7 @@ export const toRewardAccounts = ); export const createRewardAccountsTracker = ({ + drepInfo$, rewardAccountAddresses$, stakePoolProvider, rewardsProvider, @@ -403,6 +409,7 @@ export const createRewardAccountsTracker = ({ transactions$, transactionsInFlight$ }: { + drepInfo$: (drepIds: Cardano.DRepID[]) => Observable; rewardAccountAddresses$: Observable; stakePoolProvider: ObservableStakePoolProvider; rewardsProvider: ObservableRewardsProvider; @@ -416,7 +423,7 @@ export const createRewardAccountsTracker = ({ combineLatest([ addressCredentialStatuses(rewardAccounts, transactions$, transactionsInFlight$), addressDelegatees(rewardAccounts, transactions$, stakePoolProvider, epoch$), - addressDRepDelegatees(rewardAccounts, transactions$), + addressDRepDelegatees(rewardAccounts, transactions$, drepInfo$), addressRewards(rewardAccounts, transactionsInFlight$, rewardsProvider, balancesStore) ]).pipe(map(toRewardAccounts(rewardAccounts))) ) diff --git a/packages/wallet/src/services/DrepInfoTracker.ts b/packages/wallet/src/services/DrepInfoTracker.ts new file mode 100644 index 00000000000..508b9871260 --- /dev/null +++ b/packages/wallet/src/services/DrepInfoTracker.ts @@ -0,0 +1,66 @@ +import { Cardano, DRepInfo, DRepProvider } from '@cardano-sdk/core'; +import { Logger } from 'ts-log'; +import { Observable, map, merge, of, withLatestFrom } from 'rxjs'; +import { RetryBackoffConfig } from 'backoff-rxjs'; +import { coldObservableProvider } from '@cardano-sdk/util-rxjs'; +import { distinctBlock } from './util'; +import { isNotNil } from '@cardano-sdk/util'; + +type DrepInfoObservableProps = { + drepProvider: DRepProvider; + logger: Logger; + retryBackoffConfig: RetryBackoffConfig; + refetchTrigger$: Observable; +}; + +/** Use DRepProvider to fetch DRepInfos with retry backoff logic */ +export const createDrepInfoColdObservable = + ({ drepProvider, retryBackoffConfig, refetchTrigger$ }: DrepInfoObservableProps) => + (drepIds: Cardano.DRepID[]) => + coldObservableProvider({ + provider: () => drepProvider.getDRepsInfo({ ids: drepIds }), + retryBackoffConfig, + trigger$: merge(of(true), refetchTrigger$) + }); + +/** Replaces drep credential entries with DrepInfo. Undefined if drep not found in drepInfo */ +export const drepsToDelegatees = + (dreps: (Cardano.DelegateRepresentative | undefined)[]) => + (drepInfos: DRepInfo[]): (Cardano.DRepDelegatee | undefined)[] => + dreps.map((drep) => { + if (!drep) { + return; + } + if (Cardano.isDRepCredential(drep)) { + const drepInfo = drepInfos.find( + (info) => Cardano.DRepID.toCip129DRepID(info.id) === Cardano.DRepID.cip129FromCredential(drep) + ); + if (!drepInfo) { + // DRep not found, assume it's inactive + return; + } + return { delegateRepresentative: drepInfo }; + } + return { delegateRepresentative: drep }; + }); + +/** Removes undefined, AlwaysAbstain and AlwaysNoConfidence, removes duplicates and maps to DRepID[] */ +export const drepsToDrepIds = (dreps: Array): Cardano.DRepID[] => [ + ...new Set( + dreps + .filter(isNotNil) + .filter(Cardano.isDRepCredential) + .map((drepCredential) => Cardano.DRepID.cip129FromCredential(drepCredential)) + ) +]; + +export const onlyDistinctBlockRefetch = ( + refetchTrigger$: Observable, + tip$: Observable> +): Observable => + distinctBlock( + refetchTrigger$.pipe( + withLatestFrom(tip$), + map(([, tip]) => tip) + ) + ).pipe(map(() => void 0)); diff --git a/packages/wallet/src/services/ProviderTracker/ProviderStatusTracker.ts b/packages/wallet/src/services/ProviderTracker/ProviderStatusTracker.ts index 03ebfb8c64b..2f20fb28cf3 100644 --- a/packages/wallet/src/services/ProviderTracker/ProviderStatusTracker.ts +++ b/packages/wallet/src/services/ProviderTracker/ProviderStatusTracker.ts @@ -23,6 +23,7 @@ import { Milliseconds } from '../types'; import { ProviderFnStats } from './ProviderTracker'; import { TrackedAssetProvider } from './TrackedAssetProvider'; import { TrackedChainHistoryProvider } from './TrackedChainHistoryProvider'; +import { TrackedDrepProvider } from './TrackedDrepProvider'; import { TrackedRewardsProvider } from './TrackedRewardsProvider'; import { TrackedStakePoolProvider } from './TrackedStakePoolProvider'; import { TrackedUtxoProvider } from './TrackedUtxoProvider'; @@ -39,6 +40,7 @@ export interface ProviderStatusTrackerDependencies { assetProvider: TrackedAssetProvider; utxoProvider: TrackedUtxoProvider; chainHistoryProvider: TrackedChainHistoryProvider; + drepProvider: TrackedDrepProvider; rewardsProvider: TrackedRewardsProvider; logger: Logger; } @@ -49,7 +51,8 @@ const getDefaultProviderSyncRelevantStats = ({ assetProvider, utxoProvider, chainHistoryProvider, - rewardsProvider + rewardsProvider, + drepProvider }: ProviderStatusTrackerDependencies): Observable => combineLatest([ networkInfoProvider.stats.ledgerTip$, @@ -60,6 +63,7 @@ const getDefaultProviderSyncRelevantStats = ({ stakePoolProvider.stats.queryStakePools$, utxoProvider.stats.utxoByAddresses$, chainHistoryProvider.stats.transactionsByAddresses$, + drepProvider.stats.getDRepInfo$, rewardsProvider.stats.rewardsHistory$, rewardsProvider.stats.rewardAccountBalance$ ]); diff --git a/packages/wallet/src/services/ProviderTracker/TrackedDrepProvider.ts b/packages/wallet/src/services/ProviderTracker/TrackedDrepProvider.ts new file mode 100644 index 00000000000..b0d105791c0 --- /dev/null +++ b/packages/wallet/src/services/ProviderTracker/TrackedDrepProvider.ts @@ -0,0 +1,35 @@ +import { BehaviorSubject } from 'rxjs'; +import { CLEAN_FN_STATS, ProviderFnStats, ProviderTracker } from './ProviderTracker'; +import { DRepProvider } from '@cardano-sdk/core'; + +export class DrepProviderStats { + readonly healthCheck$ = new BehaviorSubject(CLEAN_FN_STATS); + readonly getDRepInfo$ = new BehaviorSubject(CLEAN_FN_STATS); + + shutdown() { + this.healthCheck$.complete(); + this.getDRepInfo$.complete(); + } + + reset() { + this.healthCheck$.next(CLEAN_FN_STATS); + this.getDRepInfo$.next(CLEAN_FN_STATS); + } +} + +/** Wraps a DRepProvider, tracking # of calls of each function */ +export class TrackedDrepProvider extends ProviderTracker implements DRepProvider { + readonly stats = new DrepProviderStats(); + readonly healthCheck: DRepProvider['healthCheck']; + readonly getDRepInfo: DRepProvider['getDRepInfo']; + readonly getDRepsInfo: DRepProvider['getDRepsInfo']; + + constructor(drepProvider: DRepProvider) { + super(); + drepProvider = drepProvider; + + this.healthCheck = () => this.trackedCall(() => drepProvider.healthCheck(), this.stats.healthCheck$); + this.getDRepInfo = (args) => this.trackedCall(() => drepProvider.getDRepInfo(args), this.stats.getDRepInfo$); + this.getDRepsInfo = (args) => this.trackedCall(() => drepProvider.getDRepsInfo(args), this.stats.getDRepInfo$); + } +} diff --git a/packages/wallet/src/services/ProviderTracker/index.ts b/packages/wallet/src/services/ProviderTracker/index.ts index 379c2b5d86a..d1da9c668b8 100644 --- a/packages/wallet/src/services/ProviderTracker/index.ts +++ b/packages/wallet/src/services/ProviderTracker/index.ts @@ -7,3 +7,4 @@ export * from './ProviderStatusTracker'; export * from './TrackedUtxoProvider'; export * from './TrackedChainHistoryProvider'; export * from './TrackedRewardsProvider'; +export * from './TrackedDrepProvider'; diff --git a/packages/wallet/src/services/util/trigger.ts b/packages/wallet/src/services/util/trigger.ts index 4313dac5fcd..6bf0bc08dd2 100644 --- a/packages/wallet/src/services/util/trigger.ts +++ b/packages/wallet/src/services/util/trigger.ts @@ -2,7 +2,7 @@ import { Cardano, EraSummary } from '@cardano-sdk/core'; import { Observable, distinctUntilChanged, map } from 'rxjs'; import { eraSummariesEquals } from './equals'; -export const distinctBlock = (tip$: Observable) => +export const distinctBlock = (tip$: Observable>) => tip$.pipe( map(({ blockNo }) => blockNo), distinctUntilChanged() diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index e6d398651eb..86334ebbbca 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -88,6 +88,12 @@ export const isTxBodyWithHash = (tx: Serialization.TxCBOR | Cardano.TxBodyWithHa export interface ObservableWallet { readonly balance: BalanceTracker; + /** + * dRepDelegatee from `delegation.rewardAccounts$` is not always up-to-date. + * It is refreshed when either the DReps currently delegated to change (usually detected while inspecting the + * transaction history), or when a TxBuilder created + * with `createTxBuilder()` is used to `build()` and either `inspect()` or `sign()` a transaction. + */ readonly delegation: DelegationTracker; readonly utxo: UtxoTracker; readonly transactions: TransactionsTracker; diff --git a/packages/wallet/test/PersonalWallet/load.test.ts b/packages/wallet/test/PersonalWallet/load.test.ts index 75cb0b981ec..098a5c0df68 100644 --- a/packages/wallet/test/PersonalWallet/load.test.ts +++ b/packages/wallet/test/PersonalWallet/load.test.ts @@ -114,6 +114,7 @@ const createWallet = async (props: CreateWalletProps) => { } = props.providers; const txSubmitProvider = mocks.mockTxSubmitProvider(); const assetProvider = mocks.mockAssetProvider(); + const drepProvider = mocks.mockDrepProvider(); const stakePoolProvider = createStubStakePoolProvider(); const bip32Account = await Bip32Account.fromAsyncKeyAgent(asyncKeyAgent); @@ -127,6 +128,7 @@ const createWallet = async (props: CreateWalletProps) => { bip32Account, chainHistoryProvider, connectionStatusTracker$, + drepProvider, handleProvider, logger, networkInfoProvider, diff --git a/packages/wallet/test/PersonalWallet/methods.test.ts b/packages/wallet/test/PersonalWallet/methods.test.ts index b31b43fa0de..11b902797fa 100644 --- a/packages/wallet/test/PersonalWallet/methods.test.ts +++ b/packages/wallet/test/PersonalWallet/methods.test.ts @@ -7,6 +7,7 @@ import { BehaviorSubject, Subscription, firstValueFrom, skip } from 'rxjs'; import { Cardano, ChainHistoryProvider, + DRepProvider, HandleProvider, ProviderError, ProviderFailure, @@ -86,6 +87,7 @@ describe('BaseWallet methods', () => { let rewardsProvider: RewardsProvider; let chainHistoryProvider: ChainHistoryProvider; let handleProvider: HandleProvider; + let drepProvider: DRepProvider; let wallet: BaseWallet; let utxoProvider: mocks.UtxoProviderStub; let witnesser: Witnesser; @@ -102,6 +104,7 @@ describe('BaseWallet methods', () => { chainHistoryProvider = mockChainHistoryProvider(); handleProvider = mocks.mockHandleProvider(); addressDiscovery = { discover: jest.fn().mockImplementation(async () => [groupedAddress]) }; + drepProvider = mocks.mockDrepProvider(); const asyncKeyAgent = await testAsyncKeyAgent(); bip32Account = await Bip32Account.fromAsyncKeyAgent(asyncKeyAgent); @@ -114,6 +117,7 @@ describe('BaseWallet methods', () => { assetProvider, bip32Account, chainHistoryProvider, + drepProvider, handleProvider, logger, networkInfoProvider, @@ -538,6 +542,7 @@ describe('BaseWallet methods', () => { assetProvider, bip32Account, chainHistoryProvider, + drepProvider, handleProvider, logger, networkInfoProvider, @@ -606,6 +611,7 @@ describe('BaseWallet methods', () => { assetProvider, bip32Account, chainHistoryProvider, + drepProvider, handleProvider, logger, networkInfoProvider, @@ -705,6 +711,7 @@ describe('BaseWallet methods', () => { assetProvider, bip32Account, chainHistoryProvider, + drepProvider, handleProvider, logger, networkInfoProvider, @@ -750,6 +757,7 @@ describe('BaseWallet methods', () => { assetProvider, bip32Account, chainHistoryProvider, + drepProvider, handleProvider, logger, networkInfoProvider, @@ -801,6 +809,7 @@ describe('BaseWallet methods', () => { assetProvider, bip32Account, chainHistoryProvider, + drepProvider, handleProvider, logger, networkInfoProvider, @@ -845,6 +854,7 @@ describe('BaseWallet methods', () => { { assetProvider, chainHistoryProvider, + drepProvider, handleProvider, logger, networkInfoProvider, @@ -881,6 +891,7 @@ describe('BaseWallet methods', () => { { assetProvider, chainHistoryProvider, + drepProvider, handleProvider, logger, networkInfoProvider, diff --git a/packages/wallet/test/PersonalWallet/rollback.test.ts b/packages/wallet/test/PersonalWallet/rollback.test.ts index 789a8246f8a..de458b0e457 100644 --- a/packages/wallet/test/PersonalWallet/rollback.test.ts +++ b/packages/wallet/test/PersonalWallet/rollback.test.ts @@ -52,6 +52,7 @@ const createWallet = async (stores: WalletStores, providers: Providers, pollingC } = providers; const assetProvider = mocks.mockAssetProvider(); const stakePoolProvider = createStubStakePoolProvider(); + const drepProvider = mocks.mockDrepProvider(); return createPersonalWallet( { name, polling: pollingConfig }, @@ -61,6 +62,7 @@ const createWallet = async (stores: WalletStores, providers: Providers, pollingC bip32Account, chainHistoryProvider, connectionStatusTracker$, + drepProvider, logger, networkInfoProvider, rewardsProvider, diff --git a/packages/wallet/test/PersonalWallet/shutdown.test.ts b/packages/wallet/test/PersonalWallet/shutdown.test.ts index 7a8accec21a..db328991f39 100644 --- a/packages/wallet/test/PersonalWallet/shutdown.test.ts +++ b/packages/wallet/test/PersonalWallet/shutdown.test.ts @@ -58,6 +58,7 @@ const createWallet = async (stores: WalletStores, providers: Providers, pollingC providers; const txSubmitProvider = mocks.mockTxSubmitProvider(); const assetProvider = mocks.mockAssetProvider(); + const drepProvider = mocks.mockDrepProvider(); const stakePoolProvider = createStubStakePoolProvider(); return createPersonalWallet( @@ -67,6 +68,7 @@ const createWallet = async (stores: WalletStores, providers: Providers, pollingC bip32Account, chainHistoryProvider, connectionStatusTracker$, + drepProvider, logger, networkInfoProvider, rewardsProvider, diff --git a/packages/wallet/test/hardware/ledger/LedgerKeyAgent.integration.test.ts b/packages/wallet/test/hardware/ledger/LedgerKeyAgent.integration.test.ts index 84ebd7167ad..dac8a414d42 100644 --- a/packages/wallet/test/hardware/ledger/LedgerKeyAgent.integration.test.ts +++ b/packages/wallet/test/hardware/ledger/LedgerKeyAgent.integration.test.ts @@ -11,6 +11,7 @@ import { dummyLogger as logger } from 'ts-log'; const { mockAssetProvider, mockChainHistoryProvider, + mockDrepProvider, mockNetworkInfoProvider, mockRewardsProvider, mockTxSubmitProvider, @@ -26,12 +27,15 @@ const createWallet = async (keyAgent: KeyAgent) => { const rewardsProvider = mockRewardsProvider(); const asyncKeyAgent = util.createAsyncKeyAgent(keyAgent); const chainHistoryProvider = mockChainHistoryProvider(); + const drepProvider = mockDrepProvider(); + return createPersonalWallet( { name: 'Wallet1' }, { assetProvider, bip32Account: await Bip32Account.fromAsyncKeyAgent(asyncKeyAgent), chainHistoryProvider, + drepProvider, logger, networkInfoProvider, rewardsProvider, diff --git a/packages/wallet/test/hardware/ledger/LedgerKeyAgent.test.ts b/packages/wallet/test/hardware/ledger/LedgerKeyAgent.test.ts index b8a38293c38..1609da963f4 100644 --- a/packages/wallet/test/hardware/ledger/LedgerKeyAgent.test.ts +++ b/packages/wallet/test/hardware/ledger/LedgerKeyAgent.test.ts @@ -196,6 +196,7 @@ describe('LedgerKeyAgent', () => { 0 ); const assetProvider = mocks.mockAssetProvider(); + const drepProvider = mocks.mockDrepProvider(); const stakePoolProvider = createStubStakePoolProvider(); const networkInfoProvider = mocks.mockNetworkInfoProvider(); const utxoProvider = mocks.mockUtxoProvider({ address }); @@ -208,6 +209,7 @@ describe('LedgerKeyAgent', () => { assetProvider, bip32Account: await Bip32Account.fromAsyncKeyAgent(asyncKeyAgent), chainHistoryProvider, + drepProvider, logger, networkInfoProvider, rewardsProvider, diff --git a/packages/wallet/test/hardware/trezor/TrezorKeyAgent.integration.test.ts b/packages/wallet/test/hardware/trezor/TrezorKeyAgent.integration.test.ts index ee53d944c7c..7631a185b63 100644 --- a/packages/wallet/test/hardware/trezor/TrezorKeyAgent.integration.test.ts +++ b/packages/wallet/test/hardware/trezor/TrezorKeyAgent.integration.test.ts @@ -10,6 +10,7 @@ import { dummyLogger as logger } from 'ts-log'; const { mockAssetProvider, mockChainHistoryProvider, + mockDrepProvider, mockNetworkInfoProvider, mockRewardsProvider, mockTxSubmitProvider, @@ -27,12 +28,14 @@ const createWallet = async (keyAgent: KeyAgent) => { const rewardsProvider = mockRewardsProvider(); const asyncKeyAgent = util.createAsyncKeyAgent(keyAgent); const chainHistoryProvider = mockChainHistoryProvider(); + const drepProvider = mockDrepProvider(); return createPersonalWallet( { name: 'Wallet1' }, { assetProvider, bip32Account: await Bip32Account.fromAsyncKeyAgent(asyncKeyAgent), chainHistoryProvider, + drepProvider, logger, networkInfoProvider, rewardsProvider, diff --git a/packages/wallet/test/hardware/trezor/TrezorKeyAgent.test.ts b/packages/wallet/test/hardware/trezor/TrezorKeyAgent.test.ts index 51fdf0b4bf6..03b13ad12b1 100644 --- a/packages/wallet/test/hardware/trezor/TrezorKeyAgent.test.ts +++ b/packages/wallet/test/hardware/trezor/TrezorKeyAgent.test.ts @@ -52,6 +52,7 @@ describe('TrezorKeyAgent', () => { address = groupedAddress.address; const rewardAccount = groupedAddress.rewardAccount; const assetProvider = mocks.mockAssetProvider(); + const drepProvider = mocks.mockDrepProvider(); const stakePoolProvider = createStubStakePoolProvider(); const networkInfoProvider = mocks.mockNetworkInfoProvider(); const utxoProvider = mocks.mockUtxoProvider({ address }); @@ -64,6 +65,7 @@ describe('TrezorKeyAgent', () => { assetProvider, bip32Account: await Bip32Account.fromAsyncKeyAgent(asyncKeyAgent), chainHistoryProvider, + drepProvider, logger, networkInfoProvider, rewardsProvider, diff --git a/packages/wallet/test/integration/CustomObservableWallet.test.ts b/packages/wallet/test/integration/CustomObservableWallet.test.ts index 2dc26169d29..0577597d498 100644 --- a/packages/wallet/test/integration/CustomObservableWallet.test.ts +++ b/packages/wallet/test/integration/CustomObservableWallet.test.ts @@ -46,6 +46,7 @@ describe('CustomObservableWallet', () => { assetProvider: mocks.mockAssetProvider(), bip32Account: await Bip32Account.fromAsyncKeyAgent(await testAsyncKeyAgent()), chainHistoryProvider: mocks.mockChainHistoryProvider(), + drepProvider: mocks.mockDrepProvider(), logger, networkInfoProvider: mocks.mockNetworkInfoProvider(), rewardsProvider: mocks.mockRewardsProvider(), diff --git a/packages/wallet/test/integration/cip30mapping.test.ts b/packages/wallet/test/integration/cip30mapping.test.ts index 3eae979bebc..a67c833788e 100644 --- a/packages/wallet/test/integration/cip30mapping.test.ts +++ b/packages/wallet/test/integration/cip30mapping.test.ts @@ -40,6 +40,7 @@ import uniq from 'lodash/uniq.js'; const { mockChainHistoryProvider, + mockDrepProvider, mockNetworkInfoProvider, mockRewardsProvider, mockTxSubmitProvider, @@ -914,6 +915,7 @@ describe('cip30', () => { const stakePoolProvider = createStubStakePoolProvider(); const rewardsProvider = mockRewardsProvider(); const chainHistoryProvider = mockChainHistoryProvider(); + const drepProvider = mockDrepProvider(); const groupedAddress: GroupedAddress = { accountIndex: 0, address, @@ -932,6 +934,7 @@ describe('cip30', () => { assetProvider, bip32Account, chainHistoryProvider, + drepProvider, logger, networkInfoProvider, rewardsProvider, diff --git a/packages/wallet/test/integration/util.ts b/packages/wallet/test/integration/util.ts index 372af49cbff..f91f6cb452b 100644 --- a/packages/wallet/test/integration/util.ts +++ b/packages/wallet/test/integration/util.ts @@ -11,12 +11,14 @@ const { mockNetworkInfoProvider, mockRewardsProvider, mockTxSubmitProvider, - mockUtxoProvider + mockUtxoProvider, + mockDrepProvider } = mockProviders; const createDefaultProviders = () => ({ assetProvider: mockAssetProvider(), chainHistoryProvider: mockChainHistoryProvider(), + drepProvider: mockDrepProvider(), networkInfoProvider: mockNetworkInfoProvider(), rewardsProvider: mockRewardsProvider(), stakePoolProvider: createStubStakePoolProvider(), diff --git a/packages/wallet/test/services/DelegationTracker/RewardAccounts.test.ts b/packages/wallet/test/services/DelegationTracker/RewardAccounts.test.ts index c14bbb95da0..aa89e2ef5e8 100644 --- a/packages/wallet/test/services/DelegationTracker/RewardAccounts.test.ts +++ b/packages/wallet/test/services/DelegationTracker/RewardAccounts.test.ts @@ -3,7 +3,7 @@ /* eslint-disable prettier/prettier */ /* eslint-disable sonarjs/no-duplicate-string */ import * as Crypto from '@cardano-sdk/crypto'; -import { Cardano, RewardsProvider, StakePoolProvider } from '@cardano-sdk/core'; +import { Cardano, DRepInfo, RewardsProvider, StakePoolProvider } from '@cardano-sdk/core'; import { EMPTY, Observable, firstValueFrom, of } from 'rxjs'; import { InMemoryStakePoolsStore, KeyValueStore } from '../../../src/persistence'; import { @@ -17,7 +17,8 @@ import { createDelegateeTracker, createQueryStakePoolsProvider, createRewardsProvider, - fetchRewardsTrigger$, getStakePoolIdAtEpoch + fetchRewardsTrigger$, + getStakePoolIdAtEpoch } from '../../../src'; import { RetryBackoffConfig } from 'backoff-rxjs'; import { TxWithEpoch } from '../../../src/services/DelegationTracker/types'; @@ -46,6 +47,7 @@ describe('RewardAccounts', () => { 'stake_test1up7pvfq8zn4quy45r2g572290p9vf99mr9tn7r9xrgy2l2qdsf58d' ].map(Cardano.RewardAccount); + let drepInfo$: jest.Mock, [drepIds: Cardano.DRepID[]]>; let store: InMemoryStakePoolsStore; let stakePoolProviderMock: StakePoolProvider; let stakePoolProviderTracked: TrackedStakePoolProvider; @@ -58,6 +60,9 @@ describe('RewardAccounts', () => { stakePoolProviderMock = mockStakePoolsProvider(); stakePoolProviderTracked = new TrackedStakePoolProvider(stakePoolProviderMock); provider = createQueryStakePoolsProvider(stakePoolProviderTracked, store, retryBackoffConfig); + drepInfo$ = jest.fn( + (drepIds: Cardano.DRepID[]): Observable => of(drepIds.map((id) => ({ active: true, id } as DRepInfo))) + ); coldObservableProviderMock.mockClear(); }); @@ -147,7 +152,8 @@ describe('RewardAccounts', () => { certificates: [ { __typename: Cardano.CertificateType.VoteRegistrationDelegation - } as Cardano.VoteRegistrationDelegationCertificate], + } as Cardano.VoteRegistrationDelegationCertificate + ], epoch: Cardano.EpochNo(103) }, { @@ -170,7 +176,8 @@ describe('RewardAccounts', () => { { __typename: Cardano.CertificateType.StakeRegistrationDelegation, poolId: poolId1 - } as Cardano.StakeRegistrationDelegationCertificate], + } as Cardano.StakeRegistrationDelegationCertificate + ], epoch: Cardano.EpochNo(106) }, // Unregister stake key @@ -184,7 +191,8 @@ describe('RewardAccounts', () => { { __typename: Cardano.CertificateType.StakeVoteRegistrationDelegation, poolId: poolId2 - } as Cardano.StakeVoteRegistrationDelegationCertificate], + } as Cardano.StakeVoteRegistrationDelegationCertificate + ], epoch: Cardano.EpochNo(108) }, // Delegation ignored after stake key is unregistered @@ -595,10 +603,15 @@ describe('RewardAccounts', () => { describe('addressDRepDelegatees', () => { it('emits a dRep delegatee for every reward account', () => { createTestScheduler().run(({ cold, expectObservable }) => { - const rewardAccount1 = Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27'); - const rewardAccount2 = Cardano.RewardAccount('stake_test1up7pvfq8zn4quy45r2g572290p9vf99mr9tn7r9xrgy2l2qdsf58d'); + const rewardAccount1 = Cardano.RewardAccount( + 'stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27' + ); + const rewardAccount2 = Cardano.RewardAccount( + 'stake_test1up7pvfq8zn4quy45r2g572290p9vf99mr9tn7r9xrgy2l2qdsf58d' + ); const stakeKeyHash1 = Cardano.RewardAccount.toHash(rewardAccount1); const stakeKeyHash2 = Cardano.RewardAccount.toHash(rewardAccount2); + const transactions$ = cold('a-b-c', { a: [], b: [ @@ -650,7 +663,7 @@ describe('RewardAccounts', () => { { __typename: Cardano.CertificateType.VoteDelegation, dRep: { - __typename: 'AlwaysAbstain' + __typename: 'AlwaysNoConfidence' }, stakeCredential: { hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash2), @@ -663,29 +676,44 @@ describe('RewardAccounts', () => { } as TxWithEpoch ] }); - const tracker$ = addressDRepDelegatees([rewardAccount1, rewardAccount2], transactions$); + const tracker$ = addressDRepDelegatees([rewardAccount1, rewardAccount2], transactions$, drepInfo$); expectObservable(tracker$).toBe('a-b-c', { a: [undefined, undefined], - b: [{ delegateRepresentative: { - __typename: 'AlwaysAbstain' - } }, - undefined], - c: [{ delegateRepresentative: { - __typename: 'AlwaysAbstain' - } }, { delegateRepresentative: { - __typename: 'AlwaysAbstain' - } }] + b: [ + { + delegateRepresentative: { + __typename: 'AlwaysAbstain' + } + }, + undefined + ], + c: [ + { + delegateRepresentative: { + __typename: 'AlwaysAbstain' + } + }, + { + delegateRepresentative: { + __typename: 'AlwaysNoConfidence' + } + } + ] }); }); }); it('emits the most recent dRep delegatee', () => { + const rewardAccount1 = Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27'); + const rewardAccount2 = Cardano.RewardAccount('stake_test1up7pvfq8zn4quy45r2g572290p9vf99mr9tn7r9xrgy2l2qdsf58d'); + const stakeKeyHash1 = Cardano.RewardAccount.toHash(rewardAccount1); + const stakeKeyHash2 = Cardano.RewardAccount.toHash(rewardAccount2); + const delegateRepresentative: Cardano.Credential = { + hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash2), + type: Cardano.CredentialType.KeyHash + }; + const drepId = Cardano.DRepID.cip129FromCredential(delegateRepresentative); createTestScheduler().run(({ cold, expectObservable }) => { - const rewardAccount1 = Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27'); - const rewardAccount2 = Cardano.RewardAccount('stake_test1up7pvfq8zn4quy45r2g572290p9vf99mr9tn7r9xrgy2l2qdsf58d'); - const stakeKeyHash1 = Cardano.RewardAccount.toHash(rewardAccount1); - const stakeKeyHash2 = Cardano.RewardAccount.toHash(rewardAccount2); - const transactions$ = cold('a-b-c', { a: [], b: [ @@ -752,23 +780,27 @@ describe('RewardAccounts', () => { } as TxWithEpoch ] }); - const tracker$ = addressDRepDelegatees([rewardAccount1], transactions$); + const tracker$ = addressDRepDelegatees([rewardAccount1], transactions$, drepInfo$); expectObservable(tracker$).toBe('a-b-c', { a: [undefined], - b: [{ delegateRepresentative: { - __typename: 'AlwaysNoConfidence' - } }], - c: [{ delegateRepresentative: { - hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash2), - type: Cardano.CredentialType.KeyHash - } }] + b: [ + { + delegateRepresentative: { + __typename: 'AlwaysNoConfidence' + } + } + ], + c: [{ delegateRepresentative: { active: true, id: drepId } as DRepInfo }] }); }); + expect(drepInfo$).toHaveBeenLastCalledWith([Cardano.DRepID.cip129FromCredential(delegateRepresentative)]); }); it('unsets dRep if a StakeDeregistration happens', () => { createTestScheduler().run(({ cold, expectObservable }) => { - const rewardAccount1 = Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27'); + const rewardAccount1 = Cardano.RewardAccount( + 'stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27' + ); const stakeKeyHash1 = Cardano.RewardAccount.toHash(rewardAccount1); const transactions$ = cold('a-b-c-d-e', { @@ -945,7 +977,8 @@ describe('RewardAccounts', () => { __typename: Cardano.CertificateType.VoteDelegation, dRep: { __typename: 'AlwaysNoConfidence' - }, stakeCredential: { + }, + stakeCredential: { hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash1), type: Cardano.CredentialType.KeyHash } @@ -956,23 +989,34 @@ describe('RewardAccounts', () => { } as TxWithEpoch ] }); - const tracker$ = addressDRepDelegatees([rewardAccount1], transactions$); + const tracker$ = addressDRepDelegatees([rewardAccount1], transactions$, drepInfo$); expectObservable(tracker$).toBe('a-b-c---d', { a: [undefined], - b: [{ delegateRepresentative: { - __typename: 'AlwaysNoConfidence' - } }], + b: [ + { + delegateRepresentative: { + __typename: 'AlwaysNoConfidence' + } + } + ], c: [undefined], // Un-register sets dRep to undefined, re-register still doesnt defined dRep but observable doesnt re-emit undefined - d: [{ delegateRepresentative: { // delegate - __typename: 'AlwaysNoConfidence' - } }] + d: [ + { + delegateRepresentative: { + // delegate + __typename: 'AlwaysNoConfidence' + } + } + ] }); }); }); it('unsets dRep if a Unregistration happens', () => { createTestScheduler().run(({ cold, expectObservable }) => { - const rewardAccount1 = Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27'); + const rewardAccount1 = Cardano.RewardAccount( + 'stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27' + ); const stakeKeyHash1 = Cardano.RewardAccount.toHash(rewardAccount1); const transactions$ = cold('a-b-c-d', { @@ -1095,27 +1139,49 @@ describe('RewardAccounts', () => { } as TxWithEpoch ] }); - const tracker$ = addressDRepDelegatees([rewardAccount1], transactions$); + const tracker$ = addressDRepDelegatees([rewardAccount1], transactions$, drepInfo$); expectObservable(tracker$).toBe('a-b-c-d', { a: [undefined], - b: [{ delegateRepresentative: { - __typename: 'AlwaysNoConfidence' - } }], + b: [ + { + delegateRepresentative: { + __typename: 'AlwaysNoConfidence' + } + } + ], c: [undefined], // Un-register sets dRep to undefined - d: [{ delegateRepresentative: { // re-register + vote delegate - __typename: 'AlwaysNoConfidence' - } }] + d: [ + { + delegateRepresentative: { + // re-register + vote delegate + __typename: 'AlwaysNoConfidence' + } + } + ] }); }); }); it('detects all vote delegation certificates', () => { - createTestScheduler().run(({ cold, expectObservable }) => { - const rewardAccount1 = Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27'); - const rewardAccount2 = Cardano.RewardAccount('stake_test1up7pvfq8zn4quy45r2g572290p9vf99mr9tn7r9xrgy2l2qdsf58d'); - const stakeKeyHash1 = Cardano.RewardAccount.toHash(rewardAccount1); - const stakeKeyHash2 = Cardano.RewardAccount.toHash(rewardAccount2); + const rewardAccount1 = Cardano.RewardAccount('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27'); + const rewardAccount2 = Cardano.RewardAccount('stake_test1up7pvfq8zn4quy45r2g572290p9vf99mr9tn7r9xrgy2l2qdsf58d'); + const stakeKeyHash1 = Cardano.RewardAccount.toHash(rewardAccount1); + const stakeKeyHash2 = Cardano.RewardAccount.toHash(rewardAccount2); + + const delegateRepresentative1: Cardano.Credential = { + hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash1), + type: Cardano.CredentialType.ScriptHash + }; + const drepId1 = Cardano.DRepID.cip129FromCredential(delegateRepresentative1); + + const delegateRepresentative2: Cardano.Credential = { + hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash2), + type: Cardano.CredentialType.KeyHash + }; + const drepId2 = Cardano.DRepID.cip129FromCredential(delegateRepresentative2); + + createTestScheduler().run(({ cold, expectObservable }) => { const transactions$ = cold('a-b-c-d-e', { a: [], b: [ @@ -1181,45 +1247,45 @@ describe('RewardAccounts', () => { } as TxWithEpoch ], d: [ - { - epoch: Cardano.EpochNo(100), - tx: { - body: { - certificates: [ - { - __typename: Cardano.CertificateType.VoteDelegation, - dRep: { - __typename: 'AlwaysAbstain' - }, - stakeCredential: { - hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash1), - type: Cardano.CredentialType.KeyHash + { + epoch: Cardano.EpochNo(100), + tx: { + body: { + certificates: [ + { + __typename: Cardano.CertificateType.VoteDelegation, + dRep: { + __typename: 'AlwaysAbstain' + }, + stakeCredential: { + hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash1), + type: Cardano.CredentialType.KeyHash + } } - } - ] + ] + } } - } - } as TxWithEpoch, - { - epoch: Cardano.EpochNo(101), - tx: { - body: { - certificates: [ - { - __typename: Cardano.CertificateType.StakeVoteDelegation, - dRep: { - __typename: 'AlwaysNoConfidence' - }, - poolId: poolId1, - stakeCredential: { - hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash1), - type: Cardano.CredentialType.KeyHash + } as TxWithEpoch, + { + epoch: Cardano.EpochNo(101), + tx: { + body: { + certificates: [ + { + __typename: Cardano.CertificateType.StakeVoteDelegation, + dRep: { + __typename: 'AlwaysNoConfidence' + }, + poolId: poolId1, + stakeCredential: { + hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash1), + type: Cardano.CredentialType.KeyHash + } } - } - ] + ] + } } - } - } as TxWithEpoch, + } as TxWithEpoch, { epoch: Cardano.EpochNo(102), tx: { @@ -1242,7 +1308,7 @@ describe('RewardAccounts', () => { } } } as TxWithEpoch - ], + ], e: [ { epoch: Cardano.EpochNo(100), @@ -1328,25 +1394,36 @@ describe('RewardAccounts', () => { } as TxWithEpoch ] }); - const tracker$ = addressDRepDelegatees([rewardAccount1], transactions$); + const tracker$ = addressDRepDelegatees([rewardAccount1], transactions$, drepInfo$); expectObservable(tracker$).toBe('a-b-c-d-e', { a: [undefined], - b: [{ delegateRepresentative: { - __typename: 'AlwaysAbstain' - } }], - c: [{ delegateRepresentative: { - __typename: 'AlwaysNoConfidence' - } }], - d: [{ delegateRepresentative: { - hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash2), - type: Cardano.CredentialType.KeyHash - } }], - e: [{ delegateRepresentative: { - hash: Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(stakeKeyHash1), - type: Cardano.CredentialType.ScriptHash - } }] + b: [ + { + delegateRepresentative: { + __typename: 'AlwaysAbstain' + } + } + ], + c: [ + { + delegateRepresentative: { + __typename: 'AlwaysNoConfidence' + } + } + ], + d: [{ delegateRepresentative: { active: true, id: drepId2 } as DRepInfo }], + e: [{ delegateRepresentative: { active: true, id: drepId1 } as DRepInfo }] }); }); + expect(drepInfo$).toHaveBeenCalledTimes(5); + // Initial empty drepDelegatees + expect(drepInfo$).toHaveBeenNthCalledWith(1, []); + // AlwaysAbstain does not fetch from drepInfo$ + expect(drepInfo$).toHaveBeenNthCalledWith(2, []); + // AlwaysNoConfidence does not fetch from drepInfo$ + expect(drepInfo$).toHaveBeenNthCalledWith(3, []); + expect(drepInfo$).toHaveBeenNthCalledWith(4, [Cardano.DRepID.cip129FromCredential(delegateRepresentative2)]); + expect(drepInfo$).toHaveBeenNthCalledWith(5, [Cardano.DRepID.cip129FromCredential(delegateRepresentative1)]); }); }); }); diff --git a/packages/wallet/test/services/DrepInfoTracker.test.ts b/packages/wallet/test/services/DrepInfoTracker.test.ts new file mode 100644 index 00000000000..ef4067cc873 --- /dev/null +++ b/packages/wallet/test/services/DrepInfoTracker.test.ts @@ -0,0 +1,91 @@ +import { Cardano, DRepInfo } from '@cardano-sdk/core'; +import { createTestScheduler } from '@cardano-sdk/util-dev'; +import { drepsToDelegatees, drepsToDrepIds, onlyDistinctBlockRefetch } from '../../src/services/DrepInfoTracker'; + +// From preview.gov.tools/drep_directory +const DREP_IDS = [ + 'drep1xaaduszgcqptvzhtt4mrgsjp8h9tc0d0pccma39ns6uh2elk56u', + 'drep1y93l7d0f3sy3y3asse3t48uewsum96t4s8rel99m65t3u89tzy0', + 'drep1epa4q3az62nw8pj4jnqcd4cjeen3pxattjkhh2uu8zjtsg2j6h0' +]; + +describe('drepInfoTracker', () => { + let drepIds: Cardano.DRepID[]; + beforeEach(() => { + drepIds = DREP_IDS.map(Cardano.DRepID).map(Cardano.DRepID.toCip129DRepID); + }); + + describe('drepsToDelegatees', () => { + it('replace dreps with drepInfos, without changing undefined, AlwaysAbstain and AlwaysNoConfidence', () => { + const dreps: (Cardano.DelegateRepresentative | undefined)[] = [ + { __typename: 'AlwaysAbstain' }, + Cardano.DRepID.toCredential(drepIds[0]), + undefined, + Cardano.DRepID.toCredential(drepIds[1]), + { __typename: 'AlwaysNoConfidence' }, + Cardano.DRepID.toCredential(drepIds[2]) + ]; + + const drepInfos: DRepInfo[] = [ + { active: true, amount: 0n, hasScript: false, id: drepIds[0] }, + { active: false, amount: 0n, hasScript: false, id: drepIds[1] }, + { active: true, amount: 0n, hasScript: false, id: drepIds[2] } + ]; + + expect(drepsToDelegatees(dreps)(drepInfos)).toEqual([ + { delegateRepresentative: { __typename: 'AlwaysAbstain' } }, + { delegateRepresentative: drepInfos[0] }, + undefined, + { delegateRepresentative: drepInfos[1] }, + { delegateRepresentative: { __typename: 'AlwaysNoConfidence' } }, + { delegateRepresentative: drepInfos[2] } + ]); + }); + + it('should replace not found drepId with undefined', () => { + const dreps: (Cardano.DelegateRepresentative | undefined)[] = [ + Cardano.DRepID.toCredential(drepIds[0]), + Cardano.DRepID.toCredential(drepIds[1]) + ]; + + const drepInfos: DRepInfo[] = [{ active: true, amount: 0n, hasScript: false, id: drepIds[1] }]; + + expect(drepsToDelegatees(dreps)(drepInfos)).toEqual([undefined, { delegateRepresentative: drepInfos[0] }]); + }); + }); + + describe('drepsToDrepIds', () => { + it('should remove duplicates and map to DRepID[]', () => { + const dreps: (Cardano.DelegateRepresentative | undefined)[] = [ + Cardano.DRepID.toCredential(drepIds[0]), + Cardano.DRepID.toCredential(drepIds[1]), + Cardano.DRepID.toCredential(drepIds[0]) + ]; + + expect(drepsToDrepIds(dreps)).toEqual([drepIds[0], drepIds[1]]); + }); + + it('should remove undefined, AlwaysAbstain and AlwaysNoConfidence', () => { + const dreps: (Cardano.DelegateRepresentative | undefined)[] = [ + { __typename: 'AlwaysAbstain' }, + Cardano.DRepID.toCredential(drepIds[0]), + undefined, + { __typename: 'AlwaysNoConfidence' }, + Cardano.DRepID.toCredential(drepIds[1]) + ]; + + expect(drepsToDrepIds(dreps)).toEqual([drepIds[0], drepIds[1]]); + }); + }); + + describe('onlyDistinctBlockRefetch', () => { + it('should ignore refetch while on the same block', () => { + createTestScheduler().run(({ cold, expectObservable }) => { + const refetchTrigger$ = cold('aaa', { a: void 0 }); + const tip$ = cold(' a-b', { a: { blockNo: Cardano.BlockNo(1) }, b: { blockNo: Cardano.BlockNo(2) } }); + + expectObservable(onlyDistinctBlockRefetch(refetchTrigger$, tip$)).toBe('a-a', { a: void 0 }); + }); + }); + }); +}); diff --git a/packages/wallet/test/services/ProviderTracker/ProviderStatusTracker.test.ts b/packages/wallet/test/services/ProviderTracker/ProviderStatusTracker.test.ts index 2dce399045f..2c88c999731 100644 --- a/packages/wallet/test/services/ProviderTracker/ProviderStatusTracker.test.ts +++ b/packages/wallet/test/services/ProviderTracker/ProviderStatusTracker.test.ts @@ -5,6 +5,7 @@ import { ProviderFnStats, TrackedAssetProvider, TrackedChainHistoryProvider, + TrackedDrepProvider, TrackedRewardsProvider, TrackedStakePoolProvider, TrackedUtxoProvider, @@ -17,6 +18,7 @@ import { dummyLogger } from 'ts-log'; const { mockAssetProvider, mockChainHistoryProvider, + mockDrepProvider, mockNetworkInfoProvider, mockRewardsProvider, mockUtxoProvider @@ -67,6 +69,7 @@ describe('createProviderStatusTracker', () => { let assetProvider: TrackedAssetProvider; let utxoProvider: TrackedUtxoProvider; let chainHistoryProvider: TrackedChainHistoryProvider; + let drepProvider: TrackedDrepProvider; let rewardsProvider: TrackedRewardsProvider; const timeout = 5000; @@ -77,6 +80,7 @@ describe('createProviderStatusTracker', () => { networkInfoProvider = new TrackedWalletNetworkInfoProvider(mockNetworkInfoProvider()); assetProvider = new TrackedAssetProvider(mockAssetProvider()); chainHistoryProvider = new TrackedChainHistoryProvider(mockChainHistoryProvider()); + drepProvider = new TrackedDrepProvider(mockDrepProvider()); rewardsProvider = new TrackedRewardsProvider(mockRewardsProvider()); }); @@ -93,6 +97,7 @@ describe('createProviderStatusTracker', () => { { assetProvider, chainHistoryProvider, + drepProvider, logger: dummyLogger, networkInfoProvider, rewardsProvider, @@ -125,6 +130,7 @@ describe('createProviderStatusTracker', () => { { assetProvider, chainHistoryProvider, + drepProvider, logger: dummyLogger, networkInfoProvider, rewardsProvider, @@ -153,6 +159,7 @@ describe('createProviderStatusTracker', () => { { assetProvider, chainHistoryProvider, + drepProvider, logger: dummyLogger, networkInfoProvider, rewardsProvider, @@ -181,6 +188,7 @@ describe('createProviderStatusTracker', () => { { assetProvider, chainHistoryProvider, + drepProvider, logger: dummyLogger, networkInfoProvider, rewardsProvider, @@ -209,6 +217,7 @@ describe('createProviderStatusTracker', () => { { assetProvider, chainHistoryProvider, + drepProvider, logger: dummyLogger, networkInfoProvider, rewardsProvider, diff --git a/packages/wallet/test/services/WalletUtil.test.ts b/packages/wallet/test/services/WalletUtil.test.ts index c34fb2df592..1908b525f69 100644 --- a/packages/wallet/test/services/WalletUtil.test.ts +++ b/packages/wallet/test/services/WalletUtil.test.ts @@ -544,6 +544,7 @@ describe('WalletUtil', () => { const stakePoolProvider = createStubStakePoolProvider(); const rewardsProvider = mocks.mockRewardsProvider(); const chainHistoryProvider = mocks.mockChainHistoryProvider(); + const drepProvider = mocks.mockDrepProvider(); const groupedAddress: GroupedAddress = { accountIndex: 0, address, @@ -565,6 +566,7 @@ describe('WalletUtil', () => { assetProvider, bip32Account, chainHistoryProvider, + drepProvider, logger, networkInfoProvider, rewardsProvider,