Sollet Chrome Extension v0 (#127)

* init

* ui changes

* injection

* upd

* adding connections page

* upd extension

* merge

* prettier

* fix

* fix

* fix
This commit is contained in:
jhl-alameda 2021-03-15 07:27:11 +08:00 committed by GitHub
parent 2664ed7d11
commit 754dd37595
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 948 additions and 196 deletions

3
.gitignore vendored
View File

@ -24,3 +24,6 @@ yarn-error.log*
.idea
.eslintcache
# generate with `build:extension` script
extension/build/*

View File

@ -0,0 +1,70 @@
const responseHandlers = new Map();
function launchPopup(message, sender, sendResponse) {
const searchParams = new URLSearchParams();
searchParams.set('origin', sender.origin);
searchParams.set('network', message.data.params.network);
searchParams.set('request', JSON.stringify(message.data));
// TODO consolidate popup dimensions
chrome.windows.getLastFocused((focusedWindow) => {
chrome.windows.create({
url: 'index.html/#' + searchParams.toString(),
type: 'popup',
width: 375,
height: 600,
top: focusedWindow.top,
left: focusedWindow.left + (focusedWindow.width - 375),
setSelfAsOpener: true,
focused: true,
});
});
responseHandlers.set(message.data.id, sendResponse);
}
function handleConnect(message, sender, sendResponse) {
chrome.storage.local.get('connectedWallets', (result) => {
const connectedWallet = (result.connectedWallets || {})[sender.origin];
if (!connectedWallet) {
launchPopup(message, sender, sendResponse);
} else {
sendResponse({
method: 'connected',
params: {
publicKey: connectedWallet.publicKey,
autoApprove: connectedWallet.autoApprove,
},
id: message.data.id,
});
}
});
}
function handleDisconnect(message, sender, sendResponse) {
chrome.storage.local.get('connectedWallets', (result) => {
delete result.connectedWallets[sender.origin];
chrome.storage.local.set(
{ connectedWallets: result.connectedWallets },
() => sendResponse({ method: 'disconnected', id: message.data.id }),
);
});
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.channel === 'sollet_contentscript_background_channel') {
if (message.data.method === 'connect') {
handleConnect(message, sender, sendResponse);
} else if (message.data.method === 'disconnect') {
handleDisconnect(message, sender, sendResponse);
} else {
launchPopup(message, sender, sendResponse);
}
// keeps response channel open
return true;
} else if (message.channel === 'sollet_extension_background_channel') {
const responseHandler = responseHandlers.get(message.data.id);
responseHandlers.delete(message.data.id);
responseHandler(message.data);
}
});

View File

@ -0,0 +1,24 @@
const container = document.head || document.documentElement;
const scriptTag = document.createElement('script');
scriptTag.setAttribute('async', 'false');
scriptTag.src = chrome.runtime.getURL('script.js');
container.insertBefore(scriptTag, container.children[0]);
container.removeChild(scriptTag);
window.addEventListener('sollet_injected_script_message', (event) => {
chrome.runtime.sendMessage(
{
channel: 'sollet_contentscript_background_channel',
data: event.detail,
},
(response) => {
// Can return null response if window is killed
if (!response) {
return;
}
window.dispatchEvent(
new CustomEvent('sollet_contentscript_message', { detail: response }),
);
},
);
});

View File

@ -0,0 +1,35 @@
{
"name": "Sollet",
"description": "Solana SPL Token Wallet",
"version": "0.1",
"browser_action": {
"default_popup": "index.html",
"default_title": "Open the popup"
},
"manifest_version": 2,
"icons": {
"16": "favicon.ico",
"192": "logo192.png",
"512": "logo512.png"
},
"background": {
"persistent": false,
"scripts": ["background.js"]
},
"permissions": [
"storage"
],
"content_scripts": [
{
"matches": ["file://*/*", "http://*/*", "https://*/*"],
"js": [
"contentscript.js"
],
"run_at": "document_start",
"all_frames": true
}
],
"web_accessible_resources": ["script.js"],
"content_security_policy": "script-src 'self' 'sha256-ek+jXksbUr00x+EdLLqiv69t8hATh5rPjHVvVVGA9ms='; object-src 'self'"
}

15
extension/src/script.js Normal file
View File

@ -0,0 +1,15 @@
window.sollet = {
postMessage: (message) => {
const listener = (event) => {
if (event.detail.id === message.id) {
window.removeEventListener('sollet_contentscript_message', listener);
window.postMessage(event.detail);
}
};
window.addEventListener('sollet_contentscript_message', listener);
window.dispatchEvent(
new CustomEvent('sollet_injected_script_message', { detail: message }),
);
},
};

View File

@ -37,14 +37,21 @@
"predeploy": "git pull --ff-only && yarn && yarn build",
"deploy": "gh-pages -d build",
"fix": "run-s fix:*",
"fix:prettier": "prettier \"src/**/*.js\" --write",
"fix:prettier": "prettier \"src/**/*.js\" \"extension/src/*.js\" --write",
"start": "react-scripts start",
"build": "react-scripts build",
"build:extension": "yarn build && cp -a ./build/. ./extension/build/ && yarn build:extension-scripts",
"build:extension-scripts": "cp -a ./extension/src/. ./extension/build/.",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
"env": {
"browser": true,
"es6": true,
"webextensions": true
},
"extends": ["react-app"]
},
"jest": {
"transformIgnorePatterns": [

View File

@ -10,10 +10,14 @@ import NavigationFrame from './components/NavigationFrame';
import { ConnectionProvider } from './utils/connection';
import WalletPage from './pages/WalletPage';
import { useWallet, WalletProvider } from './utils/wallet';
import { ConnectedWalletsProvider } from './utils/connected-wallets';
import LoadingIndicator from './components/LoadingIndicator';
import { SnackbarProvider } from 'notistack';
import PopupPage from './pages/PopupPage';
import LoginPage from './pages/LoginPage';
import ConnectionsPage from './pages/ConnectionsPage';
import { isExtension } from './utils/utils';
import { PageProvider, usePage } from './utils/page';
export default function App() {
// TODO: add toggle for dark mode
@ -25,6 +29,8 @@ export default function App() {
type: prefersDarkMode ? 'dark' : 'light',
primary: blue,
},
// TODO consolidate popup dimensions
ext: '450',
}),
[prefersDarkMode],
);
@ -34,6 +40,22 @@ export default function App() {
return null;
}
let appElement = (
<NavigationFrame>
<Suspense fallback={<LoadingIndicator />}>
<PageContents />
</Suspense>
</NavigationFrame>
);
if (isExtension) {
appElement = (
<ConnectedWalletsProvider>
<PageProvider>{appElement}</PageProvider>
</ConnectedWalletsProvider>
);
}
return (
<Suspense fallback={<LoadingIndicator />}>
<ThemeProvider theme={theme}>
@ -41,13 +63,7 @@ export default function App() {
<ConnectionProvider>
<SnackbarProvider maxSnack={5} autoHideDuration={8000}>
<WalletProvider>
<NavigationFrame>
<Suspense fallback={<LoadingIndicator />}>
<PageContents />
</Suspense>
</NavigationFrame>
</WalletProvider>
<WalletProvider>{appElement}</WalletProvider>
</SnackbarProvider>
</ConnectionProvider>
</ThemeProvider>
@ -57,11 +73,16 @@ export default function App() {
function PageContents() {
const wallet = useWallet();
const [page] = usePage();
if (!wallet) {
return <LoginPage />;
}
if (window.opener) {
return <PopupPage opener={window.opener} />;
}
return <WalletPage />;
if (page === 'wallet') {
return <WalletPage />;
} else if (page === 'connections') {
return <ConnectionsPage />;
}
}

View File

@ -19,7 +19,7 @@ import Link from '@material-ui/core/Link';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import { makeStyles } from '@material-ui/core/styles';
import { abbreviateAddress } from '../utils/utils';
import { abbreviateAddress, useIsExtensionWidth } from '../utils/utils';
import Button from '@material-ui/core/Button';
import SendIcon from '@material-ui/icons/Send';
import ReceiveIcon from '@material-ui/icons/WorkOutline';
@ -106,6 +106,7 @@ export default function BalancesList() {
const [showMergeAccounts, setShowMergeAccounts] = useState(false);
const [sortAccounts, setSortAccounts] = useState(SortAccounts.None);
const { accounts, setAccountName } = useWalletSelector();
const isExtensionWidth = useIsExtensionWidth();
// Dummy var to force rerenders on demand.
const [, setForceUpdate] = useState(false);
const selectedAccount = accounts.find((a) => a.isSelected);
@ -182,12 +183,19 @@ export default function BalancesList() {
});
}, [sortedPublicKeys, setUsdValuesCallback]);
const iconSize = isExtensionWidth ? 'small' : 'medium';
return (
<Paper>
<AppBar position="static" color="default" elevation={1}>
<Toolbar>
<Typography variant="h6" style={{ flexGrow: 1 }} component="h2">
{selectedAccount && selectedAccount.name} Balances{' '}
<Typography
variant="h6"
style={{ flexGrow: 1, fontSize: isExtensionWidth && '1rem' }}
component="h2"
>
{selectedAccount && selectedAccount.name}
{isExtensionWidth ? '' : ' Balances'}{' '}
{allTokensLoaded && (
<>({numberFormat.format(totalUsdValue.toFixed(2))})</>
)}
@ -196,23 +204,33 @@ export default function BalancesList() {
selectedAccount.name !== 'Main account' &&
selectedAccount.name !== 'Hardware wallet' && (
<Tooltip title="Edit Account Name" arrow>
<IconButton onClick={() => setShowEditAccountNameDialog(true)}>
<IconButton
size={iconSize}
onClick={() => setShowEditAccountNameDialog(true)}
>
<EditIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title="Merge Accounts" arrow>
<IconButton onClick={() => setShowMergeAccounts(true)}>
<IconButton
size={iconSize}
onClick={() => setShowMergeAccounts(true)}
>
<MergeType />
</IconButton>
</Tooltip>
<Tooltip title="Add Token" arrow>
<IconButton onClick={() => setShowAddTokenDialog(true)}>
<IconButton
size={iconSize}
onClick={() => setShowAddTokenDialog(true)}
>
<AddIcon />
</IconButton>
</Tooltip>
<Tooltip title="Sort Accounts" arrow>
<IconButton
size={iconSize}
onClick={() => {
switch (sortAccounts) {
case SortAccounts.None:
@ -234,6 +252,7 @@ export default function BalancesList() {
</Tooltip>
<Tooltip title="Refresh" arrow>
<IconButton
size={iconSize}
onClick={() => {
refreshWalletPublicKeys(wallet);
publicKeys.map((publicKey) =>
@ -298,6 +317,7 @@ export function BalanceListItem({ publicKey, expandable, setUsdValue }) {
const classes = useStyles();
const connection = useConnection();
const [open, setOpen] = useState(false);
const isExtensionWidth = useIsExtensionWidth();
const [, setForceUpdate] = useState(false);
// Valid states:
// * undefined => loading.
@ -344,6 +364,13 @@ export function BalanceListItem({ publicKey, expandable, setUsdValue }) {
}
let { amount, decimals, mint, tokenName, tokenSymbol } = balanceInfo;
tokenName = tokenName ?? abbreviateAddress(mint);
let displayName;
if (isExtensionWidth) {
displayName = tokenSymbol ?? tokenName;
} else {
displayName = tokenName + (tokenSymbol ? ` (${tokenSymbol})` : '');
}
// Fetch and cache the associated token address.
if (wallet && wallet.publicKey && mint) {
@ -383,7 +410,7 @@ export function BalanceListItem({ publicKey, expandable, setUsdValue }) {
return false;
})();
const subtitle = (
const subtitle = isExtensionWidth ? undefined : (
<div style={{ display: 'flex', height: '20px', overflow: 'hidden' }}>
{isAssociatedToken && (
<div
@ -430,8 +457,7 @@ export function BalanceListItem({ publicKey, expandable, setUsdValue }) {
primary={
<>
{balanceFormat.format(amount / Math.pow(10, decimals))}{' '}
{tokenName ?? abbreviateAddress(mint)}
{tokenSymbol ? ` (${tokenSymbol})` : null}
{displayName}
</>
}
secondary={subtitle}
@ -497,6 +523,7 @@ function BalanceListItemDetails({ publicKey, serumMarkets, balanceInfo }) {
balanceInfo.mint?.toBase58(),
publicKey.toBase58(),
]);
const isExtensionWidth = useIsExtensionWidth();
if (!balanceInfo) {
return <LoadingIndicator delay={0} />;
@ -514,6 +541,75 @@ function BalanceListItemDetails({ publicKey, serumMarkets, balanceInfo }) {
: undefined
: undefined;
const additionalInfo = isExtensionWidth ? undefined : (
<>
<Typography variant="body2" className={classes.address}>
Deposit Address: {publicKey.toBase58()}
</Typography>
<Typography variant="body2">
Token Name: {tokenName ?? 'Unknown'}
</Typography>
<Typography variant="body2">
Token Symbol: {tokenSymbol ?? 'Unknown'}
</Typography>
{mint ? (
<Typography variant="body2" className={classes.address}>
Token Address: {mint.toBase58()}
</Typography>
) : null}
<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
</Link>
</Typography>
{market && (
<Typography variant="body2">
<Link
href={`https://dex.projectserum.com/#/market/${market}`}
target="_blank"
rel="noopener"
>
View on Serum
</Link>
</Typography>
)}
{swapInfo && swapInfo.coin.erc20Contract && (
<Typography variant="body2">
<Link
href={
`https://etherscan.io/token/${swapInfo.coin.erc20Contract}` +
urlSuffix
}
target="_blank"
rel="noopener"
>
View on Ethereum
</Link>
</Typography>
)}
</div>
{exportNeedsDisplay && wallet.allowsExport && (
<div>
<Typography variant="body2">
<Link href={'#'} onClick={(e) => setExportAccDialogOpen(true)}>
Export
</Link>
</Typography>
</div>
)}
</div>
</>
);
return (
<>
{wallet.allowsExport && (
@ -562,70 +658,7 @@ function BalanceListItemDetails({ publicKey, serumMarkets, balanceInfo }) {
</Button>
) : null}
</div>
<Typography variant="body2" className={classes.address}>
Deposit Address: {publicKey.toBase58()}
</Typography>
<Typography variant="body2">
Token Name: {tokenName ?? 'Unknown'}
</Typography>
<Typography variant="body2">
Token Symbol: {tokenSymbol ?? 'Unknown'}
</Typography>
{mint ? (
<Typography variant="body2" className={classes.address}>
Token Address: {mint.toBase58()}
</Typography>
) : null}
<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
</Link>
</Typography>
{market && (
<Typography variant="body2">
<Link
href={`https://dex.projectserum.com/#/market/${market}`}
target="_blank"
rel="noopener"
>
View on Serum
</Link>
</Typography>
)}
{swapInfo && swapInfo.coin.erc20Contract && (
<Typography variant="body2">
<Link
href={
`https://etherscan.io/token/${swapInfo.coin.erc20Contract}` +
urlSuffix
}
target="_blank"
rel="noopener"
>
View on Ethereum
</Link>
</Typography>
)}
</div>
{exportNeedsDisplay && wallet.allowsExport && (
<div>
<Typography variant="body2">
<Link href={'#'} onClick={(e) => setExportAccDialogOpen(true)}>
Export
</Link>
</Typography>
</div>
)}
</div>
{additionalInfo}
</div>
<SendDialog
open={sendDialogOpen}

View File

@ -0,0 +1,11 @@
import React from 'react';
import SvgIcon from '@material-ui/core/SvgIcon';
export default function ConnectionIcon() {
return (
<SvgIcon style={{ height: 28, width: 28, margin: -2 }} viewBox="0 0 24 24">
<path fill="none" d="M0 0h24v24H0z" />
<path d="M7.76 16.24C6.67 15.16 6 13.66 6 12s.67-3.16 1.76-4.24l1.42 1.42C8.45 9.9 8 10.9 8 12c0 1.1.45 2.1 1.17 2.83l-1.41 1.41zm8.48 0C17.33 15.16 18 13.66 18 12s-.67-3.16-1.76-4.24l-1.42 1.42C15.55 9.9 16 10.9 16 12c0 1.1-.45 2.1-1.17 2.83l1.41 1.41zM12 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm8 2c0 2.21-.9 4.21-2.35 5.65l1.42 1.42C20.88 17.26 22 14.76 22 12s-1.12-5.26-2.93-7.07l-1.42 1.42A7.94 7.94 0 0120 12zM6.35 6.35L4.93 4.93C3.12 6.74 2 9.24 2 12s1.12 5.26 2.93 7.07l1.42-1.42C4.9 16.21 4 14.21 4 12s.9-4.21 2.35-5.65z" />
</SvgIcon>
);
}

View File

@ -0,0 +1,153 @@
import React, { useState } from 'react';
import {
AppBar,
Button,
Collapse,
List,
ListItem,
ListItemIcon,
ListItemText,
makeStyles,
Paper,
Toolbar,
Typography,
} from '@material-ui/core';
import DeleteIcon from '@material-ui/icons/Delete';
import { DoneAll, ExpandLess, ExpandMore } from '@material-ui/icons';
import { useConnectedWallets } from '../utils/connected-wallets';
import { useIsExtensionWidth } from '../utils/utils';
import { useWalletSelector } from '../utils/wallet';
export default function ConnectionsList() {
const isExtensionWidth = useIsExtensionWidth();
const connectedWallets = useConnectedWallets();
return (
<Paper>
<AppBar position="static" color="default" elevation={1}>
<Toolbar>
<Typography
variant="h6"
style={{ flexGrow: 1, fontSize: isExtensionWidth && '1rem' }}
component="h2"
>
Connected Dapps
</Typography>
</Toolbar>
</AppBar>
<List disablePadding>
{Object.entries(connectedWallets).map(([origin, connectedWallet]) => (
<ConnectionsListItem
origin={origin}
connectedWallet={connectedWallet}
key={origin}
/>
))}
</List>
</Paper>
);
}
const ICON_SIZE = 28;
const IMAGE_SIZE = 24;
const useStyles = makeStyles((theme) => ({
itemDetails: {
marginLeft: theme.spacing(3),
marginRight: theme.spacing(3),
marginBottom: theme.spacing(2),
},
buttonContainer: {
display: 'flex',
justifyContent: 'space-evenly',
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
},
listItemIcon: {
backgroundColor: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: ICON_SIZE,
width: ICON_SIZE,
borderRadius: ICON_SIZE / 2,
},
listItemImage: {
height: IMAGE_SIZE,
width: IMAGE_SIZE,
borderRadius: IMAGE_SIZE / 2,
},
}));
function ConnectionsListItem({ origin, connectedWallet }) {
const classes = useStyles();
const [open, setOpen] = useState(false);
// TODO better way to get high res icon?
const appleIconUrl = origin + '/apple-touch-icon.png';
const faviconUrl = origin + '/favicon.ico';
const [iconUrl, setIconUrl] = useState(appleIconUrl);
const { accounts } = useWalletSelector();
// TODO better way to do this
const account = accounts.find(
(account) => account.address.toBase58() === connectedWallet.publicKey,
);
const setAutoApprove = (autoApprove) => {
chrome.storage.local.get('connectedWallets', (result) => {
result.connectedWallets[origin].autoApprove = autoApprove;
chrome.storage.local.set({ connectedWallets: result.connectedWallets });
});
};
const disconnectWallet = () => {
chrome.storage.local.get('connectedWallets', (result) => {
delete result.connectedWallets[origin];
chrome.storage.local.set({ connectedWallets: result.connectedWallets });
});
};
return (
<>
<ListItem button onClick={() => setOpen((open) => !open)}>
<ListItemIcon>
<div className={classes.listItemIcon}>
<img
src={iconUrl}
onError={() => setIconUrl(faviconUrl)}
className={classes.listItemImage}
alt={origin}
/>
</div>
</ListItemIcon>
<div style={{ display: 'flex', flex: 1 }}>
<ListItemText primary={origin} secondary={account.name} />
</div>
{open ? <ExpandLess /> : <ExpandMore />}
</ListItem>
<Collapse in={open} timeout="auto" unmountOnExit>
<div class={classes.itemDetails}>
<div class={classes.buttonContainer}>
<Button
variant={connectedWallet.autoApprove ? 'contained' : 'outlined'}
color="primary"
size="small"
startIcon={<DoneAll />}
onClick={() => setAutoApprove(!connectedWallet.autoApprove)}
>
{connectedWallet.autoApprove ? 'Auto-Approved' : 'Auto-Approve'}
</Button>
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<DeleteIcon />}
onClick={disconnectWallet}
>
Disconnect
</Button>
</div>
</div>
</Collapse>
</>
);
}

View File

@ -62,8 +62,9 @@ export default function DebugButtons() {
const noSol = amount === 0;
const requestAirdropDisabled = endpoint === MAINNET_URL;
const spacing = 24;
return (
<div style={{ display: 'flex' }}>
<div style={{ display: 'flex', marginLeft: spacing }}>
<Tooltip
title={
requestAirdropDisabled
@ -95,7 +96,7 @@ export default function DebugButtons() {
color="primary"
onClick={mintTestToken}
disabled={sending || noSol}
style={{ marginLeft: 24 }}
style={{ marginLeft: spacing }}
>
Mint Test Token
</Button>

View File

@ -26,14 +26,26 @@ import AddAccountDialog from './AddAccountDialog';
import DeleteMnemonicDialog from './DeleteMnemonicDialog';
import AddHardwareWalletDialog from './AddHarwareWalletDialog';
import { ExportMnemonicDialog } from './ExportAccountDialog.js';
import {
isExtension,
isExtensionPopup,
useIsExtensionWidth,
} from '../utils/utils';
import ConnectionIcon from './ConnectionIcon';
import { Badge } from '@material-ui/core';
import { useConnectedWallets } from '../utils/connected-wallets';
import { usePage } from '../utils/page';
import { MonetizationOn, OpenInNew } from '@material-ui/icons';
const useStyles = makeStyles((theme) => ({
content: {
flexGrow: 1,
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
paddingLeft: theme.spacing(1),
paddingRight: theme.spacing(1),
[theme.breakpoints.up(theme.ext)]: {
paddingTop: theme.spacing(3),
paddingLeft: theme.spacing(1),
paddingRight: theme.spacing(1),
},
},
title: {
flexGrow: 1,
@ -44,23 +56,128 @@ const useStyles = makeStyles((theme) => ({
menuItemIcon: {
minWidth: 32,
},
badge: {
backgroundColor: theme.palette.success.main,
color: theme.palette.text.main,
height: 16,
width: 16,
},
}));
export default function NavigationFrame({ children }) {
const classes = useStyles();
const isExtensionWidth = useIsExtensionWidth();
return (
<>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" className={classes.title} component="h1">
Solana SPL Token Wallet
{isExtensionWidth ? 'Sollet' : 'Solana SPL Token Wallet'}
</Typography>
<WalletSelector />
<NetworkSelector />
<NavigationButtons />
</Toolbar>
</AppBar>
<main className={classes.content}>{children}</main>
<Footer />
{!isExtensionWidth && <Footer />}
</>
);
}
function NavigationButtons() {
const isExtensionWidth = useIsExtensionWidth();
const [page] = usePage();
if (isExtensionPopup) {
return null;
}
let elements = [];
if (page === 'wallet') {
elements = [
isExtension && <ConnectionsButton />,
<WalletSelector />,
<NetworkSelector />,
];
} else if (page === 'connections') {
elements = [<WalletButton />];
}
if (isExtension && isExtensionWidth) {
elements.push(<ExpandButton />);
}
return elements;
}
function ExpandButton() {
const onClick = () => {
window.open(chrome.extension.getURL('index.html'), '_blank');
};
return (
<Tooltip title="Expand View">
<IconButton color="inherit" onClick={onClick}>
<OpenInNew />
</IconButton>
</Tooltip>
);
}
function WalletButton() {
const classes = useStyles();
const setPage = usePage()[1];
const onClick = () => setPage('wallet');
return (
<>
<Hidden smUp>
<Tooltip title="Wallet Balances">
<IconButton color="inherit" onClick={onClick}>
<MonetizationOn />
</IconButton>
</Tooltip>
</Hidden>
<Hidden xsDown>
<Button color="inherit" onClick={onClick} className={classes.button}>
Wallet
</Button>
</Hidden>
</>
);
}
function ConnectionsButton() {
const classes = useStyles();
const setPage = usePage()[1];
const onClick = () => setPage('connections');
const connectedWallets = useConnectedWallets();
const connectionAmount = Object.keys(connectedWallets).length;
return (
<>
<Hidden smUp>
<Tooltip title="Manage Connections">
<IconButton color="inherit" onClick={onClick}>
<Badge
badgeContent={connectionAmount}
classes={{ badge: classes.badge }}
>
<ConnectionIcon />
</Badge>
</IconButton>
</Tooltip>
</Hidden>
<Hidden xsDown>
<Badge
badgeContent={connectionAmount}
classes={{ badge: classes.badge }}
>
<Button color="inherit" onClick={onClick} className={classes.button}>
Connections
</Button>
</Badge>
</Hidden>
</>
);
}

View File

@ -2,7 +2,13 @@ import React from 'react';
import Link from '@material-ui/core/Link';
import Typography from '@material-ui/core/Typography';
export default function LabelValue({ label, value, link = false, onClick, gutterBottom }) {
export default function LabelValue({
label,
value,
link = false,
onClick,
gutterBottom,
}) {
return (
<Typography gutterBottom={gutterBottom}>
{label}:{' '}

View File

@ -3,7 +3,7 @@ import Typography from '@material-ui/core/Typography';
import LabelValue from './LabelValue';
import { useWallet } from '../../utils/wallet';
export default function Neworder({ instruction, onOpenAddress, v3=false }) {
export default function Neworder({ instruction, onOpenAddress, v3 = false }) {
const wallet = useWallet();
const { data, market, marketInfo } = instruction;
const marketLabel =

View File

@ -3,7 +3,7 @@ import LabelValue from './LabelValue';
import Typography from '@material-ui/core/Typography';
export default function UnknownInstruction({ instruction, onOpenAddress }) {
return (
return (
<>
<Typography
variant="subtitle1"
@ -13,14 +13,15 @@ export default function UnknownInstruction({ instruction, onOpenAddress }) {
Unknown instruction:
</Typography>
<LabelValue
key='Program'
label='Program'
key="Program"
label="Program"
value={instruction.programId?.toBase58()}
link={true}
gutterBottom={true}
onClick={() => onOpenAddress(instruction.programId.toBase58())}
/>
{instruction.accountMetas && instruction.accountMetas.map((accountMeta, index) => {
{instruction.accountMetas &&
instruction.accountMetas.map((accountMeta, index) => {
return (
<>
<LabelValue
@ -30,7 +31,9 @@ export default function UnknownInstruction({ instruction, onOpenAddress }) {
link={true}
onClick={() => onOpenAddress(accountMeta.publicKey.toBase58())}
/>
<Typography gutterBottom>Writable: {accountMeta.isWritable.toString()}</Typography>
<Typography gutterBottom>
Writable: {accountMeta.isWritable.toString()}
</Typography>
</>
);
})}

View File

@ -7,12 +7,40 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* TODO consolidate popup dimensions */
html,
body,
#root {
min-width: 375px;
min-height: 600px;
height: 100%;
}
/* TODO consolidate popup dimensions */
@media screen and (max-height: 700px) {
html,
body,
#root {
max-height: 600px;
}
}
/* TODO consolidate popup dimensions */
@media screen and (max-width: 450px) {
html,
body,
#root {
max-width: 375px;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
#root::-webkit-scrollbar {
width: 1px;
background: transparent;
}
}
#root {
display: flex;
flex-direction: column;

View File

@ -0,0 +1,31 @@
import React from 'react';
import Container from '@material-ui/core/Container';
import Grid from '@material-ui/core/Grid';
import { makeStyles } from '@material-ui/core';
import { useIsExtensionWidth } from '../utils/utils';
import ConnectionsList from '../components/ConnectionsList';
const useStyles = makeStyles((theme) => ({
container: {
[theme.breakpoints.down(theme.ext)]: {
padding: 0,
},
[theme.breakpoints.up(theme.ext)]: {
maxWidth: 'md',
},
},
}));
export default function ConnectionsPage() {
const classes = useStyles();
const isExtensionWidth = useIsExtensionWidth();
return (
<Container fixed maxWidth="md" className={classes.container}>
<Grid container spacing={isExtensionWidth ? 0 : 3}>
<Grid item xs={12}>
<ConnectionsList />
</Grid>
</Grid>
</Container>
);
}

View File

@ -5,7 +5,12 @@ import React, {
useRef,
useState,
} from 'react';
import { useWallet, useWalletPublicKeys } from '../utils/wallet';
import {
useWallet,
useWalletPublicKeys,
useWalletSelector,
} from '../utils/wallet';
import { PublicKey } from '@solana/web3.js';
import { decodeMessage } from '../utils/transactions';
import { useConnection, useSolanaExplorerUrlSuffix } from '../utils/connection';
import {
@ -31,31 +36,74 @@ import WarningIcon from '@material-ui/icons/Warning';
import SystemInstruction from '../components/instructions/SystemInstruction';
import DexInstruction from '../components/instructions/DexInstruction';
import TokenInstruction from '../components/instructions/TokenInstruction';
import { useLocalStorageState } from '../utils/utils';
import { useLocalStorageState, isExtension } from '../utils/utils';
function getInitialRequests() {
if (!isExtension) {
return [];
}
// TODO CHECK OPENER (?)
const urlParams = new URLSearchParams(window.location.hash.slice(1));
return [JSON.parse(urlParams.get('request'))];
}
export default function PopupPage({ opener }) {
const wallet = useWallet();
const origin = useMemo(() => {
let params = new URLSearchParams(window.location.hash.slice(1));
return params.get('origin');
}, []);
const selectedWallet = useWallet();
const { accounts, setWalletSelector } = useWalletSelector();
const [wallet, setWallet] = useState(isExtension ? null : selectedWallet);
const [connectedAccount, setConnectedAccount] = useState(null);
const hasConnectedAccount = !!connectedAccount;
const [requests, setRequests] = useState(getInitialRequests);
const [autoApprove, setAutoApprove] = useState(false);
const postMessage = useCallback(
(message) => {
opener.postMessage({ jsonrpc: '2.0', ...message }, origin);
if (isExtension) {
chrome.runtime.sendMessage({
channel: 'sollet_extension_background_channel',
data: message,
});
} else {
opener.postMessage({ jsonrpc: '2.0', ...message }, origin);
}
},
[opener, origin],
);
const [connectedAccount, setConnectedAccount] = useState(null);
const hasConnectedAccount = !!connectedAccount;
const [requests, setRequests] = useState([]);
const [autoApprove, setAutoApprove] = useState(false);
// (Extension only) Fetch connected wallet for site from local storage.
useEffect(() => {
if (isExtension) {
chrome.storage.local.get('connectedWallets', (result) => {
const connectedWallet = (result.connectedWallets || {})[origin];
if (connectedWallet) {
setWalletSelector(connectedWallet.selector);
setConnectedAccount(new PublicKey(connectedWallet.publicKey));
setAutoApprove(connectedWallet.autoApprove);
} else {
setConnectedAccount(selectedWallet.publicKey);
}
});
}
}, [origin, setWalletSelector, selectedWallet]);
// (Extension only) Set wallet once connectedWallet is retrieved.
useEffect(() => {
if (isExtension && connectedAccount) {
setWallet(selectedWallet);
}
}, [connectedAccount, selectedWallet]);
// Send a disconnect event if this window is closed, this component is
// unmounted, or setConnectedAccount(null) is called.
useEffect(() => {
if (hasConnectedAccount) {
if (hasConnectedAccount && !isExtension) {
function unloadHandler() {
postMessage({ method: 'disconnected' });
}
@ -65,11 +113,15 @@ export default function PopupPage({ opener }) {
window.removeEventListener('beforeunload', unloadHandler);
};
}
}, [hasConnectedAccount, postMessage]);
}, [hasConnectedAccount, postMessage, origin]);
// Disconnect if the user switches to a different wallet.
useEffect(() => {
if (connectedAccount && !connectedAccount.equals(wallet.publicKey)) {
if (
wallet &&
connectedAccount &&
!connectedAccount.equals(wallet.publicKey)
) {
setConnectedAccount(null);
}
}, [connectedAccount, wallet]);
@ -92,92 +144,132 @@ export default function PopupPage({ opener }) {
return () => window.removeEventListener('message', messageHandler);
}, [origin, postMessage]);
if (!connectedAccount || !connectedAccount.equals(wallet.publicKey)) {
const request = requests[0];
const popRequest = () => setRequests((requests) => requests.slice(1));
if (requests.length === 0) {
if (isExtension) {
window.close();
} else {
focusParent();
}
return (
<Typography>
{isExtension
? 'Submitting...'
: 'Please keep this window open in the background.'}
</Typography>
);
}
if (!wallet) {
return <Typography>Loading wallet...</Typography>;
}
const mustConnect =
!connectedAccount || !connectedAccount.equals(wallet.publicKey);
// We must detect when to show the connection form on the website as it is not sent as a request.
if (
(isExtension && request.method === 'connect') ||
(!isExtension && mustConnect)
) {
// Approve the parent page to connect to this wallet.
function connect(autoApprove) {
setConnectedAccount(wallet.publicKey);
if (isExtension) {
chrome.storage.local.get('connectedWallets', (result) => {
// TODO better way to do this
const account = accounts.find((account) =>
account.address.equals(wallet.publicKey),
);
const connectedWallets = {
...(result.connectedWallets || {}),
[origin]: {
publicKey: wallet.publicKey.toBase58(),
selector: account.selector,
autoApprove,
},
};
chrome.storage.local.set({ connectedWallets });
});
}
postMessage({
method: 'connected',
params: { publicKey: wallet.publicKey.toBase58(), autoApprove },
id: isExtension ? request.id : undefined,
});
setAutoApprove(autoApprove);
focusParent();
if (!isExtension) {
focusParent();
} else {
popRequest();
}
}
return <ApproveConnectionForm origin={origin} onApprove={connect} />;
}
if (requests.length > 0) {
const request = requests[0];
assert(
request.method === 'signTransaction' ||
request.method === 'signAllTransactions',
assert(
(request.method === 'signTransaction' ||
request.method === 'signAllTransactions') &&
wallet,
);
let messages =
request.method === 'signTransaction'
? [bs58.decode(request.params.message)]
: request.params.messages.map((m) => bs58.decode(m));
async function onApprove() {
popRequest();
if (request.method === 'signTransaction') {
sendSignature(messages[0]);
} else {
sendAllSignatures(messages);
}
}
async function sendSignature(message) {
postMessage({
result: {
signature: await wallet.createSignature(message),
publicKey: wallet.publicKey.toBase58(),
},
id: request.id,
});
}
async function sendAllSignatures(messages) {
const signatures = await Promise.all(
messages.map((m) => wallet.createSignature(m)),
);
postMessage({
result: {
signatures,
publicKey: wallet.publicKey.toBase58(),
},
id: request.id,
});
}
let messages =
request.method === 'signTransaction'
? [bs58.decode(request.params.message)]
: request.params.messages.map((m) => bs58.decode(m));
async function onApprove() {
setRequests((requests) => requests.slice(1));
if (request.method === 'signTransaction') {
sendSignature(messages[0]);
} else {
sendAllSignatures(messages);
}
if (requests.length === 1) {
focusParent();
}
}
async function sendSignature(message) {
postMessage({
result: {
signature: await wallet.createSignature(message),
publicKey: wallet.publicKey.toBase58(),
},
id: request.id,
});
}
async function sendAllSignatures(messages) {
const signatures = await Promise.all(
messages.map((m) => wallet.createSignature(m)),
);
postMessage({
result: {
signatures,
publicKey: wallet.publicKey.toBase58(),
},
id: request.id,
});
}
function sendReject() {
setRequests((requests) => requests.slice(1));
postMessage({
error: 'Transaction cancelled',
id: request.id,
});
if (requests.length === 1) {
focusParent();
}
}
return (
<ApproveSignatureForm
key={request.id}
autoApprove={autoApprove}
origin={origin}
messages={messages}
onApprove={onApprove}
onReject={sendReject}
/>
);
function sendReject() {
popRequest();
postMessage({
error: 'Transaction cancelled',
id: request.id,
});
}
return (
<Typography>Please keep this window open in the background.</Typography>
<ApproveSignatureForm
key={request.id}
autoApprove={autoApprove}
origin={origin}
messages={messages}
onApprove={onApprove}
onReject={sendReject}
/>
);
}
@ -234,6 +326,11 @@ const useStyles = makeStyles((theme) => ({
function ApproveConnectionForm({ origin, onApprove }) {
const wallet = useWallet();
const { accounts } = useWalletSelector();
// TODO better way to do this
const account = accounts.find((account) =>
account.address.equals(wallet.publicKey),
);
const classes = useStyles();
const [autoApprove, setAutoApprove] = useState(false);
let [dismissed, setDismissed] = useLocalStorageState(
@ -249,7 +346,10 @@ function ApproveConnectionForm({ origin, onApprove }) {
<div className={classes.connection}>
<Typography>{origin}</Typography>
<ImportExportIcon fontSize="large" />
<Typography>{wallet.publicKey.toBase58()}</Typography>
<Typography>{account.name}</Typography>
<Typography variant="caption">
({wallet.publicKey.toBase58()})
</Typography>
</div>
<Typography>Only connect with sites you trust.</Typography>
<Divider className={classes.divider} />
@ -330,7 +430,11 @@ function isSafeInstruction(publicKeys, owner, txInstructions) {
} else {
if (instruction.type === 'raydium') {
// Whitelist raydium for now.
} else if (['cancelOrder', 'matchOrders', 'cancelOrderV3'].includes(instruction.type)) {
} else if (
['cancelOrder', 'matchOrders', 'cancelOrderV3'].includes(
instruction.type,
)
) {
// It is always considered safe to cancel orders, match orders
} else if (instruction.type === 'systemCreate') {
let { newAccountPubkey } = instruction.data;
@ -500,10 +604,19 @@ function ApproveSignatureForm({
);
case 'newOrderV3':
return (
<NewOrder instruction={instruction} onOpenAddress={onOpenAddress} v3={true} />
<NewOrder
instruction={instruction}
onOpenAddress={onOpenAddress}
v3={true}
/>
);
default:
return <UnknownInstruction instruction={instruction} onOpenAddress={onOpenAddress} />;
return (
<UnknownInstruction
instruction={instruction}
onOpenAddress={onOpenAddress}
/>
);
}
};

View File

@ -4,13 +4,33 @@ import BalancesList from '../components/BalancesList';
import Grid from '@material-ui/core/Grid';
import { useIsProdNetwork } from '../utils/connection';
import DebugButtons from '../components/DebugButtons';
import { makeStyles } from '@material-ui/core';
import { useIsExtensionWidth } from '../utils/utils';
const useStyles = makeStyles((theme) => ({
container: {
[theme.breakpoints.down(theme.ext)]: {
padding: 0,
},
[theme.breakpoints.up(theme.ext)]: {
maxWidth: 'md',
},
},
balancesContainer: {
[theme.breakpoints.down(theme.ext)]: {
marginBottom: 24,
},
},
}));
export default function WalletPage() {
const classes = useStyles();
const isProdNetwork = useIsProdNetwork();
const isExtensionWidth = useIsExtensionWidth();
return (
<Container fixed maxWidth="md">
<Grid container spacing={3}>
<Grid item xs={12}>
<Container fixed maxWidth="md" className={classes.container}>
<Grid container spacing={isExtensionWidth ? 0 : 3}>
<Grid item xs={12} className={classes.balancesContainer}>
<BalancesList />
</Grid>
{isProdNetwork ? null : (

View File

@ -0,0 +1,31 @@
import { createContext, useContext, useEffect, useState } from 'react';
const ConnectedWalletsContext = createContext({});
export const ConnectedWalletsProvider = ({ children }) => {
const [connectedWallets, setConnectedWallets] = useState({});
useEffect(() => {
const updateConnectionAmount = () => {
chrome.storage.local.get('connectedWallets', (result) => {
setConnectedWallets(result.connectedWallets || {});
});
};
const listener = (changes) => {
if ('connectedWallets' in changes) {
updateConnectionAmount();
}
};
updateConnectionAmount();
chrome.storage.local.onChanged.addListener(listener);
return () => chrome.storage.local.onChanged.removeListener(listener);
}, []);
return (
<ConnectedWalletsContext.Provider value={connectedWallets}>
{children}
</ConnectedWalletsContext.Provider>
);
};
export const useConnectedWallets = () => useContext(ConnectedWalletsContext);

15
src/utils/page.js Normal file
View File

@ -0,0 +1,15 @@
import { createContext, useContext, useState } from 'react';
const PageContext = createContext('wallet');
export const PageProvider = ({ children }) => {
const [page, setPage] = useState('wallet');
return (
<PageContext.Provider value={[page, setPage]}>
{children}
</PageContext.Provider>
);
};
export const usePage = () => useContext(PageContext);

View File

@ -4,6 +4,7 @@ import ERC20_ABI from './erc20-abi.json';
import SWAP_ABI from './swap-abi.json';
import Button from '@material-ui/core/Button';
import { useCallAsync } from '../notifications';
import { isExtension } from '../utils';
const web3 = new Web3(window.ethereum);
// Change to use estimated gas limit
@ -234,11 +235,11 @@ export function ConnectToMetamaskButton() {
color="primary"
variant="outlined"
component="a"
href="https://metamask.io/"
href={isExtension ? 'https://sollet.io' : 'https://metamask.io/'}
target="_blank"
rel="noopener"
>
Connect to MetaMask
{isExtension ? 'Open sollet.io' : 'Connect to MetaMask'}
</Button>
);
}

View File

@ -169,7 +169,6 @@ function publicKeyLayout(property) {
return new PublicKeyLayout(property);
}
export const OWNER_VALIDATION_PROGRAM_ID = new PublicKey(
'4MNPdKu9wFMvEeZBMt3Eipfs5ovVWTJb31pEXDJAAxX5',
);

View File

@ -132,12 +132,12 @@ const toInstruction = async (
} else {
return {
type: 'Unknown',
accountMetas: instruction.accounts.map(index => ({
publicKey: accountKeys[index],
isWritable: transactionMessage.isAccountWritable(index)
accountMetas: instruction.accounts.map((index) => ({
publicKey: accountKeys[index],
isWritable: transactionMessage.isAccountWritable(index),
})),
programId
}
programId,
};
}
} catch {}
@ -411,7 +411,7 @@ const getNewOrderV3Data = (accounts, accountKeys) => {
NEW_ORDER_V3_OWNER_INDEX,
);
return { openOrdersPubkey, ownerPubkey };
}
};
const getSettleFundsData = (accounts, accountKeys) => {
const basePubkey = getAccountByIndex(

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Account, Connection, PublicKey } from '@solana/web3.js';
import { useMediaQuery } from '@material-ui/core';
import * as bs58 from 'bs58';
export async function sleep(ms: number) {
@ -87,6 +88,14 @@ export async function confirmTransaction(
return result.value;
}
// TODO consolidate popup dimensions
export function useIsExtensionWidth() {
return useMediaQuery('(max-width:450px)');
}
export const isExtension = window.location.protocol === 'chrome-extension:';
export const isExtensionPopup = isExtension && window.opener;
/**
* Returns an account object when given the private key
*/

View File

@ -3,6 +3,7 @@ import { randomBytes, secretbox } from 'tweetnacl';
import * as bip32 from 'bip32';
import bs58 from 'bs58';
import { EventEmitter } from 'events';
import { isExtension } from './utils';
export async function generateMnemonicAndSeed() {
const bip39 = await import('bip39');
@ -170,5 +171,10 @@ export function forgetWallet() {
importsEncryptionKey: null,
};
walletSeedChanged.emit('change', unlockedMnemonicAndSeed);
window.location.reload();
if (isExtension) {
// Must use wrapper function for window.location.reload
chrome.storage.local.clear(() => window.location.reload());
} else {
window.location.reload();
}
}