From 86a33b1d1356e3a20a478cda6d3423668a785d1e Mon Sep 17 00:00:00 2001 From: Erdi Maden Date: Thu, 16 May 2024 18:01:16 -0500 Subject: [PATCH 1/3] Implementing wallet.createWallet --- src/coinbase/address.ts | 9 --- src/coinbase/coinbase.ts | 4 +- src/coinbase/tests/address_test.ts | 4 -- src/coinbase/tests/coinbase_test.ts | 41 +++++++++--- src/coinbase/tests/user_test.ts | 2 +- src/coinbase/tests/wallet_test.ts | 13 ++-- src/coinbase/types.ts | 24 +++++++ src/coinbase/user.ts | 25 +++++++- src/coinbase/wallet.ts | 98 ++++++++++++++++++++++++++--- 9 files changed, 182 insertions(+), 38 deletions(-) diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts index af080f41..8955d1c8 100644 --- a/src/coinbase/address.ts +++ b/src/coinbase/address.ts @@ -62,15 +62,6 @@ export class Address { return this.model.network_id; } - /** - * Returns the public key. - * - * @returns {string} The public key. - */ - public getPublicKey(): string { - return this.model.public_key; - } - /** * Returns the wallet ID. * diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index 6f25e92b..c7db5e54 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 { AddressesApiFactory, User as UserModel, UsersApiFactory, WalletsApiFactory } from "../client"; import { BASE_PATH } from "./../client/base"; import { Configuration } from "./../client/configuration"; import { CoinbaseAuthenticator } from "./authenticator"; @@ -59,6 +59,8 @@ export class Coinbase { ); this.apiClients.user = UsersApiFactory(config, BASE_PATH, axiosInstance); + this.apiClients.wallet = WalletsApiFactory(config, BASE_PATH, axiosInstance); + this.apiClients.address = AddressesApiFactory(config, BASE_PATH, axiosInstance); } /** diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index b817e266..86643b09 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -49,10 +49,6 @@ describe("Address", () => { expect(address.getNetworkId()).toBe(VALID_ADDRESS_MODEL.network_id); }); - it("should return the public key", () => { - expect(address.getPublicKey()).toBe(newEthAddress.publicKey); - }); - it("should return the wallet ID", () => { expect(address.getWalletId()).toBe(VALID_ADDRESS_MODEL.wallet_id); }); diff --git a/src/coinbase/tests/coinbase_test.ts b/src/coinbase/tests/coinbase_test.ts index ae5af200..da439f7d 100644 --- a/src/coinbase/tests/coinbase_test.ts +++ b/src/coinbase/tests/coinbase_test.ts @@ -2,6 +2,7 @@ import { Coinbase } from "../coinbase"; import MockAdapter from "axios-mock-adapter"; import axios from "axios"; import { APIError } from "../api_error"; +import { VALID_WALLET_MODEL } from "./wallet_test"; const axiosMock = new MockAdapter(axios); const PATH_PREFIX = "./src/coinbase/tests/config"; @@ -39,17 +40,43 @@ describe("Coinbase tests", () => { ); }); - it("should be able to get the default user", async () => { - axiosMock.onGet().reply(200, { - id: 123, - }); + describe("should able to interact with the API", () => { const cbInstance = Coinbase.configureFromJson( `${PATH_PREFIX}/coinbase_cloud_api_key.json`, true, ); - const user = await cbInstance.getDefaultUser(); - expect(user.getId()).toBe(123); - expect(user.toString()).toBe("Coinbase:User{userId: 123}"); + let user; + beforeEach(async () => { + axiosMock.reset(); + axiosMock + .onPost(/\/v1\/wallets\/.*\/addresses\/.*\/faucet/) + .reply(200, { transaction_hash: "0xdeadbeef" }) + .onGet(/\/me/) + .reply(200, { + id: 123, + }) + .onPost(/\/v1\/wallets/) + .reply(200, VALID_WALLET_MODEL) + .onGet(/\/v1\/wallets\/.*/) + .reply(200, VALID_WALLET_MODEL); + user = await cbInstance.getDefaultUser(); + }); + + it("should return the correct user ID", () => { + expect(user.getId()).toBe(123); + expect(user.toString()).toBe("User{ userId: 123 }"); + }); + + it("should able to get faucet funds", async () => { + const wallet = await user.createWallet(); + expect(wallet.getId()).toBe(VALID_WALLET_MODEL.id); + + const defaultAddress = wallet.defaultAddress(); + expect(defaultAddress?.getId()).toBe(VALID_WALLET_MODEL.default_address.address_id); + + const faucetTransaction = await wallet?.faucet(); + expect(faucetTransaction.getTransactionHash()).toBe("0xdeadbeef"); + }); }); it("should raise an error if the user is not found", async () => { diff --git a/src/coinbase/tests/user_test.ts b/src/coinbase/tests/user_test.ts index ce047872..fab4292d 100644 --- a/src/coinbase/tests/user_test.ts +++ b/src/coinbase/tests/user_test.ts @@ -22,6 +22,6 @@ describe("User Class", () => { it("should return a correctly formatted string representation of the User instance", () => { const user = new User(mockUserModel, mockApiClients); - expect(user.toString()).toBe(`Coinbase:User{userId: ${mockUserModel.id}}`); + expect(user.toString()).toBe(`User{ userId: ${mockUserModel.id} }`); }); }); diff --git a/src/coinbase/tests/wallet_test.ts b/src/coinbase/tests/wallet_test.ts index bcc64fd1..b82240fc 100644 --- a/src/coinbase/tests/wallet_test.ts +++ b/src/coinbase/tests/wallet_test.ts @@ -8,7 +8,7 @@ import { Wallet } from "../wallet"; import { createAxiosMock } from "./utils"; const walletId = randomUUID(); -const VALID_WALLET_MODEL = { +export const VALID_WALLET_MODEL = { id: randomUUID(), network_id: Coinbase.networkList.BaseSepolia, default_address: { @@ -56,13 +56,12 @@ describe("Wallet Class", () => { it("should derive the correct number of addresses", async () => { expect(wallet.addresses.length).toBe(2); }); - }); - it("should return the correct string representation", async () => { - const wallet = await Wallet.init(VALID_WALLET_MODEL, client); - expect(wallet.toString()).toBe( - `Wallet{id: '${VALID_WALLET_MODEL.id}', networkId: 'base-sepolia'}`, - ); + it("should return the correct string representation", async () => { + expect(wallet.toString()).toBe( + `Wallet{id: '${VALID_WALLET_MODEL.id}', networkId: '${Coinbase.networkList.BaseSepolia}'}`, + ); + }); }); it("should throw an ArgumentError when the API client is not provided", async () => { diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index 123ce3ba..f2c1b877 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -1,6 +1,7 @@ import { AxiosPromise, AxiosRequestConfig, RawAxiosRequestConfig } from "axios"; import { Address, + CreateAddressRequest, CreateWalletRequest, User as UserModel, Wallet as WalletModel, @@ -22,6 +23,15 @@ export type WalletAPIClient = { createWalletRequest?: CreateWalletRequest, options?: RawAxiosRequestConfig, ) => AxiosPromise; + + /** + * Returns the wallet model with the given ID. + * + * @param walletId - The ID of the wallet to fetch + * @param options - Override http request option. + * @throws {APIError} + */ + getWallet: (walletId: string, options?: RawAxiosRequestConfig) => AxiosPromise; }; /** @@ -54,6 +64,20 @@ export type AddressAPIClient = { addressId: string, options?: AxiosRequestConfig, ): AxiosPromise
; + + /** + * Create a new address scoped to the wallet. + * + * @param walletId - The ID of the wallet to create the address in. + * @param createAddressRequest - The address creation request. + * @param options - Axios request options. + * @throws {APIError} If the request fails. + */ + createAddress( + walletId: string, + createAddressRequest?: CreateAddressRequest, + options?: AxiosRequestConfig, + ): AxiosPromise
; }; /** diff --git a/src/coinbase/user.ts b/src/coinbase/user.ts index c5fab06c..a83f64e2 100644 --- a/src/coinbase/user.ts +++ b/src/coinbase/user.ts @@ -1,5 +1,7 @@ import { ApiClients } from "./types"; import { User as UserModel } from "./../client/api"; +import { Coinbase } from "./coinbase"; +import { Wallet } from "./wallet"; /** * A representation of a User. * Users have Wallets, which can hold balances of Assets. @@ -20,6 +22,27 @@ export class User { this.model = user; } + /** + * Creates a new Wallet belonging to the User. + * + * @throws {APIError} If the request fails. + * @throws {ArgumentError} If the model or client is not provided. + * @throws {InternalError} - If address derivation or caching fails. + * @returns the new Wallet + */ + async createWallet(): Promise { + const payload = { + wallet: { + network_id: Coinbase.networkList.BaseSepolia, + }, + }; + const walletData = await this.client.wallet!.createWallet(payload); + return Wallet.init(walletData.data!, { + wallet: this.client.wallet!, + address: this.client.address!, + }); + } + /** * Returns the user's ID. * @@ -35,6 +58,6 @@ export class User { * @returns {string} The string representation of the User. */ toString(): string { - return `Coinbase:User{userId: ${this.model.id}}`; + return `User{ userId: ${this.model.id} }`; } } diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index 7ef4e9d9..f7cda42b 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -1,9 +1,12 @@ import { HDKey } from "@scure/bip32"; import * as bip39 from "bip39"; -import { ethers, Wallet as ETHWallet } from "ethers"; +import * as crypto from "crypto"; +import { ethers } from "ethers"; +import * as secp256k1 from "secp256k1"; import { Address as AddressModel, Wallet as WalletModel } from "../client"; import { Address } from "./address"; import { ArgumentError, InternalError } from "./errors"; +import { FaucetTransaction } from "./faucet_transaction"; import { AddressAPIClient, WalletAPIClient } from "./types"; import { convertStringToHex } from "./utils"; @@ -76,8 +79,13 @@ export class Wallet { const master = HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(seed)); const wallet = new Wallet(model, client, master); - for (let i = 0; i < addressCount; i++) { - await wallet.deriveAddress(); + if (addressCount > 0) { + for (let i = 0; i < addressCount; i++) { + await wallet.deriveAddress(); + } + } else { + await wallet.createAddress(); + await wallet.updateModel(); } return wallet; @@ -86,26 +94,85 @@ export class Wallet { /** * Derives a key for an already registered Address in the Wallet. * + * @throws {InternalError} - If the key derivation fails. * @returns The derived key. */ - private deriveKey(): ETHWallet { + private deriveKey(): HDKey { const derivedKey = this.master.derive(`${this.addressPathPrefix}/${this.addressIndex++}`); if (!derivedKey?.privateKey) { throw new InternalError("Failed to derive key"); } - return new ethers.Wallet(convertStringToHex(derivedKey.privateKey)); + return derivedKey; + } + + /** + * Creates a new Address in the 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 payload = { + public_key: publicKey, + attestation: attestation, + }; + const response = await this.client.address.createAddress(this.model.id!, payload); + this.cacheAddress(response!.data); + } + + /** + * Creates an attestation for the Address currently being created. + * + * @param key - The key of the Wallet. + * @returns The attestation. + */ + private createAttestation(key: HDKey): string { + if (!key.publicKey || !key.privateKey) { + throw InternalError; + } + + const publicKey = convertStringToHex(key.publicKey); + + const payload = JSON.stringify({ + wallet_id: this.model.id, + public_key: publicKey, + }); + + const hashedPayload = crypto.createHash("sha256").update(payload).digest(); + const signature = secp256k1.ecdsaSign(hashedPayload, key.privateKey); + + const r = signature.signature.slice(0, 32); + const s = signature.signature.slice(32, 64); + const v = signature.recid + 27 + 4; + + const newSignatureBuffer = Buffer.concat([Buffer.from([v]), r, s]); + const newSignatureHex = newSignatureBuffer.toString("hex"); + + return newSignatureHex; + } + + /** + * Updates the Wallet model with the latest data from the server. + */ + private async updateModel(): Promise { + const result = await this.client.wallet.getWallet(this.model.id!); + this.model = result?.data; } /** * Derives an already registered Address in the Wallet. * - * @throws {InternalError} - If address derivation or caching fails. + * @throws {InternalError} - If address derivation fails. * @throws {APIError} - If the request fails. - * @returns {Promise} A promise that resolves when the address is derived. + * @returns A promise that resolves when the address is derived. */ private async deriveAddress(): Promise { const key = this.deriveKey(); - const response = await this.client.address.getAddress(this.model.id!, key.address); + const wallet = new ethers.Wallet(convertStringToHex(key.privateKey!)); + const response = await this.client.address.getAddress(this.model.id!, wallet.address); this.cacheAddress(response.data); } @@ -150,6 +217,21 @@ export class Wallet { : undefined; } + /** + * Requests funds from the faucet for the Wallet's default address and returns the faucet transaction. + * This is only supported on testnet networks. + * + * @throws {InternalError} If the default address is not found. + * @throws {APIError} If the request fails. + * @returns The successful faucet transaction + */ + public async faucet(): Promise { + if (!this.model.default_address) { + throw new InternalError("Default address not found"); + } + const transaction = await this.defaultAddress()?.faucet(); + return transaction!; + } /** * Returns a String representation of the Wallet. * From 300d48e9c1a9702c2e49132be9e9fd5b06edf7ec Mon Sep 17 00:00:00 2001 From: Erdi Maden Date: Thu, 16 May 2024 21:58:28 -0500 Subject: [PATCH 2/3] Updating JSDocs --- src/coinbase/tests/coinbase_test.ts | 2 +- src/coinbase/types.ts | 8 ++++---- src/coinbase/user.ts | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/coinbase/tests/coinbase_test.ts b/src/coinbase/tests/coinbase_test.ts index da439f7d..6a887b12 100644 --- a/src/coinbase/tests/coinbase_test.ts +++ b/src/coinbase/tests/coinbase_test.ts @@ -67,7 +67,7 @@ describe("Coinbase tests", () => { expect(user.toString()).toBe("User{ userId: 123 }"); }); - it("should able to get faucet funds", async () => { + it("should be able to get faucet funds", async () => { const wallet = await user.createWallet(); expect(wallet.getId()).toBe(VALID_WALLET_MODEL.id); diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index 7169b290..a5a6db15 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -33,9 +33,9 @@ export type WalletAPIClient = { /** * Returns the wallet model with the given ID. * - * @param walletId - The ID of the wallet to fetch + * @param walletId - The ID of the wallet to fetch. * @param options - Override http request option. - * @throws {APIError} + * @throws {APIError} If the request fails. */ getWallet: (walletId: string, options?: RawAxiosRequestConfig) => AxiosPromise; }; @@ -49,7 +49,7 @@ export type AddressAPIClient = { * * @param walletId - The wallet ID. * @param addressId - The address ID. - * @returns The transaction hash + * @returns The transaction hash. * @throws {APIError} If the request fails. */ requestFaucetFunds( @@ -58,7 +58,7 @@ export type AddressAPIClient = { ): Promise<{ data: { transaction_hash: string } }>; /** - * Get address by onchain address + * Get address by onchain address. * * @param walletId - The ID of the wallet the address belongs to. * @param addressId - The onchain address of the address that is being fetched. diff --git a/src/coinbase/user.ts b/src/coinbase/user.ts index 764b0303..cf18f862 100644 --- a/src/coinbase/user.ts +++ b/src/coinbase/user.ts @@ -15,8 +15,8 @@ export class User { /** * Initializes a new User instance. * - * @param {UserModel} user - The user model. - * @param {ApiClients} client - The API clients. + * @param user - The user model. + * @param client - The API clients. */ constructor(user: UserModel, client: ApiClients) { this.client = client; @@ -26,8 +26,8 @@ export class User { /** * Creates a new Wallet belonging to the User. * - * @throws {APIError} If the request fails. - * @throws {ArgumentError} If the model or client is not provided. + * @throws {APIError} - If the request fails. + * @throws {ArgumentError} - If the model or client is not provided. * @throws {InternalError} - If address derivation or caching fails. * @returns the new Wallet */ @@ -47,7 +47,7 @@ export class User { /** * Returns the user's ID. * - * @returns {string} The user's ID. + * @returns The user's ID. */ public getId(): string { return this.model.id; @@ -56,7 +56,7 @@ export class User { /** * Returns a string representation of the User. * - * @returns {string} The string representation of the User. + * @returns The string representation of the User. */ toString(): string { return `User{ userId: ${this.model.id} }`; From 6d4bfac837981a8d94c354cdb393ef352c2aba5f Mon Sep 17 00:00:00 2001 From: Erdi Maden Date: Thu, 16 May 2024 22:46:17 -0500 Subject: [PATCH 3/3] Adding index.ts --- src/coinbase/index.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/coinbase/index.ts diff --git a/src/coinbase/index.ts b/src/coinbase/index.ts new file mode 100644 index 00000000..cd2aa0e1 --- /dev/null +++ b/src/coinbase/index.ts @@ -0,0 +1 @@ +export { Coinbase } from "./coinbase";