diff --git a/README.md b/README.md index beb6e490..23ebe3d0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # Coinbase Node.js SDK -The Coinbase Node.js SDK enables the simple integration of crypto into your app. -By calling Coinbase's Platform APIs, the SDK allows you to provision crypto wallets, -send crypto into/out of those wallets, track wallet balances, and trade crypto from -one asset into another. +The Coinbase Node.js SDK enables the simple integration of crypto into your app. By calling Coinbase's Platform APIs, the SDK allows you to provision crypto wallets, send crypto into/out of those wallets, track wallet balances, and trade crypto from one asset into another. The SDK currently supports Customer-custodied Wallets on the Base Sepolia test network. @@ -12,8 +9,7 @@ The SDK currently supports Customer-custodied Wallets on the Base Sepolia test n - **may make backwards-incompatible changes between releases** - **should not be used on Mainnet (i.e. with real funds)** -Currently, the SDK is intended for use on testnet for quick bootstrapping of crypto wallets at -hackathons, code academies, and other development settings. +Currently, the SDK is intended for use on testnet for quick bootstrapping of crypto wallets at hackathons, code academies, and other development settings. ## Documentation @@ -38,8 +34,9 @@ yarn install @coinbase/coinbase-sdk After running `npx ts-node` to start the REPL, you can import the SDK as follows: ```typescript -import { Coinbase } from '@coinbase/coinbase-sdk'; +import { Coinbase } from "@coinbase/coinbase-sdk"; ``` + ### Requirements - Node.js 18 or higher @@ -51,18 +48,17 @@ import { Coinbase } from '@coinbase/coinbase-sdk'; To start, [create a CDP API Key](https://portal.cdp.coinbase.com/access/api). Then, initialize the Platform SDK by passing your API Key name and API Key's private key via the `Coinbase` constructor: ```typescript -const apiKeyName = 'Copy your API Key name here.'; +const apiKeyName = "Copy your API Key name here."; -const apiKeyPrivateKey = 'Copy your API Key\'s private key here.'; +const apiKeyPrivateKey = "Copy your API Key's private key here."; const coinbase = new Coinbase(apiKeyName, apiKeyPrivateKey); ``` -Another way to initialize the SDK is by sourcing the API key from the json file that contains your API key, -downloaded from CDP portal. +Another way to initialize the SDK is by sourcing the API key from the json file that contains your API key, downloaded from CDP portal. ```typescript -const coinbase = Coinbase.configureFromJson('path/to/your/api-key.json'); +const coinbase = Coinbase.configureFromJson("path/to/your/api-key.json"); ``` This will allow you to authenticate with the Platform APIs and get access to the default `User`. @@ -105,6 +101,50 @@ const anotherWallet = await user.createWallet(); const transfer = await wallet.createTransfer(0.00001, Coinbase.assetList.Eth, anotherWallet); ``` +### Re-Instantiating Wallets + +The SDK creates Wallets with developer managed keys, which means you are responsible for securely storing the keys required to re-instantiate Wallets. The below code walks you through how to export a Wallets and store it in a secure location. + +```typescript +// Export the data required to re-instantiate the Wallet. +const data = wallet.export(); +``` + +In order to persist the data for the Wallet, you will need to implement a store method to store the data export in a secure location. If you do not store the Wallet in a secure location you will lose access to the Wallet and all of the funds on it. + +```typescript +// At this point, you should implement your own "store" method to securely persist +// the data required to re-instantiate the Wallet at a later time. +await store(data); +``` + +For convenience during testing, we provide a `saveWallet` method that stores the Wallet data in your local file system. This is an insecure method of storing wallet seeds and should only be used for development purposes. + +```typescript +user.saveWallet(wallet); +``` + +To encrypt the saved data, set encrypt to true. Note that your CDP API key also serves as the encryption key for the data persisted locally. To re-instantiate wallets with encrypted data, ensure that your SDK is configured with the same API key when invoking `saveWallet` and `loadWallets`. + +```typescript +user.saveWallet(wallet, true); +``` + +The below code demonstrates how to re-instantiate a Wallet from the data export. + +```typescript +// The Wallet can be re-instantiated using the exported data. +const importedWallet = await user.import(data); +``` + +To import Wallets that were persisted to your local file system using `saveWallet`, use the below code. + +```typescript +// The Wallet can be re-instantiated using the exported data. +const wallets = await user.loadWallets(); +const reinitWallet = wallets[wallet.getId()]; +``` + ## Development ### Node.js Version @@ -155,8 +195,7 @@ npx jest ./src/coinbase/tests/wallet_test.ts ### REPL -The repository is equipped with a REPL to allow developers to play with the SDK. To start -it, run: +The repository is equipped with a REPL to allow developers to play with the SDK. To start it, run: ```bash npx ts-node diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index 1c68a0ae..57ad93ab 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -1,5 +1,5 @@ import globalAxios from "axios"; -import fs from "fs"; +import * as fs from "fs"; import { AddressesApiFactory, User as UserModel, @@ -51,6 +51,20 @@ export class Coinbase { */ static readonly WEI_PER_ETHER: bigint = BigInt("1000000000000000000"); + /** + * The backup file path for Wallet seeds. + * + * @constant + */ + static backupFilePath: string = "seed.json"; + + /** + * The CDP API key Private Key. + * + * @constant + */ + static apiKeyPrivateKey: string; + /** * Initializes the Coinbase SDK. * @@ -59,6 +73,7 @@ export class Coinbase { * @param privateKey - The private key associated with the API key. * @param debugging - If true, logs API requests and responses to the console. * @param basePath - The base path for the API. + * @param backupFilePath - The path to the file containing the Wallet backup data. * @throws {InternalError} If the configuration is invalid. * @throws {InvalidAPIKeyFormat} If not able to create JWT token. */ @@ -67,6 +82,7 @@ export class Coinbase { privateKey: string, debugging = false, basePath: string = BASE_PATH, + backupFilePath?: string, ) { if (apiKeyName === "") { throw new InternalError("Invalid configuration: apiKeyName is empty"); @@ -92,6 +108,7 @@ export class Coinbase { Coinbase.apiClients.baseSepoliaProvider = new ethers.JsonRpcProvider( "https://sepolia.base.org", ); + Coinbase.apiKeyPrivateKey = backupFilePath ? backupFilePath : privateKey; } /** @@ -100,6 +117,7 @@ export class Coinbase { * @param filePath - The path to the JSON file containing the API key and private key. * @param debugging - If true, logs API requests and responses to the console. * @param basePath - The base path for the API. + * @param backupFilePath - The path to the file containing the Wallet backup data. * @returns A new instance of the Coinbase SDK. * @throws {InvalidAPIKeyFormat} If the file does not exist or the configuration values are missing/invalid. * @throws {InvalidConfiguration} If the configuration is invalid. @@ -109,6 +127,7 @@ export class Coinbase { filePath: string = "coinbase_cloud_api_key.json", debugging: boolean = false, basePath: string = BASE_PATH, + backupFilePath?: string, ): Coinbase { if (!fs.existsSync(filePath)) { throw new InvalidConfiguration(`Invalid configuration: file not found at ${filePath}`); @@ -120,7 +139,7 @@ export class Coinbase { throw new InvalidAPIKeyFormat("Invalid configuration: missing configuration values"); } - return new Coinbase(config.name, config.privateKey, debugging, basePath); + return new Coinbase(config.name, config.privateKey, debugging, basePath, backupFilePath); } catch (e) { if (e instanceof SyntaxError) { throw new InvalidAPIKeyFormat("Not able to parse the configuration file"); diff --git a/src/coinbase/tests/user_test.ts b/src/coinbase/tests/user_test.ts index 10443497..b7dd74c0 100644 --- a/src/coinbase/tests/user_test.ts +++ b/src/coinbase/tests/user_test.ts @@ -1,8 +1,26 @@ -import { User as UserModel } from "./../../client/api"; +import * as fs from "fs"; +import * as crypto from "crypto"; +import * as bip39 from "bip39"; +import { + User as UserModel, + Wallet as WalletModel, + Address as AddressModel, + AddressList, +} from "./../../client/api"; import { User } from "./../user"; +import { Coinbase } from "./../coinbase"; +import { mockFn, walletsApiMock, addressesApiMock, newAddressModel } from "./utils"; +import { SeedData, WalletData } from "./../types"; +import { Wallet } from "./../wallet"; +import { ArgumentError } from "../errors"; describe("User Class", () => { let mockUserModel: UserModel; + let mockAddressModel: AddressModel; + let mockWalletModel: WalletModel; + let mockAddressList: AddressList; + let user: User; + beforeEach(() => { mockUserModel = { id: "12345", @@ -19,4 +37,263 @@ describe("User Class", () => { const user = new User(mockUserModel); expect(user.toString()).toBe(`User{ userId: ${mockUserModel.id} }`); }); + + describe("importWallet", () => { + let importedWallet: Wallet; + let walletId: string; + let walletData: WalletData; + + beforeAll(async () => { + walletId = crypto.randomUUID(); + mockAddressModel = newAddressModel(walletId); + mockAddressList = { + data: [mockAddressModel], + has_more: false, + next_page: "", + total_count: 1, + }; + mockWalletModel = { + id: walletId, + network_id: Coinbase.networkList.BaseSepolia, + default_address: mockAddressModel, + }; + Coinbase.apiClients.wallet = walletsApiMock; + Coinbase.apiClients.wallet!.getWallet = mockFn(() => { + return { data: mockWalletModel }; + }); + Coinbase.apiClients.address = addressesApiMock; + Coinbase.apiClients.address!.listAddresses = mockFn(() => { + return { data: mockAddressList }; + }); + Coinbase.apiClients.address!.getAddress = mockFn(() => { + return { data: mockAddressModel }; + }); + 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); + expect(Coinbase.apiClients.wallet!.getWallet).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.address!.listAddresses).toHaveBeenCalledWith(walletId); + expect(Coinbase.apiClients.address!.listAddresses).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.address!.getAddress).toHaveBeenCalledTimes(1); + }); + + it("should import an exported wallet", async () => { + expect(importedWallet.getId()).toBe(walletId); + }); + + it("should load the wallet addresses", async () => { + expect(importedWallet.defaultAddress()!.getId()).toBe(mockAddressModel.address_id); + }); + + it("should contain the same seed when re-exported", async () => { + expect(importedWallet.export().seed!).toBe(walletData.seed); + }); + }); + + describe("saveWallet", () => { + let seed: string; + let addressCount: number; + let walletId: string; + let mockSeedWallet: Wallet; + let savedWallet: Wallet; + + beforeAll(async () => { + walletId = crypto.randomUUID(); + seed = "86fc9fba421dcc6ad42747f14132c3cd975bd9fb1454df84ce5ea554f2542fbe"; + addressCount = 1; + mockAddressModel = { + address_id: "0xdeadbeef", + wallet_id: walletId, + public_key: "0x1234567890", + network_id: Coinbase.networkList.BaseSepolia, + }; + mockWalletModel = { + id: walletId, + network_id: Coinbase.networkList.BaseSepolia, + default_address: mockAddressModel, + }; + user = new User(mockUserModel); + Coinbase.apiClients.address = addressesApiMock; + Coinbase.apiClients.address!.getAddress = mockFn(() => { + return { data: mockAddressModel }; + }); + Coinbase.backupFilePath = crypto.randomUUID() + ".json"; + Coinbase.apiKeyPrivateKey = crypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey; + mockSeedWallet = await Wallet.init(mockWalletModel, seed, addressCount); + }); + + afterEach(async () => { + fs.unlinkSync(Coinbase.backupFilePath); + }); + + it("should save the Wallet data when encryption is false", async () => { + savedWallet = user.saveWallet(mockSeedWallet); + expect(savedWallet).toBe(mockSeedWallet); + expect(Coinbase.apiClients.address!.getAddress).toHaveBeenCalledTimes(1); + const storedSeedData = fs.readFileSync(Coinbase.backupFilePath); + const walletSeedData = JSON.parse(storedSeedData.toString()); + expect(walletSeedData[walletId].encrypted).toBe(false); + expect(walletSeedData[walletId].iv).toBe(""); + expect(walletSeedData[walletId].authTag).toBe(""); + expect(walletSeedData[walletId].seed).toBe(seed); + }); + + it("should save the Wallet data when encryption is true", async () => { + savedWallet = user.saveWallet(mockSeedWallet, true); + expect(savedWallet).toBe(mockSeedWallet); + expect(Coinbase.apiClients.address!.getAddress).toHaveBeenCalledTimes(1); + const storedSeedData = fs.readFileSync(Coinbase.backupFilePath); + const walletSeedData = JSON.parse(storedSeedData.toString()); + expect(walletSeedData[walletId].encrypted).toBe(true); + expect(walletSeedData[walletId].iv).toBeTruthy(); + expect(walletSeedData[walletId].authTag).toBeTruthy(); + expect(walletSeedData[walletId].seed).not.toBe(seed); + }); + + it("should throw an error when the existing file is malformed", async () => { + fs.writeFileSync( + Coinbase.backupFilePath, + JSON.stringify({ malformed: "test" }, null, 2), + "utf8", + ); + expect(() => user.saveWallet(mockSeedWallet)).toThrow(ArgumentError); + }); + }); + + describe("loadWallets", () => { + let mockUserModel: UserModel; + let user: User; + let walletId: string; + let addressModel: AddressModel; + let walletModelWithDefaultAddress: WalletModel; + let addressListModel: AddressList; + let initialSeedData: Record; + let malformedSeedData: Record; + let seedDataWithoutSeed: Record; + let seedDataWithoutIv: Record; + let seedDataWithoutAuthTag: Record; + + beforeAll(() => { + walletId = crypto.randomUUID(); + addressModel = newAddressModel(walletId); + walletModelWithDefaultAddress = { + id: walletId, + network_id: Coinbase.networkList.BaseSepolia, + default_address: addressModel, + }; + addressListModel = { + data: [addressModel], + has_more: false, + next_page: "", + total_count: 1, + }; + Coinbase.apiClients.wallet = walletsApiMock; + Coinbase.apiClients.address = addressesApiMock; + Coinbase.backupFilePath = `${crypto.randomUUID()}.json`; + Coinbase.apiKeyPrivateKey = crypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey; + mockUserModel = { + id: "12345", + } as UserModel; + + initialSeedData = { + [walletId]: { + seed: "86fc9fba421dcc6ad42747f14132c3cd975bd9fb1454df84ce5ea554f2542fbe", + encrypted: false, + iv: "", + authTag: "", + }, + }; + malformedSeedData = { + [walletId]: "test", + }; + seedDataWithoutSeed = { + [walletId]: { + seed: "", + encrypted: false, + }, + }; + seedDataWithoutIv = { + [walletId]: { + seed: "86fc9fba421dcc6ad42747f14132c3cd975bd9fb1454df84ce5ea554f2542fbe", + encrypted: true, + iv: "", + auth_tag: "0x111", + }, + }; + seedDataWithoutAuthTag = { + [walletId]: { + seed: "86fc9fba421dcc6ad42747f14132c3cd975bd9fb1454df84ce5ea554f2542fbe", + encrypted: true, + iv: "0x111", + auth_tag: "", + }, + }; + }); + + beforeEach(() => { + user = new User(mockUserModel); + fs.writeFileSync(Coinbase.backupFilePath, JSON.stringify(initialSeedData, null, 2)); + }); + + afterEach(() => { + if (fs.existsSync(Coinbase.backupFilePath)) { + fs.unlinkSync(Coinbase.backupFilePath); + } + }); + + it("loads the Wallet from backup", async () => { + Coinbase.apiClients.wallet = walletsApiMock; + Coinbase.apiClients.wallet!.getWallet = mockFn(() => { + return { data: walletModelWithDefaultAddress }; + }); + Coinbase.apiClients.address = addressesApiMock; + Coinbase.apiClients.address!.listAddresses = mockFn(() => { + return { data: addressListModel }; + }); + Coinbase.apiClients.address!.getAddress = mockFn(() => { + return { data: addressModel }; + }); + + const wallets = await user.loadWallets(); + const wallet = wallets[walletId]; + expect(wallet).not.toBeNull(); + expect(wallet.getId()).toBe(walletId); + expect(wallet.defaultAddress()?.getId()).toBe(addressModel.address_id); + }); + + it("throws an error when the backup file is absent", async () => { + fs.unlinkSync(Coinbase.backupFilePath); + await expect(user.loadWallets()).rejects.toThrow(new ArgumentError("Backup file not found")); + }); + + it("throws an error when the backup file is corrupted", async () => { + fs.writeFileSync(Coinbase.backupFilePath, JSON.stringify(malformedSeedData, null, 2)); + await expect(user.loadWallets()).rejects.toThrow(new ArgumentError("Malformed backup data")); + }); + + it("throws an error when backup does not contain seed", async () => { + fs.writeFileSync(Coinbase.backupFilePath, JSON.stringify(seedDataWithoutSeed, null, 2)); + await expect(user.loadWallets()).rejects.toThrow(new ArgumentError("Malformed backup data")); + }); + + it("throws an error when backup does not contain iv", async () => { + fs.writeFileSync(Coinbase.backupFilePath, JSON.stringify(seedDataWithoutIv, null, 2)); + await expect(user.loadWallets()).rejects.toThrow(new ArgumentError("Malformed backup data")); + }); + + it("throws an error when backup does not contain auth_tag", async () => { + fs.writeFileSync(Coinbase.backupFilePath, JSON.stringify(seedDataWithoutAuthTag, null, 2)); + await expect(user.loadWallets()).rejects.toThrow(new ArgumentError("Malformed backup data")); + }); + }); }); diff --git a/src/coinbase/tests/utils.ts b/src/coinbase/tests/utils.ts index 1a2ed136..cc0a8600 100644 --- a/src/coinbase/tests/utils.ts +++ b/src/coinbase/tests/utils.ts @@ -126,6 +126,7 @@ export const walletsApiMock = { export const addressesApiMock = { requestFaucetFunds: jest.fn(), getAddress: jest.fn(), + listAddresses: jest.fn(), getAddressBalance: jest.fn(), listAddressBalances: jest.fn(), createAddress: jest.fn(), diff --git a/src/coinbase/tests/wallet_test.ts b/src/coinbase/tests/wallet_test.ts index d36dd1de..a0d67255 100644 --- a/src/coinbase/tests/wallet_test.ts +++ b/src/coinbase/tests/wallet_test.ts @@ -1,12 +1,13 @@ import { randomUUID } from "crypto"; import { Coinbase } from "../coinbase"; import { Wallet } from "../wallet"; +import { Address as AddressModel, Wallet as WalletModel } from "./../../client"; import { addressesApiMock, mockFn, newAddressModel, walletsApiMock } from "./utils"; import { ArgumentError } from "../errors"; describe("Wallet Class", () => { let wallet, walletModel, walletId; - describe(".create", () => { + describe("create", () => { const apiResponses = {}; beforeAll(async () => { @@ -57,7 +58,7 @@ describe("Wallet Class", () => { }); }); - describe(".init", () => { + describe("init", () => { const existingSeed = "hidden assault maple cheap gentle paper earth surprise trophy guide room tired"; const addressList = [ @@ -132,4 +133,42 @@ describe("Wallet Class", () => { await expect(Wallet.init(undefined!)).rejects.toThrow(ArgumentError); }); }); + + describe("export", () => { + let walletId: string; + let addressModel: AddressModel; + let walletModel: WalletModel; + let seedWallet: Wallet; + const seed = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + const addressCount = 1; + + beforeAll(async () => { + walletId = randomUUID(); + addressModel = newAddressModel(walletId); + walletModel = { + id: walletId, + network_id: Coinbase.networkList.BaseSepolia, + default_address: addressModel, + }; + Coinbase.apiClients.address = addressesApiMock; + Coinbase.apiClients.address!.getAddress = mockFn(() => { + return { data: addressModel }; + }); + seedWallet = await Wallet.init(walletModel, seed, addressCount); + }); + + it("exports the Wallet data", () => { + const walletData = seedWallet.export(); + expect(Coinbase.apiClients.address!.getAddress).toHaveBeenCalledTimes(1); + expect(walletData.walletId).toBe(seedWallet.getId()); + expect(walletData.seed).toBe(seed); + }); + + it("allows for re-creation of a Wallet", async () => { + const walletData = seedWallet.export(); + const newWallet = await Wallet.init(walletModel, walletData.seed, addressCount); + expect(Coinbase.apiClients.address!.getAddress).toHaveBeenCalledTimes(2); + expect(newWallet).toBeInstanceOf(Wallet); + }); + }); }); diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index bf5ff152..9087e84a 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -2,6 +2,7 @@ import { AxiosPromise, AxiosRequestConfig, RawAxiosRequestConfig } from "axios"; import { ethers } from "ethers"; import { Address as AddressModel, + AddressList, AddressBalanceList, Balance, CreateAddressRequest, @@ -72,6 +73,23 @@ export type AddressAPIClient = { options?: AxiosRequestConfig, ): AxiosPromise; + /** + * Lists addresses. + * + * @param walletId - The ID of the wallet the addresses belong to. + * @param limit - The maximum number of addresses to return. + * @param page - A cursor for pagination across multiple pages of results. Do not include this parameter on the first call. + * Use the next_page value returned in a previous response to request subsequent results. + * @param options - Override http request option. + * @throws {APIError} If the request fails. + */ + listAddresses( + walletId: string, + limit?: number, + page?: string, + options?: AxiosRequestConfig, + ): AxiosPromise; + /** * Get address balance * @@ -232,3 +250,22 @@ export enum TransferStatus { COMPLETE = "COMPLETE", FAILED = "FAILED", } + +/** + * The Wallet Data type definition. + * The data required to recreate a Wallet. + */ +export type WalletData = { + walletId: string; + seed: string; +}; + +/** + * The Seed Data type definition. + */ +export type SeedData = { + seed: string; + encrypted: boolean; + authTag: string; + iv: string; +}; diff --git a/src/coinbase/user.ts b/src/coinbase/user.ts index 593dde4a..76ce5eb3 100644 --- a/src/coinbase/user.ts +++ b/src/coinbase/user.ts @@ -1,5 +1,10 @@ +import * as fs from "fs"; +import * as crypto from "crypto"; +import { WalletData, SeedData } from "./types"; import { User as UserModel } from "./../client/api"; import { Wallet } from "./wallet"; +import { Coinbase } from "./coinbase"; +import { ArgumentError } from "./errors"; /** * A representation of a User. @@ -39,6 +44,162 @@ export class User { return this.model.id; } + /** + * Saves a Wallet to local file system. Wallet saved this way can be re-instantiated with `loadWallets` function, + * provided the backup file is available. This is an insecure method of storing Wallet seeds and should only be used + * for development purposes. If you call `saveWallet` with Wallets containing the same walletId, the backup will be overwritten during the second attempt. + * The default backup file is `seeds.json` in the root folder. It can be configured by changing `Coinbase.backupFilePath`. + * + * @param wallet - The Wallet object to save. + * @param encrypt - Whether or not to encrypt the backup persisted to local file system. + * @returns The saved Wallet object. + */ + public saveWallet(wallet: Wallet, encrypt: boolean = false): Wallet { + const existingSeedsInStore = this.getExistingSeeds(); + const data = wallet.export(); + let seedToStore = data.seed; + let authTag = ""; + let iv = ""; + + if (encrypt) { + const ivBytes = crypto.randomBytes(12); + const sharedSecret = this.storeEncryptionKey(); + const cipher: crypto.CipherCCM = crypto.createCipheriv( + "aes-256-gcm", + crypto.createHash("sha256").update(sharedSecret).digest(), + ivBytes, + ); + const encryptedData = Buffer.concat([cipher.update(data.seed, "utf8"), cipher.final()]); + authTag = cipher.getAuthTag().toString("hex"); + seedToStore = encryptedData.toString("hex"); + iv = ivBytes.toString("hex"); + } + + existingSeedsInStore[data.walletId] = { + seed: seedToStore, + encrypted: encrypt, + authTag: authTag, + iv: iv, + }; + + fs.writeFileSync( + Coinbase.backupFilePath, + JSON.stringify(existingSeedsInStore, null, 2), + "utf8", + ); + return wallet; + } + + /** + * Loads all wallets belonging to the User with backup persisted to the local file system. + * + * @returns the map of walletId's to the Wallet objects. + * @throws {ArgumentError} - If the backup file is not found or the data is malformed. + */ + public async loadWallets(): Promise<{ [key: string]: Wallet }> { + const existingSeedsInStore = this.getExistingSeeds(); + if (Object.keys(existingSeedsInStore).length === 0) { + throw new ArgumentError("Backup file not found"); + } + + const wallets: { [key: string]: Wallet } = {}; + for (const [walletId, seedData] of Object.entries(existingSeedsInStore)) { + let seed = seedData.seed; + if (!seed) { + throw new Error("Malformed backup data"); + } + + if (seedData.encrypted) { + const sharedSecret = this.storeEncryptionKey(); + if (!seedData.iv || !seedData.authTag) { + throw new Error("Malformed encrypted seed data"); + } + + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + crypto.createHash("sha256").update(sharedSecret).digest(), + Buffer.from(seedData.iv, "hex"), + ); + decipher.setAuthTag(Buffer.from(seedData.authTag, "hex")); + + const decryptedData = Buffer.concat([ + decipher.update(Buffer.from(seed, "hex")), + decipher.final(), + ]); + + seed = decryptedData.toString("utf8"); + } + + const data: WalletData = { walletId, seed }; + wallets[walletId] = await this.importWallet(data); + } + return wallets; + } + + /** + * Imports a Wallet belonging to a User. + * + * @param data - The Wallet data to import. + * @returns The imported Wallet. + */ + public async importWallet(data: WalletData): Promise { + const walletModel = await Coinbase.apiClients.wallet!.getWallet(data.walletId); + const addressList = await Coinbase.apiClients.address!.listAddresses(data.walletId); + const addressCount = addressList.data!.total_count; + + return Wallet.init(walletModel.data, data.seed, addressCount); + } + + /** + * Gets existing seeds if any from the backup file. + * + * @returns The existing seeds as a JSON object. + * @throws {ArgumentError} - If the backup data is malformed. + */ + private getExistingSeeds(): Record { + try { + const data = fs.readFileSync(Coinbase.backupFilePath, "utf8"); + if (!data) { + return {} as Record; + } + const seedData = JSON.parse(data); + if ( + !Object.entries(seedData).every( + ([key, value]) => + typeof key === "string" && + typeof (value! as any).authTag! === "string" && + typeof (value! as any).encrypted! === "boolean" && + typeof (value! as any).iv! === "string" && + typeof (value! as any).seed! === "string", + ) + ) { + throw new ArgumentError("Malformed backup data"); + } + + return seedData; + } catch (error: any) { + if (error.code === "ENOENT") { + return {} as Record; + } + throw new ArgumentError("Malformed backup data"); + } + } + + /** + * Stores the encryption key for encrypting the backup. + * + * @returns The encryption key. + */ + private storeEncryptionKey(): Buffer { + const privateKey = crypto.createPrivateKey(Coinbase.apiKeyPrivateKey); + const publicKey = crypto.createPublicKey(Coinbase.apiKeyPrivateKey); + const sharedSecret = crypto.diffieHellman({ + privateKey, + publicKey, + }); + return sharedSecret; + } + /** * Returns a string representation of the User. * diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index 07cf38c0..2bd40914 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -8,6 +8,7 @@ import { Address } from "./address"; import { Coinbase } from "./coinbase"; import { ArgumentError, InternalError } from "./errors"; import { FaucetTransaction } from "./faucet_transaction"; +import { WalletData } from "./types"; import { convertStringToHex } from "./utils"; /** @@ -19,6 +20,7 @@ export class Wallet { private model: WalletModel; private master: HDKey; + private seed: string; private addresses: Address[] = []; private readonly addressPathPrefix = "m/44'/60'/0'/0"; private addressIndex = 0; @@ -29,11 +31,13 @@ export class Wallet { * @ignore * @param model - The wallet model object. * @param master - The HD master key. + * @param seed - The seed to use for the Wallet. Expects a 32-byte hexadecimal with no 0x prefix. * @hideconstructor */ - private constructor(model: WalletModel, master: HDKey) { + private constructor(model: WalletModel, master: HDKey, seed: string) { this.model = model; this.master = master; + this.seed = seed; } /** @@ -85,7 +89,8 @@ export class Wallet { seed = bip39.generateMnemonic(); } const master = HDKey.fromMasterSeed(bip39.mnemonicToSeedSync(seed)); - const wallet = new Wallet(model, master); + + const wallet = new Wallet(model, master, seed); if (addressCount > 0) { for (let i = 0; i < addressCount; i++) { @@ -96,6 +101,15 @@ export class Wallet { return wallet; } + /** + * Exports the Wallet's data to a WalletData object. + * + * @returns The Wallet's data. + */ + public export(): WalletData { + return { walletId: this.getId()!, seed: this.seed }; + } + /** * Derives a key for an already registered Address in the Wallet. *