Skip to content

Commit

Permalink
feat!: partial BaseWallet tx history
Browse files Browse the repository at this point in the history
BaseWallet will load only last n transactions on initial load

BREAKING CHANGE: remove BaseWallet stake pool and drep provider dependency
- add RewardAccountInfoProvider as a new BaseWallet dependency
  • Loading branch information
mkazlauskas committed Jan 20, 2025
1 parent b52e51f commit 40a3ce0
Show file tree
Hide file tree
Showing 57 changed files with 1,277 additions and 2,702 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/continuous-integration-blockfrost-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ env:
TEST_CLIENT_TX_SUBMIT_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:4000/"}'
TEST_CLIENT_UTXO_PROVIDER: 'blockfrost'
TEST_CLIENT_UTXO_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:3015"}'
TEST_CLIENT_REWARD_ACCOUNT_INFO_PROVIDER: 'blockfrost'
TEST_CLIENT_REWARD_ACCOUNT_INFO_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:3015"}'
TEST_CLIENT_STAKE_POOL_PROVIDER: 'http'
TEST_CLIENT_STAKE_POOL_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:4000/"}'
WS_PROVIDER_URL: 'http://localhost:4100/ws'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/continuous-integration-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ env:
TEST_CLIENT_UTXO_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:4000/"}'
TEST_CLIENT_STAKE_POOL_PROVIDER: 'http'
TEST_CLIENT_STAKE_POOL_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:4000/"}'
TEST_CLIENT_REWARD_ACCOUNT_INFO_PROVIDER: 'blockfrost'
TEST_CLIENT_REWARD_ACCOUNT_INFO_PROVIDER_PARAMS: '{"baseUrl":"http://localhost:3015"}'
WS_PROVIDER_URL: 'http://localhost:4100/ws'

on:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { Cardano, DRepProvider, RewardAccountInfoProvider, Serialization, StakePoolProvider } from '@cardano-sdk/core';

import { BlockfrostClient, BlockfrostProvider, fetchSequentially, isBlockfrostNotFoundError } from '../blockfrost';
import { HexBlob, isNotNil } from '@cardano-sdk/util';
import { Logger } from 'ts-log';
import uniq from 'lodash/uniq.js';
import type { Responses } from '@blockfrost/blockfrost-js';

export type BlockfrostRewardAccountInfoProviderDependencies = {
client: BlockfrostClient;
logger: Logger;
stakePoolProvider: StakePoolProvider;
dRepProvider: DRepProvider;
};

const emptyArrayIfNotFound = (error: unknown) => {
if (isBlockfrostNotFoundError(error)) {
return [];
}
throw error;
};

export class BlockfrostRewardAccountInfoProvider extends BlockfrostProvider implements RewardAccountInfoProvider {
#dRepProvider: DRepProvider;
#stakePoolProvider: StakePoolProvider;

constructor({ client, logger, stakePoolProvider, dRepProvider }: BlockfrostRewardAccountInfoProviderDependencies) {
super(client, logger);
this.#dRepProvider = dRepProvider;
this.#stakePoolProvider = stakePoolProvider;
}

async rewardAccountInfo(
address: Cardano.RewardAccount,
localEpoch: Cardano.EpochNo
): Promise<Cardano.RewardAccountInfo> {
const [account, [lastRegistrationActivity]] = await Promise.all([
await this.request<Responses['account_content']>(`accounts/${address}`).catch(
(error): Responses['account_content'] => {
if (isBlockfrostNotFoundError(error)) {
return {
active: false,
active_epoch: null,
controlled_amount: '0',
drep_id: null,
pool_id: null,
reserves_sum: '0',
rewards_sum: '0',
stake_address: address,
treasury_sum: '0',
withdrawable_amount: '0',
withdrawals_sum: '0'
};
}
throw error;
}
),
this.request<Responses['account_registration_content']>(
`accounts/${address}/registrations?order=desc&count=1`
).catch(emptyArrayIfNotFound)
]);

const isUnregisteringAtEpoch = await this.#getUnregisteringAtEpoch(lastRegistrationActivity);

const credentialStatus = account.active
? Cardano.StakeCredentialStatus.Registered
: lastRegistrationActivity?.action === 'registered'
? Cardano.StakeCredentialStatus.Registering
: typeof isUnregisteringAtEpoch === 'undefined' || isUnregisteringAtEpoch <= localEpoch
? Cardano.StakeCredentialStatus.Unregistered
: Cardano.StakeCredentialStatus.Unregistering;
const rewardBalance = BigInt(account.withdrawable_amount || '0');

const [delegatee, dRepDelegatee, deposit] = await Promise.all([
this.#getDelegatee(address, localEpoch, isUnregisteringAtEpoch),
this.#getDrepDelegatee(account),
// This provider currently does not find other deposits (pool/drep/govaction)
this.#getKeyDeposit(lastRegistrationActivity)
]);

return {
address,
credentialStatus,
dRepDelegatee,
delegatee,
deposit,
rewardBalance
};
}

async delegationPortfolio(rewardAccount: Cardano.RewardAccount): Promise<Cardano.Cip17DelegationPortfolio | null> {
const portfolios = await fetchSequentially({
haveEnoughItems: (items: Array<null | Cardano.Cip17DelegationPortfolio>) => items.some(isNotNil),
paginationOptions: { order: 'desc' },
request: async (paginationQueryString) => {
const txs = await this.request<Responses['account_delegation_content']>(
`accounts/${rewardAccount}/delegations?${paginationQueryString}`
).catch(emptyArrayIfNotFound);
const result: Array<null | Cardano.Cip17DelegationPortfolio> = [];
for (const { tx_hash } of txs) {
const metadata = await this.request<Responses['tx_content_metadata_cbor']>(
`txs/${tx_hash}/metadata/cbor`
).catch(emptyArrayIfNotFound);
const cbor = metadata.find(({ label }) => label === '6862')?.metadata;
if (!cbor) {
result.push(null);
continue;
}
const metadatum = Serialization.TransactionMetadatum.fromCbor(HexBlob(cbor));
try {
result.push(Cardano.cip17FromMetadatum(metadatum.toCore()));
break;
} catch {
result.push(null);
}
}
return result;
}
});
return portfolios.find(isNotNil) || null;
}

async #getUnregisteringAtEpoch(
lastRegistrationActivity: Responses['account_registration_content'][0] | undefined
): Promise<Cardano.EpochNo | undefined> {
if (!lastRegistrationActivity || lastRegistrationActivity.action === 'registered') {
return;
}
const tx = await this.request<Responses['tx_content']>(`txs/${lastRegistrationActivity.tx_hash}`);
const block = await this.request<Responses['block_content']>(`blocks/${tx.block}`);
return Cardano.EpochNo(block.epoch!);
}

async #getDrepDelegatee(account: Responses['account_content']): Promise<Cardano.DRepDelegatee | undefined> {
if (!account.drep_id) return;
if (account.drep_id === 'drep_always_abstain') {
return { delegateRepresentative: { __typename: 'AlwaysAbstain' } };
}
if (account.drep_id === 'drep_always_no_confidence') {
return { delegateRepresentative: { __typename: 'AlwaysNoConfidence' } };
}
const cip129DrepId = Cardano.DRepID.toCip129DRepID(Cardano.DRepID(account.drep_id));
const dRepInfo = await this.#dRepProvider.getDRepInfo({ id: cip129DrepId });
return {
delegateRepresentative: dRepInfo
};
}

async #getKeyDeposit(lastRegistrationActivity: Responses['account_registration_content'][0] | undefined) {
if (!lastRegistrationActivity || lastRegistrationActivity.action === 'deregistered') {
return 0n;
}
const tx = await this.request<Responses['tx_content']>(`txs/${lastRegistrationActivity.tx_hash}`);
const block = await this.request<Responses['block_content']>(`blocks/${tx.block}`);
const epochParameters = await this.request<Responses['epoch_param_content']>(`epochs/${block.epoch}/parameters`);
return BigInt(epochParameters.key_deposit);
}

async #getDelegatee(
address: Cardano.RewardAccount,
currentEpoch: Cardano.EpochNo,
isUnregisteringAtEpoch: Cardano.EpochNo | undefined
): Promise<Cardano.Delegatee | undefined> {
const delegationHistory = await fetchSequentially<Responses['account_delegation_content'][0]>({
haveEnoughItems: (items) => items[items.length - 1]?.active_epoch <= currentEpoch,
paginationOptions: { order: 'desc' },
request: (paginationQueryString) => this.request(`accounts/${address}/delegations?${paginationQueryString}`)
});

const isRegisteredAt = (epochFromNow: number): true | undefined => {
if (!isUnregisteringAtEpoch) {
return true;
}
return isUnregisteringAtEpoch > currentEpoch + epochFromNow || undefined;
};

const poolIds = [
// current epoch
isRegisteredAt(0) && delegationHistory.find(({ active_epoch }) => active_epoch <= currentEpoch)?.pool_id,
// next epoch
isRegisteredAt(1) && delegationHistory.find(({ active_epoch }) => active_epoch <= currentEpoch + 1)?.pool_id,
// next next epoch
isRegisteredAt(2) && delegationHistory.find(({ active_epoch }) => active_epoch <= currentEpoch + 2)?.pool_id
] as Array<Cardano.PoolId | undefined>;

const poolIdsToFetch = uniq(poolIds.filter(isNotNil));
if (poolIdsToFetch.length === 0) {
return undefined;
}

const stakePools = await this.#stakePoolProvider.queryStakePools({
filters: { identifier: { values: poolIdsToFetch.map((id) => ({ id })) } },
pagination: { limit: 3, startAt: 0 }
});

const stakePoolMathingPoolId = (index: number) => stakePools.pageResults.find((pool) => pool.id === poolIds[index]);
return {
currentEpoch: stakePoolMathingPoolId(0),
nextEpoch: stakePoolMathingPoolId(1),
nextNextEpoch: stakePoolMathingPoolId(2)
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './RewardAccountInfoProvider';
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class BlockfrostRewardsProvider extends BlockfrostProvider implements Rew
}: Range<Cardano.EpochNo> = {}
): Promise<Reward[]> {
const batchSize = 100;
return fetchSequentially<Reward, Responses['account_reward_content'][0]>({
return fetchSequentially<Responses['account_reward_content'][0], Reward>({
haveEnoughItems: (_, rewardsPage) => {
const lastReward = rewardsPage[rewardsPage.length - 1];
return !lastReward || lastReward.epoch >= upperBound;
Expand Down
4 changes: 2 additions & 2 deletions packages/cardano-services-client/src/blockfrost/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const buildQueryString = ({ page, count, order }: PaginationOptions) => {
};

// copied from @cardano-sdk/cardano-services and updated to use custom blockfrost client instead of blockfrost-js
export const fetchSequentially = async <Item, Response>(
export const fetchSequentially = async <Response, Item = Response>(
props: {
request: (paginationQueryString: string) => Promise<Response[]>;
responseTranslator?: (response: Response[]) => Item[];
Expand All @@ -42,7 +42,7 @@ export const fetchSequentially = async <Item, Response>(
const newAccumulatedItems = [...accumulated, ...maybeTranslatedResponse] as Item[];
const haveEnoughItems = props.haveEnoughItems?.(newAccumulatedItems, response);
if (response.length === count && !haveEnoughItems) {
return fetchSequentially<Item, Response>(props, page + 1, newAccumulatedItems);
return fetchSequentially<Response, Item>(props, page + 1, newAccumulatedItems);
}
return newAccumulatedItems;
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions packages/cardano-services-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './StakePoolProvider';
export * from './UtxoProvider';
export * from './ChainHistoryProvider';
export * from './DRepProvider';
export * from './RewardAccountInfoProvider';
export * from './NetworkInfoProvider';
export * from './RewardsProvider';
export * from './HandleProvider';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './types';
9 changes: 9 additions & 0 deletions packages/core/src/Provider/RewardAccountInfoProvider/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Cardano, Provider } from '../..';

export interface RewardAccountInfoProvider extends Provider {
rewardAccountInfo(
rewardAccount: Cardano.RewardAccount,
localEpoch: Cardano.EpochNo
): Promise<Cardano.RewardAccountInfo>;
delegationPortfolio(rewardAccounts: Cardano.RewardAccount): Promise<Cardano.Cip17DelegationPortfolio | null>;
}
1 change: 1 addition & 0 deletions packages/core/src/Provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './Provider';
export * from './StakePoolProvider';
export * from './AssetProvider';
export * from './NetworkInfoProvider';
export * from './RewardAccountInfoProvider';
export * from './RewardsProvider';
export * from './TxSubmitProvider';
export * as ProviderUtil from './providerUtil';
Expand Down
2 changes: 2 additions & 0 deletions packages/e2e/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ TEST_CLIENT_HANDLE_PROVIDER=http
TEST_CLIENT_HANDLE_PROVIDER_PARAMS='{"baseUrl":"http://localhost:4011/"}'
TEST_CLIENT_NETWORK_INFO_PROVIDER=ws
TEST_CLIENT_NETWORK_INFO_PROVIDER_PARAMS='{"baseUrl":"http://localhost:4000/"}'
TEST_CLIENT_REWARD_ACCOUNT_INFO_PROVIDER=blockfrost
TEST_CLIENT_REWARD_ACCOUNT_INFO_PROVIDER_PARAMS='{"baseUrl":"http://localhost:3015"}'
TEST_CLIENT_REWARDS_PROVIDER=http
TEST_CLIENT_REWARDS_PROVIDER_PARAMS='{"baseUrl":"http://localhost:4000/"}'
TEST_CLIENT_TX_SUBMIT_PROVIDER=http
Expand Down
4 changes: 4 additions & 0 deletions packages/e2e/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ const validators = {
TEST_CLIENT_HANDLE_PROVIDER_PARAMS: providerParams(),
TEST_CLIENT_NETWORK_INFO_PROVIDER: str(),
TEST_CLIENT_NETWORK_INFO_PROVIDER_PARAMS: providerParams(),
TEST_CLIENT_REWARD_ACCOUNT_INFO_PROVIDER: str(),
TEST_CLIENT_REWARD_ACCOUNT_INFO_PROVIDER_PARAMS: providerParams(),
TEST_CLIENT_REWARDS_PROVIDER: str(),
TEST_CLIENT_REWARDS_PROVIDER_PARAMS: providerParams(),
TEST_CLIENT_STAKE_POOL_PROVIDER: str(),
Expand Down Expand Up @@ -158,6 +160,8 @@ export const walletVariables = [
'TEST_CLIENT_NETWORK_INFO_PROVIDER_PARAMS',
'TEST_CLIENT_REWARDS_PROVIDER',
'TEST_CLIENT_REWARDS_PROVIDER_PARAMS',
'TEST_CLIENT_REWARD_ACCOUNT_INFO_PROVIDER',
'TEST_CLIENT_REWARD_ACCOUNT_INFO_PROVIDER_PARAMS',
'TEST_CLIENT_STAKE_POOL_PROVIDER',
'TEST_CLIENT_STAKE_POOL_PROVIDER_PARAMS',
'TEST_CLIENT_TX_SUBMIT_PROVIDER',
Expand Down
Loading

0 comments on commit 40a3ce0

Please sign in to comment.