diff --git a/package.json b/package.json index 639e4b58..376ac1ca 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@types/secp256k1": "^4.0.6", "@typescript-eslint/eslint-plugin": "^7.8.0", "@typescript-eslint/parser": "^7.8.0", - "axios-mock-adapter": "^1.22.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-jsdoc": "^48.2.5", diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts index 7c6a9e5f..3375179d 100644 --- a/src/coinbase/address.ts +++ b/src/coinbase/address.ts @@ -1,9 +1,9 @@ import { Address as AddressModel } from "../client"; import { Balance } from "./balance"; import { BalanceMap } from "./balance_map"; +import { Coinbase } from "./coinbase"; import { InternalError } from "./errors"; import { FaucetTransaction } from "./faucet_transaction"; -import { AddressAPIClient } from "./types"; import { Decimal } from "decimal.js"; /** @@ -11,24 +11,18 @@ import { Decimal } from "decimal.js"; */ export class Address { private model: AddressModel; - private client: AddressAPIClient; /** * Initializes a new Address instance. * * @param {AddressModel} model - The address model data. - * @param {AddressAPIClient} client - The API client to interact with address-related endpoints. * @throws {InternalError} If the model or client is empty. */ - constructor(model: AddressModel, client: AddressAPIClient) { + constructor(model: AddressModel) { if (!model) { throw new InternalError("Address model cannot be empty"); } - if (!client) { - throw new InternalError("Address client cannot be empty"); - } this.model = model; - this.client = client; } /** @@ -40,7 +34,7 @@ export class Address { * @throws {Error} If the request fails. */ async faucet(): Promise { - const response = await this.client.requestFaucetFunds( + const response = await Coinbase.apiClients.address!.requestFaucetFunds( this.model.wallet_id, this.model.address_id, ); @@ -71,7 +65,7 @@ export class Address { * @returns {BalanceMap} - The map from asset ID to balance. */ async listBalances(): Promise { - const response = await this.client.listAddressBalances( + const response = await Coinbase.apiClients.address!.listAddressBalances( this.model.wallet_id, this.model.address_id, ); @@ -86,7 +80,7 @@ export class Address { * @returns {Decimal} The balance of the asset. */ async getBalance(assetId: string): Promise { - const response = await this.client.getAddressBalance( + const response = await Coinbase.apiClients.address!.getAddressBalance( this.model.wallet_id, this.model.address_id, assetId, diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index acb42396..1c68a0ae 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -42,7 +42,7 @@ export class Coinbase { Weth: "weth", }; - apiClients: ApiClients = {}; + static apiClients: ApiClients = {}; /** * Represents the number of Wei per Ether. @@ -85,11 +85,13 @@ export class Coinbase { response => logApiResponse(response, debugging), ); - this.apiClients.user = UsersApiFactory(config, BASE_PATH, axiosInstance); - this.apiClients.wallet = WalletsApiFactory(config, BASE_PATH, axiosInstance); - this.apiClients.address = AddressesApiFactory(config, BASE_PATH, axiosInstance); - this.apiClients.transfer = TransfersApiFactory(config, BASE_PATH, axiosInstance); - this.apiClients.baseSepoliaProvider = new ethers.JsonRpcProvider("https://sepolia.base.org"); + Coinbase.apiClients.user = UsersApiFactory(config, BASE_PATH, axiosInstance); + Coinbase.apiClients.wallet = WalletsApiFactory(config, BASE_PATH, axiosInstance); + Coinbase.apiClients.address = AddressesApiFactory(config, BASE_PATH, axiosInstance); + Coinbase.apiClients.transfer = TransfersApiFactory(config, BASE_PATH, axiosInstance); + Coinbase.apiClients.baseSepoliaProvider = new ethers.JsonRpcProvider( + "https://sepolia.base.org", + ); } /** @@ -137,7 +139,7 @@ export class Coinbase { * @throws {APIError} If the request fails. */ async getDefaultUser(): Promise { - const userResponse = await this.apiClients.user!.getCurrentUser(); - return new User(userResponse.data as UserModel, this.apiClients); + const userResponse = await Coinbase.apiClients.user!.getCurrentUser(); + return new User(userResponse.data as UserModel); } } diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index 9bd67587..7e15b9ab 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -1,91 +1,48 @@ -import { ethers } from "ethers"; -import { - AddressBalanceList, - AddressesApiFactory, - Address as AddressModel, - Balance as BalanceModel, -} from "../../client"; import { Address } from "./../address"; import { FaucetTransaction } from "./../faucet_transaction"; -import MockAdapter from "axios-mock-adapter"; -import { randomUUID } from "crypto"; +import Decimal from "decimal.js"; import { APIError, FaucetLimitReachedError } from "../api_error"; import { Coinbase } from "../coinbase"; import { InternalError } from "../errors"; -import { createAxiosMock } from "./utils"; -import Decimal from "decimal.js"; - -// newAddressModel creates a new AddressModel with a random wallet ID and a random Ethereum address. -export const newAddressModel = (walletId: string): AddressModel => { - const ethAddress = ethers.Wallet.createRandom(); - - return { - address_id: ethAddress.address, - network_id: Coinbase.networkList.BaseSepolia, - public_key: ethAddress.publicKey, - wallet_id: walletId, - }; -}; - -const VALID_ADDRESS_MODEL = newAddressModel(randomUUID()); - -const VALID_BALANCE_MODEL: BalanceModel = { - amount: "1000000000000000000", - asset: { - asset_id: Coinbase.assetList.Eth, - network_id: Coinbase.networkList.BaseSepolia, - }, -}; - -const VALID_ADDRESS_BALANCE_LIST: AddressBalanceList = { - data: [ - { - amount: "1000000000000000000", - asset: { - asset_id: Coinbase.assetList.Eth, - network_id: Coinbase.networkList.BaseSepolia, - decimals: 18, - }, - }, - { - amount: "5000000000", - asset: { - asset_id: "usdc", - network_id: Coinbase.networkList.BaseSepolia, - decimals: 6, - }, - }, - { - amount: "3000000000000000000", - asset: { - asset_id: "weth", - network_id: Coinbase.networkList.BaseSepolia, - decimals: 6, - }, - }, - ], - has_more: false, - next_page: "", - total_count: 3, -}; +import { + VALID_ADDRESS_BALANCE_LIST, + VALID_ADDRESS_MODEL, + addressesApiMock, + generateRandomHash, + mockFn, + mockReturnRejectedValue, +} from "./utils"; // Test suite for Address class describe("Address", () => { - const [axiosInstance, configuration, BASE_PATH] = createAxiosMock(); - const client = AddressesApiFactory(configuration, BASE_PATH, axiosInstance); - let address: Address, axiosMock; + const transactionHash = generateRandomHash(); + let address: Address, balanceModel; beforeAll(() => { - axiosMock = new MockAdapter(axiosInstance); + Coinbase.apiClients.address = addressesApiMock; + Coinbase.apiClients.address!.getAddressBalance = mockFn(request => { + const { asset_id } = request; + balanceModel = { + amount: "1000000000000000000", + asset: { + asset_id, + network_id: Coinbase.networkList.BaseSepolia, + }, + }; + return { data: balanceModel }; + }); + Coinbase.apiClients.address!.listAddressBalances = mockFn(() => { + return { data: VALID_ADDRESS_BALANCE_LIST }; + }); + Coinbase.apiClients.address!.requestFaucetFunds = mockFn(() => { + return { data: { transaction_hash: transactionHash } }; + }); }); beforeEach(() => { - address = new Address(VALID_ADDRESS_MODEL, client); - }); - - afterEach(() => { - axiosMock.reset(); + address = new Address(VALID_ADDRESS_MODEL); + jest.clearAllMocks(); }); it("should initialize a new Address", () => { @@ -101,42 +58,62 @@ describe("Address", () => { }); 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)); + expect(Coinbase.apiClients.address!.listAddressBalances).toHaveBeenCalledWith( + address.getWalletId(), + address.getId(), + ); + expect(Coinbase.apiClients.address!.listAddressBalances).toHaveBeenCalledTimes(1); }); it("should return the correct ETH balance", async () => { - axiosMock.onGet().reply(200, VALID_BALANCE_MODEL); const ethBalance = await address.getBalance(Coinbase.assetList.Eth); expect(ethBalance).toBeInstanceOf(Decimal); expect(ethBalance).toEqual(new Decimal(1)); + expect(Coinbase.apiClients.address!.getAddressBalance).toHaveBeenCalledWith( + address.getWalletId(), + address.getId(), + Coinbase.assetList.Eth, + ); + expect(Coinbase.apiClients.address!.getAddressBalance).toHaveBeenCalledTimes(1); }); it("should return the correct Gwei balance", async () => { - axiosMock.onGet().reply(200, VALID_BALANCE_MODEL); - const ethBalance = await address.getBalance("gwei"); + const assetId = "gwei"; + const ethBalance = await address.getBalance(assetId); expect(ethBalance).toBeInstanceOf(Decimal); expect(ethBalance).toEqual(new Decimal(1000000000)); + expect(Coinbase.apiClients.address!.getAddressBalance).toHaveBeenCalledWith( + address.getWalletId(), + address.getId(), + assetId, + ); + expect(Coinbase.apiClients.address!.getAddressBalance).toHaveBeenCalledTimes(1); }); it("should return the correct Wei balance", async () => { - axiosMock.onGet().reply(200, VALID_BALANCE_MODEL); - const ethBalance = await address.getBalance("wei"); + const assetId = "wei"; + const ethBalance = await address.getBalance(assetId); expect(ethBalance).toBeInstanceOf(Decimal); expect(ethBalance).toEqual(new Decimal(1000000000000000000)); + expect(Coinbase.apiClients.address!.getAddressBalance).toHaveBeenCalledWith( + address.getWalletId(), + address.getId(), + assetId, + ); + expect(Coinbase.apiClients.address!.getAddressBalance).toHaveBeenCalledTimes(1); }); 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); - } + const getAddressBalance = mockReturnRejectedValue(new APIError("")); + const assetId = "unsupported-asset"; + Coinbase.apiClients.address!.getAddressBalance = getAddressBalance; + await expect(address.getBalance(assetId)).rejects.toThrow(APIError); + expect(getAddressBalance).toHaveBeenCalledWith(address.getWalletId(), address.getId(), assetId); + expect(getAddressBalance).toHaveBeenCalledTimes(1); }); it("should return the wallet ID", () => { @@ -144,42 +121,44 @@ describe("Address", () => { }); it("should throw an InternalError when model is not provided", () => { - expect(() => new Address(null!, client)).toThrow(`Address model cannot be empty`); - }); - - it("should throw an InternalError when client is not provided", () => { - expect(() => new Address(VALID_ADDRESS_MODEL, null!)).toThrow(`Address client cannot be empty`); + expect(() => new Address(null!)).toThrow(`Address model cannot be empty`); }); it("should request funds from the faucet and returns the faucet transaction", async () => { - const transactionHash = "0xdeadbeef"; - axiosMock.onPost().reply(200, { - transaction_hash: transactionHash, - }); const faucetTransaction = await address.faucet(); expect(faucetTransaction).toBeInstanceOf(FaucetTransaction); expect(faucetTransaction.getTransactionHash()).toBe(transactionHash); + expect(Coinbase.apiClients.address!.requestFaucetFunds).toHaveBeenCalledWith( + address.getWalletId(), + address.getId(), + ); + expect(Coinbase.apiClients.address!.requestFaucetFunds).toHaveBeenCalledTimes(1); }); it("should throw an APIError when the request is unsuccesful", async () => { - axiosMock.onPost().reply(400); + Coinbase.apiClients.address!.requestFaucetFunds = mockReturnRejectedValue(new APIError("")); await expect(address.faucet()).rejects.toThrow(APIError); + expect(Coinbase.apiClients.address!.requestFaucetFunds).toHaveBeenCalledWith( + address.getWalletId(), + address.getId(), + ); + expect(Coinbase.apiClients.address!.requestFaucetFunds).toHaveBeenCalledTimes(1); }); it("should throw a FaucetLimitReachedError when the faucet limit is reached", async () => { - axiosMock.onPost().reply(429, { - code: "faucet_limit_reached", - message: "Faucet limit reached", - }); + Coinbase.apiClients.address!.requestFaucetFunds = mockReturnRejectedValue( + new FaucetLimitReachedError(""), + ); await expect(address.faucet()).rejects.toThrow(FaucetLimitReachedError); + expect(Coinbase.apiClients.address!.requestFaucetFunds).toHaveBeenCalledTimes(1); }); it("should throw an InternalError when the request fails unexpectedly", async () => { - axiosMock.onPost().reply(500, { - code: "internal", - message: "unexpected error occurred while requesting faucet funds", - }); + Coinbase.apiClients.address!.requestFaucetFunds = mockReturnRejectedValue( + new InternalError(""), + ); await expect(address.faucet()).rejects.toThrow(InternalError); + expect(Coinbase.apiClients.address!.requestFaucetFunds).toHaveBeenCalledTimes(1); }); it("should return the correct string representation", () => { diff --git a/src/coinbase/tests/coinbase_test.ts b/src/coinbase/tests/coinbase_test.ts index 6a887b12..a3f03ae0 100644 --- a/src/coinbase/tests/coinbase_test.ts +++ b/src/coinbase/tests/coinbase_test.ts @@ -1,17 +1,20 @@ -import { Coinbase } from "../coinbase"; -import MockAdapter from "axios-mock-adapter"; -import axios from "axios"; +import { randomUUID } from "crypto"; import { APIError } from "../api_error"; -import { VALID_WALLET_MODEL } from "./wallet_test"; +import { Coinbase } from "../coinbase"; +import { + VALID_WALLET_MODEL, + addressesApiMock, + generateRandomHash, + mockReturnRejectedValue, + mockReturnValue, + usersApiMock, + walletsApiMock, +} from "./utils"; +import { ethers } from "ethers"; -const axiosMock = new MockAdapter(axios); const PATH_PREFIX = "./src/coinbase/tests/config"; describe("Coinbase tests", () => { - beforeEach(() => { - axiosMock.reset(); - }); - it("should throw an error if the API key name or private key is empty", () => { expect(() => new Coinbase("", "test")).toThrow("Invalid configuration: apiKeyName is empty"); expect(() => new Coinbase("test", "")).toThrow("Invalid configuration: privateKey is empty"); @@ -41,47 +44,84 @@ describe("Coinbase tests", () => { }); describe("should able to interact with the API", () => { + let user, walletId, publicKey, addressId, transactionHash; const cbInstance = Coinbase.configureFromJson( `${PATH_PREFIX}/coinbase_cloud_api_key.json`, true, ); - let user; - beforeEach(async () => { - axiosMock.reset(); - axiosMock - .onPost(/\/v1\/wallets\/.*\/addresses\/.*\/faucet/) - .reply(200, { transaction_hash: "0xdeadbeef" }) - .onGet(/\/me/) - .reply(200, { - id: 123, - }) - .onPost(/\/v1\/wallets/) - .reply(200, VALID_WALLET_MODEL) - .onGet(/\/v1\/wallets\/.*/) - .reply(200, VALID_WALLET_MODEL); + + beforeAll(async () => { + Coinbase.apiClients = { + user: usersApiMock, + wallet: walletsApiMock, + address: addressesApiMock, + }; + const ethAddress = ethers.Wallet.createRandom(); + + walletId = randomUUID(); + publicKey = ethAddress.publicKey; + addressId = randomUUID(); + transactionHash = generateRandomHash(8); + + const walletModel = { + id: walletId, + network_id: Coinbase.networkList.BaseSepolia, + default_address: { + wallet_id: walletId, + address_id: addressId, + public_key: publicKey, + network_id: Coinbase.networkList.BaseSepolia, + }, + }; + + Coinbase.apiClients.user!.getCurrentUser = mockReturnValue({ id: 123 }); + Coinbase.apiClients.wallet!.createWallet = mockReturnValue(walletModel); + Coinbase.apiClients.wallet!.getWallet = mockReturnValue(walletModel); + Coinbase.apiClients.address!.requestFaucetFunds = mockReturnValue({ + transaction_hash: transactionHash, + }); + Coinbase.apiClients.address!.createAddress = mockReturnValue( + VALID_WALLET_MODEL.default_address, + ); + user = await cbInstance.getDefaultUser(); }); - it("should return the correct user ID", () => { + it("should return the correct user ID", async () => { expect(user.getId()).toBe(123); expect(user.toString()).toBe("User{ userId: 123 }"); + expect(Coinbase.apiClients.user!.getCurrentUser).toHaveBeenCalledWith(); + expect(usersApiMock.getCurrentUser).toHaveBeenCalledTimes(1); }); it("should be able to get faucet funds", async () => { const wallet = await user.createWallet(); - expect(wallet.getId()).toBe(VALID_WALLET_MODEL.id); + expect(wallet.getId()).toBe(walletId); + const payload = { wallet: { network_id: Coinbase.networkList.BaseSepolia } }; + expect(walletsApiMock.createWallet).toHaveBeenCalledWith(payload); + expect(walletsApiMock.createWallet).toHaveBeenCalledTimes(1); const defaultAddress = wallet.defaultAddress(); - expect(defaultAddress?.getId()).toBe(VALID_WALLET_MODEL.default_address.address_id); + expect(defaultAddress?.getId()).toBe(addressId); const faucetTransaction = await wallet?.faucet(); - expect(faucetTransaction.getTransactionHash()).toBe("0xdeadbeef"); + expect(faucetTransaction.getTransactionHash()).toBe(transactionHash); + expect(addressesApiMock.requestFaucetFunds).toHaveBeenCalledWith( + defaultAddress.getWalletId(), + defaultAddress?.getId(), + ); + expect(addressesApiMock.requestFaucetFunds).toHaveBeenCalledTimes(1); }); }); it("should raise an error if the user is not found", async () => { - axiosMock.onGet().reply(404); const cbInstance = Coinbase.configureFromJson(`${PATH_PREFIX}/coinbase_cloud_api_key.json`); + Coinbase.apiClients.user!.getCurrentUser = mockReturnRejectedValue( + new APIError("User not found"), + ); + await expect(cbInstance.getDefaultUser()).rejects.toThrow(APIError); + expect(usersApiMock.getCurrentUser).toHaveBeenCalledWith(); + expect(usersApiMock.getCurrentUser).toHaveBeenCalledTimes(1); }); }); diff --git a/src/coinbase/tests/user_test.ts b/src/coinbase/tests/user_test.ts index fab4292d..10443497 100644 --- a/src/coinbase/tests/user_test.ts +++ b/src/coinbase/tests/user_test.ts @@ -1,27 +1,22 @@ -import { User } from "./../user"; -import { ApiClients } from "./../types"; import { User as UserModel } from "./../../client/api"; +import { User } from "./../user"; describe("User Class", () => { let mockUserModel: UserModel; - let mockApiClients: ApiClients; - beforeEach(() => { mockUserModel = { id: "12345", } as UserModel; - - mockApiClients = {} as ApiClients; }); it("should initialize User instance with a valid user model and API clients, and set the user ID correctly", () => { - const user = new User(mockUserModel, mockApiClients); + const user = new User(mockUserModel); expect(user).toBeInstanceOf(User); expect(user.getId()).toBe(mockUserModel.id); }); it("should return a correctly formatted string representation of the User instance", () => { - const user = new User(mockUserModel, mockApiClients); + const user = new User(mockUserModel); expect(user.toString()).toBe(`User{ userId: ${mockUserModel.id} }`); }); }); diff --git a/src/coinbase/tests/utils.ts b/src/coinbase/tests/utils.ts index d9c961b7..1a2ed136 100644 --- a/src/coinbase/tests/utils.ts +++ b/src/coinbase/tests/utils.ts @@ -1,8 +1,98 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import axios, { AxiosInstance } from "axios"; -import { Configuration } from "../../client"; +import { ethers } from "ethers"; +import { randomUUID } from "crypto"; +import { + Configuration, + Wallet as WalletModel, + Balance as BalanceModel, + AddressBalanceList, + Address as AddressModel, +} from "../../client"; import { BASE_PATH } from "../../client/base"; +import { Coinbase } from "../coinbase"; import { registerAxiosInterceptors } from "../utils"; +export const mockFn = (...args) => jest.fn(...args) as any; +export const mockReturnValue = data => jest.fn().mockResolvedValue({ data }); +export const mockReturnRejectedValue = data => jest.fn().mockRejectedValue(data); + +export const walletId = randomUUID(); + +export const generateRandomHash = (length = 8) => { + const characters = "abcdef0123456789"; + let hash = "0x"; + for (let i = 0; i < length; i++) { + hash += characters[Math.floor(Math.random() * characters.length)]; + } + return hash; +}; + +// newAddressModel creates a new AddressModel with a random wallet ID and a random Ethereum address. +export const newAddressModel = (walletId: string): AddressModel => { + const ethAddress = ethers.Wallet.createRandom(); + + return { + address_id: ethAddress.address, + network_id: Coinbase.networkList.BaseSepolia, + public_key: ethAddress.publicKey, + wallet_id: walletId, + }; +}; + +export const VALID_ADDRESS_MODEL = newAddressModel(randomUUID()); + +export const VALID_WALLET_MODEL: WalletModel = { + id: randomUUID(), + network_id: Coinbase.networkList.BaseSepolia, + default_address: { + wallet_id: walletId, + address_id: "0xdeadbeef", + public_key: "0x1234567890", + network_id: Coinbase.networkList.BaseSepolia, + }, +}; + +export const VALID_ADDRESS_BALANCE_LIST: AddressBalanceList = { + data: [ + { + amount: "1000000000000000000", + asset: { + asset_id: Coinbase.assetList.Eth, + network_id: Coinbase.networkList.BaseSepolia, + decimals: 18, + }, + }, + { + amount: "5000000000", + asset: { + asset_id: "usdc", + network_id: Coinbase.networkList.BaseSepolia, + decimals: 6, + }, + }, + { + amount: "3000000000000000000", + asset: { + asset_id: "weth", + network_id: Coinbase.networkList.BaseSepolia, + decimals: 6, + }, + }, + ], + has_more: false, + next_page: "", + total_count: 3, +}; + +export const VALID_BALANCE_MODEL: BalanceModel = { + amount: "1000000000000000000", + asset: { + asset_id: Coinbase.assetList.Eth, + network_id: Coinbase.networkList.BaseSepolia, + }, +}; + /** * AxiosMockReturn type. Represents the Axios instance, configuration, and base path. */ @@ -10,7 +100,8 @@ type AxiosMockType = [AxiosInstance, Configuration, string]; /** * Returns an Axios instance with interceptors and configuration for testing. - * @returns {AxiosMockType} - The Axios instance, configuration, and base path. + * + * @returns The Axios instance, configuration, and base path. */ export const createAxiosMock = (): AxiosMockType => { const axiosInstance = axios.create(); @@ -22,3 +113,20 @@ export const createAxiosMock = (): AxiosMockType => { const configuration = new Configuration(); return [axiosInstance, configuration, BASE_PATH]; }; + +export const usersApiMock = { + getCurrentUser: jest.fn(), +}; + +export const walletsApiMock = { + getWallet: jest.fn(), + createWallet: jest.fn(), +}; + +export const addressesApiMock = { + requestFaucetFunds: jest.fn(), + getAddress: jest.fn(), + getAddressBalance: jest.fn(), + listAddressBalances: jest.fn(), + createAddress: jest.fn(), +}; diff --git a/src/coinbase/tests/wallet_test.ts b/src/coinbase/tests/wallet_test.ts index 16d5046e..d36dd1de 100644 --- a/src/coinbase/tests/wallet_test.ts +++ b/src/coinbase/tests/wallet_test.ts @@ -1,71 +1,51 @@ -import MockAdapter from "axios-mock-adapter"; -import * as bip39 from "bip39"; import { randomUUID } from "crypto"; -import { Address as AddressModel, AddressesApiFactory, WalletsApiFactory } from "../../client"; import { Coinbase } from "../coinbase"; -import { BASE_PATH } from "../../client/base"; -import { ArgumentError } from "../errors"; import { Wallet } from "../wallet"; -import { createAxiosMock } from "./utils"; -import { newAddressModel } from "./address_test"; - -const walletId = randomUUID(); - -const DEFAULT_ADDRESS_MODEL = newAddressModel(walletId); - -export const VALID_WALLET_MODEL = { - id: walletId, - network_id: Coinbase.networkList.BaseSepolia, - default_address: DEFAULT_ADDRESS_MODEL, -}; - -export const VALID_WALLET_MODEL_WITHOUT_DEFAULT_ADDRESS = { - id: walletId, - network_id: Coinbase.networkList.BaseSepolia, -}; +import { addressesApiMock, mockFn, newAddressModel, walletsApiMock } from "./utils"; +import { ArgumentError } from "../errors"; describe("Wallet Class", () => { - let wallet, axiosMock; - const seed = bip39.generateMnemonic(); - - const [axiosInstance, configuration, BASE_PATH] = createAxiosMock(); - const client = { - wallet: WalletsApiFactory(configuration, BASE_PATH, axiosInstance), - address: AddressesApiFactory(configuration, BASE_PATH, axiosInstance), - }; - - beforeAll(async () => { - axiosMock = new MockAdapter(axiosInstance); - }); - - afterEach(() => { - axiosMock.reset(); - }); - + let wallet, walletModel, walletId; describe(".create", () => { - beforeEach(async () => { - axiosMock - .onPost(BASE_PATH + "/v1/wallets") - .reply(200, VALID_WALLET_MODEL_WITHOUT_DEFAULT_ADDRESS); - - axiosMock - .onPost(BASE_PATH + "/v1/wallets/" + VALID_WALLET_MODEL.id + "/addresses") - .reply(200, DEFAULT_ADDRESS_MODEL); - - // Mock reloading the wallet after default address is created. - axiosMock - .onGet(BASE_PATH + "/v1/wallets/" + VALID_WALLET_MODEL.id) - .reply(200, VALID_WALLET_MODEL); - - wallet = await Wallet.create(client); + const apiResponses = {}; + + beforeAll(async () => { + walletId = randomUUID(); + // Mock the API calls + Coinbase.apiClients.wallet = walletsApiMock; + Coinbase.apiClients.address = addressesApiMock; + Coinbase.apiClients.wallet!.createWallet = mockFn(request => { + const { network_id } = request.wallet; + apiResponses[walletId] = { + id: walletId, + network_id, + default_address: newAddressModel(walletId), + }; + return { data: apiResponses[walletId] }; + }); + Coinbase.apiClients.wallet!.getWallet = mockFn(walletId => { + walletModel = apiResponses[walletId]; + return { data: apiResponses[walletId] }; + }); + Coinbase.apiClients.address!.createAddress = mockFn(walletId => { + return { data: apiResponses[walletId].default_address }; + }); + wallet = await Wallet.create(); }); it("should return a Wallet instance", async () => { expect(wallet).toBeInstanceOf(Wallet); + expect(Coinbase.apiClients.wallet!.createWallet).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.wallet!.getWallet).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.address!.createAddress).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.wallet!.createWallet).toHaveBeenCalledWith({ + wallet: { network_id: Coinbase.networkList.BaseSepolia }, + }); + expect(Coinbase.apiClients.wallet!.getWallet).toHaveBeenCalledWith(walletId); }); it("should return the correct wallet ID", async () => { - expect(wallet.getId()).toBe(VALID_WALLET_MODEL.id); + expect(wallet.getId()).toBe(walletModel.id); }); it("should return the correct network ID", async () => { @@ -73,7 +53,7 @@ describe("Wallet Class", () => { }); it("should return the correct default address", async () => { - expect(wallet.defaultAddress()?.getId()).toBe(DEFAULT_ADDRESS_MODEL.address_id); + expect(wallet.defaultAddress()?.getId()).toBe(walletModel.default_address.address_id); }); }); @@ -96,15 +76,30 @@ describe("Wallet Class", () => { ]; beforeEach(async () => { - addressList.forEach(address => { - axiosMock - .onGet( - BASE_PATH + "/v1/wallets/" + VALID_WALLET_MODEL.id + "/addresses/" + address.address_id, - ) - .replyOnce(200, address); + jest.clearAllMocks(); + const getAddress = jest.fn(); + addressList.forEach(() => { + getAddress.mockImplementationOnce((wallet_id, address_id) => { + return Promise.resolve({ + data: { + address_id, + network_id: Coinbase.networkList.BaseSepolia, + public_key: "0x03c3379b488a32a432a4dfe91cc3a28be210eddc98b2005bb59a4cf4ed0646eb56", + wallet_id, + }, + }); + }); + }); + Coinbase.apiClients.address!.getAddress = getAddress; + wallet = await Wallet.init(walletModel, existingSeed, 2); + expect(Coinbase.apiClients.address!.getAddress).toHaveBeenCalledTimes(2); + addressList.forEach((address, callIndex) => { + expect(Coinbase.apiClients.address!.getAddress).toHaveBeenNthCalledWith( + callIndex + 1, + walletId, + address.address_id, + ); }); - - wallet = await Wallet.init(VALID_WALLET_MODEL, client, existingSeed, 2); }); it("should return a Wallet instance", async () => { @@ -112,7 +107,7 @@ describe("Wallet Class", () => { }); it("should return the correct wallet ID", async () => { - expect(wallet.getId()).toBe(VALID_WALLET_MODEL.id); + expect(wallet.getId()).toBe(walletModel.id); }); it("should return the correct network ID", async () => { @@ -120,7 +115,7 @@ describe("Wallet Class", () => { }); it("should return the correct default address", async () => { - expect(wallet.defaultAddress()?.getId()).toBe(VALID_WALLET_MODEL.default_address.address_id); + expect(wallet.defaultAddress()?.getId()).toBe(walletModel.default_address?.address_id); }); it("should derive the correct number of addresses", async () => { @@ -129,16 +124,12 @@ describe("Wallet Class", () => { it("should return the correct string representation", async () => { expect(wallet.toString()).toBe( - `Wallet{id: '${VALID_WALLET_MODEL.id}', networkId: '${Coinbase.networkList.BaseSepolia}'}`, + `Wallet{id: '${walletModel.id}', networkId: '${Coinbase.networkList.BaseSepolia}'}`, ); }); - it("should throw an ArgumentError when the API client is not provided", async () => { - await expect(Wallet.init(VALID_WALLET_MODEL, undefined!)).rejects.toThrow(ArgumentError); - }); - it("should throw an ArgumentError when the wallet model is not provided", async () => { - await expect(Wallet.init(undefined!, client)).rejects.toThrow(ArgumentError); + await expect(Wallet.init(undefined!)).rejects.toThrow(ArgumentError); }); }); }); diff --git a/src/coinbase/user.ts b/src/coinbase/user.ts index e29f32c3..593dde4a 100644 --- a/src/coinbase/user.ts +++ b/src/coinbase/user.ts @@ -1,4 +1,3 @@ -import { ApiClients } from "./types"; import { User as UserModel } from "./../client/api"; import { Wallet } from "./wallet"; @@ -9,16 +8,13 @@ import { Wallet } from "./wallet"; */ export class User { private model: UserModel; - private client: ApiClients; /** * Initializes a new User instance. * * @param user - The user model. - * @param client - The API clients. */ - constructor(user: UserModel, client: ApiClients) { - this.client = client; + constructor(user: UserModel) { this.model = user; } @@ -31,10 +27,7 @@ export class User { * @returns the new Wallet */ async createWallet(): Promise { - return Wallet.create({ - wallet: this.client.wallet!, - address: this.client.address!, - }); + return Wallet.create(); } /** diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index 0053631b..07cf38c0 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -8,17 +8,8 @@ import { Address } from "./address"; import { Coinbase } from "./coinbase"; import { ArgumentError, InternalError } from "./errors"; import { FaucetTransaction } from "./faucet_transaction"; -import { AddressAPIClient, WalletAPIClient } from "./types"; import { convertStringToHex } from "./utils"; -/** - * The Wallet API client types. - */ -type WalletClients = { - wallet: WalletAPIClient; - address: AddressAPIClient; -}; - /** * A representation of a Wallet. Wallets come with a single default Address, but can expand to have a set of Addresses, * each of which can hold a balance of one or more Assets. Wallets can create new Addresses, list their addresses, @@ -26,7 +17,6 @@ type WalletClients = { */ export class Wallet { private model: WalletModel; - private client: WalletClients; private master: HDKey; private addresses: Address[] = []; @@ -38,13 +28,11 @@ export class Wallet { * * @ignore * @param model - The wallet model object. - * @param client - The API client to interact with the server. * @param master - The HD master key. * @hideconstructor */ - private constructor(model: WalletModel, client: WalletClients, master: HDKey) { + private constructor(model: WalletModel, master: HDKey) { this.model = model; - this.client = client; this.master = master; } @@ -53,24 +41,19 @@ export class Wallet { * Instead, use User.createWallet. * * @constructs Wallet - * @param client - The API client to interact with the server. * @throws {ArgumentError} If the model or client is not provided. * @throws {InternalError} - If address derivation or caching fails. * @throws {APIError} - If the request fails. * @returns A promise that resolves with the new Wallet object. */ - public static async create(client: WalletClients): Promise { - if (!client?.address || !client?.wallet) { - throw new ArgumentError("Wallet and address clients cannot be empty"); - } - - const walletData = await client.wallet!.createWallet({ + public static async create(): Promise { + const walletData = await Coinbase.apiClients.wallet!.createWallet({ wallet: { network_id: Coinbase.networkList.BaseSepolia, }, }); - const wallet = await Wallet.init(walletData.data!, client); + const wallet = await Wallet.init(walletData.data); await wallet.createAddress(); await wallet.reload(); @@ -83,7 +66,6 @@ export class Wallet { * * @constructs Wallet * @param model - The underlying Wallet model object - * @param client - The API client to interact with the server. * @param seed - The seed to use for the Wallet. Expects a 32-byte hexadecimal with no 0x prefix. If not provided, a new seed will be generated. * @param addressCount - The number of addresses already registered for the Wallet. * @throws {ArgumentError} If the model or client is not provided. @@ -93,22 +75,17 @@ export class Wallet { */ public static async init( model: WalletModel, - client: WalletClients, seed: string = "", addressCount: number = 0, ): Promise { if (!model) { throw new ArgumentError("Wallet model cannot be empty"); } - if (!client?.address || !client?.wallet) { - throw new ArgumentError("Address client cannot be empty"); - } - if (!seed) { seed = bip39.generateMnemonic(); } const master = HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(seed)); - const wallet = new Wallet(model, client, master); + const wallet = new Wallet(model, master); if (addressCount > 0) { for (let i = 0; i < addressCount; i++) { @@ -147,7 +124,7 @@ export class Wallet { public_key: publicKey, attestation: attestation, }; - const response = await this.client.address.createAddress(this.model.id!, payload); + const response = await Coinbase.apiClients.address!.createAddress(this.model.id!, payload); this.cacheAddress(response!.data); } @@ -186,7 +163,7 @@ export class Wallet { * Reloads the Wallet model with the latest data from the server. */ private async reload(): Promise { - const result = await this.client.wallet.getWallet(this.model.id!); + const result = await Coinbase.apiClients.wallet!.getWallet(this.model.id!); this.model = result?.data; } @@ -200,7 +177,7 @@ export class Wallet { private async deriveAddress(): Promise { const key = this.deriveKey(); const wallet = new ethers.Wallet(convertStringToHex(key.privateKey!)); - const response = await this.client.address.getAddress(this.model.id!, wallet.address); + const response = await Coinbase.apiClients.address!.getAddress(this.model.id!, wallet.address); this.cacheAddress(response.data); } @@ -212,7 +189,7 @@ export class Wallet { * @returns {void} */ private cacheAddress(address: AddressModel): void { - this.addresses.push(new Address(address, this.client.address!)); + this.addresses.push(new Address(address)); this.addressIndex++; } @@ -240,9 +217,7 @@ export class Wallet { * @returns The default address */ public defaultAddress(): Address | undefined { - return this.model.default_address - ? new Address(this.model.default_address, this.client.address!) - : undefined; + return this.model.default_address ? new Address(this.model.default_address) : undefined; } /** diff --git a/yarn.lock b/yarn.lock index 5b545c79..edd237bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1059,14 +1059,6 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -axios-mock-adapter@^1.22.0: - version "1.22.0" - resolved "https://registry.yarnpkg.com/axios-mock-adapter/-/axios-mock-adapter-1.22.0.tgz#0f3e6be0fc9b55baab06f2d49c0b71157e7c053d" - integrity sha512-dmI0KbkyAhntUR05YY96qg2H6gg0XMl2+qTW0xmYg6Up+BFBAJYRLROMXRdDEL06/Wqwa0TJThAYvFtSFdRCZw== - dependencies: - fast-deep-equal "^3.1.3" - is-buffer "^2.0.5" - axios@^1.6.8: version "1.6.8" resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66" @@ -1955,11 +1947,6 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== -is-buffer@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" - integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== - is-builtin-module@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169"