Merge branch 'master' into feature/bring_to_front

This commit is contained in:
Nathaniel Parke 2020-11-10 10:35:40 +08:00
commit 30adddc56a
13 changed files with 730 additions and 140 deletions

View File

@ -5,7 +5,7 @@
"dependencies": {
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@project-serum/serum": "^0.13.4",
"@project-serum/serum": "^0.13.11",
"@solana/web3.js": "^0.78.2",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",

View File

@ -0,0 +1,86 @@
import React, { useState } from 'react';
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 TextField from '@material-ui/core/TextField';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import FormGroup from '@material-ui/core/FormGroup';
import Switch from '@material-ui/core/Switch';
import { Account } from '@solana/web3.js';
import * as bs58 from 'bs58';
import DialogForm from './DialogForm';
export default function AddAccountDialog({ open, onAdd, onClose }) {
const [name, setName] = useState('');
const [isImport, setIsImport] = useState(false);
const [importedPrivateKey, setPrivateKey] = useState('');
const importedAccount = isImport ? decodeAccount(importedPrivateKey) : undefined;
const isAddEnabled = isImport ? name && importedAccount !== undefined : name;
return (
<DialogForm
open={open}
onClose={onClose}
onSubmit={() => onAdd({ name, importedAccount })}
fullWidth
>
<DialogTitle>Add account</DialogTitle>
<DialogContent style={{ paddingTop: 16 }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<TextField
label="Name"
fullWidth
variant="outlined"
margin="normal"
value={name}
onChange={(e) => setName(e.target.value.trim())}
/>
<FormGroup>
<FormControlLabel
control={
<Switch
checked={isImport}
onChange={() => setIsImport(!isImport)}
/>
}
label="Import private key"
/>
</FormGroup>
{isImport && (
<TextField
label="Paste your private key here"
fullWidth
type="password"
value={importedPrivateKey}
variant="outlined"
margin="normal"
onChange={(e) => setPrivateKey(e.target.value.trim())}
/>
)}
</div>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
<Button type="submit" color="primary" disabled={!isAddEnabled}>
Add
</Button>
</DialogActions>
</DialogForm>
);
}
function decodeAccount(privateKey) {
try {
const a = new Account(bs58.decode(privateKey));
return a;
} catch (_) {
return undefined;
}
}

View File

@ -170,18 +170,20 @@ export default function AddTokenDialog({ open, onClose }) {
</React.Fragment>
) : tab === 'popular' ? (
<List disablePadding>
{popularTokens.map((token) => (
<TokenListItem
key={token.mintAddress}
{...token}
existingAccount={(walletAccounts || []).find(
(account) =>
account.parsed.mint.toBase58() === token.mintAddress,
)}
onSubmit={onSubmit}
disalbed={sending}
/>
))}
{popularTokens
.filter((token) => !token.deprecated)
.map((token) => (
<TokenListItem
key={token.mintAddress}
{...token}
existingAccount={(walletAccounts || []).find(
(account) =>
account.parsed.mint.toBase58() === token.mintAddress,
)}
onSubmit={onSubmit}
disalbed={sending}
/>
))}
</List>
) : tab === 'erc20' ? (
<>

View File

@ -30,6 +30,7 @@ import IconButton from '@material-ui/core/IconButton';
import InfoIcon from '@material-ui/icons/InfoOutlined';
import Tooltip from '@material-ui/core/Tooltip';
import AddTokenDialog from './AddTokenDialog';
import ExportAccountDialog from './ExportAccountDialog';
import SendDialog from './SendDialog';
import DepositDialog from './DepositDialog';
import {
@ -157,6 +158,7 @@ function BalanceListItemDetails({ publicKey, balanceInfo }) {
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [depositDialogOpen, setDepositDialogOpen] = useState(false);
const [tokenInfoDialogOpen, setTokenInfoDialogOpen] = useState(false);
const [exportAccDialogOpen, setExportAccDialogOpen] = useState(false);
const [
closeTokenAccountDialogOpen,
setCloseTokenAccountDialogOpen,
@ -168,8 +170,16 @@ function BalanceListItemDetails({ publicKey, balanceInfo }) {
let { mint, tokenName, tokenSymbol, owner, amount } = balanceInfo;
// Only show the export UI for the native SOL coin.
const exportNeedsDisplay =
mint === null && tokenName === 'SOL' && tokenSymbol === 'SOL';
return (
<>
<ExportAccountDialog
onClose={() => setExportAccDialogOpen(false)}
open={exportAccDialogOpen}
/>
<div className={classes.itemDetails}>
<div className={classes.buttonContainer}>
{!publicKey.equals(owner) && showTokenInfoDialog ? (
@ -224,18 +234,31 @@ function BalanceListItemDetails({ publicKey, balanceInfo }) {
Token Address: {mint.toBase58()}
</Typography>
) : null}
<Typography variant="body2">
<Link
href={
`https://explorer.solana.com/account/${publicKey.toBase58()}` +
urlSuffix
}
target="_blank"
rel="noopener"
>
View on Solana Explorer
</Link>
</Typography>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div>
<Typography variant="body2">
<Link
href={
`https://explorer.solana.com/account/${publicKey.toBase58()}` +
urlSuffix
}
target="_blank"
rel="noopener"
>
View on Solana Explorer
</Link>
</Typography>
</div>
{exportNeedsDisplay && (
<div>
<Typography variant="body2">
<Link href={'#'} onClick={(e) => setExportAccDialogOpen(true)}>
Export
</Link>
</Typography>
</div>
)}
</div>
</div>
<SendDialog
open={sendDialogOpen}

View File

@ -0,0 +1,44 @@
import React, { useState } from 'react';
import Button from '@material-ui/core/Button';
import DialogActions from '@material-ui/core/DialogActions';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import TextField from '@material-ui/core/TextField';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import Switch from '@material-ui/core/Switch';
import * as bs58 from 'bs58';
import DialogForm from './DialogForm';
import { useWallet } from '../utils/wallet';
export default function ExportAccountDialog({ open, onClose }) {
const wallet = useWallet();
const [isHidden, setIsHidden] = useState(true);
return (
<DialogForm open={open} onClose={onClose} fullWidth>
<DialogTitle>Export account</DialogTitle>
<DialogContent>
<TextField
label="Private key"
fullWidth
type={isHidden && 'password'}
variant="outlined"
margin="normal"
value={bs58.encode(wallet.account.secretKey)}
/>
<FormControlLabel
control={
<Switch
checked={!isHidden}
onChange={() => setIsHidden(!isHidden)}
/>
}
label="Reveal"
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</DialogForm>
);
}

View File

@ -19,6 +19,7 @@ import IconButton from '@material-ui/core/IconButton';
import SolanaIcon from './SolanaIcon';
import CodeIcon from '@material-ui/icons/Code';
import Tooltip from '@material-ui/core/Tooltip';
import AddAccountDialog from './AddAccountDialog';
const useStyles = makeStyles((theme) => ({
content: {
@ -125,16 +126,31 @@ function NetworkSelector() {
}
function WalletSelector() {
const { addresses, walletIndex, setWalletIndex } = useWalletSelector();
const { accounts, setWalletSelector, addAccount } = useWalletSelector();
const [anchorEl, setAnchorEl] = useState(null);
const [addAccountOpen, setAddAccountOpen] = useState(false);
const classes = useStyles();
if (addresses.length === 0) {
if (accounts.length === 0) {
return null;
}
return (
<>
<AddAccountDialog
open={addAccountOpen}
onClose={() => setAddAccountOpen(false)}
onAdd={({ name, importedAccount }) => {
addAccount({ name, importedAccount });
setWalletSelector({
walletIndex: importedAccount ? undefined : accounts.length,
importedPubkey: importedAccount
? importedAccount.publicKey.toString()
: undefined,
});
setAddAccountOpen(false);
}}
/>
<Hidden xsDown>
<Button
color="inherit"
@ -161,32 +177,38 @@ function WalletSelector() {
}}
getContentAnchorEl={null}
>
{addresses.map((address, index) => (
{accounts.map(({ isSelected, selector, address, name, label }) => (
<MenuItem
key={address.toBase58()}
onClick={() => {
setAnchorEl(null);
setWalletIndex(index);
setWalletSelector(selector);
}}
selected={index === walletIndex}
selected={isSelected}
component="div"
>
<ListItemIcon className={classes.menuItemIcon}>
{index === walletIndex ? <CheckIcon fontSize="small" /> : null}
{isSelected ? <CheckIcon fontSize="small" /> : null}
</ListItemIcon>
{address.toBase58()}
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Typography>{name}</Typography>
<Typography color="textSecondary">
{address.toBase58()}
</Typography>
</div>
</MenuItem>
))}
<Divider />
<MenuItem
onClick={() => {
setAnchorEl(null);
setWalletIndex(addresses.length);
setAddAccountOpen(true);
}}
>
<ListItemIcon className={classes.menuItemIcon}>
<AddIcon fontSize="small" />
</ListItemIcon>
Create Account
Add Account
</MenuItem>
</Menu>
</>

View File

@ -5,7 +5,7 @@ import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import TextField from '@material-ui/core/TextField';
import DialogForm from './DialogForm';
import { useWallet } from '../utils/wallet';
import { useWallet, useWalletAddressForMint } from '../utils/wallet';
import { PublicKey } from '@solana/web3.js';
import { abbreviateAddress } from '../utils/utils';
import InputAdornment from '@material-ui/core/InputAdornment';
@ -28,10 +28,20 @@ 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 { parseTokenAccountData } from '../utils/tokens/data';
const WUSDC_MINT = new PublicKey(
'BXXkv6z8ykpG1yuvUDPgh732wzVHB69RnB9YgSYh3itW',
);
const USDC_MINT = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');
export default function SendDialog({ open, onClose, publicKey, balanceInfo }) {
const isProdNetwork = useIsProdNetwork();
const [tab, setTab] = useState(0);
const [tab, setTab] = useState('spl');
const onSubmitRef = useRef();
const [swapCoinInfo] = useSwapApiGet(
@ -49,6 +59,7 @@ export default function SendDialog({ open, onClose, publicKey, balanceInfo }) {
open={open}
onClose={onClose}
onSubmit={() => onSubmitRef.current()}
fullWidth
>
<DialogTitle>
Send {tokenName ?? abbreviateAddress(mint)}
@ -62,23 +73,52 @@ export default function SendDialog({ open, onClose, publicKey, balanceInfo }) {
textColor="primary"
indicatorColor="primary"
>
<Tab label={`SPL ${swapCoinInfo.ticker}`} />
<Tab
label={`${swapCoinInfo.erc20Contract ? 'ERC20' : 'Native'} ${
swapCoinInfo.ticker
}`}
/>
{mint?.equals(WUSDC_MINT)
? [
<Tab label="SPL WUSDC" key="spl" value="spl" />,
<Tab
label="SPL USDC"
key="wusdcToSplUsdc"
value="wusdcToSplUsdc"
/>,
<Tab label="ERC20 USDC" key="swap" value="swap" />,
]
: [
<Tab
label={`SPL ${swapCoinInfo.ticker}`}
key="spl"
value="spl"
/>,
<Tab
label={`${
swapCoinInfo.erc20Contract ? 'ERC20' : 'Native'
} ${swapCoinInfo.ticker}`}
key="swap"
value="swap"
/>,
]}
</Tabs>
) : null}
{tab === 0 ? (
{tab === 'spl' ? (
<SendSplDialog
onClose={onClose}
publicKey={publicKey}
balanceInfo={balanceInfo}
onSubmitRef={onSubmitRef}
/>
) : tab === 'wusdcToSplUsdc' ? (
<SendSwapDialog
key={tab}
onClose={onClose}
publicKey={publicKey}
balanceInfo={balanceInfo}
swapCoinInfo={swapCoinInfo}
onSubmitRef={onSubmitRef}
wusdcToSplUsdc
/>
) : (
<SendSwapDialog
key={tab}
onClose={onClose}
publicKey={publicKey}
balanceInfo={balanceInfo}
@ -97,15 +137,60 @@ 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 wallet = useWallet();
const [sendTransaction, sending] = useSendTransaction();
const [addressHelperText, setAddressHelperText] = useState(
defaultAddressHelperText,
);
const [passValidation, setPassValidation] = useState();
const {
fields,
destinationAddress,
transferAmountString,
validAmount,
} = useForm(balanceInfo);
const { decimals } = balanceInfo;
} = useForm(balanceInfo, addressHelperText, passValidation);
const { decimals, mint } = balanceInfo;
const mintString = mint && mint.toBase58();
useEffect(() => {
(async () => {
if (!destinationAddress) {
setAddressHelperText(defaultAddressHelperText);
setPassValidation(undefined);
return;
}
try {
const destinationAccountInfo = await wallet.connection.getAccountInfo(
new PublicKey(destinationAddress),
);
if (destinationAccountInfo.owner.equals(TOKEN_PROGRAM_ID)) {
const accountInfo = parseTokenAccountData(
destinationAccountInfo.data,
);
if (accountInfo.mint.toBase58() === mintString) {
setPassValidation(true);
setAddressHelperText('Address is a valid SPL token address');
} else {
setPassValidation(false);
setAddressHelperText('Destination address mint does not match');
}
} else {
setPassValidation(true);
setAddressHelperText('Destination is a Solana address');
}
} catch (e) {
console.log(`Received error validating address ${e}`);
setAddressHelperText(defaultAddressHelperText);
setPassValidation(undefined);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [destinationAddress, wallet, mintString]);
async function makeTransaction() {
let amount = Math.round(parseFloat(transferAmountString) * 10 ** decimals);
@ -116,6 +201,7 @@ function SendSplDialog({ onClose, publicKey, balanceInfo, onSubmitRef }) {
publicKey,
new PublicKey(destinationAddress),
amount,
balanceInfo.mint,
);
}
@ -146,6 +232,7 @@ function SendSwapDialog({
balanceInfo,
swapCoinInfo,
ethAccount,
wusdcToSplUsdc = false,
onSubmitRef,
}) {
const wallet = useWallet();
@ -159,9 +246,12 @@ function SendSwapDialog({
validAmount,
} = useForm(balanceInfo);
const { tokenName, decimals } = balanceInfo;
const blockchain =
swapCoinInfo.blockchain === 'sol' ? 'eth' : swapCoinInfo.blockchain;
const { tokenName, decimals, mint } = balanceInfo;
const blockchain = wusdcToSplUsdc
? 'sol'
: swapCoinInfo.blockchain === 'sol'
? 'eth'
: swapCoinInfo.blockchain;
const needMetamask = blockchain === 'eth';
useEffect(() => {
@ -170,17 +260,34 @@ function SendSwapDialog({
}
}, [blockchain, ethAccount, setDestinationAddress]);
let splUsdcWalletAddress = useWalletAddressForMint(
wusdcToSplUsdc ? USDC_MINT : null,
);
useEffect(() => {
if (wusdcToSplUsdc && splUsdcWalletAddress) {
setDestinationAddress(splUsdcWalletAddress);
}
}, [setDestinationAddress, wusdcToSplUsdc, splUsdcWalletAddress]);
async function makeTransaction() {
let amount = Math.round(parseFloat(transferAmountString) * 10 ** decimals);
if (!amount || amount <= 0) {
throw new Error('Invalid amount');
}
const swapInfo = await swapApiRequest('POST', 'swap_to', {
const params = {
blockchain,
coin: swapCoinInfo.erc20Contract,
address: destinationAddress,
size: amount / 10 ** decimals,
});
};
if (blockchain === 'sol') {
params.coin = swapCoinInfo.splMint;
} else if (blockchain === 'eth') {
params.coin = swapCoinInfo.erc20Contract;
}
if (mint?.equals(WUSDC_MINT)) {
params.wusdcToUsdc = true;
}
const swapInfo = await swapApiRequest('POST', 'swap_to', params);
if (swapInfo.blockchain !== 'sol') {
throw new Error('Unexpected blockchain');
}
@ -188,6 +295,7 @@ function SendSwapDialog({
publicKey,
new PublicKey(swapInfo.address),
amount,
balanceInfo.mint,
swapInfo.memo,
);
}
@ -203,6 +311,7 @@ function SendSwapDialog({
key={signature}
publicKey={publicKey}
signature={signature}
blockchain={blockchain}
onClose={onClose}
/>
);
@ -213,7 +322,11 @@ function SendSwapDialog({
<DialogContent style={{ paddingTop: 16 }}>
<DialogContentText>
SPL {tokenName} can be converted to{' '}
{swapCoinInfo.erc20Contract ? 'ERC20' : 'native'}{' '}
{blockchain === 'eth' && swapCoinInfo.erc20Contract
? 'ERC20'
: blockchain === 'sol' && swapCoinInfo.splMint
? 'SPL'
: 'native'}{' '}
{swapCoinInfo.ticker}
{needMetamask ? ' via MetaMask' : null}.
</DialogContentText>
@ -233,7 +346,7 @@ function SendSwapDialog({
);
}
function SendSwapProgress({ publicKey, signature, onClose }) {
function SendSwapProgress({ publicKey, signature, onClose, blockchain }) {
const connection = useConnection();
const [swaps] = useSwapApiGet(`swaps_from/sol/${publicKey.toBase58()}`, {
refreshInterval: 1000,
@ -255,6 +368,8 @@ function SendSwapProgress({ publicKey, signature, onClose }) {
if (withdrawal.txid?.startsWith('0x')) {
step = 3;
ethTxid = withdrawal.txid;
} else if (withdrawal.txid && blockchain !== 'eth') {
step = 3;
} else {
step = 2;
}
@ -285,7 +400,7 @@ function SendSwapProgress({ publicKey, signature, onClose }) {
View on Etherscan
</Link>
</Typography>
) : (
) : step < 3 ? (
<div
style={{
display: 'flex',
@ -302,7 +417,13 @@ function SendSwapProgress({ publicKey, signature, onClose }) {
<Typography>Transaction Pending</Typography>
)}
</div>
)}
) : null}
{!ethTxid && blockchain === 'eth' ? (
<DialogContentText style={{ marginTop: 16, marginBottom: 0 }}>
Please keep this window open. You will need to approve the request
on MetaMask to complete the transaction.
</DialogContentText>
) : null}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
@ -311,7 +432,7 @@ function SendSwapProgress({ publicKey, signature, onClose }) {
);
}
function useForm(balanceInfo) {
function useForm(balanceInfo, addressHelperText, passAddressValidation) {
const [destinationAddress, setDestinationAddress] = useState('');
const [transferAmountString, setTransferAmountString] = useState('');
const { amount: balanceAmount, decimals, tokenSymbol } = balanceInfo;
@ -328,6 +449,13 @@ function useForm(balanceInfo) {
margin="normal"
value={destinationAddress}
onChange={(e) => setDestinationAddress(e.target.value.trim())}
helperText={addressHelperText}
id={
!passAddressValidation && passAddressValidation !== undefined
? 'outlined-error-helper-text'
: undefined
}
error={!passAddressValidation && passAddressValidation !== undefined}
/>
<TextField
label="Amount"

View File

@ -1,5 +1,11 @@
import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
import {
PublicKey,
SystemProgram,
Transaction,
Account,
} from '@solana/web3.js';
import {
assertOwner,
closeAccount,
initializeAccount,
initializeMint,
@ -8,7 +14,12 @@ import {
TOKEN_PROGRAM_ID,
transfer,
} from './instructions';
import { ACCOUNT_LAYOUT, getOwnedAccountsFilters, MINT_LAYOUT } from './data';
import {
ACCOUNT_LAYOUT,
getOwnedAccountsFilters,
MINT_LAYOUT,
parseTokenAccountData,
} from './data';
import bs58 from 'bs58';
export async function getOwnedTokenAccounts(connection, publicKey) {
@ -157,6 +168,61 @@ export async function transferTokens({
destinationPublicKey,
amount,
memo,
mint,
}) {
const destinationAccountInfo = await connection.getAccountInfo(
destinationPublicKey,
);
if (!!destinationAccountInfo && destinationAccountInfo.owner.equals(TOKEN_PROGRAM_ID)) {
return await transferBetweenSplTokenAccounts({
connection,
owner,
sourcePublicKey,
destinationPublicKey,
amount,
memo,
});
}
if (!destinationAccountInfo || destinationAccountInfo.lamports === 0) {
throw new Error('Cannot send to address with zero SOL balances');
}
const destinationSplTokenAccount = (
await getOwnedTokenAccounts(connection, destinationPublicKey)
)
.map(({ publicKey, accountInfo }) => {
return { publicKey, parsed: parseTokenAccountData(accountInfo.data) };
})
.filter(({ parsed }) => parsed.mint.equals(mint))
.sort((a, b) => {
return b.parsed.amount - a.parsed.amount;
})[0];
if (destinationSplTokenAccount) {
return await transferBetweenSplTokenAccounts({
connection,
owner,
sourcePublicKey,
destinationPublicKey: destinationSplTokenAccount.publicKey,
amount,
memo,
});
}
return await createAndTransferToAccount({
connection,
owner,
sourcePublicKey,
destinationPublicKey,
amount,
memo,
mint,
});
}
function createTransferBetweenSplTokenAccountsInstruction({
owner,
sourcePublicKey,
destinationPublicKey,
amount,
memo,
}) {
let transaction = new Transaction().add(
transfer({
@ -169,12 +235,81 @@ export async function transferTokens({
if (memo) {
transaction.add(memoInstruction(memo));
}
return transaction;
}
async function transferBetweenSplTokenAccounts({
connection,
owner,
sourcePublicKey,
destinationPublicKey,
amount,
memo,
}) {
const transaction = createTransferBetweenSplTokenAccountsInstruction({
owner,
sourcePublicKey,
destinationPublicKey,
amount,
memo,
});
let signers = [owner];
return await connection.sendTransaction(transaction, signers, {
preflightCommitment: 'single',
});
}
async function createAndTransferToAccount({
connection,
owner,
sourcePublicKey,
destinationPublicKey,
amount,
memo,
mint,
}) {
const newAccount = new Account();
let transaction = new Transaction();
transaction.add(
assertOwner({
account: destinationPublicKey,
owner: SystemProgram.programId,
}),
);
transaction.add(
SystemProgram.createAccount({
fromPubkey: owner.publicKey,
newAccountPubkey: newAccount.publicKey,
lamports: await connection.getMinimumBalanceForRentExemption(
ACCOUNT_LAYOUT.span,
),
space: ACCOUNT_LAYOUT.span,
programId: TOKEN_PROGRAM_ID,
}),
);
transaction.add(
initializeAccount({
account: newAccount.publicKey,
mint,
owner: destinationPublicKey,
}),
);
const transferBetweenAccountsTxn = createTransferBetweenSplTokenAccountsInstruction(
{
owner,
sourcePublicKey,
destinationPublicKey: newAccount.publicKey,
amount,
memo,
},
);
transaction.add(transferBetweenAccountsTxn);
let signers = [owner, newAccount];
return await connection.sendTransaction(transaction, signers, {
preflightCommitment: 'single',
});
}
export async function closeTokenAccount({
connection,
owner,

View File

@ -4,6 +4,7 @@ import {
SYSVAR_RENT_PUBKEY,
TransactionInstruction,
} from '@solana/web3.js';
import { publicKeyLayout } from '@project-serum/serum/lib/layout';
export const TOKEN_PROGRAM_ID = new PublicKey(
'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
@ -150,3 +151,26 @@ export function memoInstruction(memo) {
programId: MEMO_PROGRAM_ID,
});
}
export const OWNER_VALIDATION_PROGRAM_ID = new PublicKey(
'4MNPdKu9wFMvEeZBMt3Eipfs5ovVWTJb31pEXDJAAxX5',
);
export const OWNER_VALIDATION_LAYOUT = BufferLayout.struct([
publicKeyLayout('account'),
]);
export function encodeOwnerValidationInstruction(instruction) {
const b = Buffer.alloc(OWNER_VALIDATION_LAYOUT.span);
const span = OWNER_VALIDATION_LAYOUT.encode(instruction, b);
return b.slice(0, span);
}
export function assertOwner({ account, owner }) {
const keys = [{ pubkey: account, isSigner: false, isWritable: false }];
return new TransactionInstruction({
keys,
data: encodeOwnerValidationInstruction({ account: owner }),
programId: OWNER_VALIDATION_PROGRAM_ID,
});
}

View File

@ -70,10 +70,18 @@ export const TOKENS = {
},
{
tokenSymbol: 'USDC',
mintAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
tokenName: 'USD Coin',
icon:
'https://raw.githubusercontent.com/trustwallet/assets/f3ffd0b9ae2165336279ce2f8db1981a55ce30f8/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png',
},
{
tokenSymbol: 'WUSDC',
mintAddress: 'BXXkv6z8ykpG1yuvUDPgh732wzVHB69RnB9YgSYh3itW',
tokenName: 'Wrapped USDC',
icon:
'https://raw.githubusercontent.com/trustwallet/assets/f3ffd0b9ae2165336279ce2f8db1981a55ce30f8/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png',
deprecated: true,
},
{
tokenSymbol: 'SUSHI',
@ -145,9 +153,21 @@ export const TOKENS = {
'https://raw.githubusercontent.com/trustwallet/assets/08d734b5e6ec95227dc50efef3a9cdfea4c398a1/blockchains/ethereum/assets/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984/logo.png',
},
{
tokenSymbol: 'ALEPH',
mintAddress: 'CsZ5LZkDS7h9TDKjrbL7VAwQZ9nsRu8vJLhRYfmGaN8K',
tokenName: 'Wrapped ALEPH',
tokenSymbol: 'MATH',
mintAddress: 'GeDS162t9yGJuLEHPWXXGrb1zwkzinCgRwnT8vHYjKza',
tokenName: 'Wrapped MATH',
},
{
tokenSymbol: 'TOMO',
mintAddress: 'GXMvfY2jpQctDqZ9RoU3oWPhufKiCcFEfchvYumtX7jd',
tokenName: 'Wrapped TOMO',
icon: "https://raw.githubusercontent.com/trustwallet/assets/08d734b5e6ec95227dc50efef3a9cdfea4c398a1/blockchains/tomochain/info/logo.png"
},
{
tokenSymbol: 'LUA',
mintAddress: 'EqWCKXfs3x47uVosDpTRgFniThL9Y8iCztJaapxbEaVX',
tokenName: 'Wrapped LUA',
icon: 'https://raw.githubusercontent.com/trustwallet/assets/2d2491130e6beda208ba4fc6df028a82a0106ab6/blockchains/ethereum/assets/0xB1f66997A5760428D3a87D68b90BfE0aE64121cC/logo.png',
},
],
};
@ -168,13 +188,11 @@ export function useTokenName(mint) {
}
let info = customTokenNamesByNetwork?.[endpoint]?.[mint.toBase58()];
if (!info) {
let match = TOKENS?.[endpoint]?.find(
(token) => token.mintAddress === mint.toBase58(),
);
if (match) {
info = { name: match.tokenName, symbol: match.tokenSymbol };
}
let match = TOKENS?.[endpoint]?.find(
(token) => token.mintAddress === mint.toBase58(),
);
if (match && (!info || match.deprecated)) {
info = { name: match.tokenName, symbol: match.tokenSymbol };
}
return { name: info?.name, symbol: info?.symbol };
}

View File

@ -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;
}

View File

@ -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) {
@ -69,7 +78,7 @@ export class Wallet {
);
};
transferToken = async (source, destination, amount, memo = null) => {
transferToken = async (source, destination, amount, mint, memo = null) => {
if (source.equals(this.publicKey)) {
if (memo) {
throw new Error('Memo not implemented');
@ -83,6 +92,7 @@ export class Wallet {
destinationPublicKey: destination,
amount,
memo,
mint,
});
};
@ -112,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 (
<WalletContext.Provider
value={{ wallet, walletIndex, setWalletIndex, seed, mnemonic }}
value={{
wallet,
seed,
mnemonic,
importsEncryptionKey,
walletSelector,
setWalletSelector,
privateKeyImports,
setPrivateKeyImports,
}}
>
{children}
</WalletContext.Provider>
@ -165,6 +219,19 @@ export function refreshWalletPublicKeys(wallet) {
refreshCache(wallet.getTokenAccountInfo);
}
export function useWalletAddressForMint(mint) {
const [walletAccounts] = useWalletTokenAccounts();
return useMemo(
() =>
mint
? walletAccounts
?.find((account) => account.parsed?.mint?.equals(mint))
?.publicKey.toBase58()
: null,
[walletAccounts, mint],
);
}
export function useBalanceInfo(publicKey) {
let [accountInfo, accountInfoLoaded] = useAccountInfo(publicKey);
let { mint, owner, amount } = accountInfo?.owner.equals(TOKEN_PROGRAM_ID)
@ -230,31 +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) {
if (walletIndex >= walletCount) {
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 addresses = useMemo(() => {
const accounts = useMemo(() => {
if (!seed) {
return [];
}
const seedBuffer = Buffer.from(seed, 'hex');
return [...Array(walletCount).keys()].map(
(walletIndex) =>
Wallet.getAccountFromSeed(seedBuffer, walletIndex).publicKey,
);
}, [seed, walletCount]);
return { addresses, 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 };
}

View File

@ -1578,12 +1578,12 @@
resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b"
integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
"@project-serum/serum@^0.13.4":
version "0.13.4"
resolved "https://registry.yarnpkg.com/@project-serum/serum/-/serum-0.13.4.tgz#5a7a2d6e418fa986a92c3e9dd3c04776ce3b5f5d"
integrity sha512-fUruHI17rUrrl5DhuLIChGO1fhcJWwkc/GROToMwfsrHGUDA1FX0MOiXfDYQJaXvMJKJg/cMa6aNThShr05tmQ==
"@project-serum/serum@^0.13.11":
version "0.13.11"
resolved "https://registry.yarnpkg.com/@project-serum/serum/-/serum-0.13.11.tgz#c55c830cbfb28cdc2b4e4076b1367d45ad19175d"
integrity sha512-kE9PhXuryP7afJFkZrKkLzNxeRETHgPvN5Jl3BZIDH5oyHLUfmj3OQreMPwIqJXnMkLwCHewG5JFJALwit+3Wg==
dependencies:
"@solana/web3.js" "^0.71.10"
"@solana/web3.js" "0.86.1"
bn.js "^5.1.2"
buffer-layout "^1.2.0"
@ -1597,10 +1597,10 @@
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==
"@solana/web3.js@^0.71.10":
version "0.71.14"
resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-0.71.14.tgz#b21f9613cb2e27defc93264bd894761689d209e3"
integrity sha512-23jWjzMxSOKzcAUzLBaD5p0YRJys6A9cEdWZQtPV/CV7bmo5JNIdPDR+UhzPxe1L3WX3bu7KAmAjcIERCXKDfQ==
"@solana/web3.js@0.86.1":
version "0.86.1"
resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-0.86.1.tgz#034a2cef742569f74dfc9960dfbcabc92e674b08"
integrity sha512-9mjWs17ym7PIm7bHA37wnnYyD7rIVHwkx1RI6BzGhMO5h8E+HlZM8ISLgOx+NItg8XRCfFhlrVgJTzK4om1s0g==
dependencies:
"@babel/runtime" "^7.3.1"
bn.js "^5.0.0"
@ -1610,10 +1610,12 @@
crypto-hash "^1.2.2"
esdoc-inject-style-plugin "^1.0.0"
jayson "^3.0.1"
keccak "^3.0.1"
mz "^2.7.0"
node-fetch "^2.2.0"
npm-run-all "^4.1.5"
rpc-websockets "^7.4.0"
rpc-websockets "^7.4.2"
secp256k1 "^4.0.2"
superstruct "^0.8.3"
tweetnacl "^1.0.0"
ws "^7.0.0"
@ -7599,7 +7601,7 @@ jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3:
array-includes "^3.1.1"
object.assign "^4.1.0"
keccak@^3.0.0:
keccak@^3.0.0, keccak@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/keccak/-/keccak-3.0.1.tgz#ae30a0e94dbe43414f741375cff6d64c8bea0bff"
integrity sha512-epq90L9jlFWCW7+pQa6JOnKn2Xgl2mtI664seYR6MHskvI9agt7AnDqmAlp9TqU4/caMYbA08Hi5DMZAl5zdkA==
@ -10725,21 +10727,6 @@ rlp@^2.2.3:
dependencies:
bn.js "^4.11.1"
rpc-websockets@^7.4.0:
version "7.4.2"
resolved "https://registry.yarnpkg.com/rpc-websockets/-/rpc-websockets-7.4.2.tgz#9e85ca7451e64a2015996c7a361cbff37c3ad597"
integrity sha512-kUpYcnbEU/BeAxGTlfySZ/tp9FU+TLSgONbViyx6hQsIh8876uxggJWzVOCe+CztBvuCOAOd0BXyPlKfcflykw==
dependencies:
"@babel/runtime" "^7.11.2"
assert-args "^1.2.1"
circular-json "^0.5.9"
eventemitter3 "^4.0.7"
uuid "^8.3.0"
ws "^7.3.1"
optionalDependencies:
bufferutil "^4.0.1"
utf-8-validate "^5.0.2"
rpc-websockets@^7.4.2:
version "7.4.6"
resolved "https://registry.yarnpkg.com/rpc-websockets/-/rpc-websockets-7.4.6.tgz#a0053ad36e893774cdd0edb72ac577deaf34f247"
@ -10875,7 +10862,7 @@ scrypt-js@^3.0.0, scrypt-js@^3.0.1:
resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312"
integrity sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==
secp256k1@^4.0.1:
secp256k1@^4.0.1, secp256k1@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-4.0.2.tgz#15dd57d0f0b9fdb54ac1fa1694f40e5e9a54f4a1"
integrity sha512-UDar4sKvWAksIlfX3xIaQReADn+WFnHvbVujpcbr+9Sf/69odMwy2MUsz5CKLQgX9nsIyrjuxL2imVyoNHa3fg==