diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts index a72e2b6a..aeb9fb76 100644 --- a/src/coinbase/address.ts +++ b/src/coinbase/address.ts @@ -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. @@ -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; } @@ -76,7 +72,7 @@ export class Address { * * @returns {BalanceMap} - The map from asset ID to balance. */ - async listBalances(): Promise { + async getBalances(): Promise { const response = await Coinbase.apiClients.address!.listAddressBalances( this.model.wallet_id, this.model.address_id, @@ -85,6 +81,38 @@ 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 { + 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?.length ? page : undefined, + ); + + response.data.data.forEach(transferModel => { + transfers.push(Transfer.fromModel(transferModel)); + }); + + if (response.data.has_more) { + if (response.data.next_page) { + queue.push(response.data.next_page); + } + } + } + + return transfers; + } + /** * Returns the balance of the provided asset. * @@ -115,11 +143,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. @@ -134,6 +162,9 @@ export class Address { intervalSeconds = 0.2, timeoutSeconds = 10, ): Promise { + 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); diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index 79cb47ed..bed6cce4 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -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"; @@ -19,6 +19,7 @@ import { transfersApiMock, } from "./utils"; import { ArgumentError } from "../errors"; +import { Transfer } from "../transfer"; // Test suite for Address class describe("Address", () => { @@ -68,7 +69,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)); @@ -252,6 +253,19 @@ describe("Address", () => { ).rejects.toThrow(APIError); }); + it("should throw an InternalError if the address key is not provided", async () => { + const addressWithoutKey = new Address(VALID_ADDRESS_MODEL, null!); + await expect( + addressWithoutKey.createTransfer( + weiAmount, + Coinbase.assetList.Wei, + destination, + intervalSeconds, + timeoutSeconds, + ), + ).rejects.toThrow(InternalError); + }); + it("should throw an APIError if the broadcastTransfer API call fails", async () => { Coinbase.apiClients.transfer!.createTransfer = mockReturnValue(VALID_TRANSFER_MODEL); Coinbase.apiClients.transfer!.broadcastTransfer = mockReturnRejectedValue( @@ -305,4 +319,51 @@ describe("Address", () => { jest.restoreAllMocks(); }); }); + + describe(".getTransfers", () => { + beforeEach(() => { + jest.clearAllMocks(); + const pages = ["abc", "def"]; + const response = { + data: [VALID_TRANSFER_MODEL], + has_more: false, + next_page: "", + total_count: 0, + } as TransferList; + Coinbase.apiClients.transfer!.listTransfers = mockFn((walletId, addressId) => { + VALID_TRANSFER_MODEL.wallet_id = walletId; + VALID_TRANSFER_MODEL.address_id = addressId; + 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(transfers[0]).toBeInstanceOf(Transfer); + 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", + ); + }); + + it("should raise an APIError when the API call fails", async () => { + jest.clearAllMocks(); + Coinbase.apiClients.transfer!.listTransfers = mockReturnRejectedValue(new APIError("")); + await expect(address.getTransfers()).rejects.toThrow(APIError); + expect(Coinbase.apiClients.transfer!.listTransfers).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/coinbase/tests/user_test.ts b/src/coinbase/tests/user_test.ts index 91e866ec..3a875f28 100644 --- a/src/coinbase/tests/user_test.ts +++ b/src/coinbase/tests/user_test.ts @@ -1,10 +1,12 @@ import * as bip39 from "bip39"; import * as crypto from "crypto"; import * as fs from "fs"; -import { ArgumentError } from "../errors"; +import { ArgumentError, InternalError } from "../errors"; import { + AddressBalanceList, AddressList, Address as AddressModel, + Balance as BalanceModel, User as UserModel, Wallet as WalletModel, } from "./../../client/api"; @@ -14,11 +16,18 @@ import { User } from "./../user"; import { Wallet } from "./../wallet"; import { addressesApiMock, + generateRandomHash, + generateWalletFromSeed, + getAddressFromHDKey, mockReturnRejectedValue, mockReturnValue, newAddressModel, walletsApiMock, } from "./utils"; +import { HDKey } from "@scure/bip32"; +import { convertStringToHex } from "../utils"; +import Decimal from "decimal.js"; +import { FaucetTransaction } from "../faucet_transaction"; describe("User Class", () => { let mockUserModel: UserModel; @@ -51,8 +60,9 @@ describe("User Class", () => { beforeAll(async () => { walletId = crypto.randomUUID(); - mockAddressModel = newAddressModel(walletId); - mockAddressModel.address_id = "0x406210138180fD4d69368bcDD961062c6D8E8Cf7"; + walletData = { walletId: walletId, seed: bip39.generateMnemonic() }; + const { address1 } = generateWalletFromSeed(walletData.seed); + mockAddressModel = newAddressModel(walletId, address1); mockAddressList = { data: [mockAddressModel], has_more: false, @@ -69,10 +79,6 @@ describe("User Class", () => { Coinbase.apiClients.address = addressesApiMock; Coinbase.apiClients.address!.listAddresses = mockReturnValue(mockAddressList); user = new User(mockUserModel); - walletData = { - walletId: walletId, - seed: "month volume van spin despair squirrel drum observe cook sport spin confirm", - }; importedWallet = await user.importWallet(walletData); expect(importedWallet).toBeInstanceOf(Wallet); expect(Coinbase.apiClients.wallet!.getWallet).toHaveBeenCalledWith(walletId); @@ -103,10 +109,11 @@ describe("User Class", () => { beforeAll(async () => { walletId = crypto.randomUUID(); seed = "86fc9fba421dcc6ad42747f14132c3cd975bd9fb1454df84ce5ea554f2542fbe"; + const { address1, wallet1PrivateKey } = generateWalletFromSeed(seed); mockAddressModel = { - address_id: "0xB1666C6cDDB29468f721f3A4881a6e95CC963849", + address_id: address1, wallet_id: walletId, - public_key: "0x1234567890", + public_key: wallet1PrivateKey, network_id: Coinbase.networkList.BaseSepolia, }; mockWalletModel = { @@ -249,6 +256,22 @@ describe("User Class", () => { }); it("loads the Wallet from backup", async () => { + const seed = "86fc9fba421dcc6ad42747f14132c3cd975bd9fb1454df84ce5ea554f2542fbe"; + const { address1, address2 } = generateWalletFromSeed(seed); + 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; @@ -258,7 +281,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 () => { @@ -296,6 +319,10 @@ describe("User Class", () => { beforeEach(() => { jest.clearAllMocks(); walletId = crypto.randomUUID(); + const seed = bip39.generateMnemonic(); + const { address1 } = generateWalletFromSeed(seed); + mockAddressModel = newAddressModel(walletId, address1); + const addressModel1: AddressModel = newAddressModel(walletId); const addressModel2: AddressModel = newAddressModel(walletId); walletModelWithDefaultAddress = { @@ -350,5 +377,128 @@ describe("User Class", () => { expect(Coinbase.apiClients.wallet!.listWallets).toHaveBeenCalledTimes(1); expect(Coinbase.apiClients.address!.listAddresses).toHaveBeenCalledTimes(0); }); + + it("should return the list of Wallets", async () => { + const seed = bip39.generateMnemonic(); + const { address1 } = generateWalletFromSeed(seed); + mockAddressModel = newAddressModel(walletId, address1); + + Coinbase.apiClients.wallet!.listWallets = mockReturnValue({ + data: [walletModelWithDefaultAddress], + has_more: false, + next_page: "", + total_count: 1, + }); + Coinbase.apiClients.address!.listAddresses = mockReturnValue(addressListModel); + const wallets = await user.getWallets(); + expect(wallets[0]).toBeInstanceOf(Wallet); + expect(wallets.length).toBe(1); + expect(wallets[0].getId()).toBe(walletId); + expect(wallets[0].getAddresses().length).toBe(2); + expect(Coinbase.apiClients.wallet!.listWallets).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.address!.listAddresses).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.address!.listAddresses).toHaveBeenCalledWith( + walletId, + Wallet.MAX_ADDRESSES, + ); + expect(Coinbase.apiClients.wallet!.listWallets).toHaveBeenCalledWith(10, undefined); + }); + + it("should create Wallets when seed is provided", async () => { + const seed = bip39.generateMnemonic(); + const { address1 } = generateWalletFromSeed(seed); + mockAddressModel = newAddressModel(walletId, address1); + Coinbase.apiClients.wallet!.listWallets = mockReturnValue({ + data: [walletModelWithDefaultAddress], + has_more: false, + next_page: "", + total_count: 1, + }); + Coinbase.apiClients.address!.listAddresses = mockReturnValue(mockAddressModel); + const [unhydratedWallet] = await user.getWallets(); + expect(unhydratedWallet.canSign()).toBe(false); + await unhydratedWallet.setSeed(seed); + expect(unhydratedWallet).toBeInstanceOf(Wallet); + expect(unhydratedWallet?.getId()).toBe(walletId); + expect(unhydratedWallet.canSign()).toBe(true); + }); + + it("should prevent access to master wallet required methods", async () => { + const seed = bip39.generateMnemonic(); + const { address1 } = generateWalletFromSeed(seed); + mockAddressModel = newAddressModel(walletId, address1); + Coinbase.apiClients.wallet!.listWallets = mockReturnValue({ + data: [walletModelWithDefaultAddress], + has_more: false, + next_page: "", + total_count: 1, + }); + Coinbase.apiClients.address!.listAddresses = mockReturnValue(mockAddressModel); + const [unhydratedWallet] = await user.getWallets(); + expect(() => unhydratedWallet.export()).toThrow( + new InternalError("Cannot export Wallet without loaded seed"), + ); + await expect(unhydratedWallet.createAddress()).rejects.toThrow(InternalError); + await expect( + unhydratedWallet.createTransfer( + new Decimal("500000000000000000"), + Coinbase.assetList.Eth, + address1, + ), + ).rejects.toThrow(InternalError); + expect(unhydratedWallet.canSign()).toBe(false); + }); + + it("should access read-only methods", async () => { + const seed = bip39.generateMnemonic(); + const { address1 } = generateWalletFromSeed(seed); + mockAddressModel = newAddressModel(walletId, address1); + Coinbase.apiClients.wallet!.listWallets = mockReturnValue({ + data: [walletModelWithDefaultAddress], + has_more: false, + next_page: "", + total_count: 1, + }); + Coinbase.apiClients.address!.listAddresses = mockReturnValue(addressListModel); + const mockWalletBalance: BalanceModel = { + amount: "5000000000000000000", + asset: { + asset_id: Coinbase.assetList.Eth, + network_id: Coinbase.networkList.BaseSepolia, + decimals: 18, + }, + }; + const addressBalanceList: AddressBalanceList = { + data: [mockWalletBalance], + has_more: false, + next_page: "", + total_count: 2, + }; + Coinbase.apiClients.wallet!.getWalletBalance = mockReturnValue(mockWalletBalance); + Coinbase.apiClients.wallet!.listWalletBalances = mockReturnValue(addressBalanceList); + Coinbase.apiClients.address!.requestFaucetFunds = mockReturnValue({ + transaction_hash: generateRandomHash(8), + }); + + const [wallet] = await user.getWallets(); + expect(wallet.getId()).toBe(walletId); + expect(wallet.canSign()).toBe(false); + expect(wallet.getNetworkId()).toBe(Coinbase.networkList.BaseSepolia); + expect(wallet.getDefaultAddress()?.getId()).toBe( + walletModelWithDefaultAddress.default_address?.address_id, + ); + expect(wallet.getAddresses().length).toBe(2); + expect(wallet.getAddress(addressListModel.data[0].address_id)?.getId()).toBe( + addressListModel.data[0].address_id, + ); + const balance = await wallet.getBalance(Coinbase.assetList.Eth); + expect(balance).toEqual(new Decimal("5")); + + const balanceMap = await wallet.getBalances(); + expect(balanceMap.get("eth")).toEqual(new Decimal("5")); + + const faucet = await wallet.faucet(); + expect(faucet).toBeInstanceOf(FaucetTransaction); + }); }); }); diff --git a/src/coinbase/tests/utils.ts b/src/coinbase/tests/utils.ts index 5493b2c4..64722039 100644 --- a/src/coinbase/tests/utils.ts +++ b/src/coinbase/tests/utils.ts @@ -3,6 +3,7 @@ import axios, { AxiosInstance } from "axios"; import { Decimal } from "decimal.js"; import { ethers } from "ethers"; import { randomUUID } from "crypto"; +import * as bip39 from "bip39"; import { Configuration, Wallet as WalletModel, @@ -28,6 +29,18 @@ export const getAddressFromHDKey = (hdKey: HDKey): string => { export const walletId = randomUUID(); export const transferId = randomUUID(); +export const generateWalletFromSeed = (seed: string, count = 2) => { + const baseWallet = HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(seed)); + const data: Record = {}; + for (let i = 0; i < count; i++) { + const wallet = baseWallet.derive(`m/44'/60'/0'/0/${i}`); + data[`wallet${i + 1}`] = getAddressFromHDKey(wallet); + data[`wallet${i + 1}PrivateKey`] = convertStringToHex(wallet.privateKey!); + data[`address${i + 1}`] = getAddressFromHDKey(wallet); + } + return data; +}; + export const generateRandomHash = (length = 8) => { const characters = "abcdef0123456789"; let hash = "0x"; @@ -38,11 +51,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, diff --git a/src/coinbase/tests/wallet_test.ts b/src/coinbase/tests/wallet_test.ts index 5d460aea..09af8836 100644 --- a/src/coinbase/tests/wallet_test.ts +++ b/src/coinbase/tests/wallet_test.ts @@ -1,10 +1,10 @@ -import { HDKey } from "@scure/bip32"; -import * as bip39 from "bip39"; +import crypto from "crypto"; import Decimal from "decimal.js"; -import { Address } from "../address"; import { ethers } from "ethers"; -import crypto from "crypto"; +import { Address } from "../address"; +import { APIError } from "../api_error"; import { Coinbase } from "../coinbase"; +import { GWEI_PER_ETHER, WEI_PER_ETHER } from "../constants"; import { ArgumentError } from "../errors"; import { Wallet } from "../wallet"; import { @@ -14,19 +14,17 @@ import { Wallet as WalletModel, } from "./../../client"; import { - VALID_TRANSFER_MODEL, VALID_ADDRESS_MODEL, + VALID_TRANSFER_MODEL, addressesApiMock, - getAddressFromHDKey, + generateWalletFromSeed, mockFn, - mockReturnValue, mockReturnRejectedValue, + mockReturnValue, newAddressModel, - walletsApiMock, transfersApiMock, + walletsApiMock } from "./utils"; -import { APIError } from "../api_error"; -import { GWEI_PER_ETHER, WEI_PER_ETHER } from "../constants"; describe("Wallet Class", () => { let wallet: Wallet; @@ -221,36 +219,57 @@ describe("Wallet Class", () => { }); it("should create new address and update the existing address list", async () => { + expect(wallet.getAddresses().length).toBe(1); + Coinbase.apiClients.address!.createAddress = mockReturnValue(newAddressModel(walletId)); const newAddress = await wallet.createAddress(); expect(newAddress).toBeInstanceOf(Address); expect(wallet.getAddresses().length).toBe(2); expect(wallet.getAddress(newAddress.getId())!.getId()).toBe(newAddress.getId()); - expect(Coinbase.apiClients.address!.createAddress).toHaveBeenCalledTimes(2); + expect(Coinbase.apiClients.address!.createAddress).toHaveBeenCalledTimes(1); }); }); describe(".init", () => { - walletId = crypto.randomUUID(); - 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, - }, - ]; + let wallet: Wallet; + let walletId = crypto.randomUUID(); + let addressList: AddressModel[]; + let walletModel: WalletModel; beforeEach(async () => { jest.clearAllMocks(); + const existingSeed = + "hidden assault maple cheap gentle paper earth surprise trophy guide room tired"; + const { address1, address2, wallet1PrivateKey, wallet2PrivateKey } = + generateWalletFromSeed(existingSeed); + addressList = [ + { + address_id: address1, + network_id: Coinbase.networkList.BaseSepolia, + public_key: wallet1PrivateKey, + wallet_id: walletId, + }, + { + address_id: address2, + network_id: Coinbase.networkList.BaseSepolia, + public_key: wallet2PrivateKey, + wallet_id: walletId, + }, + ]; + walletModel = { + id: walletId, + network_id: Coinbase.networkList.BaseSepolia, + default_address: addressList[0], + }; wallet = await Wallet.init(walletModel, existingSeed, addressList); + Coinbase.apiClients.address!.createAddress = mockFn(walletId => { + return { + data: { + id: walletId, + network_id: Coinbase.networkList.BaseSepolia, + default_address: newAddressModel(walletId), + }, + }; + }); }); it("should return a Wallet instance", async () => { @@ -270,6 +289,7 @@ describe("Wallet Class", () => { }); it("should create new address and update the existing address list", async () => { + expect(wallet.getAddresses().length).toBe(2); const newAddress = await wallet.createAddress(); expect(newAddress).toBeInstanceOf(Address); expect(wallet.getAddresses().length).toBe(3); @@ -283,7 +303,7 @@ describe("Wallet Class", () => { }); it("should throw an ArgumentError when the wallet model is not provided", async () => { - await expect(Wallet.init(undefined!)).rejects.toThrow(ArgumentError); + await expect(Wallet.init(undefined!, undefined)).rejects.toThrow(ArgumentError); }); }); diff --git a/src/coinbase/user.ts b/src/coinbase/user.ts index 374605e6..7d90ed9e 100644 --- a/src/coinbase/user.ts +++ b/src/coinbase/user.ts @@ -116,9 +116,9 @@ export class User { addressModelMap[wallet.id!] = addressList.data.data; } - return await Promise.all( + return Promise.all( walletsModels.map(async wallet => { - return await Wallet.init(wallet, "", addressModelMap[wallet.id!]); + return await Wallet.init(wallet, undefined, addressModelMap[wallet.id!]); }), ); } diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index 68a4d8e7..39bd899a 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -6,11 +6,11 @@ import * as secp256k1 from "secp256k1"; import { Address as AddressModel, Wallet as WalletModel } from "../client"; import { Address } from "./address"; import { Coinbase } from "./coinbase"; -import { Transfer } from "./transfer"; import { ArgumentError, InternalError } from "./errors"; -import { FaucetTransaction } from "./faucet_transaction"; +import { Transfer } from "./transfer"; import { Amount, Destination, WalletData } from "./types"; import { convertStringToHex } from "./utils"; +import { FaucetTransaction } from "./faucet_transaction"; import { BalanceMap } from "./balance_map"; import Decimal from "decimal.js"; import { Balance } from "./balance"; @@ -23,10 +23,11 @@ import { Balance } from "./balance"; export class Wallet { private model: WalletModel; - private master: HDKey; - private seed: string; + private master?: HDKey; + private seed?: string; private addresses: Address[] = []; private addressModels: AddressModel[] = []; + private readonly addressPathPrefix = "m/44'/60'/0'/0"; private addressIndex = 0; static MAX_ADDRESSES = 20; @@ -43,8 +44,8 @@ export class Wallet { */ private constructor( model: WalletModel, - master: HDKey, - seed: string, + master: HDKey | undefined, + seed: string | undefined, addressModels: AddressModel[] = [], ) { this.model = model; @@ -70,7 +71,8 @@ export class Wallet { }, }); - const wallet = await Wallet.init(walletData.data); + const seed = bip39.generateMnemonic(); + const wallet = await Wallet.init(walletData.data, seed); await wallet.createAddress(); await wallet.reload(); @@ -92,16 +94,14 @@ export class Wallet { */ public static async init( model: WalletModel, - seed: string = "", + seed: string | undefined, addressModels: AddressModel[] = [], ): Promise { if (!model) { throw new ArgumentError("Wallet model cannot be empty"); } - if (!seed) { - seed = bip39.generateMnemonic(); - } - const master = HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(seed)); + + const master = seed ? HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(seed)) : undefined; const wallet = new Wallet(model, master, seed, addressModels); if (addressModels.length > 0) { wallet.deriveAddresses(addressModels); @@ -115,6 +115,9 @@ export class Wallet { * @returns The Wallet's data. */ public export(): WalletData { + if (!this.seed) { + throw new InternalError("Cannot export Wallet without loaded seed"); + } return { walletId: this.getId()!, seed: this.seed }; } @@ -125,7 +128,7 @@ export class Wallet { * @returns The derived key. */ private deriveKey(): HDKey { - const derivedKey = this.master.derive(`${this.addressPathPrefix}/${this.addressIndex++}`); + const derivedKey = this.master?.derive(this.addressPathPrefix + "/" + this.addressIndex++); if (!derivedKey?.privateKey) { throw new InternalError("Failed to derive key"); } @@ -200,18 +203,15 @@ export class Wallet { * @param addressModel - The Address model * @throws {InternalError} - If address derivation fails. * @throws {APIError} - If the request fails. - * @returns A promise that resolves when the address is derived. */ - private async deriveAddress( - addressMap: { [key: string]: boolean }, - addressModel: AddressModel, - ): Promise { - const hdKey = this.deriveKey(); - const key = new ethers.Wallet(convertStringToHex(hdKey.privateKey!)); - if (!addressMap[key.address]) { + private deriveAddress(addressMap: { [key: string]: boolean }, addressModel: AddressModel): void { + const doesMasterExist = this.master !== undefined; + const key = doesMasterExist + ? new ethers.Wallet(convertStringToHex(this.deriveKey().privateKey!)) + : undefined; + if (key && !addressMap[key.address]) { throw new InternalError("Invalid address"); } - this.cacheAddress(addressModel, key); } @@ -220,7 +220,7 @@ export class Wallet { * * @param addresses - The models of the addresses already registered with the */ - public async deriveAddresses(addresses: AddressModel[]): Promise { + private deriveAddresses(addresses: AddressModel[]): void { const addressMap = this.buildAddressMap(addresses); for (const address of addresses) { this.deriveAddress(addressMap, address); @@ -251,9 +251,19 @@ export class Wallet { * @throws {InternalError} If the address is not provided. * @returns {void} */ - private cacheAddress(address: AddressModel, key: ethers.Wallet): void { + private cacheAddress(address: AddressModel, key?: ethers.Wallet): void { this.addresses.push(new Address(address, key)); - this.addressIndex++; + } + + /** + * Returns the Wallet model. + * + * @param seed - The seed to use for the Wallet. Expects a 32-byte hexadecimal with no 0x prefix. + */ + public async setSeed(seed: string): Promise { + if (this.master === undefined) { + this.master = HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(seed)); + } } /** @@ -337,7 +347,7 @@ export class Wallet { * @returns Whether the Wallet has a seed with which to derive keys and sign transactions. */ public canSign(): boolean { - return this.master.publicKey !== undefined; + return this.master?.publicKey !== undefined; } /** @@ -357,14 +367,15 @@ export class Wallet { } /** - * 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. + * Currently only the default_address is used to source the Transfer. * - * @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. + * @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. + * @returns The hash of the Transfer transaction. * @throws {APIError} if the API request to create a Transfer fails. * @throws {APIError} if the API request to broadcast a Transfer fails. * @throws {Error} if the Transfer times out.