From ea89618345e448240d1cfbaf9f2f096eb376ce16 Mon Sep 17 00:00:00 2001 From: John Peterson Date: Tue, 14 May 2024 12:09:05 -0700 Subject: [PATCH] [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 | 17 ++ 6 files changed, 545 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 3f7dc00c..1a84aee4 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,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 7ab57de1..c72327f8 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"; @@ -13,6 +14,11 @@ import { logApiResponse, registerAxiosInterceptors } from "./utils"; export class Coinbase { apiClients: ApiClients = {}; + /** + * Represents the number of Wei per Ether. + */ + static readonly WEI_PER_ETHER: bigint = BigInt("1000000000000000000"); + /** * Initializes the Coinbase SDK. * @constructor @@ -48,6 +54,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 94d50f0a..796a48f6 100644 --- a/src/coinbase/errors.ts +++ b/src/coinbase/errors.ts @@ -55,3 +55,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 3f41b689..0d32dda8 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -1,4 +1,5 @@ import { AxiosPromise, AxiosRequestConfig } from "axios"; +import { ethers } from "ethers"; import { User as UserModel, Transfer as TransferModel, @@ -126,4 +127,20 @@ export type ApiClients = { * @type {TransferAPIClient} */ transfer?: TransferAPIClient; + + /** + * The ethers.js Provider client for the Base Sepolia network. + * @type {ethers.JsonRpcProvider} + */ + baseSepoliaProvider?: ethers.Provider; }; + +/** + * Transfer status type definition. + */ +export enum TransferStatus { + PENDING = "PENDING", + BROADCAST = "BROADCAST", + COMPLETE = "COMPLETE", + FAILED = "FAILED", +}