From cfd8dd7ef844fa8efb0bb1569c87407a8555fdbb Mon Sep 17 00:00:00 2001 From: John Peterson Date: Tue, 14 May 2024 11:49:09 -0700 Subject: [PATCH 1/4] transfer api type --- src/coinbase/coinbase.ts | 3 +- src/coinbase/types.ts | 88 ++++++++++++++++++++++++++++++++++++++-- src/coinbase/user.ts | 1 + 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index 6f25e92b..1f0b82ba 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -1,6 +1,6 @@ import globalAxios from "axios"; import fs from "fs"; -import { User as UserModel, UsersApiFactory } from "../client"; +import { User as UserModel, UsersApiFactory, TransfersApiFactory } from "../client"; import { BASE_PATH } from "./../client/base"; import { Configuration } from "./../client/configuration"; import { CoinbaseAuthenticator } from "./authenticator"; @@ -59,6 +59,7 @@ export class Coinbase { ); this.apiClients.user = UsersApiFactory(config, BASE_PATH, axiosInstance); + this.apiClients.transfer = TransfersApiFactory(config, BASE_PATH, axiosInstance); } /** diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index 123ce3ba..e956c90d 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -1,9 +1,13 @@ import { AxiosPromise, AxiosRequestConfig, RawAxiosRequestConfig } from "axios"; import { - Address, CreateWalletRequest, + BroadcastTransferRequest, + CreateTransferRequest, + TransferList, User as UserModel, Wallet as WalletModel, + Address as AddressModel, + Transfer as TransferModel, } from "./../client/api"; /** @@ -53,7 +57,7 @@ export type AddressAPIClient = { walletId: string, addressId: string, options?: AxiosRequestConfig, - ): AxiosPromise
; + ): AxiosPromise; }; /** @@ -70,12 +74,90 @@ export type UserAPIClient = { getCurrentUser(options?: AxiosRequestConfig): AxiosPromise; }; +/** + * TransferAPI client type definition. + */ +export type TransferAPIClient = { + /** + * Broadcasts a transfer. + * + * @param walletId - The ID of the wallet the address belongs to. + * @param addressId - The ID of the address the transfer belongs to. + * @param transferId - The ID of the transfer to broadcast. + * @param broadcastTransferRequest - The request body. + * @param options - Axios request options. + * @returns - A promise resolving to the Transfer model. + * @throws {APIError} If the request fails. + */ + broadcastTransfer( + walletId: string, + addressId: string, + transferId: string, + broadcastTransferRequest: BroadcastTransferRequest, + options?: AxiosRequestConfig, + ): AxiosPromise; + + /** + * Creates a Transfer. + * + * @param walletId - The ID of the wallet the address belongs to. + * @param addressId - The ID of the address the transfer belongs to. + * @param createTransferRequest - The request body. + * @param options - Axios request options. + * @returns - A promise resolving to the Transfer model. + * @throws {APIError} If the request fails. + */ + createTransfer( + walletId: string, + addressId: string, + createTransferRequest: CreateTransferRequest, + options?: AxiosRequestConfig, + ): AxiosPromise; + + /** + * Retrieves a Transfer. + * + * @param walletId - The ID of the wallet the address belongs to. + * @param addressId - The ID of the address the transfer belongs to. + * @param transferId - The ID of the transfer to retrieve. + * @param options - Axios request options. + * @returns - A promise resolving to the Transfer model. + * @throws {APIError} If the request fails. + */ + getTransfer( + walletId: string, + addressId: string, + transferId: string, + options?: AxiosRequestConfig, + ): AxiosPromise; + + /** + * Lists Transfers. + * + * @param walletId - The ID of the wallet the address belongs to. + * @param addressId - The ID of the address the transfers belong to. + * @param limit - The maximum number of transfers to return. + * @param page - The cursor for pagination across multiple pages of Transfers. + * @param options - Axios request options. + * @returns - A promise resolving to the Transfer list. + * @throws {APIError} If the request fails. + */ + listTransfers( + walletId: string, + addressId: string, + limit?: number, + page?: string, + options?: AxiosRequestConfig, + ): AxiosPromise; +}; + /** * API clients type definition for the Coinbase SDK. * Represents the set of API clients available in the SDK. */ export type ApiClients = { user?: UserAPIClient; - address?: AddressAPIClient; wallet?: WalletAPIClient; + address?: AddressAPIClient; + transfer?: TransferAPIClient; }; diff --git a/src/coinbase/user.ts b/src/coinbase/user.ts index c5fab06c..347a2990 100644 --- a/src/coinbase/user.ts +++ b/src/coinbase/user.ts @@ -1,5 +1,6 @@ import { ApiClients } from "./types"; import { User as UserModel } from "./../client/api"; + /** * A representation of a User. * Users have Wallets, which can hold balances of Assets. From f17c7864ac4d9be25790666e0f54918f9af67d83 Mon Sep 17 00:00:00 2001 From: John Peterson Date: Tue, 14 May 2024 12:09:05 -0700 Subject: [PATCH 2/4] [PSDK-117] Implement Transfer Class --- package.json | 3 +- src/coinbase/coinbase.ts | 7 + src/coinbase/errors.ts | 19 ++ src/coinbase/tests/transfer_test.ts | 274 ++++++++++++++++++++++++++++ src/coinbase/transfer.ts | 226 +++++++++++++++++++++++ src/coinbase/types.ts | 12 ++ 6 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 src/coinbase/tests/transfer_test.ts create mode 100644 src/coinbase/transfer.ts diff --git a/package.json b/package.json index 6ee4598f..4a6c6ea0 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "text" ], "verbose": true, - "testRegex": ".test.ts$" + "testRegex": ".test.ts$", + "maxWorkers": 1 } } diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index 1f0b82ba..0e968af2 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -1,6 +1,7 @@ import globalAxios from "axios"; import fs from "fs"; import { User as UserModel, UsersApiFactory, TransfersApiFactory } from "../client"; +import { ethers } from "ethers"; import { BASE_PATH } from "./../client/base"; import { Configuration } from "./../client/configuration"; import { CoinbaseAuthenticator } from "./authenticator"; @@ -24,6 +25,11 @@ export class Coinbase { apiClients: ApiClients = {}; + /** + * Represents the number of Wei per Ether. + */ + static readonly WEI_PER_ETHER: bigint = BigInt("1000000000000000000"); + /** * Initializes the Coinbase SDK. * @@ -60,6 +66,7 @@ export class Coinbase { this.apiClients.user = UsersApiFactory(config, BASE_PATH, axiosInstance); this.apiClients.transfer = TransfersApiFactory(config, BASE_PATH, axiosInstance); + this.apiClients.baseSepoliaProvider = new ethers.JsonRpcProvider("https://sepolia.base.org"); } /** diff --git a/src/coinbase/errors.ts b/src/coinbase/errors.ts index af0431e6..3b66048f 100644 --- a/src/coinbase/errors.ts +++ b/src/coinbase/errors.ts @@ -77,3 +77,22 @@ export class InvalidConfiguration extends Error { } } } + +/** + * InvalidUnsignedPayload error is thrown when the unsigned payload is invalid. + */ +export class InvalidUnsignedPayload extends Error { + static DEFAULT_MESSAGE = "Invalid unsigned payload"; + + /** + * Initializes a new InvalidUnsignedPayload instance. + * @param message - The error message. + */ + constructor(message: string = InvalidUnsignedPayload.DEFAULT_MESSAGE) { + super(message); + this.name = "InvalidUnsignedPayload"; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, InvalidUnsignedPayload); + } + } +} diff --git a/src/coinbase/tests/transfer_test.ts b/src/coinbase/tests/transfer_test.ts new file mode 100644 index 00000000..75733300 --- /dev/null +++ b/src/coinbase/tests/transfer_test.ts @@ -0,0 +1,274 @@ +import { ethers } from "ethers"; +import { Transfer as TransferModel, TransferStatusEnum } from "../../client/api"; +import { ApiClients, TransferStatus } from "../types"; +import { Transfer } from "../transfer"; +import { Coinbase } from "../coinbase"; + +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 signedPayload = + "02f86b83014a3401830f4240830f4350825208946cd01c0f55ce9e0bf78f5e90f72b4345b" + + "16d515d0280c001a0566afb8ab09129b3f5b666c3a1e4a7e92ae12bbee8c75b4c6e0c46f6" + + "6dd10094a02115d1b52c49b39b6cb520077161c9bf636730b1b40e749250743f4524e9e4ba"; + +const transactionHash = "0x6c087c1676e8269dd81e0777244584d0cbfd39b6997b3477242a008fa9349e11"; + +const mockProvider = new ethers.JsonRpcProvider( + "https://sepolia.base.org", +) as jest.Mocked; +mockProvider.getTransaction = jest.fn(); +mockProvider.getTransactionReceipt = jest.fn(); + +describe("Transfer Class", () => { + let transferModel: TransferModel; + let mockApiClients: ApiClients; + 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 = { baseSepoliaProvider: mockProvider } as ApiClients; + + transfer = new Transfer(transferModel, mockApiClients); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("constructor", () => { + it("should initialize a new Transfer", () => { + expect(transfer).toBeInstanceOf(Transfer); + }); + }); + + describe("getId", () => { + it("should return the transfer ID", () => { + expect(transfer.getId()).toEqual(transferId); + }); + }); + + describe("getNetworkId", () => { + it("should return the network ID", () => { + expect(transfer.getNetworkId()).toEqual(networkId); + }); + }); + + describe("getWalletId", () => { + it("should return the wallet ID", () => { + expect(transfer.getWalletId()).toEqual(walletId); + }); + }); + + describe("getFromAddressId", () => { + it("should return the source address ID", () => { + expect(transfer.getFromAddressId()).toEqual(fromAddressId); + }); + }); + + describe("getDestinationAddressId", () => { + it("should return the destination address ID", () => { + expect(transfer.getDestinationAddressId()).toEqual(toAddressId); + }); + }); + + describe("getAssetId", () => { + it("should return the asset ID", () => { + expect(transfer.getAssetId()).toEqual("eth"); + }); + }); + + 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(BigInt(ethAmount)); + }); + }); + + describe("getUnsignedPayload", () => { + it("should return the unsigned payload", () => { + expect(transfer.getUnsignedPayload()).toEqual(unsignedPayload); + }); + }); + + describe("getSignedPayload", () => { + it("should return undefined when the transfer has not been broadcast on chain", () => { + expect(transfer.getSignedPayload()).toBeUndefined(); + }); + + it("should return the signed payload when the transfer has been broadcast on chain", () => { + transferModel.signed_payload = signedPayload; + transfer = new Transfer(transferModel, mockApiClients); + expect(transfer.getSignedPayload()).toEqual(signedPayload); + }); + }); + + describe("getTransactionHash", () => { + it("should return undefined when the transfer has not been broadcast on chain", () => { + expect(transfer.getTransactionHash()).toBeUndefined(); + }); + + it("should return the transaction hash when the transfer has been broadcast on chain", () => { + transferModel.transaction_hash = transactionHash; + transfer = new Transfer(transferModel, mockApiClients); + expect(transfer.getTransactionHash()).toEqual(transactionHash); + }); + }); + + describe("getTransaction", () => { + it("should return the Transfer transaction", () => { + const transaction = transfer.getTransaction(); + expect(transaction).toBeInstanceOf(ethers.Transaction); + expect(transaction.chainId).toEqual(BigInt("0x14a34")); + expect(transaction.nonce).toEqual(Number("0x0")); + 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.data).toEqual("0x"); + }); + }); + + describe("getStatus", () => { + it("should return PENDING when the transaction has not been created", async () => { + const status = await transfer.getStatus(); + expect(status).toEqual(TransferStatus.PENDING); + }); + + it("should return PENDING when the transaction has been created but not broadcast", async () => { + transferModel.transaction_hash = transactionHash; + transfer = new Transfer(transferModel, mockApiClients); + mockProvider.getTransaction.mockResolvedValueOnce(null); + const status = await transfer.getStatus(); + expect(status).toEqual(TransferStatus.PENDING); + }); + + 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); + mockProvider.getTransaction.mockResolvedValueOnce({ + blockHash: null, + } as ethers.TransactionResponse); + const status = await transfer.getStatus(); + expect(status).toEqual(TransferStatus.BROADCAST); + }); + + it("should return COMPLETE when the transaction has confirmed", 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); + const status = await transfer.getStatus(); + expect(status).toEqual(TransferStatus.COMPLETE); + }); + + 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); + 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/transfer.ts b/src/coinbase/transfer.ts new file mode 100644 index 00000000..ad9cb758 --- /dev/null +++ b/src/coinbase/transfer.ts @@ -0,0 +1,226 @@ +import { ApiClients, TransferStatus } from "./types"; +import { Coinbase } from "./coinbase"; +import { Transfer as TransferModel } from "../client/api"; +import { ethers } from "ethers"; +import { InternalError, InvalidUnsignedPayload } from "./errors"; + +/** + * A representation of a transfer, which moves an amount of an asset from + * a user-controlled wallet to another address. The fee is assumed to be paid + * in the native asset of the network. + */ +export class Transfer { + private model: TransferModel; + private client: ApiClients; + private transaction?: ethers.Transaction; + + /** + * Initializes a new Transfer instance. + * @param {TransferModel} transferModel - The transfer model. + * @param {ApiClients} client - The API clients. + */ + constructor(transferModel: TransferModel, client: ApiClients) { + 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; + } + + /** + * Returns the ID of the transfer. + * @returns {string} The transfer ID. + */ + public getId(): string { + return this.model.transfer_id; + } + + /** + * Returns the network ID of the transfer. + * @returns {string} The network ID. + */ + public getNetworkId(): string { + return this.model.network_id; + } + + /** + * Returns the wallet ID of the transfer. + * @returns {string} The wallet ID. + */ + public getWalletId(): string { + return this.model.wallet_id; + } + + /** + * Returns the from address ID of the transfer. + * @returns {string} The from address ID. + */ + public getFromAddressId(): string { + return this.model.address_id; + } + + /** + * Returns the destination address ID of the transfer. + * @returns {string} The destination address ID. + */ + public getDestinationAddressId(): string { + return this.model.destination; + } + + /** + * Returns the asset ID of the transfer. + * @returns {string} The asset ID. + */ + public getAssetId(): string { + return this.model.asset_id; + } + + /** + * Returns the amount of the transfer. + * @returns {string} The amount of the asset. + */ + public getAmount(): bigint { + const amount = BigInt(this.model.amount); + + if (this.getAssetId() === "eth") { + return amount / BigInt(Coinbase.WEI_PER_ETHER); + } + return BigInt(this.model.amount); + } + + /** + * Returns the unsigned payload of the transfer. + * @returns {string} The unsigned payload as a hex string. + */ + public getUnsignedPayload(): string { + return this.model.unsigned_payload; + } + + /** + * Returns the signed payload of the transfer. + * @returns {string | undefined} The signed payload as a hex string, or undefined if not yet available. + */ + public getSignedPayload(): string | undefined { + return this.model.signed_payload; + } + + /** + * Returns the transaction hash of the transfer. + * @returns {string | undefined} The transaction hash as a hex string, or undefined if not yet available. + */ + public getTransactionHash(): string | undefined { + return this.model.transaction_hash; + } + + /** + * Returns the transaction of the transfer. + * @returns {ethers.Transaction} The ethers.js Transaction object. + * @throws (InvalidUnsignedPayload) If the unsigned payload is invalid. + */ + public getTransaction(): ethers.Transaction { + if (this.transaction) return this.transaction; + + const transaction = new ethers.Transaction(); + + const rawPayload = this.getUnsignedPayload() + .match(/../g) + ?.map(byte => parseInt(byte, 16)); + if (!rawPayload) { + throw new InvalidUnsignedPayload("Unable to parse unsigned payload"); + } + + const rawPayloadBytes = new Uint8Array(rawPayload); + + const decoder = new TextDecoder(); + + let parsedPayload; + try { + parsedPayload = JSON.parse(decoder.decode(rawPayloadBytes)); + } catch (error) { + throw new InvalidUnsignedPayload("Unable to decode unsigned payload JSON"); + } + + transaction.chainId = BigInt(parsedPayload["chainId"]); + transaction.nonce = BigInt(parsedPayload["nonce"]); + transaction.maxPriorityFeePerGas = BigInt(parsedPayload["maxPriorityFeePerGas"]); + transaction.maxFeePerGas = BigInt(parsedPayload["maxFeePerGas"]); + transaction.gasLimit = BigInt(parsedPayload["gas"]); + transaction.to = parsedPayload["to"]; + transaction.value = BigInt(parsedPayload["value"]); + transaction.data = parsedPayload["input"]; + + this.transaction = transaction; + return transaction; + } + + /** + * Sets the signed transaction of the transfer. + * @param {ethers.Transaction} transaction - The signed transaction. + */ + public setSignedTransaction(transaction: ethers.Transaction): void { + this.transaction = transaction; + } + + /** + * Returns the status of the transfer. + * @returns {Promise} The status of the transfer. + */ + public async getStatus(): Promise { + const transactionHash = this.getTransactionHash(); + if (!transactionHash) return TransferStatus.PENDING; + + const onchainTransansaction = + await this.client.baseSepoliaProvider!.getTransaction(transactionHash); + if (!onchainTransansaction) return TransferStatus.PENDING; + if (!onchainTransansaction.blockHash) return TransferStatus.BROADCAST; + + const transactionReceipt = + await this.client.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. + * Raises an error if the transfer takes longer than the given timeout. + * @param {number} intervalSeconds - The interval at which to poll the network, in seconds. + * @param {number} timeoutSeconds - The maximum amount of time to wait for the transfer to complete, in seconds. + * @returns {Promise} The completed Transfer object. + */ + 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 new Promise(resolve => setTimeout(resolve, intervalSeconds * 1000)); + } + throw new Error("Transfer timed out"); + } + + /** + * Returns the link to the transaction on the blockchain explorer. + * @returns {string} The link to the transaction on the blockchain explorer. + */ + public getTransactionLink(): string { + return `https://sepolia.basescan.org/tx/${this.getTransactionHash()}`; + } + + /** + * Returns a string representation of the Transfer. + * @returns {Promise} a string representation of the Transfer. + */ + public async toString(): Promise { + const status = await this.getStatus(); + return ( + `Coinbase::Transfer{transfer_id: '${this.getId()}', network_id: '${this.getNetworkId()}', ` + + `from_address_id: '${this.getFromAddressId()}', destination_address_id: '${this.getDestinationAddressId()}', ` + + `asset_id: '${this.getAssetId()}', amount: '${this.getAmount()}', transaction_hash: '${this.getTransactionHash()}', ` + + `transaction_link: '${this.getTransactionLink()}', status: '${status}'}` + ); + } +} diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index e956c90d..0ce6957f 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -1,4 +1,5 @@ import { AxiosPromise, AxiosRequestConfig, RawAxiosRequestConfig } from "axios"; +import { ethers } from "ethers"; import { CreateWalletRequest, BroadcastTransferRequest, @@ -160,4 +161,15 @@ export type ApiClients = { wallet?: WalletAPIClient; address?: AddressAPIClient; transfer?: TransferAPIClient; + baseSepoliaProvider?: ethers.Provider; }; + +/** + * Transfer status type definition. + */ +export enum TransferStatus { + PENDING = "PENDING", + BROADCAST = "BROADCAST", + COMPLETE = "COMPLETE", + FAILED = "FAILED", +} From d538a1f437dc55d7f6a6b343fc3539f51da6bf47 Mon Sep 17 00:00:00 2001 From: John Peterson Date: Thu, 16 May 2024 10:48:19 -0700 Subject: [PATCH 3/4] code review --- src/coinbase/coinbase.ts | 19 +++- src/coinbase/errors.ts | 1 + src/coinbase/tests/transfer_test.ts | 11 +- src/coinbase/transfer.ts | 149 ++++++++++++++++------------ 4 files changed, 111 insertions(+), 69 deletions(-) diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index 0e968af2..e85eec2b 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -23,10 +23,25 @@ export class Coinbase { BaseSepolia: "base-sepolia", }; + /** + * The list of supported assets. + * + * @constant + */ + static assetList = { + Eth: "eth", + Wei: "wei", + Gwei: "gwei", + Usdc: "usdc", + Weth: "weth", + }; + apiClients: ApiClients = {}; /** * Represents the number of Wei per Ether. + * + * @constant */ static readonly WEI_PER_ETHER: bigint = BigInt("1000000000000000000"); @@ -75,7 +90,7 @@ export class Coinbase { * @param filePath - The path to the JSON file containing the API key and private key. * @param debugging - If true, logs API requests and responses to the console. * @param basePath - The base path for the API. - * @returns {Coinbase} A new instance of the Coinbase SDK. + * @returns A new instance of the Coinbase SDK. * @throws {InvalidAPIKeyFormat} If the file does not exist or the configuration values are missing/invalid. * @throws {InvalidConfiguration} If the configuration is invalid. * @throws {InvalidAPIKeyFormat} If not able to create JWT token. @@ -110,7 +125,7 @@ export class Coinbase { /** * Returns User object for the default user. * - * @returns {User} The default user. + * @returns The default user. * @throws {APIError} If the request fails. */ async getDefaultUser(): Promise { diff --git a/src/coinbase/errors.ts b/src/coinbase/errors.ts index 3b66048f..46ea85b9 100644 --- a/src/coinbase/errors.ts +++ b/src/coinbase/errors.ts @@ -86,6 +86,7 @@ export class InvalidUnsignedPayload extends Error { /** * Initializes a new InvalidUnsignedPayload instance. + * * @param message - The error message. */ constructor(message: string = InvalidUnsignedPayload.DEFAULT_MESSAGE) { diff --git a/src/coinbase/tests/transfer_test.ts b/src/coinbase/tests/transfer_test.ts index 75733300..3d57161b 100644 --- a/src/coinbase/tests/transfer_test.ts +++ b/src/coinbase/tests/transfer_test.ts @@ -1,7 +1,7 @@ import { ethers } from "ethers"; import { Transfer as TransferModel, TransferStatusEnum } from "../../client/api"; -import { ApiClients, TransferStatus } from "../types"; -import { Transfer } from "../transfer"; +import { TransferAPIClient, TransferStatus } from "../types"; +import { Transfer, TransferClients } from "../transfer"; import { Coinbase } from "../coinbase"; const fromKey = ethers.Wallet.createRandom(); @@ -41,7 +41,7 @@ mockProvider.getTransactionReceipt = jest.fn(); describe("Transfer Class", () => { let transferModel: TransferModel; - let mockApiClients: ApiClients; + let mockApiClients: TransferClients; let transfer: Transfer; beforeEach(() => { @@ -57,7 +57,10 @@ describe("Transfer Class", () => { status: TransferStatusEnum.Pending, } as TransferModel; - mockApiClients = { baseSepoliaProvider: mockProvider } as ApiClients; + mockApiClients = { + transfer: {} as TransferAPIClient, + baseSepoliaProvider: mockProvider, + } as TransferClients; transfer = new Transfer(transferModel, mockApiClients); }); diff --git a/src/coinbase/transfer.ts b/src/coinbase/transfer.ts index ad9cb758..3f9e5397 100644 --- a/src/coinbase/transfer.ts +++ b/src/coinbase/transfer.ts @@ -1,25 +1,34 @@ -import { ApiClients, TransferStatus } from "./types"; +import { TransferAPIClient, TransferStatus } from "./types"; import { Coinbase } from "./coinbase"; import { Transfer as TransferModel } from "../client/api"; import { ethers } from "ethers"; import { InternalError, InvalidUnsignedPayload } from "./errors"; /** - * A representation of a transfer, which moves an amount of an asset from - * a user-controlled wallet to another address. The fee is assumed to be paid - * in the native asset of the network. + * The Transfer API client types. + */ +export type TransferClients = { + transfer: TransferAPIClient; + baseSepoliaProvider: ethers.Provider; +}; + +/** + * A representation of a Transfer, which moves an Amount of an Asset from + * a user-controlled Wallet to another Address. The fee is assumed to be paid + * in the native Asset of the Network. */ export class Transfer { private model: TransferModel; - private client: ApiClients; + private client: TransferClients; private transaction?: ethers.Transaction; /** * Initializes a new Transfer instance. - * @param {TransferModel} transferModel - The transfer model. - * @param {ApiClients} client - The API clients. + * + * @param transferModel - The Transfer model. + * @param client - The API clients. */ - constructor(transferModel: TransferModel, client: ApiClients) { + constructor(transferModel: TransferModel, client: TransferClients) { if (!transferModel) { throw new InternalError("Transfer model cannot be empty"); } @@ -32,94 +41,105 @@ export class Transfer { } /** - * Returns the ID of the transfer. - * @returns {string} The transfer ID. + * Returns the ID of the Transfer. + * + * @returns The Transfer ID. */ public getId(): string { return this.model.transfer_id; } /** - * Returns the network ID of the transfer. - * @returns {string} The network ID. + * Returns the Network ID of the Transfer. + * + * @returns The Network ID. */ public getNetworkId(): string { return this.model.network_id; } /** - * Returns the wallet ID of the transfer. - * @returns {string} The wallet ID. + * Returns the Wallet ID of the Transfer. + * + * @returns The Wallet ID. */ public getWalletId(): string { return this.model.wallet_id; } /** - * Returns the from address ID of the transfer. - * @returns {string} The from address ID. + * Returns the From Address ID of the Transfer. + * + * @returns The From Address ID. */ public getFromAddressId(): string { return this.model.address_id; } /** - * Returns the destination address ID of the transfer. - * @returns {string} The destination address ID. + * Returns the Destination Address ID of the Transfer. + * + * @returns The Destination Address ID. */ public getDestinationAddressId(): string { return this.model.destination; } /** - * Returns the asset ID of the transfer. - * @returns {string} The asset ID. + * Returns the Asset ID of the Transfer. + * + * @returns The Asset ID. */ public getAssetId(): string { return this.model.asset_id; } /** - * Returns the amount of the transfer. - * @returns {string} The amount of the asset. + * Returns the Amount of the Transfer. + * + * @returns The Amount of the Asset. */ public getAmount(): bigint { const amount = BigInt(this.model.amount); - if (this.getAssetId() === "eth") { + if (this.getAssetId() === Coinbase.assetList.Eth) { return amount / BigInt(Coinbase.WEI_PER_ETHER); } return BigInt(this.model.amount); } /** - * Returns the unsigned payload of the transfer. - * @returns {string} The unsigned payload as a hex string. + * Returns the Unsigned Payload of the Transfer. + * + * @returns The Unsigned Payload as a Hex string. */ public getUnsignedPayload(): string { return this.model.unsigned_payload; } /** - * Returns the signed payload of the transfer. - * @returns {string | undefined} The signed payload as a hex string, or undefined if not yet available. + * Returns the Signed Payload of the Transfer. + * + * @returns The Signed Payload as a Hex string, or undefined if not yet available. */ public getSignedPayload(): string | undefined { return this.model.signed_payload; } /** - * Returns the transaction hash of the transfer. - * @returns {string | undefined} The transaction hash as a hex string, or undefined if not yet available. + * Returns the Transaction Hash of the Transfer. + * + * @returns The Transaction Hash as a Hex string, or undefined if not yet available. */ public getTransactionHash(): string | undefined { return this.model.transaction_hash; } /** - * Returns the transaction of the transfer. - * @returns {ethers.Transaction} The ethers.js Transaction object. - * @throws (InvalidUnsignedPayload) If the unsigned payload is invalid. + * Returns the Transaction of the Transfer. + * + * @returns The ethers.js Transaction object. + * @throws (InvalidUnsignedPayload) If the Unsigned Payload is invalid. */ public getTransaction(): ethers.Transaction { if (this.transaction) return this.transaction; @@ -133,50 +153,50 @@ export class Transfer { throw new InvalidUnsignedPayload("Unable to parse unsigned payload"); } - const rawPayloadBytes = new Uint8Array(rawPayload); - - const decoder = new TextDecoder(); - let parsedPayload; try { + const rawPayloadBytes = new Uint8Array(rawPayload); + const decoder = new TextDecoder(); parsedPayload = JSON.parse(decoder.decode(rawPayloadBytes)); } catch (error) { throw new InvalidUnsignedPayload("Unable to decode unsigned payload JSON"); } - transaction.chainId = BigInt(parsedPayload["chainId"]); - transaction.nonce = BigInt(parsedPayload["nonce"]); - transaction.maxPriorityFeePerGas = BigInt(parsedPayload["maxPriorityFeePerGas"]); - transaction.maxFeePerGas = BigInt(parsedPayload["maxFeePerGas"]); - transaction.gasLimit = BigInt(parsedPayload["gas"]); - transaction.to = parsedPayload["to"]; - transaction.value = BigInt(parsedPayload["value"]); - transaction.data = parsedPayload["input"]; + transaction.chainId = BigInt(parsedPayload.chainId); + transaction.nonce = BigInt(parsedPayload.nonce); + transaction.maxPriorityFeePerGas = BigInt(parsedPayload.maxPriorityFeePerGas); + transaction.maxFeePerGas = BigInt(parsedPayload.maxFeePerGas); + transaction.gasLimit = BigInt(parsedPayload.gas); + transaction.to = parsedPayload.to; + transaction.value = BigInt(parsedPayload.value); + transaction.data = parsedPayload.input; this.transaction = transaction; return transaction; } /** - * Sets the signed transaction of the transfer. - * @param {ethers.Transaction} transaction - The signed transaction. + * Sets the Signed Transaction of the Transfer. + * + * @param transaction - The Signed Transaction. */ public setSignedTransaction(transaction: ethers.Transaction): void { this.transaction = transaction; } /** - * Returns the status of the transfer. - * @returns {Promise} The status of the transfer. + * Returns the Status of the Transfer. + * + * @returns The Status of the Transfer. */ public async getStatus(): Promise { const transactionHash = this.getTransactionHash(); if (!transactionHash) return TransferStatus.PENDING; - const onchainTransansaction = + const onchainTransaction = await this.client.baseSepoliaProvider!.getTransaction(transactionHash); - if (!onchainTransansaction) return TransferStatus.PENDING; - if (!onchainTransansaction.blockHash) return TransferStatus.BROADCAST; + if (!onchainTransaction) return TransferStatus.PENDING; + if (!onchainTransaction.blockHash) return TransferStatus.BROADCAST; const transactionReceipt = await this.client.baseSepoliaProvider!.getTransactionReceipt(transactionHash); @@ -184,11 +204,12 @@ export class Transfer { } /** - * Waits until the transfer is completed or failed by polling the network at the given interval. - * Raises an error if the transfer takes longer than the given timeout. - * @param {number} intervalSeconds - The interval at which to poll the network, in seconds. - * @param {number} timeoutSeconds - The maximum amount of time to wait for the transfer to complete, in seconds. - * @returns {Promise} The completed Transfer object. + * 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(); @@ -203,8 +224,9 @@ export class Transfer { } /** - * Returns the link to the transaction on the blockchain explorer. - * @returns {string} The link to the transaction on the blockchain explorer. + * Returns the link to the Transaction on the blockchain explorer. + * + * @returns The link to the Transaction on the blockchain explorer. */ public getTransactionLink(): string { return `https://sepolia.basescan.org/tx/${this.getTransactionHash()}`; @@ -212,15 +234,16 @@ export class Transfer { /** * Returns a string representation of the Transfer. - * @returns {Promise} a string representation of the Transfer. + * + * @returns The string representation of the Transfer. */ public async toString(): Promise { const status = await this.getStatus(); return ( - `Coinbase::Transfer{transfer_id: '${this.getId()}', network_id: '${this.getNetworkId()}', ` + - `from_address_id: '${this.getFromAddressId()}', destination_address_id: '${this.getDestinationAddressId()}', ` + - `asset_id: '${this.getAssetId()}', amount: '${this.getAmount()}', transaction_hash: '${this.getTransactionHash()}', ` + - `transaction_link: '${this.getTransactionLink()}', status: '${status}'}` + `Transfer{transferId: '${this.getId()}', networkId: '${this.getNetworkId()}', ` + + `fromAddressId: '${this.getFromAddressId()}', destinationAddressId: '${this.getDestinationAddressId()}', ` + + `assetId: '${this.getAssetId()}', amount: '${this.getAmount()}', transactionHash: '${this.getTransactionHash()}', ` + + `transactionLink: '${this.getTransactionLink()}', status: '${status}'}` ); } } From 441d808b721560140dc384df2cbf8456afcd7275 Mon Sep 17 00:00:00 2001 From: John Peterson Date: Thu, 16 May 2024 11:07:05 -0700 Subject: [PATCH 4/4] delay util --- src/coinbase/transfer.ts | 3 ++- src/coinbase/utils.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/coinbase/transfer.ts b/src/coinbase/transfer.ts index 3f9e5397..79e4687d 100644 --- a/src/coinbase/transfer.ts +++ b/src/coinbase/transfer.ts @@ -3,6 +3,7 @@ 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. @@ -218,7 +219,7 @@ export class Transfer { if (status === TransferStatus.COMPLETE || status === TransferStatus.FAILED) { return this; } - await new Promise(resolve => setTimeout(resolve, intervalSeconds * 1000)); + await delay(intervalSeconds); } throw new Error("Transfer timed out"); } diff --git a/src/coinbase/utils.ts b/src/coinbase/utils.ts index b45ab341..f647b8c1 100644 --- a/src/coinbase/utils.ts +++ b/src/coinbase/utils.ts @@ -70,3 +70,13 @@ export const registerAxiosInterceptors = ( export const convertStringToHex = (key: Uint8Array): string => { return Buffer.from(key).toString("hex"); }; + +/** + * Delays the execution of the function by the specified number of seconds. + * + * @param seconds - The number of seconds to delay the execution. + * @returns A promise that resolves after the specified number of seconds. + */ +export async function delay(seconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, seconds * 1000)); +}