diff --git a/package-lock.json b/package-lock.json index e885fe7e..abb02c56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@scure/bip32": "^1.4.0", "axios": "^1.6.8", "bip39": "^3.1.0", + "decimal.js": "^10.4.3", "ethers": "^6.12.1", "node-jose": "^2.2.0", "secp256k1": "^5.0.0" @@ -23,10 +24,13 @@ "@types/secp256k1": "^4.0.6", "@typescript-eslint/eslint-plugin": "^7.8.0", "@typescript-eslint/parser": "^7.8.0", + "axios-mock-adapter": "^1.22.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", + "eslint-plugin-jsdoc": "^48.2.5", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.7.0", + "mock-fs": "^5.2.0", "prettier": "^3.2.5", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", @@ -653,6 +657,23 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.43.0.tgz", + "integrity": "sha512-Q1CnsQrytI3TlCB1IVWXWeqUIPGVEKGaE7IbVdt13Nq/3i0JESAkQQERrfiQkmlpijl+++qyqPgaS31Bvc1jRQ==", + "dev": true, + "dependencies": { + "@types/eslint": "^8.56.5", + "@types/estree": "^1.0.5", + "@typescript-eslint/types": "^7.2.0", + "comment-parser": "1.4.1", + "esquery": "^1.5.0", + "jsdoc-type-pratt-parser": "~4.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1399,6 +1420,22 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/eslint": { + "version": "8.56.10", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", + "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1819,6 +1856,15 @@ "node": ">= 8" } }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -1855,6 +1901,19 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-mock-adapter": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.22.0.tgz", + "integrity": "sha512-dmI0KbkyAhntUR05YY96qg2H6gg0XMl2+qTW0xmYg6Up+BFBAJYRLROMXRdDEL06/Wqwa0TJThAYvFtSFdRCZw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2125,6 +2184,18 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2268,6 +2339,15 @@ "node": ">= 0.8" } }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2338,6 +2418,11 @@ } } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -2566,6 +2651,29 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-jsdoc": { + "version": "48.2.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.2.5.tgz", + "integrity": "sha512-ZeTfKV474W1N9niWfawpwsXGu+ZoMXu4417eBROX31d7ZuOk8zyG66SO77DpJ2+A9Wa2scw/jRqBPnnQo7VbcQ==", + "dev": true, + "dependencies": { + "@es-joy/jsdoccomment": "~0.43.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "esquery": "^1.5.0", + "is-builtin-module": "^3.2.1", + "semver": "^7.6.1", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, "node_modules/eslint-plugin-prettier": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", @@ -3299,6 +3407,44 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=4" + } + }, + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "dependencies": { + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-core-module": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", @@ -4025,6 +4171,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", + "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -4300,6 +4455,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mock-fs": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", + "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -4983,6 +5147,28 @@ "source-map": "^0.6.0" } }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", + "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", + "dev": true + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts index 3375179d..a72e2b6a 100644 --- a/src/coinbase/address.ts +++ b/src/coinbase/address.ts @@ -1,28 +1,40 @@ +import { ethers } from "ethers"; +import { Decimal } from "decimal.js"; import { Address as AddressModel } from "../client"; import { Balance } from "./balance"; import { BalanceMap } from "./balance_map"; import { Coinbase } from "./coinbase"; -import { InternalError } from "./errors"; +import { ArgumentError, InternalError } from "./errors"; import { FaucetTransaction } from "./faucet_transaction"; -import { Decimal } from "decimal.js"; +import { Amount, Destination, TransferStatus } from "./types"; +import { Transfer } from "./transfer"; +import { delay, destinationToAddressHexString } from "./utils"; +import { ATOMIC_UNITS_PER_USDC, WEI_PER_ETHER, WEI_PER_GWEI } from "./constants"; /** * A representation of a blockchain address, which is a user-controlled account on a network. */ export class Address { private model: AddressModel; + private key: ethers.Wallet; /** * Initializes a new Address instance. * - * @param {AddressModel} model - The address model data. - * @throws {InternalError} If the model or client is empty. + * @param model - The address model data. + * @param key - The ethers.js Wallet the Address uses to sign data. + * @throws {InternalError} If the model or key is empty. */ - constructor(model: AddressModel) { + constructor(model: AddressModel, key: ethers.Wallet) { if (!model) { throw new InternalError("Address model cannot be empty"); } + if (!key) { + throw new InternalError("Key cannot be empty"); + } + this.model = model; + this.key = key; } /** @@ -102,6 +114,108 @@ export class Address { return this.model.wallet_id; } + /** + * Sends an amount of an asset to a destination. + * + * @param amount - The amount to send. + * @param assetId - The asset ID to send. + * @param destination - The destination address. + * @param intervalSeconds - The interval at which to poll the Network for Transfer status, in seconds. + * @param timeoutSeconds - The maximum amount of time to wait for the Transfer to complete, in seconds. + * @returns The transfer object. + * @throws {APIError} if the API request to create a Transfer fails. + * @throws {APIError} if the API request to broadcast a Transfer fails. + * @throws {Error} if the Transfer times out. + */ + public async createTransfer( + amount: Amount, + assetId: string, + destination: Destination, + intervalSeconds = 0.2, + timeoutSeconds = 10, + ): Promise { + let normalizedAmount = new Decimal(amount.toString()); + + const currentBalance = await this.getBalance(assetId); + if (currentBalance.lessThan(normalizedAmount)) { + throw new ArgumentError( + `Insufficient funds: ${normalizedAmount} requested, but only ${currentBalance} available`, + ); + } + + switch (assetId) { + case Coinbase.assetList.Eth: + normalizedAmount = normalizedAmount.mul(WEI_PER_ETHER); + break; + case Coinbase.assetList.Gwei: + normalizedAmount = normalizedAmount.mul(WEI_PER_GWEI); + break; + case Coinbase.assetList.Wei: + break; + case Coinbase.assetList.Weth: + normalizedAmount = normalizedAmount.mul(WEI_PER_ETHER); + break; + case Coinbase.assetList.Usdc: + normalizedAmount = normalizedAmount.mul(ATOMIC_UNITS_PER_USDC); + break; + default: + throw new InternalError(`Unsupported asset ID: ${assetId}`); + } + + const normalizedDestination = destinationToAddressHexString(destination); + + const normalizedAssetId = ((): string => { + switch (assetId) { + case Coinbase.assetList.Gwei: + case Coinbase.assetList.Wei: + return Coinbase.assetList.Eth; + default: + return assetId; + } + })(); + + const createTransferRequest = { + amount: normalizedAmount.toFixed(0), + network_id: this.getNetworkId(), + asset_id: normalizedAssetId, + destination: normalizedDestination, + }; + + let response = await Coinbase.apiClients.transfer!.createTransfer( + this.getWalletId(), + this.getId(), + createTransferRequest, + ); + + const transfer = Transfer.fromModel(response.data); + const transaction = transfer.getTransaction(); + let signedPayload = await this.key.signTransaction(transaction); + signedPayload = signedPayload.slice(2); + + const broadcastTransferRequest = { + signed_payload: signedPayload, + }; + + response = await Coinbase.apiClients.transfer!.broadcastTransfer( + this.getWalletId(), + this.getId(), + transfer.getId(), + broadcastTransferRequest, + ); + + const updatedTransfer = Transfer.fromModel(response.data); + + const startTime = Date.now(); + while (Date.now() - startTime < timeoutSeconds * 1000) { + const status = await updatedTransfer.getStatus(); + if (status === TransferStatus.COMPLETE || status === TransferStatus.FAILED) { + return updatedTransfer; + } + await delay(intervalSeconds); + } + throw new Error("Transfer timed out"); + } + /** * Returns a string representation of the address. * diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index c4291cef..9ca680b7 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -1,10 +1,10 @@ import globalAxios from "axios"; import * as fs from "fs"; import { - AddressesApiFactory, User as UserModel, UsersApiFactory, TransfersApiFactory, + AddressesApiFactory, WalletsApiFactory, } from "../client"; import { ethers } from "ethers"; @@ -44,20 +44,6 @@ export class Coinbase { static apiClients: ApiClients = {}; - /** - * Represents the number of Wei per Ether. - * - * @constant - */ - static readonly WEI_PER_ETHER: bigint = BigInt("1000000000000000000"); - - /** - * Represents the number of Gwei per Ether. - * - * @constant - */ - static readonly GWEI_PER_ETHER: bigint = BigInt("1000000000"); - /** * The backup file path for Wallet seeds. * diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index 7e15b9ab..79cb47ed 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -1,6 +1,8 @@ import { Address } from "./../address"; +import * as crypto from "crypto"; +import { ethers } from "ethers"; import { FaucetTransaction } from "./../faucet_transaction"; - +import { Balance as BalanceModel } from "../../client"; import Decimal from "decimal.js"; import { APIError, FaucetLimitReachedError } from "../api_error"; import { Coinbase } from "../coinbase"; @@ -8,16 +10,22 @@ import { InternalError } from "../errors"; import { VALID_ADDRESS_BALANCE_LIST, VALID_ADDRESS_MODEL, + VALID_TRANSFER_MODEL, addressesApiMock, generateRandomHash, mockFn, mockReturnRejectedValue, + mockReturnValue, + transfersApiMock, } from "./utils"; +import { ArgumentError } from "../errors"; // Test suite for Address class describe("Address", () => { const transactionHash = generateRandomHash(); - let address: Address, balanceModel; + let address: Address; + let balanceModel: BalanceModel; + let key; beforeAll(() => { Coinbase.apiClients.address = addressesApiMock; @@ -41,7 +49,9 @@ describe("Address", () => { }); beforeEach(() => { - address = new Address(VALID_ADDRESS_MODEL); + key = ethers.Wallet.createRandom(); + address = new Address(VALID_ADDRESS_MODEL, key as unknown as ethers.Wallet); + jest.clearAllMocks(); }); @@ -85,7 +95,7 @@ describe("Address", () => { const assetId = "gwei"; const ethBalance = await address.getBalance(assetId); expect(ethBalance).toBeInstanceOf(Decimal); - expect(ethBalance).toEqual(new Decimal(1000000000)); + expect(ethBalance).toEqual(new Decimal("1000000000")); expect(Coinbase.apiClients.address!.getAddressBalance).toHaveBeenCalledWith( address.getWalletId(), address.getId(), @@ -98,7 +108,7 @@ describe("Address", () => { const assetId = "wei"; const ethBalance = await address.getBalance(assetId); expect(ethBalance).toBeInstanceOf(Decimal); - expect(ethBalance).toEqual(new Decimal(1000000000000000000)); + expect(ethBalance).toEqual(new Decimal("1000000000000000000")); expect(Coinbase.apiClients.address!.getAddressBalance).toHaveBeenCalledWith( address.getWalletId(), address.getId(), @@ -121,7 +131,9 @@ describe("Address", () => { }); it("should throw an InternalError when model is not provided", () => { - expect(() => new Address(null!)).toThrow(`Address model cannot be empty`); + expect(() => new Address(null!, key as unknown as ethers.Wallet)).toThrow( + `Address model cannot be empty`, + ); }); it("should request funds from the faucet and returns the faucet transaction", async () => { @@ -166,4 +178,131 @@ describe("Address", () => { `Coinbase:Address{addressId: '${VALID_ADDRESS_MODEL.address_id}', networkId: '${VALID_ADDRESS_MODEL.network_id}', walletId: '${VALID_ADDRESS_MODEL.wallet_id}'}`, ); }); + + describe(".createTransfer", () => { + let weiAmount, destination, intervalSeconds, timeoutSeconds; + let walletId, id; + + const mockProvider = new ethers.JsonRpcProvider( + "https://sepolia.base.org", + ) as jest.Mocked; + mockProvider.getTransaction = jest.fn(); + mockProvider.getTransactionReceipt = jest.fn(); + Coinbase.apiClients.baseSepoliaProvider = mockProvider; + + beforeEach(() => { + weiAmount = new Decimal("500000000000000000"); + destination = new Address(VALID_ADDRESS_MODEL, key as unknown as ethers.Wallet); + intervalSeconds = 0.2; + timeoutSeconds = 10; + walletId = crypto.randomUUID(); + id = crypto.randomUUID(); + Coinbase.apiClients.address!.getAddressBalance = mockFn(request => { + const { asset_id } = request; + balanceModel = { + amount: "1000000000000000000", + asset: { + asset_id, + network_id: Coinbase.networkList.BaseSepolia, + }, + }; + return { data: balanceModel }; + }); + + Coinbase.apiClients.transfer = transfersApiMock; + }); + + it("should successfully create and complete a transfer", async () => { + Coinbase.apiClients.transfer!.createTransfer = mockReturnValue(VALID_TRANSFER_MODEL); + Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnValue({ + transaction_hash: "0x6c087c1676e8269dd81e0777244584d0cbfd39b6997b3477242a008fa9349e11", + ...VALID_TRANSFER_MODEL, + }); + mockProvider.getTransaction.mockResolvedValueOnce({ + blockHash: "0xdeadbeef", + } as ethers.TransactionResponse); + mockProvider.getTransactionReceipt.mockResolvedValueOnce({ + status: 1, + } as ethers.TransactionReceipt); + + const transfer = await address.createTransfer( + weiAmount, + Coinbase.assetList.Wei, + destination, + intervalSeconds, + timeoutSeconds, + ); + + expect(Coinbase.apiClients.transfer!.createTransfer).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.transfer!.broadcastTransfer).toHaveBeenCalledTimes(1); + }); + + it("should throw an APIError if the createTransfer API call fails", async () => { + Coinbase.apiClients.transfer!.createTransfer = mockReturnRejectedValue( + new APIError("Failed to create transfer"), + ); + await expect( + address.createTransfer( + weiAmount, + Coinbase.assetList.Wei, + destination, + intervalSeconds, + timeoutSeconds, + ), + ).rejects.toThrow(APIError); + }); + + it("should throw an APIError if the broadcastTransfer API call fails", async () => { + Coinbase.apiClients.transfer!.createTransfer = mockReturnValue(VALID_TRANSFER_MODEL); + Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnRejectedValue( + new APIError("Failed to broadcast transfer"), + ); + await expect( + address.createTransfer( + weiAmount, + Coinbase.assetList.Wei, + destination, + intervalSeconds, + timeoutSeconds, + ), + ).rejects.toThrow(APIError); + }); + + it("should throw an Error if the transfer times out", async () => { + Coinbase.apiClients.transfer!.createTransfer = mockReturnValue(VALID_TRANSFER_MODEL); + Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnValue({ + transaction_hash: "0x6c087c1676e8269dd81e0777244584d0cbfd39b6997b3477242a008fa9349e11", + ...VALID_TRANSFER_MODEL, + }); + intervalSeconds = 0.000002; + timeoutSeconds = 0.000002; + + await expect( + address.createTransfer( + weiAmount, + Coinbase.assetList.Wei, + destination, + intervalSeconds, + timeoutSeconds, + ), + ).rejects.toThrow("Transfer timed out"); + }); + + it("should throw an ArgumentError if there are insufficient funds", async () => { + const insufficientAmount = new Decimal("10000000000000000000"); + await expect( + address.createTransfer( + insufficientAmount, + Coinbase.assetList.Wei, + destination, + intervalSeconds, + timeoutSeconds, + ), + ).rejects.toThrow(ArgumentError); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + }); }); diff --git a/src/coinbase/tests/coinbase_test.ts b/src/coinbase/tests/coinbase_test.ts index a3f03ae0..d6f75010 100644 --- a/src/coinbase/tests/coinbase_test.ts +++ b/src/coinbase/tests/coinbase_test.ts @@ -93,25 +93,6 @@ describe("Coinbase tests", () => { expect(Coinbase.apiClients.user!.getCurrentUser).toHaveBeenCalledWith(); expect(usersApiMock.getCurrentUser).toHaveBeenCalledTimes(1); }); - - it("should be able to get faucet funds", async () => { - const wallet = await user.createWallet(); - expect(wallet.getId()).toBe(walletId); - const payload = { wallet: { network_id: Coinbase.networkList.BaseSepolia } }; - expect(walletsApiMock.createWallet).toHaveBeenCalledWith(payload); - expect(walletsApiMock.createWallet).toHaveBeenCalledTimes(1); - - const defaultAddress = wallet.defaultAddress(); - expect(defaultAddress?.getId()).toBe(addressId); - - const faucetTransaction = await wallet?.faucet(); - expect(faucetTransaction.getTransactionHash()).toBe(transactionHash); - expect(addressesApiMock.requestFaucetFunds).toHaveBeenCalledWith( - defaultAddress.getWalletId(), - defaultAddress?.getId(), - ); - expect(addressesApiMock.requestFaucetFunds).toHaveBeenCalledTimes(1); - }); }); it("should raise an error if the user is not found", async () => { diff --git a/src/coinbase/tests/transfer_test.ts b/src/coinbase/tests/transfer_test.ts index 3d57161b..333811f2 100644 --- a/src/coinbase/tests/transfer_test.ts +++ b/src/coinbase/tests/transfer_test.ts @@ -1,31 +1,14 @@ import { ethers } from "ethers"; -import { Transfer as TransferModel, TransferStatusEnum } from "../../client/api"; -import { TransferAPIClient, TransferStatus } from "../types"; -import { Transfer, TransferClients } from "../transfer"; +import { Decimal } from "decimal.js"; +import { Transfer as TransferModel } from "../../client/api"; +import { TransferStatus } from "../types"; +import { Transfer } from "../transfer"; import { Coinbase } from "../coinbase"; +import { WEI_PER_ETHER } from "../constants"; +import { VALID_TRANSFER_MODEL } from "./utils"; -const fromKey = ethers.Wallet.createRandom(); - -const networkId = "base_sepolia"; -const walletId = "12345"; -const fromAddressId = fromKey.address; -const amount = ethers.parseUnits("100", 18); -const ethAmount = amount / BigInt(Coinbase.WEI_PER_ETHER); -const toAddressId = "0x4D9E4F3f4D1A8B5F4f7b1F5b5C7b8d6b2B3b1b0b"; -const transferId = "67890"; - -const unsignedPayload = - "7b2274797065223a22307832222c22636861696e4964223a2230783134613334222c226e6f6e63" + - "65223a22307830222c22746f223a22307834643965346633663464316138623566346637623166" + - "356235633762386436623262336231623062222c22676173223a22307835323038222c22676173" + - "5072696365223a6e756c6c2c226d61785072696f72697479466565506572476173223a223078" + - "3539363832663030222c226d6178466565506572476173223a2230783539363832663030222c22" + - "76616c7565223a2230783536626337356532643633313030303030222c22696e707574223a22" + - "3078222c226163636573734c697374223a5b5d2c2276223a22307830222c2272223a2230783022" + - "2c2273223a22307830222c2279506172697479223a22307830222c2268617368223a2230783664" + - "633334306534643663323633653363396561396135656438646561346332383966613861363966" + - "3031653635393462333732386230386138323335333433227d"; - +const amount = new Decimal(ethers.parseUnits("100", 18).toString()); +const ethAmount = amount.div(WEI_PER_ETHER); const signedPayload = "02f86b83014a3401830f4240830f4350825208946cd01c0f55ce9e0bf78f5e90f72b4345b" + "16d515d0280c001a0566afb8ab09129b3f5b666c3a1e4a7e92ae12bbee8c75b4c6e0c46f6" + @@ -38,31 +21,16 @@ const mockProvider = new ethers.JsonRpcProvider( ) as jest.Mocked; mockProvider.getTransaction = jest.fn(); mockProvider.getTransactionReceipt = jest.fn(); +Coinbase.apiClients.baseSepoliaProvider = mockProvider; describe("Transfer Class", () => { let transferModel: TransferModel; - let mockApiClients: TransferClients; let transfer: Transfer; beforeEach(() => { - transferModel = { - transfer_id: transferId, - network_id: networkId, - wallet_id: walletId, - address_id: fromAddressId, - destination: toAddressId, - asset_id: "eth", - amount: amount.toString(), - unsigned_payload: unsignedPayload, - status: TransferStatusEnum.Pending, - } as TransferModel; - - mockApiClients = { - transfer: {} as TransferAPIClient, - baseSepoliaProvider: mockProvider, - } as TransferClients; + transferModel = VALID_TRANSFER_MODEL; - transfer = new Transfer(transferModel, mockApiClients); + transfer = Transfer.fromModel(transferModel); }); afterEach(() => { @@ -77,55 +45,55 @@ describe("Transfer Class", () => { describe("getId", () => { it("should return the transfer ID", () => { - expect(transfer.getId()).toEqual(transferId); + expect(transfer.getId()).toEqual(VALID_TRANSFER_MODEL.transfer_id); }); }); describe("getNetworkId", () => { it("should return the network ID", () => { - expect(transfer.getNetworkId()).toEqual(networkId); + expect(transfer.getNetworkId()).toEqual(VALID_TRANSFER_MODEL.network_id); }); }); describe("getWalletId", () => { it("should return the wallet ID", () => { - expect(transfer.getWalletId()).toEqual(walletId); + expect(transfer.getWalletId()).toEqual(VALID_TRANSFER_MODEL.wallet_id); }); }); describe("getFromAddressId", () => { it("should return the source address ID", () => { - expect(transfer.getFromAddressId()).toEqual(fromAddressId); + expect(transfer.getFromAddressId()).toEqual(VALID_TRANSFER_MODEL.address_id); }); }); describe("getDestinationAddressId", () => { it("should return the destination address ID", () => { - expect(transfer.getDestinationAddressId()).toEqual(toAddressId); + expect(transfer.getDestinationAddressId()).toEqual(VALID_TRANSFER_MODEL.destination); }); }); describe("getAssetId", () => { it("should return the asset ID", () => { - expect(transfer.getAssetId()).toEqual("eth"); + expect(transfer.getAssetId()).toEqual(VALID_TRANSFER_MODEL.asset_id); }); }); describe("getAmount", () => { - it("should return the amount", () => { - transferModel.asset_id = "usdc"; - transfer = new Transfer(transferModel, mockApiClients); - expect(transfer.getAmount()).toEqual(amount); + it("should return the ETH amount when the asset ID is eth", () => { + expect(transfer.getAmount()).toEqual(ethAmount); }); - it("should return the ETH amount when the asset ID is eth", () => { - expect(transfer.getAmount()).toEqual(BigInt(ethAmount)); + it("should return the amoun when the asset ID is not eth", () => { + transferModel.asset_id = "usdc"; + transfer = Transfer.fromModel(transferModel); + expect(transfer.getAmount()).toEqual(amount); }); }); describe("getUnsignedPayload", () => { it("should return the unsigned payload", () => { - expect(transfer.getUnsignedPayload()).toEqual(unsignedPayload); + expect(transfer.getUnsignedPayload()).toEqual(VALID_TRANSFER_MODEL.unsigned_payload); }); }); @@ -136,7 +104,7 @@ describe("Transfer Class", () => { it("should return the signed payload when the transfer has been broadcast on chain", () => { transferModel.signed_payload = signedPayload; - transfer = new Transfer(transferModel, mockApiClients); + transfer = Transfer.fromModel(transferModel); expect(transfer.getSignedPayload()).toEqual(signedPayload); }); }); @@ -148,7 +116,7 @@ describe("Transfer Class", () => { it("should return the transaction hash when the transfer has been broadcast on chain", () => { transferModel.transaction_hash = transactionHash; - transfer = new Transfer(transferModel, mockApiClients); + transfer = Transfer.fromModel(transferModel); expect(transfer.getTransactionHash()).toEqual(transactionHash); }); }); @@ -162,8 +130,8 @@ describe("Transfer Class", () => { expect(transaction.maxPriorityFeePerGas).toEqual(BigInt("0x59682f00")); expect(transaction.maxFeePerGas).toEqual(BigInt("0x59682f00")); expect(transaction.gasLimit).toEqual(BigInt("0x5208")); - expect(transaction.to).toEqual(toAddressId); - expect(transaction.value).toEqual(amount); + expect(transaction.to).toEqual(VALID_TRANSFER_MODEL.destination); + expect(transaction.value).toEqual(BigInt(amount.toFixed(0))); expect(transaction.data).toEqual("0x"); }); }); @@ -176,7 +144,7 @@ describe("Transfer Class", () => { it("should return PENDING when the transaction has been created but not broadcast", async () => { transferModel.transaction_hash = transactionHash; - transfer = new Transfer(transferModel, mockApiClients); + transfer = Transfer.fromModel(transferModel); mockProvider.getTransaction.mockResolvedValueOnce(null); const status = await transfer.getStatus(); expect(status).toEqual(TransferStatus.PENDING); @@ -184,7 +152,7 @@ describe("Transfer Class", () => { it("should return BROADCAST when the transaction has been broadcast but not included in a block", async () => { transferModel.transaction_hash = transactionHash; - transfer = new Transfer(transferModel, mockApiClients); + transfer = Transfer.fromModel(transferModel); mockProvider.getTransaction.mockResolvedValueOnce({ blockHash: null, } as ethers.TransactionResponse); @@ -194,7 +162,7 @@ describe("Transfer Class", () => { it("should return COMPLETE when the transaction has confirmed", async () => { transferModel.transaction_hash = transactionHash; - transfer = new Transfer(transferModel, mockApiClients); + transfer = Transfer.fromModel(transferModel); mockProvider.getTransaction.mockResolvedValueOnce({ blockHash: "0xdeadbeef", } as ethers.TransactionResponse); @@ -207,71 +175,15 @@ describe("Transfer Class", () => { it("should return FAILED when the transaction has failed", async () => { transferModel.transaction_hash = transactionHash; - transfer = new Transfer(transferModel, mockApiClients); - mockProvider.getTransaction.mockResolvedValueOnce({ - blockHash: "0xdeadbeef", - } as ethers.TransactionResponse); - mockProvider.getTransactionReceipt.mockResolvedValueOnce({ - status: 0, - } as ethers.TransactionReceipt); - const status = await transfer.getStatus(); - expect(status).toEqual(TransferStatus.FAILED); - }); - }); - - describe("wait", () => { - it("should return the completed Transfer when the transfer is completed", async () => { - transferModel.transaction_hash = transactionHash; - transfer = new Transfer(transferModel, mockApiClients); - mockProvider.getTransaction.mockResolvedValueOnce({ - blockHash: "0xdeadbeef", - } as ethers.TransactionResponse); - mockProvider.getTransactionReceipt.mockResolvedValueOnce({ - status: 1, - } as ethers.TransactionReceipt); - mockProvider.getTransaction.mockResolvedValueOnce({ - blockHash: "0xdeadbeef", - } as ethers.TransactionResponse); - mockProvider.getTransactionReceipt.mockResolvedValueOnce({ - status: 1, - } as ethers.TransactionReceipt); - - const promise = transfer.wait(0.2, 10); - - const result = await promise; - expect(result).toBe(transfer); - const status = await transfer.getStatus(); - expect(status).toEqual(TransferStatus.COMPLETE); - }); - - it("should return the failed Transfer when the transfer is failed", async () => { - transferModel.transaction_hash = transactionHash; - transfer = new Transfer(transferModel, mockApiClients); + transfer = Transfer.fromModel(transferModel); mockProvider.getTransaction.mockResolvedValueOnce({ blockHash: "0xdeadbeef", } as ethers.TransactionResponse); mockProvider.getTransactionReceipt.mockResolvedValueOnce({ status: 0, } as ethers.TransactionReceipt); - mockProvider.getTransaction.mockResolvedValueOnce({ - blockHash: "0xdeadbeef", - } as ethers.TransactionResponse); - mockProvider.getTransactionReceipt.mockResolvedValueOnce({ - status: 0, - } as ethers.TransactionReceipt); - - const promise = transfer.wait(0.2, 10); - - const result = await promise; - expect(result).toBe(transfer); const status = await transfer.getStatus(); expect(status).toEqual(TransferStatus.FAILED); }); - - it("should throw an error when the transfer times out", async () => { - const promise = transfer.wait(0.2, 0.00001); - - await expect(promise).rejects.toThrow("Transfer timed out"); - }); }); }); diff --git a/src/coinbase/tests/user_test.ts b/src/coinbase/tests/user_test.ts index b54a2071..5508fda9 100644 --- a/src/coinbase/tests/user_test.ts +++ b/src/coinbase/tests/user_test.ts @@ -84,7 +84,7 @@ describe("User Class", () => { }); it("should load the wallet addresses", async () => { - expect(importedWallet.defaultAddress()!.getId()).toBe(mockAddressModel.address_id); + expect(importedWallet.getDefaultAddress()!.getId()).toBe(mockAddressModel.address_id); }); it("should contain the same seed when re-exported", async () => { @@ -260,7 +260,7 @@ describe("User Class", () => { const wallet = wallets[walletId]; expect(wallet).not.toBeNull(); expect(wallet.getId()).toBe(walletId); - expect(wallet.defaultAddress()?.getId()).toBe(addressModel.address_id); + expect(wallet.getDefaultAddress()?.getId()).toBe(addressModel.address_id); }); it("throws an error when the backup file is absent", async () => { diff --git a/src/coinbase/tests/utils.ts b/src/coinbase/tests/utils.ts index af12ce20..c19fb71e 100644 --- a/src/coinbase/tests/utils.ts +++ b/src/coinbase/tests/utils.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import axios, { AxiosInstance } from "axios"; +import { Decimal } from "decimal.js"; import { ethers } from "ethers"; import { randomUUID } from "crypto"; import { @@ -8,6 +9,8 @@ import { Balance as BalanceModel, AddressBalanceList, Address as AddressModel, + Transfer as TransferModel, + TransferStatusEnum, } from "../../client"; import { BASE_PATH } from "../../client/base"; import { Coinbase } from "../coinbase"; @@ -18,6 +21,7 @@ export const mockReturnValue = data => jest.fn().mockResolvedValue({ data }); export const mockReturnRejectedValue = data => jest.fn().mockRejectedValue(data); export const walletId = randomUUID(); +export const transferId = randomUUID(); export const generateRandomHash = (length = 8) => { const characters = "abcdef0123456789"; @@ -53,6 +57,28 @@ export const VALID_WALLET_MODEL: WalletModel = { }, }; +export const VALID_TRANSFER_MODEL: TransferModel = { + transfer_id: transferId, + network_id: Coinbase.networkList.BaseSepolia, + wallet_id: walletId, + address_id: ethers.Wallet.createRandom().address, + destination: "0x4D9E4F3f4D1A8B5F4f7b1F5b5C7b8d6b2B3b1b0b", + asset_id: Coinbase.assetList.Eth, + amount: new Decimal(ethers.parseUnits("100", 18).toString()).toString(), + unsigned_payload: + "7b2274797065223a22307832222c22636861696e4964223a2230783134613334222c226e6f6e63" + + "65223a22307830222c22746f223a22307834643965346633663464316138623566346637623166" + + "356235633762386436623262336231623062222c22676173223a22307835323038222c22676173" + + "5072696365223a6e756c6c2c226d61785072696f72697479466565506572476173223a223078" + + "3539363832663030222c226d6178466565506572476173223a2230783539363832663030222c22" + + "76616c7565223a2230783536626337356532643633313030303030222c22696e707574223a22" + + "3078222c226163636573734c697374223a5b5d2c2276223a22307830222c2272223a2230783022" + + "2c2273223a22307830222c2279506172697479223a22307830222c2268617368223a2230783664" + + "633334306534643663323633653363396561396135656438646561346332383966613861363966" + + "3031653635393462333732386230386138323335333433227d", + status: TransferStatusEnum.Pending, +}; + export const VALID_ADDRESS_BALANCE_LIST: AddressBalanceList = { data: [ { @@ -133,3 +159,10 @@ export const addressesApiMock = { listAddressBalances: jest.fn(), createAddress: jest.fn(), }; + +export const transfersApiMock = { + broadcastTransfer: jest.fn(), + createTransfer: jest.fn(), + getTransfer: jest.fn(), + listTransfers: jest.fn(), +}; diff --git a/src/coinbase/tests/wallet_test.ts b/src/coinbase/tests/wallet_test.ts index c17d602a..22864d13 100644 --- a/src/coinbase/tests/wallet_test.ts +++ b/src/coinbase/tests/wallet_test.ts @@ -1,4 +1,6 @@ -import { randomUUID } from "crypto"; +import { ethers } from "ethers"; +import crypto from "crypto"; +import { Decimal } from "decimal.js"; import { Coinbase } from "../coinbase"; import { Wallet } from "../wallet"; import { @@ -7,46 +9,181 @@ import { Wallet as WalletModel, Balance as BalanceModel, } from "./../../client"; -import { ArgumentError } from "../errors"; +import { Address } from "../address"; import { + VALID_TRANSFER_MODEL, + VALID_ADDRESS_MODEL, addressesApiMock, mockFn, mockReturnValue, + mockReturnRejectedValue, newAddressModel, walletsApiMock, + transfersApiMock, } from "./utils"; -import { Address } from "../address"; -import Decimal from "decimal.js"; +import { ArgumentError } from "../errors"; +import { APIError } from "../api_error"; +import { GWEI_PER_ETHER, WEI_PER_ETHER } from "../constants"; describe("Wallet Class", () => { - let wallet, walletModel, walletId; - describe(".create", () => { - const apiResponses = {}; + let wallet: Wallet; + let walletModel: WalletModel; + let walletId: string; + const apiResponses = {}; - beforeAll(async () => { - walletId = randomUUID(); - // Mock the API calls - Coinbase.apiClients.wallet = walletsApiMock; - Coinbase.apiClients.address = addressesApiMock; - Coinbase.apiClients.wallet!.createWallet = mockFn(request => { - const { network_id } = request.wallet; - apiResponses[walletId] = { - id: walletId, - network_id, - default_address: newAddressModel(walletId), + beforeAll(async () => { + walletId = crypto.randomUUID(); + // Mock the API calls + Coinbase.apiClients.wallet = walletsApiMock; + Coinbase.apiClients.address = addressesApiMock; + Coinbase.apiClients.wallet!.createWallet = mockFn(request => { + const { network_id } = request.wallet; + apiResponses[walletId] = { + id: walletId, + network_id, + default_address: newAddressModel(walletId), + }; + return { data: apiResponses[walletId] }; + }); + Coinbase.apiClients.wallet!.getWallet = mockFn(walletId => { + walletModel = apiResponses[walletId]; + return { data: apiResponses[walletId] }; + }); + Coinbase.apiClients.address!.createAddress = mockFn(walletId => { + return { data: apiResponses[walletId].default_address }; + }); + wallet = await Wallet.create(); + }); + + describe(".createTransfer", () => { + let weiAmount, destination, intervalSeconds, timeoutSeconds; + let walletId, id, balanceModel: BalanceModel; + + const mockProvider = new ethers.JsonRpcProvider( + "https://sepolia.base.org", + ) as jest.Mocked; + mockProvider.getTransaction = jest.fn(); + mockProvider.getTransactionReceipt = jest.fn(); + Coinbase.apiClients.baseSepoliaProvider = mockProvider; + + beforeEach(() => { + const key = ethers.Wallet.createRandom(); + weiAmount = new Decimal("500000000000000000"); + destination = new Address(VALID_ADDRESS_MODEL, key as unknown as ethers.Wallet); + intervalSeconds = 0.2; + timeoutSeconds = 10; + walletId = crypto.randomUUID(); + id = crypto.randomUUID(); + Coinbase.apiClients.address!.getAddressBalance = mockFn(request => { + const { asset_id } = request; + balanceModel = { + amount: "1000000000000000000", + asset: { + asset_id, + network_id: Coinbase.networkList.BaseSepolia, + }, }; - return { data: apiResponses[walletId] }; + return { data: balanceModel }; }); - Coinbase.apiClients.wallet!.getWallet = mockFn(walletId => { - walletModel = apiResponses[walletId]; - return { data: apiResponses[walletId] }; + + Coinbase.apiClients.transfer = transfersApiMock; + }); + + it("should successfully create and complete a transfer", async () => { + Coinbase.apiClients.transfer!.createTransfer = mockReturnValue(VALID_TRANSFER_MODEL); + Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnValue({ + transaction_hash: "0x6c087c1676e8269dd81e0777244584d0cbfd39b6997b3477242a008fa9349e11", + ...VALID_TRANSFER_MODEL, }); - Coinbase.apiClients.address!.createAddress = mockFn(walletId => { - return { data: apiResponses[walletId].default_address }; + mockProvider.getTransaction.mockResolvedValueOnce({ + blockHash: "0xdeadbeef", + } as ethers.TransactionResponse); + mockProvider.getTransactionReceipt.mockResolvedValueOnce({ + status: 1, + } as ethers.TransactionReceipt); + + const transfer = await wallet.createTransfer( + weiAmount, + Coinbase.assetList.Wei, + destination, + intervalSeconds, + timeoutSeconds, + ); + + expect(Coinbase.apiClients.transfer!.createTransfer).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.transfer!.broadcastTransfer).toHaveBeenCalledTimes(1); + }); + + it("should throw an APIError if the createTransfer API call fails", async () => { + Coinbase.apiClients.transfer!.createTransfer = mockReturnRejectedValue( + new APIError("Failed to create transfer"), + ); + await expect( + wallet.createTransfer( + weiAmount, + Coinbase.assetList.Wei, + destination, + intervalSeconds, + timeoutSeconds, + ), + ).rejects.toThrow(APIError); + }); + + it("should throw an APIError if the broadcastTransfer API call fails", async () => { + Coinbase.apiClients.transfer!.createTransfer = mockReturnValue(VALID_TRANSFER_MODEL); + Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnRejectedValue( + new APIError("Failed to broadcast transfer"), + ); + await expect( + wallet.createTransfer( + weiAmount, + Coinbase.assetList.Wei, + destination, + intervalSeconds, + timeoutSeconds, + ), + ).rejects.toThrow(APIError); + }); + + it("should throw an Error if the transfer times out", async () => { + Coinbase.apiClients.transfer!.createTransfer = mockReturnValue(VALID_TRANSFER_MODEL); + Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnValue({ + transaction_hash: "0x6c087c1676e8269dd81e0777244584d0cbfd39b6997b3477242a008fa9349e11", + ...VALID_TRANSFER_MODEL, }); - wallet = await Wallet.create(); + intervalSeconds = 0.000002; + timeoutSeconds = 0.000002; + + await expect( + wallet.createTransfer( + weiAmount, + Coinbase.assetList.Wei, + destination, + intervalSeconds, + timeoutSeconds, + ), + ).rejects.toThrow("Transfer timed out"); }); + it("should throw an ArgumentError if there are insufficient funds", async () => { + const insufficientAmount = new Decimal("10000000000000000000"); + await expect( + wallet.createTransfer( + insufficientAmount, + Coinbase.assetList.Wei, + destination, + intervalSeconds, + timeoutSeconds, + ), + ).rejects.toThrow(ArgumentError); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + }); + + describe(".create", () => { it("should return a Wallet instance", async () => { expect(wallet).toBeInstanceOf(Wallet); expect(Coinbase.apiClients.wallet!.createWallet).toHaveBeenCalledTimes(1); @@ -58,20 +195,29 @@ describe("Wallet Class", () => { expect(Coinbase.apiClients.wallet!.getWallet).toHaveBeenCalledWith(walletId); }); - it("should return the correct wallet ID", async () => { - expect(wallet.getId()).toBe(walletModel.id); + describe(".getId", () => { + it("should return the correct wallet ID", async () => { + expect(wallet.getId()).toBe(walletModel.id); + }); }); - it("should return the correct network ID", async () => { - expect(wallet.getNetworkId()).toBe(Coinbase.networkList.BaseSepolia); + describe(".getNetworkID", () => { + it("should return the correct network ID", async () => { + expect(wallet.getNetworkId()).toBe(Coinbase.networkList.BaseSepolia); + }); }); - it("should return the correct default address", async () => { - expect(wallet.defaultAddress()?.getId()).toBe(walletModel.default_address.address_id); + describe(".getDefaultAddress", () => { + it("should return the correct default address", async () => { + expect((wallet.getDefaultAddress() as Address).getId()).toBe( + walletModel.default_address!.address_id, + ); + }); }); }); describe(".init", () => { + walletId = crypto.randomUUID(); const existingSeed = "hidden assault maple cheap gentle paper earth surprise trophy guide room tired"; const addressList = [ @@ -120,22 +266,6 @@ describe("Wallet Class", () => { expect(wallet).toBeInstanceOf(Wallet); }); - it("should return the correct wallet ID", async () => { - expect(wallet.getId()).toBe(walletModel.id); - }); - - it("should return the correct network ID", async () => { - expect(wallet.getNetworkId()).toBe(Coinbase.networkList.BaseSepolia); - }); - - it("should return the correct default address", async () => { - expect(wallet.defaultAddress()?.getId()).toBe(walletModel.default_address?.address_id); - }); - - it("should derive the correct number of addresses", async () => { - expect(wallet.addresses.length).toBe(2); - }); - it("should return the correct string representation", async () => { expect(wallet.toString()).toBe( `Wallet{id: '${walletModel.id}', networkId: '${Coinbase.networkList.BaseSepolia}'}`, @@ -147,7 +277,7 @@ describe("Wallet Class", () => { }); }); - describe("#export", () => { + describe(".export", () => { let walletId: string; let addressModel: AddressModel; let walletModel: WalletModel; @@ -156,7 +286,7 @@ describe("Wallet Class", () => { const addressCount = 1; beforeAll(async () => { - walletId = randomUUID(); + walletId = crypto.randomUUID(); addressModel = newAddressModel(walletId); walletModel = { id: walletId, @@ -185,46 +315,7 @@ describe("Wallet Class", () => { }); }); - describe("#defaultAddress", () => { - let wallet, walletId; - beforeEach(async () => { - jest.clearAllMocks(); - walletId = randomUUID(); - const mockAddressModel: AddressModel = newAddressModel(walletId); - const walletMockWithDefaultAddress: WalletModel = { - id: walletId, - network_id: Coinbase.networkList.BaseSepolia, - default_address: mockAddressModel, - }; - Coinbase.apiClients.wallet = walletsApiMock; - Coinbase.apiClients.address = addressesApiMock; - Coinbase.apiClients.wallet!.createWallet = mockReturnValue(walletMockWithDefaultAddress); - Coinbase.apiClients.wallet!.getWallet = mockReturnValue(walletMockWithDefaultAddress); - Coinbase.apiClients.address!.createAddress = mockReturnValue(mockAddressModel); - wallet = await Wallet.create(); - }); - - it("should return the correct address", async () => { - const defaultAddress = wallet.defaultAddress(); - const address = wallet.getAddress(defaultAddress?.getId()); - expect(address).toBeInstanceOf(Address); - expect(address?.getId()).toBe(address.getId()); - }); - - describe(".walletId", () => { - it("should return the correct wallet ID", async () => { - expect(wallet.getId()).toBe(walletId); - }); - }); - - describe(".networkId", () => { - it("should return the correct network ID", async () => { - expect(wallet.getNetworkId()).toBe(Coinbase.networkList.BaseSepolia); - }); - }); - }); - - describe("#getBalances", () => { + describe(".getBalances", () => { beforeEach(() => { const mockBalanceResponse: AddressBalanceList = { data: [ @@ -261,7 +352,7 @@ describe("Wallet Class", () => { }); }); - describe("#getBalance", () => { + describe(".getBalance", () => { beforeEach(() => { const mockWalletBalance: BalanceModel = { amount: "5000000000000000000", @@ -286,7 +377,7 @@ describe("Wallet Class", () => { it("should return the correct GWEI balance", async () => { const balance = await wallet.getBalance(Coinbase.assetList.Gwei); - expect(balance).toEqual(new Decimal((BigInt(5) * Coinbase.GWEI_PER_ETHER).toString())); + expect(balance).toEqual(GWEI_PER_ETHER.mul(5)); expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledTimes(1); expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledWith( walletId, @@ -296,7 +387,7 @@ describe("Wallet Class", () => { it("should return the correct WEI balance", async () => { const balance = await wallet.getBalance(Coinbase.assetList.Wei); - expect(balance).toEqual(new Decimal((BigInt(5) * Coinbase.WEI_PER_ETHER).toString())); + expect(balance).toEqual(WEI_PER_ETHER.mul(5)); expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledTimes(1); expect(Coinbase.apiClients.wallet!.getWalletBalance).toHaveBeenCalledWith( walletId, @@ -316,7 +407,7 @@ describe("Wallet Class", () => { }); }); - describe("#canSign", () => { + describe(".canSign", () => { let wallet; beforeAll(async () => { const mockAddressModel = newAddressModel(walletId); diff --git a/src/coinbase/transfer.ts b/src/coinbase/transfer.ts index 79e4687d..7670742d 100644 --- a/src/coinbase/transfer.ts +++ b/src/coinbase/transfer.ts @@ -1,17 +1,10 @@ -import { TransferAPIClient, TransferStatus } from "./types"; +import { Decimal } from "decimal.js"; +import { TransferStatus } from "./types"; import { Coinbase } from "./coinbase"; import { Transfer as TransferModel } from "../client/api"; import { ethers } from "ethers"; import { InternalError, InvalidUnsignedPayload } from "./errors"; -import { delay } from "./utils"; - -/** - * The Transfer API client types. - */ -export type TransferClients = { - transfer: TransferAPIClient; - baseSepoliaProvider: ethers.Provider; -}; +import { WEI_PER_ETHER } from "./constants"; /** * A representation of a Transfer, which moves an Amount of an Asset from @@ -20,25 +13,30 @@ export type TransferClients = { */ export class Transfer { private model: TransferModel; - private client: TransferClients; private transaction?: ethers.Transaction; /** - * Initializes a new Transfer instance. + * Private constructor to prevent direct instantiation outside of the factory methods. * + * @ignore * @param transferModel - The Transfer model. - * @param client - The API clients. + * @hideconstructor */ - constructor(transferModel: TransferModel, client: TransferClients) { + private constructor(transferModel: TransferModel) { if (!transferModel) { throw new InternalError("Transfer model cannot be empty"); } this.model = transferModel; + } - if (!client) { - throw new InternalError("API clients cannot be empty"); - } - this.client = client; + /** + * Converts a TransferModel into a Transfer object. + * + * @param transferModel - The Transfer model object. + * @returns The Transfer object. + */ + public static fromModel(transferModel: TransferModel): Transfer { + return new Transfer(transferModel); } /** @@ -100,13 +98,13 @@ export class Transfer { * * @returns The Amount of the Asset. */ - public getAmount(): bigint { - const amount = BigInt(this.model.amount); + public getAmount(): Decimal { + const amount = new Decimal(this.model.amount); if (this.getAssetId() === Coinbase.assetList.Eth) { - return amount / BigInt(Coinbase.WEI_PER_ETHER); + return amount.div(WEI_PER_ETHER); } - return BigInt(this.model.amount); + return amount; } /** @@ -195,35 +193,15 @@ export class Transfer { if (!transactionHash) return TransferStatus.PENDING; const onchainTransaction = - await this.client.baseSepoliaProvider!.getTransaction(transactionHash); + await Coinbase.apiClients.baseSepoliaProvider!.getTransaction(transactionHash); if (!onchainTransaction) return TransferStatus.PENDING; if (!onchainTransaction.blockHash) return TransferStatus.BROADCAST; const transactionReceipt = - await this.client.baseSepoliaProvider!.getTransactionReceipt(transactionHash); + await Coinbase.apiClients.baseSepoliaProvider!.getTransactionReceipt(transactionHash); return transactionReceipt?.status ? TransferStatus.COMPLETE : TransferStatus.FAILED; } - /** - * Waits until the Transfer is completed or failed by polling the Network at the given interval. - * - * @param intervalSeconds - The interval at which to poll the Network, in seconds. - * @param timeoutSeconds - The maximum amount of time to wait for the Transfer to complete, in seconds. - * @returns The completed Transfer object. - * @throws {Error} if the Transfer takes longer than the given timeout. - */ - public async wait(intervalSeconds = 0.2, timeoutSeconds = 10): Promise { - const startTime = Date.now(); - while (Date.now() - startTime < timeoutSeconds * 1000) { - const status = await this.getStatus(); - if (status === TransferStatus.COMPLETE || status === TransferStatus.FAILED) { - return this; - } - await delay(intervalSeconds); - } - throw new Error("Transfer timed out"); - } - /** * Returns the link to the Transaction on the blockchain explorer. * diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index ca3db4c2..3302c910 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -1,3 +1,4 @@ +import { Decimal } from "decimal.js"; import { AxiosPromise, AxiosRequestConfig, RawAxiosRequestConfig } from "axios"; import { ethers } from "ethers"; import { @@ -14,6 +15,8 @@ import { Wallet as WalletModel, Transfer as TransferModel, } from "./../client/api"; +import { Address } from "./address"; +import { Wallet } from "./wallet"; /** * WalletAPI client type definition. @@ -304,3 +307,13 @@ export type SeedData = { authTag: string; iv: string; }; + +/** + * Amount type definition. + */ +export type Amount = number | bigint | Decimal; + +/** + * Destination type definition. + */ +export type Destination = string | Address | Wallet; diff --git a/src/coinbase/utils.ts b/src/coinbase/utils.ts index f647b8c1..1a2ee736 100644 --- a/src/coinbase/utils.ts +++ b/src/coinbase/utils.ts @@ -1,6 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Axios, AxiosResponse, InternalAxiosRequestConfig } from "axios"; +import { Destination } from "./types"; import { APIError } from "./api_error"; +import { Wallet } from "./wallet"; +import { Address } from "./address"; /** * Prints Axios response to the console for debugging purposes. @@ -80,3 +83,22 @@ export const convertStringToHex = (key: Uint8Array): string => { export async function delay(seconds: number): Promise { return new Promise(resolve => setTimeout(resolve, seconds * 1000)); } + +/** + * Converts a Destination to an Address hex string. + * + * @param destination - The Destination to convert. + * @returns The Address Hex string. + * @throws {Error} If the Destination is an unsupported type. + */ +export function destinationToAddressHexString(destination: Destination): string { + if (typeof destination === "string") { + return destination; + } else if (destination instanceof Address) { + return destination.getId(); + } else if (destination instanceof Wallet) { + return destination.getDefaultAddress()!.getId(); + } else { + throw new Error("Unsupported type"); + } +} diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index fa74fb19..f388914d 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -6,9 +6,10 @@ import * as secp256k1 from "secp256k1"; import { Address as AddressModel, Wallet as WalletModel } from "../client"; import { Address } from "./address"; import { Coinbase } from "./coinbase"; +import { Transfer } from "./transfer"; import { ArgumentError, InternalError } from "./errors"; import { FaucetTransaction } from "./faucet_transaction"; -import { WalletData } from "./types"; +import { Amount, Destination, WalletData } from "./types"; import { convertStringToHex } from "./utils"; import { BalanceMap } from "./balance_map"; import Decimal from "decimal.js"; @@ -133,16 +134,17 @@ export class Wallet { * @throws {APIError} - If the address creation fails. */ private async createAddress(): Promise { - const key = this.deriveKey(); - const attestation = this.createAttestation(key); - const publicKey = convertStringToHex(key.publicKey!); + const hdKey = this.deriveKey(); + const attestation = this.createAttestation(hdKey); + const publicKey = convertStringToHex(hdKey.publicKey!); + const key = new ethers.Wallet(convertStringToHex(hdKey.privateKey!)); const payload = { public_key: publicKey, attestation: attestation, }; const response = await Coinbase.apiClients.address!.createAddress(this.model.id!, payload); - this.cacheAddress(response!.data); + this.cacheAddress(response!.data, key); } /** @@ -192,21 +194,22 @@ export class Wallet { * @returns A promise that resolves when the address is derived. */ private async deriveAddress(): Promise { - const key = this.deriveKey(); - const wallet = new ethers.Wallet(convertStringToHex(key.privateKey!)); - const response = await Coinbase.apiClients.address!.getAddress(this.model.id!, wallet.address); - this.cacheAddress(response.data); + const hdKey = this.deriveKey(); + const key = new ethers.Wallet(convertStringToHex(hdKey.privateKey!)); + const response = await Coinbase.apiClients.address!.getAddress(this.model.id!, key.address); + this.cacheAddress(response.data, key); } /** * Caches an Address on the client-side and increments the address index. * * @param address - The AddressModel to cache. + * @param key - The ethers.js Wallet object the address uses for signing data. * @throws {InternalError} If the address is not provided. * @returns {void} */ - private cacheAddress(address: AddressModel): void { - this.addresses.push(new Address(address)); + private cacheAddress(address: AddressModel, key: ethers.Wallet): void { + this.addresses.push(new Address(address, key)); this.addressIndex++; } @@ -270,8 +273,10 @@ export class Wallet { * * @returns The default address */ - public defaultAddress(): Address | undefined { - return this.model.default_address ? new Address(this.model.default_address) : undefined; + public getDefaultAddress(): Address | undefined { + return this.addresses.find( + address => address.getId() === this.model.default_address?.address_id, + ); } /** @@ -295,9 +300,42 @@ export class Wallet { if (!this.model.default_address) { throw new InternalError("Default address not found"); } - const transaction = await this.defaultAddress()?.faucet(); + const transaction = await this.getDefaultAddress()!.faucet(); return transaction!; } + + /** + * Sends an amount of an asset to a destination. + * + * @param amount - The amount to send. + * @param assetId - The asset ID to send. + * @param destination - The destination address. + * @param intervalSeconds - The interval at which to poll the Network for Transfer status, in seconds. + * @param timeoutSeconds - The maximum amount of time to wait for the Transfer to complete, in seconds. + * @returns The transfer object. + * @throws {APIError} if the API request to create a Transfer fails. + * @throws {APIError} if the API request to broadcast a Transfer fails. + * @throws {Error} if the Transfer times out. + */ + public async createTransfer( + amount: Amount, + assetId: string, + destination: Destination, + intervalSeconds = 0.2, + timeoutSeconds = 10, + ): Promise { + if (!this.getDefaultAddress()) { + throw new InternalError("Default address not found"); + } + return await this.getDefaultAddress()!.createTransfer( + amount, + assetId, + destination, + intervalSeconds, + timeoutSeconds, + ); + } + /** * Returns a String representation of the Wallet. * diff --git a/yarn.lock b/yarn.lock index edd237bc..03390163 100644 --- a/yarn.lock +++ b/yarn.lock @@ -804,7 +804,7 @@ expect "^29.0.0" pretty-format "^29.0.0" -"@types/json-schema@*", "@types/json-schema@^7.0.15": +"@types/json-schema@*": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -817,9 +817,9 @@ "@types/node" "*" "@types/node@*", "@types/node@^20.12.11": - version "20.12.11" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.11.tgz#c4ef00d3507000d17690643278a60dc55a9dc9be" - integrity sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw== + version "20.12.12" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.12.tgz#7cbecdf902085cec634fdb362172dfe12b8f2050" + integrity sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw== dependencies: undici-types "~5.26.4" @@ -835,11 +835,6 @@ dependencies: "@types/node" "*" -"@types/semver@^7.5.8": - version "7.5.8" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" - integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== - "@types/stack-utils@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" @@ -858,68 +853,61 @@ "@types/yargs-parser" "*" "@typescript-eslint/eslint-plugin@^7.8.0": - version "7.8.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.8.0.tgz#c78e309fe967cb4de05b85cdc876fb95f8e01b6f" - integrity sha512-gFTT+ezJmkwutUPmB0skOj3GZJtlEGnlssems4AjkVweUPGj7jRwwqg0Hhg7++kPGJqKtTYx+R05Ftww372aIg== + version "7.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.9.0.tgz#093b96fc4e342226e65d5f18f9c87081e0b04a31" + integrity sha512-6e+X0X3sFe/G/54aC3jt0txuMTURqLyekmEHViqyA2VnxhLMpvA6nqmcjIy+Cr9tLDHPssA74BP5Mx9HQIxBEA== dependencies: "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "7.8.0" - "@typescript-eslint/type-utils" "7.8.0" - "@typescript-eslint/utils" "7.8.0" - "@typescript-eslint/visitor-keys" "7.8.0" - debug "^4.3.4" + "@typescript-eslint/scope-manager" "7.9.0" + "@typescript-eslint/type-utils" "7.9.0" + "@typescript-eslint/utils" "7.9.0" + "@typescript-eslint/visitor-keys" "7.9.0" graphemer "^1.4.0" ignore "^5.3.1" natural-compare "^1.4.0" - semver "^7.6.0" ts-api-utils "^1.3.0" "@typescript-eslint/parser@^7.8.0": - version "7.8.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.8.0.tgz#1e1db30c8ab832caffee5f37e677dbcb9357ddc8" - integrity sha512-KgKQly1pv0l4ltcftP59uQZCi4HUYswCLbTqVZEJu7uLX8CTLyswqMLqLN+2QFz4jCptqWVV4SB7vdxcH2+0kQ== - dependencies: - "@typescript-eslint/scope-manager" "7.8.0" - "@typescript-eslint/types" "7.8.0" - "@typescript-eslint/typescript-estree" "7.8.0" - "@typescript-eslint/visitor-keys" "7.8.0" + version "7.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.9.0.tgz#fb3ba01b75e0e65cb78037a360961b00301f6c70" + integrity sha512-qHMJfkL5qvgQB2aLvhUSXxbK7OLnDkwPzFalg458pxQgfxKDfT1ZDbHQM/I6mDIf/svlMkj21kzKuQ2ixJlatQ== + dependencies: + "@typescript-eslint/scope-manager" "7.9.0" + "@typescript-eslint/types" "7.9.0" + "@typescript-eslint/typescript-estree" "7.9.0" + "@typescript-eslint/visitor-keys" "7.9.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@7.8.0": - version "7.8.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.8.0.tgz#bb19096d11ec6b87fb6640d921df19b813e02047" - integrity sha512-viEmZ1LmwsGcnr85gIq+FCYI7nO90DVbE37/ll51hjv9aG+YZMb4WDE2fyWpUR4O/UrhGRpYXK/XajcGTk2B8g== +"@typescript-eslint/scope-manager@7.9.0": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.9.0.tgz#1dd3e63a4411db356a9d040e75864851b5f2619b" + integrity sha512-ZwPK4DeCDxr3GJltRz5iZejPFAAr4Wk3+2WIBaj1L5PYK5RgxExu/Y68FFVclN0y6GGwH8q+KgKRCvaTmFBbgQ== dependencies: - "@typescript-eslint/types" "7.8.0" - "@typescript-eslint/visitor-keys" "7.8.0" + "@typescript-eslint/types" "7.9.0" + "@typescript-eslint/visitor-keys" "7.9.0" -"@typescript-eslint/type-utils@7.8.0": - version "7.8.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.8.0.tgz#9de166f182a6e4d1c5da76e94880e91831e3e26f" - integrity sha512-H70R3AefQDQpz9mGv13Uhi121FNMh+WEaRqcXTX09YEDky21km4dV1ZXJIp8QjXc4ZaVkXVdohvWDzbnbHDS+A== +"@typescript-eslint/type-utils@7.9.0": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.9.0.tgz#f523262e1b66ca65540b7a65a1222db52e0a90c9" + integrity sha512-6Qy8dfut0PFrFRAZsGzuLoM4hre4gjzWJB6sUvdunCYZsYemTkzZNwF1rnGea326PHPT3zn5Lmg32M/xfJfByA== dependencies: - "@typescript-eslint/typescript-estree" "7.8.0" - "@typescript-eslint/utils" "7.8.0" + "@typescript-eslint/typescript-estree" "7.9.0" + "@typescript-eslint/utils" "7.9.0" debug "^4.3.4" ts-api-utils "^1.3.0" -"@typescript-eslint/types@7.8.0": - version "7.8.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.8.0.tgz#1fd2577b3ad883b769546e2d1ef379f929a7091d" - integrity sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw== - -"@typescript-eslint/types@^7.2.0": +"@typescript-eslint/types@7.9.0", "@typescript-eslint/types@^7.2.0": version "7.9.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.9.0.tgz#b58e485e4bfba055659c7e683ad4f5f0821ae2ec" integrity sha512-oZQD9HEWQanl9UfsbGVcZ2cGaR0YT5476xfWE0oE5kQa2sNK2frxOlkeacLOTh9po4AlUT5rtkGyYM5kew0z5w== -"@typescript-eslint/typescript-estree@7.8.0": - version "7.8.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.8.0.tgz#b028a9226860b66e623c1ee55cc2464b95d2987c" - integrity sha512-5pfUCOwK5yjPaJQNy44prjCwtr981dO8Qo9J9PwYXZ0MosgAbfEMB008dJ5sNo3+/BN6ytBPuSvXUg9SAqB0dg== +"@typescript-eslint/typescript-estree@7.9.0": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.9.0.tgz#3395e27656060dc313a6b406c3a298b729685e07" + integrity sha512-zBCMCkrb2YjpKV3LA0ZJubtKCDxLttxfdGmwZvTqqWevUPN0FZvSI26FalGFFUZU/9YQK/A4xcQF9o/VVaCKAg== dependencies: - "@typescript-eslint/types" "7.8.0" - "@typescript-eslint/visitor-keys" "7.8.0" + "@typescript-eslint/types" "7.9.0" + "@typescript-eslint/visitor-keys" "7.9.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" @@ -927,25 +915,22 @@ semver "^7.6.0" ts-api-utils "^1.3.0" -"@typescript-eslint/utils@7.8.0": - version "7.8.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.8.0.tgz#57a79f9c0c0740ead2f622e444cfaeeb9fd047cd" - integrity sha512-L0yFqOCflVqXxiZyXrDr80lnahQfSOfc9ELAAZ75sqicqp2i36kEZZGuUymHNFoYOqxRT05up760b4iGsl02nQ== +"@typescript-eslint/utils@7.9.0": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.9.0.tgz#1b96a34eefdca1c820cb1bbc2751d848b4540899" + integrity sha512-5KVRQCzZajmT4Ep+NEgjXCvjuypVvYHUW7RHlXzNPuak2oWpVoD1jf5xCP0dPAuNIchjC7uQyvbdaSTFaLqSdA== dependencies: "@eslint-community/eslint-utils" "^4.4.0" - "@types/json-schema" "^7.0.15" - "@types/semver" "^7.5.8" - "@typescript-eslint/scope-manager" "7.8.0" - "@typescript-eslint/types" "7.8.0" - "@typescript-eslint/typescript-estree" "7.8.0" - semver "^7.6.0" + "@typescript-eslint/scope-manager" "7.9.0" + "@typescript-eslint/types" "7.9.0" + "@typescript-eslint/typescript-estree" "7.9.0" -"@typescript-eslint/visitor-keys@7.8.0": - version "7.8.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.8.0.tgz#7285aab991da8bee411a42edbd5db760d22fdd91" - integrity sha512-q4/gibTNBQNA0lGyYQCmWRS5D15n8rXh4QjK3KV+MBPlTYHpfBUT3D3PaPR/HeNiI9W6R7FvlkcGhNyAoP+caA== +"@typescript-eslint/visitor-keys@7.9.0": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.9.0.tgz#82162656e339c3def02895f5c8546f6888d9b9ea" + integrity sha512-iESPx2TNLDNGQLyjKhUvIKprlP49XNEK+MvIf9nIO7ZZaZdbnfWKHnXAgufpxqfA0YryH8XToi4+CjBgVnFTSQ== dependencies: - "@typescript-eslint/types" "7.8.0" + "@typescript-eslint/types" "7.9.0" eslint-visitor-keys "^3.4.3" "@ungap/structured-clone@^1.2.0": @@ -1240,9 +1225,9 @@ camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001587: - version "1.0.30001617" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001617.tgz#809bc25f3f5027ceb33142a7d6c40759d7a901eb" - integrity sha512-mLyjzNI9I+Pix8zwcrpxEbGlfqOkF9kM3ptzmKNw5tizSyYwMe+nGLTqMK9cO+0E+Bh6TsBxNAaHWEM8xwSsmA== + version "1.0.30001620" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz#78bb6f35b8fe315b96b8590597094145d0b146b4" + integrity sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew== chalk@^2.4.2: version "2.4.2" @@ -1430,9 +1415,9 @@ doctrine@^3.0.0: esutils "^2.0.2" electron-to-chromium@^1.4.668: - version "1.4.763" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.763.tgz#64f2041ed496fd6fc710b9be806fe91da9334f91" - integrity sha512-k4J8NrtJ9QrvHLRo8Q18OncqBCB7tIUyqxRcJnlonQ0ioHKYB988GcDFF3ZePmnb8eHEopDs/wPHR/iGAFgoUQ== + version "1.4.773" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.773.tgz#49741af9bb4e712ad899e35d8344d8d59cdb7e12" + integrity sha512-87eHF+h3PlCRwbxVEAw9KtK3v7lWfc/sUDr0W76955AdYTG4bV/k0zrl585Qnj/skRMH2qOSiE+kqMeOQ+LOpw== elliptic@^6.5.4: version "6.5.5" @@ -2806,10 +2791,10 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -picocolors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" - integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== +picocolors@^1.0.0, picocolors@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" + integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" @@ -3241,12 +3226,12 @@ undici-types@~5.26.4: integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== update-browserslist-db@^1.0.13: - version "1.0.15" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.15.tgz#60ed9f8cba4a728b7ecf7356f641a31e3a691d97" - integrity sha512-K9HWH62x3/EalU1U6sjSZiylm9C8tgq2mSvshZpqc7QE69RaA2qjhkW2HlNA0tFpEbtyFz7HTqbSdN4MSwUodA== + version "1.0.16" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz#f6d489ed90fb2f07d67784eb3f53d7891f736356" + integrity sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ== dependencies: escalade "^3.1.2" - picocolors "^1.0.0" + picocolors "^1.0.1" uri-js@^4.2.2: version "4.4.1"