From fdb1ab6f0dce710b7d4044cb424f4a36dea5108e Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Wed, 20 Nov 2024 10:57:06 -0500 Subject: [PATCH 01/13] Fund operation client --- src/client/api.ts | 1 + src/coinbase/types.ts | 73 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/client/api.ts b/src/client/api.ts index 771e15ec..941154cf 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -6523,6 +6523,7 @@ export interface FundApiInterface { * @memberof FundApiInterface */ listFundOperations(walletId: string, addressId: string, limit?: number, page?: string, options?: RawAxiosRequestConfig): AxiosPromise; + } /** diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index 61a1131c..7557005c 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -51,11 +51,16 @@ import { SmartContractList, CreateSmartContractRequest, SmartContract as SmartContractModel, + FundOperation as FundOperationModel, + FundQuote as FundQuoteModel, DeploySmartContractRequest, WebhookEventTypeFilter, CreateWalletWebhookRequest, ReadContractRequest, SolidityValue, + FundOperationList, + CreateFundOperationRequest, + CreateFundQuoteRequest, } from "./../client/api"; import { Address } from "./address"; import { Wallet } from "./wallet"; @@ -1389,6 +1394,74 @@ export interface SmartContractAPIClient { ): AxiosPromise; } +export interface FundOperationApiClient { + /** + * List fund operations + * + * @param walletId - The ID of the wallet the address belongs to. + * @param addressId - The ID of the address to list fund operations for. + * @param limit - A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10. + * @param page - A cursor for pagination across multiple pages of results. Don\'t include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results. + * @param options - Axios request options + * @throws {APIError} If the request fails + */ + listFundOperations( + walletId: string, + addressId: string, + limit?: number, + page?: string, + options?: RawAxiosRequestConfig, + ): AxiosPromise; + + /** + * Get a fund operation + * + * @param walletId - The ID of the wallet the address belongs to. + * @param addressId - The ID of the address the fund operation belongs to. + * @param fundOperationId - The ID of the fund operation to retrieve + * @param options - Axios request options + * @throws {APIError} If the request fails + */ + getFundOperation( + walletId: string, + addressId: string, + fundOperationId: string, + options?: RawAxiosRequestConfig, + ): AxiosPromise; + + /** + * Create a fund operation + * + * @param walletId - The ID of the wallet to create the fund operation for + * @param addressId - The ID of the address to create the fund operation for + * @param createFundOperationRequest - The request body containing the fund operation details + * @param options - Axios request options + * @throws {APIError} If the request fails + */ + createFundOperation( + walletId: string, + addressId: string, + createFundOperationRequest: CreateFundOperationRequest, + options?: RawAxiosRequestConfig, + ): AxiosPromise; + + /** + * Create a fund operation quote + * + * @param walletId - The ID of the wallet the address belongs to. + * @param addressId - The ID of the address to create the fund operation quote for. + * @param createFundQuoteRequest - The request body containing the fund operation quote details. + * @param options - Axios request options. + * @throws {APIError} If the request fails. + */ + createFundQuote( + walletId: string, + addressId: string, + createFundQuoteRequest: CreateFundQuoteRequest, + options?: RawAxiosRequestConfig, + ): AxiosPromise; +} + /** * Options for pagination on list methods. */ From 73900430622eeea710baeb7f500f341de147f678 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Wed, 20 Nov 2024 11:48:12 -0500 Subject: [PATCH 02/13] fund operation, fund quote, crypto amount --- src/coinbase/crypto_amount.ts | 102 +++++++++++++ src/coinbase/fund_operation.ts | 256 +++++++++++++++++++++++++++++++++ src/coinbase/fund_quote.ts | 162 +++++++++++++++++++++ src/coinbase/types.ts | 1 + 4 files changed, 521 insertions(+) create mode 100644 src/coinbase/crypto_amount.ts create mode 100644 src/coinbase/fund_operation.ts create mode 100644 src/coinbase/fund_quote.ts diff --git a/src/coinbase/crypto_amount.ts b/src/coinbase/crypto_amount.ts new file mode 100644 index 00000000..b2ccde9d --- /dev/null +++ b/src/coinbase/crypto_amount.ts @@ -0,0 +1,102 @@ +import Decimal from "decimal.js"; +import { CryptoAmount as CryptoAmountModel } from "../client/api"; +import { Asset } from "./asset"; + +/** + * A representation of a CryptoAmount that includes the amount and asset. + */ +export class CryptoAmount { + private amount: Decimal; + private assetObj: Asset; + private assetId: string; + + /** + * Creates a new CryptoAmount instance. + * + * @param amount - The amount of the Asset + * @param asset - The Asset + * @param assetId - Optional Asset ID override + */ + constructor(amount: Decimal, asset: Asset, assetId?: string) { + if (!amount || !asset) { + throw new Error("Amount and asset cannot be empty"); + } + this.amount = amount; + this.assetObj = asset; + this.assetId = assetId || asset.getAssetId(); + } + + /** + * Converts a CryptoAmount model to a CryptoAmount. + * + * @param amountModel - The crypto amount from the API + * @returns The converted CryptoAmount object + */ + public static fromModel(amountModel: CryptoAmountModel): CryptoAmount { + const asset = Asset.fromModel(amountModel.asset); + return new CryptoAmount(asset.fromAtomicAmount(new Decimal(amountModel.amount)), asset); + } + + /** + * Converts a CryptoAmount model and asset ID to a CryptoAmount. + * This can be used to specify a non-primary denomination that we want the amount + * to be converted to. + * + * @param amountModel - The crypto amount from the API + * @param assetId - The Asset ID of the denomination we want returned + * @returns The converted CryptoAmount object + */ + public static fromModelAndAssetId(amountModel: CryptoAmountModel, assetId: string): CryptoAmount { + const asset = Asset.fromModel(amountModel.asset); + return new CryptoAmount( + asset.fromAtomicAmount(new Decimal(amountModel.amount)), + asset, + assetId, + ); + } + + /** + * Gets the amount of the Asset. + * + * @returns The amount of the Asset + */ + public getAmount(): Decimal { + return this.amount; + } + + /** + * Gets the Asset. + * + * @returns The Asset + */ + public getAsset(): Asset { + return this.assetObj; + } + + /** + * Gets the Asset ID. + * + * @returns The Asset ID + */ + public getAssetId(): string { + return this.assetId; + } + + /** + * Converts the amount to atomic units. + * + * @returns The amount in atomic units + */ + public toAtomicAmount(): bigint { + return this.assetObj.toAtomicAmount(this.amount); + } + + /** + * Returns a string representation of the CryptoAmount. + * + * @returns A string representation of the CryptoAmount + */ + public toString(): string { + return `CryptoAmount{amount: '${this.amount}', assetId: '${this.assetId}'}`; + } +} diff --git a/src/coinbase/fund_operation.ts b/src/coinbase/fund_operation.ts new file mode 100644 index 00000000..20591436 --- /dev/null +++ b/src/coinbase/fund_operation.ts @@ -0,0 +1,256 @@ +import { Decimal } from "decimal.js"; +import { FundOperation as FundOperationModel } from "../client/api"; +import { Asset } from "./asset"; +import { Coinbase } from "./coinbase"; +import { delay } from "./utils"; +import { TimeoutError } from "./errors"; +import { FundQuote } from "./fund_quote"; +import { PaginationOptions, PaginationResponse } from "./types"; + +/** + * A representation of a Fund Operation. + */ +export class FundOperation { + /** + * Fund Operation status constants. + */ + public static readonly Status = { + PENDING: "pending", + COMPLETE: "complete", + FAILED: "failed", + TERMINAL_STATES: new Set(["complete", "failed"]), + } as const; + + private model: FundOperationModel; + private asset: Asset | null = null; + + /** + * Creates a new FundOperation instance. + * + * @param model - The model representing the fund operation + */ + constructor(model: FundOperationModel) { + if (!model) { + throw new Error("Fund operation model cannot be empty"); + } + this.model = model; + } + + /** + * Create a new Fund Operation. + * + * @param walletId - The Wallet ID + * @param addressId - The Address ID + * @param amount - The amount of the Asset + * @param assetId - The Asset ID + * @param networkId - The Network ID + * @param quote - Optional Fund Quote + * @returns The new FundOperation object + */ + public static async create( + walletId: string, + addressId: string, + amount: Decimal, + assetId: string, + networkId: string, + quote?: FundQuote, + ): Promise { + const asset = await Asset.fetch(networkId, assetId); + + const createRequest = { + amount: asset.toAtomicAmount(amount).toString(), + asset_id: Asset.primaryDenomination(assetId), + }; + + if (quote) { + Object.assign(createRequest, { fund_quote_id: quote.getId() }); + } + + const response = await Coinbase.apiClients.fund!.createFundOperation( + walletId, + addressId, + createRequest, + ); + + return new FundOperation(response.data); + } + + /** + * List fund operations. + * + * @param walletId - The wallet ID + * @param addressId - The address ID + * @param options - The pagination options + * @param options.limit - The maximum number of Fund Operations to return. Limit can range between 1 and 100. + * @param options.page - The cursor for pagination across multiple pages of Fund Operations. Don't include this parameter on the first call. Use the next page value returned in a previous response to request subsequent results. + * @returns The paginated list response of fund operations + */ + public static async listFundOperations( + walletId: string, + addressId: string, + { limit = Coinbase.defaultPageLimit, page = undefined }: PaginationOptions = {}, + ): Promise> { + const data: FundOperation[] = []; + let nextPage: string | undefined; + + const response = await Coinbase.apiClients.fund!.listFundOperations( + walletId, + addressId, + limit, + page, + ); + + response.data.data.forEach(operationModel => { + data.push(new FundOperation(operationModel)); + }); + + const hasMore = response.data.has_more; + + if (hasMore) { + if (response.data.next_page) { + nextPage = response.data.next_page; + } + } + + return { + data, + hasMore, + nextPage, + }; + } + + /** + * Gets the Fund Operation ID. + * + * @returns {string} The unique identifier of the fund operation + */ + public getId(): string { + return this.model.fund_operation_id; + } + + /** + * Gets the Network ID. + * + * @returns {string} The network identifier + */ + public getNetworkId(): string { + return this.model.network_id; + } + + /** + * Gets the Wallet ID. + * + * @returns {string} The wallet identifier + */ + public getWalletId(): string { + return this.model.wallet_id; + } + + /** + * Gets the Address ID. + * + * @returns {string} The address identifier + */ + public getAddressId(): string { + return this.model.address_id; + } + + /** + * Gets the Asset. + * + * @returns {Asset} The asset associated with this operation + */ + public getAsset(): Asset { + if (!this.asset) { + this.asset = Asset.fromModel(this.model.crypto_amount.asset); + } + return this.asset; + } + + /** + * Gets the amount. + * + * @returns {Decimal} The amount in decimal format + */ + public getAmount(): Decimal { + return new Decimal(this.model.crypto_amount.amount).div( + new Decimal(10).pow(this.model.crypto_amount.asset.decimals || 0), + ); + } + + /** + * Gets the fiat amount. + * + * @returns {Decimal} The fiat amount in decimal format + */ + public getFiatAmount(): Decimal { + return new Decimal(this.model.fiat_amount.amount); + } + + /** + * Gets the fiat currency. + * + * @returns {string} The fiat currency code + */ + public getFiatCurrency(): string { + return this.model.fiat_amount.currency; + } + + /** + * Gets the status. + * + * @returns {string} The current status of the fund operation + */ + public getStatus(): string { + return this.model.status; + } + + /** + * Reloads the fund operation from the server. + * + * @returns {Promise} A promise that resolves to the updated fund operation + */ + public async reload(): Promise { + const response = await Coinbase.apiClients.fund!.getFundOperation( + this.getWalletId(), + this.getAddressId(), + this.getId(), + ); + this.model = response.data; + return this; + } + + /** + * Wait for the fund operation to complete. + * + * @param options - Options for waiting + * @param options.intervalSeconds - The interval between checks in seconds + * @param options.timeoutSeconds - The timeout in seconds + * @returns The completed fund operation + * @throws {TimeoutError} If the operation takes too long + */ + public async wait({ intervalSeconds = 0.2, timeoutSeconds = 20 } = {}): Promise { + const startTime = Date.now(); + + while (!this.isTerminalState()) { + await this.reload(); + + if (Date.now() - startTime > timeoutSeconds * 1000) { + throw new TimeoutError("Fund operation timed out"); + } + + await delay(intervalSeconds); + } + + return this; + } + + /** + * Check if the operation is in a terminal state. + * + * @returns {boolean} True if the operation is in a terminal state, false otherwise + */ + private isTerminalState(): boolean { + return FundOperation.Status.TERMINAL_STATES.has(this.getStatus()); + } +} diff --git a/src/coinbase/fund_quote.ts b/src/coinbase/fund_quote.ts new file mode 100644 index 00000000..e9e1a604 --- /dev/null +++ b/src/coinbase/fund_quote.ts @@ -0,0 +1,162 @@ +import { Decimal } from "decimal.js"; +import { FundQuote as FundQuoteModel } from "../client/api"; +import { Asset } from "./asset"; +import { CryptoAmount } from "./crypto_amount"; +import { Coinbase } from "./coinbase"; +import { FundOperation } from "./fund_operation"; + +/** + * A representation of a Fund Operation Quote. + */ +export class FundQuote { + private model: FundQuoteModel; + private asset: Asset | null = null; + + /** + * Creates a new FundQuote instance. + * + * @param model - The model representing the fund quote + */ + constructor(model: FundQuoteModel) { + this.model = model; + } + + /** + * Create a new Fund Operation Quote. + * + * @param walletId - The Wallet ID + * @param addressId - The Address ID + * @param amount - The amount of the Asset + * @param assetId - The Asset ID + * @param networkId - The Network ID + * @returns The new FundQuote object + */ + public static async create( + walletId: string, + addressId: string, + amount: Decimal, + assetId: string, + networkId: string, + ): Promise { + const asset = await Asset.fetch(networkId, assetId); + + const response = await Coinbase.apiClients.fund!.createFundQuote(walletId, addressId, { + asset_id: Asset.primaryDenomination(assetId), + amount: asset.toAtomicAmount(amount).toString(), + }); + + return new FundQuote(response.data); + } + + /** + * Gets the Fund Quote ID. + * + * @returns {string} The unique identifier of the fund quote + */ + public getId(): string { + return this.model.fund_quote_id; + } + + /** + * Gets the Network ID. + * + * @returns {string} The network identifier + */ + public getNetworkId(): string { + return this.model.network_id; + } + + /** + * Gets the Wallet ID. + * + * @returns {string} The wallet identifier + */ + public getWalletId(): string { + return this.model.wallet_id; + } + + /** + * Gets the Address ID. + * + * @returns {string} The address identifier + */ + public getAddressId(): string { + return this.model.address_id; + } + + /** + * Gets the Asset. + * + * @returns {Asset} The asset associated with this quote + */ + public getAsset(): Asset { + if (!this.asset) { + this.asset = Asset.fromModel(this.model.crypto_amount.asset); + } + return this.asset; + } + + /** + * Gets the crypto amount. + * + * @returns {CryptoAmount} The cryptocurrency amount + */ + public getAmount(): CryptoAmount { + return CryptoAmount.fromModel(this.model.crypto_amount); + } + + /** + * Gets the fiat amount. + * + * @returns {Decimal} The fiat amount in decimal format + */ + public getFiatAmount(): Decimal { + return new Decimal(this.model.fiat_amount.amount); + } + + /** + * Gets the fiat currency. + * + * @returns {string} The fiat currency code + */ + public getFiatCurrency(): string { + return this.model.fiat_amount.currency; + } + + /** + * Gets the buy fee. + * + * @returns {{ amount: string; currency: string }} The buy fee amount and currency + */ + public getBuyFee(): { amount: string; currency: string } { + return { + amount: this.model.fees.buy_fee.amount, + currency: this.model.fees.buy_fee.currency, + }; + } + + /** + * Gets the transfer fee. + * + * @returns {CryptoAmount} The transfer fee as a crypto amount + */ + public getTransferFee(): CryptoAmount { + return CryptoAmount.fromModel(this.model.fees.transfer_fee); + } + + /** + * Execute the fund quote to create a fund operation. + * + * @returns {Promise} A promise that resolves to the created fund operation + */ + public async execute(): Promise { + return FundOperation.create( + this.getWalletId(), + this.getAddressId(), + this.getAmount().getAmount(), + this.getAsset().getAssetId(), + this.getNetworkId(), + this, + ); + } +} diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index 7557005c..3ff657bd 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -726,6 +726,7 @@ export type ApiClients = { balanceHistory?: BalanceHistoryApiClient; transactionHistory?: TransactionHistoryApiClient; smartContract?: SmartContractAPIClient; + fund?: FundOperationApiClient; }; /** From 3e8cace2aabaf056c0746211d466f4fc7fbd87ba Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Wed, 20 Nov 2024 14:45:18 -0500 Subject: [PATCH 03/13] Changes --- src/coinbase/address/wallet_address.ts | 41 ++++++++++++++++++++++++++ src/coinbase/wallet.ts | 36 ++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/coinbase/address/wallet_address.ts b/src/coinbase/address/wallet_address.ts index dca794c5..5e5b7ed7 100644 --- a/src/coinbase/address/wallet_address.ts +++ b/src/coinbase/address/wallet_address.ts @@ -26,6 +26,8 @@ import { Wallet as WalletClass } from "../wallet"; import { StakingOperation } from "../staking_operation"; import { PayloadSignature } from "../payload_signature"; import { SmartContract } from "../smart_contract"; +import { FundOperation } from "../fund_operation"; +import { FundQuote } from "../fund_quote"; /** * A representation of a blockchain address, which is a wallet-controlled account on a network. @@ -747,6 +749,45 @@ export class WalletAddress extends Address { }; } + + /** + * Fund the address from your account on the Coinbase Platform. + * + * @param amount - The amount of the Asset to fund the wallet with + * @param assetId - The ID of the Asset to fund with. For Ether, eth, gwei, and wei are supported. + * @returns The created fund operation object + */ + public async fund(amount: Amount, assetId: string): Promise { + const normalizedAmount = new Decimal(amount.toString()); + + return FundOperation.create( + this.getWalletId(), + this.getId(), + normalizedAmount, + assetId, + this.getNetworkId(), + ); + } + + /** + * Get a quote for funding the address from your Coinbase platform account. + * + * @param amount - The amount to fund + * @param assetId - The ID of the Asset to fund with. For Ether, eth, gwei, and wei are supported. + * @returns The fund quote object + */ + public async quoteFund(amount: Amount, assetId: string): Promise { + const normalizedAmount = new Decimal(amount.toString()); + + return FundQuote.create( + this.getWalletId(), + this.getId(), + normalizedAmount, + assetId, + this.getNetworkId(), + ); + } + /** * Returns the address and network ID of the given destination. * diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index aa3cf085..bfa9ab5c 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -41,6 +41,8 @@ import { ContractInvocation } from "../coinbase/contract_invocation"; import { SmartContract } from "./smart_contract"; import { Webhook } from "./webhook"; import { HistoricalBalance } from "./historical_balance"; +import { FundOperation } from "./fund_operation"; +import { FundQuote } from "./fund_quote"; /** * A representation of a Wallet. Wallets come with a single default Address, but can expand to have a set of Addresses, @@ -836,6 +838,40 @@ export class Wallet { return (await this.getDefaultAddress()).deployMultiToken(options); } + /** + * Fund the wallet from your account on the Coinbase Platform. + * + * @param amount - The amount of the Asset to fund the wallet with + * @param assetId - The ID of the Asset to fund with. For Ether, eth, gwei, and wei are supported. + * @returns The created fund operation object + * @throws {Error} If the default address does not exist + */ + public async fund(amount: Amount, assetId: string): Promise { + const defaultAddress = await this.getDefaultAddress(); + if (!defaultAddress) { + throw new Error("Default address does not exist"); + } + + return defaultAddress.fund(amount, assetId); + } + + /** + * Get a quote for funding the wallet from your Coinbase platform account. + * + * @param amount - The amount to fund + * @param assetId - The ID of the Asset to fund with. For Ether, eth, gwei, and wei are supported. + * @returns The fund quote object + * @throws {Error} If the default address does not exist + */ + public async quoteFund(amount: Amount, assetId: string): Promise { + const defaultAddress = await this.getDefaultAddress(); + if (!defaultAddress) { + throw new Error("Default address does not exist"); + } + + return defaultAddress.quoteFund(amount, assetId); + } + /** * Returns a String representation of the Wallet. * From dc5077f330771447b3df2f318114d78c2987a00b Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Thu, 21 Nov 2024 17:04:32 -0500 Subject: [PATCH 04/13] WIP --- src/coinbase/coinbase.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index 0da04866..ab289acf 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -18,6 +18,7 @@ import { SmartContractsApiFactory, TransactionHistoryApiFactory, MPCWalletStakeApiFactory, + FundApiFactory, } from "../client"; import { BASE_PATH } from "./../client/base"; import { Configuration } from "./../client/configuration"; @@ -154,6 +155,7 @@ export class Coinbase { Coinbase.apiClients.balanceHistory = BalanceHistoryApiFactory(config, basePath, axiosInstance); Coinbase.apiClients.contractEvent = ContractEventsApiFactory(config, basePath, axiosInstance); Coinbase.apiClients.smartContract = SmartContractsApiFactory(config, basePath, axiosInstance); + Coinbase.apiClients.fund = FundApiFactory(config, basePath, axiosInstance); Coinbase.apiClients.transactionHistory = TransactionHistoryApiFactory( config, basePath, From dc4220f9ed1cee4fbaadc91ea1a4b8044a2f48f1 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Fri, 22 Nov 2024 15:38:50 -0500 Subject: [PATCH 05/13] WIP --- src/coinbase/crypto_amount.ts | 5 +- src/coinbase/fiat_amount.ts | 55 ++++++++++++++++++++++ src/tests/crypto_amount_test.ts | 82 +++++++++++++++++++++++++++++++++ src/tests/fiat_amount_test.ts | 41 +++++++++++++++++ src/tests/utils.ts | 36 +++++++++++++++ 5 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 src/coinbase/fiat_amount.ts create mode 100644 src/tests/crypto_amount_test.ts create mode 100644 src/tests/fiat_amount_test.ts diff --git a/src/coinbase/crypto_amount.ts b/src/coinbase/crypto_amount.ts index b2ccde9d..7ac1dd39 100644 --- a/src/coinbase/crypto_amount.ts +++ b/src/coinbase/crypto_amount.ts @@ -18,9 +18,6 @@ export class CryptoAmount { * @param assetId - Optional Asset ID override */ constructor(amount: Decimal, asset: Asset, assetId?: string) { - if (!amount || !asset) { - throw new Error("Amount and asset cannot be empty"); - } this.amount = amount; this.assetObj = asset; this.assetId = assetId || asset.getAssetId(); @@ -47,7 +44,7 @@ export class CryptoAmount { * @returns The converted CryptoAmount object */ public static fromModelAndAssetId(amountModel: CryptoAmountModel, assetId: string): CryptoAmount { - const asset = Asset.fromModel(amountModel.asset); + const asset = Asset.fromModel(amountModel.asset, assetId); return new CryptoAmount( asset.fromAtomicAmount(new Decimal(amountModel.amount)), asset, diff --git a/src/coinbase/fiat_amount.ts b/src/coinbase/fiat_amount.ts new file mode 100644 index 00000000..941dfd85 --- /dev/null +++ b/src/coinbase/fiat_amount.ts @@ -0,0 +1,55 @@ +import { FiatAmount as FiatAmountModel } from "../client/api"; + +/** + * A representation of a FiatAmount that includes the amount and currency. + */ +export class FiatAmount { + private amount: string; + private currency: string; + + /** + * Initialize a new FiatAmount. Do not use this directly, use the fromModel method instead. + * @param amount The amount in the fiat currency + * @param currency The currency code (e.g. 'USD') + */ + constructor(amount: string, currency: string) { + this.amount = amount; + this.currency = currency; + } + + /** + * Convert a FiatAmount model to a FiatAmount. + * @param fiatAmountModel The fiat amount from the API. + * @returns The converted FiatAmount object. + */ + public static fromModel(fiatAmountModel: FiatAmountModel): FiatAmount { + return new FiatAmount( + fiatAmountModel.amount, + fiatAmountModel.currency + ); + } + + /** + * Get the amount in the fiat currency. + * @returns The amount in the fiat currency. + */ + public getAmount(): string { + return this.amount; + } + + /** + * Get the currency code. + * @returns The currency code. + */ + public getCurrency(): string { + return this.currency; + } + + /** + * Get a string representation of the FiatAmount. + * @returns A string representation of the FiatAmount. + */ + public toString(): string { + return `FiatAmount(amount: '${this.amount}', currency: '${this.currency}')`; + } +} \ No newline at end of file diff --git a/src/tests/crypto_amount_test.ts b/src/tests/crypto_amount_test.ts new file mode 100644 index 00000000..3021bca7 --- /dev/null +++ b/src/tests/crypto_amount_test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from '@jest/globals'; +import Decimal from 'decimal.js'; +import { CryptoAmount } from '../coinbase/crypto_amount'; +import { Asset } from '../coinbase/asset'; +import { CryptoAmount as CryptoAmountModel } from '../client/api'; +import { Coinbase } from '../coinbase/coinbase'; +import { contractInvocationApiMock, getAssetMock, VALID_ETH_CRYPTO_AMOUNT_MODEL, VALID_USDC_CRYPTO_AMOUNT_MODEL } from './utils'; +import { ContractInvocation } from '../coinbase/contract_invocation'; + +describe('CryptoAmount', () => { + let cryptoAmountModel: CryptoAmountModel; + let cryptoAmount: CryptoAmount; + + beforeEach(() => { + cryptoAmountModel = VALID_USDC_CRYPTO_AMOUNT_MODEL; + cryptoAmount = CryptoAmount.fromModel(cryptoAmountModel); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('.fromModel', () => { + it('should correctly create CryptoAmount from model', () => { + expect(cryptoAmount).toBeInstanceOf(CryptoAmount); + expect(cryptoAmount.getAmount().equals(new Decimal(1).div(new Decimal(10).pow(6)))); + expect(cryptoAmount.getAsset().assetId).toEqual(Coinbase.assets.Usdc); + expect(cryptoAmount.getAsset().networkId).toEqual("base-sepolia"); + expect(cryptoAmount.getAsset().decimals).toEqual(6); + }); + }); + + describe('.fromModelAndAssetId', () => { + it('should correctly create CryptoAmount from model with gwei denomination', () => { + const cryptoAmount = CryptoAmount.fromModelAndAssetId(VALID_ETH_CRYPTO_AMOUNT_MODEL, Coinbase.assets.Gwei); + expect(cryptoAmount.getAmount().equals(new Decimal(1).div(new Decimal(10).pow(9)))); + expect(cryptoAmount.getAsset().assetId).toEqual(Coinbase.assets.Gwei); + expect(cryptoAmount.getAsset().networkId).toEqual("base-sepolia"); + expect(cryptoAmount.getAsset().decimals).toEqual(9); + }); + + it('should correctly create CryptoAmount from model with wei denomination', () => { + const cryptoAmount = CryptoAmount.fromModelAndAssetId(VALID_ETH_CRYPTO_AMOUNT_MODEL, Coinbase.assets.Wei); + expect(cryptoAmount.getAmount().equals(new Decimal(1))); + expect(cryptoAmount.getAsset().assetId).toEqual(Coinbase.assets.Wei); + expect(cryptoAmount.getAsset().networkId).toEqual("base-sepolia"); + expect(cryptoAmount.getAsset().decimals).toEqual(0); + }); + }); + + describe('#getAmount', () => { + it('should return the correct amount', () => { + expect(cryptoAmount.getAmount().equals(new Decimal(1).div(new Decimal(10).pow(6)))); + }); + }); + + describe('#getAsset', () => { + it('should return the correct asset', () => { + expect(cryptoAmount.getAsset().assetId).toEqual(Coinbase.assets.Usdc); + expect(cryptoAmount.getAsset().networkId).toEqual("base-sepolia"); + expect(cryptoAmount.getAsset().decimals).toEqual(6); + }); + }); + + describe('#getAssetId', () => { + it('should return the correct asset ID', () => { + expect(cryptoAmount.getAssetId()).toEqual(Coinbase.assets.Usdc); + }); + }); + + describe('#toAtomicAmount', () => { + it('should correctly convert to atomic amount', () => { + // expect(cryptoAmount.toAtomicAmount()).toEqual(new Decimal(1)) + }); + }); + + describe('#toString', () => { + it('should have correct string representation', () => { + expect(cryptoAmount.toString()).toEqual("CryptoAmount{amount: '0.000001', assetId: 'usdc'}"); + }); + }); +}); \ No newline at end of file diff --git a/src/tests/fiat_amount_test.ts b/src/tests/fiat_amount_test.ts new file mode 100644 index 00000000..f214838f --- /dev/null +++ b/src/tests/fiat_amount_test.ts @@ -0,0 +1,41 @@ +import { FiatAmount } from "../coinbase/fiat_amount"; +import { FiatAmount as FiatAmountModel } from "../client/api"; + +describe("FiatAmount", () => { + describe(".fromModel", () => { + it("should convert a FiatAmount model to a FiatAmount", () => { + const model: FiatAmountModel = { + amount: "100.50", + currency: "USD" + }; + + const fiatAmount = FiatAmount.fromModel(model); + + expect(fiatAmount.getAmount()).toBe("100.50"); + expect(fiatAmount.getCurrency()).toBe("USD"); + }); + }); + + describe("#getAmount", () => { + it("should return the correct amount", () => { + const fiatAmount = new FiatAmount("50.25", "USD"); + expect(fiatAmount.getAmount()).toBe("50.25"); + }); + }); + + describe("#getCurrency", () => { + it("should return the correct currency", () => { + const fiatAmount = new FiatAmount("50.25", "USD"); + expect(fiatAmount.getCurrency()).toBe("USD"); + }); + }); + + describe("#toString", () => { + it("should return the correct string representation", () => { + const fiatAmount = new FiatAmount("75.00", "USD"); + const expectedStr = "FiatAmount(amount: '75.00', currency: 'USD')"; + + expect(fiatAmount.toString()).toBe(expectedStr); + }); + }); +}); \ No newline at end of file diff --git a/src/tests/utils.ts b/src/tests/utils.ts index e95c3dc3..d3ea7985 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -17,6 +17,7 @@ import { PayloadSignatureStatusEnum, ContractInvocation as ContractInvocationModel, SmartContract as SmartContractModel, + CryptoAmount as CryptoAmountModel, SmartContractType, ValidatorList, Validator, @@ -32,6 +33,7 @@ import { BASE_PATH } from "../client/base"; import { Coinbase } from "../coinbase/coinbase"; import { convertStringToHex, registerAxiosInterceptors } from "../coinbase/utils"; import { HDKey } from "@scure/bip32"; +import { Asset } from "../coinbase/asset"; export const mockFn = (...args) => jest.fn(...args) as any; export const mockReturnValue = data => jest.fn().mockResolvedValue({ data }); @@ -369,6 +371,33 @@ export const VALID_SMART_CONTRACT_ERC1155_MODEL: SmartContractModel = { }, }; +const asset = Asset.fromModel({ + asset_id: Coinbase.assets.Eth, + network_id: "base-sepolia", + contract_address: "0x", + decimals: 18, +}); + +export const VALID_USDC_CRYPTO_AMOUNT_MODEL: CryptoAmountModel = { + amount: "1", + asset: { + network_id: "base-sepolia", + asset_id: Coinbase.assets.Usdc, + contract_address: "0x", + decimals: 6, + } +}; + +export const VALID_ETH_CRYPTO_AMOUNT_MODEL: CryptoAmountModel = { + amount: "1", + asset: { + network_id: "base-sepolia", + asset_id: Coinbase.assets.Eth, + contract_address: "0x", + decimals: 18, + } +}; + /** * mockStakingOperation returns a mock StakingOperation object with the provided status. * @@ -678,6 +707,13 @@ export const contractInvocationApiMock = { broadcastContractInvocation: jest.fn(), }; +export const fundOperationsApiMock = { + getFundOperation: jest.fn(), + listFundOperations: jest.fn(), + createFundOperation: jest.fn(), + cancelFundQuote: jest.fn(), +}; + export const testAllReadTypesABI = [ { type: "function", From afea07403a2a1cfce746109849c5eab79060031d Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Fri, 22 Nov 2024 16:43:55 -0500 Subject: [PATCH 06/13] WIP --- src/coinbase/fund_operation.ts | 14 +++- src/coinbase/fund_quote.ts | 12 ++- src/index.ts | 4 + src/tests/crypto_amount_test.ts | 2 +- src/tests/fund_operation_test.ts | 64 +++++++++++++++ src/tests/fund_quote_test.ts | 131 +++++++++++++++++++++++++++++++ src/tests/index_test.ts | 4 + src/tests/utils.ts | 44 ++++++++++- 8 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 src/tests/fund_operation_test.ts create mode 100644 src/tests/fund_quote_test.ts diff --git a/src/coinbase/fund_operation.ts b/src/coinbase/fund_operation.ts index 20591436..fff7c982 100644 --- a/src/coinbase/fund_operation.ts +++ b/src/coinbase/fund_operation.ts @@ -36,6 +36,16 @@ export class FundOperation { this.model = model; } + /** + * Converts a FundOperationModel into a FundOperation object. + * + * @param fundOperationModel - The FundOperation model object. + * @returns The FundOperation object. + */ + public static fromModel(fundOperationModel: FundOperationModel): FundOperation { + return new FundOperation(fundOperationModel); + } + /** * Create a new Fund Operation. * @@ -72,7 +82,7 @@ export class FundOperation { createRequest, ); - return new FundOperation(response.data); + return FundOperation.fromModel(response.data); } /** @@ -101,7 +111,7 @@ export class FundOperation { ); response.data.data.forEach(operationModel => { - data.push(new FundOperation(operationModel)); + data.push(FundOperation.fromModel(operationModel)); }); const hasMore = response.data.has_more; diff --git a/src/coinbase/fund_quote.ts b/src/coinbase/fund_quote.ts index e9e1a604..e82f8128 100644 --- a/src/coinbase/fund_quote.ts +++ b/src/coinbase/fund_quote.ts @@ -21,6 +21,16 @@ export class FundQuote { this.model = model; } + /** + * Converts a FundQuoteModel into a FundQuote object. + * + * @param fundQuoteModel - The FundQuote model object. + * @returns The FundQuote object. + */ + public static fromModel(fundQuoteModel: FundQuoteModel): FundQuote { + return new FundQuote(fundQuoteModel); + } + /** * Create a new Fund Operation Quote. * @@ -45,7 +55,7 @@ export class FundQuote { amount: asset.toAtomicAmount(amount).toString(), }); - return new FundQuote(response.data); + return FundQuote.fromModel(response.data); } /** diff --git a/src/index.ts b/src/index.ts index 3b4bb154..64302e70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,3 +28,7 @@ export * from "./coinbase/validator"; export * from "./coinbase/wallet"; export * from "./coinbase/webhook"; export * from "./coinbase/read_contract"; +export * from "./coinbase/crypto_amount"; +export * from "./coinbase/fiat_amount"; +export * from "./coinbase/fund_operation"; +export * from "./coinbase/fund_quote"; \ No newline at end of file diff --git a/src/tests/crypto_amount_test.ts b/src/tests/crypto_amount_test.ts index 3021bca7..727b0f0f 100644 --- a/src/tests/crypto_amount_test.ts +++ b/src/tests/crypto_amount_test.ts @@ -70,7 +70,7 @@ describe('CryptoAmount', () => { describe('#toAtomicAmount', () => { it('should correctly convert to atomic amount', () => { - // expect(cryptoAmount.toAtomicAmount()).toEqual(new Decimal(1)) + expect(cryptoAmount.toAtomicAmount().toString()).toEqual("1") }); }); diff --git a/src/tests/fund_operation_test.ts b/src/tests/fund_operation_test.ts new file mode 100644 index 00000000..db187b3e --- /dev/null +++ b/src/tests/fund_operation_test.ts @@ -0,0 +1,64 @@ +import { FundOperation } from "../coinbase/fund_operation"; + +describe("FundOperation", () => { + describe("constructor", () => { + it("should initialize a FundOperation object", () => {}); + it("should throw error if model is empty", () => {}); + }); + + describe(".create", () => { + it("should create a new fund operation without quote", () => {}); + it("should create a new fund operation with quote", () => {}); + }); + + describe(".listFundOperations", () => { + it("should list fund operations", () => {}); + it("should handle pagination", () => {}); + }); + + describe("#getId", () => { + it("should return the fund operation ID", () => {}); + }); + + describe("#getNetworkId", () => { + it("should return the network ID", () => {}); + }); + + describe("#getWalletId", () => { + it("should return the wallet ID", () => {}); + }); + + describe("#getAddressId", () => { + it("should return the address ID", () => {}); + }); + + describe("#getAsset", () => { + it("should return the asset", () => {}); + }); + + describe("#getAmount", () => { + it("should return the amount", () => {}); + }); + + describe("#getFiatAmount", () => { + it("should return the fiat amount", () => {}); + }); + + describe("#getFiatCurrency", () => { + it("should return the fiat currency", () => {}); + }); + + describe("#getStatus", () => { + it("should return the current status", () => {}); + }); + + describe("#reload", () => { + it("should reload the fund operation from server", () => {}); + }); + + describe("#wait", () => { + it("should wait for operation to complete", () => {}); + it("should throw timeout error if operation takes too long", () => {}); + it("should handle terminal states correctly", () => {}); + }); +}); diff --git a/src/tests/fund_quote_test.ts b/src/tests/fund_quote_test.ts new file mode 100644 index 00000000..4638b272 --- /dev/null +++ b/src/tests/fund_quote_test.ts @@ -0,0 +1,131 @@ +import { FundQuote } from "../coinbase/fund_quote"; +import { FundQuote as FundQuoteModel, Asset as AssetModel } from "../client/api"; +import { Coinbase } from "../coinbase/coinbase"; +import { VALID_FUND_QUOTE_MODEL, VALID_ASSET_MODEL, mockReturnValue, mockReturnRejectedValue, contractInvocationApiMock, fundOperationsApiMock, assetApiMock } from "./utils"; +import { Asset } from "../coinbase/asset"; +import Decimal from "decimal.js"; +import { CryptoAmount } from "../coinbase/crypto_amount"; +import { FundOperation } from "../coinbase/fund_operation"; + +describe("FundQuote", () => { + let assetModel: AssetModel; + let asset: Asset; + let fundQuoteModel: FundQuoteModel; + let fundQuote: FundQuote; + + beforeEach(() => { + Coinbase.apiClients.asset = assetApiMock; + Coinbase.apiClients.fund = fundOperationsApiMock; + + assetModel = VALID_ASSET_MODEL; + asset = Asset.fromModel(assetModel); + + fundQuoteModel = VALID_FUND_QUOTE_MODEL; + fundQuote = FundQuote.fromModel(fundQuoteModel); + + Coinbase.apiClients.asset!.getAsset = mockReturnValue(assetModel); + Coinbase.apiClients.fund!.createFundQuote = mockReturnValue(fundQuoteModel); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("constructor", () => { + it("should initialize a FundQuote object", () => { + expect(fundQuote).toBeInstanceOf(FundQuote); + }); + }); + + describe(".create", () => { + it("should create a new fund quote", async () => { + const newFundQuote = await FundQuote.create(fundQuoteModel.wallet_id, fundQuoteModel.address_id, new Decimal(fundQuoteModel.crypto_amount.amount), fundQuoteModel.crypto_amount.asset.asset_id, fundQuoteModel.network_id); + expect(newFundQuote).toBeInstanceOf(FundQuote); + expect(Coinbase.apiClients.asset!.getAsset).toHaveBeenCalledWith(fundQuoteModel.network_id, fundQuoteModel.crypto_amount.asset.asset_id); + expect( + Coinbase.apiClients.fund!.createFundQuote, + ).toHaveBeenCalledWith(fundQuoteModel.wallet_id, fundQuoteModel.address_id, { + asset_id: Asset.primaryDenomination(fundQuoteModel.crypto_amount.asset.asset_id), + amount: asset.toAtomicAmount(new Decimal(fundQuoteModel.crypto_amount.amount)).toString(), + }); + }); + }); + + describe("#getId", () => { + it("should return the fund quote ID", () => { + expect(fundQuote.getId()).toEqual(fundQuoteModel.fund_quote_id); + }); + }); + + describe("#getNetworkId", () => { + it("should return the network ID", () => { + expect(fundQuote.getNetworkId()).toEqual(fundQuoteModel.network_id); + }); + }); + + describe("#getWalletId", () => { + it("should return the wallet ID", () => { + expect(fundQuote.getWalletId()).toEqual(fundQuoteModel.wallet_id); + }); + }); + + describe("#getAddressId", () => { + it("should return the address ID", () => { + expect(fundQuote.getAddressId()).toEqual(fundQuoteModel.address_id); + }); + }); + + describe("#getAsset", () => { + it("should return the asset", () => { + expect(fundQuote.getAsset()).toEqual(asset); + }); + }); + + describe("#getAmount", () => { + it("should return the crypto amount", () => { + const cryptoAmount = fundQuote.getAmount(); + expect(cryptoAmount.getAmount()).toEqual(new Decimal(fundQuoteModel.crypto_amount.amount).div(new Decimal(10).pow(asset.decimals))); + expect(cryptoAmount.getAsset()).toEqual(asset); + }); + }); + + describe("#getFiatAmount", () => { + it("should return the fiat amount", () => { + expect(fundQuote.getFiatAmount()).toEqual(new Decimal(fundQuoteModel.fiat_amount.amount)); + }); + }); + + describe("#getFiatCurrency", () => { + it("should return the fiat currency", () => { + expect(fundQuote.getFiatCurrency()).toEqual(fundQuoteModel.fiat_amount.currency); + }); + }); + + describe("#getBuyFee", () => { + it("should return the buy fee", () => { + expect(fundQuote.getBuyFee()).toEqual({ + amount: fundQuoteModel.fees.buy_fee.amount, + currency: fundQuoteModel.fees.buy_fee.currency, + }); + }); + }); + + describe("#getTransferFee", () => { + it("should return the transfer fee", () => { + expect(fundQuote.getTransferFee()).toEqual(CryptoAmount.fromModel(fundQuoteModel.fees.transfer_fee)); + }); + }); + + describe("#execute", () => { + it("should execute the fund quote and create a fund operation", async () => { + Coinbase.apiClients.fund!.createFundOperation = mockReturnValue(fundQuoteModel); + const newFundOperation = await fundQuote.execute(); + expect(newFundOperation).toBeInstanceOf(FundOperation); + expect(Coinbase.apiClients.fund!.createFundOperation).toHaveBeenCalledWith(fundQuoteModel.wallet_id, fundQuoteModel.address_id, { + asset_id: Asset.primaryDenomination(fundQuoteModel.crypto_amount.asset.asset_id), + amount: fundQuoteModel.crypto_amount.amount, + fund_quote_id: fundQuoteModel.fund_quote_id, + }); + }); + }); +}); diff --git a/src/tests/index_test.ts b/src/tests/index_test.ts index a3902854..7dc0ddb6 100644 --- a/src/tests/index_test.ts +++ b/src/tests/index_test.ts @@ -34,5 +34,9 @@ describe("Index file exports", () => { expect(index).toHaveProperty("Wallet"); expect(index).toHaveProperty("WalletAddress"); expect(index).toHaveProperty("Webhook"); + expect(index).toHaveProperty("CryptoAmount"); + expect(index).toHaveProperty("FiatAmount"); + expect(index).toHaveProperty("FundOperation"); + expect(index).toHaveProperty("FundQuote"); }); }); diff --git a/src/tests/utils.ts b/src/tests/utils.ts index d3ea7985..046113d9 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -18,6 +18,9 @@ import { ContractInvocation as ContractInvocationModel, SmartContract as SmartContractModel, CryptoAmount as CryptoAmountModel, + Asset as AssetModel, + FundQuote as FundQuoteModel, + FundOperation as FundOperationModel, SmartContractType, ValidatorList, Validator, @@ -398,6 +401,41 @@ export const VALID_ETH_CRYPTO_AMOUNT_MODEL: CryptoAmountModel = { } }; +export const VALID_ASSET_MODEL: AssetModel = { + asset_id: Coinbase.assets.Eth, + network_id: "base-sepolia", + contract_address: "0x", + decimals: 18, +}; + +export const VALID_FUND_QUOTE_MODEL: FundQuoteModel = { + fund_quote_id: "test-quote-id", + network_id: "base-sepolia", + wallet_id: "test-wallet-id", + address_id: "test-address-id", + crypto_amount: VALID_ETH_CRYPTO_AMOUNT_MODEL, + fiat_amount: { + amount: "100", + currency: "USD" + }, + expires_at: "2024-12-31T23:59:59Z", + fees: { + buy_fee: { + amount: "1", + currency: "USD" + }, + transfer_fee: { + amount: "10000000000000000", // 0.01 ETH + asset: { + network_id: "base-sepolia", + asset_id: Coinbase.assets.Eth, + contract_address: "0x", + decimals: 18 + } + } + } +}; + /** * mockStakingOperation returns a mock StakingOperation object with the provided status. * @@ -707,11 +745,15 @@ export const contractInvocationApiMock = { broadcastContractInvocation: jest.fn(), }; +export const assetApiMock = { + getAsset: jest.fn(), +}; + export const fundOperationsApiMock = { getFundOperation: jest.fn(), listFundOperations: jest.fn(), createFundOperation: jest.fn(), - cancelFundQuote: jest.fn(), + createFundQuote: jest.fn(), }; export const testAllReadTypesABI = [ From 242d2ff0f53c96052de7f5ad0aa7f2f425aee479 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Fri, 22 Nov 2024 16:44:44 -0500 Subject: [PATCH 07/13] Format, lint --- src/coinbase/address/wallet_address.ts | 1 - src/coinbase/fiat_amount.ts | 90 +++++++++++++------------- src/index.ts | 2 +- src/tests/crypto_amount_test.ts | 79 ++++++++++++---------- src/tests/external_address_test.ts | 10 +-- src/tests/faucet_transaction_test.ts | 27 ++++---- src/tests/fiat_amount_test.ts | 58 ++++++++--------- src/tests/fund_quote_test.ts | 59 ++++++++++++----- src/tests/index_test.ts | 2 +- src/tests/transaction_test.ts | 39 +++++------ src/tests/utils.ts | 18 +++--- src/tests/wallet_address_test.ts | 29 +++++---- src/tests/wallet_test.ts | 4 +- 13 files changed, 231 insertions(+), 187 deletions(-) diff --git a/src/coinbase/address/wallet_address.ts b/src/coinbase/address/wallet_address.ts index 5e5b7ed7..31c15181 100644 --- a/src/coinbase/address/wallet_address.ts +++ b/src/coinbase/address/wallet_address.ts @@ -749,7 +749,6 @@ export class WalletAddress extends Address { }; } - /** * Fund the address from your account on the Coinbase Platform. * diff --git a/src/coinbase/fiat_amount.ts b/src/coinbase/fiat_amount.ts index 941dfd85..165c8612 100644 --- a/src/coinbase/fiat_amount.ts +++ b/src/coinbase/fiat_amount.ts @@ -4,52 +4,54 @@ import { FiatAmount as FiatAmountModel } from "../client/api"; * A representation of a FiatAmount that includes the amount and currency. */ export class FiatAmount { - private amount: string; - private currency: string; + private amount: string; + private currency: string; - /** - * Initialize a new FiatAmount. Do not use this directly, use the fromModel method instead. - * @param amount The amount in the fiat currency - * @param currency The currency code (e.g. 'USD') - */ - constructor(amount: string, currency: string) { - this.amount = amount; - this.currency = currency; - } + /** + * Initialize a new FiatAmount. Do not use this directly, use the fromModel method instead. + * + * @param amount - The amount in the fiat currency + * @param currency - The currency code (e.g. 'USD') + */ + constructor(amount: string, currency: string) { + this.amount = amount; + this.currency = currency; + } - /** - * Convert a FiatAmount model to a FiatAmount. - * @param fiatAmountModel The fiat amount from the API. - * @returns The converted FiatAmount object. - */ - public static fromModel(fiatAmountModel: FiatAmountModel): FiatAmount { - return new FiatAmount( - fiatAmountModel.amount, - fiatAmountModel.currency - ); - } + /** + * Convert a FiatAmount model to a FiatAmount. + * + * @param fiatAmountModel - The fiat amount from the API. + * @returns The converted FiatAmount object. + */ + public static fromModel(fiatAmountModel: FiatAmountModel): FiatAmount { + return new FiatAmount(fiatAmountModel.amount, fiatAmountModel.currency); + } - /** - * Get the amount in the fiat currency. - * @returns The amount in the fiat currency. - */ - public getAmount(): string { - return this.amount; - } + /** + * Get the amount in the fiat currency. + * + * @returns The amount in the fiat currency. + */ + public getAmount(): string { + return this.amount; + } - /** - * Get the currency code. - * @returns The currency code. - */ - public getCurrency(): string { - return this.currency; - } + /** + * Get the currency code. + * + * @returns The currency code. + */ + public getCurrency(): string { + return this.currency; + } - /** - * Get a string representation of the FiatAmount. - * @returns A string representation of the FiatAmount. - */ - public toString(): string { - return `FiatAmount(amount: '${this.amount}', currency: '${this.currency}')`; - } -} \ No newline at end of file + /** + * Get a string representation of the FiatAmount. + * + * @returns A string representation of the FiatAmount. + */ + public toString(): string { + return `FiatAmount(amount: '${this.amount}', currency: '${this.currency}')`; + } +} diff --git a/src/index.ts b/src/index.ts index 64302e70..d5412e89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,4 +31,4 @@ export * from "./coinbase/read_contract"; export * from "./coinbase/crypto_amount"; export * from "./coinbase/fiat_amount"; export * from "./coinbase/fund_operation"; -export * from "./coinbase/fund_quote"; \ No newline at end of file +export * from "./coinbase/fund_quote"; diff --git a/src/tests/crypto_amount_test.ts b/src/tests/crypto_amount_test.ts index 727b0f0f..f10443ff 100644 --- a/src/tests/crypto_amount_test.ts +++ b/src/tests/crypto_amount_test.ts @@ -1,27 +1,32 @@ -import { describe, it, expect } from '@jest/globals'; -import Decimal from 'decimal.js'; -import { CryptoAmount } from '../coinbase/crypto_amount'; -import { Asset } from '../coinbase/asset'; -import { CryptoAmount as CryptoAmountModel } from '../client/api'; -import { Coinbase } from '../coinbase/coinbase'; -import { contractInvocationApiMock, getAssetMock, VALID_ETH_CRYPTO_AMOUNT_MODEL, VALID_USDC_CRYPTO_AMOUNT_MODEL } from './utils'; -import { ContractInvocation } from '../coinbase/contract_invocation'; +import { describe, it, expect } from "@jest/globals"; +import Decimal from "decimal.js"; +import { CryptoAmount } from "../coinbase/crypto_amount"; +import { Asset } from "../coinbase/asset"; +import { CryptoAmount as CryptoAmountModel } from "../client/api"; +import { Coinbase } from "../coinbase/coinbase"; +import { + contractInvocationApiMock, + getAssetMock, + VALID_ETH_CRYPTO_AMOUNT_MODEL, + VALID_USDC_CRYPTO_AMOUNT_MODEL, +} from "./utils"; +import { ContractInvocation } from "../coinbase/contract_invocation"; -describe('CryptoAmount', () => { +describe("CryptoAmount", () => { let cryptoAmountModel: CryptoAmountModel; let cryptoAmount: CryptoAmount; beforeEach(() => { - cryptoAmountModel = VALID_USDC_CRYPTO_AMOUNT_MODEL; - cryptoAmount = CryptoAmount.fromModel(cryptoAmountModel); - }); + cryptoAmountModel = VALID_USDC_CRYPTO_AMOUNT_MODEL; + cryptoAmount = CryptoAmount.fromModel(cryptoAmountModel); + }); - afterEach(() => { - jest.restoreAllMocks(); - }); + afterEach(() => { + jest.restoreAllMocks(); + }); - describe('.fromModel', () => { - it('should correctly create CryptoAmount from model', () => { + describe(".fromModel", () => { + it("should correctly create CryptoAmount from model", () => { expect(cryptoAmount).toBeInstanceOf(CryptoAmount); expect(cryptoAmount.getAmount().equals(new Decimal(1).div(new Decimal(10).pow(6)))); expect(cryptoAmount.getAsset().assetId).toEqual(Coinbase.assets.Usdc); @@ -30,17 +35,23 @@ describe('CryptoAmount', () => { }); }); - describe('.fromModelAndAssetId', () => { - it('should correctly create CryptoAmount from model with gwei denomination', () => { - const cryptoAmount = CryptoAmount.fromModelAndAssetId(VALID_ETH_CRYPTO_AMOUNT_MODEL, Coinbase.assets.Gwei); + describe(".fromModelAndAssetId", () => { + it("should correctly create CryptoAmount from model with gwei denomination", () => { + const cryptoAmount = CryptoAmount.fromModelAndAssetId( + VALID_ETH_CRYPTO_AMOUNT_MODEL, + Coinbase.assets.Gwei, + ); expect(cryptoAmount.getAmount().equals(new Decimal(1).div(new Decimal(10).pow(9)))); expect(cryptoAmount.getAsset().assetId).toEqual(Coinbase.assets.Gwei); expect(cryptoAmount.getAsset().networkId).toEqual("base-sepolia"); expect(cryptoAmount.getAsset().decimals).toEqual(9); }); - it('should correctly create CryptoAmount from model with wei denomination', () => { - const cryptoAmount = CryptoAmount.fromModelAndAssetId(VALID_ETH_CRYPTO_AMOUNT_MODEL, Coinbase.assets.Wei); + it("should correctly create CryptoAmount from model with wei denomination", () => { + const cryptoAmount = CryptoAmount.fromModelAndAssetId( + VALID_ETH_CRYPTO_AMOUNT_MODEL, + Coinbase.assets.Wei, + ); expect(cryptoAmount.getAmount().equals(new Decimal(1))); expect(cryptoAmount.getAsset().assetId).toEqual(Coinbase.assets.Wei); expect(cryptoAmount.getAsset().networkId).toEqual("base-sepolia"); @@ -48,35 +59,35 @@ describe('CryptoAmount', () => { }); }); - describe('#getAmount', () => { - it('should return the correct amount', () => { + describe("#getAmount", () => { + it("should return the correct amount", () => { expect(cryptoAmount.getAmount().equals(new Decimal(1).div(new Decimal(10).pow(6)))); }); }); - describe('#getAsset', () => { - it('should return the correct asset', () => { + describe("#getAsset", () => { + it("should return the correct asset", () => { expect(cryptoAmount.getAsset().assetId).toEqual(Coinbase.assets.Usdc); expect(cryptoAmount.getAsset().networkId).toEqual("base-sepolia"); expect(cryptoAmount.getAsset().decimals).toEqual(6); }); }); - describe('#getAssetId', () => { - it('should return the correct asset ID', () => { + describe("#getAssetId", () => { + it("should return the correct asset ID", () => { expect(cryptoAmount.getAssetId()).toEqual(Coinbase.assets.Usdc); }); }); - describe('#toAtomicAmount', () => { - it('should correctly convert to atomic amount', () => { - expect(cryptoAmount.toAtomicAmount().toString()).toEqual("1") + describe("#toAtomicAmount", () => { + it("should correctly convert to atomic amount", () => { + expect(cryptoAmount.toAtomicAmount().toString()).toEqual("1"); }); }); - describe('#toString', () => { - it('should have correct string representation', () => { + describe("#toString", () => { + it("should have correct string representation", () => { expect(cryptoAmount.toString()).toEqual("CryptoAmount{amount: '0.000001', assetId: 'usdc'}"); }); }); -}); \ No newline at end of file +}); diff --git a/src/tests/external_address_test.ts b/src/tests/external_address_test.ts index 5ef06846..50526260 100644 --- a/src/tests/external_address_test.ts +++ b/src/tests/external_address_test.ts @@ -584,16 +584,16 @@ describe("ExternalAddress", () => { describe("#faucet", () => { beforeEach(() => { - Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds = mockReturnValue(VALID_FAUCET_TRANSACTION_MODEL); + Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds = mockReturnValue( + VALID_FAUCET_TRANSACTION_MODEL, + ); }); it("should successfully request funds from the faucet", async () => { const faucetTx = await address.faucet(); - const { - transaction_hash: txHash, - transaction_link: txLink, - } = VALID_FAUCET_TRANSACTION_MODEL.transaction; + const { transaction_hash: txHash, transaction_link: txLink } = + VALID_FAUCET_TRANSACTION_MODEL.transaction; expect(faucetTx.getTransactionHash()).toEqual(txHash); expect(faucetTx.getTransactionLink()).toEqual(txLink); diff --git a/src/tests/faucet_transaction_test.ts b/src/tests/faucet_transaction_test.ts index 56807fb4..78e3b52a 100644 --- a/src/tests/faucet_transaction_test.ts +++ b/src/tests/faucet_transaction_test.ts @@ -30,8 +30,7 @@ describe("FaucetTransaction tests", () => { }); it("throws an Error if model is not provided", () => { - expect(() => new FaucetTransaction(null!)) - .toThrow(`FaucetTransaction model cannot be empty`); + expect(() => new FaucetTransaction(null!)).toThrow(`FaucetTransaction model cannot be empty`); }); }); @@ -79,9 +78,9 @@ describe("FaucetTransaction tests", () => { TransactionStatusEnum.Pending, TransactionStatusEnum.Complete, TransactionStatusEnum.Failed, - ].forEach((status) => { + ].forEach(status => { describe(`when the transaction is ${status}`, () => { - beforeAll(() => txStatus = status); + beforeAll(() => (txStatus = status)); it("returns a FaucetTransaction", () => { expect(reloadedFaucetTx).toBeInstanceOf(FaucetTransaction); @@ -97,7 +96,9 @@ describe("FaucetTransaction tests", () => { toAddressId, txHash, ); - expect(Coinbase.apiClients.externalAddress!.getFaucetTransaction).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.externalAddress!.getFaucetTransaction).toHaveBeenCalledTimes( + 1, + ); }); }); }); @@ -106,7 +107,8 @@ describe("FaucetTransaction tests", () => { describe("#wait", () => { describe("when the transaction eventually completes", () => { beforeEach(() => { - Coinbase.apiClients.externalAddress!.getFaucetTransaction = jest.fn() + Coinbase.apiClients.externalAddress!.getFaucetTransaction = jest + .fn() .mockResolvedValueOnce({ data: VALID_FAUCET_TRANSACTION_MODEL }) // Pending .mockResolvedValueOnce({ data: { @@ -140,7 +142,8 @@ describe("FaucetTransaction tests", () => { describe("when the transaction eventually fails", () => { beforeEach(() => { - Coinbase.apiClients.externalAddress!.getFaucetTransaction = jest.fn() + Coinbase.apiClients.externalAddress!.getFaucetTransaction = jest + .fn() .mockResolvedValueOnce({ data: VALID_FAUCET_TRANSACTION_MODEL }) // Pending .mockResolvedValueOnce({ data: { @@ -175,13 +178,15 @@ describe("FaucetTransaction tests", () => { describe("when the transaction times out", () => { beforeEach(() => { // Returns pending for every request. - Coinbase.apiClients.externalAddress!.getFaucetTransaction = jest.fn() - .mockResolvedValueOnce({ data: VALID_FAUCET_TRANSACTION_MODEL }) // Pending + Coinbase.apiClients.externalAddress!.getFaucetTransaction = jest + .fn() + .mockResolvedValueOnce({ data: VALID_FAUCET_TRANSACTION_MODEL }); // Pending }); it("throws a TimeoutError", async () => { - expect(faucetTransaction.wait({ timeoutSeconds: 0.001, intervalSeconds: 0.001 })) - .rejects.toThrow(new Error("FaucetTransaction timed out")); + expect( + faucetTransaction.wait({ timeoutSeconds: 0.001, intervalSeconds: 0.001 }), + ).rejects.toThrow(new Error("FaucetTransaction timed out")); }); }); }); diff --git a/src/tests/fiat_amount_test.ts b/src/tests/fiat_amount_test.ts index f214838f..c0e748c5 100644 --- a/src/tests/fiat_amount_test.ts +++ b/src/tests/fiat_amount_test.ts @@ -2,40 +2,40 @@ import { FiatAmount } from "../coinbase/fiat_amount"; import { FiatAmount as FiatAmountModel } from "../client/api"; describe("FiatAmount", () => { - describe(".fromModel", () => { - it("should convert a FiatAmount model to a FiatAmount", () => { - const model: FiatAmountModel = { - amount: "100.50", - currency: "USD" - }; - - const fiatAmount = FiatAmount.fromModel(model); + describe(".fromModel", () => { + it("should convert a FiatAmount model to a FiatAmount", () => { + const model: FiatAmountModel = { + amount: "100.50", + currency: "USD", + }; - expect(fiatAmount.getAmount()).toBe("100.50"); - expect(fiatAmount.getCurrency()).toBe("USD"); - }); + const fiatAmount = FiatAmount.fromModel(model); + + expect(fiatAmount.getAmount()).toBe("100.50"); + expect(fiatAmount.getCurrency()).toBe("USD"); }); + }); - describe("#getAmount", () => { - it("should return the correct amount", () => { - const fiatAmount = new FiatAmount("50.25", "USD"); - expect(fiatAmount.getAmount()).toBe("50.25"); - }); + describe("#getAmount", () => { + it("should return the correct amount", () => { + const fiatAmount = new FiatAmount("50.25", "USD"); + expect(fiatAmount.getAmount()).toBe("50.25"); }); + }); - describe("#getCurrency", () => { - it("should return the correct currency", () => { - const fiatAmount = new FiatAmount("50.25", "USD"); - expect(fiatAmount.getCurrency()).toBe("USD"); - }); + describe("#getCurrency", () => { + it("should return the correct currency", () => { + const fiatAmount = new FiatAmount("50.25", "USD"); + expect(fiatAmount.getCurrency()).toBe("USD"); }); + }); + + describe("#toString", () => { + it("should return the correct string representation", () => { + const fiatAmount = new FiatAmount("75.00", "USD"); + const expectedStr = "FiatAmount(amount: '75.00', currency: 'USD')"; - describe("#toString", () => { - it("should return the correct string representation", () => { - const fiatAmount = new FiatAmount("75.00", "USD"); - const expectedStr = "FiatAmount(amount: '75.00', currency: 'USD')"; - - expect(fiatAmount.toString()).toBe(expectedStr); - }); + expect(fiatAmount.toString()).toBe(expectedStr); }); -}); \ No newline at end of file + }); +}); diff --git a/src/tests/fund_quote_test.ts b/src/tests/fund_quote_test.ts index 4638b272..16e23258 100644 --- a/src/tests/fund_quote_test.ts +++ b/src/tests/fund_quote_test.ts @@ -1,7 +1,15 @@ import { FundQuote } from "../coinbase/fund_quote"; import { FundQuote as FundQuoteModel, Asset as AssetModel } from "../client/api"; import { Coinbase } from "../coinbase/coinbase"; -import { VALID_FUND_QUOTE_MODEL, VALID_ASSET_MODEL, mockReturnValue, mockReturnRejectedValue, contractInvocationApiMock, fundOperationsApiMock, assetApiMock } from "./utils"; +import { + VALID_FUND_QUOTE_MODEL, + VALID_ASSET_MODEL, + mockReturnValue, + mockReturnRejectedValue, + contractInvocationApiMock, + fundOperationsApiMock, + assetApiMock, +} from "./utils"; import { Asset } from "../coinbase/asset"; import Decimal from "decimal.js"; import { CryptoAmount } from "../coinbase/crypto_amount"; @@ -39,15 +47,26 @@ describe("FundQuote", () => { describe(".create", () => { it("should create a new fund quote", async () => { - const newFundQuote = await FundQuote.create(fundQuoteModel.wallet_id, fundQuoteModel.address_id, new Decimal(fundQuoteModel.crypto_amount.amount), fundQuoteModel.crypto_amount.asset.asset_id, fundQuoteModel.network_id); + const newFundQuote = await FundQuote.create( + fundQuoteModel.wallet_id, + fundQuoteModel.address_id, + new Decimal(fundQuoteModel.crypto_amount.amount), + fundQuoteModel.crypto_amount.asset.asset_id, + fundQuoteModel.network_id, + ); expect(newFundQuote).toBeInstanceOf(FundQuote); - expect(Coinbase.apiClients.asset!.getAsset).toHaveBeenCalledWith(fundQuoteModel.network_id, fundQuoteModel.crypto_amount.asset.asset_id); - expect( - Coinbase.apiClients.fund!.createFundQuote, - ).toHaveBeenCalledWith(fundQuoteModel.wallet_id, fundQuoteModel.address_id, { - asset_id: Asset.primaryDenomination(fundQuoteModel.crypto_amount.asset.asset_id), - amount: asset.toAtomicAmount(new Decimal(fundQuoteModel.crypto_amount.amount)).toString(), - }); + expect(Coinbase.apiClients.asset!.getAsset).toHaveBeenCalledWith( + fundQuoteModel.network_id, + fundQuoteModel.crypto_amount.asset.asset_id, + ); + expect(Coinbase.apiClients.fund!.createFundQuote).toHaveBeenCalledWith( + fundQuoteModel.wallet_id, + fundQuoteModel.address_id, + { + asset_id: Asset.primaryDenomination(fundQuoteModel.crypto_amount.asset.asset_id), + amount: asset.toAtomicAmount(new Decimal(fundQuoteModel.crypto_amount.amount)).toString(), + }, + ); }); }); @@ -84,7 +103,9 @@ describe("FundQuote", () => { describe("#getAmount", () => { it("should return the crypto amount", () => { const cryptoAmount = fundQuote.getAmount(); - expect(cryptoAmount.getAmount()).toEqual(new Decimal(fundQuoteModel.crypto_amount.amount).div(new Decimal(10).pow(asset.decimals))); + expect(cryptoAmount.getAmount()).toEqual( + new Decimal(fundQuoteModel.crypto_amount.amount).div(new Decimal(10).pow(asset.decimals)), + ); expect(cryptoAmount.getAsset()).toEqual(asset); }); }); @@ -112,7 +133,9 @@ describe("FundQuote", () => { describe("#getTransferFee", () => { it("should return the transfer fee", () => { - expect(fundQuote.getTransferFee()).toEqual(CryptoAmount.fromModel(fundQuoteModel.fees.transfer_fee)); + expect(fundQuote.getTransferFee()).toEqual( + CryptoAmount.fromModel(fundQuoteModel.fees.transfer_fee), + ); }); }); @@ -121,11 +144,15 @@ describe("FundQuote", () => { Coinbase.apiClients.fund!.createFundOperation = mockReturnValue(fundQuoteModel); const newFundOperation = await fundQuote.execute(); expect(newFundOperation).toBeInstanceOf(FundOperation); - expect(Coinbase.apiClients.fund!.createFundOperation).toHaveBeenCalledWith(fundQuoteModel.wallet_id, fundQuoteModel.address_id, { - asset_id: Asset.primaryDenomination(fundQuoteModel.crypto_amount.asset.asset_id), - amount: fundQuoteModel.crypto_amount.amount, - fund_quote_id: fundQuoteModel.fund_quote_id, - }); + expect(Coinbase.apiClients.fund!.createFundOperation).toHaveBeenCalledWith( + fundQuoteModel.wallet_id, + fundQuoteModel.address_id, + { + asset_id: Asset.primaryDenomination(fundQuoteModel.crypto_amount.asset.asset_id), + amount: fundQuoteModel.crypto_amount.amount, + fund_quote_id: fundQuoteModel.fund_quote_id, + }, + ); }); }); }); diff --git a/src/tests/index_test.ts b/src/tests/index_test.ts index 7dc0ddb6..d6c7c928 100644 --- a/src/tests/index_test.ts +++ b/src/tests/index_test.ts @@ -37,6 +37,6 @@ describe("Index file exports", () => { expect(index).toHaveProperty("CryptoAmount"); expect(index).toHaveProperty("FiatAmount"); expect(index).toHaveProperty("FundOperation"); - expect(index).toHaveProperty("FundQuote"); + expect(index).toHaveProperty("FundQuote"); }); }); diff --git a/src/tests/transaction_test.ts b/src/tests/transaction_test.ts index e0edde0a..25cf5612 100644 --- a/src/tests/transaction_test.ts +++ b/src/tests/transaction_test.ts @@ -210,14 +210,14 @@ describe("Transaction", () => { describe("#getStatus", () => { [ - {status: TransactionStatus.PENDING, expected: "pending"}, - {status: TransactionStatus.BROADCAST, expected: "broadcast"}, - {status: TransactionStatus.SIGNED, expected: "signed"}, - {status: TransactionStatus.COMPLETE, expected: "complete"}, - {status: TransactionStatus.FAILED, expected: "failed"}, - ].forEach(({status, expected}) => { + { status: TransactionStatus.PENDING, expected: "pending" }, + { status: TransactionStatus.BROADCAST, expected: "broadcast" }, + { status: TransactionStatus.SIGNED, expected: "signed" }, + { status: TransactionStatus.COMPLETE, expected: "complete" }, + { status: TransactionStatus.FAILED, expected: "failed" }, + ].forEach(({ status, expected }) => { describe(`when the status is ${status}`, () => { - beforeEach(() => model.status = status); + beforeEach(() => (model.status = status)); it(`should return ${expected}`, () => { const transaction = new Transaction(model); @@ -229,23 +229,18 @@ describe("Transaction", () => { }); describe("#isTerminalState", () => { - [ - TransactionStatus.PENDING, - TransactionStatus.BROADCAST, - TransactionStatus.SIGNED, - ].forEach((status) => { - it(`should return false when the status is ${status}`, () => { - model.status = status; - const transaction = new Transaction(model); + [TransactionStatus.PENDING, TransactionStatus.BROADCAST, TransactionStatus.SIGNED].forEach( + status => { + it(`should return false when the status is ${status}`, () => { + model.status = status; + const transaction = new Transaction(model); - expect(transaction.isTerminalState()).toEqual(false); - }); - }); + expect(transaction.isTerminalState()).toEqual(false); + }); + }, + ); - [ - TransactionStatus.COMPLETE, - TransactionStatus.FAILED, - ].forEach((status) => { + [TransactionStatus.COMPLETE, TransactionStatus.FAILED].forEach(status => { it(`should return true when the status is ${status}`, () => { model.status = status; const transaction = new Transaction(model); diff --git a/src/tests/utils.ts b/src/tests/utils.ts index 046113d9..dfc14e72 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -258,7 +258,7 @@ export const MINT_NFT_ARGS = { recipient: "0x475d41de7A81298Ba263184996800CBcaAD const faucetTxHash = generateRandomHash(64); -export const VALID_FAUCET_TRANSACTION_MODEL: FaucetTransactionModel = { +export const VALID_FAUCET_TRANSACTION_MODEL: FaucetTransactionModel = { transaction_hash: faucetTxHash, transaction_link: "https://sepolia.basescan.org/tx/" + faucetTxHash, transaction: { @@ -388,7 +388,7 @@ export const VALID_USDC_CRYPTO_AMOUNT_MODEL: CryptoAmountModel = { asset_id: Coinbase.assets.Usdc, contract_address: "0x", decimals: 6, - } + }, }; export const VALID_ETH_CRYPTO_AMOUNT_MODEL: CryptoAmountModel = { @@ -398,7 +398,7 @@ export const VALID_ETH_CRYPTO_AMOUNT_MODEL: CryptoAmountModel = { asset_id: Coinbase.assets.Eth, contract_address: "0x", decimals: 18, - } + }, }; export const VALID_ASSET_MODEL: AssetModel = { @@ -416,13 +416,13 @@ export const VALID_FUND_QUOTE_MODEL: FundQuoteModel = { crypto_amount: VALID_ETH_CRYPTO_AMOUNT_MODEL, fiat_amount: { amount: "100", - currency: "USD" + currency: "USD", }, expires_at: "2024-12-31T23:59:59Z", fees: { buy_fee: { amount: "1", - currency: "USD" + currency: "USD", }, transfer_fee: { amount: "10000000000000000", // 0.01 ETH @@ -430,10 +430,10 @@ export const VALID_FUND_QUOTE_MODEL: FundQuoteModel = { network_id: "base-sepolia", asset_id: Coinbase.assets.Eth, contract_address: "0x", - decimals: 18 - } - } - } + decimals: 18, + }, + }, + }, }; /** diff --git a/src/tests/wallet_address_test.ts b/src/tests/wallet_address_test.ts index 6a85eea7..cb035b24 100644 --- a/src/tests/wallet_address_test.ts +++ b/src/tests/wallet_address_test.ts @@ -213,7 +213,9 @@ describe("WalletAddress", () => { let faucetTransaction: FaucetTransaction; beforeEach(() => { - Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds = mockReturnValue(VALID_FAUCET_TRANSACTION_MODEL); + Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds = mockReturnValue( + VALID_FAUCET_TRANSACTION_MODEL, + ); }); it("returns the faucet transaction", async () => { @@ -221,8 +223,9 @@ describe("WalletAddress", () => { expect(faucetTransaction).toBeInstanceOf(FaucetTransaction); - expect(faucetTransaction.getTransactionHash()) - .toBe(VALID_FAUCET_TRANSACTION_MODEL.transaction!.transaction_hash); + expect(faucetTransaction.getTransactionHash()).toBe( + VALID_FAUCET_TRANSACTION_MODEL.transaction!.transaction_hash, + ); expect(Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds).toHaveBeenCalledWith( address.getNetworkId(), @@ -231,15 +234,17 @@ describe("WalletAddress", () => { true, // Skip wait should be true. ); - expect(Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds) - .toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds).toHaveBeenCalledTimes( + 1, + ); }); it("returns the faucet transaction when specifying the asset ID", async () => { const faucetTransaction = await address.faucet("usdc"); - expect(faucetTransaction.getTransactionHash()) - .toBe(VALID_FAUCET_TRANSACTION_MODEL.transaction!.transaction_hash); + expect(faucetTransaction.getTransactionHash()).toBe( + VALID_FAUCET_TRANSACTION_MODEL.transaction!.transaction_hash, + ); expect(Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds).toHaveBeenCalledWith( address.getNetworkId(), @@ -248,8 +253,9 @@ describe("WalletAddress", () => { true, // Skip wait should be true. ); - expect(Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds) - .toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds).toHaveBeenCalledTimes( + 1, + ); }); it("should throw an APIError when the request is unsuccessful", async () => { @@ -266,8 +272,9 @@ describe("WalletAddress", () => { true, // Skip wait should be true. ); - expect(Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds) - .toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.externalAddress!.requestExternalFaucetFunds).toHaveBeenCalledTimes( + 1, + ); }); it("should throw a FaucetLimitReachedError when the faucet limit is reached", async () => { diff --git a/src/tests/wallet_test.ts b/src/tests/wallet_test.ts index 3e476d86..2ad696c7 100644 --- a/src/tests/wallet_test.ts +++ b/src/tests/wallet_test.ts @@ -600,9 +600,7 @@ describe("Wallet Class", () => { beforeEach(async () => { expectedFaucetTx = new FaucetTransaction(VALID_FAUCET_TRANSACTION_MODEL); - (await wallet.getDefaultAddress()).faucet = jest - .fn() - .mockResolvedValue(expectedFaucetTx); + (await wallet.getDefaultAddress()).faucet = jest.fn().mockResolvedValue(expectedFaucetTx); }); it("successfully requests faucet funds", async () => { From f5ffe34d467269b33e6356854dcaf3be1d1ea593 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Fri, 22 Nov 2024 16:45:52 -0500 Subject: [PATCH 08/13] Fix --- src/tests/fund_quote_test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/tests/fund_quote_test.ts b/src/tests/fund_quote_test.ts index 16e23258..862a99eb 100644 --- a/src/tests/fund_quote_test.ts +++ b/src/tests/fund_quote_test.ts @@ -5,8 +5,6 @@ import { VALID_FUND_QUOTE_MODEL, VALID_ASSET_MODEL, mockReturnValue, - mockReturnRejectedValue, - contractInvocationApiMock, fundOperationsApiMock, assetApiMock, } from "./utils"; From e8810e563d7e3568fece90ce24b842c24e2601aa Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Mon, 25 Nov 2024 10:58:52 -0500 Subject: [PATCH 09/13] Fix --- src/coinbase/fund_operation.ts | 43 ++++--- src/coinbase/types.ts | 9 ++ src/tests/fund_operation_test.ts | 204 ++++++++++++++++++++++++++++--- src/tests/utils.ts | 28 +++++ 4 files changed, 245 insertions(+), 39 deletions(-) diff --git a/src/coinbase/fund_operation.ts b/src/coinbase/fund_operation.ts index fff7c982..80c1fbea 100644 --- a/src/coinbase/fund_operation.ts +++ b/src/coinbase/fund_operation.ts @@ -5,7 +5,8 @@ import { Coinbase } from "./coinbase"; import { delay } from "./utils"; import { TimeoutError } from "./errors"; import { FundQuote } from "./fund_quote"; -import { PaginationOptions, PaginationResponse } from "./types"; +import { FundOperationStatus, PaginationOptions, PaginationResponse } from "./types"; +import { CryptoAmount } from "./crypto_amount"; /** * A representation of a Fund Operation. @@ -15,9 +16,6 @@ export class FundOperation { * Fund Operation status constants. */ public static readonly Status = { - PENDING: "pending", - COMPLETE: "complete", - FAILED: "failed", TERMINAL_STATES: new Set(["complete", "failed"]), } as const; @@ -30,9 +28,6 @@ export class FundOperation { * @param model - The model representing the fund operation */ constructor(model: FundOperationModel) { - if (!model) { - throw new Error("Fund operation model cannot be empty"); - } this.model = model; } @@ -180,12 +175,10 @@ export class FundOperation { /** * Gets the amount. * - * @returns {Decimal} The amount in decimal format + * @returns {CryptoAmount} The crypto amount */ - public getAmount(): Decimal { - return new Decimal(this.model.crypto_amount.amount).div( - new Decimal(10).pow(this.model.crypto_amount.asset.decimals || 0), - ); + public getAmount(): CryptoAmount { + return CryptoAmount.fromModel(this.model.crypto_amount); } /** @@ -207,12 +200,21 @@ export class FundOperation { } /** - * Gets the status. + * Returns the Status of the Transfer. * - * @returns {string} The current status of the fund operation + * @returns The Status of the Transfer. */ - public getStatus(): string { - return this.model.status; + public getStatus(): FundOperationStatus { + switch (this.model.status) { + case FundOperationStatus.PENDING: + return FundOperationStatus.PENDING; + case FundOperationStatus.COMPLETE: + return FundOperationStatus.COMPLETE; + case FundOperationStatus.FAILED: + return FundOperationStatus.FAILED; + default: + throw new Error(`Unknown fund operation status: ${this.model.status}`); + } } /** @@ -242,17 +244,18 @@ export class FundOperation { public async wait({ intervalSeconds = 0.2, timeoutSeconds = 20 } = {}): Promise { const startTime = Date.now(); - while (!this.isTerminalState()) { + while (Date.now() - startTime < timeoutSeconds * 1000) { await this.reload(); - if (Date.now() - startTime > timeoutSeconds * 1000) { - throw new TimeoutError("Fund operation timed out"); + // If the FundOperation is in a terminal state, return the FundOperation + if (this.isTerminalState()) { + return this; } await delay(intervalSeconds); } - return this; + throw new TimeoutError("Fund operation timed out"); } /** diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index 3ff657bd..f89c31e5 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -800,6 +800,15 @@ export enum PayloadSignatureStatus { FAILED = "failed", } +/** + * Fund Operation status type definition. + */ +export enum FundOperationStatus { + PENDING = "pending", + COMPLETE = "complete", + FAILED = "failed", +} + /** * The Wallet Data type definition. * The data required to recreate a Wallet. diff --git a/src/tests/fund_operation_test.ts b/src/tests/fund_operation_test.ts index db187b3e..a981544a 100644 --- a/src/tests/fund_operation_test.ts +++ b/src/tests/fund_operation_test.ts @@ -1,64 +1,230 @@ +import { FundOperation as FundOperationModel, Asset as AssetModel, FundOperationList, FundOperationStatusEnum } from "../client/api"; +import { Coinbase } from "../coinbase/coinbase"; +import { + VALID_ASSET_MODEL, + mockReturnValue, + fundOperationsApiMock, + assetApiMock, + VALID_FUND_OPERATION_MODEL, + VALID_FUND_QUOTE_MODEL, +} from "./utils"; +import { Asset } from "../coinbase/asset"; import { FundOperation } from "../coinbase/fund_operation"; +import Decimal from "decimal.js"; +import { FundQuote } from "../coinbase/fund_quote"; +import { CryptoAmount } from "../coinbase/crypto_amount"; +import { TimeoutError } from "../coinbase/errors"; +import { FundOperationStatus } from "../coinbase/types"; describe("FundOperation", () => { + let assetModel: AssetModel; + let asset: Asset; + let fundOperationModel: FundOperationModel; + let fundOperation: FundOperation; + + beforeEach(() => { + Coinbase.apiClients.asset = assetApiMock; + Coinbase.apiClients.fund = fundOperationsApiMock; + + assetModel = VALID_ASSET_MODEL; + asset = Asset.fromModel(assetModel); + + fundOperationModel = VALID_FUND_OPERATION_MODEL; + fundOperation = FundOperation.fromModel(fundOperationModel); + + Coinbase.apiClients.asset!.getAsset = mockReturnValue(assetModel); + Coinbase.apiClients.fund!.createFundOperation = mockReturnValue(fundOperationModel); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + describe("constructor", () => { - it("should initialize a FundOperation object", () => {}); - it("should throw error if model is empty", () => {}); + it("should initialize a FundOperation object", () => { + expect(fundOperation).toBeInstanceOf(FundOperation); + }); }); describe(".create", () => { - it("should create a new fund operation without quote", () => {}); - it("should create a new fund operation with quote", () => {}); + it("should create a new fund operation without quote", async () => { + const newFundOperation = await FundOperation.create(fundOperationModel.wallet_id, fundOperationModel.address_id, new Decimal(fundOperationModel.crypto_amount.amount), fundOperationModel.crypto_amount.asset.asset_id, fundOperationModel.network_id); + expect(newFundOperation).toBeInstanceOf(FundOperation); + expect(Coinbase.apiClients.fund!.createFundOperation).toHaveBeenCalledWith(fundOperationModel.wallet_id, fundOperationModel.address_id, { + fund_quote_id: undefined, + amount: new Decimal(fundOperationModel.crypto_amount.amount).mul(10 ** asset.decimals).toString(), + asset_id: fundOperationModel.crypto_amount.asset.asset_id, + }); + }); + it("should create a new fund operation with quote", async () => { + const newFundOperation = await FundOperation.create(fundOperationModel.wallet_id, fundOperationModel.address_id, new Decimal(fundOperationModel.crypto_amount.amount), fundOperationModel.crypto_amount.asset.asset_id, fundOperationModel.network_id, FundQuote.fromModel(VALID_FUND_QUOTE_MODEL)); + expect(newFundOperation).toBeInstanceOf(FundOperation); + expect(Coinbase.apiClients.fund!.createFundOperation).toHaveBeenCalledWith(fundOperationModel.wallet_id, fundOperationModel.address_id, { + fund_quote_id: VALID_FUND_QUOTE_MODEL.fund_quote_id, + amount: new Decimal(fundOperationModel.crypto_amount.amount).mul(10 ** asset.decimals).toString(), + asset_id: fundOperationModel.crypto_amount.asset.asset_id, + }); + }); }); describe(".listFundOperations", () => { - it("should list fund operations", () => {}); - it("should handle pagination", () => {}); + it("should list fund operations", async () => { + const response = { + data: [VALID_FUND_OPERATION_MODEL], + has_more: false, + next_page: "", + total_count: 0, + } as FundOperationList; + Coinbase.apiClients.fund!.listFundOperations = mockReturnValue(response); + const paginationResponse = await FundOperation.listFundOperations(fundOperationModel.wallet_id, fundOperationModel.address_id); + const fundOperations = paginationResponse.data; + expect(fundOperations).toHaveLength(1); + expect(fundOperations[0]).toBeInstanceOf(FundOperation); + expect(Coinbase.apiClients.fund!.listFundOperations).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.fund!.listFundOperations).toHaveBeenCalledWith( + fundOperationModel.wallet_id, + fundOperationModel.address_id, + 100, + undefined, + ); + }); + it("should handle pagination", async () => { + const response = { + data: [VALID_FUND_OPERATION_MODEL], + has_more: true, + next_page: "abc", + total_count: 0, + } as FundOperationList; + Coinbase.apiClients.fund!.listFundOperations = mockReturnValue(response); + const paginationResponse = await FundOperation.listFundOperations(fundOperationModel.wallet_id, fundOperationModel.address_id); + expect(paginationResponse.nextPage).toEqual("abc"); + expect(paginationResponse.hasMore).toEqual(true); + const fundOperations = paginationResponse.data; + expect(fundOperations).toHaveLength(1); + expect(fundOperations[0]).toBeInstanceOf(FundOperation); + expect(Coinbase.apiClients.fund!.listFundOperations).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.fund!.listFundOperations).toHaveBeenCalledWith( + fundOperationModel.wallet_id, + fundOperationModel.address_id, + 100, + undefined, + ); + }); }); describe("#getId", () => { - it("should return the fund operation ID", () => {}); + it("should return the fund operation ID", () => { + expect(fundOperation.getId()).toEqual(fundOperationModel.fund_operation_id); + }); }); describe("#getNetworkId", () => { - it("should return the network ID", () => {}); + it("should return the network ID", () => { + expect(fundOperation.getNetworkId()).toEqual(fundOperationModel.network_id); + }); }); describe("#getWalletId", () => { - it("should return the wallet ID", () => {}); + it("should return the wallet ID", () => { + expect(fundOperation.getWalletId()).toEqual(fundOperationModel.wallet_id); + }); }); describe("#getAddressId", () => { - it("should return the address ID", () => {}); + it("should return the address ID", () => { + expect(fundOperation.getAddressId()).toEqual(fundOperationModel.address_id); + }); }); describe("#getAsset", () => { - it("should return the asset", () => {}); + it("should return the asset", () => { + expect(fundOperation.getAsset()).toEqual(asset); + }); }); describe("#getAmount", () => { - it("should return the amount", () => {}); + it("should return the amount", () => { + expect(fundOperation.getAmount()).toEqual(CryptoAmount.fromModel(fundOperationModel.crypto_amount)); + }); }); describe("#getFiatAmount", () => { - it("should return the fiat amount", () => {}); + it("should return the fiat amount", () => { + expect(fundOperation.getFiatAmount()).toEqual(new Decimal(fundOperationModel.fiat_amount.amount)); + }); }); describe("#getFiatCurrency", () => { - it("should return the fiat currency", () => {}); + it("should return the fiat currency", () => { + expect(fundOperation.getFiatCurrency()).toEqual(fundOperationModel.fiat_amount.currency); + }); }); describe("#getStatus", () => { - it("should return the current status", () => {}); + it("should return the current status", () => { + expect(fundOperation.getStatus()).toEqual(fundOperationModel.status); + }); }); describe("#reload", () => { - it("should reload the fund operation from server", () => {}); + it("should return PENDING when the fund operation has not been created", async () => { + Coinbase.apiClients.fund!.getFundOperation = mockReturnValue({ + ...VALID_FUND_OPERATION_MODEL, + status: FundOperationStatusEnum.Pending, + }); + await fundOperation.reload(); + expect(fundOperation.getStatus()).toEqual(FundOperationStatus.PENDING); + expect(Coinbase.apiClients.fund!.getFundOperation).toHaveBeenCalledTimes(1); + }); + + it("should return COMPLETE when the fund operation is complete", async () => { + Coinbase.apiClients.fund!.getFundOperation = mockReturnValue({ + ...VALID_FUND_OPERATION_MODEL, + status: FundOperationStatusEnum.Complete, + }); + await fundOperation.reload(); + expect(fundOperation.getStatus()).toEqual(FundOperationStatus.COMPLETE); + expect(Coinbase.apiClients.fund!.getFundOperation).toHaveBeenCalledTimes(1); + }); + + it("should return FAILED when the fund operation has failed", async () => { + Coinbase.apiClients.fund!.getFundOperation = mockReturnValue({ + ...VALID_FUND_OPERATION_MODEL, + status: FundOperationStatusEnum.Failed, + }); + await fundOperation.reload(); + expect(fundOperation.getStatus()).toEqual(FundOperationStatus.FAILED); + expect(Coinbase.apiClients.fund!.getFundOperation).toHaveBeenCalledTimes(1); + }); }); describe("#wait", () => { - it("should wait for operation to complete", () => {}); - it("should throw timeout error if operation takes too long", () => {}); - it("should handle terminal states correctly", () => {}); + it("should wait for operation to complete", async () => { + Coinbase.apiClients.fund!.getFundOperation = mockReturnValue({ + ...VALID_FUND_OPERATION_MODEL, + status: FundOperationStatusEnum.Complete, + }); + const completedFundOperation = await fundOperation.wait(); + expect(completedFundOperation).toBeInstanceOf(FundOperation); + expect(completedFundOperation.getStatus()).toEqual(FundOperationStatus.COMPLETE); + expect(Coinbase.apiClients.fund!.getFundOperation).toHaveBeenCalledTimes(1); + }); + it("should return the failed fund operation when the operation has failed", async () => { + Coinbase.apiClients.fund!.getFundOperation = mockReturnValue({ + ...VALID_FUND_OPERATION_MODEL, + status: FundOperationStatusEnum.Failed, + }); + const completedFundOperation = await fundOperation.wait(); + expect(completedFundOperation).toBeInstanceOf(FundOperation); + expect(completedFundOperation.getStatus()).toEqual(FundOperationStatus.FAILED); + expect(Coinbase.apiClients.fund!.getFundOperation).toHaveBeenCalledTimes(1); + }); + it("should throw an error when the fund operation has not been created", async () => { + Coinbase.apiClients.fund!.getFundOperation = mockReturnValue({ + ...VALID_FUND_OPERATION_MODEL, + status: FundOperationStatus.PENDING, + }); + await expect(fundOperation.wait({ timeoutSeconds: 0.05, intervalSeconds: 0.05 })).rejects.toThrow(new TimeoutError("Fund operation timed out")); + }); }); }); diff --git a/src/tests/utils.ts b/src/tests/utils.ts index dfc14e72..f5fc3c99 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -436,6 +436,34 @@ export const VALID_FUND_QUOTE_MODEL: FundQuoteModel = { }, }; +export const VALID_FUND_OPERATION_MODEL: FundOperationModel = { + fund_operation_id: "test-operation-id", + network_id: Coinbase.networks.BaseSepolia, + wallet_id: "test-wallet-id", + address_id: "test-address-id", + crypto_amount: VALID_ETH_CRYPTO_AMOUNT_MODEL, + fiat_amount: { + amount: "100", + currency: "USD" + }, + fees: { + buy_fee: { + amount: "1", + currency: "USD" + }, + transfer_fee: { + amount: "10000000000000000", // 0.01 ETH in wei + asset: { + asset_id: Coinbase.assets.Eth, + network_id: Coinbase.networks.BaseSepolia, + decimals: 18, + contract_address: "0x", + } + } + }, + status: "complete" as const +}; + /** * mockStakingOperation returns a mock StakingOperation object with the provided status. * From 073b1aea7dafc78e211e0fd1f721911e6c475c82 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Mon, 25 Nov 2024 11:33:35 -0500 Subject: [PATCH 10/13] fix --- src/tests/wallet_address_fund_test.ts | 112 ++++++++++++++++++++++++ src/tests/wallet_fund_test.ts | 117 ++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 src/tests/wallet_address_fund_test.ts create mode 100644 src/tests/wallet_fund_test.ts diff --git a/src/tests/wallet_address_fund_test.ts b/src/tests/wallet_address_fund_test.ts new file mode 100644 index 00000000..86b8ac8f --- /dev/null +++ b/src/tests/wallet_address_fund_test.ts @@ -0,0 +1,112 @@ +import { WalletAddress } from "../coinbase/address/wallet_address"; +import { FundOperation } from "../coinbase/fund_operation"; +import { FundQuote } from "../coinbase/fund_quote"; +import { newAddressModel } from "./utils"; +import { Decimal } from "decimal.js"; + +describe("WalletAddress Fund", () => { + let walletAddress: WalletAddress; + const walletId = "test-wallet-id"; + const addressId = "0x123abc..."; + + beforeEach(() => { + walletAddress = new WalletAddress(newAddressModel(walletId, addressId)); + + jest.spyOn(FundOperation, 'create').mockResolvedValue({} as FundOperation); + jest.spyOn(FundQuote, 'create').mockResolvedValue({} as FundQuote); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("#fund", () => { + it("should call FundOperation.create with correct parameters when passing in decimal amount", async () => { + const amount = new Decimal("1.0"); + const assetId = "eth"; + + await walletAddress.fund(amount, assetId); + + expect(FundOperation.create).toHaveBeenCalledWith( + walletId, + addressId, + amount, + assetId, + walletAddress.getNetworkId() + ); + }); + it("should call FundOperation.create with correct parameters when passing in number amount", async () => { + const amount = 1; + const assetId = "eth"; + + await walletAddress.fund(amount, assetId); + + expect(FundOperation.create).toHaveBeenCalledWith( + walletId, + addressId, + new Decimal(amount), + assetId, + walletAddress.getNetworkId() + ); + }); + it("should call FundOperation.create with correct parameters when passing in bigint amount", async () => { + const amount = BigInt(1); + const assetId = "eth"; + + await walletAddress.fund(amount, assetId); + + expect(FundOperation.create).toHaveBeenCalledWith( + walletId, + addressId, + new Decimal(amount.toString()), + assetId, + walletAddress.getNetworkId() + ); + }); + }); + + describe("#quoteFund", () => { + it("should call FundQuote.create with correct parameters when passing in decimal amount", async () => { + const amount = new Decimal("1.0"); + const assetId = "eth"; + + await walletAddress.quoteFund(amount, assetId); + + expect(FundQuote.create).toHaveBeenCalledWith( + walletId, + addressId, + amount, + assetId, + walletAddress.getNetworkId() + ); + }); + it("should call FundQuote.create with correct parameters when passing in number amount", async () => { + const amount = 1; + const assetId = "eth"; + + await walletAddress.quoteFund(amount, assetId); + + expect(FundQuote.create).toHaveBeenCalledWith( + walletId, + addressId, + new Decimal(amount), + assetId, + walletAddress.getNetworkId() + ); + }); + it("should call FundQuote.create with correct parameters when passing in bigint amount", async () => { + const amount = BigInt(1); + const assetId = "eth"; + + await walletAddress.quoteFund(amount, assetId); + + expect(FundQuote.create).toHaveBeenCalledWith( + walletId, + addressId, + new Decimal(amount.toString()), + assetId, + walletAddress.getNetworkId() + ); + }); + }); +}); diff --git a/src/tests/wallet_fund_test.ts b/src/tests/wallet_fund_test.ts new file mode 100644 index 00000000..9a7b6e09 --- /dev/null +++ b/src/tests/wallet_fund_test.ts @@ -0,0 +1,117 @@ +import { Wallet } from "../coinbase/wallet"; +import { WalletAddress } from "../coinbase/address/wallet_address"; +import { FundOperation } from "../coinbase/fund_operation"; +import { FundQuote } from "../coinbase/fund_quote"; +import { newAddressModel } from "./utils"; +import { Decimal } from "decimal.js"; +import { Coinbase } from ".."; +import { FeatureSet, Wallet as WalletModel } from "../client/api"; + +describe("Wallet Fund", () => { + let wallet: Wallet; + let walletModel: WalletModel; + let defaultAddress: WalletAddress; + const walletId = "test-wallet-id"; + const addressId = "0x123abc..."; + + beforeEach(() => { + const addressModel = newAddressModel(walletId, addressId); + defaultAddress = new WalletAddress(addressModel); + + walletModel = { + id: walletId, + network_id: Coinbase.networks.BaseSepolia, + default_address: addressModel, + feature_set: {} as FeatureSet, + }; + + wallet = Wallet.init(walletModel, ""); + + // Mock getDefaultAddress to return our test address + jest.spyOn(wallet, "getDefaultAddress").mockResolvedValue(defaultAddress); + + // Mock the fund and quoteFund methods on the default address + jest.spyOn(defaultAddress, "fund").mockResolvedValue({} as FundOperation); + jest.spyOn(defaultAddress, "quoteFund").mockResolvedValue({} as FundQuote); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("#fund", () => { + it("should call defaultAddress.fund with correct parameters when passing in decimal amount", async () => { + const amount = new Decimal("1.0"); + const assetId = "eth"; + + await wallet.fund(amount, assetId); + + expect(defaultAddress.fund).toHaveBeenCalledWith(amount, assetId); + }); + + it("should call defaultAddress.fund with correct parameters when passing in number amount", async () => { + const amount = 1; + const assetId = "eth"; + + await wallet.fund(amount, assetId); + + expect(defaultAddress.fund).toHaveBeenCalledWith(amount, assetId); + }); + + it("should call defaultAddress.fund with correct parameters when passing in bigint amount", async () => { + const amount = BigInt(1); + const assetId = "eth"; + + await wallet.fund(amount, assetId); + + expect(defaultAddress.fund).toHaveBeenCalledWith(amount, assetId); + }); + + it("should throw error if default address does not exist", async () => { + jest.spyOn(wallet, "getDefaultAddress").mockRejectedValue(new Error("Default address does not exist")); + + const amount = new Decimal("1.0"); + const assetId = "eth"; + + await expect(wallet.fund(amount, assetId)).rejects.toThrow("Default address does not exist"); + }); + }); + + describe("#quoteFund", () => { + it("should call defaultAddress.quoteFund with correct parameters when passing in decimal amount", async () => { + const amount = new Decimal("1.0"); + const assetId = "eth"; + + await wallet.quoteFund(amount, assetId); + + expect(defaultAddress.quoteFund).toHaveBeenCalledWith(amount, assetId); + }); + + it("should call defaultAddress.quoteFund with correct parameters when passing in number amount", async () => { + const amount = 1; + const assetId = "eth"; + + await wallet.quoteFund(amount, assetId); + + expect(defaultAddress.quoteFund).toHaveBeenCalledWith(amount, assetId); + }); + + it("should call defaultAddress.quoteFund with correct parameters when passing in bigint amount", async () => { + const amount = BigInt(1); + const assetId = "eth"; + + await wallet.quoteFund(amount, assetId); + + expect(defaultAddress.quoteFund).toHaveBeenCalledWith(amount, assetId); + }); + + it("should throw error if default address does not exist", async () => { + jest.spyOn(wallet, "getDefaultAddress").mockRejectedValue(new Error("Default address does not exist")); + + const amount = new Decimal("1.0"); + const assetId = "eth"; + + await expect(wallet.quoteFund(amount, assetId)).rejects.toThrow("Default address does not exist"); + }); + }); +}); From e1c0acb970e7192eaef769b8bb610c2f4dd13ce2 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Mon, 25 Nov 2024 11:35:37 -0500 Subject: [PATCH 11/13] lint format --- src/tests/fund_operation_test.ts | 80 ++++++++++++++++++++------- src/tests/utils.ts | 36 ++++++------ src/tests/wallet_address_fund_test.ts | 16 +++--- src/tests/wallet_fund_test.ts | 30 ++++++---- 4 files changed, 105 insertions(+), 57 deletions(-) diff --git a/src/tests/fund_operation_test.ts b/src/tests/fund_operation_test.ts index a981544a..ee8b32cd 100644 --- a/src/tests/fund_operation_test.ts +++ b/src/tests/fund_operation_test.ts @@ -1,4 +1,9 @@ -import { FundOperation as FundOperationModel, Asset as AssetModel, FundOperationList, FundOperationStatusEnum } from "../client/api"; +import { + FundOperation as FundOperationModel, + Asset as AssetModel, + FundOperationList, + FundOperationStatusEnum, +} from "../client/api"; import { Coinbase } from "../coinbase/coinbase"; import { VALID_ASSET_MODEL, @@ -17,7 +22,7 @@ import { TimeoutError } from "../coinbase/errors"; import { FundOperationStatus } from "../coinbase/types"; describe("FundOperation", () => { - let assetModel: AssetModel; + let assetModel: AssetModel; let asset: Asset; let fundOperationModel: FundOperationModel; let fundOperation: FundOperation; @@ -48,22 +53,47 @@ describe("FundOperation", () => { describe(".create", () => { it("should create a new fund operation without quote", async () => { - const newFundOperation = await FundOperation.create(fundOperationModel.wallet_id, fundOperationModel.address_id, new Decimal(fundOperationModel.crypto_amount.amount), fundOperationModel.crypto_amount.asset.asset_id, fundOperationModel.network_id); + const newFundOperation = await FundOperation.create( + fundOperationModel.wallet_id, + fundOperationModel.address_id, + new Decimal(fundOperationModel.crypto_amount.amount), + fundOperationModel.crypto_amount.asset.asset_id, + fundOperationModel.network_id, + ); expect(newFundOperation).toBeInstanceOf(FundOperation); - expect(Coinbase.apiClients.fund!.createFundOperation).toHaveBeenCalledWith(fundOperationModel.wallet_id, fundOperationModel.address_id, { - fund_quote_id: undefined, - amount: new Decimal(fundOperationModel.crypto_amount.amount).mul(10 ** asset.decimals).toString(), - asset_id: fundOperationModel.crypto_amount.asset.asset_id, - }); + expect(Coinbase.apiClients.fund!.createFundOperation).toHaveBeenCalledWith( + fundOperationModel.wallet_id, + fundOperationModel.address_id, + { + fund_quote_id: undefined, + amount: new Decimal(fundOperationModel.crypto_amount.amount) + .mul(10 ** asset.decimals) + .toString(), + asset_id: fundOperationModel.crypto_amount.asset.asset_id, + }, + ); }); it("should create a new fund operation with quote", async () => { - const newFundOperation = await FundOperation.create(fundOperationModel.wallet_id, fundOperationModel.address_id, new Decimal(fundOperationModel.crypto_amount.amount), fundOperationModel.crypto_amount.asset.asset_id, fundOperationModel.network_id, FundQuote.fromModel(VALID_FUND_QUOTE_MODEL)); + const newFundOperation = await FundOperation.create( + fundOperationModel.wallet_id, + fundOperationModel.address_id, + new Decimal(fundOperationModel.crypto_amount.amount), + fundOperationModel.crypto_amount.asset.asset_id, + fundOperationModel.network_id, + FundQuote.fromModel(VALID_FUND_QUOTE_MODEL), + ); expect(newFundOperation).toBeInstanceOf(FundOperation); - expect(Coinbase.apiClients.fund!.createFundOperation).toHaveBeenCalledWith(fundOperationModel.wallet_id, fundOperationModel.address_id, { - fund_quote_id: VALID_FUND_QUOTE_MODEL.fund_quote_id, - amount: new Decimal(fundOperationModel.crypto_amount.amount).mul(10 ** asset.decimals).toString(), - asset_id: fundOperationModel.crypto_amount.asset.asset_id, - }); + expect(Coinbase.apiClients.fund!.createFundOperation).toHaveBeenCalledWith( + fundOperationModel.wallet_id, + fundOperationModel.address_id, + { + fund_quote_id: VALID_FUND_QUOTE_MODEL.fund_quote_id, + amount: new Decimal(fundOperationModel.crypto_amount.amount) + .mul(10 ** asset.decimals) + .toString(), + asset_id: fundOperationModel.crypto_amount.asset.asset_id, + }, + ); }); }); @@ -76,7 +106,10 @@ describe("FundOperation", () => { total_count: 0, } as FundOperationList; Coinbase.apiClients.fund!.listFundOperations = mockReturnValue(response); - const paginationResponse = await FundOperation.listFundOperations(fundOperationModel.wallet_id, fundOperationModel.address_id); + const paginationResponse = await FundOperation.listFundOperations( + fundOperationModel.wallet_id, + fundOperationModel.address_id, + ); const fundOperations = paginationResponse.data; expect(fundOperations).toHaveLength(1); expect(fundOperations[0]).toBeInstanceOf(FundOperation); @@ -96,7 +129,10 @@ describe("FundOperation", () => { total_count: 0, } as FundOperationList; Coinbase.apiClients.fund!.listFundOperations = mockReturnValue(response); - const paginationResponse = await FundOperation.listFundOperations(fundOperationModel.wallet_id, fundOperationModel.address_id); + const paginationResponse = await FundOperation.listFundOperations( + fundOperationModel.wallet_id, + fundOperationModel.address_id, + ); expect(paginationResponse.nextPage).toEqual("abc"); expect(paginationResponse.hasMore).toEqual(true); const fundOperations = paginationResponse.data; @@ -144,13 +180,17 @@ describe("FundOperation", () => { describe("#getAmount", () => { it("should return the amount", () => { - expect(fundOperation.getAmount()).toEqual(CryptoAmount.fromModel(fundOperationModel.crypto_amount)); + expect(fundOperation.getAmount()).toEqual( + CryptoAmount.fromModel(fundOperationModel.crypto_amount), + ); }); }); describe("#getFiatAmount", () => { it("should return the fiat amount", () => { - expect(fundOperation.getFiatAmount()).toEqual(new Decimal(fundOperationModel.fiat_amount.amount)); + expect(fundOperation.getFiatAmount()).toEqual( + new Decimal(fundOperationModel.fiat_amount.amount), + ); }); }); @@ -224,7 +264,9 @@ describe("FundOperation", () => { ...VALID_FUND_OPERATION_MODEL, status: FundOperationStatus.PENDING, }); - await expect(fundOperation.wait({ timeoutSeconds: 0.05, intervalSeconds: 0.05 })).rejects.toThrow(new TimeoutError("Fund operation timed out")); + await expect( + fundOperation.wait({ timeoutSeconds: 0.05, intervalSeconds: 0.05 }), + ).rejects.toThrow(new TimeoutError("Fund operation timed out")); }); }); }); diff --git a/src/tests/utils.ts b/src/tests/utils.ts index f5fc3c99..08893a75 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -439,29 +439,29 @@ export const VALID_FUND_QUOTE_MODEL: FundQuoteModel = { export const VALID_FUND_OPERATION_MODEL: FundOperationModel = { fund_operation_id: "test-operation-id", network_id: Coinbase.networks.BaseSepolia, - wallet_id: "test-wallet-id", + wallet_id: "test-wallet-id", address_id: "test-address-id", crypto_amount: VALID_ETH_CRYPTO_AMOUNT_MODEL, fiat_amount: { - amount: "100", - currency: "USD" + amount: "100", + currency: "USD", }, fees: { - buy_fee: { - amount: "1", - currency: "USD" - }, - transfer_fee: { - amount: "10000000000000000", // 0.01 ETH in wei - asset: { - asset_id: Coinbase.assets.Eth, - network_id: Coinbase.networks.BaseSepolia, - decimals: 18, - contract_address: "0x", - } - } - }, - status: "complete" as const + buy_fee: { + amount: "1", + currency: "USD", + }, + transfer_fee: { + amount: "10000000000000000", // 0.01 ETH in wei + asset: { + asset_id: Coinbase.assets.Eth, + network_id: Coinbase.networks.BaseSepolia, + decimals: 18, + contract_address: "0x", + }, + }, + }, + status: "complete" as const, }; /** diff --git a/src/tests/wallet_address_fund_test.ts b/src/tests/wallet_address_fund_test.ts index 86b8ac8f..4050d096 100644 --- a/src/tests/wallet_address_fund_test.ts +++ b/src/tests/wallet_address_fund_test.ts @@ -12,8 +12,8 @@ describe("WalletAddress Fund", () => { beforeEach(() => { walletAddress = new WalletAddress(newAddressModel(walletId, addressId)); - jest.spyOn(FundOperation, 'create').mockResolvedValue({} as FundOperation); - jest.spyOn(FundQuote, 'create').mockResolvedValue({} as FundQuote); + jest.spyOn(FundOperation, "create").mockResolvedValue({} as FundOperation); + jest.spyOn(FundQuote, "create").mockResolvedValue({} as FundQuote); }); afterEach(() => { @@ -32,7 +32,7 @@ describe("WalletAddress Fund", () => { addressId, amount, assetId, - walletAddress.getNetworkId() + walletAddress.getNetworkId(), ); }); it("should call FundOperation.create with correct parameters when passing in number amount", async () => { @@ -46,7 +46,7 @@ describe("WalletAddress Fund", () => { addressId, new Decimal(amount), assetId, - walletAddress.getNetworkId() + walletAddress.getNetworkId(), ); }); it("should call FundOperation.create with correct parameters when passing in bigint amount", async () => { @@ -60,7 +60,7 @@ describe("WalletAddress Fund", () => { addressId, new Decimal(amount.toString()), assetId, - walletAddress.getNetworkId() + walletAddress.getNetworkId(), ); }); }); @@ -77,7 +77,7 @@ describe("WalletAddress Fund", () => { addressId, amount, assetId, - walletAddress.getNetworkId() + walletAddress.getNetworkId(), ); }); it("should call FundQuote.create with correct parameters when passing in number amount", async () => { @@ -91,7 +91,7 @@ describe("WalletAddress Fund", () => { addressId, new Decimal(amount), assetId, - walletAddress.getNetworkId() + walletAddress.getNetworkId(), ); }); it("should call FundQuote.create with correct parameters when passing in bigint amount", async () => { @@ -105,7 +105,7 @@ describe("WalletAddress Fund", () => { addressId, new Decimal(amount.toString()), assetId, - walletAddress.getNetworkId() + walletAddress.getNetworkId(), ); }); }); diff --git a/src/tests/wallet_fund_test.ts b/src/tests/wallet_fund_test.ts index 9a7b6e09..4c5caa2f 100644 --- a/src/tests/wallet_fund_test.ts +++ b/src/tests/wallet_fund_test.ts @@ -19,17 +19,17 @@ describe("Wallet Fund", () => { defaultAddress = new WalletAddress(addressModel); walletModel = { - id: walletId, - network_id: Coinbase.networks.BaseSepolia, - default_address: addressModel, - feature_set: {} as FeatureSet, - }; - + id: walletId, + network_id: Coinbase.networks.BaseSepolia, + default_address: addressModel, + feature_set: {} as FeatureSet, + }; + wallet = Wallet.init(walletModel, ""); // Mock getDefaultAddress to return our test address jest.spyOn(wallet, "getDefaultAddress").mockResolvedValue(defaultAddress); - + // Mock the fund and quoteFund methods on the default address jest.spyOn(defaultAddress, "fund").mockResolvedValue({} as FundOperation); jest.spyOn(defaultAddress, "quoteFund").mockResolvedValue({} as FundQuote); @@ -68,8 +68,10 @@ describe("Wallet Fund", () => { }); it("should throw error if default address does not exist", async () => { - jest.spyOn(wallet, "getDefaultAddress").mockRejectedValue(new Error("Default address does not exist")); - + jest + .spyOn(wallet, "getDefaultAddress") + .mockRejectedValue(new Error("Default address does not exist")); + const amount = new Decimal("1.0"); const assetId = "eth"; @@ -106,12 +108,16 @@ describe("Wallet Fund", () => { }); it("should throw error if default address does not exist", async () => { - jest.spyOn(wallet, "getDefaultAddress").mockRejectedValue(new Error("Default address does not exist")); - + jest + .spyOn(wallet, "getDefaultAddress") + .mockRejectedValue(new Error("Default address does not exist")); + const amount = new Decimal("1.0"); const assetId = "eth"; - await expect(wallet.quoteFund(amount, assetId)).rejects.toThrow("Default address does not exist"); + await expect(wallet.quoteFund(amount, assetId)).rejects.toThrow( + "Default address does not exist", + ); }); }); }); From 7034e86f3d05c93d831d0086172ca80149ded089 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Wed, 27 Nov 2024 12:38:37 -0500 Subject: [PATCH 12/13] Fix --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc64bc56..0c7379fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +## Added + +- Add `FundOperation` and `FundQuote` classes to support wallet funding + ### Fixed - Fixed a bug where the asset ID was not being set correctly for Gwei and Wei From f59b791b3190b41668d7ccc33dc2cfbe5e12c7e1 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Wed, 27 Nov 2024 13:49:20 -0500 Subject: [PATCH 13/13] Change --- src/coinbase/fund_operation.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/coinbase/fund_operation.ts b/src/coinbase/fund_operation.ts index 80c1fbea..73edf3d9 100644 --- a/src/coinbase/fund_operation.ts +++ b/src/coinbase/fund_operation.ts @@ -247,7 +247,6 @@ export class FundOperation { while (Date.now() - startTime < timeoutSeconds * 1000) { await this.reload(); - // If the FundOperation is in a terminal state, return the FundOperation if (this.isTerminalState()) { return this; }