-
Notifications
You must be signed in to change notification settings - Fork 41
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
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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 () => { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
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 () => { | ||||||
|
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, | ||||||
|
@@ -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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
* @param options - Override http request option. | ||||||
* @throws {APIError} | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
*/ | ||||||
getWallet: (walletId: string, options?: RawAxiosRequestConfig) => AxiosPromise<WalletModel>; | ||||||
}; | ||||||
|
||||||
/** | ||||||
|
@@ -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>; | ||||||
}; | ||||||
|
||||||
|
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. | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lets be consistent on if we add the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need to use - for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tracking with |
||
*/ | ||
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. | ||
* | ||
|
@@ -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} }`; | ||
} | ||
} |
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"; | ||
|
||
|
@@ -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 { | ||
Comment on lines
-91
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not blocking but it would be nice for us to continue using |
||
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); | ||
} | ||
|
||
|
@@ -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. | ||
* | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@yuga-cb's feedback