Skip to content

Commit

Permalink
Implementing UnhydratedWallet class
Browse files Browse the repository at this point in the history
  • Loading branch information
erdimaden committed May 24, 2024
1 parent b603eee commit 549eb3a
Show file tree
Hide file tree
Showing 8 changed files with 333 additions and 194 deletions.
55 changes: 43 additions & 12 deletions src/coinbase/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { ATOMIC_UNITS_PER_USDC, WEI_PER_ETHER, WEI_PER_GWEI } from "./constants"
*/
export class Address {
private model: AddressModel;
private key: ethers.Wallet;
private key?: ethers.Wallet;

/**
* Initializes a new Address instance.
Expand All @@ -25,14 +25,10 @@ export class Address {
* @param key - The ethers.js Wallet the Address uses to sign data.
* @throws {InternalError} If the model or key is empty.
*/
constructor(model: AddressModel, key: ethers.Wallet) {
constructor(model: AddressModel, key?: ethers.Wallet) {
if (!model) {
throw new InternalError("Address model cannot be empty");
}
if (!key) {
throw new InternalError("Key cannot be empty");
}

this.model = model;
this.key = key;
}
Expand Down Expand Up @@ -76,7 +72,7 @@ export class Address {
*
* @returns {BalanceMap} - The map from asset ID to balance.
*/
async listBalances(): Promise<BalanceMap> {
async getBalances(): Promise<BalanceMap> {
const response = await Coinbase.apiClients.address!.listAddressBalances(
this.model.wallet_id,
this.model.address_id,
Expand All @@ -85,6 +81,39 @@ export class Address {
return BalanceMap.fromBalances(response.data.data);
}

/**
* Returns all of the transfers associated with the address.
*
* @returns {Transfer[]} The list of transfers.
*/
async getTransfers(): Promise<Transfer[]> {
const transfers: Transfer[] = [];
const queue: string[] = [""];

while (queue.length > 0) {
const page = queue.shift();
const response = await Coinbase.apiClients.transfer!.listTransfers(
this.model.wallet_id,
this.model.address_id,
100,
page || undefined,
);

response.data.data.forEach(transferModel => {
transfers.push(Transfer.fromModel(transferModel));
});

if (response.data.has_more) {
const nextPage = new URL(response.data.next_page).searchParams.get("starting_after");
if (nextPage) {
queue.push(nextPage);
}
}
}

return transfers;
}

/**
* Returns the balance of the provided asset.
*
Expand Down Expand Up @@ -115,11 +144,11 @@ export class Address {
}

/**
* Sends an amount of an asset to a destination.
* Transfers the given amount of the given Asset to the given address. Only same-Network Transfers are supported.
*
* @param amount - The amount to send.
* @param assetId - The asset ID to send.
* @param destination - The destination address.
* @param amount - The amount of the Asset to send.
* @param assetId - The ID of the Asset to send. For Ether, Coinbase.assetList.Eth, Coinbase.assetList.Gwei, and Coinbase.assetList.Wei supported.
* @param destination - The destination of the transfer. If a Wallet, sends to the Wallet's default address. If a String, interprets it as the address ID.
* @param intervalSeconds - The interval at which to poll the Network for Transfer status, in seconds.
* @param timeoutSeconds - The maximum amount of time to wait for the Transfer to complete, in seconds.
* @returns The transfer object.
Expand All @@ -134,8 +163,10 @@ export class Address {
intervalSeconds = 0.2,
timeoutSeconds = 10,
): Promise<Transfer> {
if (!this.key) {
throw new InternalError("Cannot transfer from address without private key loaded");
}
let normalizedAmount = new Decimal(amount.toString());

const currentBalance = await this.getBalance(assetId);
if (currentBalance.lessThan(normalizedAmount)) {
throw new ArgumentError(
Expand Down
40 changes: 38 additions & 2 deletions src/coinbase/tests/address_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Address } from "./../address";
import * as crypto from "crypto";
import { ethers } from "ethers";
import { FaucetTransaction } from "./../faucet_transaction";
import { Balance as BalanceModel } from "../../client";
import { Balance as BalanceModel, TransferList } from "../../client";
import Decimal from "decimal.js";
import { APIError, FaucetLimitReachedError } from "../api_error";
import { Coinbase } from "../coinbase";
Expand Down Expand Up @@ -68,7 +68,7 @@ describe("Address", () => {
});

it("should return the correct list of balances", async () => {
const balances = await address.listBalances();
const balances = await address.getBalances();
expect(balances.get(Coinbase.assetList.Eth)).toEqual(new Decimal(1));
expect(balances.get("usdc")).toEqual(new Decimal(5000));
expect(balances.get("weth")).toEqual(new Decimal(3));
Expand Down Expand Up @@ -305,4 +305,40 @@ describe("Address", () => {
jest.restoreAllMocks();
});
});

describe(".getTransfers", () => {
beforeEach(() => {
jest.clearAllMocks();
const pages = ["http://localhost?starting_after=abc", "http://localhost?starting_after=def"];
const response = {
data: [VALID_TRANSFER_MODEL],
has_more: false,
next_page: "",
total_count: 0,
} as TransferList;
Coinbase.apiClients.transfer!.listTransfers = mockFn(() => {
response.next_page = pages.shift() as string;
response.data = [VALID_TRANSFER_MODEL];
response.has_more = !!response.next_page;
return { data: response };
});
});
it("should return the list of transfers", async () => {
const transfers = await address.getTransfers();
expect(transfers).toHaveLength(3);
expect(Coinbase.apiClients.transfer!.listTransfers).toHaveBeenCalledTimes(3);
expect(Coinbase.apiClients.transfer!.listTransfers).toHaveBeenCalledWith(
address.getWalletId(),
address.getId(),
100,
undefined,
);
expect(Coinbase.apiClients.transfer!.listTransfers).toHaveBeenCalledWith(
address.getWalletId(),
address.getId(),
100,
"abc",
);
});
});
});
65 changes: 56 additions & 9 deletions src/coinbase/tests/user_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ import { User } from "./../user";
import { Wallet } from "./../wallet";
import {
addressesApiMock,
getAddressFromHDKey,
mockReturnRejectedValue,
mockReturnValue,
newAddressModel,
walletsApiMock,
} from "./utils";
import { HDKey } from "@scure/bip32";
import { convertStringToHex } from "../utils";
import { UnhydratedWallet } from "../unhydrated_wallet";

describe("User Class", () => {
let mockUserModel: UserModel;
Expand Down Expand Up @@ -51,7 +55,11 @@ describe("User Class", () => {

beforeAll(async () => {
walletId = crypto.randomUUID();
mockAddressModel = newAddressModel(walletId);
walletData = { walletId: walletId, seed: bip39.generateMnemonic() };
const baseWallet = HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(walletData.seed));
const wallet1 = baseWallet.derive("m/44'/60'/0'/0/0");
const address1 = getAddressFromHDKey(wallet1);
mockAddressModel = newAddressModel(walletId, address1);
mockAddressList = {
data: [mockAddressModel],
has_more: false,
Expand All @@ -68,7 +76,6 @@ describe("User Class", () => {
Coinbase.apiClients.address = addressesApiMock;
Coinbase.apiClients.address!.listAddresses = mockReturnValue(mockAddressList);
user = new User(mockUserModel);
walletData = { walletId: walletId, seed: bip39.generateMnemonic() };
importedWallet = await user.importWallet(walletData);
expect(importedWallet).toBeInstanceOf(Wallet);
expect(Coinbase.apiClients.wallet!.getWallet).toHaveBeenCalledWith(walletId);
Expand Down Expand Up @@ -99,10 +106,14 @@ describe("User Class", () => {
beforeAll(async () => {
walletId = crypto.randomUUID();
seed = "86fc9fba421dcc6ad42747f14132c3cd975bd9fb1454df84ce5ea554f2542fbe";
const baseWallet = HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(seed));
const wallet1 = baseWallet.derive("m/44'/60'/0'/0/0");
const address1 = getAddressFromHDKey(wallet1);

mockAddressModel = {
address_id: "0xdeadbeef",
address_id: address1,
wallet_id: walletId,
public_key: "0x1234567890",
public_key: convertStringToHex(wallet1.privateKey!),
network_id: Coinbase.networkList.BaseSepolia,
};
mockWalletModel = {
Expand Down Expand Up @@ -170,6 +181,7 @@ describe("User Class", () => {
let seedDataWithoutSeed: Record<string, any>;
let seedDataWithoutIv: Record<string, any>;
let seedDataWithoutAuthTag: Record<string, any>;
let seed: string = "86fc9fba421dcc6ad42747f14132c3cd975bd9fb1454df84ce5ea554f2542fbe";

beforeAll(() => {
walletId = crypto.randomUUID();
Expand Down Expand Up @@ -199,7 +211,7 @@ describe("User Class", () => {

initialSeedData = {
[walletId]: {
seed: "86fc9fba421dcc6ad42747f14132c3cd975bd9fb1454df84ce5ea554f2542fbe",
seed,
encrypted: false,
iv: "",
authTag: "",
Expand All @@ -216,15 +228,15 @@ describe("User Class", () => {
};
seedDataWithoutIv = {
[walletId]: {
seed: "86fc9fba421dcc6ad42747f14132c3cd975bd9fb1454df84ce5ea554f2542fbe",
seed,
encrypted: true,
iv: "",
auth_tag: "0x111",
},
};
seedDataWithoutAuthTag = {
[walletId]: {
seed: "86fc9fba421dcc6ad42747f14132c3cd975bd9fb1454df84ce5ea554f2542fbe",
seed,
encrypted: true,
iv: "0x111",
auth_tag: "",
Expand All @@ -244,6 +256,27 @@ describe("User Class", () => {
});

it("loads the Wallet from backup", async () => {
const baseWallet = HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(seed));
const wallet1 = baseWallet.derive("m/44'/60'/0'/0/0");
const address1 = getAddressFromHDKey(wallet1);

const wallet2 = baseWallet.derive("m/44'/60'/0'/0/1");
const address2 = getAddressFromHDKey(wallet2);

const addressModel1: AddressModel = newAddressModel(walletId, address1);
const addressModel2: AddressModel = newAddressModel(walletId, address2);
walletModelWithDefaultAddress = {
id: walletId,
network_id: Coinbase.networkList.BaseSepolia,
default_address: addressModel1,
};
addressListModel = {
data: [addressModel1, addressModel2],
has_more: false,
next_page: "",
total_count: 2,
};

Coinbase.apiClients.wallet = walletsApiMock;
Coinbase.apiClients.wallet!.getWallet = mockReturnValue(walletModelWithDefaultAddress);
Coinbase.apiClients.address = addressesApiMock;
Expand All @@ -253,7 +286,7 @@ describe("User Class", () => {
const wallet = wallets[walletId];
expect(wallet).not.toBeNull();
expect(wallet.getId()).toBe(walletId);
expect(wallet.getDefaultAddress()?.getId()).toBe(addressModel.address_id);
expect(wallet.getDefaultAddress()?.getId()).toBe(addressModel1.address_id);
});

it("throws an error when the backup file is absent", async () => {
Expand Down Expand Up @@ -291,6 +324,12 @@ describe("User Class", () => {
beforeEach(() => {
jest.clearAllMocks();
walletId = crypto.randomUUID();
const seed = bip39.generateMnemonic();
const baseWallet = HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(seed));
const wallet1 = baseWallet.derive("m/44'/60'/0'/0/0");
const address1 = getAddressFromHDKey(wallet1);
mockAddressModel = newAddressModel(walletId, address1);

const addressModel1: AddressModel = newAddressModel(walletId);
const addressModel2: AddressModel = newAddressModel(walletId);
walletModelWithDefaultAddress = {
Expand Down Expand Up @@ -347,14 +386,22 @@ describe("User Class", () => {
});

it("should return the list of Wallets", async () => {
const seed = bip39.generateMnemonic();
const baseWallet = HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(seed));
const wallet1 = baseWallet.derive("m/44'/60'/0'/0/0");
const address1 = getAddressFromHDKey(wallet1);
mockAddressModel = newAddressModel(walletId, address1);

Coinbase.apiClients.wallet!.listWallets = mockReturnValue({
data: [walletModelWithDefaultAddress],
has_more: false,
next_page: "",
total_count: 2,
total_count: 1,
});
Coinbase.apiClients.address!.listAddresses = mockReturnValue(addressListModel);
const wallets = await user.getWallets();
// instance of UnhydratedWallet
expect(wallets[0]).toBeInstanceOf(UnhydratedWallet);
expect(wallets.length).toBe(1);
expect(wallets[0].getId()).toBe(walletId);
expect(wallets[0].getAddresses().length).toBe(2);
Expand Down
4 changes: 2 additions & 2 deletions src/coinbase/tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ export const generateRandomHash = (length = 8) => {
};

// newAddressModel creates a new AddressModel with a random wallet ID and a random Ethereum address.
export const newAddressModel = (walletId: string): AddressModel => {
export const newAddressModel = (walletId: string, address_id: string = ""): AddressModel => {
const ethAddress = ethers.Wallet.createRandom();

return {
address_id: ethAddress.address,
address_id: address_id ? address_id : ethAddress.address,
network_id: Coinbase.networkList.BaseSepolia,
public_key: ethAddress.publicKey,
wallet_id: walletId,
Expand Down
Loading

0 comments on commit 549eb3a

Please sign in to comment.