Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing getBalances, getBalance, getAddress and canSign methods #24

Merged
merged 3 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/coinbase/coinbase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
4 changes: 2 additions & 2 deletions src/coinbase/tests/balance_map_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe("BalanceMap", () => {
});
});

describe("#add", () => {
describe(".add", () => {
const assetId = Coinbase.assetList.Eth;
const balance = Balance.fromModelAndAssetId(
{
Expand All @@ -66,7 +66,7 @@ describe("BalanceMap", () => {
});
});

describe("#toString", () => {
describe(".toString", () => {
const assetId = Coinbase.assetList.Eth;
const balance = Balance.fromModelAndAssetId(
{
Expand Down
2 changes: 2 additions & 0 deletions src/coinbase/tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
171 changes: 168 additions & 3 deletions src/coinbase/tests/wallet_test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -134,7 +147,7 @@ describe("Wallet Class", () => {
});
});

describe(".export", () => {
describe("#export", () => {
let walletId: string;
let addressModel: AddressModel;
let walletModel: WalletModel;
Expand Down Expand Up @@ -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());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could also have this just assert on the mockAddressModel.AddresId to reduce coupling on getAddress behavior.

});

describe(".walletId", () => {
it("should return the correct wallet ID", async () => {
expect(wallet.getId()).toBe(walletId);
});
});

describe(".networkId", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and wlaletId should probably be # too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I will have a PR for this changes (PSDK-165)

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 ", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we test when the wallet does not have a seed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alex-stone Based on my understanding, wallet = await Wallet.create(); does not provide an option to include or exclude a seed. Regardless of whether we use Wallet.create or Wallet.init, the Wallet class will always have a master wallet after creation. I added this method to ensure parity with the Ruby SDK. Please let me know if there's any specific aspect you're concerned about or if I have misunderstood your point.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synced offline: Let's add that when we add listing wallets support

expect(wallet.canSign()).toBe(true);
});
});
});
35 changes: 35 additions & 0 deletions src/coinbase/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,41 @@ export type WalletAPIClient = {
* @throws {APIError} If the request fails.
*/
getWallet: (walletId: string, options?: RawAxiosRequestConfig) => AxiosPromise<WalletModel>;

/**
* 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<AddressBalanceList>;

/**
* 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<AddressBalanceList>;

/**
* 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<Balance>;
};

/**
Expand Down
49 changes: 49 additions & 0 deletions src/coinbase/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<BalanceMap> {
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<Decimal> {
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.
*
Expand Down Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this just be this.publicKey !== undefined ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Ruby SDK, we check with the master wallet. Additionally, we do not store the public key directly in the Wallet class. Instead, the privateKey or publicKey should be accessible through the master wallet.

Ruby implementation:
Screenshot 2024-05-23 at 10 37 37 AM

}

/**
* Requests funds from the faucet for the Wallet's default address and returns the faucet transaction.
* This is only supported on testnet networks.
Expand Down
Loading