diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index 764659ee..9bd67587 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -16,15 +16,20 @@ import { InternalError } from "../errors"; import { createAxiosMock } from "./utils"; import Decimal from "decimal.js"; -const newEthAddress = ethers.Wallet.createRandom(); +// newAddressModel creates a new AddressModel with a random wallet ID and a random Ethereum address. +export const newAddressModel = (walletId: string): AddressModel => { + const ethAddress = ethers.Wallet.createRandom(); -const VALID_ADDRESS_MODEL: AddressModel = { - address_id: newEthAddress.address, - network_id: Coinbase.networkList.BaseSepolia, - public_key: newEthAddress.publicKey, - wallet_id: randomUUID(), + return { + address_id: ethAddress.address, + network_id: Coinbase.networkList.BaseSepolia, + public_key: ethAddress.publicKey, + wallet_id: walletId, + }; }; +const VALID_ADDRESS_MODEL = newAddressModel(randomUUID()); + const VALID_BALANCE_MODEL: BalanceModel = { amount: "1000000000000000000", asset: { @@ -87,11 +92,11 @@ describe("Address", () => { expect(address).toBeInstanceOf(Address); }); - it("should return the network ID", () => { - expect(address.getId()).toBe(newEthAddress.address); + it("should return the address ID", () => { + expect(address.getId()).toBe(VALID_ADDRESS_MODEL.address_id); }); - it("should return the address ID", () => { + it("should return the network ID", () => { expect(address.getNetworkId()).toBe(VALID_ADDRESS_MODEL.network_id); }); diff --git a/src/coinbase/tests/wallet_test.ts b/src/coinbase/tests/wallet_test.ts index b82240fc..16d5046e 100644 --- a/src/coinbase/tests/wallet_test.ts +++ b/src/coinbase/tests/wallet_test.ts @@ -1,22 +1,27 @@ import MockAdapter from "axios-mock-adapter"; import * as bip39 from "bip39"; import { randomUUID } from "crypto"; -import { AddressesApiFactory, WalletsApiFactory } from "../../client"; +import { Address as AddressModel, AddressesApiFactory, WalletsApiFactory } from "../../client"; import { Coinbase } from "../coinbase"; +import { BASE_PATH } from "../../client/base"; import { ArgumentError } from "../errors"; import { Wallet } from "../wallet"; import { createAxiosMock } from "./utils"; +import { newAddressModel } from "./address_test"; const walletId = randomUUID(); + +const DEFAULT_ADDRESS_MODEL = newAddressModel(walletId); + export const VALID_WALLET_MODEL = { - id: randomUUID(), + id: walletId, + network_id: Coinbase.networkList.BaseSepolia, + default_address: DEFAULT_ADDRESS_MODEL, +}; + +export const VALID_WALLET_MODEL_WITHOUT_DEFAULT_ADDRESS = { + id: walletId, network_id: Coinbase.networkList.BaseSepolia, - default_address: { - wallet_id: walletId, - address_id: "0xdeadbeef", - public_key: "0x1234567890", - network_id: Coinbase.networkList.BaseSepolia, - }, }; describe("Wallet Class", () => { @@ -31,24 +36,89 @@ describe("Wallet Class", () => { beforeAll(async () => { axiosMock = new MockAdapter(axiosInstance); - axiosMock.onPost().reply(200, VALID_WALLET_MODEL).onGet().reply(200, VALID_WALLET_MODEL); - wallet = await Wallet.init(VALID_WALLET_MODEL, client, seed, 2); }); afterEach(() => { axiosMock.reset(); }); - describe("should initializes a new Wallet", () => { + describe(".create", () => { + beforeEach(async () => { + axiosMock + .onPost(BASE_PATH + "/v1/wallets") + .reply(200, VALID_WALLET_MODEL_WITHOUT_DEFAULT_ADDRESS); + + axiosMock + .onPost(BASE_PATH + "/v1/wallets/" + VALID_WALLET_MODEL.id + "/addresses") + .reply(200, DEFAULT_ADDRESS_MODEL); + + // Mock reloading the wallet after default address is created. + axiosMock + .onGet(BASE_PATH + "/v1/wallets/" + VALID_WALLET_MODEL.id) + .reply(200, VALID_WALLET_MODEL); + + wallet = await Wallet.create(client); + }); + it("should return a Wallet instance", async () => { expect(wallet).toBeInstanceOf(Wallet); }); + it("should return the correct wallet ID", async () => { expect(wallet.getId()).toBe(VALID_WALLET_MODEL.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(DEFAULT_ADDRESS_MODEL.address_id); + }); + }); + + describe(".init", () => { + const existingSeed = + "hidden assault maple cheap gentle paper earth surprise trophy guide room tired"; + const addressList = [ + { + address_id: "0x23626702fdC45fc75906E535E38Ee1c7EC0C3213", + network_id: Coinbase.networkList.BaseSepolia, + public_key: "0x032c11a826d153bb8cf17426d03c3ffb74ea445b17362f98e1536f22bcce720772", + wallet_id: walletId, + }, + { + address_id: "0x770603171A98d1CD07018F7309A1413753cA0018", + network_id: Coinbase.networkList.BaseSepolia, + public_key: "0x03c3379b488a32a432a4dfe91cc3a28be210eddc98b2005bb59a4cf4ed0646eb56", + wallet_id: walletId, + }, + ]; + + beforeEach(async () => { + addressList.forEach(address => { + axiosMock + .onGet( + BASE_PATH + "/v1/wallets/" + VALID_WALLET_MODEL.id + "/addresses/" + address.address_id, + ) + .replyOnce(200, address); + }); + + wallet = await Wallet.init(VALID_WALLET_MODEL, client, existingSeed, 2); + }); + + it("should return a Wallet instance", async () => { + expect(wallet).toBeInstanceOf(Wallet); + }); + + it("should return the correct wallet ID", async () => { + expect(wallet.getId()).toBe(VALID_WALLET_MODEL.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(VALID_WALLET_MODEL.default_address.address_id); }); @@ -62,13 +132,13 @@ describe("Wallet Class", () => { `Wallet{id: '${VALID_WALLET_MODEL.id}', networkId: '${Coinbase.networkList.BaseSepolia}'}`, ); }); - }); - it("should throw an ArgumentError when the API client is not provided", async () => { - await expect(Wallet.init(VALID_WALLET_MODEL, undefined!)).rejects.toThrow(ArgumentError); - }); + it("should throw an ArgumentError when the API client is not provided", async () => { + await expect(Wallet.init(VALID_WALLET_MODEL, undefined!)).rejects.toThrow(ArgumentError); + }); - it("should throw an ArgumentError when the wallet model is not provided", async () => { - await expect(Wallet.init(undefined!, client)).rejects.toThrow(ArgumentError); + it("should throw an ArgumentError when the wallet model is not provided", async () => { + await expect(Wallet.init(undefined!, client)).rejects.toThrow(ArgumentError); + }); }); }); diff --git a/src/coinbase/user.ts b/src/coinbase/user.ts index cf18f862..e29f32c3 100644 --- a/src/coinbase/user.ts +++ b/src/coinbase/user.ts @@ -1,6 +1,5 @@ import { ApiClients } from "./types"; import { User as UserModel } from "./../client/api"; -import { Coinbase } from "./coinbase"; import { Wallet } from "./wallet"; /** @@ -32,13 +31,7 @@ export class User { * @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!, { + return Wallet.create({ wallet: this.client.wallet!, address: this.client.address!, }); diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index f7cda42b..0053631b 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -5,6 +5,7 @@ import { ethers } from "ethers"; import * as secp256k1 from "secp256k1"; import { Address as AddressModel, Wallet as WalletModel } from "../client"; import { Address } from "./address"; +import { Coinbase } from "./coinbase"; import { ArgumentError, InternalError } from "./errors"; import { FaucetTransaction } from "./faucet_transaction"; import { AddressAPIClient, WalletAPIClient } from "./types"; @@ -47,6 +48,36 @@ export class Wallet { this.master = master; } + /** + * Returns a newly created Wallet object. Do not use this method directly. + * Instead, use User.createWallet. + * + * @constructs Wallet + * @param client - The API client to interact with the server. + * @throws {ArgumentError} If the model or client is not provided. + * @throws {InternalError} - If address derivation or caching fails. + * @throws {APIError} - If the request fails. + * @returns A promise that resolves with the new Wallet object. + */ + public static async create(client: WalletClients): Promise { + if (!client?.address || !client?.wallet) { + throw new ArgumentError("Wallet and address clients cannot be empty"); + } + + const walletData = await client.wallet!.createWallet({ + wallet: { + network_id: Coinbase.networkList.BaseSepolia, + }, + }); + + const wallet = await Wallet.init(walletData.data!, client); + + await wallet.createAddress(); + await wallet.reload(); + + return wallet; + } + /** * Returns a new Wallet object. Do not use this method directly. Instead, use User.createWallet or User.importWallet. * @@ -83,9 +114,6 @@ export class Wallet { for (let i = 0; i < addressCount; i++) { await wallet.deriveAddress(); } - } else { - await wallet.createAddress(); - await wallet.updateModel(); } return wallet; @@ -155,9 +183,9 @@ export class Wallet { } /** - * Updates the Wallet model with the latest data from the server. + * Reloads the Wallet model with the latest data from the server. */ - private async updateModel(): Promise { + private async reload(): Promise { const result = await this.client.wallet.getWallet(this.model.id!); this.model = result?.data; }