Skip to content

Commit

Permalink
refactor: gather fungible token derived state into one place (#204)
Browse files Browse the repository at this point in the history
* refactor: gather fungible token derived state into one place

and add unit tests

* refactor: add visible back and deprecate it so PR core is not breaking change

* chore: readability

Co-authored-by: Victor Kirov <[email protected]>

* fix: move the logic to always support runes tokens to be clearer

* chore: add comments and readability

* chore: extend manageTokens type to cater for tokens that were not set yet

---------

Co-authored-by: Victor Kirov <[email protected]>
Co-authored-by: fede erbes <[email protected]>
  • Loading branch information
3 people authored Oct 8, 2024
1 parent 67253ca commit 822ac7e
Show file tree
Hide file tree
Showing 14 changed files with 273 additions and 55 deletions.
1 change: 0 additions & 1 deletion api/ordinals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ export async function getOrdinalsFtBalance(network: NetworkType, address: string
ticker: responseToken.ticker?.toUpperCase(),
decimals: 0,
image: '',
visible: true,
supported: true,
tokenFiatRate: null,
protocol: 'brc-20',
Expand Down
12 changes: 6 additions & 6 deletions api/runes/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ import {
RuneMarketInfo,
RuneSellRequest,
RuneSellResponse,
runeTokenToFungibleToken,
SubmitCancelOrderRequest,
SubmitCancelOrderResponse,
SubmitRuneSellRequest,
SubmitRunesSellResponse,
} from '../../types';
import { runeTokenToFungibleToken } from '../../fungibleTokens';
import { JSONBig } from '../../utils/bignumber';
import { getXClientVersion } from '../../utils/xClientVersion';

Expand Down Expand Up @@ -93,8 +93,8 @@ class RunesApi {

/**
* Get the balance of all rune tokens an address has
* @param {string} address
* @param {boolean} includeUnconfirmed Set to true to include unconfirmed transactions
* @param address
* @param includeUnconfirmed Set to true to include unconfirmed transactions
* in the balance (default is false)
* @return {Promise<RuneBalance[]>}
*/
Expand All @@ -119,7 +119,7 @@ class RunesApi {
return cachedRuneInfo;
}

let response: AxiosResponse<Rune, any>;
let response: AxiosResponse<Rune, any>; // eslint-disable-line @typescript-eslint/no-explicit-any

if (typeof runeNameOrId === 'bigint') {
const blockHeight = runeNameOrId >> 16n;
Expand All @@ -141,8 +141,8 @@ class RunesApi {

/**
* Get rune details in fungible token format
* @param {string} address
* @param {boolean} includeUnconfirmed Set to true to include unconfirmed transactions
* @param address
* @param includeUnconfirmed Set to true to include unconfirmed transactions
* in the balance (default is false)
* @return {Promise<FungibleToken[]>}
*/
Expand Down
1 change: 0 additions & 1 deletion api/stacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ export async function getFtData(stxAddress: string, network: StacksNetwork): Pro
fungibleToken.assetName = key.substring(index + 2);
fungibleToken.principal = key.substring(0, index);
fungibleToken.protocol = 'stacks';
fungibleToken.visible = new BigNumber(fungibleToken.balance).gt(0);
tokens.push(fungibleToken);
}
return tokens;
Expand Down
6 changes: 3 additions & 3 deletions api/xverse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ class XverseApi {
});
};

getCoinsInfo = async (contractids: string[], fiatCurrency: string): Promise<CoinsResponse> => {
const response = await this.client.post<CoinsResponse>('/v1/coins', {
getSip10Tokens = async (contractids: string[], fiatCurrency: string): Promise<CoinsResponse> => {
const response = await this.client.post<CoinsResponse>('/v1/sip10/tokens', {
currency: fiatCurrency,
coins: JSON.stringify(contractids),
});
Expand Down Expand Up @@ -428,7 +428,7 @@ export async function getCoinsInfo(
fiatCurrency: string,
): Promise<CoinsResponse | null> {
return getXverseApiClient(network)
.getCoinsInfo(contractids, fiatCurrency)
.getSip10Tokens(contractids, fiatCurrency)
.catch(() => null);
}

Expand Down
48 changes: 48 additions & 0 deletions fungibleTokens/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import BigNumber from 'bignumber.js';
import { type RuneBalance, type FungibleToken, type FungibleTokenStates } from '../types';

export const runeTokenToFungibleToken = (runeBalance: RuneBalance): FungibleToken => ({
name: runeBalance.runeName,
decimals: runeBalance.divisibility,
principal: runeBalance.id,
balance: runeBalance.amount.toString(),
total_sent: '',
total_received: '',
assetName: runeBalance.runeName,
ticker: '',
runeSymbol: runeBalance.symbol,
runeInscriptionId: runeBalance.inscriptionId,
protocol: 'runes',
supported: true, // all runes are supported
});

/**
* Logic for determining the derived UI state of a fungible token
*
* Extend this if any of the business logic changes around when a token shows
* up in spam, in manage tokens, is included in account balance etc.
*/
export const getFungibleTokenStates = ({
fungibleToken,
manageTokens,
spamTokens,
showSpamTokens,
}: {
fungibleToken: FungibleToken;
manageTokens?: Record<string, boolean | undefined>;
spamTokens?: string[];
showSpamTokens?: boolean;
}): FungibleTokenStates => {
const hasBalance = new BigNumber(fungibleToken.balance).gt(0);
const isSpam = showSpamTokens ? false : !!spamTokens?.includes(fungibleToken.principal);
const isUserEnabled = manageTokens?.[fungibleToken.principal]; // true=enabled, false=disabled, undefined=not set
const isDefaultEnabled = fungibleToken.supported && hasBalance && !isSpam;
const isEnabled = isUserEnabled || !!(isUserEnabled === undefined && isDefaultEnabled);
const showToggle = isEnabled || (hasBalance && !isSpam);

return {
isSpam,
isEnabled,
showToggle,
};
};
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
} from './constant';
export * from './currency';
export * from './encryption';
export * from './fungibleTokens';
export * from './gaia';
export * from './hooks';
export * from './ledger';
Expand Down
6 changes: 3 additions & 3 deletions tests/connect/transactionRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const mocked = vi.hoisted(() => ({
{ fee_rate: 30.00478207316422, fee: 180 },
]),
estimateContractDeploy: vi.fn(() => BigInt('581')),
getCoinsInfo: vi.fn(() => null),
getSip10Tokens: vi.fn(() => null),
fetchAppInfo: vi.fn(() => null),
fetchStxPendingTxData: vi.fn(() => ({ pendingTransactions: [] })),
getContractInterface: vi.fn(() => null),
Expand Down Expand Up @@ -46,7 +46,7 @@ vi.mock('@stacks/transactions', async () => ({
}));
vi.mock('../../api/xverse', () => ({
getXverseApiClient: () => ({
getCoinsInfo: mocked.getCoinsInfo,
getSip10Tokens: mocked.getSip10Tokens,
fetchAppInfo: mocked.fetchAppInfo,
}),
}));
Expand Down Expand Up @@ -202,7 +202,7 @@ describe('txPayloadToRequest', () => {
);
expect(mocked.fetchStxPendingTxData).toBeCalledTimes(1);
expect(mocked.getContractInterface).toBeCalledTimes(1);
expect(mocked.getCoinsInfo).toBeCalledTimes(1);
expect(mocked.getSip10Tokens).toBeCalledTimes(1);

mocked.serializePostCondition.mockImplementation((arg) => Buffer.from(arg, 'hex'));
mocked.addressToString.mockReturnValue(contractCallPayload.contractAddress);
Expand Down
176 changes: 176 additions & 0 deletions tests/fungibleTokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import { getFungibleTokenStates } from '../fungibleTokens';
import { type FungibleToken } from '../types';

const sip10Token: FungibleToken = {
assetName: 'odin-tkn',
balance: '0',
decimals: 6,
image: 'https://SP2X2Z28NXZVJFCJPBR9Q3NBVYBK3GPX8PXA3R83C.odin-tkn/1-thumb.png',
name: 'Odin',
principal: 'SP2X2Z28NXZVJFCJPBR9Q3NBVYBK3GPX8PXA3R83C.odin-tkn',
protocol: 'stacks',
ticker: 'ODIN',
total_received: '',
total_sent: '',
};

const brc20Token: FungibleToken = {
name: 'ORDI',
principal: 'ORDI',
balance: '0',
total_sent: '',
total_received: '',
assetName: 'ORDI',
ticker: 'ORDI',
protocol: 'brc-20',
};

const runesToken: FungibleToken = {
assetName: 'DOG•GO•TO•THE•MOON',
balance: '3142748244',
decimals: 5,
name: 'DOG•GO•TO•THE•MOON',
principal: '840000:3',
protocol: 'runes',
runeInscriptionId: 'e79134080a83fe3e0e06ed6990c5a9b63b362313341745707a2bff7d788a1375i0',
runeSymbol: '🐕',
ticker: '',
tokenFiatRate: 0.00256291,
total_received: '',
total_sent: '',
};

describe('getFungibleTokenStates', () => {
[
{
name: 'should default a supported sip10 token with balance to enabled',
inputs: {
fungibleToken: {
...sip10Token,
supported: true,
balance: '100',
},
manageTokens: {},
spamTokens: [],
showSpamTokens: false,
},
expected: {
isSpam: false,
isEnabled: true,
showToggle: true,
},
},
{
name: 'should default an unsupported sip10 token with balance to disabled - scam tokens',
inputs: {
fungibleToken: {
...sip10Token,
supported: false,
balance: '100',
},
manageTokens: {},
spamTokens: [],
showSpamTokens: false,
},
expected: {
isSpam: false,
isEnabled: false,
showToggle: true,
},
},
{
name: 'should disable and hide any token with balance if in spam tokens',
inputs: {
fungibleToken: {
...sip10Token,
supported: true,
balance: '100',
},
manageTokens: {},
spamTokens: [sip10Token.principal],
showSpamTokens: false,
},
expected: {
isSpam: true,
isEnabled: false,
showToggle: false,
},
},
{
name: 'should show toggle if token has balance, in spam tokens, but user pressed show spam tokens',
inputs: {
fungibleToken: {
...sip10Token,
supported: false,
balance: '100',
},
manageTokens: {},
spamTokens: [sip10Token.principal],
showSpamTokens: true,
},
expected: {
isSpam: false,
isEnabled: false,
showToggle: true,
},
},
{
name: 'should default any supported brc20 token with balance to enabled, if not in spam tokens',
inputs: {
fungibleToken: {
...brc20Token,
balance: '100',
supported: true,
},
manageTokens: {},
spamTokens: [],
showSpamTokens: false,
},
expected: {
isSpam: false,
isEnabled: true,
showToggle: true,
},
},
{
name: 'should default any supported runes token with balance to enabled, if not in spam tokens',
inputs: {
fungibleToken: {
...runesToken,
balance: '100',
supported: true,
},
manageTokens: {},
spamTokens: [],
showSpamTokens: false,
},
expected: {
isSpam: false,
isEnabled: true,
showToggle: true,
},
},
{
name: 'should show toggle if token is in spam tokens but user enabled it',
inputs: {
fungibleToken: {
...runesToken,
balance: '100',
},
manageTokens: { [runesToken.principal]: true },
spamTokens: [runesToken.principal],
showSpamTokens: false,
},
expected: {
isSpam: true,
isEnabled: true,
showToggle: true,
},
},
].forEach(({ name, inputs, expected }) => {
it(name, () => {
expect(getFungibleTokenStates(inputs)).toEqual(expected);
});
});
});
2 changes: 1 addition & 1 deletion transactions/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export const createContractCallPromises = async (
const ftContactAddresses = getFTInfoFromPostConditions(postConds);

// Stacks isn't setup for testnet, so we default to mainnet
const coinsMetaDataPromise: Coin[] | null = await getXverseApiClient('Mainnet').getCoinsInfo(
const coinsMetaDataPromise: Coin[] | null = await getXverseApiClient('Mainnet').getSip10Tokens(
ftContactAddresses,
'USD',
);
Expand Down
16 changes: 0 additions & 16 deletions types/api/runes/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { BigNumber } from '../../../utils/bignumber';
import { FungibleToken } from '../shared';

type BigNullable = BigNumber | null;

Expand Down Expand Up @@ -108,18 +107,3 @@ export type RuneBalance = {
inscriptionId: string | null;
id: string;
};

export const runeTokenToFungibleToken = (runeBalance: RuneBalance): FungibleToken => ({
name: runeBalance.runeName,
decimals: runeBalance.divisibility,
principal: runeBalance.id,
balance: runeBalance.amount.toString(),
total_sent: '',
total_received: '',
assetName: runeBalance.runeName,
visible: true,
ticker: '',
runeSymbol: runeBalance.symbol,
runeInscriptionId: runeBalance.inscriptionId,
protocol: 'runes',
});
Loading

0 comments on commit 822ac7e

Please sign in to comment.