Add wallet selector and ledger support (#60)

This commit is contained in:
B 2021-03-02 08:45:00 -06:00 committed by GitHub
parent 4351dd0abf
commit d4a65b837f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 788 additions and 128 deletions

View File

@ -6,6 +6,7 @@
"dependencies": {
"@ant-design/icons": "^4.2.1",
"@craco/craco": "^5.6.4",
"@ledgerhq/hw-transport-webusb": "^5.41.0",
"@project-serum/associated-token": "0.1.0",
"@project-serum/awesome-serum": "1.0.1",
"@project-serum/pool": "^0.2.0",
@ -77,6 +78,8 @@
}
},
"devDependencies": {
"@types/ledgerhq__hw-transport": "^4.21.3",
"@types/ledgerhq__hw-transport-webusb": "^4.70.1",
"gh-pages": "^3.1.0",
"git-format-staged": "^2.1.0",
"husky": "^4.2.5",

View File

@ -10,7 +10,8 @@ import {
getSelectedTokenAccountForMint,
MarketProvider,
useBalances,
useCustomMarkets, useLocallyStoredFeeDiscountKey,
useCustomMarkets,
useLocallyStoredFeeDiscountKey,
useMarket,
useTokenAccounts,
} from '../utils/markets';
@ -23,7 +24,7 @@ import FloatingElement from './layout/FloatingElement';
import WalletConnect from './WalletConnect';
import { SwapOutlined } from '@ant-design/icons';
import { CustomMarketInfo } from '../utils/types';
import Wallet from '@project-serum/sol-wallet-adapter';
import { WalletAdapter } from '../wallet-adapters';
const { Option } = Select;
const { Title } = Typography;
@ -173,7 +174,7 @@ function ConvertFormSubmit({
setSize: (newSize: number | undefined) => void;
fromToken: string;
toToken: string;
wallet: Wallet;
wallet?: WalletAdapter;
customMarkets: CustomMarketInfo[];
}) {
const { market } = useMarket();
@ -181,7 +182,9 @@ function ConvertFormSubmit({
const balances = useBalances();
const [fromAmount, setFromAmount] = useState<number | undefined>();
const [toAmount, setToAmount] = useState<number | undefined>();
const { storedFeeDiscountKey: feeDiscountKey } = useLocallyStoredFeeDiscountKey();
const {
storedFeeDiscountKey: feeDiscountKey,
} = useLocallyStoredFeeDiscountKey();
const connection = useConnection();
const sendConnection = useSendConnection();
@ -269,6 +272,10 @@ function ConvertFormSubmit({
setIsConverting(true);
try {
if (!wallet) {
return null;
}
await placeOrder({
side,
price: parsedPrice,

View File

@ -5,9 +5,11 @@ import { LinkOutlined } from '@ant-design/icons';
export default function LinkAddress({
title,
address,
shorten = false,
}: {
title?: undefined | any;
address: string;
shorten?: boolean;
}) {
return (
<div>
@ -18,8 +20,9 @@ export default function LinkAddress({
href={'https://explorer.solana.com/address/' + address}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
{address}
{shorten ? `${address.slice(0, 4)}...${address.slice(-4)}` : address}
</Button>
</div>
);

View File

@ -52,6 +52,15 @@ export default function StandaloneBalancesDisplay() {
balances && balances.find((b) => b.coin === quoteCurrency);
async function onSettleFunds() {
if (!wallet) {
notify({
message: 'Wallet not connected',
description: 'wallet is undefined',
type: 'error',
});
return;
}
if (!market) {
notify({
message: 'Error settling funds',

View File

@ -8,7 +8,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import logo from '../assets/logo.svg';
import styled from 'styled-components';
import { useWallet, WALLET_PROVIDERS } from '../utils/wallet';
import { useWallet } from '../utils/wallet';
import { ENDPOINTS, useConnectionConfig } from '../utils/connection';
import Settings from './Settings';
import CustomClusterEndpointDialog from './CustomClusterEndpointDialog';
@ -51,7 +51,7 @@ const EXTERNAL_LINKS = {
};
export default function TopBar() {
const { connected, wallet, providerUrl, setProvider } = useWallet();
const { connected, wallet } = useWallet();
const {
endpoint,
endpointInfo,
@ -321,15 +321,6 @@ export default function TopBar() {
</Popover>
</div>
)}
<div>
<Select onSelect={setProvider} value={providerUrl}>
{WALLET_PROVIDERS.map(({ name, url }) => (
<Select.Option value={url} key={url}>
{name}
</Select.Option>
))}
</Select>
</div>
<div>
<WalletConnect />
</div>

View File

@ -8,7 +8,9 @@ import {
useMarkPrice,
useSelectedOpenOrdersAccount,
useSelectedBaseCurrencyAccount,
useSelectedQuoteCurrencyAccount, useFeeDiscountKeys, useLocallyStoredFeeDiscountKey,
useSelectedQuoteCurrencyAccount,
useFeeDiscountKeys,
useLocallyStoredFeeDiscountKey,
} from '../utils/markets';
import { useWallet } from '../utils/wallet';
import { notify } from '../utils/notifications';
@ -64,7 +66,9 @@ export default function TradeForm({
const sendConnection = useSendConnection();
const markPrice = useMarkPrice();
useFeeDiscountKeys();
const { storedFeeDiscountKey: feeDiscountKey } = useLocallyStoredFeeDiscountKey();
const {
storedFeeDiscountKey: feeDiscountKey,
} = useLocallyStoredFeeDiscountKey();
const [postOnly, setPostOnly] = useState(false);
const [ioc, setIoc] = useState(false);
@ -85,6 +89,8 @@ export default function TradeForm({
market?.minOrderSize && getDecimalCount(market.minOrderSize);
let priceDecimalCount = market?.tickSize && getDecimalCount(market.tickSize);
const publicKey = wallet?.publicKey;
useEffect(() => {
setChangeOrderRef && setChangeOrderRef(doChangeOrder);
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -103,17 +109,14 @@ export default function TradeForm({
useEffect(() => {
const warmUpCache = async () => {
try {
if (!wallet || !wallet.publicKey || !market) {
if (!wallet || !publicKey || !market) {
console.log(`Skipping refreshing accounts`);
return;
}
const startTime = getUnixTs();
console.log(`Refreshing accounts for ${market.address}`);
await market?.findOpenOrdersAccountsForOwner(
sendConnection,
wallet.publicKey,
);
await market?.findBestFeeDiscountKey(sendConnection, wallet.publicKey);
await market?.findOpenOrdersAccountsForOwner(sendConnection, publicKey);
await market?.findBestFeeDiscountKey(sendConnection, publicKey);
const endTime = getUnixTs();
console.log(
`Finished refreshing accounts for ${market.address} after ${
@ -127,7 +130,7 @@ export default function TradeForm({
warmUpCache();
const id = setInterval(warmUpCache, 30_000);
return () => clearInterval(id);
}, [market, sendConnection, wallet, wallet.publicKey]);
}, [market, sendConnection, wallet, publicKey]);
const onSetBaseSize = (baseSize: number | undefined) => {
setBaseSize(baseSize);
@ -242,6 +245,10 @@ export default function TradeForm({
setSubmitting(true);
try {
if (!wallet) {
return null;
}
await placeOrder({
side,
price,
@ -252,7 +259,7 @@ export default function TradeForm({
wallet,
baseCurrencyAccount: baseCurrencyAccount?.pubkey,
quoteCurrencyAccount: quoteCurrencyAccount?.pubkey,
feeDiscountPubkey: feeDiscountKey
feeDiscountPubkey: feeDiscountKey,
});
refreshCache(tuple('getTokenAccounts', wallet, connected));
setPrice(undefined);

View File

@ -4,7 +4,7 @@ import {
widget,
ChartingLibraryWidgetOptions,
IChartingLibraryWidget,
ResolutionString
ResolutionString,
} from '../../charting_library'; // Make sure to follow step 1 of the README
import { useMarket } from '../../utils/markets';
import { BONFIDA_DATA_FEED } from '../../utils/bonfidaConnector';

View File

@ -36,6 +36,10 @@ export default function OpenOrderTable({
async function cancel(order) {
setCancelId(order?.orderId);
try {
if (!wallet) {
return null;
}
await cancelOrder({
order,
market: order.market,

View File

@ -35,6 +35,15 @@ export default function WalletBalancesTable({
async function onSettleFunds() {
setSettlingFunds(true);
try {
if (!wallet) {
notify({
message: 'Wallet not connected',
description: 'Wallet not connected',
type: 'error',
});
return;
}
if (!tokenAccounts || !tokenAccountsConnected) {
notify({
message: 'Error settling funds',

View File

@ -1,34 +1,24 @@
import React from 'react';
import { Button, Popover } from 'antd';
import { InfoCircleOutlined, UserOutlined } from '@ant-design/icons';
import { Dropdown, Menu } from 'antd';
import { useWallet } from '../utils/wallet';
import LinkAddress from './LinkAddress';
export default function WalletConnect() {
const { connected, wallet } = useWallet();
const publicKey = wallet?.publicKey?.toBase58();
const { connected, wallet, select, connect, disconnect } = useWallet();
const publicKey = (connected && wallet?.publicKey?.toBase58()) || '';
const menu = (
<Menu>
{connected && <LinkAddress shorten={true} address={publicKey} />}
<Menu.Item key="3" onClick={select}>
Change Wallet
</Menu.Item>
</Menu>
);
return (
<React.Fragment>
<Button
type="text"
size="large"
onClick={connected ? wallet.disconnect : wallet.connect}
style={{ color: '#2abdd2' }}
>
<UserOutlined />
{!connected ? 'Connect wallet' : 'Disconnect'}
</Button>
{connected && (
<Popover
content={<LinkAddress address={publicKey} />}
placement="bottomRight"
title="Wallet public key"
trigger="click"
>
<InfoCircleOutlined style={{ color: '#2abdd2' }} />
</Popover>
)}
</React.Fragment>
<Dropdown.Button onClick={connected ? disconnect : connect} overlay={menu}>
{connected ? 'Disconnect' : 'Connect'}
</Dropdown.Button>
);
}

View File

@ -76,7 +76,7 @@ export default function NewPoolPage() {
}, [programId]);
useEffect(() => {
if (connected) {
if (connected && wallet) {
setAdminAddress(wallet.publicKey.toBase58());
}
}, [wallet, connected]);
@ -91,7 +91,7 @@ export default function NewPoolPage() {
(adminAddress || !adminControlled);
async function onSubmit() {
if (!canSubmit) {
if (!canSubmit || !wallet) {
return;
}
setSubmitting(true);

View File

@ -65,7 +65,7 @@ function PauseUnpauseTab({ poolInfo }: TabParams) {
const [submitting, setSubmitting] = useState(false);
async function sendPause() {
if (!connected) {
if (!connected || !wallet) {
return;
}
setSubmitting(true);
@ -85,7 +85,7 @@ function PauseUnpauseTab({ poolInfo }: TabParams) {
}
async function sendUnpause() {
if (!connected) {
if (!connected || !wallet) {
return;
}
setSubmitting(true);
@ -130,7 +130,7 @@ function AddAssetTab({ poolInfo }: TabParams) {
mintAddress,
);
const transaction = new Transaction();
if (!(await connection.getAccountInfo(vaultAddress))) {
if (!(await connection.getAccountInfo(vaultAddress)) && wallet) {
transaction.add(
await createAssociatedTokenAccount(
wallet.publicKey,
@ -208,6 +208,10 @@ function DepositTab({ poolInfo }: TabParams) {
const [onSubmit, submitting] = useOnSubmitHandler(
'depositing to pool',
async () => {
if (!wallet) {
throw new Error('Wallet is not connected');
}
const mintAddress = new PublicKey(address);
const vaultAddress = poolInfo.state.assets.find((asset) =>
asset.mint.equals(mintAddress),
@ -316,6 +320,10 @@ function WithdrawTab({ poolInfo }: TabParams) {
const [onSubmit, submitting] = useOnSubmitHandler(
'withdrawing from pool',
async () => {
if (!wallet) {
throw new Error('Wallet is not connected');
}
const mintAddress = new PublicKey(address);
const vaultAddress = poolInfo.state.assets.find((asset) =>
asset.mint.equals(mintAddress),
@ -472,7 +480,7 @@ function useOnSubmitHandler(
}
setSubmitting(true);
try {
if (!connected) {
if (!connected || !wallet) {
throw new Error('Wallet not connected');
}
const [transaction, signers] = await makeTransaction();

View File

@ -95,7 +95,7 @@ function CreateRedeemTab({ poolInfo, mintInfo, tab }: CreateRedeemInnerPanel) {
async function onSubmit(e) {
e.preventDefault();
if (!action || !basket || !connected || !canSubmit) {
if (!action || !basket || !connected || !canSubmit || !wallet) {
return;
}
setSubmitting(true);

View File

@ -49,7 +49,7 @@ export default function PoolPage() {
);
const { wallet } = useWallet();
if (poolInfo && mintInfo) {
if (poolInfo && mintInfo && wallet) {
return (
<>
<PageHeader

View File

@ -423,7 +423,7 @@ export function useOpenOrdersAccounts(fast = false) {
const { connected, wallet } = useWallet();
const connection = useConnection();
async function getOpenOrdersAccounts() {
if (!connected) {
if (!connected || !wallet) {
return null;
}
if (!market) {
@ -456,7 +456,7 @@ export function useTokenAccounts(): [
const { connected, wallet } = useWallet();
const connection = useConnection();
async function getTokenAccounts() {
if (!connected) {
if (!connected || !wallet) {
return null;
}
return await getTokenAccountInfo(connection, wallet.publicKey);
@ -603,7 +603,7 @@ export function useFeeDiscountKeys(): [
const connection = useConnection();
const { setStoredFeeDiscountKey } = useLocallyStoredFeeDiscountKey();
let getFeeDiscountKeys = async () => {
if (!connected) {
if (!connected || !wallet) {
return null;
}
if (!market) {
@ -660,7 +660,7 @@ export function useFillsForAllMarkets(limit = 100) {
let marketData;
for (marketData of allMarkets) {
const { market, marketName } = marketData;
if (!market) {
if (!market || !wallet) {
return fills;
}
const openOrdersAccounts = await market.findOpenOrdersAccountsForOwner(
@ -710,7 +710,7 @@ export function useAllOpenOrdersAccounts() {
].map((stringProgramId) => new PublicKey(stringProgramId));
const getAllOpenOrdersAccounts = async () => {
if (!connected) {
if (!connected || !wallet) {
return [];
}
return (
@ -819,7 +819,7 @@ export const useAllOpenOrders = (): {
};
useEffect(() => {
if (connected) {
if (connected && wallet) {
const getAllOpenOrders = async () => {
setLoaded(false);
const _openOrders: { orders: Order[]; marketAddress: string }[] = [];
@ -852,7 +852,7 @@ export const useAllOpenOrders = (): {
};
getAllOpenOrders();
}
}, [connected, wallet, refresh]);
}, [connection, connected, wallet, refresh]);
return {
openOrders: openOrders,
loaded: loaded,
@ -1042,7 +1042,7 @@ export function useGetOpenOrdersForDeprecatedMarkets(): {
.map((market) => market.address.toBase58());
async function getOpenOrdersForDeprecatedMarkets() {
if (!connected) {
if (!connected || !wallet) {
return null;
}
if (!marketsList) {

View File

@ -31,6 +31,10 @@ export function PreferencesProvider({ children }) {
const autoSettle = async () => {
const markets = (marketList || []).map((m) => m.market);
try {
if (!wallet) {
return;
}
console.log('Auto settling');
await settleAllFunds({
connection,

View File

@ -21,12 +21,12 @@ import {
TOKEN_MINTS,
TokenInstructions,
} from '@project-serum/serum';
import Wallet from '@project-serum/sol-wallet-adapter';
import { SelectedTokenAccounts, TokenAccount } from './types';
import { Order } from '@project-serum/serum/lib/market';
import { Buffer } from 'buffer';
import assert from 'assert';
import { struct } from 'superstruct';
import { WalletAdapter } from '../wallet-adapters';
export async function createTokenAccountTransaction({
connection,
@ -34,7 +34,7 @@ export async function createTokenAccountTransaction({
mintPublicKey,
}: {
connection: Connection;
wallet: Wallet;
wallet: WalletAdapter;
mintPublicKey: PublicKey;
}): Promise<{
transaction: Transaction;
@ -76,7 +76,7 @@ export async function settleFunds({
market: Market;
openOrders: OpenOrders;
connection: Connection;
wallet: Wallet;
wallet: WalletAdapter;
baseCurrencyAccount: TokenAccount;
quoteCurrencyAccount: TokenAccount;
}): Promise<string | undefined> {
@ -174,7 +174,7 @@ export async function settleAllFunds({
selectedTokenAccounts,
}: {
connection: Connection;
wallet: Wallet;
wallet: WalletAdapter;
tokenAccounts: TokenAccount[];
markets: Market[];
selectedTokenAccounts?: SelectedTokenAccounts;
@ -289,7 +289,7 @@ export async function settleAllFunds({
export async function cancelOrder(params: {
market: Market;
connection: Connection;
wallet: Wallet;
wallet: WalletAdapter;
order: Order;
}) {
return cancelOrders({ ...params, orders: [params.order] });
@ -302,7 +302,7 @@ export async function cancelOrders({
orders,
}: {
market: Market;
wallet: Wallet;
wallet: WalletAdapter;
connection: Connection;
orders: Order[];
}) {
@ -339,7 +339,7 @@ export async function placeOrder({
orderType: 'ioc' | 'postOnly' | 'limit';
market: Market | undefined | null;
connection: Connection;
wallet: Wallet;
wallet: WalletAdapter;
baseCurrencyAccount: PublicKey | undefined;
quoteCurrencyAccount: PublicKey | undefined;
feeDiscountPubkey: PublicKey | undefined;
@ -480,7 +480,7 @@ export async function listMarket({
dexProgramId,
}: {
connection: Connection;
wallet: Wallet;
wallet: WalletAdapter;
baseMint: PublicKey;
quoteMint: PublicKey;
baseLotSize: number;
@ -637,7 +637,7 @@ export async function sendTransaction({
timeout = DEFAULT_TIMEOUT,
}: {
transaction: Transaction;
wallet: Wallet;
wallet: WalletAdapter;
signers?: Array<Account>;
connection: Connection;
sendingMessage?: string;
@ -668,7 +668,7 @@ export async function signTransaction({
connection,
}: {
transaction: Transaction;
wallet: Wallet;
wallet: WalletAdapter;
signers?: Array<Account>;
connection: Connection;
}) {
@ -691,7 +691,7 @@ export async function signTransactions({
transaction: Transaction;
signers?: Array<Account>;
}[];
wallet: Wallet;
wallet: WalletAdapter;
connection: Connection;
}) {
const blockhash = (await connection.getRecentBlockhash('max')).blockhash;

View File

@ -1,8 +1,8 @@
import { AccountInfo, Connection, PublicKey } from '@solana/web3.js';
import Wallet from '@project-serum/sol-wallet-adapter';
import { Market, OpenOrders } from '@project-serum/serum';
import { Event } from '@project-serum/serum/lib/queue';
import { Order } from '@project-serum/serum/lib/market';
import { WalletAdapter } from '../wallet-adapters';
export interface ConnectionContextValues {
endpoint: string;
@ -14,11 +14,12 @@ export interface ConnectionContextValues {
}
export interface WalletContextValues {
wallet: Wallet;
wallet: WalletAdapter | undefined;
connected: boolean;
providerUrl: string;
setProviderUrl: (newProviderUrl: string) => void;
providerName: string;
select: () => void;
}
export interface MarketInfo {

View File

@ -28,7 +28,9 @@ export function floorToDecimal(
value: number,
decimals: number | undefined | null,
) {
return decimals ? Math.floor(value * 10 ** decimals) / 10 ** decimals : Math.floor(value);
return decimals
? Math.floor(value * 10 ** decimals) / 10 ** decimals
: Math.floor(value);
}
export function roundToDecimal(
@ -39,10 +41,18 @@ export function roundToDecimal(
}
export function getDecimalCount(value): number {
if (!isNaN(value) && Math.floor(value) !== value && value.toString().includes('.'))
if (
!isNaN(value) &&
Math.floor(value) !== value &&
value.toString().includes('.')
)
return value.toString().split('.')[1].length || 0;
if (!isNaN(value) && Math.floor(value) !== value && value.toString().includes('e'))
return parseInt(value.toString().split(('e-'))[1] || "0");
if (
!isNaN(value) &&
Math.floor(value) !== value &&
value.toString().includes('e')
)
return parseInt(value.toString().split('e-')[1] || '0');
return 0;
}

View File

@ -1,12 +1,49 @@
import React, { useContext, useEffect, useMemo, useState } from 'react';
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import Wallet from '@project-serum/sol-wallet-adapter';
import { notify } from './notifications';
import { useConnectionConfig } from './connection';
import { useLocalStorageState } from './utils';
import { WalletContextValues } from './types';
import { Button, Modal } from 'antd';
import {
WalletAdapter,
LedgerWalletAdapter,
SolongWalletAdapter,
PhantomWalletAdapter,
} from '../wallet-adapters';
const ASSET_URL =
'https://cdn.jsdelivr.net/gh/solana-labs/oyster@main/assets/wallets';
export const WALLET_PROVIDERS = [
{ name: 'sollet.io', url: 'https://www.sollet.io' },
{
name: 'sollet.io',
url: 'https://www.sollet.io',
icon: `${ASSET_URL}/sollet.svg`,
},
{
name: 'Ledger',
url: 'https://www.ledger.com',
icon: `${ASSET_URL}/ledger.svg`,
adapter: LedgerWalletAdapter,
},
{
name: 'Solong',
url: 'https://www.solong.com',
icon: `${ASSET_URL}/solong.png`,
adapter: SolongWalletAdapter,
},
{
name: 'Phantom',
url: 'https://www.phantom.app',
icon: `https://www.phantom.app/img/logo.png`,
adapter: PhantomWalletAdapter,
},
];
const WalletContext = React.createContext<null | WalletContextValues>(null);
@ -14,62 +51,93 @@ const WalletContext = React.createContext<null | WalletContextValues>(null);
export function WalletProvider({ children }) {
const { endpoint } = useConnectionConfig();
const [savedProviderUrl, setProviderUrl] = useLocalStorageState(
'walletProvider',
'https://www.sollet.io',
);
let providerUrl;
if (!savedProviderUrl) {
providerUrl = 'https://www.sollet.io';
} else {
providerUrl = savedProviderUrl;
}
const [autoConnect, setAutoConnect] = useState(false);
const [providerUrl, setProviderUrl] = useLocalStorageState('walletProvider');
const wallet = useMemo(() => new Wallet(providerUrl, endpoint), [
providerUrl,
endpoint,
]);
const provider = useMemo(
() => WALLET_PROVIDERS.find(({ url }) => url === providerUrl),
[providerUrl],
);
const wallet = useMemo(
function () {
if (provider) {
return new (provider.adapter || Wallet)(
providerUrl,
endpoint,
) as WalletAdapter;
}
},
[provider, providerUrl, endpoint],
);
const [connected, setConnected] = useState(false);
useEffect(() => {
console.log('trying to connect');
wallet.on('connect', () => {
console.log('connected');
localStorage.removeItem('feeDiscountKey')
setConnected(true);
let walletPublicKey = wallet.publicKey.toBase58();
let keyToDisplay =
walletPublicKey.length > 20
? `${walletPublicKey.substring(0, 7)}.....${walletPublicKey.substring(
walletPublicKey.length - 7,
walletPublicKey.length,
)}`
: walletPublicKey;
notify({
message: 'Wallet update',
description: 'Connected to wallet ' + keyToDisplay,
if (wallet) {
wallet.on('connect', () => {
if (wallet.publicKey) {
console.log('connected');
localStorage.removeItem('feeDiscountKey');
setConnected(true);
const walletPublicKey = wallet.publicKey.toBase58();
const keyToDisplay =
walletPublicKey.length > 20
? `${walletPublicKey.substring(
0,
7,
)}.....${walletPublicKey.substring(
walletPublicKey.length - 7,
walletPublicKey.length,
)}`
: walletPublicKey;
notify({
message: 'Wallet update',
description: 'Connected to wallet ' + keyToDisplay,
});
}
});
});
wallet.on('disconnect', () => {
setConnected(false);
notify({
message: 'Wallet update',
description: 'Disconnected from wallet',
wallet.on('disconnect', () => {
setConnected(false);
notify({
message: 'Wallet update',
description: 'Disconnected from wallet',
});
localStorage.removeItem('feeDiscountKey');
});
localStorage.removeItem('feeDiscountKey')
});
}
return () => {
wallet.disconnect();
setConnected(false);
if (wallet) {
wallet.disconnect();
setConnected(false);
}
};
}, [wallet]);
useEffect(() => {
if (wallet && autoConnect) {
wallet.connect();
setAutoConnect(false);
}
return () => {};
}, [wallet, autoConnect]);
const [isModalVisible, setIsModalVisible] = useState(false);
const select = useCallback(() => setIsModalVisible(true), []);
const close = useCallback(() => setIsModalVisible(false), []);
return (
<WalletContext.Provider
value={{
wallet,
connected,
select,
providerUrl,
setProviderUrl,
providerName:
@ -78,6 +146,47 @@ export function WalletProvider({ children }) {
}}
>
{children}
<Modal
title="Select Wallet"
okText="Connect"
visible={isModalVisible}
okButtonProps={{ style: { display: 'none' } }}
onCancel={close}
width={400}
>
{WALLET_PROVIDERS.map((provider) => {
const onClick = function () {
setProviderUrl(provider.url);
setAutoConnect(true);
close();
};
return (
<Button
size="large"
type={providerUrl === provider.url ? 'primary' : 'ghost'}
onClick={onClick}
icon={
<img
alt={`${provider.name}`}
width={20}
height={20}
src={provider.icon}
style={{ marginRight: 8 }}
/>
}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
marginBottom: 8,
}}
>
{provider.name}
</Button>
);
})}
</Modal>
</WalletContext.Provider>
);
}
@ -87,11 +196,20 @@ export function useWallet() {
if (!context) {
throw new Error('Missing wallet context');
}
const wallet = context.wallet;
return {
connected: context.connected,
wallet: context.wallet,
wallet: wallet,
providerUrl: context.providerUrl,
setProvider: context.setProviderUrl,
providerName: context.providerName,
select: context.select,
connect() {
wallet ? wallet.connect() : context.select();
},
disconnect() {
wallet?.disconnect();
},
};
}

View File

@ -0,0 +1,4 @@
export * from './ledger';
export * from './solong';
export * from './phantom';
export * from './types';

View File

@ -0,0 +1,133 @@
import type Transport from '@ledgerhq/hw-transport';
import type { Transaction } from '@solana/web3.js';
import { PublicKey } from '@solana/web3.js';
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 ledgerSend(
transport: Transport,
instruction: number,
p1: number,
payload: Buffer,
) {
let p2 = 0;
let payloadOffset = 0;
if (payload.length > MAX_PAYLOAD) {
while (payload.length - payloadOffset > MAX_PAYLOAD) {
const chunk = payload.slice(payloadOffset, payloadOffset + MAX_PAYLOAD);
payloadOffset += MAX_PAYLOAD;
console.log(
'send',
(p2 | P2_MORE).toString(16),
chunk.length.toString(16),
chunk,
);
const reply = await transport.send(
LEDGER_CLA,
instruction,
p1,
p2 | P2_MORE,
chunk,
);
if (reply.length !== 2) {
throw new Error('Received unexpected reply payload');
}
p2 |= P2_EXTEND;
}
}
const chunk = payload.slice(payloadOffset);
console.log('send', p2.toString(16), chunk.length.toString(16), chunk);
const reply = await transport.send(LEDGER_CLA, instruction, p1, p2, chunk);
return reply.slice(0, reply.length - 2);
}
const BIP32_HARDENED_BIT = (1 << 31) >>> 0;
function harden(n: number = 0) {
return (n | BIP32_HARDENED_BIT) >>> 0;
}
export function getSolanaDerivationPath(account?: number, change?: number) {
var length;
if (account !== undefined) {
if (change !== undefined) {
length = 4;
} else {
length = 3;
}
} else {
length = 2;
}
var derivationPath = Buffer.alloc(1 + length * 4);
// eslint-disable-next-line
var offset = 0;
offset = derivationPath.writeUInt8(length, offset);
offset = derivationPath.writeUInt32BE(harden(44), offset); // Using BIP44
offset = derivationPath.writeUInt32BE(harden(501), offset); // Solana's BIP44 path
if (length > 2) {
offset = derivationPath.writeUInt32BE(harden(account), offset);
if (length === 4) {
// @FIXME: https://github.com/project-serum/spl-token-wallet/issues/59
offset = derivationPath.writeUInt32BE(harden(change), offset);
}
}
return derivationPath;
}
export async function signTransaction(
transport: Transport,
transaction: Transaction,
derivationPath: Buffer = getSolanaDerivationPath(),
) {
const messageBytes = transaction.serializeMessage();
return signBytes(transport, messageBytes, derivationPath);
}
export async function signBytes(
transport: Transport,
bytes: Buffer,
derivationPath: Buffer = getSolanaDerivationPath(),
) {
const numPaths = Buffer.alloc(1);
numPaths.writeUInt8(1, 0);
const payload = Buffer.concat([numPaths, derivationPath, bytes]);
// @FIXME: must enable blind signing in Solana Ledger App per https://github.com/project-serum/spl-token-wallet/issues/71
// See also https://github.com/project-serum/spl-token-wallet/pull/23#issuecomment-712317053
return ledgerSend(transport, INS_SIGN_MESSAGE, P1_CONFIRM, payload);
}
export async function getPublicKey(
transport: Transport,
derivationPath: Buffer = getSolanaDerivationPath(),
) {
const publicKeyBytes = await ledgerSend(
transport,
INS_GET_PUBKEY,
P1_NON_CONFIRM,
derivationPath,
);
return new PublicKey(publicKeyBytes);
}

View File

@ -0,0 +1,100 @@
import type Transport from '@ledgerhq/hw-transport';
import type { Transaction } from '@solana/web3.js';
import EventEmitter from 'eventemitter3';
import { PublicKey } from '@solana/web3.js';
import TransportWebUSB from '@ledgerhq/hw-transport-webusb';
import { notify } from '../../utils/notifications';
import { getPublicKey, signTransaction } from './core';
import { DEFAULT_PUBLIC_KEY, WalletAdapter } from '../types';
export class LedgerWalletAdapter extends EventEmitter implements WalletAdapter {
_connecting: boolean;
_publicKey: PublicKey | null;
_transport: Transport | null;
constructor() {
super();
this._connecting = false;
this._publicKey = null;
this._transport = null;
}
get publicKey() {
return this._publicKey || DEFAULT_PUBLIC_KEY;
}
get connected() {
return this._publicKey !== null;
}
get autoApprove() {
return false;
}
public async signAllTransactions(
transactions: Transaction[],
): Promise<Transaction[]> {
const result: Transaction[] = [];
for (let i = 0; i < transactions.length; i++) {
const transaction = transactions[i];
const signed = await this.signTransaction(transaction);
result.push(signed);
}
return result;
}
async signTransaction(transaction: Transaction) {
if (!this._transport || !this._publicKey) {
throw new Error('Not connected to Ledger');
}
// @TODO: account selection (derivation path changes with account)
const signature = await signTransaction(this._transport, transaction);
transaction.addSignature(this._publicKey, signature);
return transaction;
}
async connect() {
if (this._connecting) {
return;
}
this._connecting = true;
try {
// @TODO: transport selection (WebUSB, WebHID, bluetooth, ...)
this._transport = await TransportWebUSB.create();
// @TODO: account selection
this._publicKey = await getPublicKey(this._transport);
this.emit('connect', this._publicKey);
} catch (error) {
notify({
message: 'Ledger Error',
description: error.message,
});
await this.disconnect();
} finally {
this._connecting = false;
}
}
async disconnect() {
let emit = false;
if (this._transport) {
await this._transport.close();
this._transport = null;
emit = true;
}
this._connecting = false;
this._publicKey = null;
if (emit) {
this.emit('disconnect');
}
}
}

View File

@ -0,0 +1,95 @@
import EventEmitter from 'eventemitter3';
import { PublicKey, Transaction } from '@solana/web3.js';
import { notify } from '../../utils/notifications';
import { DEFAULT_PUBLIC_KEY, WalletAdapter } from '../types';
type PhantomEvent = 'disconnect' | 'connect';
type PhantomRequestMethod =
| 'connect'
| 'disconnect'
| 'signTransaction'
| 'signAllTransactions';
interface PhantomProvider {
publicKey?: PublicKey;
isConnected?: boolean;
autoApprove?: boolean;
signTransaction: (transaction: Transaction) => Promise<Transaction>;
signAllTransactions: (transactions: Transaction[]) => Promise<Transaction[]>;
connect: () => Promise<void>;
disconnect: () => Promise<void>;
on: (event: PhantomEvent, handler: (args: any) => void) => void;
request: (method: PhantomRequestMethod, params: any) => Promise<any>;
}
const SUPPORTED_PHANTOM_EVENTS: PhantomEvent[] = ['connect', 'disconnect'];
export class PhantomWalletAdapter
extends EventEmitter
implements WalletAdapter {
constructor() {
super();
this.connect = this.connect.bind(this);
window.onload = () => {
for (const event of SUPPORTED_PHANTOM_EVENTS) {
this._provider?.on(event, (...args) => this.emit(event, ...args));
}
};
}
private get _provider(): PhantomProvider | undefined {
if ((window as any)?.solana?.isPhantom) {
return (window as any).solana;
}
return undefined;
}
get connected() {
return this._provider?.isConnected || false;
}
get autoApprove() {
return this._provider?.autoApprove || false;
}
async signAllTransactions(
transactions: Transaction[],
): Promise<Transaction[]> {
if (!this._provider) {
return transactions;
}
return this._provider.signAllTransactions(transactions);
}
get publicKey() {
return this._provider?.publicKey || DEFAULT_PUBLIC_KEY;
}
async signTransaction(transaction: Transaction) {
if (!this._provider) {
return transaction;
}
return this._provider.signTransaction(transaction);
}
connect() {
if (!this._provider) {
window.open('https://phantom.app/', '_blank');
notify({
message: 'Connection Error',
description: 'Please install Phantom wallet',
});
return;
}
return this._provider?.connect();
}
disconnect() {
if (this._provider) {
this._provider.disconnect();
}
}
}

View File

@ -0,0 +1,87 @@
import EventEmitter from 'eventemitter3';
import { PublicKey, Transaction } from '@solana/web3.js';
import { notify } from '../../utils/notifications';
import { DEFAULT_PUBLIC_KEY, WalletAdapter } from '../types';
export class SolongWalletAdapter extends EventEmitter implements WalletAdapter {
_publicKey?: PublicKey;
_onProcess: boolean;
_connected: boolean;
constructor() {
super();
this._onProcess = false;
this._connected = false;
this.connect = this.connect.bind(this);
}
get connected() {
return this._connected;
}
get autoApprove() {
return false;
}
public async signAllTransactions(
transactions: Transaction[],
): Promise<Transaction[]> {
const solong = (window as any).solong;
if (solong.signAllTransactions) {
return solong.signAllTransactions(transactions);
} else {
const result: Transaction[] = [];
for (let i = 0; i < transactions.length; i++) {
const transaction = transactions[i];
const signed = await solong.signTransaction(transaction);
result.push(signed);
}
return result;
}
}
get publicKey() {
return this._publicKey || DEFAULT_PUBLIC_KEY;
}
async signTransaction(transaction: Transaction) {
return (window as any).solong.signTransaction(transaction);
}
connect() {
if (this._onProcess) {
return;
}
if ((window as any).solong === undefined) {
notify({
message: 'Solong Error',
description: 'Please install solong wallet from Chrome ',
});
return;
}
this._onProcess = true;
(window as any).solong
.selectAccount()
.then((account: any) => {
this._publicKey = new PublicKey(account);
this._connected = true;
this.emit('connect', this._publicKey);
})
.catch(() => {
this.disconnect();
})
.finally(() => {
this._onProcess = false;
});
}
disconnect() {
if (this._publicKey) {
this._publicKey = undefined;
this._connected = false;
this.emit('disconnect');
}
}
}

View File

@ -0,0 +1,16 @@
import { PublicKey, Transaction } from '@solana/web3.js';
export const DEFAULT_PUBLIC_KEY = new PublicKey(
'11111111111111111111111111111111',
);
export interface WalletAdapter {
publicKey: PublicKey;
autoApprove: boolean;
connected: boolean;
signTransaction: (transaction: Transaction) => Promise<Transaction>;
signAllTransactions: (transaction: Transaction[]) => Promise<Transaction[]>;
connect: () => any;
disconnect: () => any;
on<T>(event: string, fn: () => void): this;
}

View File

@ -1419,6 +1419,45 @@
"@types/yargs" "^15.0.0"
chalk "^4.0.0"
"@ledgerhq/devices@^5.43.0":
version "5.43.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-5.43.0.tgz#9b8ca838a7f8ece74098dc84aa6468eb7651972d"
integrity sha512-/M5ZLUBdBK7Vl2T4yNJbES3Z4w55LbPdxD9rcOBAKH/5V3V0obQv6MUasP9b7DSkwGSSLCOGZLohoT2NxK2D2A==
dependencies:
"@ledgerhq/errors" "^5.43.0"
"@ledgerhq/logs" "^5.43.0"
rxjs "^6.6.3"
semver "^7.3.4"
"@ledgerhq/errors@^5.43.0":
version "5.43.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-5.43.0.tgz#6bec77ebc31c4333a7f8d13b1f3f4d739b859b75"
integrity sha512-ZjKlUQbIn/DHXAefW3Y1VyDrlVhVqqGnXzrqbOXuDbZ2OAIfSe/A1mrlCbWt98jP/8EJQBuCzBOtnmpXIL/nYg==
"@ledgerhq/hw-transport-webusb@^5.41.0":
version "5.43.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-webusb/-/hw-transport-webusb-5.43.0.tgz#02fa4a51dd93efae73e2caa1005be9782c381066"
integrity sha512-Mf/qRn8cvK20cqqNtxFfpKVut8BvSvXkq/9HSArV7AUk+a6wga2VEvPlfk8xC551dkJlfln6+nECZ9KIEq9hFw==
dependencies:
"@ledgerhq/devices" "^5.43.0"
"@ledgerhq/errors" "^5.43.0"
"@ledgerhq/hw-transport" "^5.43.0"
"@ledgerhq/logs" "^5.43.0"
"@ledgerhq/hw-transport@^5.43.0":
version "5.43.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-5.43.0.tgz#dc9863706d31bae96aed66f193b8922a876cbf82"
integrity sha512-0S+TGmiEJOqgM2MWnolZQPVKU3oRtoDj4yUFUZts9Owbgby+hmo4dIKTvv0vs8mwknQbOZByUgh3MQOQiK70MQ==
dependencies:
"@ledgerhq/devices" "^5.43.0"
"@ledgerhq/errors" "^5.43.0"
events "^3.2.0"
"@ledgerhq/logs@^5.43.0":
version "5.43.0"
resolved "https://registry.yarnpkg.com/@ledgerhq/logs/-/logs-5.43.0.tgz#031bad4b8a3525c5e14210afde0bc09c79564026"
integrity sha512-QWfQjea3ekh9ZU+JeL2tJC9cTKLZ/JrcS0JGatLejpRYxQajvnHvHfh0dbHOKXEaXfCskEPTZ3f1kzuts742GA==
"@mrmlnc/readdir-enhanced@^2.2.1":
version "2.2.1"
resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
@ -1820,6 +1859,21 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad"
integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==
"@types/ledgerhq__hw-transport-webusb@^4.70.1":
version "4.70.1"
resolved "https://registry.yarnpkg.com/@types/ledgerhq__hw-transport-webusb/-/ledgerhq__hw-transport-webusb-4.70.1.tgz#ea80859607a46030f001bce462e1e7443b27ec43"
integrity sha512-s+bt/fU5cH7etjLrNRn2LebZZqUL+YHIWciC1T6SUw2kyFpSqQQmjcM81ZrMR/tccQGfYTy3ebrJx9ZK3Mn+HA==
dependencies:
"@types/ledgerhq__hw-transport" "*"
"@types/node" "*"
"@types/ledgerhq__hw-transport@*", "@types/ledgerhq__hw-transport@^4.21.3":
version "4.21.3"
resolved "https://registry.yarnpkg.com/@types/ledgerhq__hw-transport/-/ledgerhq__hw-transport-4.21.3.tgz#1e658da6b5d01ffab92f9660cf57121aecfa7e2c"
integrity sha512-6QveiZLsFLq9WZDk8HWAZhivoGzyz5S8WV36hpUe7KrVDaTR1fDdB+syorrNRhYbyjraAuUJrIdJR5p/7doq8g==
dependencies:
"@types/node" "*"
"@types/lodash@^4.14.159":
version "4.14.168"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008"
@ -4988,7 +5042,7 @@ eventemitter3@^4.0.0, eventemitter3@^4.0.4, 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==
@ -10977,6 +11031,13 @@ semver@^7.3.2:
dependencies:
lru-cache "^6.0.0"
semver@^7.3.4:
version "7.3.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97"
integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==
dependencies:
lru-cache "^6.0.0"
send@0.17.1:
version "0.17.1"
resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"