-
- {index === 0 ? 'Main account' : name || `Account ${index}`}
-
+
{name}
{address.toBase58()}
@@ -204,7 +208,7 @@ function WalletSelector() {
- Create Account
+ Add Account
>
diff --git a/src/components/SendDialog.js b/src/components/SendDialog.js
index cc279a4..ffa0d3d 100644
--- a/src/components/SendDialog.js
+++ b/src/components/SendDialog.js
@@ -28,7 +28,10 @@ import Link from '@material-ui/core/Link';
import Typography from '@material-ui/core/Typography';
import { useAsyncData } from '../utils/fetch-loop';
import CircularProgress from '@material-ui/core/CircularProgress';
-import {TOKEN_PROGRAM_ID, WRAPPED_SOL_MINT} from '../utils/tokens/instructions';
+import {
+ TOKEN_PROGRAM_ID,
+ WRAPPED_SOL_MINT,
+} from '../utils/tokens/instructions';
import { parseTokenAccountData } from '../utils/tokens/data';
const WUSDC_MINT = new PublicKey(
@@ -134,9 +137,10 @@ export default function SendDialog({ open, onClose, publicKey, balanceInfo }) {
}
function SendSplDialog({ onClose, publicKey, balanceInfo, onSubmitRef }) {
- const defaultAddressHelperText = !balanceInfo.mint || balanceInfo.mint.equals(WRAPPED_SOL_MINT) ?
- 'Enter Solana Address' :
- 'Enter SPL token or Solana address';
+ const defaultAddressHelperText =
+ !balanceInfo.mint || balanceInfo.mint.equals(WRAPPED_SOL_MINT)
+ ? 'Enter Solana Address'
+ : 'Enter SPL token or Solana address';
const wallet = useWallet();
const [sendTransaction, sending] = useSendTransaction();
const [addressHelperText, setAddressHelperText] = useState(
diff --git a/src/utils/wallet-seed.js b/src/utils/wallet-seed.js
index 7f36682..c6e19be 100644
--- a/src/utils/wallet-seed.js
+++ b/src/utils/wallet-seed.js
@@ -1,5 +1,6 @@
import { pbkdf2 } from 'crypto';
import { randomBytes, secretbox } from 'tweetnacl';
+import * as bip32 from 'bip32';
import bs58 from 'bs58';
import { EventEmitter } from 'events';
@@ -19,11 +20,20 @@ export async function mnemonicToSeed(mnemonic) {
return Buffer.from(seed).toString('hex');
}
-let unlockedMnemonicAndSeed = JSON.parse(
- sessionStorage.getItem('unlocked') ||
- localStorage.getItem('unlocked') ||
- 'null',
-) || { mnemonic: null, seed: null };
+let unlockedMnemonicAndSeed = (() => {
+ const stored = JSON.parse(
+ sessionStorage.getItem('unlocked') ||
+ localStorage.getItem('unlocked') ||
+ 'null',
+ );
+ if (stored === null) {
+ return { mnemonic: null, seed: null, importsEncryptionKey: null };
+ }
+ return {
+ importsEncryptionKey: deriveImportsEncryptionKey(stored.seed),
+ ...stored,
+ };
+})();
export const walletSeedChanged = new EventEmitter();
export function getUnlockedMnemonicAndSeed() {
@@ -34,8 +44,8 @@ export function hasLockedMnemonicAndSeed() {
return !!localStorage.getItem('locked');
}
-function setUnlockedMnemonicAndSeed(mnemonic, seed) {
- unlockedMnemonicAndSeed = { mnemonic, seed };
+function setUnlockedMnemonicAndSeed(mnemonic, seed, importsEncryptionKey) {
+ unlockedMnemonicAndSeed = { mnemonic, seed, importsEncryptionKey };
walletSeedChanged.emit('change', unlockedMnemonicAndSeed);
}
@@ -67,7 +77,8 @@ export async function storeMnemonicAndSeed(mnemonic, seed, password) {
localStorage.removeItem('locked');
sessionStorage.removeItem('unlocked');
}
- setUnlockedMnemonicAndSeed(mnemonic, seed);
+ const privateKey = deriveImportsEncryptionKey(seed);
+ setUnlockedMnemonicAndSeed(mnemonic, seed, privateKey);
}
export async function loadMnemonicAndSeed(password, stayLoggedIn) {
@@ -91,7 +102,8 @@ export async function loadMnemonicAndSeed(password, stayLoggedIn) {
if (stayLoggedIn) {
sessionStorage.setItem('unlocked', decodedPlaintext);
}
- setUnlockedMnemonicAndSeed(mnemonic, seed);
+ const privateKey = deriveImportsEncryptionKey(seed);
+ setUnlockedMnemonicAndSeed(mnemonic, seed, privateKey);
return { mnemonic, seed };
}
@@ -109,5 +121,12 @@ async function deriveEncryptionKey(password, salt, iterations, digest) {
}
export function lockWallet() {
- setUnlockedMnemonicAndSeed(null, null);
+ setUnlockedMnemonicAndSeed(null, null, null);
+}
+
+// Returns the 32 byte key used to encrypt imported private keys.
+function deriveImportsEncryptionKey(seed) {
+ // SLIP16 derivation path.
+ return bip32.fromSeed(Buffer.from(seed, 'hex')).derivePath("m/10016'/0")
+ .privateKey;
}
diff --git a/src/utils/wallet.js b/src/utils/wallet.js
index 98f0c9b..171435e 100644
--- a/src/utils/wallet.js
+++ b/src/utils/wallet.js
@@ -1,6 +1,12 @@
import React, { useContext, useMemo } from 'react';
import * as bip32 from 'bip32';
-import { Account, SystemProgram, Transaction } from '@solana/web3.js';
+import * as bs58 from 'bs58';
+import {
+ Account,
+ SystemProgram,
+ Transaction,
+ PublicKey,
+} from '@solana/web3.js';
import nacl from 'tweetnacl';
import {
setInitialAccountInfo,
@@ -24,12 +30,15 @@ import { useTokenName } from './tokens/names';
import { refreshCache, useAsyncData } from './fetch-loop';
import { getUnlockedMnemonicAndSeed, walletSeedChanged } from './wallet-seed';
+const DEFAULT_WALLET_SELECTOR = {
+ walletIndex: 0,
+ importedPubkey: undefined,
+};
+
export class Wallet {
- constructor(connection, seed, walletIndex = 0) {
+ constructor(connection, account) {
this.connection = connection;
- this.seed = seed;
- this.walletIndex = walletIndex;
- this.account = Wallet.getAccountFromSeed(this.seed, this.walletIndex);
+ this.account = account;
}
static getAccountFromSeed(seed, walletIndex, accountIndex = 0) {
@@ -113,19 +122,63 @@ const WalletContext = React.createContext(null);
export function WalletProvider({ children }) {
useListener(walletSeedChanged, 'change');
- const { mnemonic, seed } = getUnlockedMnemonicAndSeed();
+ const { mnemonic, seed, importsEncryptionKey } = getUnlockedMnemonicAndSeed();
const connection = useConnection();
- const [walletIndex, setWalletIndex] = useLocalStorageState('walletIndex', 0);
- const wallet = useMemo(
- () =>
- seed
- ? new Wallet(connection, Buffer.from(seed, 'hex'), walletIndex)
- : null,
- [connection, seed, walletIndex],
+
+ // `privateKeyImports` are accounts imported *in addition* to HD wallets
+ const [privateKeyImports, setPrivateKeyImports] = useLocalStorageState(
+ 'walletPrivateKeyImports',
+ {},
);
+ // `walletSelector` identifies which wallet to use.
+ const [walletSelector, setWalletSelector] = useLocalStorageState(
+ 'walletSelector',
+ DEFAULT_WALLET_SELECTOR,
+ );
+
+ const wallet = useMemo(() => {
+ if (!seed) {
+ return null;
+ }
+ const account =
+ walletSelector.walletIndex !== undefined
+ ? Wallet.getAccountFromSeed(
+ Buffer.from(seed, 'hex'),
+ walletSelector.walletIndex,
+ )
+ : new Account(
+ (() => {
+ const { nonce, ciphertext } = privateKeyImports[
+ walletSelector.importedPubkey
+ ];
+ return nacl.secretbox.open(
+ bs58.decode(ciphertext),
+ bs58.decode(nonce),
+ importsEncryptionKey,
+ );
+ })(),
+ );
+ return new Wallet(connection, account);
+ }, [
+ connection,
+ seed,
+ walletSelector,
+ privateKeyImports,
+ importsEncryptionKey,
+ ]);
+
return (
{children}
@@ -244,34 +297,66 @@ export function useBalanceInfo(publicKey) {
}
export function useWalletSelector() {
- const { walletIndex, setWalletIndex, seed } = useContext(WalletContext);
+ const {
+ seed,
+ importsEncryptionKey,
+ walletSelector,
+ setWalletSelector,
+ privateKeyImports,
+ setPrivateKeyImports,
+ } = useContext(WalletContext);
+
+ // `walletCount` is the number of HD wallets.
const [walletCount, setWalletCount] = useLocalStorageState('walletCount', 1);
- function selectWallet(walletIndex, name) {
- if (walletIndex >= walletCount) {
- name && localStorage.setItem(`name${walletIndex}`, name);
- setWalletCount(walletIndex + 1);
+
+ function addAccount({ name, importedAccount }) {
+ if (importedAccount === undefined) {
+ name && localStorage.setItem(`name${walletCount}`, name);
+ setWalletCount(walletCount + 1);
+ } else {
+ const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
+ const plaintext = importedAccount.secretKey;
+ const ciphertext = nacl.secretbox(plaintext, nonce, importsEncryptionKey);
+ // `useLocalStorageState` requires a new object.
+ let newPrivateKeyImports = { ...privateKeyImports };
+ newPrivateKeyImports[importedAccount.publicKey.toString()] = {
+ name,
+ ciphertext: bs58.encode(ciphertext),
+ nonce: bs58.encode(nonce),
+ };
+ setPrivateKeyImports(newPrivateKeyImports);
}
- setWalletIndex(walletIndex);
}
+
const accounts = useMemo(() => {
if (!seed) {
return [];
}
- const seedBuffer = Buffer.from(seed, 'hex');
- return [...Array(walletCount).keys()].map((walletIndex) => {
- let address = Wallet.getAccountFromSeed(seedBuffer, walletIndex)
- .publicKey;
- let name = localStorage.getItem(`name${walletIndex}`);
- return { index: walletIndex, address, name };
- });
- }, [seed, walletCount]);
- return { accounts, walletIndex, setWalletIndex: selectWallet };
-}
-export async function mnemonicToSecretKey(mnemonic) {
- const { mnemonicToSeed } = await import('bip39');
- const rootSeed = Buffer.from(await mnemonicToSeed(mnemonic), 'hex');
- const derivedSeed = bip32.fromSeed(rootSeed).derivePath("m/501'/0'/0/0")
- .privateKey;
- return nacl.sign.keyPair.fromSeed(derivedSeed).secretKey;
+ const seedBuffer = Buffer.from(seed, 'hex');
+ const derivedAccounts = [...Array(walletCount).keys()].map((idx) => {
+ let address = Wallet.getAccountFromSeed(seedBuffer, idx).publicKey;
+ let name = localStorage.getItem(`name${idx}`);
+ return {
+ selector: { walletIndex: idx, importedPubkey: undefined },
+ isSelected: walletSelector.walletIndex === idx,
+ address,
+ name: idx === 0 ? 'Main account' : name || `Account ${idx}`,
+ };
+ });
+
+ const importedAccounts = Object.keys(privateKeyImports).map((pubkey) => {
+ const { name } = privateKeyImports[pubkey];
+ return {
+ selector: { walletIndex: undefined, importedPubkey: pubkey },
+ address: new PublicKey(bs58.decode(pubkey)),
+ name: `${name} (imported)`, // TODO: do this in the Component with styling.
+ isSelected: walletSelector.importedPubkey === pubkey,
+ };
+ });
+
+ return derivedAccounts.concat(importedAccounts);
+ }, [seed, walletCount, walletSelector, privateKeyImports]);
+
+ return { accounts, setWalletSelector, addAccount };
}