diff --git a/CHANGELOG.md b/CHANGELOG.md index b482318e..0e69ed2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Add Function `listHistoricalBalances` for `Address` for fetching historical balances for an asset +- Support for retrieving historical staking balances information ## [0.0.15] diff --git a/src/client/api.ts b/src/client/api.ts index a5628337..d8eb2bcb 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -756,6 +756,31 @@ export interface FetchStakingRewardsRequest { 'format': StakingRewardFormat; } +/** + * + * @export + * @interface FetchHistoricalStakingBalances200Response + */ +export interface FetchHistoricalStakingBalances200Response { + /** + * + * @type {Array} + * @memberof FetchHistoricalStakingBalances200Response + */ + 'data': Array; + /** + * True if this list has another page of items after this one that can be fetched. + * @type {boolean} + * @memberof FetchHistoricalStakingBalances200Response + */ + 'has_more': boolean; + /** + * The page token to be used to fetch the next page. + * @type {string} + * @memberof FetchHistoricalStakingBalances200Response + */ + 'next_page': string; +} /** * @@ -1381,6 +1406,44 @@ export interface StakingRewardUSDValue { */ 'conversion_time': string; } +/** + * The staking balances for an address. + * @export + * @interface StakingBalance + */ +export interface StakingBalance { + /** + * The onchain address for which the staking balances are being fetched. + * @type {string} + * @memberof StakingBalance + */ + 'address': string; + /** + * The date of the staking balance in format \'YYYY-MM-DD\' in UTC. + * @type {string} + * @memberof StakingBalance + */ + 'date': string; + /** + * The bonded stake. + * @type {Balance} + * @memberof StakingBalance + */ + 'bonded_stake': Balance; + /** + * The unbonded balance. + * @type {Balance} + * @memberof StakingBalance + */ + 'unbonded_balance': Balance; + /** + * The participant type of the given address. + * @type {string} + * @memberof StakingBalance + */ + 'participant_type': string; +} + /** * A trade of an asset to another asset * @export @@ -4110,6 +4173,69 @@ export const StakeApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * Fetch historical staking balances for a given address + * @summary Fetch historical staking balances + * @param {string} address The address to fetch the historical staking balances for + * @param {string} networkId The ID of the blockchain network + * @param {string} assetId The ID of the asset + * @param {string} startTime The start time of the staking balances period + * @param {string} endTime The end time of the stake balances period + * @param {number} [limit] A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 50. + * @param {string} [page] A cursor for pagination across multiple pages of results. Don\'t include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + fetchHistoricalStakingBalances: async (address: string, networkId: string, assetId: string, startTime: string, endTime: string, limit?: number, page?: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'networkId' is not null or undefined + assertParamExists('fetchHistoricalStakingBalances', 'networkId', networkId) + // verify required parameter 'address' is not null or undefined + assertParamExists('fetchHistoricalStakingBalances', 'address', address) + // verify required parameter 'assetId' is not null or undefined + assertParamExists('fetchHistoricalStakingBalances', 'assetId', assetId) + // verify required parameter 'startTime' is not null or undefined + assertParamExists('fetchHistoricalStakingBalances', 'startTime', startTime) + // verify required parameter 'endTime' is not null or undefined + assertParamExists('fetchHistoricalStakingBalances', 'endTime', endTime) + + const localVarPath = `/v1/networks/{network_id}/addresses/{address_id}/stake/balances` + .replace(`{${"network_id"}}`, encodeURIComponent(String(networkId))) + .replace(`{${"address_id"}}`, encodeURIComponent(String(address))) + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarQueryParameter['asset_id'] = assetId; + localVarQueryParameter['start_time'] = startTime; + localVarQueryParameter['end_time'] = endTime + + if (limit !== undefined) { + localVarQueryParameter['limit'] = limit; + } + + if (page !== undefined) { + localVarQueryParameter['page'] = page; + } + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(null, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Get the latest state of a staking operation * @summary Get the latest state of a staking operation @@ -4299,6 +4425,25 @@ export const StakeApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['StakeApi.fetchStakingRewards']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Fetch historical staking balances for given address + * @summary Fetch historical staking balances + * @param {string} address The address to fetch historical staking balances for + * @param {string} networkId The ID of the blockchain network + * @param {string} assetId The ID of the asset + * @param {string} startTime The start time of the staking balances + * @param {string} endTime The end time of the staking balances + * @param {number} [limit] A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 50. + * @param {string} [page] A cursor for pagination across multiple pages of results. Don\'t include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async fetchHistoricalStakingBalances(address: string, networkId: string, assetId: string, startTime: string, endTime: string, limit?: number, page?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.fetchHistoricalStakingBalances(address, networkId, assetId, startTime, endTime, limit, page, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['StakeApi.fetchHistoricalStakingBalances']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Get the latest state of a staking operation * @summary Get the latest state of a staking operation @@ -4399,6 +4544,22 @@ export const StakeApiFactory = function (configuration?: Configuration, basePath fetchStakingRewards(fetchStakingRewardsRequest: FetchStakingRewardsRequest, limit?: number, page?: string, options?: any): AxiosPromise { return localVarFp.fetchStakingRewards(fetchStakingRewardsRequest, limit, page, options).then((request) => request(axios, basePath)); }, + /** + * Fetch historical staking balances for given address + * @summary Fetch historical staking balances + * @param {string} address The onchain address for which the staking balances are being fetched + * @param {string} networkId The ID of the blockchain network + * @param {string} assetId The ID of the asset + * @param {string} startTime The start time of the staking balances period + * @param {string} endTime The end time of the staking balances period + * @param {number} [limit] A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 50. + * @param {string} [page] A cursor for pagination across multiple pages of results. Don\'t include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + fetchHistoricalStakingBalances(address: string, networkId: string, assetId: string, startTime: string, endTime: string, limit?: number, page?: string, options?: any): AxiosPromise { + return localVarFp.fetchHistoricalStakingBalances(address, networkId, assetId, startTime, endTime, limit, page, options).then((request) => request(axios, basePath)); + }, /** * Get the latest state of a staking operation * @summary Get the latest state of a staking operation diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts index 9755328a..7c4f090f 100644 --- a/src/coinbase/address.ts +++ b/src/coinbase/address.ts @@ -14,6 +14,7 @@ import { import { formatDate, getWeekBackDate } from "./utils"; import { StakingRewardFormat } from "../client"; import { StakingReward } from "./staking_reward"; +import { StakingBalance } from "./staking_balance"; /** * A representation of a blockchain address, which is a user-controlled account on a network. @@ -177,6 +178,28 @@ export class Address { ); } + /** + * Lists the historical staking balances for the address. + * + * @param assetId - The asset ID. + * @param startTime - The start time. + * @param endTime - The end time. + * @returns The staking balances. + */ + public async historicalStakingBalances( + assetId: string, + startTime = getWeekBackDate(new Date()), + endTime = formatDate(new Date()), + ): Promise { + return StakingBalance.list( + Coinbase.normalizeNetwork(this.getNetworkId()), + assetId, + this.getId(), + startTime, + endTime, + ); + } + /** * Get the stakeable balance for the supplied asset. * diff --git a/src/coinbase/balance.ts b/src/coinbase/balance.ts index 3aac5e03..91d1ba63 100644 --- a/src/coinbase/balance.ts +++ b/src/coinbase/balance.ts @@ -52,4 +52,24 @@ export class Balance { asset, ); } + + /** + * Converts a BalanceModel of which the amount is in the most common denomination such as ETH, BTC, etc. + * + * @param {BalanceModel} model - The balance model object. + * @returns {Balance} The Balance object. + */ + public static fromModelWithAmountInWholeUnits(model: BalanceModel): Balance { + const asset = Asset.fromModel(model.asset); + return new Balance(new Decimal(model.amount), asset.getAssetId(), asset); + } + + /** + * Print the Balance as a string. + * + * @returns The string representation of the Balance. + */ + public toString(): string { + return `Balance { amount: '${this.amount}' asset: '${this.asset?.toString()}' }`; + } } diff --git a/src/coinbase/staking_balance.ts b/src/coinbase/staking_balance.ts new file mode 100644 index 00000000..e18bae5c --- /dev/null +++ b/src/coinbase/staking_balance.ts @@ -0,0 +1,120 @@ +import { StakingBalance as StakingBalanceModel } from "../client"; +import { Balance } from "./balance"; +import { Coinbase } from "./coinbase"; + +/** + * A representation of the staking balance for a given asset on a specific date. + */ +export class StakingBalance { + private model: StakingBalanceModel; + + /** + * Creates the StakingBalance object. + * + * @param model - The underlying staking balance object. + */ + constructor(model: StakingBalanceModel) { + this.model = model; + } + + /** + * Returns a list of StakingBalances for the provided network, asset, and address. + * + * @param networkId - The network ID. + * @param assetId - The asset ID. + * @param address - The address ID. + * @param startTime - The start time. + * @param endTime - The end time. + * @returns The staking balances. + */ + public static async list( + networkId: string, + assetId: string, + address: string, + startTime: string, + endTime: string, + ): Promise { + const stakingBalances: StakingBalance[] = []; + const queue: string[] = [""]; + + while (queue.length > 0) { + const page = queue.shift(); + + const response = await Coinbase.apiClients.stake!.fetchHistoricalStakingBalances( + address, + networkId, + assetId, + startTime, + endTime, + 100, + page?.length ? page : undefined, + ); + + response.data.data.forEach(stakingBalance => { + stakingBalances.push(new StakingBalance(stakingBalance)); + }); + + if (response.data.has_more) { + if (response.data.next_page) { + queue.push(response.data.next_page); + } + } + } + + return stakingBalances; + } + + /** + * Returns the bonded stake amount of the StakingBalance. + * + * @returns The Balance. + */ + public bondedStake(): Balance { + return Balance.fromModelWithAmountInWholeUnits(this.model.bonded_stake); + } + + /** + * Returns the unbonded stake amount of the StakingBalance. + * + * @returns The Balance. + */ + public unbondedBalance(): Balance { + return Balance.fromModelWithAmountInWholeUnits(this.model.unbonded_balance); + } + + /** + * Returns the participant type of the address. + * + * @returns The participant type. + */ + public participantType(): string { + return this.model.participant_type; + } + + /** + * Returns the date of the StakingBalance. + * + * @returns The date. + */ + public date(): Date { + return new Date(this.model.date); + } + + /** + * Returns the onchain address of the StakingBalance. + * + * @returns The onchain address. + */ + public address(): string { + return this.model.address; + } + + /** + * Print the Staking Balance as a string. + * + * @returns The string representation of the Staking Balance. + */ + public toString(): string { + return `StakingBalance { date: '${this.date().toISOString()}' address: '${this.address()}' bondedStake: '${this.bondedStake().toString()}' unbondedBalance: '${this.unbondedBalance().toString()}' participantType: '${this.participantType()}' }`; + } +} diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index fe7ecec6..801b1b66 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -27,6 +27,7 @@ import { StakingContext as StakingContextModel, FetchStakingRewardsRequest, FetchStakingRewards200Response, + FetchHistoricalStakingBalances200Response, FaucetTransaction, BroadcastStakingOperationRequest, CreateStakingOperationRequest, @@ -442,6 +443,29 @@ export type StakeAPIClient = { options?: AxiosRequestConfig, ): AxiosPromise; + /** + * Get the staking balances for an address. + * + * @param address - The onchain address to fetch the staking balances for. + * @param networkId - The ID of the blockchain network. + * @param assetId - The ID of the asset to fetch the staking balances for. + * @param startTime - The start time of the staking balances. + * @param endTime - The end time of the staking balances. + * @param limit - The amount of records to return in a single call. + * @param page - The batch of records for a given section in the response. + * @param options - Axios request options. + */ + fetchHistoricalStakingBalances( + address: string, + networkId: string, + assetId: string, + startTime: string, + endTime: string, + limit?: number, + page?: string, + options?: AxiosRequestConfig, + ): AxiosPromise; + broadcastStakingOperation( walletId: string, addressId: string, diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index ad169863..c63a5d9e 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -28,6 +28,7 @@ import { import { convertStringToHex, delay, formatDate, getWeekBackDate } from "./utils"; import { StakingOperation } from "./staking_operation"; import { StakingReward } from "./staking_reward"; +import { StakingBalance } from "./staking_balance"; /** * A representation of a Wallet. Wallets come with a single default Address, but can expand to have a set of Addresses, @@ -396,6 +397,26 @@ export class Wallet { return await this.getDefaultAddress()!.stakingRewards(assetId, startTime, endTime, format); } + /** + * Lists the historical staking balances for the address. + * + * @param assetId - The asset ID. + * @param startTime - The start time. + * @param endTime - The end time. + * @throws {Error} if the default address is not found. + * @returns The staking balances. + */ + public async historicalStakingBalances( + assetId: string, + startTime = getWeekBackDate(new Date()), + endTime = formatDate(new Date()), + ): Promise { + if (!this.getDefaultAddress()) { + throw new InternalError("Default address not found"); + } + return await this.getDefaultAddress()!.historicalStakingBalances(assetId, startTime, endTime); + } + /** * Creates a staking operation to stake, signs it, and broadcasts it on the blockchain. * diff --git a/src/index.ts b/src/index.ts index f1b63d4f..487fac82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,4 +17,5 @@ export * from "./coinbase/address/external_address"; export * from "./coinbase/address/wallet_address"; export * from "./coinbase/staking_operation"; export * from "./coinbase/staking_reward"; +export * from "./coinbase/staking_balance"; export * from "./coinbase/validator"; diff --git a/src/tests/balance_test.ts b/src/tests/balance_test.ts index 4d88191a..c9b25463 100644 --- a/src/tests/balance_test.ts +++ b/src/tests/balance_test.ts @@ -49,4 +49,27 @@ describe("Balance", () => { expect(balance.assetId).toBe(Coinbase.assets.Eth); }); }); + + describe(".fromModelWithAmountInWholeUnits", () => { + const amount = new Decimal(32); + const balanceModel: BalanceModel = { + asset: { + asset_id: Coinbase.assets.Eth, + network_id: Coinbase.networks.BaseSepolia, + decimals: 18, + contract_address: "0x", + }, + amount: "32", + }; + + const balance = Balance.fromModelWithAmountInWholeUnits(balanceModel); + + it("returns a new Balance object with the correct amount", () => { + expect(balance.amount).toEqual(amount); + }); + + it("returns a new Balance object with the correct asset_id", () => { + expect(balance.assetId).toBe(Coinbase.assets.Eth); + }); + }); }); diff --git a/src/tests/external_address_test.ts b/src/tests/external_address_test.ts index 2ddbca5a..3ff3e35b 100644 --- a/src/tests/external_address_test.ts +++ b/src/tests/external_address_test.ts @@ -16,6 +16,7 @@ import { AddressBalanceList, Balance, FetchStakingRewards200Response, + FetchHistoricalStakingBalances200Response, StakingContext as StakingContextModel, StakingOperation as StakingOperationModel, StakingRewardFormat, @@ -28,6 +29,7 @@ import { StakingOperation } from "../coinbase/staking_operation"; import { Asset } from "../coinbase/asset"; import { randomUUID } from "crypto"; import { StakingReward } from "../coinbase/staking_reward"; +import { StakingBalance } from "../coinbase/staking_balance"; describe("ExternalAddress", () => { const newAddress = newAddressModel("", randomUUID(), Coinbase.networks.EthereumHolesky); @@ -134,6 +136,55 @@ describe("ExternalAddress", () => { next_page: "", }; + const HISTORICAL_STAKING_BALANCES_RESPONSE: FetchHistoricalStakingBalances200Response = { + data: [ + { + address: address.getId(), + date: "2024-05-01", + bonded_stake: { + amount: "32", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: address.getNetworkId(), + decimals: 18, + }, + }, + unbonded_balance: { + amount: "2", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: address.getNetworkId(), + decimals: 18, + }, + }, + participant_type: "validator", + }, + { + address: address.getId(), + date: "2024-05-02", + bonded_stake: { + amount: "33", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: address.getNetworkId(), + decimals: 18, + }, + }, + unbonded_balance: { + amount: "3", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: address.getNetworkId(), + decimals: 18, + }, + }, + participant_type: "validator", + }, + ], + has_more: false, + next_page: "", + }; + beforeAll(() => { Coinbase.apiClients.stake = stakeApiMock; Coinbase.apiClients.asset = assetsApiMock; @@ -377,6 +428,26 @@ describe("ExternalAddress", () => { }); }); + describe(".historicalStakingBalances", () => { + it("should return staking balances successfully", async () => { + Coinbase.apiClients.stake!.fetchHistoricalStakingBalances = mockReturnValue(HISTORICAL_STAKING_BALANCES_RESPONSE); + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + const response = await address.historicalStakingBalances(Coinbase.assets.Eth, startTime, endTime); + + expect(response).toBeInstanceOf(Array); + expect(response.length).toEqual(2); + expect(Coinbase.apiClients.stake!.fetchHistoricalStakingBalances).toHaveBeenCalledWith( + address.getId(), + address.getNetworkId(), + Coinbase.assets.Eth, + startTime, + endTime, + 100, + undefined, + ); + }); + }); + describe(".listBalances", () => { beforeEach(() => { const mockBalanceResponse: AddressBalanceList = { diff --git a/src/tests/staking_historical_balance_test.ts b/src/tests/staking_historical_balance_test.ts new file mode 100644 index 00000000..6dc938ba --- /dev/null +++ b/src/tests/staking_historical_balance_test.ts @@ -0,0 +1,171 @@ +import { + FetchHistoricalStakingBalances200Response, +} from "../client"; +import { Coinbase } from "../coinbase/coinbase"; +import { + assetsApiMock, + getAssetMock, + mockFn, + mockReturnValue, + newAddressModel, + stakeApiMock, +} from "./utils"; +import { StakingBalance } from "../coinbase/staking_balance"; +import { ExternalAddress } from "../coinbase/address/external_address"; +import { Asset } from "../coinbase/asset"; + +describe("StakingBalance", () => { + const startTime = "2024-05-01T00:00:00Z"; + const endTime = "2024-05-21T00:00:00Z"; + const newAddress = newAddressModel("", "some-address-id", Coinbase.networks.EthereumHolesky); + const address = new ExternalAddress(newAddress.network_id, newAddress.address_id); + const asset = { + asset_id: Coinbase.assets.Eth, + network_id: address.getNetworkId(), + decimals: 18, + }; + + const bondedStake = { + amount: "32", + asset: asset, + }; + const unbondedBalance = { + amount: "2", + asset: asset, + }; + + const HISTORICAL_STAKING_BALANCES_RESPONSE: FetchHistoricalStakingBalances200Response = { + data: [ + { + address: address.getId(), + date: "2024-05-01", + bonded_stake: bondedStake, + unbonded_balance: unbondedBalance, + participant_type: "validator", + }, + { + address: address.getId(), + date: "2024-05-02", + bonded_stake: bondedStake, + unbonded_balance: unbondedBalance, + participant_type: "validator", + }, + ], + has_more: false, + next_page: "", + }; + + beforeAll(() => { + Coinbase.apiClients.stake = stakeApiMock; + Coinbase.apiClients.asset = assetsApiMock; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("#list", () => { + it("should successfully return staking balances", async () => { + Coinbase.apiClients.stake!.fetchHistoricalStakingBalances = mockReturnValue(HISTORICAL_STAKING_BALANCES_RESPONSE); + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + const response = await StakingBalance.list( + address.getNetworkId(), + Coinbase.assets.Eth, + address.getId(), + startTime, + endTime, + ); + expect(response).toBeInstanceOf(Array); + expect(response.length).toEqual(2); + expect(Coinbase.apiClients.stake!.fetchHistoricalStakingBalances).toHaveBeenCalledWith( + address.getId(), + address.getNetworkId(), + Coinbase.assets.Eth, + startTime, + endTime, + 100, + undefined, + ); + }); + it("should successfully return staking balances for multiple pages", async () => { + const pages = ["abc", "def"]; + Coinbase.apiClients.stake!.fetchHistoricalStakingBalances = mockFn(() => { + HISTORICAL_STAKING_BALANCES_RESPONSE.next_page = pages.shift() as string; + HISTORICAL_STAKING_BALANCES_RESPONSE.has_more = !!HISTORICAL_STAKING_BALANCES_RESPONSE.next_page; + return { data: HISTORICAL_STAKING_BALANCES_RESPONSE }; + }); + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + const response = await StakingBalance.list( + address.getNetworkId(), + Coinbase.assets.Eth, + address.getId(), + startTime, + endTime, + ); + expect(response).toBeInstanceOf(Array); + expect(response.length).toEqual(6); + expect(Coinbase.apiClients.stake!.fetchHistoricalStakingBalances).toHaveBeenCalledWith( + address.getId(), + address.getNetworkId(), + Coinbase.assets.Eth, + startTime, + endTime, + 100, + undefined, + ); + }); + }); + + describe(".date", () => { + it("should return the correct date", () => { + const balance = new StakingBalance( + { + address: address.getId(), + date: "2024-05-03", + bonded_stake: bondedStake, + unbonded_balance: unbondedBalance, + participant_type: "validator", + } + ); + + const date = balance.date(); + expect(date).toEqual(new Date("2024-05-03")); + }); + }); + + describe(".toString", () => { + it("should return the string representation of a staking balance", () => { + const balance = new StakingBalance( + { + address: address.getId(), + date: "2024-05-03", + bonded_stake: bondedStake, + unbonded_balance: unbondedBalance, + participant_type: "validator", + } + ); + + const balanceStr = balance.toString(); + expect(balanceStr).toEqual( + "StakingBalance { date: '2024-05-03T00:00:00.000Z' address: 'some-address-id' bondedStake: 'Balance { amount: '32' asset: 'Asset{ networkId: ethereum-holesky, assetId: eth, contractAddress: undefined, decimals: 18 }' }' unbondedBalance: 'Balance { amount: '2' asset: 'Asset{ networkId: ethereum-holesky, assetId: eth, contractAddress: undefined, decimals: 18 }' }' participantType: 'validator' }", + ); + }); + }); + + describe(".addressId", () => { + it("should return the onchain address of the StakingBalance", () => { + const balance = new StakingBalance( + { + address: address.getId(), + date: "2024-05-03", + bonded_stake: bondedStake, + unbonded_balance: unbondedBalance, + participant_type: "validator", + } + ); + + const addressId = balance.address(); + expect(addressId).toEqual(address.getId()); + }); + }); +}); diff --git a/src/tests/utils.ts b/src/tests/utils.ts index c68fd7ab..5d2053b1 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -445,6 +445,7 @@ export const stakeApiMock = { getExternalStakingOperation: jest.fn(), getStakingContext: jest.fn(), fetchStakingRewards: jest.fn(), + fetchHistoricalStakingBalances: jest.fn(), broadcastStakingOperation: jest.fn(), createStakingOperation: jest.fn(), getStakingOperation: jest.fn(), diff --git a/src/tests/wallet_address_test.ts b/src/tests/wallet_address_test.ts index 2e2fd6b7..6af093c6 100644 --- a/src/tests/wallet_address_test.ts +++ b/src/tests/wallet_address_test.ts @@ -6,6 +6,7 @@ import { FaucetTransaction } from "../coinbase/faucet_transaction"; import { Balance as BalanceModel, FetchStakingRewards200Response, + FetchHistoricalStakingBalances200Response, StakingContext as StakingContextModel, StakingOperation as StakingOperationModel, StakingOperationStatusEnum, @@ -46,6 +47,7 @@ import { WalletAddress } from "../coinbase/address/wallet_address"; import { Wallet } from "../coinbase/wallet"; import { StakingOperation } from "../coinbase/staking_operation"; import { StakingReward } from "../coinbase/staking_reward"; +import { StakingBalance } from "../coinbase/staking_balance"; // Test suite for the WalletAddress class describe("WalletAddress", () => { @@ -344,6 +346,55 @@ describe("WalletAddress", () => { next_page: "", }; + const HISTORICAL_STAKING_BALANCES_RESPONSE: FetchHistoricalStakingBalances200Response = { + data: [ + { + address: newAddress.address_id, + date: "2024-05-01", + bonded_stake: { + amount: "32", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: Coinbase.networks.EthereumHolesky, + decimals: 18, + }, + }, + unbonded_balance: { + amount: "2", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: Coinbase.networks.EthereumHolesky, + decimals: 18, + }, + }, + participant_type: "validator", + }, + { + address: newAddress.address_id, + date: "2024-05-02", + bonded_stake: { + amount: "34", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: Coinbase.networks.EthereumHolesky, + decimals: 18, + }, + }, + unbonded_balance: { + amount: "3", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: Coinbase.networks.EthereumHolesky, + decimals: 18, + }, + }, + participant_type: "validator", + } + ], + has_more: false, + next_page: "", + }; + beforeAll(() => { Coinbase.apiClients.externalAddress = externalAddressApiMock; Coinbase.apiClients.stake = stakeApiMock; @@ -479,6 +530,24 @@ describe("WalletAddress", () => { expect(response).toBeInstanceOf(Array); }); }); + + describe(".historicalStakingBalances", () => { + it("should successfully return historical staking balances", async () => { + Coinbase.apiClients.stake!.fetchHistoricalStakingBalances = mockReturnValue(HISTORICAL_STAKING_BALANCES_RESPONSE); + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + const response = await walletAddress.historicalStakingBalances(Coinbase.assets.Eth); + expect(response).toBeInstanceOf(Array); + expect(response.length).toEqual(2); + expect(response[0].bondedStake().amount).toEqual(new Decimal("32")); + expect(response[0].bondedStake().asset?.assetId).toEqual(Coinbase.assets.Eth); + expect(response[0].bondedStake().asset?.decimals).toEqual(18); + expect(response[0].bondedStake().asset?.networkId).toEqual(Coinbase.networks.EthereumHolesky); + expect(response[0].unbondedBalance().amount).toEqual(new Decimal("2")); + expect(response[0].unbondedBalance().asset?.assetId).toEqual(Coinbase.assets.Eth); + expect(response[0].unbondedBalance().asset?.decimals).toEqual(18); + expect(response[0].unbondedBalance().asset?.networkId).toEqual(Coinbase.networks.EthereumHolesky); + }); + }); }); describe("#createTransfer", () => { diff --git a/src/tests/wallet_test.ts b/src/tests/wallet_test.ts index 3e0a3ad6..e394203c 100644 --- a/src/tests/wallet_test.ts +++ b/src/tests/wallet_test.ts @@ -18,6 +18,7 @@ import { StakingOperationStatusEnum, StakingContext as StakingContextModel, FetchStakingRewards200Response, + FetchHistoricalStakingBalances200Response, StakingRewardStateEnum, StakingRewardFormat, } from "./../client"; @@ -44,6 +45,7 @@ import { Trade } from "../coinbase/trade"; import { WalletAddress } from "../coinbase/address/wallet_address"; import { StakingOperation } from "../coinbase/staking_operation"; import { StakingReward } from "../coinbase/staking_reward"; +import { StakingBalance } from "../coinbase/staking_balance"; describe("Wallet Class", () => { let wallet: Wallet; @@ -188,6 +190,55 @@ describe("Wallet Class", () => { has_more: false, next_page: "", }; + + const HISTORICAL_STAKING_BALANCES_RESPONSE: FetchHistoricalStakingBalances200Response = { + data: [ + { + address: addressID, + date: "2024-05-01", + bonded_stake: { + amount: "32", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: Coinbase.networks.EthereumHolesky, + decimals: 18, + }, + }, + unbonded_balance: { + amount: "2", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: Coinbase.networks.EthereumHolesky, + decimals: 18, + }, + }, + participant_type: "validator", + }, + { + address: addressID, + date: "2024-05-02", + bonded_stake: { + amount: "32", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: Coinbase.networks.EthereumHolesky, + decimals: 18, + }, + }, + unbonded_balance: { + amount: "2", + asset: { + asset_id: Coinbase.assets.Eth, + network_id: Coinbase.networks.EthereumHolesky, + decimals: 18, + }, + }, + participant_type: "validator", + } + ], + has_more: false, + next_page: "", + }; beforeAll(() => { Coinbase.apiClients.stake = stakeApiMock; @@ -346,6 +397,32 @@ describe("Wallet Class", () => { expect(response).toBeInstanceOf(Array); }); }); + + describe(".historicalStakingBalances", () => { + it("should throw an error when the wallet does not have a default address", async () => { + const newWallet = Wallet.init(walletModel); + await expect( + async () => await newWallet.historicalStakingBalances(Coinbase.assets.Eth), + ).rejects.toThrow(InternalError); + }); + + it("should successfully return historical staking balances", async () => { + const wallet = await Wallet.create({ networkId: Coinbase.networks.EthereumHolesky }); + Coinbase.apiClients.stake!.fetchHistoricalStakingBalances = mockReturnValue(HISTORICAL_STAKING_BALANCES_RESPONSE); + Coinbase.apiClients.asset!.getAsset = getAssetMock(); + const response = await wallet.historicalStakingBalances(Coinbase.assets.Eth); + expect(response).toBeInstanceOf(Array); + expect(response.length).toEqual(2); + expect(response[0].bondedStake().amount).toEqual(new Decimal("32")); + expect(response[0].bondedStake().asset?.assetId).toEqual("eth"); + expect(response[0].bondedStake().asset?.decimals).toEqual(18); + expect(response[0].bondedStake().asset?.networkId).toEqual(Coinbase.networks.EthereumHolesky); + expect(response[0].unbondedBalance().amount).toEqual(new Decimal("2")); + expect(response[0].unbondedBalance().asset?.assetId).toEqual("eth"); + expect(response[0].unbondedBalance().asset?.decimals).toEqual(18); + expect(response[0].unbondedBalance().asset?.networkId).toEqual(Coinbase.networks.EthereumHolesky); + }); + }); }); describe(".createTransfer", () => {