Ledger support (#54)

* feat: add https for local development

* feat: add support for ledger

* feat: add ledger support

* feat: add ledger support

* feat: add ledger support

* feat: add ledger support

* feat: ledger support

* feat: add leadger support

* feat: ledger support

* Some updates

* Reset back to master

* Revert login page logic

* Fix various things

* working

* some updates

* Fix unlock error silent failing

* dapp signatures working

* touch up

Co-authored-by: Bartosz Lipinski <264380+bartosz-lipinski@users.noreply.github.com>
This commit is contained in:
Nathaniel Parke 2020-12-02 17:15:13 +08:00 committed by GitHub
parent 32358afde2
commit f22090c503
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 604 additions and 123 deletions

27
.cert/cert.pem Normal file
View File

@ -0,0 +1,27 @@
-----BEGIN CERTIFICATE-----
MIIEozCCAwugAwIBAgIRALVgQ4iLzJxtipCRuRZ9FWMwDQYJKoZIhvcNAQELBQAw
gbkxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTFHMEUGA1UECww+YmFy
dG9zei5saXBpbnNraUBCYXJ0b3N6cy1NYWNCb29rLVByby5sb2NhbCAoQmFydG9z
eiBMaXBpbnNraSkxTjBMBgNVBAMMRW1rY2VydCBiYXJ0b3N6LmxpcGluc2tpQEJh
cnRvc3pzLU1hY0Jvb2stUHJvLmxvY2FsIChCYXJ0b3N6IExpcGluc2tpKTAeFw0x
OTA2MDEwMDAwMDBaFw0zMDEwMTgwMjU2MzhaMHIxJzAlBgNVBAoTHm1rY2VydCBk
ZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTFHMEUGA1UECww+YmFydG9zei5saXBpbnNr
aUBCYXJ0b3N6cy1NYWNCb29rLVByby5sb2NhbCAoQmFydG9zeiBMaXBpbnNraSkw
ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDOwT5doIalTreoX71W6TLr
V2tijuSHLRmIDcDutPM/cPYNUHgkGthZveMdrcoaDqveHZdGjWY39U+8kzrdcbV2
7oXNd4ivC5acS8DIJNCO3G1JcNSYnxmZXEaPAHXaVke+SMXVTWbUvA8Rkyor9hPe
KW8gtFqm2IT/klRBfWuYLO24dILrCfYkqJkZ6g++X7pBp1R/8h9SYdHWbHxIDk2d
CWaHNA7v8g1bMw2ZmxICwgbsARplLgIU/ZWRKQik2axOIeHDpoeV9/hj4SXvs1bA
yvO8oNMjYuekkAs117NAfJb9oR6p/iet39IfzY4zQmBqwFDuAu7+nZq1NfFLajor
AgMBAAGjbDBqMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAM
BgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFLRNFQImfwsJQApnvaPukIsWedjpMBQG
A1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAYEAgm1tjv0cRoBR
lBzIajECMHVK1dHYARaaFrG4ll1DpWS38Cyn2FN/YIIdTrzh8vFIkV/Leeozcjp8
nE84zziw9nYvX86SfKtv8uVaVLPoNm2hl9JQS/19dMrB25vpeDGJqmnF3/n7oVEC
SY6fY2xLMx47tpnT5P9NuXgP6Zz6KVQp+CPEfoIkTo+dU0Kk67K5Q9OR42SyiRG2
JxSBymbPV/mHwOxAS2M6QMODPt+FpVYeiz+iM6d1lL2NGs2CnyBFaFLNlmxij9yL
rZoU+Om5LrhgY5CL7/DVkU6xZC0VI9AZvV3eV5ouPv4ofH47BSOkxwLj5V4xFGTf
+1YRuvqE12EecBpz5g/33LzrkipA7G6P1Oca38g7Xkv6+XKALXIZ2dtcXUp7s7Ty
Cf6Nydlgeqe6Ik6+OKgWIwCahr2cWWVKC/JFMqOYugD4dYcyvbv+V06YEQvYJP5B
yhVdn4uVAfrGGFUCRm9ZM3EhSPZlNwVcG6l5jaV/48L+Uy32VGvJ
-----END CERTIFICATE-----

28
.cert/key.pem Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDOwT5doIalTreo
X71W6TLrV2tijuSHLRmIDcDutPM/cPYNUHgkGthZveMdrcoaDqveHZdGjWY39U+8
kzrdcbV27oXNd4ivC5acS8DIJNCO3G1JcNSYnxmZXEaPAHXaVke+SMXVTWbUvA8R
kyor9hPeKW8gtFqm2IT/klRBfWuYLO24dILrCfYkqJkZ6g++X7pBp1R/8h9SYdHW
bHxIDk2dCWaHNA7v8g1bMw2ZmxICwgbsARplLgIU/ZWRKQik2axOIeHDpoeV9/hj
4SXvs1bAyvO8oNMjYuekkAs117NAfJb9oR6p/iet39IfzY4zQmBqwFDuAu7+nZq1
NfFLajorAgMBAAECggEBAMuSWeW1+N0q9IpEOhko44n1OTaBm2G9djYP1Lc0U41T
m/DgGmryQ7OY09aVFzkw2OiKGjjNYKgYUbpK/Nqs6w9/Kx9zYpF3x4N80wQ9u1vu
jWySO8FKZdoqkQ6cVW31Jg6leKTc4TL1N6EGVa+TS1yjT1fUPK2q4skBOxSAeUAK
t5OvL5eeuumu+Ya3nrwQ1wp1ZBWhkeDIGjED8SDPfT0kU4v8dKTJcXk7ooF5s1OZ
H6HSzEej8eZSfBLDTwPsHylaGTcTi9Tgi8CnjAVdBLaFJlS+bkvv7m5/jnuEpofX
HkyAW5LDnIvHLycE3Ql9BQsR/DF9uzQvn9VRwPGQm4ECgYEA6/eyWhQfl4rDfjQi
fzQgjEYnTbE69R3LUUKulUQQeBYcZO2JAAGZ45G6lZ1zEWFmXuj0aQRYpj798cTg
0ZyH7Dllae8GPZlQ0u/Pxp03DzrvNGbHiTEBYqyVB+gJFn9Robm1eG+kP3QVgaIq
iOXwnUGL74MaJKdwi9ruhzLaQwUCgYEA4E6qJwkBRkGfpjSc0stwTEd+04nSPU1u
V3jFkxS/MNHCF/FUOGBKvrEbbedZ4DRiHjmQ6+zsvl+5jW75bw2rrxxxjkvpNjZe
BOKhac7c+jHXEAm4xuP6lUnPOenBhy/AvOJMm+6wzxdvTzp2gfnfIUOld24bdSXP
29yOzOpYb28CgYAlDn0f0FE1x0D0LNPODi2eWdYKSW7s14T6efJY1puPgEltQDBn
o9i6+EPJAzTy4czl0sevRlN1qCbRNQ3pXR+rZUgb3sGoIs+ikK6cjkv7RFIUdJ+Z
V+zTxi6RU0s6ETyMnVF2XHH61Qwbk5ACd7nVuFl1f603XGQ8UmFrMf080QKBgQDQ
a2eg87YScOGGDvb0ywFib0BCEJqgSXVQo7B5lNp94zmFA8EszRRGkcwZ19DkCeht
izHEdhYYYlvINihg7wPqpvRAsvpUXDoKMganiQY9F9hsV4wwih8JXlbFyhT/pvhg
yalDbostMepEZN8+sE2K3A9ApLewp1y3Pv4VG17m0wKBgB7GIrI2Jx5CG4yFoM8y
kB5rbvNlGAER58AMhzMebs7e2pQg0GxMpaNwqSZyVG0kLq7ayXn9ExA+uQpAG6cR
fnBWVflYf0hKIC1M6U9kOle6lZ/Nbdo80wGa5z3Ieup6w9WB0pbtifvbsnv6RLeA
S7MNQDcNXJxQulqppa+1BOTl
-----END PRIVATE KEY-----

15
.editorconfig Normal file
View File

@ -0,0 +1,15 @@
root = true
[*]
insert_final_newline = true
[*.{js}]
charset = utf-8
[src/**.js]
indent_style = space
indent_size = 2
[{package.json,.travis.yml}]
indent_style = space
indent_size = 2

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
HTTPS=true
SSL_CRT_FILE=./.cert/cert.pem SSL_KEY_FILE=./.cert/key.pem

View File

@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@ledgerhq/hw-transport-webusb": "^5.34.0",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@project-serum/serum": "^0.13.11",

View File

@ -38,16 +38,17 @@ export default function App() {
<Suspense fallback={<LoadingIndicator />}>
<ThemeProvider theme={theme}>
<CssBaseline />
<ConnectionProvider>
<WalletProvider>
<SnackbarProvider maxSnack={5} autoHideDuration={8000}>
<SnackbarProvider maxSnack={5} autoHideDuration={8000}>
<WalletProvider>
<NavigationFrame>
<Suspense fallback={<LoadingIndicator />}>
<PageContents />
</Suspense>
</NavigationFrame>
</SnackbarProvider>
</WalletProvider>
</WalletProvider>
</SnackbarProvider>
</ConnectionProvider>
</ThemeProvider>
</Suspense>

View File

@ -0,0 +1,82 @@
import React, {useEffect, 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 DialogForm from './DialogForm';
import {LedgerWalletProvider} from "../utils/walletProvider/ledger";
import CircularProgress from "@material-ui/core/CircularProgress";
import {useSnackbar} from "notistack";
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])
return (
<DialogForm
open={open}
onEnter={() => {}}
onClose={() => {
setPubKey(undefined);
onClose();
}}
onSubmit={() => {
setPubKey(undefined);
onAdd(pubKey);
onClose();
}}
fullWidth
>
<DialogTitle>Add hardware wallet</DialogTitle>
<DialogContent style={{ paddingTop: 16 }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
{pubKey
? (
<>
<b>Hardware wallet detected:</b>
<div>{pubKey.toString()}</div>
</>
)
: (
<>
<b>Connect your ledger and open the Solana application</b>
<CircularProgress />
</>
)
}
</div>
</DialogContent>
<DialogActions>
<Button onClick={() => {
setPubKey(undefined);
onClose();
}}>Close</Button>
<Button type="submit" color="primary" disabled={!pubKey}>
Add
</Button>
</DialogActions>
</DialogForm>
);
}

View File

@ -184,6 +184,7 @@ function BalanceListItemDetails({ publicKey, balanceInfo }) {
closeTokenAccountDialogOpen,
setCloseTokenAccountDialogOpen,
] = useState(false);
const wallet = useWallet()
if (!balanceInfo) {
return <LoadingIndicator delay={0} />;
@ -197,10 +198,12 @@ function BalanceListItemDetails({ publicKey, balanceInfo }) {
return (
<>
<ExportAccountDialog
onClose={() => setExportAccDialogOpen(false)}
open={exportAccDialogOpen}
/>
{wallet.allowsExport &&
<ExportAccountDialog
onClose={() => setExportAccDialogOpen(false)}
open={exportAccDialogOpen}
/>
}
<div className={classes.itemDetails}>
<div className={classes.buttonContainer}>
{!publicKey.equals(owner) && showTokenInfoDialog ? (
@ -270,7 +273,7 @@ function BalanceListItemDetails({ publicKey, balanceInfo }) {
</Link>
</Typography>
</div>
{exportNeedsDisplay && (
{exportNeedsDisplay && wallet.allowsExport && (
<div>
<Typography variant="body2">
<Link href={'#'} onClick={(e) => setExportAccDialogOpen(true)}>

View File

@ -20,7 +20,7 @@ export default function DebugButtons() {
const wallet = useWallet();
const updateTokenName = useUpdateTokenName();
const { endpoint } = useConnectionConfig();
const balanceInfo = useBalanceInfo(wallet.account.publicKey);
const balanceInfo = useBalanceInfo(wallet.publicKey);
const [sendTransaction, sending] = useSendTransaction();
const callAsync = useCallAsync();
@ -29,13 +29,13 @@ export default function DebugButtons() {
function requestAirdrop() {
callAsync(
wallet.connection.requestAirdrop(
wallet.account.publicKey,
wallet.publicKey,
LAMPORTS_PER_SOL,
),
{
onSuccess: async () => {
await sleep(5000);
refreshAccountInfo(wallet.connection, wallet.account.publicKey);
refreshAccountInfo(wallet.connection, wallet.publicKey);
},
successMessage:
'Success! Please wait up to 30 seconds for the SOL tokens to appear in your wallet.',
@ -53,7 +53,7 @@ export default function DebugButtons() {
sendTransaction(
createAndInitializeMint({
connection: wallet.connection,
owner: wallet.account,
owner: wallet,
mint,
amount: 1000,
decimals: 2,

View File

@ -24,7 +24,7 @@ export default function ExportAccountDialog({ open, onClose }) {
type={isHidden && 'password'}
variant="outlined"
margin="normal"
value={bs58.encode(wallet.account.secretKey)}
value={bs58.encode(wallet.provider.account.secretKey)}
/>
<FormControlLabel
control={

View File

@ -14,6 +14,7 @@ import CheckIcon from '@material-ui/icons/Check';
import AddIcon from '@material-ui/icons/Add';
import ExitToApp from '@material-ui/icons/ExitToApp';
import AccountIcon from '@material-ui/icons/AccountCircle';
import UsbIcon from '@material-ui/icons/Usb';
import Divider from '@material-ui/core/Divider';
import Hidden from '@material-ui/core/Hidden';
import IconButton from '@material-ui/core/IconButton';
@ -22,6 +23,7 @@ import CodeIcon from '@material-ui/icons/Code';
import Tooltip from '@material-ui/core/Tooltip';
import AddAccountDialog from './AddAccountDialog';
import DeleteAccountDialog from "./DeleteAccountDialog";
import AddHardwareWalletDialog from "./AddHarwareWalletDialog";
const useStyles = makeStyles((theme) => ({
content: {
@ -131,6 +133,7 @@ function WalletSelector() {
const { accounts, setWalletSelector, addAccount } = useWalletSelector();
const [anchorEl, setAnchorEl] = useState(null);
const [addAccountOpen, setAddAccountOpen] = useState(false);
const [addHardwareWalletDialogOpen, setAddHardwareWalletDialogOpen] = useState(false);
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
const [isDeleteAccountEnabled, setIsDeleteAccountEnabled] = useState(false);
const classes = useStyles();
@ -141,6 +144,18 @@ function WalletSelector() {
return (
<>
<AddHardwareWalletDialog
open={addHardwareWalletDialogOpen}
onClose={() => setAddHardwareWalletDialogOpen(false)}
onAdd={(pubKey) => {
addAccount({ name: 'Hardware wallet', importedAccount: pubKey.toString(), ledger: true });
setWalletSelector({
walletIndex: undefined,
importedPubkey: pubKey.toString(),
ledger: true
});
}}
/>
<AddAccountDialog
open={addAccountOpen}
onClose={() => setAddAccountOpen(false)}
@ -151,6 +166,7 @@ function WalletSelector() {
importedPubkey: importedAccount
? importedAccount.publicKey.toString()
: undefined,
ledger: false,
});
setAddAccountOpen(false);
}}
@ -208,6 +224,14 @@ function WalletSelector() {
</MenuItem>
))}
<Divider />
<MenuItem
onClick={() => setAddHardwareWalletDialogOpen(true)}
>
<ListItemIcon className={classes.menuItemIcon}>
<UsbIcon fontSize="small" />
</ListItemIcon>
Import Hardware Wallet
</MenuItem>
<MenuItem
onClick={() => {
setAnchorEl(null);

View File

@ -72,7 +72,7 @@ export default function PopupPage({ opener }) {
useEffect(() => {
if (
connectedAccount &&
!connectedAccount.publicKey.equals(wallet.publicKey)
!connectedAccount.equals(wallet.publicKey)
) {
setConnectedAccount(null);
}
@ -95,11 +95,11 @@ export default function PopupPage({ opener }) {
if (
!connectedAccount ||
!connectedAccount.publicKey.equals(wallet.publicKey)
!connectedAccount.equals(wallet.publicKey)
) {
// Approve the parent page to connect to this wallet.
function connect(autoApprove) {
setConnectedAccount(wallet.account);
setConnectedAccount(wallet.publicKey);
postMessage({
method: 'connected',
params: { publicKey: wallet.publicKey.toBase58(), autoApprove },
@ -116,13 +116,11 @@ export default function PopupPage({ opener }) {
assert(request.method === 'signTransaction');
const message = bs58.decode(request.params.message);
function sendSignature() {
async function sendSignature() {
setRequests((requests) => requests.slice(1));
postMessage({
result: {
signature: bs58.encode(
nacl.sign.detached(message, wallet.account.secretKey),
),
signature: await wallet.createSignature(message),
publicKey: wallet.publicKey.toBase58(),
},
id: request.id,

View File

@ -39,7 +39,8 @@ export function useIsProdNetwork() {
}
export function useSolanaExplorerUrlSuffix() {
const endpoint = useContext(ConnectionContext).endpoint;
const context = useContext(ConnectionContext);
const endpoint = context.endpoint;
if (endpoint === clusterApiUrl('devnet')) {
return '?cluster=devnet';
} else if (endpoint === clusterApiUrl('testnet')) {

View File

@ -34,9 +34,9 @@ export async function getOwnedTokenAccounts(connection, publicKey) {
if (resp.error) {
throw new Error(
'failed to get token accounts owned by ' +
publicKey.toBase58() +
': ' +
resp.error.message,
publicKey.toBase58() +
': ' +
resp.error.message,
);
}
return resp.result
@ -68,9 +68,39 @@ export async function getOwnedTokenAccounts(connection, publicKey) {
});
}
export async function signAndSendTransaction(connection, transaction, wallet, signers) {
transaction.recentBlockhash = (await connection.getRecentBlockhash('max')).blockhash;
transaction.setSigners(
// fee payed by the wallet owner
wallet.publicKey,
...signers.map(s => s.publicKey)
);
if (signers.length > 0) {
transaction.partialSign(...signers);
}
transaction = await wallet.signTransaction(transaction);
const rawTransaction = transaction.serialize();
return await connection.sendRawTransaction(rawTransaction, {
preflightCommitment: 'single',
});
}
export async function nativeTransfer(connection, wallet, destination, amount) {
const tx = new Transaction().add(
SystemProgram.transfer({
fromPubkey: wallet.publicKey,
toPubkey: destination,
lamports: amount,
}),
);
return await signAndSendTransaction(connection, tx, wallet, []);
}
export async function createAndInitializeMint({
connection,
owner, // Account for paying fees and allowed to mint new tokens
owner, // Wallet for paying fees and allowed to mint new tokens
mint, // Account to hold token information
amount, // Number of tokens to issue
decimals,
@ -95,7 +125,7 @@ export async function createAndInitializeMint({
mintAuthority: owner.publicKey,
}),
);
let signers = [owner, mint];
let signers = [mint];
if (amount > 0) {
transaction.add(
SystemProgram.createAccount({
@ -125,9 +155,8 @@ export async function createAndInitializeMint({
}),
);
}
return await connection.sendTransaction(transaction, signers, {
preflightCommitment: 'single',
});
return await signAndSendTransaction(connection, transaction, owner, signers);
}
export async function createAndInitializeTokenAccount({
@ -155,10 +184,9 @@ export async function createAndInitializeTokenAccount({
owner: payer.publicKey,
}),
);
let signers = [payer, newAccount];
return await connection.sendTransaction(transaction, signers, {
preflightCommitment: 'single',
});
let signers = [newAccount];
return await signAndSendTransaction(connection, transaction, payer, signers);
}
export async function transferTokens({
@ -218,7 +246,7 @@ export async function transferTokens({
}
function createTransferBetweenSplTokenAccountsInstruction({
owner,
ownerPublicKey,
sourcePublicKey,
destinationPublicKey,
amount,
@ -228,7 +256,7 @@ function createTransferBetweenSplTokenAccountsInstruction({
transfer({
source: sourcePublicKey,
destination: destinationPublicKey,
owner: owner.publicKey,
owner: ownerPublicKey,
amount,
}),
);
@ -247,16 +275,14 @@ async function transferBetweenSplTokenAccounts({
memo,
}) {
const transaction = createTransferBetweenSplTokenAccountsInstruction({
owner,
ownerPublicKey: owner.publicKey,
sourcePublicKey,
destinationPublicKey,
amount,
memo,
});
let signers = [owner];
return await connection.sendTransaction(transaction, signers, {
preflightCommitment: 'single',
});
let signers = [];
return await signAndSendTransaction(connection, transaction, owner, signers)
}
async function createAndTransferToAccount({
@ -296,7 +322,7 @@ async function createAndTransferToAccount({
);
const transferBetweenAccountsTxn = createTransferBetweenSplTokenAccountsInstruction(
{
owner,
ownerPublicKey: owner.publicKey,
sourcePublicKey,
destinationPublicKey: newAccount.publicKey,
amount,
@ -304,10 +330,8 @@ async function createAndTransferToAccount({
},
);
transaction.add(transferBetweenAccountsTxn);
let signers = [owner, newAccount];
return await connection.sendTransaction(transaction, signers, {
preflightCommitment: 'single',
});
let signers = [newAccount];
return await signAndSendTransaction(connection, transaction, owner, signers)
}
export async function closeTokenAccount({
@ -322,8 +346,6 @@ export async function closeTokenAccount({
owner: owner.publicKey,
}),
);
let signers = [owner];
return await connection.sendTransaction(transaction, signers, {
preflightCommitment: 'single',
});
let signers = [];
return await signAndSendTransaction(connection, transaction, owner, signers);
}

View File

@ -1,61 +1,55 @@
import React, {useContext, useMemo, useState} from 'react';
import * as bip32 from 'bip32';
import React, {useContext, useEffect, useMemo, useState} from 'react';
import * as bs58 from 'bs58';
import {
Account,
SystemProgram,
Transaction,
PublicKey,
} from '@solana/web3.js';
import {Account, PublicKey,} from '@solana/web3.js';
import nacl from 'tweetnacl';
import {
setInitialAccountInfo,
useAccountInfo,
useConnection,
} from './connection';
import {setInitialAccountInfo, useAccountInfo, useConnection,} from './connection';
import {
closeTokenAccount,
createAndInitializeTokenAccount,
getOwnedTokenAccounts,
nativeTransfer,
transferTokens,
} from './tokens';
import { TOKEN_PROGRAM_ID, WRAPPED_SOL_MINT } from './tokens/instructions';
import {
ACCOUNT_LAYOUT,
parseMintData,
parseTokenAccountData,
} from './tokens/data';
import { useListener, useLocalStorageState } from './utils';
import { useTokenName } from './tokens/names';
import { refreshCache, useAsyncData } from './fetch-loop';
import { getUnlockedMnemonicAndSeed, walletSeedChanged } from './wallet-seed';
import {TOKEN_PROGRAM_ID, WRAPPED_SOL_MINT} from './tokens/instructions';
import {ACCOUNT_LAYOUT, parseMintData, parseTokenAccountData,} from './tokens/data';
import {useListener, useLocalStorageState} from './utils';
import {useTokenName} from './tokens/names';
import {refreshCache, useAsyncData} from './fetch-loop';
import {getUnlockedMnemonicAndSeed, walletSeedChanged} from './wallet-seed';
import {WalletProviderFactory} from "./walletProvider/factory";
import {getAccountFromSeed} from "./walletProvider/localStorage";
const DEFAULT_WALLET_SELECTOR = {
walletIndex: 0,
importedPubkey: undefined,
ledger: false,
};
export class Wallet {
constructor(connection, account) {
constructor(connection, type, args) {
this.connection = connection;
this.account = account;
this.type = type;
this.provider = WalletProviderFactory.getProvider(type, args);
}
static getAccountFromSeed(seed, walletIndex, accountIndex = 0) {
const derivedSeed = bip32
.fromSeed(seed)
.derivePath(`m/501'/${walletIndex}'/0/${accountIndex}`).privateKey;
return new Account(nacl.sign.keyPair.fromSeed(derivedSeed).secretKey);
static create = async (connection, type, args) => {
const instance = new Wallet(connection, type, args);
await instance.provider.init();
return instance;
}
get publicKey() {
return this.account.publicKey;
return this.provider.publicKey;
}
get allowsExport() {
return this.type === 'local';
}
getTokenAccountInfo = async () => {
let accounts = await getOwnedTokenAccounts(
this.connection,
this.account.publicKey,
this.publicKey,
);
return accounts.map(({ publicKey, accountInfo }) => {
setInitialAccountInfo(this.connection, publicKey, accountInfo);
@ -66,7 +60,7 @@ export class Wallet {
createTokenAccount = async (tokenAddress) => {
return await createAndInitializeTokenAccount({
connection: this.connection,
payer: this.account,
payer: this,
mintPublicKey: tokenAddress,
newAccount: new Account(),
});
@ -87,7 +81,7 @@ export class Wallet {
}
return await transferTokens({
connection: this.connection,
owner: this.account,
owner: this,
sourcePublicKey: source,
destinationPublicKey: destination,
amount,
@ -97,25 +91,24 @@ export class Wallet {
};
transferSol = async (destination, amount) => {
const tx = new Transaction().add(
SystemProgram.transfer({
fromPubkey: this.publicKey,
toPubkey: destination,
lamports: amount,
}),
);
return await this.connection.sendTransaction(tx, [this.account], {
preflightCommitment: 'single',
});
return nativeTransfer(this.connection, this, destination, amount);
};
closeTokenAccount = async (publicKey) => {
return await closeTokenAccount({
connection: this.connection,
owner: this.account,
owner: this,
sourcePublicKey: publicKey,
});
};
signTransaction = async (transaction) => {
return this.provider.signTransaction(transaction);
}
createSignature = async (message) => {
return this.provider.createSignature(message);
}
}
const WalletContext = React.createContext(null);
@ -124,6 +117,7 @@ export function WalletProvider({ children }) {
useListener(walletSeedChanged, 'change');
const { mnemonic, seed, importsEncryptionKey } = getUnlockedMnemonicAndSeed();
const connection = useConnection();
const [wallet, setWallet] = useState();
// `privateKeyImports` are accounts imported *in addition* to HD wallets
const [privateKeyImports, setPrivateKeyImports] = useLocalStorageState(
@ -135,43 +129,63 @@ export function WalletProvider({ children }) {
'walletSelector',
DEFAULT_WALLET_SELECTOR,
);
const [ledgerPubKey, setLedgerPubKey] = useState(walletSelector.ledger ? walletSelector.importedPubkey : undefined);
// `walletCount` is the number of HD wallets.
const [walletCount, setWalletCount] = useLocalStorageState('walletCount', 1);
const wallet = useMemo(() => {
useEffect(() => { (async () => {
if (!seed) {
return null;
}
const account =
walletSelector.walletIndex !== undefined
? Wallet.getAccountFromSeed(
Buffer.from(seed, 'hex'),
walletSelector.walletIndex,
let wallet;
if (walletSelector.ledger) {
try {
const onDisconnect = () => {
setWalletSelector(DEFAULT_WALLET_SELECTOR);
setLedgerPubKey(undefined);
};
wallet = await Wallet.create(connection, 'ledger', {onDisconnect});
} catch (e) {
console.log(`received error using ledger wallet: ${e}`);
setWalletSelector(DEFAULT_WALLET_SELECTOR);
return;
}
}
if (!wallet) {
const account =
walletSelector.walletIndex !== undefined
? getAccountFromSeed(
Buffer.from(seed, 'hex'),
walletSelector.walletIndex,
)
: new Account(
(() => {
const { nonce, ciphertext } = privateKeyImports[
walletSelector.importedPubkey
: new Account(
(() => {
const { nonce, ciphertext } = privateKeyImports[
walletSelector.importedPubkey
];
return nacl.secretbox.open(
bs58.decode(ciphertext),
bs58.decode(nonce),
importsEncryptionKey,
);
})(),
);
return new Wallet(connection, account);
}, [
return nacl.secretbox.open(
bs58.decode(ciphertext),
bs58.decode(nonce),
importsEncryptionKey,
);
})());
wallet = await Wallet.create(connection, 'local', {account})
}
setWallet(wallet);
})();}, [
connection,
seed,
walletSelector,
privateKeyImports,
importsEncryptionKey,
setWalletSelector,
]);
function addAccount({ name, importedAccount }) {
if (importedAccount === undefined) {
function addAccount({ name, importedAccount, ledger }) {
if (ledger) {
setLedgerPubKey(importedAccount);
} else if (importedAccount === undefined) {
name && localStorage.setItem(`name${walletCount}`, name);
setWalletCount(walletCount + 1);
} else {
@ -197,7 +211,7 @@ export function WalletProvider({ children }) {
}
const [walletNames, setWalletNames] = useState(getWalletNames())
function setAccountName(selector, newName) {
if (selector.importedPubkey) {
if (selector.importedPubkey && !selector.ledger) {
let newPrivateKeyImports = { ...privateKeyImports };
newPrivateKeyImports[selector.importedPubkey.toString()].name = newName;
setPrivateKeyImports(newPrivateKeyImports);
@ -214,10 +228,10 @@ export function WalletProvider({ children }) {
const seedBuffer = Buffer.from(seed, 'hex');
const derivedAccounts = [...Array(walletCount).keys()].map((idx) => {
let address = Wallet.getAccountFromSeed(seedBuffer, idx).publicKey;
let address = getAccountFromSeed(seedBuffer, idx).publicKey;
let name = localStorage.getItem(`name${idx}`);
return {
selector: { walletIndex: idx, importedPubkey: undefined },
selector: { walletIndex: idx, importedPubkey: undefined, ledger: false },
isSelected: walletSelector.walletIndex === idx,
address,
name: idx === 0 ? 'Main account' : name || `Account ${idx}`,
@ -227,16 +241,25 @@ export function WalletProvider({ children }) {
const importedAccounts = Object.keys(privateKeyImports).map((pubkey) => {
const { name } = privateKeyImports[pubkey];
return {
selector: { walletIndex: undefined, importedPubkey: pubkey },
selector: { walletIndex: undefined, importedPubkey: pubkey, ledger: false },
address: new PublicKey(bs58.decode(pubkey)),
name: `${name} (imported)`, // TODO: do this in the Component with styling.
isSelected: walletSelector.importedPubkey === pubkey,
};
});
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]);
}, [seed, walletCount, walletSelector, privateKeyImports, walletNames, ledgerPubKey]);
return (
<WalletContext.Provider
@ -270,7 +293,7 @@ export function useWalletPublicKeys() {
wallet.getTokenAccountInfo,
);
const getPublicKeys = () => [
wallet.account.publicKey,
wallet.publicKey,
...(tokenAccountInfo
? tokenAccountInfo.map(({ publicKey }) => publicKey)
: []),

View File

@ -0,0 +1,14 @@
import { LocalStorageWalletProvider } from './localStorage';
import { LedgerWalletProvider } from './ledger';
export class WalletProviderFactory {
static getProvider(type, args) {
if (type === 'local') {
return new LocalStorageWalletProvider(args)
}
if (type === 'ledger') {
return new LedgerWalletProvider(args);
}
}
}

View File

@ -0,0 +1,105 @@
import { PublicKey } from "@solana/web3.js";
const bs58 = require("bs58");
const INS_GET_PUBKEY = 0x05;
const INS_SIGN_MESSAGE = 0x06;
const P1_NON_CONFIRM = 0x00;
const P1_CONFIRM = 0x01;
const P2_EXTEND = 0x01;
const P2_MORE = 0x02;
const MAX_PAYLOAD = 255;
const LEDGER_CLA = 0xe0;
/*
* Helper for chunked send of large payloads
*/
async function solana_send(transport, instruction, p1, payload) {
var p2 = 0;
var payload_offset = 0;
if (payload.length > MAX_PAYLOAD) {
while ((payload.length - payload_offset) > MAX_PAYLOAD) {
const buf = payload.slice(payload_offset, payload_offset + MAX_PAYLOAD);
payload_offset += MAX_PAYLOAD;
console.log("send", (p2 | P2_MORE).toString(16), buf.length.toString(16), buf);
const reply = await transport.send(LEDGER_CLA, instruction, p1, (p2 | P2_MORE), buf);
if (reply.length !== 2) {
throw new Error(
"solana_send: Received unexpected reply payload",
"UnexpectedReplyPayload"
);
}
p2 |= P2_EXTEND;
}
}
const buf = payload.slice(payload_offset);
console.log("send", p2.toString(16), buf.length.toString(16), buf);
const reply = await transport.send(LEDGER_CLA, instruction, p1, p2, buf);
return reply.slice(0, reply.length - 2);
}
const BIP32_HARDENED_BIT = ((1 << 31) >>> 0);
function _harden(n) {
return (n | BIP32_HARDENED_BIT) >>> 0;
}
export function solana_derivation_path(account, change) {
var length;
if (typeof (account) === 'number') {
if (typeof (change) === 'number') {
length = 4;
} else {
length = 3;
}
} else {
length = 2;
}
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
if (length > 2) {
offset = derivation_path.writeUInt32BE(_harden(account), offset);
if (length === 4) {
offset = derivation_path.writeUInt32BE(_harden(change), offset);
}
}
return derivation_path;
}
async function solana_ledger_get_pubkey(transport, derivation_path) {
return solana_send(transport, INS_GET_PUBKEY, P1_NON_CONFIRM, derivation_path);
}
export async function solana_ledger_sign_transaction(transport, derivation_path, transaction) {
const msg_bytes = transaction.serializeMessage();
return solana_ledger_sign_bytes(transport, derivation_path, msg_bytes);
}
export async function solana_ledger_sign_bytes(transport, derivation_path, msg_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);
}
export async function getPublicKey(transport) {
const from_derivation_path = solana_derivation_path();
const from_pubkey_bytes = await solana_ledger_get_pubkey(transport, from_derivation_path);
const from_pubkey_string = bs58.encode(from_pubkey_bytes);
return new PublicKey(from_pubkey_string);
}

View File

@ -0,0 +1,43 @@
import TransportWebUsb from "@ledgerhq/hw-transport-webusb";
import {
getPublicKey,
solana_derivation_path,
solana_ledger_sign_bytes,
solana_ledger_sign_transaction
} from './ledger-core';
import bs58 from "bs58";
import nacl from "tweetnacl";
export class LedgerWalletProvider {
constructor(args) {
this.onDisconnect = (args && args.onDisconnect) || (() => {});
}
init = async () => {
this.transport = await TransportWebUsb.create();
this.pubKey = await getPublicKey(this.transport);
this.transport.on('disconnect', this.onDisconnect);
this.listAddresses = async (walletCount) => {
// TODO: read accounts from ledger
return [this.pubKey];
}
return this;
}
get publicKey() {
return this.pubKey;
}
signTransaction = async (transaction) => {
const from_derivation_path = solana_derivation_path();
const sig_bytes = await solana_ledger_sign_transaction(this.transport, from_derivation_path, transaction);
transaction.addSignature(this.publicKey, sig_bytes);
return transaction;
}
createSignature = async (message) => {
const from_derivation_path = solana_derivation_path();
const sig_bytes = await solana_ledger_sign_bytes(this.transport, from_derivation_path, message);
return bs58.encode(sig_bytes)
}
}

View File

@ -0,0 +1,47 @@
import { getUnlockedMnemonicAndSeed } from './../wallet-seed';
import * as bip32 from 'bip32';
import nacl from 'tweetnacl';
import { Account } from '@solana/web3.js';
import bs58 from 'bs58';
export function getAccountFromSeed(seed, walletIndex, accountIndex = 0) {
const derivedSeed = bip32
.fromSeed(seed)
.derivePath(`m/501'/${walletIndex}'/0/${accountIndex}`).privateKey;
return new Account(nacl.sign.keyPair.fromSeed(derivedSeed).secretKey);
}
export class LocalStorageWalletProvider {
constructor(args) {
const { seed } = getUnlockedMnemonicAndSeed();
this.account = args.account
this.listAddresses = async (walletCount) => {
const seedBuffer = Buffer.from(seed, 'hex');
return [...Array(walletCount).keys()].map(
(walletIndex) => {
let address = getAccountFromSeed(seedBuffer, walletIndex).publicKey;
let name = localStorage.getItem(`name${walletIndex}`);
return { index: walletIndex, address, name };
}
);
}
}
init = async () => {
return this;
}
get publicKey() {
return this.account.publicKey;
}
signTransaction = async (transaction) => {
transaction.partialSign(this.account);
return transaction;
}
createSignature = (message) => {
return bs58.encode(nacl.sign.detached(message, this.account.secretKey))
}
}

View File

@ -1494,6 +1494,44 @@
"@types/yargs" "^15.0.0"
chalk "^3.0.0"
"@ledgerhq/devices@^5.34.0":
version "5.34.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-5.34.0.tgz#4cb073a7bc9528f42f539241fedf44c0c6b451dc"
integrity sha512-oRoZDDfufXAv9KofQdOXc0QztISa9x/YVdiWs55iOlNRQjWYHSPFzeSP6+J+tN0Au/GS9v+wcnTf+tHCtVz27Q==
dependencies:
"@ledgerhq/errors" "^5.34.0"
"@ledgerhq/logs" "^5.30.0"
rxjs "^6.6.3"
"@ledgerhq/errors@^5.34.0":
version "5.34.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-5.34.0.tgz#c003b0929f8ca0c74901a11a88b3e3db4b40e4b7"
integrity sha512-8Td60sOP5wzsjmxmj9Q5hjrr1XjCfB3Vdifq+1fiD6rpjyscYLgmoP2y5GYDPceDhalA/GOrKNSf+aIVVmBNOw==
"@ledgerhq/hw-transport-webusb@^5.34.0":
version "5.34.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-webusb/-/hw-transport-webusb-5.34.0.tgz#8947502fee7b959d050976eb508984bb32cc9a95"
integrity sha512-GXzirgAtOZD7kV1QiJ3e7UoN8br9yt8WDCnYwXHiulJmO21biT186L7X8J3folQJrqohJ6UgUncYamtp1JflSA==
dependencies:
"@ledgerhq/devices" "^5.34.0"
"@ledgerhq/errors" "^5.34.0"
"@ledgerhq/hw-transport" "^5.34.0"
"@ledgerhq/logs" "^5.30.0"
"@ledgerhq/hw-transport@^5.34.0":
version "5.34.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-5.34.0.tgz#b27eb0d9941a2e6220e5af3e3d4f48ce8a993982"
integrity sha512-JxhqU9sBn+WH3CPMus9b70SED7LEeW17xw0VL5aRdxFu4YS5rhy5Xf4Sd5jIQfyDkHik1ouzfJVuQEju8+GGBw==
dependencies:
"@ledgerhq/devices" "^5.34.0"
"@ledgerhq/errors" "^5.34.0"
events "^3.2.0"
"@ledgerhq/logs@^5.30.0":
version "5.30.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/logs/-/logs-5.30.0.tgz#76e8d7e5a06a73c9b99da51fa5befd5cfd5309d4"
integrity sha512-wUhg2VTfUrWihjdGqKkH/s7TBzdIM1yyd2LiscYsfTX2I0xYDMnpE+NkMReeGU8PN3QhCPgnlg9/P9V6UWoJBA==
"@material-ui/core@^4.11.0":
version "4.11.0"
resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.11.0.tgz#b69b26e4553c9e53f2bfaf1053e216a0af9be15a"
@ -5151,7 +5189,7 @@ eventemitter3@^4.0.7:
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
events@^3.0.0:
events@^3.0.0, events@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.2.0.tgz#93b87c18f8efcd4202a461aec4dfc0556b639379"
integrity sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg==
@ -10766,6 +10804,13 @@ rxjs@^6.5.3, rxjs@^6.6.0:
dependencies:
tslib "^1.9.0"
rxjs@^6.6.3:
version "6.6.3"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552"
integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==
dependencies:
tslib "^1.9.0"
safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"