diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts new file mode 100644 index 00000000..524c3fb8 --- /dev/null +++ b/src/coinbase/address.ts @@ -0,0 +1,84 @@ +import { Address as AddressModel } from "../client"; +import { InternalError } from "./errors"; +import { FaucetTransaction } from "./faucet_transaction"; +import { AddressAPIClient } from "./types"; + +/** + * A representation of a blockchain address, which is a user-controlled account on a network. + */ +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) { + 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; + } + + /** + * Requests faucet funds for the address. + * Only supported on testnet networks. + * @returns {Promise} The faucet transaction object. + * @throws {InternalError} If the request does not return a transaction hash. + * @throws {Error} If the request fails. + */ + async faucet(): Promise { + const response = await this.client.requestFaucetFunds( + this.model.wallet_id, + this.model.address_id, + ); + return new FaucetTransaction(response.data); + } + + /** + * Returns the address ID. + * @returns {string} The address ID. + */ + public getId(): string { + return this.model.address_id; + } + + /** + * Returns the network ID. + * @returns {string} The network ID. + */ + public getNetworkId(): string { + return this.model.network_id; + } + + /** + * Returns the public key. + * @returns {string} The public key. + */ + public getPublicKey(): string { + return this.model.public_key; + } + + /** + * Returns the wallet ID. + * @returns {string} The wallet ID. + */ + public getWalletId(): string { + return this.model.wallet_id; + } + + /** + * Returns a string representation of the address. + * @returns {string} A string representing the address. + */ + public toString(): string { + return `Coinbase:Address{addressId: '${this.model.address_id}', networkId: '${this.model.network_id}', walletId: '${this.model.wallet_id}'}`; + } +} diff --git a/src/coinbase/api_error.ts b/src/coinbase/api_error.ts new file mode 100644 index 00000000..96d2683a --- /dev/null +++ b/src/coinbase/api_error.ts @@ -0,0 +1,130 @@ +import { AxiosError } from "axios"; +import { InternalError } from "./errors"; + +/** + * The API error response type. + */ +type APIErrorResponseType = { + code: string; + message: string; +}; + +/** + * A wrapper for API errors to provide more context. + */ +export class APIError extends AxiosError { + httpCode: number | null; + apiCode: string | null; + apiMessage: string | null; + + /** + * Initializes a new APIError object. + * @constructor + * @param {AxiosError} error - The Axios error. + */ + constructor(error) { + super(); + this.name = this.constructor.name; + this.httpCode = error.response ? error.response.status : null; + this.apiCode = null; + this.apiMessage = null; + + if (error.response && error.response.data) { + const body = error.response.data; + this.apiCode = body.code; + this.apiMessage = body.message; + } + } + + /** + * Creates a specific APIError based on the API error code. + * @param {AxiosError} error - The underlying error object. + * @returns {APIError} A specific APIError instance. + */ + static fromError(error: AxiosError) { + const apiError = new APIError(error); + if (!error.response || !error.response.data) { + return apiError; + } + + const body = error?.response?.data as APIErrorResponseType; + switch (body?.code) { + case "unimplemented": + return new UnimplementedError(error); + case "unauthorized": + return new UnauthorizedError(error); + case "internal": + return new InternalError(error.message); + case "not_found": + return new NotFoundError(error); + case "invalid_wallet_id": + return new InvalidWalletIDError(error); + case "invalid_address_id": + return new InvalidAddressIDError(error); + case "invalid_wallet": + return new InvalidWalletError(error); + case "invalid_address": + return new InvalidAddressError(error); + case "invalid_amount": + return new InvalidAmountError(error); + case "invalid_transfer_id": + return new InvalidTransferIDError(error); + case "invalid_page_token": + return new InvalidPageError(error); + case "invalid_page_limit": + return new InvalidLimitError(error); + case "already_exists": + return new AlreadyExistsError(error); + case "malformed_request": + return new MalformedRequestError(error); + case "unsupported_asset": + return new UnsupportedAssetError(error); + case "invalid_asset_id": + return new InvalidAssetIDError(error); + case "invalid_destination": + return new InvalidDestinationError(error); + case "invalid_network_id": + return new InvalidNetworkIDError(error); + case "resource_exhausted": + return new ResourceExhaustedError(error); + case "faucet_limit_reached": + return new FaucetLimitReachedError(error); + case "invalid_signed_payload": + return new InvalidSignedPayloadError(error); + case "invalid_transfer_status": + return new InvalidTransferStatusError(error); + default: + return apiError; + } + } + + /** + * Returns a String representation of the APIError. + * @returns {string} a String representation of the APIError + */ + toString() { + return `APIError{httpCode: ${this.httpCode}, apiCode: ${this.apiCode}, apiMessage: ${this.apiMessage}}`; + } +} + +export class UnimplementedError extends APIError {} +export class UnauthorizedError extends APIError {} +export class NotFoundError extends APIError {} +export class InvalidWalletIDError extends APIError {} +export class InvalidAddressIDError extends APIError {} +export class InvalidWalletError extends APIError {} +export class InvalidAddressError extends APIError {} +export class InvalidAmountError extends APIError {} +export class InvalidTransferIDError extends APIError {} +export class InvalidPageError extends APIError {} +export class InvalidLimitError extends APIError {} +export class AlreadyExistsError extends APIError {} +export class MalformedRequestError extends APIError {} +export class UnsupportedAssetError extends APIError {} +export class InvalidAssetIDError extends APIError {} +export class InvalidDestinationError extends APIError {} +export class InvalidNetworkIDError extends APIError {} +export class ResourceExhaustedError extends APIError {} +export class FaucetLimitReachedError extends APIError {} +export class InvalidSignedPayloadError extends APIError {} +export class InvalidTransferStatusError extends APIError {} diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index ea80bc43..00268f59 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -1,13 +1,13 @@ import globalAxios from "axios"; import fs from "fs"; -import { UsersApiFactory, User as UserModel } from "../client"; +import { User as UserModel, UsersApiFactory } from "../client"; import { BASE_PATH } from "./../client/base"; import { Configuration } from "./../client/configuration"; import { CoinbaseAuthenticator } from "./authenticator"; +import { InternalError, InvalidAPIKeyFormat, InvalidConfiguration } from "./errors"; import { ApiClients } from "./types"; import { User } from "./user"; -import { logApiResponse } from "./utils"; -import { InvalidAPIKeyFormat, InternalError, InvalidConfiguration } from "./errors"; +import { logApiResponse, registerAxiosInterceptors } from "./utils"; // The Coinbase SDK. export class Coinbase { @@ -40,10 +40,12 @@ export class Coinbase { basePath: basePath, }); const axiosInstance = globalAxios.create(); - axiosInstance.interceptors.request.use(config => - coinbaseAuthenticator.authenticateRequest(config, debugging), + registerAxiosInterceptors( + axiosInstance, + config => coinbaseAuthenticator.authenticateRequest(config, debugging), + response => logApiResponse(response, debugging), ); - axiosInstance.interceptors.response.use(response => logApiResponse(response, debugging)); + this.apiClients.user = UsersApiFactory(config, BASE_PATH, axiosInstance); } @@ -85,14 +87,10 @@ export class Coinbase { /** * Returns User object for the default user. * @returns {User} The default user. - * @throws {InternalError} If the request fails. + * @throws {APIError} If the request fails. */ async getDefaultUser(): Promise { - try { - const userResponse = await this.apiClients.user!.getCurrentUser(); - return new User(userResponse.data as UserModel, this.apiClients); - } catch (error) { - throw new InternalError(`Failed to retrieve user: ${(error as Error).message}`); - } + const userResponse = await this.apiClients.user!.getCurrentUser(); + return new User(userResponse.data as UserModel, this.apiClients); } } diff --git a/src/coinbase/faucet_transaction.ts b/src/coinbase/faucet_transaction.ts index a0da023b..877162b3 100644 --- a/src/coinbase/faucet_transaction.ts +++ b/src/coinbase/faucet_transaction.ts @@ -15,7 +15,7 @@ export class FaucetTransaction { * @throws {InternalError} If the model does not exist. */ constructor(model: FaucetTransactionModel) { - if (!model) { + if (!model?.transaction_hash) { throw new InternalError("FaucetTransaction model cannot be empty"); } this.model = model; diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts new file mode 100644 index 00000000..1ebf5fc4 --- /dev/null +++ b/src/coinbase/tests/address_test.ts @@ -0,0 +1,103 @@ +import { ethers } from "ethers"; +import { AddressesApiFactory, Address as AddressModel } from "../../client"; +import { Address } from "./../address"; +import { FaucetTransaction } from "./../faucet_transaction"; + +import MockAdapter from "axios-mock-adapter"; +import { randomUUID } from "crypto"; +import { APIError, FaucetLimitReachedError } from "../api_error"; +import { createAxiosMock } from "./utils"; +import { InternalError } from "../errors"; + +const newEthAddress = ethers.Wallet.createRandom(); + +const VALID_ADDRESS_MODEL: AddressModel = { + address_id: newEthAddress.address, + network_id: "base-sepolia", + public_key: newEthAddress.publicKey, + wallet_id: randomUUID(), +}; + +// Test suite for Address class +describe("Address", () => { + const [axiosInstance, configuration, BASE_PATH] = createAxiosMock(); + const client = AddressesApiFactory(configuration, BASE_PATH, axiosInstance); + let address, axiosMock; + + beforeAll(() => { + axiosMock = new MockAdapter(axiosInstance); + }); + + beforeEach(() => { + address = new Address(VALID_ADDRESS_MODEL, client); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + it("should initialize a new Address", () => { + expect(address).toBeInstanceOf(Address); + }); + + it("should return the network ID", () => { + expect(address.getId()).toBe(newEthAddress.address); + }); + + it("should return the address ID", () => { + expect(address.getNetworkId()).toBe(VALID_ADDRESS_MODEL.network_id); + }); + + it("should return the public key", () => { + expect(address.getPublicKey()).toBe(newEthAddress.publicKey); + }); + + it("should return the wallet ID", () => { + expect(address.getWalletId()).toBe(VALID_ADDRESS_MODEL.wallet_id); + }); + + 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`); + }); + + 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); + }); + + it("should throw an APIError when the request is unsuccesful", async () => { + axiosMock.onPost().reply(400); + await expect(address.faucet()).rejects.toThrow(APIError); + }); + + it("should throw a FaucetLimitReachedError when the faucet limit is reached", async () => { + axiosMock.onPost().reply(429, { + code: "faucet_limit_reached", + message: "Faucet limit reached", + }); + await expect(address.faucet()).rejects.toThrow(FaucetLimitReachedError); + }); + + 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", + }); + await expect(address.faucet()).rejects.toThrow(InternalError); + }); + + it("should return the correct string representation", () => { + expect(address.toString()).toBe( + `Coinbase:Address{addressId: '${VALID_ADDRESS_MODEL.address_id}', networkId: '${VALID_ADDRESS_MODEL.network_id}', walletId: '${VALID_ADDRESS_MODEL.wallet_id}'}`, + ); + }); +}); diff --git a/src/coinbase/tests/coinbase_test.ts b/src/coinbase/tests/coinbase_test.ts index ca5cb423..ae5af200 100644 --- a/src/coinbase/tests/coinbase_test.ts +++ b/src/coinbase/tests/coinbase_test.ts @@ -1,6 +1,7 @@ import { Coinbase } from "../coinbase"; import MockAdapter from "axios-mock-adapter"; import axios from "axios"; +import { APIError } from "../api_error"; const axiosMock = new MockAdapter(axios); const PATH_PREFIX = "./src/coinbase/tests/config"; @@ -54,8 +55,6 @@ describe("Coinbase tests", () => { 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`); - await expect(cbInstance.getDefaultUser()).rejects.toThrow( - "Failed to retrieve user: Request failed with status code 404", - ); + await expect(cbInstance.getDefaultUser()).rejects.toThrow(APIError); }); }); diff --git a/src/coinbase/tests/faucetTransaction_test.ts b/src/coinbase/tests/faucet_transaction_test.ts similarity index 95% rename from src/coinbase/tests/faucetTransaction_test.ts rename to src/coinbase/tests/faucet_transaction_test.ts index 0dc9ad64..78b6dab8 100644 --- a/src/coinbase/tests/faucetTransaction_test.ts +++ b/src/coinbase/tests/faucet_transaction_test.ts @@ -1,4 +1,3 @@ -import { InternalError } from "./../errors"; import { FaucetTransaction } from "../faucet_transaction"; describe("FaucetTransaction tests", () => { diff --git a/src/coinbase/tests/utils.ts b/src/coinbase/tests/utils.ts new file mode 100644 index 00000000..d9c961b7 --- /dev/null +++ b/src/coinbase/tests/utils.ts @@ -0,0 +1,24 @@ +import axios, { AxiosInstance } from "axios"; +import { Configuration } from "../../client"; +import { BASE_PATH } from "../../client/base"; +import { registerAxiosInterceptors } from "../utils"; + +/** + * AxiosMockReturn type. Represents the Axios instance, configuration, and base path. + */ +type AxiosMockType = [AxiosInstance, Configuration, string]; + +/** + * Returns an Axios instance with interceptors and configuration for testing. + * @returns {AxiosMockType} - The Axios instance, configuration, and base path. + */ +export const createAxiosMock = (): AxiosMockType => { + const axiosInstance = axios.create(); + registerAxiosInterceptors( + axiosInstance, + request => request, + response => response, + ); + const configuration = new Configuration(); + return [axiosInstance, configuration, BASE_PATH]; +}; diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index d5e0ad34..d0e175de 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -1,6 +1,23 @@ import { AxiosPromise, AxiosRequestConfig } from "axios"; import { User as UserModel } from "./../client/api"; +/** + * AddressAPI client type definition. + */ +export type AddressAPIClient = { + /** + * Requests faucet funds for the address. + * @param {string} walletId - The wallet ID. + * @param {string} addressId - The address ID. + * @returns {Promise<{ data: { transaction_hash: string } }>} - The transaction hash + * @throws {AxiosError} If the request fails. + */ + requestFaucetFunds( + walletId: string, + addressId: string, + ): Promise<{ data: { transaction_hash: string } }>; +}; + /** * UserAPI client type definition. */ @@ -9,7 +26,7 @@ export type UserAPIClient = { * Retrieves the current user. * @param {AxiosRequestConfig} [options] - Axios request options. * @returns {AxiosPromise} - A promise resolving to the User model. - * @throws {Error} If the request fails. + * @throws {AxiosError} If the request fails. */ getCurrentUser(options?: AxiosRequestConfig): AxiosPromise; }; diff --git a/src/coinbase/utils.ts b/src/coinbase/utils.ts index 42923843..8e34b314 100644 --- a/src/coinbase/utils.ts +++ b/src/coinbase/utils.ts @@ -1,4 +1,6 @@ -import { AxiosResponse } from "axios"; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Axios, AxiosResponse, InternalAxiosRequestConfig } from "axios"; +import { APIError } from "./api_error"; /** * Prints Axios response to the console for debugging purposes. @@ -20,3 +22,36 @@ export const logApiResponse = (response: AxiosResponse, debugging = false): Axio } return response; }; + +/** + * Axios Request interceptor function type. + * @param {InternalAxiosRequestConfig} value - The Axios request configuration. + * @returns {InternalAxiosRequestConfig} The modified Axios request configuration. + */ +type RequestFunctionType = ( + value: InternalAxiosRequestConfig, +) => Promise | InternalAxiosRequestConfig; + +/** + * Axios Response interceptor function type. + * @param {AxiosResponse} value - The Axios response object. + * @returns {AxiosResponse} The modified Axios response object. + */ +type ResponseFunctionType = (value: AxiosResponse) => AxiosResponse; + +/** + * + * @param {Axios} axiosInstance - The Axios instance to register the interceptors. + * @param {RequestFunctionType} requestFn - The request interceptor function. + * @param {ResponseFunctionType} responseFn - The response interceptor function. + */ +export const registerAxiosInterceptors = ( + axiosInstance: Axios, + requestFn: RequestFunctionType, + responseFn: ResponseFunctionType, +) => { + axiosInstance.interceptors.request.use(requestFn); + axiosInstance.interceptors.response.use(responseFn, error => { + return Promise.reject(APIError.fromError(error)); + }); +};