diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index 8ce5bbb0..c4291cef 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -51,6 +51,13 @@ export class Coinbase { */ static readonly WEI_PER_ETHER: bigint = BigInt("1000000000000000000"); + /** + * Represents the number of Gwei per Ether. + * + * @constant + */ + static readonly GWEI_PER_ETHER: bigint = BigInt("1000000000"); + /** * The backup file path for Wallet seeds. * diff --git a/src/coinbase/tests/balance_map_test.ts b/src/coinbase/tests/balance_map_test.ts index 6e840e43..53c6c727 100644 --- a/src/coinbase/tests/balance_map_test.ts +++ b/src/coinbase/tests/balance_map_test.ts @@ -48,7 +48,7 @@ describe("BalanceMap", () => { }); }); - describe("#add", () => { + describe(".add", () => { const assetId = Coinbase.assetList.Eth; const balance = Balance.fromModelAndAssetId( { @@ -66,7 +66,7 @@ describe("BalanceMap", () => { }); }); - describe("#toString", () => { + describe(".toString", () => { const assetId = Coinbase.assetList.Eth; const balance = Balance.fromModelAndAssetId( { diff --git a/src/coinbase/tests/utils.ts b/src/coinbase/tests/utils.ts index cc0a8600..af12ce20 100644 --- a/src/coinbase/tests/utils.ts +++ b/src/coinbase/tests/utils.ts @@ -121,6 +121,8 @@ export const usersApiMock = { export const walletsApiMock = { getWallet: jest.fn(), createWallet: jest.fn(), + listWalletBalances: jest.fn(), + getWalletBalance: jest.fn(), }; export const addressesApiMock = { diff --git a/src/coinbase/tests/wallet_test.ts b/src/coinbase/tests/wallet_test.ts index 5451a1b4..c17d602a 100644 --- a/src/coinbase/tests/wallet_test.ts +++ b/src/coinbase/tests/wallet_test.ts @@ -1,9 +1,22 @@ import { randomUUID } from "crypto"; import { Coinbase } from "../coinbase"; import { Wallet } from "../wallet"; -import { Address as AddressModel, Wallet as WalletModel } from "./../../client"; -import { addressesApiMock, mockFn, newAddressModel, walletsApiMock } from "./utils"; +import { + AddressBalanceList, + Address as AddressModel, + Wallet as WalletModel, + Balance as BalanceModel, +} from "./../../client"; import { ArgumentError } from "../errors"; +import { + addressesApiMock, + mockFn, + mockReturnValue, + newAddressModel, + walletsApiMock, +} from "./utils"; +import { Address } from "../address"; +import Decimal from "decimal.js"; describe("Wallet Class", () => { let wallet, walletModel, walletId; @@ -134,7 +147,7 @@ describe("Wallet Class", () => { }); }); - describe(".export", () => { + describe("#export", () => { let walletId: string; let addressModel: AddressModel; let walletModel: WalletModel; @@ -171,4 +184,156 @@ describe("Wallet Class", () => { expect(newWallet).toBeInstanceOf(Wallet); }); }); + + describe("#defaultAddress", () => { + let wallet, walletId; + beforeEach(async () => { + jest.clearAllMocks(); + walletId = randomUUID(); + const mockAddressModel: AddressModel = newAddressModel(walletId); + const walletMockWithDefaultAddress: WalletModel = { + id: walletId, + network_id: Coinbase.networkList.BaseSepolia, + default_address: mockAddressModel, + }; + Coinbase.apiClients.wallet = walletsApiMock; + Coinbase.apiClients.address = addressesApiMock; + Coinbase.apiClients.wallet!.createWallet = mockReturnValue(walletMockWithDefaultAddress); + Coinbase.apiClients.wallet!.getWallet = mockReturnValue(walletMockWithDefaultAddress); + Coinbase.apiClients.address!.createAddress = mockReturnValue(mockAddressModel); + wallet = await Wallet.create(); + }); + + it("should return the correct address", async () => { + const defaultAddress = wallet.defaultAddress(); + const address = wallet.getAddress(defaultAddress?.getId()); + expect(address).toBeInstanceOf(Address); + expect(address?.getId()).toBe(address.getId()); + }); + + describe(".walletId", () => { + it("should return the correct wallet ID", async () => { + expect(wallet.getId()).toBe(walletId); + }); + }); + + describe(".networkId", () => { + it("should return the correct network ID", async () => { + expect(wallet.getNetworkId()).toBe(Coinbase.networkList.BaseSepolia); + }); + }); + }); + + describe("#getBalances", () => { + beforeEach(() => { + const mockBalanceResponse: AddressBalanceList = { + data: [ + { + amount: "1000000000000000000", + asset: { + asset_id: Coinbase.assetList.Eth, + network_id: Coinbase.networkList.BaseSepolia, + decimals: 18, + }, + }, + { + amount: "5000000", + asset: { + asset_id: "usdc", + network_id: Coinbase.networkList.BaseSepolia, + decimals: 6, + }, + }, + ], + has_more: false, + next_page: "", + total_count: 2, + }; + Coinbase.apiClients.wallet!.listWalletBalances = mockReturnValue(mockBalanceResponse); + }); + + it("should return a hash with an ETH and USDC balance", async () => { + const balanceMap = await wallet.getBalances(); + expect(balanceMap.get("eth")).toEqual(new Decimal(1)); + expect(balanceMap.get("usdc")).toEqual(new Decimal(5)); + expect(Coinbase.apiClients.wallet!.listWalletBalances).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.wallet!.listWalletBalances).toHaveBeenCalledWith(walletId); + }); + }); + + describe("#getBalance", () => { + beforeEach(() => { + const mockWalletBalance: BalanceModel = { + amount: "5000000000000000000", + asset: { + asset_id: Coinbase.assetList.Eth, + network_id: Coinbase.networkList.BaseSepolia, + decimals: 18, + }, + }; + Coinbase.apiClients.wallet!.getWalletBalance = mockReturnValue(mockWalletBalance); + }); + + it("should return the correct ETH balance", async () => { + const balanceMap = await wallet.getBalance(Coinbase.assetList.Eth); + expect(balanceMap).toEqual(new Decimal(5)); + expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledWith( + walletId, + Coinbase.assetList.Eth, + ); + }); + + it("should return the correct GWEI balance", async () => { + const balance = await wallet.getBalance(Coinbase.assetList.Gwei); + expect(balance).toEqual(new Decimal((BigInt(5) * Coinbase.GWEI_PER_ETHER).toString())); + expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledWith( + walletId, + Coinbase.assetList.Gwei, + ); + }); + + it("should return the correct WEI balance", async () => { + const balance = await wallet.getBalance(Coinbase.assetList.Wei); + expect(balance).toEqual(new Decimal((BigInt(5) * Coinbase.WEI_PER_ETHER).toString())); + expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledWith( + walletId, + Coinbase.assetList.Wei, + ); + }); + + it("should return 0 when the balance is not found", async () => { + Coinbase.apiClients.wallet!.getWalletBalance = mockReturnValue({}); + const balance = await wallet.getBalance(Coinbase.assetList.Wei); + expect(balance).toEqual(new Decimal(0)); + expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledWith( + walletId, + Coinbase.assetList.Wei, + ); + }); + }); + + describe("#canSign", () => { + let wallet; + beforeAll(async () => { + const mockAddressModel = newAddressModel(walletId); + const mockWalletModel = { + id: walletId, + network_id: Coinbase.networkList.BaseSepolia, + default_address: mockAddressModel, + }; + Coinbase.apiClients.wallet = walletsApiMock; + Coinbase.apiClients.address = addressesApiMock; + Coinbase.apiClients.wallet!.createWallet = mockReturnValue(mockWalletModel); + Coinbase.apiClients.wallet!.getWallet = mockReturnValue(mockWalletModel); + Coinbase.apiClients.address!.createAddress = mockReturnValue(mockAddressModel); + wallet = await Wallet.create(); + }); + it("should return true when the wallet initialized ", () => { + expect(wallet.canSign()).toBe(true); + }); + }); }); diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index 9087e84a..ca3db4c2 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -40,6 +40,41 @@ export type WalletAPIClient = { * @throws {APIError} If the request fails. */ getWallet: (walletId: string, options?: RawAxiosRequestConfig) => AxiosPromise; + + /** + * List the balances of all of the addresses in the wallet aggregated by asset. + * + * @param walletId - The ID of the wallet to fetch the balances for. + * @param options - Override http request option. + * @throws {RequiredError} If the required parameter is not provided. + * @throws {APIError} If the request fails. + */ + listWalletBalances(walletId: string, options?): AxiosPromise; + + /** + * List the balances of all of the addresses in the wallet aggregated by asset. + * + * @param walletId - The ID of the wallet to fetch the balances for. + * @param options - Override http request option. + * @throws {RequiredError} If the required parameter is not provided. + * @throws {APIError} If the request fails. + */ + listWalletBalances(walletId: string, options?): AxiosPromise; + + /** + * Get the aggregated balance of an asset across all of the addresses in the wallet. + * + * @param walletId - The ID of the wallet to fetch the balance for. + * @param assetId - The symbol of the asset to fetch the balance for. + * @param options - Override http request option. + * @throws {RequiredError} If the required parameter is not provided. + * @throws {APIError} If the request fails. + */ + getWalletBalance( + walletId: string, + assetId: string, + options?: RawAxiosRequestConfig, + ): AxiosPromise; }; /** diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index 2bd40914..fa74fb19 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -10,6 +10,9 @@ import { ArgumentError, InternalError } from "./errors"; import { FaucetTransaction } from "./faucet_transaction"; import { WalletData } from "./types"; import { convertStringToHex } from "./utils"; +import { BalanceMap } from "./balance_map"; +import Decimal from "decimal.js"; +import { Balance } from "./balance"; /** * A representation of a Wallet. Wallets come with a single default Address, but can expand to have a set of Addresses, @@ -207,6 +210,43 @@ export class Wallet { this.addressIndex++; } + /** + * Returns the Address with the given ID. + * + * @param addressId - The ID of the Address to retrieve. + * @returns The Address. + */ + public getAddress(addressId: string): Address | undefined { + return this.addresses.find(address => { + return address.getId() === addressId; + }); + } + + /** + * Returns the list of balances of this Wallet. Balances are aggregated across all Addresses in the Wallet. + * + * @returns The list of balances. The key is the Asset ID, and the value is the balance. + */ + public async getBalances(): Promise { + const response = await Coinbase.apiClients.wallet!.listWalletBalances(this.model.id!); + return BalanceMap.fromBalances(response.data.data); + } + + /** + * Returns the balance of the provided Asset. Balances are aggregated across all Addresses in the Wallet. + * + * @param assetId - The ID of the Asset to retrieve the balance for. + * @returns The balance of the Asset. + */ + public async getBalance(assetId: string): Promise { + const response = await Coinbase.apiClients.wallet!.getWalletBalance(this.model.id!, assetId); + if (!response.data.amount) { + return new Decimal(0); + } + const balance = Balance.fromModelAndAssetId(response.data, assetId); + return balance.amount; + } + /** * Returns the Network ID of the Wallet. * @@ -234,6 +274,15 @@ export class Wallet { return this.model.default_address ? new Address(this.model.default_address) : undefined; } + /** + * Returns whether the Wallet has a seed with which to derive keys and sign transactions. + * + * @returns Whether the Wallet has a seed with which to derive keys and sign transactions. + */ + public canSign(): boolean { + return this.master.publicKey !== undefined; + } + /** * Requests funds from the faucet for the Wallet's default address and returns the faucet transaction. * This is only supported on testnet networks.