Skip to content

Commit

Permalink
Merge pull request #2347 from near/ft-integrator-docs
Browse files Browse the repository at this point in the history
docs: add docs for fungible token discovery and display
  • Loading branch information
MaximusHaximus authored Jan 11, 2022
2 parents 3f776fb + f418e15 commit 6304bda
Show file tree
Hide file tree
Showing 18 changed files with 109 additions and 72 deletions.
34 changes: 34 additions & 0 deletions packages/frontend/docs/FungibleTokenDiscovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
NEAR Wallet Fungible Token ([NEP-141](https://nomicon.io/Standards/FungibleToken/Core.html)) Discovery and Display
===

NEAR Wallet discovers fungible tokens using a range of indexer queries and displays them using data in the token's metadata per the `ft_metadata` spec ([NEP-148](https://nomicon.io/Standards/FungibleToken/Metadata.html))

## Contents

1. [NEAR Wallet fungible token discovery](#NEAR-Wallet-fungible-token-discovery)
2. [NEAR Wallet fungible token display](#NEAR-Wallet-fungible-token-display)

## NEAR Wallet fungible token discovery
The wallet will consider contracts as fungible tokens if they meet any of the following conditions:

1. Any account makes a call to it with one of the following methods and the `receiver_id` property of the `args` is the user's account ID.
* `ft_transfer`
* `ft_transfer_call`
* `ft_mint`

2. The bridge token factory account makes a call to it with the `mint` method with the user's account ID as the `account_id` property of the `args`.
3. The user's account ID calls a method named `storage_deposit` OR any method prefixed with `ft_` on it.

The wallet will then make a view call to `ft_balance_of` on the considered contract and list it as a fungible token if it returns a value that is more than zero.

## NEAR Wallet fungible token display

The wallet will display the token using the following properties returned by the contract's `ft_metaddata`:
* `icon`: An icon will be rendered for the fungible token if a data url is supplied.
* `name`: Displayed as the fungible token's title (e.g. Banana) with a fallback to the `symbol` property.
* `symbol`: Displayed to represent the user's balance (e.g. BANANA).
* `decimals`: Used to show the proper significant digits of a token. This concept is explained well in this [OpenZeppelin post](https://docs.openzeppelin.com/contracts/3.x/erc20#a-note-on-decimals).

The wallet will also display the balance returned by `ft_balance_of`:

<img src="./assets/fungible-token-display.png" width="500">
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 6 additions & 6 deletions packages/frontend/src/components/send/SendContainerV2.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ const SendContainerV2 = ({
useEffect(() => {
// fungibleTokens contains balance data for each token -- we need to update local state every time it changes
// TODO: Add a `byIdentity` reducer for faster lookups than .find()
const targetToken = fungibleTokens.find(({ contractName, symbol }) => {
return (contractName && contractName === selectedToken.contractName) || symbol === selectedToken.symbol;
const targetToken = fungibleTokens.find(({ contractName, onChainFTMetadata }) => {
return (contractName && contractName === selectedToken.contractName) || onChainFTMetadata?.symbol === selectedToken.onChainFTMetadata?.symbol;
});

setSelectedToken(targetToken);
Expand All @@ -129,7 +129,7 @@ const SendContainerV2 = ({
setIsMaxAmount(false);
}, [accountId]);

const getRawAmount = () => getParsedTokenAmount(userInputAmount, selectedToken.symbol, selectedToken.decimals);
const getRawAmount = () => getParsedTokenAmount(userInputAmount, selectedToken.onChainFTMetadata?.symbol, selectedToken.onChainFTMetadata?.decimals);
const isValidAmount = () => {
// TODO: Handle rounding issue that can occur entering exact available amount
if (isMaxAmount === true) {
Expand Down Expand Up @@ -157,7 +157,7 @@ const SendContainerV2 = ({
setUserInputAmount(userInputAmount);
}}
onSetMaxAmount={() => {
const formattedTokenAmount = getFormattedTokenAmount(selectedToken.balance, selectedToken.symbol, selectedToken.decimals);
const formattedTokenAmount = getFormattedTokenAmount(selectedToken.balance, selectedToken.onChainFTMetadata?.symbol, selectedToken.onChainFTMetadata?.decimals);

if (!new BN(selectedToken.balance).isZero()) {
Mixpanel.track("SEND Use max amount");
Expand Down Expand Up @@ -236,9 +236,9 @@ const SendContainerV2 = ({
return (
<Success
amount={
selectedToken.symbol === 'NEAR'
selectedToken.onChainFTMetadata?.symbol === 'NEAR'
? getNearAndFiatValue(getRawAmount(), nearTokenFiatValueUSD)
: `${userInputAmount} ${selectedToken.symbol}`
: `${userInputAmount} ${selectedToken.onChainFTMetadata?.symbol}`
}
receiverId={receiverId}
onClickContinue={() => redirectTo('/')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function SendContainerWrapper({ match }) {
}}
handleContinueToReview={async ({ token, receiverId, rawAmount }) => {
try {
if (token.symbol === 'NEAR') {
if (token.onChainFTMetadata?.symbol === 'NEAR') {
const [totalFees, totalNear] = await Promise.all([
fungibleTokensService.getEstimatedTotalFees(),
fungibleTokensService.getEstimatedTotalNearAmount({ amount: rawAmount })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ const BalanceDetails = ({
data-test-id="sendPageSelectedTokenBalance"
translateIdTitle={prefixTXEntryTitledId('availableToSend')}
amount={availableToSend}
symbol={selectedToken.symbol}
decimals={selectedToken.decimals}
symbol={selectedToken.onChainFTMetadata?.symbol}
decimals={selectedToken.onChainFTMetadata?.decimals}
/>
</Breakdown>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ const RawTokenAmount = ({
}) => {
if (decimals && symbol) {
return (
<TokenAmount
token={{ symbol, decimals, balance: amount }}
<TokenAmount
token={{
onChainFTMetadata: { symbol, decimals },
balance: amount
}}
withSymbol={withSymbol}
showFiatAmount={showFiatAmountForNonNearToken}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ const SelectTokenButton = ({ token, onClick }) => {
>
<Token
translateIdTitle='sendV2.selectTokenButtonTitle'
symbol={token.symbol}
icon={token.icon}
symbol={token.onChainFTMetadata?.symbol}
icon={token.onChainFTMetadata?.icon}
/>
<ChevronIcon color='#0072ce' />
</StyledContainer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ const TransactionDetails = ({ selectedToken, estimatedFeesInNear, estimatedTotal
<Breakdown className={classNames(['transaction-details-breakdown' , open ? 'open' : ''])}>
<Token
translateIdTitle={prefixTXEntryTitledId('token')}
symbol={selectedToken.symbol}
icon={selectedToken.icon}
symbol={selectedToken.onChainFTMetadata?.symbol}
icon={selectedToken.onChainFTMetadata?.icon}
onClick={onTokenClick}
/>
<Accordion
Expand All @@ -32,7 +32,7 @@ const TransactionDetails = ({ selectedToken, estimatedFeesInNear, estimatedTotal
symbol='NEAR'
translateIdInfoTooltip='sendV2.translateIdInfoTooltip.estimatedFees'
/>
{selectedToken.symbol === 'NEAR' ?
{selectedToken.onChainFTMetadata?.symbol === 'NEAR' ?
/* Show 'Estimated total' (amount + fees) when sending NEAR only */
<Amount
translateIdTitle={prefixTXEntryTitledId('estimatedTotal')}
Expand All @@ -45,8 +45,8 @@ const TransactionDetails = ({ selectedToken, estimatedFeesInNear, estimatedTotal
<Amount
translateIdTitle={prefixTXEntryTitledId('amount')}
amount={amount}
symbol={selectedToken.symbol}
decimals={selectedToken.decimals}
symbol={selectedToken.onChainFTMetadata?.symbol}
decimals={selectedToken.onChainFTMetadata?.decimals}
/>
}
</Accordion>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const EnterAmount = ({
autoFocus={!isMobile}
/>
</div>
{selectedToken.symbol === 'NEAR' &&
{selectedToken.onChainFTMetadata?.symbol === 'NEAR' &&
<div className='usd-amount'>
<Balance amount={rawAmount} showBalanceInNEAR={false}/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ const EnterReceiver = ({
<Textfit mode='single' max={20}>
<RawTokenAmount
amount={amount}
symbol={selectedToken.symbol}
decimals={selectedToken.decimals}
symbol={selectedToken.onChainFTMetadata?.symbol}
decimals={selectedToken.onChainFTMetadata?.decimals}
showFiatAmountForNonNearToken={false}
/>
</Textfit>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ const Review = ({
<Textfit mode='single' max={38}>
<RawTokenAmount
amount={amount}
symbol={selectedToken.symbol}
decimals={selectedToken.decimals}
symbol={selectedToken.onChainFTMetadata?.symbol}
decimals={selectedToken.onChainFTMetadata?.decimals}
showFiatAmountForNonNearToken={false}
/>
</Textfit>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function filterTokens(tokens, searchSubstring) {
return tokens.filter((token) => {
if (!searchSubstring) { return true; }

return token.symbol
return token.onChainFTMetadata?.symbol
.toLowerCase()
.includes(searchSubstring.toLowerCase());
});
Expand Down
8 changes: 4 additions & 4 deletions packages/frontend/src/components/wallet/TokenAmount.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ const showFullAmount = (amount, decimals, symbol) =>
? `${formatTokenAmount(amount, decimals, decimals)} ${symbol}`
: '';

const TokenAmount = ({ token: { balance, decimals, symbol }, withSymbol = false, className, showFiatAmount = true }) => (
<div className={className} title={showFullAmount(balance, decimals, symbol)}>
const TokenAmount = ({ token: { balance, onChainFTMetadata }, withSymbol = false, className, showFiatAmount = true }) => (
<div className={className} title={showFullAmount(balance, onChainFTMetadata?.decimals, onChainFTMetadata?.symbol)}>
<div>
{balance
? formatToken(balance, decimals)
? formatToken(balance, onChainFTMetadata?.decimals)
: <span className='dots' />
}
<span className='currency'>{withSymbol ? ` ${symbol}` : null}</span>
<span className='currency'>{withSymbol ? ` ${onChainFTMetadata?.symbol}` : null}</span>
</div>
{showFiatAmount &&
<div className='fiat-amount'>
Expand Down
12 changes: 6 additions & 6 deletions packages/frontend/src/components/wallet/TokenBox.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ const TokenBox = ({ token, onClick }) => {
data-test-id={`token-selection-${token.contractName || "NEAR"}`}
>
<div className='icon'>
<TokenIcon symbol={token.symbol} icon={token.icon}/>
<TokenIcon symbol={token.onChainFTMetadata?.symbol} icon={token.onChainFTMetadata?.icon}/>
</div>
<div className='desc'>
{token.contractName ?
Expand All @@ -152,22 +152,22 @@ const TokenBox = ({ token, onClick }) => {
target='_blank'
rel='noopener noreferrer'
>
{token.name || token.symbol}
{token.onChainFTMetadata?.name || token.onChainFTMetadata?.symbol}
</a>
</span>
:
<span className='symbol'>
{token.symbol}
{token.onChainFTMetadata?.symbol}
</span>
}
<span className='fiat-rate'>
{token.usd
? <>${token.usd}</>
{token.coingeckoMetadata?.usd
? <>${token.coingeckoMetadata?.usd}</>
: <span><Translate id='tokenBox.priceUnavailable' /></span>
}
</span>
</div>
{token.symbol === 'NEAR' && !token.contractName ?
{token.onChainFTMetadata?.symbol === 'NEAR' && !token.contractName ?
<div className='balance'>
<Balance
amount={token.balance}
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/components/wallet/Tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const Tokens = ({ tokens, onClick }) => {
return (
<StyledContainer>
{tokens.map((token, i) => (
<TokenBox key={token.contractName || token.symbol} token={token} onClick={onClick}/>
<TokenBox key={token.contractName || token.onChainFTMetadata?.symbol} token={token} onClick={onClick}/>
))}
</StyledContainer>
);
Expand Down
5 changes: 4 additions & 1 deletion packages/frontend/src/components/wallet/Wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,10 @@ export function Wallet({ tab, setTab }) {

const FungibleTokens = ({ balance, tokensLoader, fungibleTokens }) => {
const availableBalanceIsZero = balance?.balanceAvailable === '0';
const hideFungibleTokenSection = availableBalanceIsZero && fungibleTokens?.length === 1 && fungibleTokens[0].symbol === 'NEAR';
const hideFungibleTokenSection =
availableBalanceIsZero &&
fungibleTokens?.length === 1 &&
fungibleTokens[0]?.onChainFTMetadata?.symbol === "NEAR";
return (
<>
<div className='total-balance'>
Expand Down
45 changes: 18 additions & 27 deletions packages/frontend/src/hooks/fungibleTokensIncludingNEAR.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,26 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useSelector } from "react-redux";

import { selectAccountId, selectBalance } from '../redux/slices/account';
import { selectNearTokenFiatValueUSD } from '../redux/slices/tokenFiatValues';
import { selectTokensWithMetadataForAccountId } from '../redux/slices/tokens';
import { selectAccountId, selectBalance } from "../redux/slices/account";
import { selectNearTokenFiatValueUSD } from "../redux/slices/tokenFiatValues";
import { selectTokensWithMetadataForAccountId } from "../redux/slices/tokens";

const fungibleTokensIncludingNEAR = (tokens, balance, nearTokenFiatValueUSD) => {
return [
{
balance,
symbol: 'NEAR',
usd: nearTokenFiatValueUSD
},
...Object.values(tokens)
];
const useNEARAsTokenWithMetadata = () => {
const nearBalance = useSelector(selectBalance);
const nearTokenFiatValueUSD = useSelector(selectNearTokenFiatValueUSD);

return {
balance: nearBalance?.balanceAvailable || "",
onChainFTMetadata: { symbol: "NEAR" },
coingeckoMetadata: { usd: nearTokenFiatValueUSD },
};
};

export const useFungibleTokensIncludingNEAR = function () {
const balance = useSelector(selectBalance);
const nearAsToken = useNEARAsTokenWithMetadata();
const accountId = useSelector(selectAccountId);
const tokens = useSelector((state) => selectTokensWithMetadataForAccountId(state, { accountId }));
const nearTokenFiatValueUSD = useSelector(selectNearTokenFiatValueUSD);

const balanceToDisplay = balance?.balanceAvailable;
const [fungibleTokensList, setFungibleTokensList] = useState(
() => fungibleTokensIncludingNEAR(tokens, balanceToDisplay)
const fungibleTokens = useSelector((state) =>
selectTokensWithMetadataForAccountId(state, { accountId })
);

useEffect(() => {
setFungibleTokensList(fungibleTokensIncludingNEAR(tokens, balanceToDisplay, nearTokenFiatValueUSD));
}, [tokens, balanceToDisplay]);

return fungibleTokensList;
};
return [nearAsToken, ...fungibleTokens];
};
24 changes: 15 additions & 9 deletions packages/frontend/src/redux/slices/tokens/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,21 @@ export const selectOneTokenFromOwnedTokens = createSelector(

export const selectTokensWithMetadataForAccountId = createSelector(
[selectAllContractMetadata, selectOwnedTokensForAccount],
(allContractMetadata, ownedTokensForAccount) => Object.entries(ownedTokensForAccount)
.filter(([_, { balance }]) => !new BN(balance).isZero())
.sort(([a], [b]) => allContractMetadata[a].name.localeCompare(allContractMetadata[b].name))
.map(([contractName, { balance }]) => ({
...initialOwnedTokenState,
contractName,
balance,
...(allContractMetadata[contractName] || {})
}))
(allContractMetadata, ownedTokensForAccount) =>
Object.entries(ownedTokensForAccount)
.filter(([_, { balance }]) => !new BN(balance).isZero())
.sort(([a], [b]) =>
allContractMetadata[a].name.localeCompare(
allContractMetadata[b].name
)
)
.map(([contractName, { balance }]) => ({
...initialOwnedTokenState,
contractName,
balance,
onChainFTMetadata: allContractMetadata[contractName] || {},
coingeckoMetadata: {},
}))
);

export const selectTokensLoading = createSelector(
Expand Down

0 comments on commit 6304bda

Please sign in to comment.