Merge branch 'master' of github.com:project-serum/spl-token-wallet

This commit is contained in:
jhl-alameda 2021-04-09 14:33:59 +08:00
commit 63ae7bd130
14 changed files with 559 additions and 285 deletions

View File

@ -6,7 +6,7 @@
"@ledgerhq/hw-transport-webusb": "^5.45.0",
"@material-ui/core": "^4.11.2",
"@material-ui/icons": "^4.11.2",
"@project-serum/serum": "^0.13.24",
"@project-serum/serum": "^0.13.33",
"@solana/spl-token-registry": "^0.2.1",
"@solana/web3.js": "^0.87.2",
"@testing-library/jest-dom": "^5.11.6",
@ -52,7 +52,9 @@
"es6": true,
"webextensions": true
},
"extends": ["react-app"]
"extends": [
"react-app"
]
},
"jest": {
"transformIgnorePatterns": [

View File

@ -1,53 +1,102 @@
import React, { useEffect, useState } from 'react';
import Typography from '@material-ui/core/Typography';
import DialogActions from '@material-ui/core/DialogActions';
import Button from '@material-ui/core/Button';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import Card from '@material-ui/core/Card';
import DialogForm from './DialogForm';
import { LedgerWalletProvider } from '../utils/walletProvider/ledger';
import {
AccountsSelector,
DerivationPathMenuItem,
toDerivationPath,
} from '../pages/LoginPage.js';
import CircularProgress from '@material-ui/core/CircularProgress';
import { useSnackbar } from 'notistack';
const AddHardwareView = {
Splash: 0,
Accounts: 1,
Confirm: 2,
};
export default function AddHardwareWalletDialog({ open, onAdd, onClose }) {
const [pubKey, setPubKey] = useState();
const { enqueueSnackbar } = useSnackbar();
useEffect(() => {
(async () => {
if (open) {
try {
const provider = new LedgerWalletProvider();
await provider.init();
setPubKey(provider.publicKey);
} catch (err) {
console.log(
`received error when attempting to connect ledger: ${err}`,
);
if (err.statusCode === 0x6804) {
enqueueSnackbar('Unlock ledger device', { variant: 'error' });
}
setPubKey(undefined);
onClose();
}
}
})();
}, [open, onClose, enqueueSnackbar]);
const [view, setView] = useState(AddHardwareView.Splash);
const [hardwareAccount, setHardwareAccount] = useState(null);
return (
<DialogForm
open={open}
onEnter={() => {}}
onClose={() => {
setPubKey(undefined);
onClose();
}}
onSubmit={() => {
setPubKey(undefined);
onAdd(pubKey);
onClose();
}}
fullWidth
>
<DialogForm onClose={onClose} open={open} onEnter={() => {}} fullWidth>
{view === AddHardwareView.Splash ? (
<AddHardwareWalletSplash
onClose={onClose}
onContinue={() => setView(AddHardwareView.Accounts)}
/>
) : view === AddHardwareView.Accounts ? (
<LedgerAccounts
onContinue={(account) => {
setHardwareAccount(account);
setView(AddHardwareView.Confirm);
}}
open={open}
onClose={onClose}
/>
) : (
<ConfirmHardwareWallet
account={hardwareAccount}
onDone={() => {
onAdd(hardwareAccount);
onClose();
setView(AddHardwareView.Splash);
}}
onBack={() => {
setView(AddHardwareView.Accounts);
}}
/>
)}
</DialogForm>
);
}
function ConfirmHardwareWallet({ account, onDone, onBack }) {
const [didConfirm, setDidConfirm] = useState(false);
useEffect(() => {
if (!didConfirm) {
account.provider
.confirmPublicKey()
.then(() => setDidConfirm(true))
.catch((err) => {
console.error('Error confirming', err);
onBack();
});
}
});
return (
<>
<DialogTitle>Confirm your wallet address</DialogTitle>
<DialogContent style={{ paddingTop: 16 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Typography fontWeight="fontWeightBold">
Check your ledger and confirm the address displayed is the address
chosen. Then click "done".
</Typography>
<Typography>{account.publicKey.toString()}</Typography>
</div>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={onBack}>
Back
</Button>
<Button color="primary" onClick={onDone} disabled={!didConfirm}>
Done
</Button>
</DialogActions>
</>
);
}
function AddHardwareWalletSplash({ onContinue, onClose }) {
return (
<>
<DialogTitle>Add hardware wallet</DialogTitle>
<DialogContent style={{ paddingTop: 16 }}>
<div
@ -56,32 +105,94 @@ export default function AddHardwareWalletDialog({ open, onAdd, onClose }) {
flexDirection: 'column',
}}
>
{pubKey ? (
<>
<b>Hardware wallet detected:</b>
<div>{pubKey.toString()}</div>
</>
) : (
<>
<b>Connect your ledger and open the Solana application</b>
<CircularProgress />
</>
)}
<b>
Connect your ledger and open the Solana application. When you are
ready, click "continue".
</b>
</div>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setPubKey(undefined);
onClose();
}}
>
Close
<Button color="primary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" color="primary" disabled={!pubKey}>
Add
<Button color="primary" onClick={onContinue}>
Continue
</Button>
</DialogActions>
</DialogForm>
</>
);
}
function LedgerAccounts({ onContinue, onClose, open }) {
const [dPathMenuItem, setDPathMenuItem] = useState(
DerivationPathMenuItem.Bip44Root,
);
const { enqueueSnackbar } = useSnackbar();
const [accounts, setAccounts] = useState(null);
const onClick = (provider) => {
onContinue({
provider,
publicKey: provider.pubKey,
derivationPath: provider.derivationPath,
account: provider.account,
change: provider.change,
});
};
useEffect(() => {
if (open) {
const fetch = async () => {
let accounts = [];
if (dPathMenuItem === DerivationPathMenuItem.Bip44Root) {
let provider = new LedgerWalletProvider({
derivationPath: toDerivationPath(dPathMenuItem),
});
accounts.push(await provider.init());
} else {
setAccounts(null);
// Loading in parallel makes the ledger upset. So do it serially.
for (let k = 0; k < 10; k += 1) {
let provider = new LedgerWalletProvider({
derivationPath: toDerivationPath(dPathMenuItem),
account: k,
});
accounts.push(await provider.init());
}
}
setAccounts(accounts);
};
fetch().catch((err) => {
console.log(`received error when attempting to connect ledger: ${err}`);
if (err && err.statusCode === 0x6804) {
enqueueSnackbar('Unlock ledger device', { variant: 'error' });
}
onClose();
});
}
}, [dPathMenuItem, enqueueSnackbar, open, onClose]);
return (
<Card elevation={0}>
{accounts === null ? (
<div style={{ padding: '24px' }}>
<Typography align="center">
Loading accounts from your hardware wallet
</Typography>
<CircularProgress
style={{
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
}}
/>
</div>
) : (
<AccountsSelector
showRoot={true}
onClick={onClick}
accounts={accounts}
setDPathMenuItem={setDPathMenuItem}
dPathMenuItem={dPathMenuItem}
/>
)}
</Card>
);
}

View File

@ -23,7 +23,6 @@ import { abbreviateAddress, useIsExtensionWidth } from '../utils/utils';
import Button from '@material-ui/core/Button';
import SendIcon from '@material-ui/icons/Send';
import ReceiveIcon from '@material-ui/icons/WorkOutline';
import DeleteIcon from '@material-ui/icons/Delete';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import AddIcon from '@material-ui/icons/Add';
@ -32,9 +31,7 @@ import IconButton from '@material-ui/core/IconButton';
import InfoIcon from '@material-ui/icons/InfoOutlined';
import Tooltip from '@material-ui/core/Tooltip';
import EditIcon from '@material-ui/icons/Edit';
import MergeType from '@material-ui/icons/MergeType';
import SortIcon from '@material-ui/icons/Sort';
import FingerprintIcon from '@material-ui/icons/Fingerprint';
import AddTokenDialog from './AddTokenDialog';
import ExportAccountDialog from './ExportAccountDialog';
import SendDialog from './SendDialog';
@ -212,14 +209,6 @@ export default function BalancesList() {
</IconButton>
</Tooltip>
)}
<Tooltip title="Merge Accounts" arrow>
<IconButton
size={iconSize}
onClick={() => setShowMergeAccounts(true)}
>
<MergeType />
</IconButton>
</Tooltip>
<Tooltip title="Add Token" arrow>
<IconButton
size={iconSize}
@ -228,7 +217,7 @@ export default function BalancesList() {
<AddIcon />
</IconButton>
</Tooltip>
<Tooltip title="Sort Accounts" arrow>
<Tooltip title="Sort Tokens" arrow>
<IconButton
size={iconSize}
onClick={() => {
@ -363,7 +352,14 @@ export function BalanceListItem({ publicKey, expandable, setUsdValue }) {
return <LoadingIndicator delay={0} />;
}
let { amount, decimals, mint, tokenName, tokenSymbol, tokenLogoUri } = balanceInfo;
let {
amount,
decimals,
mint,
tokenName,
tokenSymbol,
tokenLogoUri,
} = balanceInfo;
tokenName = tokenName ?? abbreviateAddress(mint);
let displayName;
if (isExtensionWidth) {
@ -394,47 +390,39 @@ export function BalanceListItem({ publicKey, expandable, setUsdValue }) {
}
}
const isAssociatedToken = (() => {
if (
wallet &&
wallet.publicKey &&
mint &&
associatedTokensCache[wallet.publicKey.toString()]
) {
let acc =
associatedTokensCache[wallet.publicKey.toString()][mint.toString()];
if (acc && acc.equals(publicKey)) {
return true;
// undefined => not loaded.
let isAssociatedToken = mint ? undefined : false;
if (
wallet &&
wallet.publicKey &&
mint &&
associatedTokensCache[wallet.publicKey.toString()]
) {
let acc =
associatedTokensCache[wallet.publicKey.toString()][mint.toString()];
if (acc) {
if (acc.equals(publicKey)) {
isAssociatedToken = true;
} else {
isAssociatedToken = false;
}
}
return false;
})();
}
const subtitle = isExtensionWidth ? undefined : (
<div style={{ display: 'flex', height: '20px', overflow: 'hidden' }}>
{isAssociatedToken && (
const subtitle =
isExtensionWidth || !publicKey.equals(balanceInfo.owner) ? undefined : (
<div style={{ display: 'flex', height: '20px', overflow: 'hidden' }}>
<div
style={{
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
marginRight: '5px',
}}
>
<FingerprintIcon style={{ width: '20px' }} />
{publicKey.toBase58()}
</div>
)}
<div
style={{
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
}}
>
{publicKey.toBase58()}
</div>
</div>
);
);
const usdValue =
price === undefined // Not yet loaded.
@ -450,7 +438,12 @@ export function BalanceListItem({ publicKey, expandable, setUsdValue }) {
<>
<ListItem button onClick={() => expandable && setOpen((open) => !open)}>
<ListItemIcon>
<TokenIcon mint={mint} tokenName={tokenName} url={tokenLogoUri} size={28} />
<TokenIcon
mint={mint}
tokenName={tokenName}
url={tokenLogoUri}
size={28}
/>
</ListItemIcon>
<div style={{ display: 'flex', flex: 1 }}>
<ListItemText
@ -479,18 +472,26 @@ export function BalanceListItem({ publicKey, expandable, setUsdValue }) {
</div>
{expandable ? open ? <ExpandLess /> : <ExpandMore /> : <></>}
</ListItem>
<Collapse in={open} timeout="auto" unmountOnExit>
<BalanceListItemDetails
publicKey={publicKey}
serumMarkets={serumMarkets}
balanceInfo={balanceInfo}
/>
</Collapse>
{expandable && (
<Collapse in={open} timeout="auto" unmountOnExit>
<BalanceListItemDetails
isAssociatedToken={isAssociatedToken}
publicKey={publicKey}
serumMarkets={serumMarkets}
balanceInfo={balanceInfo}
/>
</Collapse>
)}
</>
);
}
function BalanceListItemDetails({ publicKey, serumMarkets, balanceInfo }) {
function BalanceListItemDetails({
publicKey,
serumMarkets,
balanceInfo,
isAssociatedToken,
}) {
const urlSuffix = useSolanaExplorerUrlSuffix();
const classes = useStyles();
const [sendDialogOpen, setSendDialogOpen] = useState(false);
@ -529,7 +530,7 @@ function BalanceListItemDetails({ publicKey, serumMarkets, balanceInfo }) {
return <LoadingIndicator delay={0} />;
}
let { mint, tokenName, tokenSymbol, owner, amount } = balanceInfo;
let { mint, tokenName, tokenSymbol, owner } = balanceInfo;
// Only show the export UI for the native SOL coin.
const exportNeedsDisplay =
@ -540,12 +541,9 @@ function BalanceListItemDetails({ publicKey, serumMarkets, balanceInfo }) {
? serumMarkets[tokenSymbol.toUpperCase()].publicKey
: undefined
: undefined;
const isSolAddress = publicKey.equals(owner);
const additionalInfo = isExtensionWidth ? undefined : (
<>
<Typography variant="body2" className={classes.address}>
Deposit Address: {publicKey.toBase58()}
</Typography>
<Typography variant="body2">
Token Name: {tokenName ?? 'Unknown'}
</Typography>
@ -557,6 +555,17 @@ function BalanceListItemDetails({ publicKey, serumMarkets, balanceInfo }) {
Token Address: {mint.toBase58()}
</Typography>
) : null}
{!isSolAddress && (
<Typography variant="body2" className={classes.address}>
{isAssociatedToken ? 'Associated' : ''} Token Metadata:{' '}
{publicKey.toBase58()}
</Typography>
)}
{!isSolAddress && isAssociatedToken === false && (
<div style={{ display: 'flex' }}>
This is an auxiliary token account.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div>
<Typography variant="body2">
@ -646,17 +655,6 @@ function BalanceListItemDetails({ publicKey, serumMarkets, balanceInfo }) {
>
Send
</Button>
{mint && amount === 0 ? (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<DeleteIcon />}
onClick={() => setCloseTokenAccountDialogOpen(true)}
>
Delete
</Button>
) : null}
</div>
{additionalInfo}
</div>
@ -672,6 +670,7 @@ function BalanceListItemDetails({ publicKey, serumMarkets, balanceInfo }) {
balanceInfo={balanceInfo}
publicKey={publicKey}
swapInfo={swapInfo}
isAssociatedToken={isAssociatedToken}
/>
<TokenInfoDialog
open={tokenInfoDialogOpen}

View File

@ -36,6 +36,7 @@ export default function DepositDialog({
publicKey,
balanceInfo,
swapInfo,
isAssociatedToken,
}) {
const ethAccount = useEthAccount();
const urlSuffix = useSolanaExplorerUrlSuffix();
@ -66,9 +67,12 @@ export default function DepositDialog({
</Tabs>
);
}
const displaySolAddress = publicKey.equals(owner) || isAssociatedToken;
const depositAddressStr = displaySolAddress
? owner.toBase58()
: publicKey.toBase58();
return (
<DialogForm open={open} onClose={onClose}>
<DialogForm open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>
Deposit {tokenName ?? mint.toBase58()}
{tokenSymbol ? ` (${tokenSymbol})` : null}
@ -84,20 +88,20 @@ export default function DepositDialog({
<DialogContent style={{ paddingTop: 16 }}>
{tab === 0 ? (
<>
{publicKey.equals(owner) ? (
<DialogContentText>
This address can only be used to receive SOL. Do not send other
tokens to this address.
</DialogContentText>
) : (
{!displaySolAddress && isAssociatedToken === false ? (
<DialogContentText>
This address can only be used to receive{' '}
{tokenSymbol ?? abbreviateAddress(mint)}. Do not send SOL to
this address.
</DialogContentText>
) : (
<DialogContentText>
This address can be used to receive{' '}
{tokenSymbol ?? abbreviateAddress(mint)}.
</DialogContentText>
)}
<CopyableDisplay
value={publicKey.toBase58()}
value={depositAddressStr}
label={'Deposit Address'}
autoFocus
qrCode
@ -105,7 +109,7 @@ export default function DepositDialog({
<DialogContentText variant="body2">
<Link
href={
`https://explorer.solana.com/account/${publicKey.toBase58()}` +
`https://explorer.solana.com/account/${depositAddressStr}` +
urlSuffix
}
target="_blank"

View File

@ -168,11 +168,11 @@ export default function MergeAccountsDialog({ open, onClose }) {
</DialogContent>
) : (
<>
<DialogTitle>Are you sure you want to merge accounts?</DialogTitle>
<DialogTitle>Are you sure you want to merge tokens?</DialogTitle>
<DialogContent>
<DialogContentText>
<b>WARNING</b>: This action may break apps that depend on your
existing accounts.
existing token accounts.
</DialogContentText>
<DialogContentText>
Merging sends all tokens to{' '}

View File

@ -184,7 +184,7 @@ function ConnectionsButton() {
function NetworkSelector() {
const { endpoint, setEndpoint } = useConnectionConfig();
const cluster = useMemo(() => clusterForEndpoint(endpoint), [endpoint])
const cluster = useMemo(() => clusterForEndpoint(endpoint), [endpoint]);
const [anchorEl, setAnchorEl] = useState(null);
const classes = useStyles();
@ -226,7 +226,9 @@ function NetworkSelector() {
selected={cluster.apiUrl === endpoint}
>
<ListItemIcon className={classes.menuItemIcon}>
{cluster.apiUrl === endpoint ? <CheckIcon fontSize="small" /> : null}
{cluster.apiUrl === endpoint ? (
<CheckIcon fontSize="small" />
) : null}
</ListItemIcon>
{cluster.apiUrl}
</MenuItem>
@ -237,7 +239,13 @@ function NetworkSelector() {
}
function WalletSelector() {
const { accounts, setWalletSelector, addAccount } = useWalletSelector();
const {
accounts,
hardwareWalletAccount,
setHardwareWalletAccount,
setWalletSelector,
addAccount,
} = useWalletSelector();
const [anchorEl, setAnchorEl] = useState(null);
const [addAccountOpen, setAddAccountOpen] = useState(false);
const [
@ -251,22 +259,28 @@ function WalletSelector() {
if (accounts.length === 0) {
return null;
}
return (
<>
<AddHardwareWalletDialog
open={addHardwareWalletDialogOpen}
onClose={() => setAddHardwareWalletDialogOpen(false)}
onAdd={(pubKey) => {
addAccount({
onAdd={({ publicKey, derivationPath, account, change }) => {
setHardwareWalletAccount({
name: 'Hardware wallet',
importedAccount: pubKey.toString(),
publicKey,
importedAccount: publicKey.toString(),
ledger: true,
derivationPath,
account,
change,
});
setWalletSelector({
walletIndex: undefined,
importedPubkey: pubKey.toString(),
importedPubkey: publicKey.toString(),
ledger: true,
derivationPath,
account,
change,
});
}}
/>
@ -319,27 +333,25 @@ function WalletSelector() {
}}
getContentAnchorEl={null}
>
{accounts.map(({ isSelected, selector, address, name, label }) => (
<MenuItem
key={address.toBase58()}
onClick={() => {
setAnchorEl(null);
setWalletSelector(selector);
}}
selected={isSelected}
component="div"
>
<ListItemIcon className={classes.menuItemIcon}>
{isSelected ? <CheckIcon fontSize="small" /> : null}
</ListItemIcon>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Typography>{name}</Typography>
<Typography color="textSecondary">
{address.toBase58()}
</Typography>
</div>
</MenuItem>
{accounts.map((account) => (
<AccountListItem
account={account}
classes={classes}
setAnchorEl={setAnchorEl}
setWalletSelector={setWalletSelector}
/>
))}
{hardwareWalletAccount && (
<>
<Divider />
<AccountListItem
account={hardwareWalletAccount}
classes={classes}
setAnchorEl={setAnchorEl}
setWalletSelector={setWalletSelector}
/>
</>
)}
<Divider />
<MenuItem onClick={() => setAddHardwareWalletDialogOpen(true)}>
<ListItemIcon className={classes.menuItemIcon}>
@ -411,3 +423,27 @@ function Footer() {
</footer>
);
}
function AccountListItem({ account, classes, setAnchorEl, setWalletSelector }) {
return (
<MenuItem
key={account.address.toBase58()}
onClick={() => {
setAnchorEl(null);
setWalletSelector(account.selector);
}}
selected={account.isSelected}
component="div"
>
<ListItemIcon className={classes.menuItemIcon}>
{account.isSelected ? <CheckIcon fontSize="small" /> : null}
</ListItemIcon>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Typography>{account.name}</Typography>
<Typography color="textSecondary">
{account.address.toBase58()}
</Typography>
</div>
</MenuItem>
);
}

View File

@ -10,7 +10,6 @@ import {
getAccountFromSeed,
DERIVATION_PATH,
} from '../utils/walletProvider/localStorage.js';
import { useSolanaExplorerUrlSuffix } from '../utils/connection';
import Container from '@material-ui/core/Container';
import LoadingIndicator from '../components/LoadingIndicator';
import { BalanceListItem } from '../components/BalancesList.js';
@ -276,6 +275,11 @@ function RestoreWalletForm({ goBack }) {
Restore your wallet using your twelve or twenty-four seed words.
Note that this will delete any existing wallet on this device.
</Typography>
<br />
<Typography fontWeight="fontWeightBold">
<b>Do not enter your hardware wallet seedphrase here.</b> Hardware
wallets can be optionally connected after a web wallet is created.
</Typography>
<TextField
variant="outlined"
fullWidth
@ -330,11 +334,9 @@ function RestoreWalletForm({ goBack }) {
function DerivedAccounts({ goBack, mnemonic, seed, password }) {
const callAsync = useCallAsync();
const urlSuffix = useSolanaExplorerUrlSuffix();
const [dPathMenuItem, setDPathMenuItem] = useState(
DerivationPathMenuItem.Bip44Change,
);
const accounts = [...Array(10)].map((_, idx) => {
return getAccountFromSeed(
Buffer.from(seed, 'hex'),
@ -356,52 +358,12 @@ function DerivedAccounts({ goBack, mnemonic, seed, password }) {
return (
<Card>
<CardContent>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
}}
>
<Typography variant="h5" gutterBottom>
Derivable Accounts
</Typography>
<FormControl variant="outlined">
<Select
value={dPathMenuItem}
onChange={(e) => setDPathMenuItem(e.target.value)}
>
<MenuItem value={DerivationPathMenuItem.Bip44Change}>
{`m/44'/501'/0'/0'`}
</MenuItem>
<MenuItem value={DerivationPathMenuItem.Bip44}>
{`m/44'/501'/0'`}
</MenuItem>
<MenuItem value={DerivationPathMenuItem.Deprecated}>
{`m/501'/0'/0/0 (deprecated)`}
</MenuItem>
</Select>
</FormControl>
</div>
{accounts.map((acc) => {
return (
<Link
href={
`https://explorer.solana.com/account/${acc.publicKey.toBase58()}` +
urlSuffix
}
target="_blank"
rel="noopener"
>
<BalanceListItem
publicKey={acc.publicKey}
walletAccount={acc}
expandable={false}
/>
</Link>
);
})}
</CardContent>
<AccountsSelector
showDeprecated={true}
accounts={accounts}
dPathMenuItem={dPathMenuItem}
setDPathMenuItem={setDPathMenuItem}
/>
<CardActions style={{ justifyContent: 'space-between' }}>
<Button onClick={goBack}>Back</Button>
<Button color="primary" onClick={submit}>
@ -412,18 +374,80 @@ function DerivedAccounts({ goBack, mnemonic, seed, password }) {
);
}
export function AccountsSelector({
showRoot,
showDeprecated,
accounts,
dPathMenuItem,
setDPathMenuItem,
onClick,
}) {
return (
<CardContent>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
}}
>
<Typography variant="h5" gutterBottom>
Derivable Accounts
</Typography>
<FormControl variant="outlined">
<Select
value={dPathMenuItem}
onChange={(e) => {
setDPathMenuItem(e.target.value);
}}
>
{showRoot && (
<MenuItem value={DerivationPathMenuItem.Bip44Root}>
{`m/44'/501'`}
</MenuItem>
)}
<MenuItem value={DerivationPathMenuItem.Bip44}>
{`m/44'/501'/0'`}
</MenuItem>
<MenuItem value={DerivationPathMenuItem.Bip44Change}>
{`m/44'/501'/0'/0'`}
</MenuItem>
{showDeprecated && (
<MenuItem value={DerivationPathMenuItem.Deprecated}>
{`m/501'/0'/0/0 (deprecated)`}
</MenuItem>
)}
</Select>
</FormControl>
</div>
{accounts.map((acc) => {
return (
<div onClick={onClick ? () => onClick(acc) : {}}>
<BalanceListItem
key={acc.publicKey.toString()}
onClick={onClick}
publicKey={acc.publicKey}
expandable={false}
/>
</div>
);
})}
</CardContent>
);
}
// Material UI's Select doesn't render properly when using an `undefined` value,
// so we define this type and the subsequent `toDerivationPath` translator as a
// workaround.
//
// DERIVATION_PATH.deprecated is always undefined.
const DerivationPathMenuItem = {
export const DerivationPathMenuItem = {
Deprecated: 0,
Bip44: 1,
Bip44Change: 2,
Bip44Root: 3, // Ledger only.
};
function toDerivationPath(dPathMenuItem) {
export function toDerivationPath(dPathMenuItem) {
switch (dPathMenuItem) {
case DerivationPathMenuItem.Deprecated:
return DERIVATION_PATH.deprecated;
@ -431,6 +455,8 @@ function toDerivationPath(dPathMenuItem) {
return DERIVATION_PATH.bip44;
case DerivationPathMenuItem.Bip44Change:
return DERIVATION_PATH.bip44Change;
case DerivationPathMenuItem.Bip44Root:
return DERIVATION_PATH.bip44Root;
default:
throw new Error(`invalid derivation path: ${dPathMenuItem}`);
}

View File

@ -49,7 +49,6 @@ export default function PopupPage({ opener }) {
const [connectedAccount, setConnectedAccount] = useState(null);
const hasConnectedAccount = !!connectedAccount;
const [requests, setRequests] = useState(getInitialRequests);
const [autoApprove, setAutoApprove] = useState(false);
const postMessage = useCallback(
@ -66,6 +65,22 @@ export default function PopupPage({ opener }) {
[opener, origin],
);
// Hack to keep selectedWallet and wallet in sync. TODO: remove this block.
useEffect(() => {
if (!isExtension) {
if (!wallet) {
setWallet(selectedWallet);
} else if (!wallet.publicKey.equals(selectedWallet.publicKey)) {
setWallet(selectedWallet);
}
}
}, [
wallet,
wallet.publicKey,
selectedWallet,
selectedWallet.publicKey,
]);
// (Extension only) Fetch connected wallet for site from local storage.
useEffect(() => {
if (isExtension) {
@ -262,9 +277,19 @@ export default function PopupPage({ opener }) {
}
async function sendAllSignatures(messages) {
const signatures = await Promise.all(
messages.map((m) => wallet.createSignature(m)),
);
console.log('wallet', wallet);
let signatures;
// Ledger must sign one by one.
if (wallet.type === 'ledger') {
signatures = [];
for (let k = 0; k < messages.length; k += 1) {
signatures.push(await wallet.createSignature(messages[k]));
}
} else {
signatures = await Promise.all(
messages.map((m) => wallet.createSignature(m)),
);
}
postMessage({
result: {
signatures,
@ -352,11 +377,11 @@ const useStyles = makeStyles((theme) => ({
function ApproveConnectionForm({ origin, onApprove }) {
const wallet = useWallet();
const { accounts } = useWalletSelector();
const { accounts, hardwareWalletAccount } = useWalletSelector();
// TODO better way to do this
const account = accounts.find((account) =>
account.address.equals(wallet.publicKey),
);
const account = accounts
.concat([hardwareWalletAccount])
.find((account) => account && account.address.equals(wallet.publicKey));
const classes = useStyles();
const [autoApprove, setAutoApprove] = useState(false);
let [dismissed, setDismissed] = useLocalStorageState(

View File

@ -7,7 +7,7 @@ interface Markets {
publicKey: PublicKey;
name: string;
deprecated?: boolean;
}
};
}
export const serumMarkets = (() => {
@ -34,7 +34,7 @@ export const serumMarkets = (() => {
// Create a cached API wrapper to avoid rate limits.
class PriceStore {
cache: {}
cache: {};
constructor() {
this.cache = {};
@ -50,7 +50,12 @@ class PriceStore {
fetch(`https://serum-api.bonfida.com/orderbooks/${marketName}`).then(
(resp) => {
resp.json().then((resp) => {
if (resp.data.asks.length === 0 && resp.data.bids.length === 0) {
if (resp.data.asks === null || resp.data.bids === null) {
resolve(undefined);
} else if (
resp.data.asks.length === 0 &&
resp.data.bids.length === 0
) {
resolve(undefined);
} else if (resp.data.asks.length === 0) {
resolve(resp.data.bids[0].price);

View File

@ -172,17 +172,20 @@ export function WalletProvider({ children }) {
{},
);
// `walletSelector` identifies which wallet to use.
const [walletSelector, setWalletSelector] = useLocalStorageState(
let [walletSelector, setWalletSelector] = useLocalStorageState(
'walletSelector',
DEFAULT_WALLET_SELECTOR,
);
const [ledgerPubKey, setLedgerPubKey] = useState(
walletSelector.ledger ? walletSelector.importedPubkey : undefined,
);
const [_hardwareWalletAccount, setHardwareWalletAccount] = useState(null);
// `walletCount` is the number of HD wallets.
const [walletCount, setWalletCount] = useLocalStorageState('walletCount', 1);
if (walletSelector.ledger && !_hardwareWalletAccount) {
walletSelector = DEFAULT_WALLET_SELECTOR;
setWalletSelector(DEFAULT_WALLET_SELECTOR);
}
useEffect(() => {
(async () => {
if (!seed) {
@ -193,9 +196,15 @@ export function WalletProvider({ children }) {
try {
const onDisconnect = () => {
setWalletSelector(DEFAULT_WALLET_SELECTOR);
setLedgerPubKey(undefined);
setHardwareWalletAccount(null);
};
wallet = await Wallet.create(connection, 'ledger', { onDisconnect });
const args = {
onDisconnect,
derivationPath: walletSelector.derivationPath,
account: walletSelector.account,
change: walletSelector.change,
};
wallet = await Wallet.create(connection, 'ledger', args);
} catch (e) {
console.log(`received error using ledger wallet: ${e}`);
let message = 'Received error unlocking ledger';
@ -204,7 +213,7 @@ export function WalletProvider({ children }) {
}
enqueueSnackbar(message, { variant: 'error' });
setWalletSelector(DEFAULT_WALLET_SELECTOR);
setLedgerPubKey(undefined);
setHardwareWalletAccount(null);
return;
}
}
@ -242,11 +251,8 @@ export function WalletProvider({ children }) {
enqueueSnackbar,
derivationPath,
]);
function addAccount({ name, importedAccount, ledger }) {
if (ledger) {
setLedgerPubKey(importedAccount);
} else if (importedAccount === undefined) {
if (importedAccount === undefined) {
name && localStorage.setItem(`name${walletCount}`, name);
setWalletCount(walletCount + 1);
} else {
@ -319,29 +325,26 @@ export function WalletProvider({ children }) {
};
});
if (ledgerPubKey) {
derivedAccounts.push({
selector: {
walletIndex: undefined,
importedPubkey: ledgerPubKey,
ledger: true,
},
address: new PublicKey(ledgerPubKey), // todo: get the ledger address
name: 'Hardware wallet',
isSelected: walletSelector.ledger,
});
}
return derivedAccounts.concat(importedAccounts);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
seed,
walletCount,
walletSelector,
privateKeyImports,
walletNames,
ledgerPubKey,
]);
}, [seed, walletCount, walletSelector, privateKeyImports, walletNames]);
let hardwareWalletAccount;
if (_hardwareWalletAccount) {
hardwareWalletAccount = {
..._hardwareWalletAccount,
selector: {
walletIndex: undefined,
ledger: true,
importedPubkey: _hardwareWalletAccount.publicKey,
derivationPath: _hardwareWalletAccount.derivationPath,
account: _hardwareWalletAccount.account,
change: _hardwareWalletAccount.change,
},
address: _hardwareWalletAccount.publicKey,
isSelected: walletSelector.ledger,
};
}
return (
<WalletContext.Provider
@ -358,6 +361,8 @@ export function WalletProvider({ children }) {
addAccount,
setAccountName,
derivationPath,
hardwareWalletAccount,
setHardwareWalletAccount,
}}
>
{children}
@ -473,7 +478,16 @@ export function useWalletSelector() {
addAccount,
setWalletSelector,
setAccountName,
hardwareWalletAccount,
setHardwareWalletAccount,
} = useContext(WalletContext);
return { accounts, setWalletSelector, addAccount, setAccountName };
return {
accounts,
setWalletSelector,
addAccount,
setAccountName,
hardwareWalletAccount,
setHardwareWalletAccount,
};
}

View File

@ -1,4 +1,5 @@
import { PublicKey } from '@solana/web3.js';
import { DERIVATION_PATH } from './localStorage';
const bs58 = require('bs58');
const INS_GET_PUBKEY = 0x05;
@ -60,21 +61,43 @@ function _harden(n) {
return (n | BIP32_HARDENED_BIT) >>> 0;
}
export function solana_derivation_path(account, change) {
const length = 4;
export function solana_derivation_path(account, change, derivationPath) {
let useAccount = account ? account : 0;
let useChange = change ? change : 0;
derivationPath = derivationPath
? derivationPath
: DERIVATION_PATH.bip44Change;
var derivation_path = Buffer.alloc(1 + length * 4);
// eslint-disable-next-line
var offset = 0;
offset = derivation_path.writeUInt8(length, offset);
offset = derivation_path.writeUInt32BE(_harden(44), offset); // Using BIP44
offset = derivation_path.writeUInt32BE(_harden(501), offset); // Solana's BIP44 path
offset = derivation_path.writeUInt32BE(_harden(useAccount), offset);
derivation_path.writeUInt32BE(_harden(useChange), offset);
return derivation_path;
if (derivationPath === DERIVATION_PATH.bip44Root) {
const length = 2;
const derivation_path = Buffer.alloc(1 + length * 4);
let offset = 0;
offset = derivation_path.writeUInt8(length, offset);
offset = derivation_path.writeUInt32BE(_harden(44), offset); // Using BIP44
derivation_path.writeUInt32BE(_harden(501), offset); // Solana's BIP44 path
return derivation_path;
} else if (derivationPath === DERIVATION_PATH.bip44) {
const length = 3;
const derivation_path = Buffer.alloc(1 + length * 4);
let offset = 0;
offset = derivation_path.writeUInt8(length, offset);
offset = derivation_path.writeUInt32BE(_harden(44), offset); // Using BIP44
offset = derivation_path.writeUInt32BE(_harden(501), offset); // Solana's BIP44 path
derivation_path.writeUInt32BE(_harden(useAccount), offset);
return derivation_path;
} else if (derivationPath === DERIVATION_PATH.bip44Change) {
const length = 4;
const derivation_path = Buffer.alloc(1 + length * 4);
let offset = 0;
offset = derivation_path.writeUInt8(length, offset);
offset = derivation_path.writeUInt32BE(_harden(44), offset); // Using BIP44
offset = derivation_path.writeUInt32BE(_harden(501), offset); // Solana's BIP44 path
offset = derivation_path.writeUInt32BE(_harden(useAccount), offset);
derivation_path.writeUInt32BE(_harden(useChange), offset);
return derivation_path;
} else {
throw new Error('Invalid derivation path');
}
}
async function solana_ledger_get_pubkey(transport, derivation_path) {
@ -102,7 +125,6 @@ export async function solana_ledger_sign_bytes(
) {
var num_paths = Buffer.alloc(1);
num_paths.writeUInt8(1);
const payload = Buffer.concat([num_paths, derivation_path, msg_bytes]);
return solana_send(transport, INS_SIGN_MESSAGE, P1_CONFIRM, payload);
@ -123,3 +145,15 @@ export async function getPublicKey(transport, path) {
return new PublicKey(from_pubkey_string);
}
export async function solana_ledger_confirm_public_key(
transport,
derivation_path,
) {
return await solana_send(
transport,
INS_GET_PUBKEY,
P1_CONFIRM,
derivation_path,
);
}

View File

@ -4,17 +4,29 @@ import {
solana_derivation_path,
solana_ledger_sign_bytes,
solana_ledger_sign_transaction,
solana_ledger_confirm_public_key,
} from './ledger-core';
import { DERIVATION_PATH } from './localStorage';
import bs58 from 'bs58';
export class LedgerWalletProvider {
constructor(args) {
this.onDisconnect = (args && args.onDisconnect) || (() => {});
this.derivationPath = args
? args.derivationPath
: DERIVATION_PATH.bip44Change;
this.account = args ? args.account : undefined;
this.change = args ? args.change : undefined;
this.solanaDerivationPath = solana_derivation_path(
this.account,
this.change,
this.derivationPath,
);
}
init = async () => {
this.transport = await TransportWebUsb.create();
this.pubKey = await getPublicKey(this.transport);
this.pubKey = await getPublicKey(this.transport, this.solanaDerivationPath);
this.transport.on('disconnect', this.onDisconnect);
this.listAddresses = async (walletCount) => {
// TODO: read accounts from ledger
@ -28,10 +40,9 @@ export class LedgerWalletProvider {
}
signTransaction = async (transaction) => {
const from_derivation_path = solana_derivation_path();
const sig_bytes = await solana_ledger_sign_transaction(
this.transport,
from_derivation_path,
this.solanaDerivationPath,
transaction,
);
transaction.addSignature(this.publicKey, sig_bytes);
@ -39,12 +50,18 @@ export class LedgerWalletProvider {
};
createSignature = async (message) => {
const from_derivation_path = solana_derivation_path();
const sig_bytes = await solana_ledger_sign_bytes(
this.transport,
from_derivation_path,
this.solanaDerivationPath,
message,
);
return bs58.encode(sig_bytes);
};
confirmPublicKey = async () => {
return await solana_ledger_confirm_public_key(
this.transport,
this.solanaDerivationPath,
);
};
}

View File

@ -9,6 +9,7 @@ export const DERIVATION_PATH = {
deprecated: undefined,
bip44: 'bip44',
bip44Change: 'bip44Change',
bip44Root: 'bip44Root', // Ledger only.
};
export function getAccountFromSeed(

View File

@ -1862,10 +1862,10 @@
schema-utils "^2.6.5"
source-map "^0.7.3"
"@project-serum/serum@^0.13.24":
version "0.13.24"
resolved "https://registry.yarnpkg.com/@project-serum/serum/-/serum-0.13.24.tgz#42ab28052924c4b754ed078790a192ae23fffab9"
integrity sha512-ux2MuO2kGGUV/QYBVx+e3roM/zATI6Fi2ThVaEo4aBdQKosbuNA60uAfSnYK9Du0kz7UjGoZHv2FnkCq8web3A==
"@project-serum/serum@^0.13.33":
version "0.13.33"
resolved "https://registry.yarnpkg.com/@project-serum/serum/-/serum-0.13.33.tgz#03ce8219c1bb458f56c09dc8aa621d76538e70f5"
integrity sha512-g2ztZwhQAvhGE9u4/Md6uEFBpaOMV2Xa/H/FGhgTx3iBv2sikW5fheHRJ8Vy7yEA9ZhZCuzbCkw8Wz1fq82VAg==
dependencies:
"@solana/web3.js" "^0.90.0"
bn.js "^5.1.2"