diff --git a/.eslintrc.json b/.eslintrc.json index 05df6a1d..2c50d540 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,7 +3,8 @@ "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", - "plugin:prettier/recommended" + "plugin:prettier/recommended", + "plugin:jsdoc/recommended" ], "plugins": ["@typescript-eslint", "prettier"], "env": { @@ -16,7 +17,34 @@ }, "rules": { "multiline-comment-style": ["error", "starred-block"], - "prettier/prettier": "error" + "prettier/prettier": "error", + "jsdoc/tag-lines": ["error", "any", { "startLines": 1 }], + "jsdoc/check-alignment": "error", + "jsdoc/no-undefined-types": "off", + "jsdoc/check-param-names": "error", + "jsdoc/check-tag-names": "error", + "jsdoc/check-types": "error", + "jsdoc/implements-on-classes": "error", + "jsdoc/require-description": "error", + "jsdoc/require-jsdoc": [ + "error", + { + "require": { + "FunctionDeclaration": true, + "MethodDefinition": true, + "ClassDeclaration": true, + "ArrowFunctionExpression": false, + "FunctionExpression": false + } + } + ], + "jsdoc/require-param": "error", + "jsdoc/require-param-description": "error", + "jsdoc/require-param-type": "off", + "jsdoc/require-returns": "error", + "jsdoc/require-returns-description": "error", + "jsdoc/require-returns-type": "off", + "jsdoc/require-hyphen-before-param-description": ["error", "always"] }, - "ignorePatterns": ["src/**/__tests__/**", "src/**/*.test.ts"] + "ignorePatterns": ["src/**/__tests__/**", "src/**/*.test.ts", "src/client/**"] } diff --git a/package.json b/package.json index 3f7dc00c..b0cc16f2 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "lint": "eslint -c .eslintrc.json src/**/*.ts", - "lint-fix": "eslint -c .eslintrc.json src/**/*.ts --fix", + "lint": "eslint -c .eslintrc.json src/coinbase/*.ts", + "lint-fix": "eslint -c .eslintrc.json src/coinbase/*.ts --fix", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format-check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", "check": "tsc --noEmit", @@ -39,6 +39,7 @@ "axios-mock-adapter": "^1.22.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", + "eslint-plugin-jsdoc": "^48.2.5", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.7.0", "mock-fs": "^5.2.0", diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts index e8a30b50..af080f41 100644 --- a/src/coinbase/address.ts +++ b/src/coinbase/address.ts @@ -1,22 +1,23 @@ import { Address as AddressModel } from "../client"; import { InternalError } from "./errors"; import { FaucetTransaction } from "./faucet_transaction"; -import { AddressClient } from "./types"; +import { AddressAPIClient } from "./types"; /** - * Class representing an Address in the Coinbase SDK. + * A representation of a blockchain address, which is a user-controlled account on a network. */ export class Address { private model: AddressModel; - private client: AddressClient; + private client: AddressAPIClient; /** - * Creates an instance of Address. + * Initializes a new Address instance. + * * @param {AddressModel} model - The address model data. - * @param {AddressClient} client - The API client to interact with address-related endpoints. + * @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: AddressClient) { + constructor(model: AddressModel, client: AddressAPIClient) { if (!model) { throw new InternalError("Address model cannot be empty"); } @@ -29,24 +30,23 @@ export class Address { /** * 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 { - try { - const response = await this.client.requestFaucetFunds( - this.model.wallet_id, - this.model.address_id, - ); - return new FaucetTransaction(response.data); - } catch (e) { - throw new Error(`Failed to request faucet funds`); - } + const response = await this.client.requestFaucetFunds( + this.model.wallet_id, + this.model.address_id, + ); + return new FaucetTransaction(response.data); } /** - * Gets the address ID. + * Returns the address ID. + * * @returns {string} The address ID. */ public getId(): string { @@ -54,7 +54,8 @@ export class Address { } /** - * Gets the network ID. + * Returns the network ID. + * * @returns {string} The network ID. */ public getNetworkId(): string { @@ -62,7 +63,8 @@ export class Address { } /** - * Gets the public key. + * Returns the public key. + * * @returns {string} The public key. */ public getPublicKey(): string { @@ -70,7 +72,8 @@ export class Address { } /** - * Gets the wallet ID. + * Returns the wallet ID. + * * @returns {string} The wallet ID. */ public getWalletId(): string { @@ -79,6 +82,7 @@ export class Address { /** * Returns a string representation of the address. + * * @returns {string} A string representing the address. */ public toString(): string { diff --git a/src/coinbase/api_error.ts b/src/coinbase/api_error.ts new file mode 100644 index 00000000..7fc89e2a --- /dev/null +++ b/src/coinbase/api_error.ts @@ -0,0 +1,134 @@ +/* eslint-disable jsdoc/require-jsdoc */ +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. + * + * @class + * @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/authenticator.ts b/src/coinbase/authenticator.ts index 83dfcee5..525f9f1a 100644 --- a/src/coinbase/authenticator.ts +++ b/src/coinbase/authenticator.ts @@ -5,14 +5,16 @@ import { InvalidAPIKeyFormat } from "./errors"; const pemHeader = "-----BEGIN EC PRIVATE KEY-----"; const pemFooter = "-----END EC PRIVATE KEY-----"; -/* A class that builds JWTs for authenticating with the Coinbase Platform APIs. */ +/** + * A class that builds JWTs for authenticating with the Coinbase Platform APIs. + */ export class CoinbaseAuthenticator { private apiKey: string; private privateKey: string; /** * Initializes the Authenticator. - * @constructor + * * @param {string} apiKey - The API key name. * @param {string} privateKey - The private key associated with the API key. */ @@ -23,6 +25,7 @@ export class CoinbaseAuthenticator { /** * Middleware to intercept requests and add JWT to Authorization header. + * * @param {InternalAxiosRequestConfig} config - The request configuration. * @param {boolean} debugging - Flag to enable debugging. * @returns {Promise} The request configuration with the Authorization header added. @@ -44,6 +47,7 @@ export class CoinbaseAuthenticator { /** * Builds the JWT for the given API endpoint URL. + * * @param {string} url - URL of the API endpoint. * @param {string} method - HTTP method of the request. * @returns {Promise} JWT token. @@ -93,6 +97,7 @@ export class CoinbaseAuthenticator { /** * Extracts the PEM key from the given private key string. + * * @param {string} privateKeyString - The private key string. * @returns {string} The PEM key. * @throws {InvalidAPIKeyFormat} If the private key string is not in the correct format. @@ -109,6 +114,7 @@ export class CoinbaseAuthenticator { /** * Generates a random nonce for the JWT. + * * @returns {string} The generated nonce. */ private nonce(): string { diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index ea80bc43..65da17f3 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -1,25 +1,37 @@ 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. +/** + * The Coinbase SDK. + */ export class Coinbase { + /** + * The list of supported networks. + * + * @constant + */ + static networkList = { + BaseSepolia: "base_sepolia", + }; + apiClients: ApiClients = {}; /** * Initializes the Coinbase SDK. - * @constructor - * @param {string} apiKeyName - The API key name. - * @param {string} privateKey - The private key associated with the API key. - * @param {boolean} debugging - If true, logs API requests and responses to the console. - * @param {string} basePath - The base path for the API. + * + * @class + * @param apiKeyName - The API key name. + * @param privateKey - The private key associated with the API key. + * @param debugging - If true, logs API requests and responses to the console. + * @param basePath - The base path for the API. * @throws {InternalError} If the configuration is invalid. * @throws {InvalidAPIKeyFormat} If not able to create JWT token. */ @@ -40,16 +52,21 @@ 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); } /** * Reads the API key and private key from a JSON file and initializes the Coinbase SDK. - * @param {string} filePath - The path to the JSON file containing the API key and private key. + * + * @param filePath - The path to the JSON file containing the API key and private key. + * @param debugging - If true, logs API requests and responses to the console. + * @param basePath - The base path for the API. * @returns {Coinbase} A new instance of the Coinbase SDK. * @throws {InvalidAPIKeyFormat} If the file does not exist or the configuration values are missing/invalid. * @throws {InvalidConfiguration} If the configuration is invalid. @@ -84,15 +101,12 @@ 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/errors.ts b/src/coinbase/errors.ts index 94d50f0a..af0431e6 100644 --- a/src/coinbase/errors.ts +++ b/src/coinbase/errors.ts @@ -1,12 +1,12 @@ /** * InvalidaAPIKeyFormat error is thrown when the API key format is invalid. - * @extends {Error} */ export class InvalidAPIKeyFormat extends Error { static DEFAULT_MESSAGE = "Invalid API key format"; /** * Initializes a new InvalidAPIKeyFormat instance. + * * @param message - The error message. */ constructor(message: string = InvalidAPIKeyFormat.DEFAULT_MESSAGE) { @@ -18,6 +18,26 @@ export class InvalidAPIKeyFormat extends Error { } } +/** + * ArgumentError is thrown when an argument is invalid. + */ +export class ArgumentError extends Error { + static DEFAULT_MESSAGE = "Argument Error"; + + /** + * Initializes a new ArgumentError instance. + * + * @param message - The error message. + */ + constructor(message: string = ArgumentError.DEFAULT_MESSAGE) { + super(message); + this.name = "ArgumentError"; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ArgumentError); + } + } +} + /** * InternalError is thrown when there is an internal error in the SDK. */ @@ -26,6 +46,7 @@ export class InternalError extends Error { /** * Initializes a new InternalError instance. + * * @param message - The error message. */ constructor(message: string = InternalError.DEFAULT_MESSAGE) { @@ -45,6 +66,7 @@ export class InvalidConfiguration extends Error { /** * Initializes a new InvalidConfiguration instance. + * * @param message - The error message. */ constructor(message: string = InvalidConfiguration.DEFAULT_MESSAGE) { diff --git a/src/coinbase/faucet_transaction.ts b/src/coinbase/faucet_transaction.ts index a0da023b..8ef0e226 100644 --- a/src/coinbase/faucet_transaction.ts +++ b/src/coinbase/faucet_transaction.ts @@ -10,12 +10,13 @@ export class FaucetTransaction { /** * Creates a new FaucetTransaction instance. * Do not use this method directly - instead, use Address.faucet(). - * @constructor + * + * @class * @param {FaucetTransactionModel} model - The FaucetTransaction model. * @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; @@ -23,6 +24,7 @@ export class FaucetTransaction { /** * Returns the transaction hash. + * * @returns {string} The transaction hash. */ public getTransactionHash(): string { @@ -31,6 +33,7 @@ export class FaucetTransaction { /** * Returns the link to the transaction on the blockchain explorer. + * * @returns {string} The link to the transaction on the blockchain explorer */ public getTransactionLink(): string { @@ -40,6 +43,7 @@ export class FaucetTransaction { /** * Returns a string representation of the FaucetTransaction. + * * @returns {string} A string representation of the FaucetTransaction. */ public toString(): string { diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index f4a987f7..b817e266 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -1,60 +1,104 @@ +import { ethers } from "ethers"; import { AddressesApiFactory, Address as AddressModel } from "../../client"; import { Address } from "./../address"; import { FaucetTransaction } from "./../faucet_transaction"; -import axios from "axios"; import MockAdapter from "axios-mock-adapter"; +import { randomUUID } from "crypto"; +import { APIError, FaucetLimitReachedError } from "../api_error"; +import { Coinbase } from "../coinbase"; +import { InternalError } from "../errors"; +import { createAxiosMock } from "./utils"; -const axiosMock = new MockAdapter(axios); +const newEthAddress = ethers.Wallet.createRandom(); const VALID_ADDRESS_MODEL: AddressModel = { - address_id: "mocked_address_id", - network_id: "mocked_network_id", - public_key: "mocked_public_key", - wallet_id: "mocked_wallet_id", + address_id: newEthAddress.address, + network_id: Coinbase.networkList.BaseSepolia, + public_key: newEthAddress.publicKey, + wallet_id: randomUUID(), }; // Test suite for Address class describe("Address", () => { - const client = AddressesApiFactory(); + const [axiosInstance, configuration, BASE_PATH] = createAxiosMock(); + const client = AddressesApiFactory(configuration, BASE_PATH, axiosInstance); + let address, axiosMock; - it("should create an Address instance", () => { - const address = new Address(VALID_ADDRESS_MODEL, client); + 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); - expect(address.getId()).toBe("mocked_address_id"); - expect(address.getNetworkId()).toBe("mocked_network_id"); - expect(address.getPublicKey()).toBe("mocked_public_key"); - expect(address.getWalletId()).toBe("mocked_wallet_id"); }); - it("should throw an InternalError if model is not provided", () => { + 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 if client is not provided", () => { + 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 faucet funds and return a FaucetTransaction", async () => { + it("should request funds from the faucet and returns the faucet transaction", async () => { + const transactionHash = "0xdeadbeef"; axiosMock.onPost().reply(200, { - transaction_hash: "mocked_transaction_hash", + transaction_hash: transactionHash, }); - const address = new Address(VALID_ADDRESS_MODEL, client); const faucetTransaction = await address.faucet(); expect(faucetTransaction).toBeInstanceOf(FaucetTransaction); - expect(faucetTransaction.getTransactionHash()).toBe("mocked_transaction_hash"); + expect(faucetTransaction.getTransactionHash()).toBe(transactionHash); }); - it("should throw an error if faucet request fails", async () => { + it("should throw an APIError when the request is unsuccesful", async () => { axiosMock.onPost().reply(400); - const address = new Address(VALID_ADDRESS_MODEL, client); - await expect(address.faucet()).rejects.toThrow("Failed to request faucet funds"); + 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", () => { - const address = new Address(VALID_ADDRESS_MODEL, client); expect(address.toString()).toBe( - "Coinbase:Address{addressId: 'mocked_address_id', networkId: 'mocked_network_id', walletId: 'mocked_wallet_id'}", + `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/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/tests/wallet_test.ts b/src/coinbase/tests/wallet_test.ts new file mode 100644 index 00000000..8d8f9add --- /dev/null +++ b/src/coinbase/tests/wallet_test.ts @@ -0,0 +1,75 @@ +import MockAdapter from "axios-mock-adapter"; +import * as bip39 from "bip39"; +import { randomUUID } from "crypto"; +import { AddressesApiFactory, WalletsApiFactory } from "../../client"; +import { Coinbase } from "../coinbase"; +import { ArgumentError } from "../errors"; +import { Wallet } from "../wallet"; +import { createAxiosMock } from "./utils"; + +const walletId = randomUUID(); +const VALID_WALLET_MODEL = { + id: randomUUID(), + network_id: Coinbase.networkList.BaseSepolia, + default_address: { + wallet_id: walletId, + address_id: "0xdeadbeef", + public_key: "0x1234567890", + network_id: Coinbase.networkList.BaseSepolia, + }, +}; + +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); + axiosMock.onPost().reply(200, VALID_WALLET_MODEL).onGet().reply(200, VALID_WALLET_MODEL); + wallet = await Wallet.init(VALID_WALLET_MODEL, client, seed, 2); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + describe("should initializes a new Wallet", () => { + it("should return a Wallet instance", async () => { + expect(wallet).toBeInstanceOf(Wallet); + }); + it("should return the correct wallet ID", async () => { + expect(wallet.getId()).toBe(VALID_WALLET_MODEL.id); + }); + it("should return the correct network ID", async () => { + expect(wallet.getNetworkId()).toBe(Coinbase.networkList.BaseSepolia); + }); + it("should return the correct default address", async () => { + expect(wallet.defaultAddress()?.getId()).toBe(VALID_WALLET_MODEL.default_address.address_id); + }); + + it("should derive the correct number of addresses", async () => { + expect(wallet.addresses.length).toBe(2); + }); + }); + + it("should return the correct string representation", async () => { + const wallet = await Wallet.init(VALID_WALLET_MODEL, client); + expect(wallet.toString()).toBe( + `Wallet{id: '${VALID_WALLET_MODEL.id}', network_id: 'base_sepolia'}`, + ); + }); + + 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); + }); +}); diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index 46395f7c..f8d53d82 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -1,12 +1,36 @@ -import { AxiosPromise, AxiosRequestConfig } from "axios"; -import { User as UserModel } from "./../client/api"; +import { AxiosPromise, AxiosRequestConfig, RawAxiosRequestConfig } from "axios"; +import { + Address, + CreateWalletRequest, + User as UserModel, + Wallet as WalletModel, +} from "./../client/api"; + +/** + * WalletAPI client type definition. + */ +export type WalletAPIClient = { + /** + * Create a new wallet scoped to the user. + * + * @class + * @param {CreateWalletRequest} [createWalletRequest] - The wallet creation request. + * @param {RawAxiosRequestConfig} [options] - Axios request options. + * @throws {RequiredError} + */ + createWallet: ( + createWalletRequest?: CreateWalletRequest, + options?: RawAxiosRequestConfig, + ) => AxiosPromise; +}; /** * AddressAPI client type definition. */ -export type AddressClient = { +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 @@ -16,6 +40,21 @@ export type AddressClient = { walletId: string, addressId: string, ): Promise<{ data: { transaction_hash: string } }>; + + /** + * Get address + * + * @summary Get address by onchain address + * @param {string} walletId - The ID of the wallet the address belongs to. + * @param {string} addressId - The onchain address of the address that is being fetched. + * @param {AxiosRequestConfig} [options] - Axios request options. + * @throws {RequiredError} + */ + getAddress( + walletId: string, + addressId: string, + options?: AxiosRequestConfig, + ): AxiosPromise
; }; /** @@ -24,9 +63,10 @@ export type AddressClient = { 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; }; @@ -36,9 +76,7 @@ export type UserAPIClient = { * Represents the set of API clients available in the SDK. */ export type ApiClients = { - /** - * The User API client. - * @type {UserAPIClient} - */ user?: UserAPIClient; + address?: AddressAPIClient; + wallet?: WalletAPIClient; }; diff --git a/src/coinbase/user.ts b/src/coinbase/user.ts index 59d063f1..c5fab06c 100644 --- a/src/coinbase/user.ts +++ b/src/coinbase/user.ts @@ -11,6 +11,7 @@ export class User { /** * Initializes a new User instance. + * * @param {UserModel} user - The user model. * @param {ApiClients} client - The API clients. */ @@ -21,6 +22,7 @@ export class User { /** * Returns the user's ID. + * * @returns {string} The user's ID. */ public getId(): string { @@ -29,6 +31,7 @@ export class User { /** * Returns a string representation of the User. + * * @returns {string} The string representation of the User. */ toString(): string { diff --git a/src/coinbase/utils.ts b/src/coinbase/utils.ts index 42923843..98f2fa04 100644 --- a/src/coinbase/utils.ts +++ b/src/coinbase/utils.ts @@ -1,9 +1,13 @@ -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. + * * @param response - The Axios response object. * @param debugging - Flag to enable or disable logging. + * @returns The Axios response object. */ export const logApiResponse = (response: AxiosResponse, debugging = false): AxiosResponse => { if (debugging) { @@ -20,3 +24,49 @@ 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; + +/** + * Registers request and response interceptors to an Axios instance. + * + * @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)); + }); +}; + +/** + * Converts a Uint8Array to a hex string. + * + * @param {Uint8Array} key - The key to convert. + * @returns {string} The converted hex string. + */ +export const convertStringToHex = (key: Uint8Array): string => { + return Buffer.from(key).toString("hex"); +}; diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts new file mode 100644 index 00000000..b43be90e --- /dev/null +++ b/src/coinbase/wallet.ts @@ -0,0 +1,161 @@ +import { HDKey } from "@scure/bip32"; +import * as bip39 from "bip39"; +import { ethers, Wallet as ETHWallet } from "ethers"; +import { Address as AddressModel, Wallet as WalletModel } from "../client"; +import { Address } from "./address"; +import { ArgumentError, InternalError } from "./errors"; +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, + * list their balances, and transfer Assets to other Addresses. Wallets should be created through User.createWallet or User.importWallet. + */ +export class Wallet { + private model: WalletModel; + private client: WalletClients; + + private master: HDKey; + private addresses: Address[] = []; + private readonly addressPathPrefix = "m/44'/60'/0'/0"; + private addressIndex = 0; + + /** + * Private constructor to prevent direct instantiation outside of factory method. Use Wallet.init instead. + * + * @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) { + this.model = model; + this.client = client; + this.master = master; + } + + /** + * Returns a new Wallet object. Do not use this method directly. Instead, use User.createWallet or User.importWallet. + * + * @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. + * @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 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); + + for (let i = 0; i < addressCount; i++) { + await wallet.deriveAddress(); + } + + return wallet; + } + + /** + * Derives a key for an already registered Address in the Wallet. + * + * @returns The derived key. + */ + private deriveKey(): ETHWallet { + const derivedKey = this.master.derive(`${this.addressPathPrefix}/${this.addressIndex++}`); + if (!derivedKey?.privateKey) { + throw new InternalError("Failed to derive key"); + } + return new ethers.Wallet(convertStringToHex(derivedKey.privateKey)); + } + + /** + * Derives an already registered Address in the Wallet. + * + * @throws {InternalError} - If address derivation or caching fails. + * @throws {APIError} - If the request fails. + * @returns {Promise} A promise that resolves when the address is derived. + */ + private async deriveAddress(): Promise { + const key = this.deriveKey(); + const response = await this.client.address.getAddress(this.model.id!, key.address); + this.cacheAddress(response.data); + } + + /** + * Caches an Address on the client-side and increments the address index. + * + * @param address - The AddressModel to cache. + * @throws {InternalError} If the address is not provided. + * @returns {void} + */ + private cacheAddress(address: AddressModel): void { + this.addresses.push(new Address(address, this.client.address!)); + this.addressIndex++; + } + + /** + * Returns the Network ID of the Wallet. + * + * @returns The network ID. + */ + public getNetworkId(): string { + return this.model.network_id; + } + + /** + * Returns the wallet ID. + * + * @returns The wallet ID. + */ + public getId(): string | undefined { + return this.model.id; + } + + /** + * Returns the default address of the Wallet. + * + * @returns The default address + */ + public defaultAddress(): Address | undefined { + return this.model.default_address + ? new Address(this.model.default_address, this.client.address!) + : undefined; + } + + /** + * Returns a String representation of the Wallet. + * + * @returns a String representation of the Wallet + */ + public toString(): string { + return `Wallet{id: '${this.model.id}', network_id: '${this.model.network_id}'}`; + } +} diff --git a/yarn.lock b/yarn.lock index 2857b342..9428b2ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -310,6 +310,18 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@es-joy/jsdoccomment@~0.43.0": + version "0.43.0" + resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.43.0.tgz#35c295cadd0a939d1a3a6cd1548f66ec76d38870" + integrity sha512-Q1CnsQrytI3TlCB1IVWXWeqUIPGVEKGaE7IbVdt13Nq/3i0JESAkQQERrfiQkmlpijl+++qyqPgaS31Bvc1jRQ== + dependencies: + "@types/eslint" "^8.56.5" + "@types/estree" "^1.0.5" + "@typescript-eslint/types" "^7.2.0" + comment-parser "1.4.1" + esquery "^1.5.0" + jsdoc-type-pratt-parser "~4.0.0" + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -745,6 +757,19 @@ dependencies: "@babel/types" "^7.20.7" +"@types/eslint@^8.56.5": + version "8.56.10" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.10.tgz#eb2370a73bf04a901eeba8f22595c7ee0f7eb58d" + integrity sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*", "@types/estree@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== + "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" @@ -779,7 +804,7 @@ expect "^29.0.0" pretty-format "^29.0.0" -"@types/json-schema@^7.0.15": +"@types/json-schema@*", "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -883,6 +908,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.8.0.tgz#1fd2577b3ad883b769546e2d1ef379f929a7091d" integrity sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw== +"@typescript-eslint/types@^7.2.0": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.9.0.tgz#b58e485e4bfba055659c7e683ad4f5f0821ae2ec" + integrity sha512-oZQD9HEWQanl9UfsbGVcZ2cGaR0YT5476xfWE0oE5kQa2sNK2frxOlkeacLOTh9po4AlUT5rtkGyYM5kew0z5w== + "@typescript-eslint/typescript-estree@7.8.0": version "7.8.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.8.0.tgz#b028a9226860b66e623c1ee55cc2464b95d2987c" @@ -997,6 +1027,11 @@ anymatch@^3.0.3: normalize-path "^3.0.0" picomatch "^2.0.4" +are-docs-informative@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/are-docs-informative/-/are-docs-informative-0.0.2.tgz#387f0e93f5d45280373d387a59d34c96db321963" + integrity sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig== + arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -1192,6 +1227,11 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +builtin-modules@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" + integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1294,6 +1334,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +comment-parser@1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.4.1.tgz#bdafead37961ac079be11eb7ec65c4d021eaf9cc" + integrity sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1452,6 +1497,21 @@ eslint-config-prettier@^9.1.0: resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz#31af3d94578645966c082fcb71a5846d3c94867f" integrity sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw== +eslint-plugin-jsdoc@^48.2.5: + version "48.2.5" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.2.5.tgz#66ec712632852faa15065a094342786858f13c49" + integrity sha512-ZeTfKV474W1N9niWfawpwsXGu+ZoMXu4417eBROX31d7ZuOk8zyG66SO77DpJ2+A9Wa2scw/jRqBPnnQo7VbcQ== + dependencies: + "@es-joy/jsdoccomment" "~0.43.0" + are-docs-informative "^0.0.2" + comment-parser "1.4.1" + debug "^4.3.4" + escape-string-regexp "^4.0.0" + esquery "^1.5.0" + is-builtin-module "^3.2.1" + semver "^7.6.1" + spdx-expression-parse "^4.0.0" + eslint-plugin-prettier@^5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz#17cfade9e732cef32b5f5be53bd4e07afd8e67e1" @@ -1531,7 +1591,7 @@ esprima@^4.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.4.2: +esquery@^1.4.2, esquery@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== @@ -1895,6 +1955,13 @@ is-buffer@^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" + integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A== + dependencies: + builtin-modules "^3.3.0" + is-core-module@^2.13.0: version "2.13.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" @@ -2375,6 +2442,11 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsdoc-type-pratt-parser@~4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz#136f0571a99c184d84ec84662c45c29ceff71114" + integrity sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ== + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -2897,7 +2969,7 @@ semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.3, semver@^7.5.4, semver@^7.6.0: +semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.1: version "7.6.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== @@ -2952,6 +3024,24 @@ source-map@^0.6.0, source-map@^0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +spdx-exceptions@^2.1.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz#5d607d27fc806f66d7b64a766650fa890f04ed66" + integrity sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w== + +spdx-expression-parse@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz#a23af9f3132115465dac215c099303e4ceac5794" + integrity sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.17" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz#887da8aa73218e51a1d917502d79863161a93f9c" + integrity sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"