Add option to swap SPL tokens for native tokens

This commit is contained in:
Gary Wang 2020-09-11 03:38:43 -07:00
parent 0f2014fcde
commit 948d0e411c
12 changed files with 603 additions and 92 deletions

View File

@ -5,9 +5,9 @@ import { makeStyles } from '@material-ui/core/styles';
import { useSnackbar } from 'notistack';
import QrcodeIcon from 'mdi-material-ui/Qrcode';
import QRCode from 'qrcode.react';
import DialogForm from './DialogForm';
import DialogContent from '@material-ui/core/DialogContent';
import IconButton from '@material-ui/core/IconButton';
import Dialog from '@material-ui/core/Dialog';
const useStyles = makeStyles((theme) => ({
root: {
@ -81,11 +81,11 @@ function Qrcode({ value }) {
<IconButton onClick={() => setShowQrcode(true)}>
<QrcodeIcon />
</IconButton>
<DialogForm open={showQrcode} onClose={() => setShowQrcode(false)}>
<Dialog open={showQrcode} onClose={() => setShowQrcode(false)}>
<DialogContent className={classes.qrcodeContainer}>
<QRCode value={value} size={256} includeMargin />
</DialogContent>
</DialogForm>
</Dialog>
</>
);
}

View File

@ -7,7 +7,11 @@ import { useUpdateTokenName } from '../utils/tokens/names';
import { useCallAsync, useSendTransaction } from '../utils/notifications';
import { Account, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { abbreviateAddress, sleep } from '../utils/utils';
import { refreshAccountInfo, useConnectionConfig, MAINNET_URL } from '../utils/connection';
import {
refreshAccountInfo,
useConnectionConfig,
MAINNET_URL,
} from '../utils/connection';
import { createAndInitializeMint } from '../utils/tokens';
import { Tooltip, Button } from '@material-ui/core';
import React from 'react';

View File

@ -17,6 +17,7 @@ import { makeStyles } from '@material-ui/core/styles';
import { useCallAsync } from '../utils/notifications';
import { SwapApiError, swapApiRequest } from '../utils/swap/api';
import {
ConnectToMetamaskButton,
getErc20Balance,
swapErc20ToSpl,
useEthAccount,
@ -104,7 +105,7 @@ function SolletSwapDepositAddress({ publicKey, balanceInfo }) {
try {
return await swapApiRequest('POST', 'swap_to', {
blockchain: 'sol',
coin: balanceInfo.mint.toBase58(),
coin: balanceInfo.mint?.toBase58(),
address: publicKey.toBase58(),
});
} catch (e) {
@ -115,7 +116,7 @@ function SolletSwapDepositAddress({ publicKey, balanceInfo }) {
}
throw e;
}
}, tuple('swapInfo', balanceInfo.mint.toBase58(), publicKey.toBase58()));
}, tuple('swapInfo', balanceInfo.mint?.toBase58(), publicKey.toBase58()));
if (!loaded) {
return <LoadingIndicator />;
@ -153,9 +154,7 @@ function SolletSwapDepositAddress({ publicKey, balanceInfo }) {
{coin.erc20Contract ? 'ERC20' : 'Native'} {coin.ticker} can be
converted to SPL {tokenName} via MetaMask.
</Typography>
{window.ethereum ? (
<MetamaskDeposit swapInfo={swapInfo} ethereum={window.ethereum} />
) : null}
<MetamaskDeposit swapInfo={swapInfo} />
</>
);
}
@ -163,7 +162,7 @@ function SolletSwapDepositAddress({ publicKey, balanceInfo }) {
return null;
}
function MetamaskDeposit({ ethereum, swapInfo }) {
function MetamaskDeposit({ swapInfo }) {
const ethAccount = useEthAccount();
const [amount, setAmount] = useState('');
const [submitted, setSubmitted] = useState(false);
@ -184,22 +183,7 @@ function MetamaskDeposit({ ethereum, swapInfo }) {
}, tuple(getErc20Balance, ethAccount, erc20Address));
if (!ethAccount) {
function connect() {
callAsync(
ethereum.request({
method: 'eth_requestAccounts',
}),
{
progressMessage: 'Connecting to MetaMask...',
successMessage: 'Connected to MetaMask',
},
);
}
return (
<Button color="primary" variant="outlined" onClick={connect}>
Connect to MetaMask
</Button>
);
return <ConnectToMetamaskButton />;
}
async function submit() {
@ -208,6 +192,7 @@ function MetamaskDeposit({ ethereum, swapInfo }) {
await callAsync(
(async () => {
let parsedAmount = parseFloat(amount);
if (!parsedAmount || parsedAmount > maxAmount) {
throw new Error('Invalid amount');
}
@ -243,7 +228,13 @@ function MetamaskDeposit({ ethereum, swapInfo }) {
}}
value={amount}
onChange={(e) => setAmount(e.target.value.trim())}
helperText={maxAmountLoaded ? `Max: ${maxAmount.toFixed(6)}` : null}
helperText={
maxAmountLoaded ? (
<span onClick={() => setAmount(maxAmount.toFixed(6))}>
Max: {maxAmount.toFixed(6)}
</span>
) : null
}
/>
<Button color="primary" style={{ marginLeft: 8 }} onClick={submit}>
Convert

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import DialogActions from '@material-ui/core/DialogActions';
import Button from '@material-ui/core/Button';
import DialogTitle from '@material-ui/core/DialogTitle';
@ -10,78 +10,301 @@ import { PublicKey } from '@solana/web3.js';
import { abbreviateAddress } from '../utils/utils';
import InputAdornment from '@material-ui/core/InputAdornment';
import { useSendTransaction } from '../utils/notifications';
import { useAsyncData } from '../utils/fetch-loop';
import { SwapApiError, swapApiRequest } from '../utils/swap/api';
import { showSwapAddress } from '../utils/config';
import Tabs from '@material-ui/core/Tabs';
import Tab from '@material-ui/core/Tab';
import DialogContentText from '@material-ui/core/DialogContentText';
import {
ConnectToMetamaskButton,
useEthAccount,
withdrawEth,
} from '../utils/swap/eth';
import { useSnackbar } from 'notistack';
export default function SendDialog({ open, onClose, publicKey, balanceInfo }) {
const [tab, setTab] = useState(0);
const onSubmitRef = useRef();
const [swapCoinInfo] = useAsyncData(async () => {
if (!showSwapAddress) {
return null;
}
try {
return await swapApiRequest(
'GET',
`coins/sol/${balanceInfo.mint?.toBase58()}`,
);
} catch (e) {
if (e instanceof SwapApiError) {
if (e.status === 404) {
return null;
}
}
throw e;
}
}, ['swapCoinInfo', balanceInfo.mint?.toBase58()]);
const ethAccount = useEthAccount();
const { mint, tokenName, tokenSymbol } = balanceInfo;
return (
<>
<DialogForm
open={open}
onClose={onClose}
onSubmit={() => onSubmitRef.current()}
>
<DialogTitle>
Send {tokenName ?? abbreviateAddress(mint)}
{tokenSymbol ? ` (${tokenSymbol})` : null}
</DialogTitle>
{swapCoinInfo ? (
<Tabs
value={tab}
variant="fullWidth"
onChange={(e, value) => setTab(value)}
>
<Tab label={`SPL ${swapCoinInfo.ticker}`} />
<Tab label={describeSwap(swapCoinInfo)} />
</Tabs>
) : null}
{tab === 0 ? (
<SendSplDialog
onClose={onClose}
publicKey={publicKey}
balanceInfo={balanceInfo}
onSubmitRef={onSubmitRef}
/>
) : (
<SendSwapDialog
onClose={onClose}
publicKey={publicKey}
balanceInfo={balanceInfo}
swapCoinInfo={swapCoinInfo}
ethAccount={ethAccount}
onSubmitRef={onSubmitRef}
/>
)}
</DialogForm>
{ethAccount &&
(swapCoinInfo?.blockchain === 'eth' || swapCoinInfo?.erc20Contract) ? (
<EthWithdrawalCompleter ethAccount={ethAccount} publicKey={publicKey} />
) : null}
</>
);
}
function describeSwap(swapCoinInfo) {
if (swapCoinInfo.blockchain === 'eth' && swapCoinInfo.erc20Contract) {
return `ERC20 ${swapCoinInfo.ticker}`;
}
return `native ${swapCoinInfo.ticker}`;
}
function SendSplDialog({ onClose, publicKey, balanceInfo, onSubmitRef }) {
const wallet = useWallet();
const [destinationAddress, setDestinationAddress] = useState('');
const [transferAmountString, setTransferAmountString] = useState('');
const [sendTransaction, sending] = useSendTransaction();
const { fields, destinationAddress, transferAmountString } = useForm(
balanceInfo,
);
const { decimals } = balanceInfo;
let {
amount: balanceAmount,
decimals,
mint,
tokenName,
tokenSymbol,
} = balanceInfo;
function onSubmit() {
let amount = Math.round(
parseFloat(transferAmountString) * Math.pow(10, decimals),
);
async function makeTransaction() {
let amount = Math.round(parseFloat(transferAmountString) * 10 ** decimals);
if (!amount || amount <= 0) {
throw new Error('Invalid amount');
}
sendTransaction(
wallet.transferToken(
publicKey,
new PublicKey(destinationAddress),
amount,
),
{ onSuccess: onClose },
return wallet.transferToken(
publicKey,
new PublicKey(destinationAddress),
amount,
);
}
async function onSubmit() {
return sendTransaction(makeTransaction(), { onSuccess: onClose });
}
onSubmitRef.current = onSubmit;
return (
<DialogForm open={open} onClose={onClose} onSubmit={onSubmit}>
<DialogTitle>
Send {tokenName ?? abbreviateAddress(mint)}
{tokenSymbol ? ` (${tokenSymbol})` : null}
</DialogTitle>
<DialogContent>
<TextField
label="Recipient Address"
fullWidth
variant="outlined"
margin="normal"
value={destinationAddress}
onChange={(e) => setDestinationAddress(e.target.value.trim())}
/>
<TextField
label="Amount"
fullWidth
variant="outlined"
margin="normal"
type="number"
InputProps={{
endAdornment: tokenSymbol ? (
<InputAdornment position="end">{tokenSymbol}</InputAdornment>
) : null,
inputProps: {
step: Math.pow(10, -decimals),
},
}}
value={transferAmountString}
onChange={(e) => setTransferAmountString(e.target.value.trim())}
helperText={`Max: ${balanceAmount / Math.pow(10, decimals)}`}
/>
</DialogContent>
<>
<DialogContent>{fields}</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button type="submit" color="primary" disabled={sending}>
Send
</Button>
</DialogActions>
</DialogForm>
</>
);
}
function SendSwapDialog({
onClose,
publicKey,
balanceInfo,
swapCoinInfo,
ethAccount,
onSubmitRef,
}) {
const wallet = useWallet();
const [sendTransaction, sending] = useSendTransaction();
const {
fields,
destinationAddress,
transferAmountString,
setDestinationAddress,
} = useForm(balanceInfo);
const { tokenName, decimals } = balanceInfo;
const blockchain =
swapCoinInfo.blockchain === 'sol' ? 'eth' : swapCoinInfo.blockchain;
const needMetamask = blockchain === 'eth';
useEffect(() => {
if (blockchain === 'eth' && ethAccount) {
setDestinationAddress(ethAccount);
}
}, [blockchain, ethAccount, setDestinationAddress]);
async function makeTransaction() {
let amount = Math.round(parseFloat(transferAmountString) * 10 ** decimals);
if (!amount || amount <= 0) {
throw new Error('Invalid amount');
}
const swapInfo = await swapApiRequest('POST', 'swap_to', {
blockchain,
coin: swapCoinInfo.erc20Contract,
address: destinationAddress,
});
if (swapInfo.blockchain !== 'sol') {
throw new Error('Unexpected blockchain');
}
return wallet.transferToken(
publicKey,
new PublicKey(swapInfo.address),
amount,
swapInfo.memo,
);
}
async function onSubmit() {
return sendTransaction(makeTransaction(), { onSuccess: onClose });
}
onSubmitRef.current = onSubmit;
return (
<>
<DialogContent>
<DialogContentText>
SPL {tokenName} can be converted to {describeSwap(swapCoinInfo)}
{needMetamask ? ' via MetaMask' : null}.
</DialogContentText>
{needMetamask && !ethAccount ? <ConnectToMetamaskButton /> : fields}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
type="submit"
color="primary"
disabled={sending || (needMetamask && !ethAccount)}
>
Send
</Button>
</DialogActions>
</>
);
}
function useForm(balanceInfo) {
const [destinationAddress, setDestinationAddress] = useState('');
const [transferAmountString, setTransferAmountString] = useState('');
const { amount: balanceAmount, decimals, tokenSymbol } = balanceInfo;
const fields = (
<>
<TextField
label="Recipient Address"
fullWidth
variant="outlined"
margin="normal"
value={destinationAddress}
onChange={(e) => setDestinationAddress(e.target.value.trim())}
/>
<TextField
label="Amount"
fullWidth
variant="outlined"
margin="normal"
type="number"
InputProps={{
endAdornment: tokenSymbol ? (
<InputAdornment position="end">{tokenSymbol}</InputAdornment>
) : null,
inputProps: {
step: Math.pow(10, -decimals),
},
}}
value={transferAmountString}
onChange={(e) => setTransferAmountString(e.target.value.trim())}
helperText={
<span
onClick={() =>
setTransferAmountString(
(balanceAmount / Math.pow(10, decimals)).toFixed(decimals),
)
}
>
Max: {balanceAmount / Math.pow(10, decimals)}
</span>
}
/>
</>
);
return {
fields,
destinationAddress,
transferAmountString,
setDestinationAddress,
};
}
function EthWithdrawalCompleter({ ethAccount, publicKey }) {
const [swaps] = useAsyncData(
() => swapApiRequest('GET', `swaps_from/sol/${publicKey.toBase58()}`),
`swaps_from/sol/${publicKey.toBase58()}`,
{ refreshInterval: 10000 },
);
if (!swaps) {
return null;
}
return swaps.map((swap) => (
<EthWithdrawalCompleterItem
key={swap.deposit.txid}
ethAccount={ethAccount}
swap={swap}
/>
));
}
function EthWithdrawalCompleterItem({ ethAccount, swap }) {
const { enqueueSnackbar } = useSnackbar();
const { withdrawal } = swap;
useEffect(() => {
if (
withdrawal.status === 'sent' &&
withdrawal.blockchain === 'eth' &&
withdrawal.txid &&
!withdrawal.txid.startsWith('0x') &&
withdrawal.txData
) {
withdrawEth(ethAccount, withdrawal).catch((e) =>
enqueueSnackbar(e.message, { variant: 'error' }),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [withdrawal.txid, withdrawal.status]);
return null;
}

View File

@ -7,7 +7,9 @@ body {
-moz-osx-font-smoothing: grayscale;
}
html, body, #root {
html,
body,
#root {
height: 100%;
}

View File

@ -8,7 +8,7 @@ ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
document.getElementById('root'),
);
// If you want your app to work offline and load faster, you can change

View File

@ -1,5 +1,6 @@
import assert from 'assert';
import { useEffect, useReducer } from 'react';
import tuple from 'immutable-tuple';
const pageLoadTime = new Date();
@ -142,6 +143,8 @@ export function useAsyncData(
cacheKey,
{ refreshInterval = 60000 } = {},
) {
cacheKey = formatCacheKey(cacheKey);
const [, rerender] = useReducer((i) => i + 1, 0);
useEffect(() => {
@ -169,6 +172,7 @@ export function useAsyncData(
}
export function refreshCache(cacheKey, clearCache = false) {
cacheKey = formatCacheKey(cacheKey);
if (clearCache) {
globalCache.delete(cacheKey);
}
@ -182,6 +186,7 @@ export function refreshCache(cacheKey, clearCache = false) {
}
export function setCache(cacheKey, value, { initializeOnly = false } = {}) {
cacheKey = formatCacheKey(cacheKey);
if (initializeOnly && globalCache.has(cacheKey)) {
return;
}
@ -191,3 +196,10 @@ export function setCache(cacheKey, value, { initializeOnly = false } = {}) {
loop.notifyListeners();
}
}
function formatCacheKey(cacheKey) {
if (Array.isArray(cacheKey)) {
return tuple(...cacheKey);
}
return cacheKey;
}

View File

@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import Web3 from 'web3';
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';
const web3 = new Web3(window.ethereum);
@ -58,14 +60,14 @@ export async function swapErc20ToSpl({
const decimals = parseInt(await erc20.methods.decimals().call(), 10);
const approveTx = erc20.methods
.approve(swapAddress, Math.floor(amount * 10 ** decimals))
.approve(swapAddress, Math.round(amount * 10 ** decimals))
.send({ from: ethAccount });
await waitForTxid(approveTx);
onStatusChange({ step: 1 });
const swapTx = swap.methods
.swapErc20(erc20Address, destination, Math.floor(amount * 10 ** decimals))
.swapErc20(erc20Address, destination, Math.round(amount * 10 ** decimals))
.send({ from: ethAccount, gasLimit: 100000 });
const swapTxid = await waitForTxid(swapTx);
@ -101,6 +103,36 @@ export async function swapEthToSpl({
onStatusChange({ step: 3 });
}
export async function withdrawEth(from, withdrawal) {
const { params, signature } = withdrawal.txData;
const swap = new web3.eth.Contract(SWAP_ABI, params[1]);
let method;
if (params[0] === 'withdrawErc20') {
method = swap.methods.withdrawErc20(
params[2],
params[3],
params[4],
params[5],
signature,
);
} else if (params[0] === 'withdrawEth') {
method = swap.methods.withdrawEth(
params[2],
params[3],
params[4],
signature,
);
} else {
return;
}
try {
await method.estimateGas();
} catch (e) {
return;
}
await method.send({ from });
}
function waitForTxid(tx) {
return new Promise((resolve, reject) => {
tx.once('transactionHash', resolve).catch(reject);
@ -124,3 +156,40 @@ function waitForConfirms(tx, onStatusChange) {
});
});
}
export function ConnectToMetamaskButton() {
const callAsync = useCallAsync();
if (!window.ethereum) {
return (
<Button
color="primary"
variant="outlined"
component="a"
href="https://metamask.io/"
target="_blank"
rel="noopener"
>
Connect to MetaMask
</Button>
);
}
function connect() {
callAsync(
window.ethereum.request({
method: 'eth_requestAccounts',
}),
{
progressMessage: 'Connecting to MetaMask...',
successMessage: 'Connected to MetaMask',
},
);
}
return (
<Button color="primary" variant="outlined" onClick={connect}>
Connect to MetaMask
</Button>
);
}

View File

@ -1,4 +1,23 @@
[
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "uint256",
"name": "nonce",
"type": "uint256"
},
{
"indexed": false,
"internalType": "bytes",
"name": "signature",
"type": "bytes"
}
],
"name": "NonceUsed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
@ -18,6 +37,19 @@
"name": "OwnershipTransferred",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "Paused",
"type": "event"
},
{
"anonymous": false,
"inputs": [
@ -43,6 +75,44 @@
"name": "Swap",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "Unpaused",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "Withdraw",
"type": "event"
},
{
"inputs": [],
"name": "owner",
@ -56,6 +126,19 @@
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "paused",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "renounceOwnership",
@ -63,6 +146,19 @@
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "transferOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
@ -102,12 +198,105 @@
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"internalType": "contract IERC20",
"name": "token",
"type": "address"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "nonce",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "signature",
"type": "bytes"
}
],
"name": "transferOwnership",
"name": "withdrawErc20",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address payable",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "nonce",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "signature",
"type": "bytes"
}
],
"name": "withdrawEth",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "pause",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "unpause",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "contract IERC20",
"name": "token",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "ownerWithdrawErc20",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "ownerWithdrawEth",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"

View File

@ -2,6 +2,7 @@ import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
import {
initializeAccount,
initializeMint,
memoInstruction,
mintTo,
TOKEN_PROGRAM_ID,
transfer,
@ -144,6 +145,7 @@ export async function transferTokens({
sourcePublicKey,
destinationPublicKey,
amount,
memo,
}) {
let transaction = new Transaction().add(
transfer({
@ -153,6 +155,9 @@ export async function transferTokens({
amount,
}),
);
if (memo) {
transaction.add(memoInstruction(memo));
}
let signers = [owner];
return await connection.sendTransaction(transaction, signers);
}

View File

@ -13,6 +13,10 @@ export const WRAPPED_SOL_MINT = new PublicKey(
'So11111111111111111111111111111111111111111',
);
export const MEMO_PROGRAM_ID = new PublicKey(
'Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo',
);
const LAYOUT = BufferLayout.union(BufferLayout.u8('instruction'));
LAYOUT.addVariant(
0,
@ -122,3 +126,11 @@ export function mintTo({ mint, destination, amount, mintAuthority }) {
programId: TOKEN_PROGRAM_ID,
});
}
export function memoInstruction(memo) {
return new TransactionInstruction({
keys: [],
data: Buffer.from(memo, 'utf-8'),
programId: MEMO_PROGRAM_ID,
});
}

View File

@ -68,8 +68,11 @@ export class Wallet {
);
};
transferToken = async (source, destination, amount) => {
transferToken = async (source, destination, amount, memo = null) => {
if (source.equals(this.publicKey)) {
if (memo) {
throw new Error('Memo not implemented');
}
return this.transferSol(destination, amount);
}
return await transferTokens({
@ -78,6 +81,7 @@ export class Wallet {
sourcePublicKey: source,
destinationPublicKey: destination,
amount,
memo,
});
};