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 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/address/wallet_address.ts b/src/coinbase/address/wallet_address.ts index dca794c5..31c15181 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,44 @@ 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/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, diff --git a/src/coinbase/crypto_amount.ts b/src/coinbase/crypto_amount.ts new file mode 100644 index 00000000..7ac1dd39 --- /dev/null +++ b/src/coinbase/crypto_amount.ts @@ -0,0 +1,99 @@ +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) { + 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, assetId); + 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/fiat_amount.ts b/src/coinbase/fiat_amount.ts new file mode 100644 index 00000000..165c8612 --- /dev/null +++ b/src/coinbase/fiat_amount.ts @@ -0,0 +1,57 @@ +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}')`; + } +} diff --git a/src/coinbase/fund_operation.ts b/src/coinbase/fund_operation.ts new file mode 100644 index 00000000..73edf3d9 --- /dev/null +++ b/src/coinbase/fund_operation.ts @@ -0,0 +1,268 @@ +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 { FundOperationStatus, PaginationOptions, PaginationResponse } from "./types"; +import { CryptoAmount } from "./crypto_amount"; + +/** + * A representation of a Fund Operation. + */ +export class FundOperation { + /** + * Fund Operation status constants. + */ + public static readonly Status = { + 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) { + 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. + * + * @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 FundOperation.fromModel(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(FundOperation.fromModel(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 {CryptoAmount} The crypto 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; + } + + /** + * Returns the Status of the Transfer. + * + * @returns The Status of the Transfer. + */ + 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}`); + } + } + + /** + * 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 (Date.now() - startTime < timeoutSeconds * 1000) { + await this.reload(); + + if (this.isTerminalState()) { + return this; + } + + await delay(intervalSeconds); + } + + throw new TimeoutError("Fund operation timed out"); + } + + /** + * 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..e82f8128 --- /dev/null +++ b/src/coinbase/fund_quote.ts @@ -0,0 +1,172 @@ +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; + } + + /** + * 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. + * + * @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 FundQuote.fromModel(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 61a1131c..f89c31e5 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"; @@ -721,6 +726,7 @@ export type ApiClients = { balanceHistory?: BalanceHistoryApiClient; transactionHistory?: TransactionHistoryApiClient; smartContract?: SmartContractAPIClient; + fund?: FundOperationApiClient; }; /** @@ -794,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. @@ -1389,6 +1404,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. */ 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. * diff --git a/src/index.ts b/src/index.ts index 3b4bb154..d5412e89 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"; diff --git a/src/tests/crypto_amount_test.ts b/src/tests/crypto_amount_test.ts new file mode 100644 index 00000000..f10443ff --- /dev/null +++ b/src/tests/crypto_amount_test.ts @@ -0,0 +1,93 @@ +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().toString()).toEqual("1"); + }); + }); + + describe("#toString", () => { + it("should have correct string representation", () => { + expect(cryptoAmount.toString()).toEqual("CryptoAmount{amount: '0.000001', assetId: 'usdc'}"); + }); + }); +}); 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 new file mode 100644 index 00000000..c0e748c5 --- /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); + }); + }); +}); diff --git a/src/tests/fund_operation_test.ts b/src/tests/fund_operation_test.ts new file mode 100644 index 00000000..ee8b32cd --- /dev/null +++ b/src/tests/fund_operation_test.ts @@ -0,0 +1,272 @@ +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", () => { + expect(fundOperation).toBeInstanceOf(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, + ); + 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", 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", () => { + expect(fundOperation.getId()).toEqual(fundOperationModel.fund_operation_id); + }); + }); + + describe("#getNetworkId", () => { + it("should return the network ID", () => { + expect(fundOperation.getNetworkId()).toEqual(fundOperationModel.network_id); + }); + }); + + describe("#getWalletId", () => { + it("should return the wallet ID", () => { + expect(fundOperation.getWalletId()).toEqual(fundOperationModel.wallet_id); + }); + }); + + describe("#getAddressId", () => { + it("should return the address ID", () => { + expect(fundOperation.getAddressId()).toEqual(fundOperationModel.address_id); + }); + }); + + describe("#getAsset", () => { + it("should return the asset", () => { + expect(fundOperation.getAsset()).toEqual(asset); + }); + }); + + describe("#getAmount", () => { + it("should return the 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), + ); + }); + }); + + describe("#getFiatCurrency", () => { + it("should return the fiat currency", () => { + expect(fundOperation.getFiatCurrency()).toEqual(fundOperationModel.fiat_amount.currency); + }); + }); + + describe("#getStatus", () => { + it("should return the current status", () => { + expect(fundOperation.getStatus()).toEqual(fundOperationModel.status); + }); + }); + + describe("#reload", () => { + 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", 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/fund_quote_test.ts b/src/tests/fund_quote_test.ts new file mode 100644 index 00000000..862a99eb --- /dev/null +++ b/src/tests/fund_quote_test.ts @@ -0,0 +1,156 @@ +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, + 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..d6c7c928 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/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 e95c3dc3..08893a75 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -17,6 +17,10 @@ import { PayloadSignatureStatusEnum, ContractInvocation as ContractInvocationModel, SmartContract as SmartContractModel, + CryptoAmount as CryptoAmountModel, + Asset as AssetModel, + FundQuote as FundQuoteModel, + FundOperation as FundOperationModel, SmartContractType, ValidatorList, Validator, @@ -32,6 +36,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 }); @@ -253,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: { @@ -369,6 +374,96 @@ 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, + }, +}; + +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, + }, + }, + }, +}; + +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. * @@ -678,6 +773,17 @@ export const contractInvocationApiMock = { broadcastContractInvocation: jest.fn(), }; +export const assetApiMock = { + getAsset: jest.fn(), +}; + +export const fundOperationsApiMock = { + getFundOperation: jest.fn(), + listFundOperations: jest.fn(), + createFundOperation: jest.fn(), + createFundQuote: jest.fn(), +}; + export const testAllReadTypesABI = [ { type: "function", diff --git a/src/tests/wallet_address_fund_test.ts b/src/tests/wallet_address_fund_test.ts new file mode 100644 index 00000000..4050d096 --- /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_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_fund_test.ts b/src/tests/wallet_fund_test.ts new file mode 100644 index 00000000..4c5caa2f --- /dev/null +++ b/src/tests/wallet_fund_test.ts @@ -0,0 +1,123 @@ +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", + ); + }); + }); +}); 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 () => {