Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing wallet.createWallet #16

Merged
merged 4 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions src/coinbase/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,6 @@ export class Address {
return this.model.network_id;
}

/**
* Returns the public key.
*
* @returns {string} The public key.
*/
public getPublicKey(): string {
Comment on lines -69 to -70
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yuga-cb's feedback

return this.model.public_key;
}

/**
* Returns the wallet ID.
*
Expand Down
10 changes: 9 additions & 1 deletion src/coinbase/coinbase.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import globalAxios from "axios";
import fs from "fs";
import { User as UserModel, UsersApiFactory, TransfersApiFactory } from "../client";
import {
AddressesApiFactory,
User as UserModel,
UsersApiFactory,
TransfersApiFactory,
WalletsApiFactory,
} from "../client";
import { ethers } from "ethers";
import { BASE_PATH } from "./../client/base";
import { Configuration } from "./../client/configuration";
Expand Down Expand Up @@ -80,6 +86,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);
this.apiClients.transfer = TransfersApiFactory(config, BASE_PATH, axiosInstance);
this.apiClients.baseSepoliaProvider = new ethers.JsonRpcProvider("https://sepolia.base.org");
}
Expand Down
4 changes: 0 additions & 4 deletions src/coinbase/tests/address_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
41 changes: 34 additions & 7 deletions src/coinbase/tests/coinbase_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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);

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 () => {
Expand Down
2 changes: 1 addition & 1 deletion src/coinbase/tests/user_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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} }`);
});
});
13 changes: 6 additions & 7 deletions src/coinbase/tests/wallet_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 () => {
Expand Down
25 changes: 25 additions & 0 deletions src/coinbase/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AxiosPromise, AxiosRequestConfig, RawAxiosRequestConfig } from "axios";
import { ethers } from "ethers";
import {
Address,
CreateAddressRequest,
CreateWalletRequest,
BroadcastTransferRequest,
CreateTransferRequest,
Expand All @@ -27,6 +29,15 @@ export type WalletAPIClient = {
createWalletRequest?: CreateWalletRequest,
options?: RawAxiosRequestConfig,
) => AxiosPromise<WalletModel>;

/**
* Returns the wallet model with the given ID.
*
* @param walletId - The ID of the wallet to fetch
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @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}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @throws {APIError}
* @throws {APIError} If the request fails.

*/
getWallet: (walletId: string, options?: RawAxiosRequestConfig) => AxiosPromise<WalletModel>;
};

/**
Expand Down Expand Up @@ -58,6 +69,20 @@ export type AddressAPIClient = {
walletId: string,
addressId: string,
options?: AxiosRequestConfig,
): AxiosPromise<Address>;

/**
* 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<AddressModel>;
};

Expand Down
25 changes: 24 additions & 1 deletion src/coinbase/user.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -21,6 +23,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
Comment on lines +31 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets be consistent on if we add the - or not. Also for ending doc strings with period

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to use - for @throws or @return. However, it is necessary to use - for @params. I will investigate how we can extend this functionality.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracking with PSDK-135

*/
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!, {
wallet: this.client.wallet!,
address: this.client.address!,
});
}

/**
* Returns the user's ID.
*
Expand All @@ -36,6 +59,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} }`;
}
}
98 changes: 90 additions & 8 deletions src/coinbase/wallet.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Comment on lines -91 to +100
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking but it would be nice for us to continue using ethers.Wallet for the Key since that is what will be required to pass to the Address constructor. We can come up with a story for this in the Address.Transfer PR if you prefer not to block.

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<void> {
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<void> {
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<void>} A promise that resolves when the address is derived.
* @returns A promise that resolves when the address is derived.
*/
private async deriveAddress(): Promise<void> {
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);
}

Expand Down Expand Up @@ -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<FaucetTransaction> {
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.
*
Expand Down
Loading