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

TODO #18

Closed
wants to merge 17 commits into from
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -25,6 +25,7 @@
"@scure/bip32": "^1.4.0",
"axios": "^1.6.8",
"bip39": "^3.1.0",
"decimal.js": "^10.4.3",
"ethers": "^6.12.1",
"node-jose": "^2.2.0",
"secp256k1": "^5.0.0"
37 changes: 37 additions & 0 deletions src/coinbase/address.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Address as AddressModel } from "../client";
import { Balance } from "./balance";
import { BalanceMap } from "./balance_map";
import { InternalError } from "./errors";
import { FaucetTransaction } from "./faucet_transaction";
import { AddressAPIClient } from "./types";
import { Decimal } from "decimal.js";

/**
* A representation of a blockchain address, which is a user-controlled account on a network.
@@ -62,6 +65,40 @@ export class Address {
return this.model.network_id;
}

/**
* Returns the list of balances for the address.
*
* @returns {BalanceMap} - The map from asset ID to balance.
*/
async listBalances(): Promise<BalanceMap> {
const response = await this.client.listAddressBalances(
this.model.wallet_id,
this.model.address_id,
);

return BalanceMap.fromBalances(response.data.data);
}

/**
* Returns the balance of the provided asset.
*
* @param {string} assetId - The asset ID.
* @returns {Decimal} The balance of the asset.
*/
async getBalance(assetId: string): Promise<Decimal> {
const response = await this.client.getAddressBalance(
this.model.wallet_id,
this.model.address_id,
assetId,
);

if (!response.data) {
return new Decimal(0);
}

return Balance.fromModelAndAssetId(response.data, assetId).amount;
}

/**
* Returns the wallet ID.
*
28 changes: 28 additions & 0 deletions src/coinbase/asset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Decimal from "decimal.js";
import { ATOMIC_UNITS_PER_USDC, WEI_PER_ETHER, WEI_PER_GWEI } from "./constants";

/** A representation of an Asset. */
export class Asset {
/**
* Converts an amount from the atomic value of the primary denomination of the provided Asset ID
* to whole units of the specified asset ID.
*
* @param {Decimal} atomicAmount - The amount in atomic units.
* @param {string} assetId - The assset ID.
* @returns The amount in whole units of the asset with the specified ID.
*/
static fromAtomicAmount(atomicAmount: Decimal, assetId: string): Decimal {
switch (assetId) {
case "eth":
return atomicAmount.div(WEI_PER_ETHER);
case "gwei":
return atomicAmount.div(WEI_PER_GWEI);
case "usdc":
return atomicAmount.div(ATOMIC_UNITS_PER_USDC);
case "weth":
return atomicAmount.div(WEI_PER_ETHER);
default:
return atomicAmount;
}
}
}
43 changes: 43 additions & 0 deletions src/coinbase/balance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Decimal from "decimal.js";
import { Balance as BalanceModel } from "../client";
import { Asset } from "./asset";

/** A representation of a balance. */
export class Balance {
public readonly amount: Decimal;
public readonly assetId: string;

/**
* Private constructor to prevent direct instantiation outside of the factory methods.
*
* @ignore
* @param {Decimal} amount - The amount of the balance.
* @param {string} assetId - The asset ID.
* @hideconstructor
*/
private constructor(amount: Decimal, assetId: string) {
this.amount = amount;
this.assetId = assetId;
}

/**
* Converts a BalanceModel into a Balance object.
*
* @param {BalanceModel} model - The balance model object.
* @returns {Balance} The Balance object.
*/
public static fromModel(model: BalanceModel): Balance {
return this.fromModelAndAssetId(model, model.asset.asset_id);
}

/**
* Converts a BalanceModel and asset ID into a Balance object.
*
* @param {BalanceModel} model - The balance model object.
* @param {string} assetId - The asset ID.
* @returns {Balance} The Balance object.
*/
public static fromModelAndAssetId(model: BalanceModel, assetId: string): Balance {
return new Balance(Asset.fromAtomicAmount(new Decimal(model.amount), assetId), assetId);
}
}
52 changes: 52 additions & 0 deletions src/coinbase/balance_map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Balance } from "./balance";
import { Balance as BalanceModel } from "../client";
import { Decimal } from "decimal.js";

/**
* A convenience class for storing and manipulating Asset balances in a human-readable format.
*/
export class BalanceMap extends Map<string, Decimal> {
/**
* Converts a list of Balance models to a BalanceMap.
*
* @param {BalanceModel[]} balances - The list of balances fetched from the API.
* @returns {BalanceMap} The converted BalanceMap object.
*/
public static fromBalances(balances: BalanceModel[]): BalanceMap {
const balanceMap = new BalanceMap();
balances.forEach(balanceModel => {
const balance = Balance.fromModel(balanceModel);
balanceMap.add(balance);
});
return balanceMap;
}

/**
* Adds a balance to the map.
*
* @param {Balance} balance - The balance to add to the map.
*/
public add(balance: Balance): void {
if (!(balance instanceof Balance)) {
throw new Error("balance must be a Balance");
}
this.set(balance.assetId, balance.amount);
}

/**
* Returns a string representation of the balance map.
*
* @returns The string representation of the balance map.
*/
public toString(): string {
const result: Record<string, string> = {};
this.forEach((value, key) => {
let str = value.toString();
if (value.isInteger()) {
str = value.toNumber().toString();
}
result[key] = str;
});
return JSON.stringify(result);
}
}
6 changes: 6 additions & 0 deletions src/coinbase/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Decimal } from "decimal.js";

export const WEI_PER_ETHER = new Decimal("1000000000000000000");
export const WEI_PER_GWEI = new Decimal("1000000000");
export const GWEI_PER_ETHER = new Decimal("1000000000");
export const ATOMIC_UNITS_PER_USDC = new Decimal("1000000");
84 changes: 81 additions & 3 deletions src/coinbase/tests/address_test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { ethers } from "ethers";
import { AddressesApiFactory, Address as AddressModel } from "../../client";
import {
AddressBalanceList,
AddressesApiFactory,
Address as AddressModel,
Balance as BalanceModel,
} from "../../client";
import { Address } from "./../address";
import { FaucetTransaction } from "./../faucet_transaction";

@@ -9,21 +14,55 @@ import { APIError, FaucetLimitReachedError } from "../api_error";
import { Coinbase } from "../coinbase";
import { InternalError } from "../errors";
import { createAxiosMock } from "./utils";
import Decimal from "decimal.js";

const newEthAddress = ethers.Wallet.createRandom();

const VALID_ADDRESS_MODEL: AddressModel = {
export const VALID_ADDRESS_MODEL: AddressModel = {
address_id: newEthAddress.address,
network_id: Coinbase.networkList.BaseSepolia,
public_key: newEthAddress.publicKey,
wallet_id: randomUUID(),
};

export const ETH_BALANCE_MODEL: BalanceModel = {
amount: "1000000000000000000",
asset: {
asset_id: Coinbase.assetList.Eth,
network_id: Coinbase.networkList.BaseSepolia,
},
};

export const USDC_BALANCE_MODEL: BalanceModel = {
amount: "5000000000",
asset: {
asset_id: "usdc",
network_id: Coinbase.networkList.BaseSepolia,
decimals: 6,
},
};

export const WETH_BALANCE_MODEL: BalanceModel = {
amount: "3000000000000000000",
asset: {
asset_id: "weth",
network_id: Coinbase.networkList.BaseSepolia,
decimals: 6,
},
};

export const VALID_ADDRESS_BALANCE_LIST: AddressBalanceList = {
data: [ETH_BALANCE_MODEL, USDC_BALANCE_MODEL, WETH_BALANCE_MODEL],
has_more: false,
next_page: "",
total_count: 3,
};

// Test suite for Address class
describe("Address", () => {
const [axiosInstance, configuration, BASE_PATH] = createAxiosMock();
const client = AddressesApiFactory(configuration, BASE_PATH, axiosInstance);
let address, axiosMock;
let address: Address, axiosMock;

beforeAll(() => {
axiosMock = new MockAdapter(axiosInstance);
@@ -49,6 +88,45 @@ describe("Address", () => {
expect(address.getNetworkId()).toBe(VALID_ADDRESS_MODEL.network_id);
});

it("should return the correct list of balances", async () => {
axiosMock.onGet().reply(200, VALID_ADDRESS_BALANCE_LIST);
const balances = await address.listBalances();
expect(balances.get(Coinbase.assetList.Eth)).toEqual(new Decimal(1));
expect(balances.get("usdc")).toEqual(new Decimal(5000));
expect(balances.get("weth")).toEqual(new Decimal(3));
});

it("should return the correct ETH balance", async () => {
axiosMock.onGet().reply(200, ETH_BALANCE_MODEL);
const ethBalance = await address.getBalance(Coinbase.assetList.Eth);
expect(ethBalance).toBeInstanceOf(Decimal);
expect(ethBalance).toEqual(new Decimal(1));
});

it("should return the correct Gwei balance", async () => {
axiosMock.onGet().reply(200, ETH_BALANCE_MODEL);
const ethBalance = await address.getBalance("gwei");
expect(ethBalance).toBeInstanceOf(Decimal);
expect(ethBalance).toEqual(new Decimal(1000000000));
});

it("should return the correct Wei balance", async () => {
axiosMock.onGet().reply(200, ETH_BALANCE_MODEL);
const ethBalance = await address.getBalance("wei");
expect(ethBalance).toBeInstanceOf(Decimal);
expect(ethBalance).toEqual(new Decimal(1000000000000000000));
});

it("should return an error for an unsupported asset", async () => {
axiosMock.onGet().reply(404, null);
try {
await address.getBalance("unsupported-asset");
fail("Expect 404 to be thrown");
} catch (error) {
expect(error).toBeInstanceOf(APIError);
}
});

it("should return the wallet ID", () => {
expect(address.getWalletId()).toBe(VALID_ADDRESS_MODEL.wallet_id);
});
86 changes: 86 additions & 0 deletions src/coinbase/tests/balance_map_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { BalanceMap } from "../balance_map";
import { Balance as BalanceModel } from "../../client";
import { Balance } from "../balance";
import { Decimal } from "decimal.js";
import { Coinbase } from "../coinbase";

describe("BalanceMap", () => {
const ethAmount = new Decimal(1);
const ethAtomicAmount = "1000000000000000000";
const usdcAmount = new Decimal(2);
const usdcAtomicAmount = "2000000";
const wethAmount = new Decimal(3);
const wethAtomicAmount = "3000000000000000000";

describe(".fromBalances", () => {
const ethBalanceModel: BalanceModel = {
asset: {
asset_id: Coinbase.assetList.Eth,
network_id: Coinbase.networkList.BaseSepolia,
},
amount: ethAtomicAmount,
};

const usdcBalanceModel: BalanceModel = {
asset: {
asset_id: "usdc",
network_id: Coinbase.networkList.BaseSepolia,
},
amount: usdcAtomicAmount,
};

const wethBalanceModel: BalanceModel = {
asset: {
asset_id: "weth",
network_id: Coinbase.networkList.BaseSepolia,
},
amount: wethAtomicAmount,
};

const balances = [ethBalanceModel, usdcBalanceModel, wethBalanceModel];

const balanceMap = BalanceMap.fromBalances(balances);

it("returns a new BalanceMap object with the correct balances", () => {
expect(balanceMap.get(Coinbase.assetList.Eth)).toEqual(ethAmount);
expect(balanceMap.get("usdc")).toEqual(usdcAmount);
expect(balanceMap.get("weth")).toEqual(wethAmount);
});
});

describe("#add", () => {
const assetId = Coinbase.assetList.Eth;
const balance = Balance.fromModelAndAssetId(
{
amount: ethAtomicAmount,
asset: { asset_id: assetId, network_id: Coinbase.networkList.BaseSepolia },
},
assetId,
);

const balanceMap = new BalanceMap();

it("sets the amount", () => {
balanceMap.add(balance);
expect(balanceMap.get(assetId)).toEqual(ethAmount);
});
});

describe("#toString", () => {
const assetId = Coinbase.assetList.Eth;
const balance = Balance.fromModelAndAssetId(
{
amount: ethAtomicAmount,
asset: { asset_id: assetId, network_id: Coinbase.networkList.BaseSepolia },
},
assetId,
);

const balanceMap = new BalanceMap();
balanceMap.add(balance);

it("returns a string representation of asset_id to floating-point number", () => {
expect(balanceMap.toString()).toBe(`{"${assetId}":"${ethAmount}"}`);
});
});
});
Loading