Skip to content

Commit

Permalink
Refactor wallet creation to split default address creation
Browse files Browse the repository at this point in the history
This refactors wallet creation to split out default address creation
to be in the `.create` method and not in the initialize method.

This parallels the ruby SDK changes made in:
coinbase/coinbase-sdk-ruby#50
  • Loading branch information
alex-stone committed May 20, 2024
1 parent 966c368 commit 7ff1cdd
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 39 deletions.
23 changes: 14 additions & 9 deletions src/coinbase/tests/address_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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);
});

Expand Down
104 changes: 87 additions & 17 deletions src/coinbase/tests/wallet_test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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);
});
Expand All @@ -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);
});
});
});
9 changes: 1 addition & 8 deletions src/coinbase/user.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { ApiClients } from "./types";
import { User as UserModel } from "./../client/api";
import { Coinbase } from "./coinbase";
import { Wallet } from "./wallet";

/**
Expand Down Expand Up @@ -32,13 +31,7 @@ export class User {
* @returns the new Wallet
*/
async createWallet(): Promise<Wallet> {
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!,
});
Expand Down
38 changes: 33 additions & 5 deletions src/coinbase/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Wallet> {
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.
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
private async reload(): Promise<void> {
const result = await this.client.wallet.getWallet(this.model.id!);
this.model = result?.data;
}
Expand Down

0 comments on commit 7ff1cdd

Please sign in to comment.