add domains name support (#336)
This commit is contained in:
parent
81d045e4e6
commit
ee796aea2f
|
@ -57,6 +57,8 @@ import TokenIcon from './TokenIcon';
|
||||||
import EditAccountNameDialog from './EditAccountNameDialog';
|
import EditAccountNameDialog from './EditAccountNameDialog';
|
||||||
import MergeAccountsDialog from './MergeAccountsDialog';
|
import MergeAccountsDialog from './MergeAccountsDialog';
|
||||||
import SwapButton from './SwapButton';
|
import SwapButton from './SwapButton';
|
||||||
|
import DnsIcon from '@material-ui/icons/Dns';
|
||||||
|
import DomainsList from './DomainsList';
|
||||||
|
|
||||||
const balanceFormat = new Intl.NumberFormat(undefined, {
|
const balanceFormat = new Intl.NumberFormat(undefined, {
|
||||||
minimumFractionDigits: 4,
|
minimumFractionDigits: 4,
|
||||||
|
@ -108,6 +110,7 @@ export default function BalancesList() {
|
||||||
const [showMergeAccounts, setShowMergeAccounts] = useState(false);
|
const [showMergeAccounts, setShowMergeAccounts] = useState(false);
|
||||||
const [showFtxPayDialog, setShowFtxPayDialog] = useState(false);
|
const [showFtxPayDialog, setShowFtxPayDialog] = useState(false);
|
||||||
const [sortAccounts, setSortAccounts] = useState(SortAccounts.None);
|
const [sortAccounts, setSortAccounts] = useState(SortAccounts.None);
|
||||||
|
const [showDomains, setShowDomains] = useState(false);
|
||||||
const { accounts, setAccountName } = useWalletSelector();
|
const { accounts, setAccountName } = useWalletSelector();
|
||||||
const isExtensionWidth = useIsExtensionWidth();
|
const isExtensionWidth = useIsExtensionWidth();
|
||||||
// Dummy var to force rerenders on demand.
|
// Dummy var to force rerenders on demand.
|
||||||
|
@ -231,6 +234,12 @@ export default function BalancesList() {
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title="See your domains" arrow>
|
||||||
|
<IconButton size={iconSize} onClick={() => setShowDomains(true)}>
|
||||||
|
<DnsIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<DomainsList open={showDomains} setOpen={setShowDomains} />
|
||||||
<SwapButton size={iconSize} />
|
<SwapButton size={iconSize} />
|
||||||
<Tooltip title="Migrate Tokens" arrow>
|
<Tooltip title="Migrate Tokens" arrow>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@ -607,8 +616,7 @@ function BalanceListItemDetails({
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
`https://solscan.io/account/${publicKey.toBase58()}` +
|
`https://solscan.io/account/${publicKey.toBase58()}` + urlSuffix
|
||||||
urlSuffix
|
|
||||||
}
|
}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
|
|
|
@ -0,0 +1,323 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
ListItemText,
|
||||||
|
ListItem,
|
||||||
|
List,
|
||||||
|
Button,
|
||||||
|
Collapse,
|
||||||
|
ListItemIcon,
|
||||||
|
Toolbar,
|
||||||
|
AppBar,
|
||||||
|
InputLabel,
|
||||||
|
TextField,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
} from '@material-ui/core';
|
||||||
|
import LoadingIndicator from './LoadingIndicator';
|
||||||
|
import SendIcon from '@material-ui/icons/Send';
|
||||||
|
import { useUserDomains } from '../utils/name-service';
|
||||||
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
|
import { PublicKey, Transaction } from '@solana/web3.js';
|
||||||
|
import DnsIcon from '@material-ui/icons/Dns';
|
||||||
|
import Modal from '@material-ui/core/Modal';
|
||||||
|
import ExpandLess from '@material-ui/icons/ExpandLess';
|
||||||
|
import ExpandMore from '@material-ui/icons/ExpandMore';
|
||||||
|
import GavelIcon from '@material-ui/icons/Gavel';
|
||||||
|
import { signAndSendTransaction } from '../utils/tokens';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import { SOL_TLD_AUTHORITY } from '../utils/name-service';
|
||||||
|
import { transferNameOwnership } from '@bonfida/spl-name-service';
|
||||||
|
import { useConnection } from '../utils/connection';
|
||||||
|
import { useWallet } from '../utils/wallet';
|
||||||
|
import { refreshCache } from '../utils/fetch-loop';
|
||||||
|
import tuple from 'immutable-tuple';
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
address: {
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-evenly',
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
},
|
||||||
|
itemDetails: {
|
||||||
|
marginLeft: theme.spacing(3),
|
||||||
|
marginRight: theme.spacing(3),
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 24,
|
||||||
|
marginTop: '2%',
|
||||||
|
marginBottom: '2%',
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 600,
|
||||||
|
width: 500,
|
||||||
|
},
|
||||||
|
transferContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 10,
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
},
|
||||||
|
paper: {
|
||||||
|
width: '50%',
|
||||||
|
height: '80%',
|
||||||
|
overflowY: 'scroll',
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TransferDialog = ({
|
||||||
|
domainName,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
}: {
|
||||||
|
domainName: string;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (args: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles();
|
||||||
|
const connection = useConnection();
|
||||||
|
const wallet = useWallet();
|
||||||
|
const [checked, setChecked] = useState(false);
|
||||||
|
const [newOwner, setNewOwner] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
|
const onChange = (e) => {
|
||||||
|
setNewOwner(e.target.value.trim());
|
||||||
|
};
|
||||||
|
const canSumbit = newOwner && checked;
|
||||||
|
const onClick = async () => {
|
||||||
|
if (!newOwner) {
|
||||||
|
return enqueueSnackbar('Invalid input', { variant: 'error' });
|
||||||
|
}
|
||||||
|
let newOwnerPubkey: PublicKey;
|
||||||
|
try {
|
||||||
|
newOwnerPubkey = new PublicKey(newOwner);
|
||||||
|
} catch {
|
||||||
|
return enqueueSnackbar('Invalid input', { variant: 'error' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const instructions = await transferNameOwnership(
|
||||||
|
connection,
|
||||||
|
domainName,
|
||||||
|
newOwnerPubkey,
|
||||||
|
undefined,
|
||||||
|
SOL_TLD_AUTHORITY,
|
||||||
|
);
|
||||||
|
await signAndSendTransaction(
|
||||||
|
connection,
|
||||||
|
new Transaction().add(instructions),
|
||||||
|
wallet,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
enqueueSnackbar('Domain name transfered', { variant: 'success' });
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Error transferring domain name ${err}`);
|
||||||
|
return enqueueSnackbar('Error transferring domain name', {
|
||||||
|
variant: 'error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
refreshCache(tuple('useUserDomain', wallet?.publicKey?.toBase58()));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Paper>
|
||||||
|
<AppBar position="sticky" elevation={1}>
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" style={{ flexGrow: 1 }} component="h2">
|
||||||
|
Ownership transfer
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<div className={classes.transferContainer}>
|
||||||
|
<div>
|
||||||
|
<InputLabel className={classes.text} shrink>
|
||||||
|
New Owner
|
||||||
|
</InputLabel>
|
||||||
|
<TextField
|
||||||
|
placeholder="New owner"
|
||||||
|
InputProps={{
|
||||||
|
className: classes.input,
|
||||||
|
}}
|
||||||
|
value={newOwner}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => setChecked(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="body2">
|
||||||
|
I understand that transferring ownership is irreversible
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={!canSumbit || loading}
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
style={{ padding: 10 }}
|
||||||
|
>
|
||||||
|
{loading ? <LoadingIndicator height="10px" /> : 'Transfer'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DomainListItemDetails = ({ domainName }: { domainName: string }) => {
|
||||||
|
const classes = useStyles();
|
||||||
|
const [transferVisible, setTransferVisible] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classes.buttonContainer}>
|
||||||
|
<Button
|
||||||
|
onClick={() => setTransferVisible(true)}
|
||||||
|
startIcon={<SendIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
<TransferDialog
|
||||||
|
domainName={domainName}
|
||||||
|
open={transferVisible}
|
||||||
|
setOpen={setTransferVisible}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
`https://naming.bonfida.org/#/domain-registration/${domainName}`,
|
||||||
|
'_blank',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
startIcon={<GavelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
Sell
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DomainListItem = ({
|
||||||
|
domainName,
|
||||||
|
domainKey,
|
||||||
|
}: {
|
||||||
|
domainName: string;
|
||||||
|
domainKey: PublicKey;
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItem onClick={() => setOpen((prev) => !prev)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DnsIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={`${domainName}.sol`}
|
||||||
|
secondary={domainKey.toBase58()}
|
||||||
|
secondaryTypographyProps={{ className: classes.address }}
|
||||||
|
/>
|
||||||
|
{open ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</ListItem>
|
||||||
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
|
<DomainListItemDetails domainName={domainName} />
|
||||||
|
</Collapse>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DomainsList = () => {
|
||||||
|
const [userDomains, userDomainsLoaded] = useUserDomains();
|
||||||
|
if (!userDomainsLoaded) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
if (userDomainsLoaded && userDomains?.length === 0) {
|
||||||
|
return (
|
||||||
|
<Typography variant="body1" align="center">
|
||||||
|
You don't own any domain
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<List>
|
||||||
|
{userDomains?.map((d) => {
|
||||||
|
return (
|
||||||
|
<DomainListItem
|
||||||
|
key={d.nameKey.toBase58()}
|
||||||
|
domainName={d.name}
|
||||||
|
domainKey={d.nameKey}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DomainDialog = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (arg: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles();
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={() => setOpen(false)} className={classes.modal}>
|
||||||
|
<Paper className={classes.paper}>
|
||||||
|
<AppBar position="sticky" color="default" elevation={1}>
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" style={{ flexGrow: 1 }} component="h2">
|
||||||
|
Your domains
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<DomainsList />
|
||||||
|
</Paper>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DomainDialog;
|
|
@ -4,13 +4,24 @@ import {
|
||||||
getHashedName,
|
getHashedName,
|
||||||
getNameAccountKey,
|
getNameAccountKey,
|
||||||
NameRegistryState,
|
NameRegistryState,
|
||||||
|
getFilteredProgramAccounts,
|
||||||
|
NAME_PROGRAM_ID,
|
||||||
} from '@bonfida/spl-name-service';
|
} from '@bonfida/spl-name-service';
|
||||||
|
import { useConnection } from '../connection';
|
||||||
|
import { useWallet } from '../wallet';
|
||||||
|
import BN from 'bn.js';
|
||||||
|
import { useAsyncData } from '../fetch-loop';
|
||||||
|
import tuple from 'immutable-tuple';
|
||||||
|
|
||||||
// Address of the SOL TLD
|
// Address of the SOL TLD
|
||||||
export const SOL_TLD_AUTHORITY = new PublicKey(
|
export const SOL_TLD_AUTHORITY = new PublicKey(
|
||||||
'58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx',
|
'58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const PROGRAM_ID = new PublicKey(
|
||||||
|
'jCebN34bUfdeUYJT13J1yG16XWQpt5PDx6Mse9GUqhR',
|
||||||
|
);
|
||||||
|
|
||||||
export const resolveTwitterHandle = async (
|
export const resolveTwitterHandle = async (
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
twitterHandle: string,
|
twitterHandle: string,
|
||||||
|
@ -45,3 +56,74 @@ export const resolveDomainName = async (
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function findOwnedNameAccountsForUser(
|
||||||
|
connection: Connection,
|
||||||
|
userAccount: PublicKey,
|
||||||
|
): Promise<PublicKey[]> {
|
||||||
|
const filters = [
|
||||||
|
{
|
||||||
|
memcmp: {
|
||||||
|
offset: 32,
|
||||||
|
bytes: userAccount.toBase58(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const accounts = await getFilteredProgramAccounts(
|
||||||
|
connection,
|
||||||
|
NAME_PROGRAM_ID,
|
||||||
|
filters,
|
||||||
|
);
|
||||||
|
return accounts.map((a) => a.publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performReverseLookup(
|
||||||
|
connection: Connection,
|
||||||
|
nameAccount: PublicKey,
|
||||||
|
): Promise<string> {
|
||||||
|
let [centralState] = await PublicKey.findProgramAddress(
|
||||||
|
[PROGRAM_ID.toBuffer()],
|
||||||
|
PROGRAM_ID,
|
||||||
|
);
|
||||||
|
let hashedReverseLookup = await getHashedName(nameAccount.toBase58());
|
||||||
|
let reverseLookupAccount = await getNameAccountKey(
|
||||||
|
hashedReverseLookup,
|
||||||
|
centralState,
|
||||||
|
);
|
||||||
|
|
||||||
|
let name = await NameRegistryState.retrieve(connection, reverseLookupAccount);
|
||||||
|
if (!name.data) {
|
||||||
|
throw new Error('Could not retrieve name data');
|
||||||
|
}
|
||||||
|
let nameLength = new BN(name.data.slice(0, 4), 'le').toNumber();
|
||||||
|
return name.data.slice(4, 4 + nameLength).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserDomains = () => {
|
||||||
|
const wallet = useWallet();
|
||||||
|
const connection = useConnection();
|
||||||
|
const fn = async () => {
|
||||||
|
const domains = await findOwnedNameAccountsForUser(
|
||||||
|
connection,
|
||||||
|
wallet.publicKey,
|
||||||
|
);
|
||||||
|
let names: { name: string; nameKey: PublicKey }[] = [];
|
||||||
|
const fn = async (d) => {
|
||||||
|
try {
|
||||||
|
const name = await performReverseLookup(connection, d);
|
||||||
|
names.push({ name: name, nameKey: d });
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Passing account ${d.toBase58()} - err ${err}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const promises = domains.map((d) => fn(d));
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
return names.sort((a, b) => {
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return useAsyncData(
|
||||||
|
fn,
|
||||||
|
tuple('useUserDomain', wallet?.publicKey?.toBase58()),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue