Skip to content

Commit

Permalink
feat: created PayWithAsset provider and integrated with transfer form
Browse files Browse the repository at this point in the history
  • Loading branch information
ap211unitech committed Jan 27, 2025
1 parent 79a0e47 commit 76b650b
Show file tree
Hide file tree
Showing 10 changed files with 176 additions and 37 deletions.
48 changes: 15 additions & 33 deletions packages/react-components/src/PayWithAsset/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,27 @@
// SPDX-License-Identifier: Apache-2.0

import type { DropdownItemProps } from 'semantic-ui-react';
import type { AssetInfoComplete } from '@polkadot/react-hooks/types';

import React, { useCallback, useEffect, useMemo, useState } from 'react';

import { getGenesis } from '@polkadot/apps-config';
import { Dropdown } from '@polkadot/react-components';
import { useApi, useAssetIds, useAssetInfos } from '@polkadot/react-hooks';
import { formatNumber } from '@polkadot/util';
import { useApi, usePayWithAsset } from '@polkadot/react-hooks';
import { BN } from '@polkadot/util';

import { useTranslation } from '../translate.js';

const ALLOWED_CHAINS = [getGenesis('statemint')];

const PayWithAsset = () => {
const { t } = useTranslation();
const { api } = useApi();
const ids = useAssetIds();
const assetInfos = useAssetInfos(ids);
const [infoIndex, setInfoIndex] = useState('0');
const [selectedAssetValue, setSelectedAssetValue] = useState('0');

const { assetOptions, isDisabled, onChange } = usePayWithAsset();

const nativeAsset = useMemo(
() => api.registry.chainTokens[0],
[api]
);

const completeInfos = useMemo(
() => (assetInfos
?.filter((i): i is AssetInfoComplete =>
!!(i.details && i.metadata) && !i.details.supply.isZero() && !!i.details?.toJSON().isSufficient)
.sort((a, b) => a.id.cmp(b.id))) || [],
[assetInfos]
);

const assetOptions = useMemo(
() => [
{ text: `${nativeAsset} (Native)`, value: nativeAsset },
...completeInfos.map(({ id, metadata }) => ({
text: `${metadata.name.toUtf8()} (${formatNumber(id)})`,
value: id.toString()
}))],
[completeInfos, nativeAsset]
);

const onSearch = useCallback(
(options: DropdownItemProps[], value: string): DropdownItemProps[] =>
options.filter((options) => {
Expand All @@ -55,23 +33,27 @@ const PayWithAsset = () => {
[]
);

const onSelect = useCallback((value: string) => {
onChange(value === nativeAsset ? new BN(-1) : new BN(value), () => setSelectedAssetValue(value));
}, [nativeAsset, onChange]);

useEffect((): void => {
const info = assetOptions.find(({ value }) => value === infoIndex);
const info = assetOptions.find(({ value }) => value === selectedAssetValue);

// if no info found (usually happens on first load), select the first one automatically
if (!info) {
setInfoIndex(assetOptions.at(0)?.value ?? nativeAsset);
setSelectedAssetValue(assetOptions.at(0)?.value ?? nativeAsset);
}
}, [assetOptions, infoIndex, nativeAsset]);
}, [assetOptions, selectedAssetValue, nativeAsset]);

return (
<Dropdown
isDisabled={!ALLOWED_CHAINS.includes(api.genesisHash.toHex()) || completeInfos.length === 0}
isDisabled={isDisabled}
label={t('asset to pay the fee')}
onChange={setInfoIndex}
onChange={onSelect}
onSearch={onSearch}
options={assetOptions}
value={infoIndex}
value={selectedAssetValue}
/>
);
};
Expand Down
10 changes: 8 additions & 2 deletions packages/react-components/src/TxButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ import type { TxButtonProps as Props } from './types.js';

import React, { useCallback, useEffect, useState } from 'react';

import { useIsMountedRef, useQueue } from '@polkadot/react-hooks';
import { useApi, useIsMountedRef, usePayWithAsset, useQueue } from '@polkadot/react-hooks';
import { getFeeAssetLocation } from '@polkadot/react-hooks/utils/getFeeAssetLocation';
import { assert, isFunction } from '@polkadot/util';

import Button from './Button/index.js';
import { useTranslation } from './translate.js';

function TxButton ({ accountId, className = '', extrinsic: propsExtrinsic, icon, isBasic, isBusy, isDisabled, isIcon, isToplevel, isUnsigned, label, onClick, onFailed, onSendRef, onStart, onSuccess, onUpdate, params, tooltip, tx, withSpinner, withoutLink }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const mountedRef = useIsMountedRef();
const { queueExtrinsic } = useQueue();
const { selectedFeeAsset } = usePayWithAsset();
const [isSending, setIsSending] = useState(false);
const [isStarted, setIsStarted] = useState(false);

Expand Down Expand Up @@ -76,6 +79,9 @@ function TxButton ({ accountId, className = '', extrinsic: propsExtrinsic, icon,
accountId: accountId?.toString(),
extrinsic,
isUnsigned,
signerOptions: {
assetId: getFeeAssetLocation(api, selectedFeeAsset)
},
txFailedCb: withSpinner ? _onFailed : onFailed,
txStartCb: _onStart,
txSuccessCb: withSpinner ? _onSuccess : onSuccess,
Expand All @@ -85,7 +91,7 @@ function TxButton ({ accountId, className = '', extrinsic: propsExtrinsic, icon,

onClick && onClick();
},
[_onFailed, _onStart, _onSuccess, accountId, isUnsigned, onClick, onFailed, onSuccess, onUpdate, params, propsExtrinsic, queueExtrinsic, setIsSending, tx, withSpinner, mountedRef]
[_onFailed, _onStart, _onSuccess, accountId, api, isUnsigned, mountedRef, onClick, onFailed, onSuccess, onUpdate, params, propsExtrinsic, queueExtrinsic, selectedFeeAsset, tx, withSpinner]
);

if (onSendRef) {
Expand Down
6 changes: 4 additions & 2 deletions packages/react-components/src/modals/Transfer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { BN } from '@polkadot/util';
import React, { useEffect, useState } from 'react';

import { checkAddress } from '@polkadot/phishing';
import { useApi, useCall } from '@polkadot/react-hooks';
import { PayWithAssetCtxRoot, useApi, useCall } from '@polkadot/react-hooks';
import { Available } from '@polkadot/react-query';
import { settings } from '@polkadot/ui-settings';
import { BN_HUNDRED, BN_ZERO, isFunction, nextTick } from '@polkadot/util';
Expand Down Expand Up @@ -149,7 +149,9 @@ function Transfer ({ className = '', onClose, recipientId: propRecipientId, send
)}
</Modal.Columns>
<Modal.Columns hint={t('By selecting this option, the transaction fee will be automatically deducted from the specified asset, ensuring a seamless and efficient payment process.')}>
<PayWithAsset />
<PayWithAssetCtxRoot>
<PayWithAsset />
</PayWithAssetCtxRoot>
</Modal.Columns>
<Modal.Columns hint={t('If the recipient account is new, the balance needs to be more than the existential deposit. Likewise if the sending account balance drops below the same value, the account will be removed from the state.')}>
{canToggleAll && isAll
Expand Down
5 changes: 5 additions & 0 deletions packages/react-hooks/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright 2017-2025 @polkadot/react-hooks authors & contributors
// SPDX-License-Identifier: Apache-2.0

import { getGenesis } from '@polkadot/apps-config';

export const AddressIdentityOtherDiscordKey = 'Discord';

export enum CoreTimeTypes {
Expand All @@ -18,3 +20,6 @@ export const ChainRenewalStatus = {

// block time on coretime chain is 2 x slower than on relay chain
export const BlockTimeCoretimeToRelayConstant = 2;

// list of chains which support sufficient non-native assets to pay fee
export const ALLOWED_CHAINS = [getGenesis('statemint')];
81 changes: 81 additions & 0 deletions packages/react-hooks/src/ctx/PayWithAsset.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright 2017-2025 @polkadot/react-hooks authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { BN } from '@polkadot/util';
import type { AssetInfoComplete } from '../types.js';
import type { PayWithAsset } from './types.js';

import React, { useCallback, useMemo, useState } from 'react';

import { useApi, useAssetIds, useAssetInfos } from '@polkadot/react-hooks';
import { formatNumber } from '@polkadot/util';

import { ALLOWED_CHAINS } from '../constants.js';

interface Props {
children?: React.ReactNode;
}

const EMPTY_STATE: PayWithAsset = {
assetOptions: [],
isDisabled: true,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onChange: () => {},
selectedFeeAsset: null
};

export const PayWithAssetCtx = React.createContext<PayWithAsset>(EMPTY_STATE);

export function PayWithAssetCtxRoot ({ children }: Props): React.ReactElement<Props> {
const { api } = useApi();
const ids = useAssetIds();
const assetInfos = useAssetInfos(ids);
const [selectedFeeAsset, setSelectedFeeAsset] = useState<AssetInfoComplete | null>(null);

const nativeAsset = useMemo(
() => api.registry.chainTokens[0],
[api]
);

const completeInfos = useMemo(
() => (assetInfos
?.filter((i): i is AssetInfoComplete =>
!!(i.details && i.metadata) && !i.details.supply.isZero() && !!i.details?.toJSON().isSufficient)
.sort((a, b) => a.id.cmp(b.id))) || [],
[assetInfos]
);

const assetOptions = useMemo(
() => [
{ text: `${nativeAsset} (Native)`, value: nativeAsset },
...completeInfos.map(({ id, metadata }) => ({
text: `${metadata.name.toUtf8()} (${formatNumber(id)})`,
value: id.toString()
}))],
[completeInfos, nativeAsset]
);

const onChange = useCallback((assetId: BN, cb?: () => void) => {
const selectedFeeAsset = completeInfos.find((a) => a.id.toString() === assetId.toString());

setSelectedFeeAsset(selectedFeeAsset ?? null);
cb?.();
}, [completeInfos]);

const isDisabled = useMemo(() => !ALLOWED_CHAINS.includes(api.genesisHash.toHex()) || completeInfos.length === 0, [api.genesisHash, completeInfos.length]);

const values: PayWithAsset = useMemo(() => {
return {
assetOptions,
isDisabled,
onChange,
selectedFeeAsset
};
}, [assetOptions, isDisabled, onChange, selectedFeeAsset]);

return (
<PayWithAssetCtx.Provider value={values}>
{children}
</PayWithAssetCtx.Provider>
);
}
1 change: 1 addition & 0 deletions packages/react-hooks/src/ctx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export { ApiStatsCtxRoot } from './ApiStats.js';
export { BlockAuthorsCtxRoot } from './BlockAuthors.js';
export { BlockEventsCtxRoot } from './BlockEvents.js';
export { KeyringCtxRoot } from './Keyring.js';
export { PayWithAssetCtxRoot } from './PayWithAsset.js';
export { QueueCtxRoot } from './Queue.js';
export { WindowSizeCtxRoot } from './WindowSize.js';
9 changes: 9 additions & 0 deletions packages/react-hooks/src/ctx/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { LinkOption } from '@polkadot/apps-config/endpoints/types';
import type { InjectedExtension } from '@polkadot/extension-inject/types';
import type { ProviderStats } from '@polkadot/rpc-provider/types';
import type { BlockNumber, EventRecord } from '@polkadot/types/interfaces';
import type { BN } from '@polkadot/util';
import type { AssetInfoComplete } from '../types.js';

Check failure on line 13 in packages/react-hooks/src/ctx/types.ts

View workflow job for this annotation

GitHub Actions / pr (build:code)

File '/home/runner/work/apps/apps/packages/react-hooks/src/types.ts' is not listed within the file list of project '/home/runner/work/apps/apps/packages/react-hooks/tsconfig.xref.json'. Projects must list all files or use an 'include' pattern.

export interface ApiState {
apiDefaultTx: SubmittableExtrinsicFunction;
Expand Down Expand Up @@ -66,6 +68,13 @@ export interface ApiStats {
when: number;
}

export interface PayWithAsset {
isDisabled: boolean;
assetOptions: {text: string, value: string}[];
onChange: (assetId: BN, cb?: () => void) => void;
selectedFeeAsset: AssetInfoComplete | null;
}

export interface BlockAuthors {
byAuthor: Record<string, string>;
eraPoints: Record<string, string>;
Expand Down
1 change: 1 addition & 0 deletions packages/react-hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export { useOwnStashInfos } from './useOwnStashInfos.js';
export { useParaApi } from './useParaApi.js';
export { useIsParasLinked, useParaEndpoints } from './useParaEndpoints.js';
export { usePassword } from './usePassword.js';
export { usePayWithAsset } from './usePayWithAsset.js';
export { usePeopleEndpoint } from './usePeopleEndpoint.js';
export { usePopupWindow } from './usePopupWindow.js';
export { usePreimage } from './usePreimage.js';
Expand Down
15 changes: 15 additions & 0 deletions packages/react-hooks/src/usePayWithAsset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2017-2025 @polkadot/react-hooks authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { PayWithAsset } from './ctx/types.js';

import { useContext } from 'react';

import { PayWithAssetCtx } from './ctx/PayWithAsset.js';
import { createNamedHook } from './createNamedHook.js';

function usePayWithAssetImpl (): PayWithAsset {
return useContext(PayWithAssetCtx);
}

export const usePayWithAsset = createNamedHook('usePayWithAsset', usePayWithAssetImpl);
37 changes: 37 additions & 0 deletions packages/react-hooks/src/utils/getFeeAssetLocation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2017-2025 @polkadot/react-hooks authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { ApiPromise } from '@polkadot/api';
import type { AnyNumber } from '@polkadot/types-codec/types';
import type { AssetInfoComplete } from '../types.js';

import { ALLOWED_CHAINS } from '../constants.js';

export const getFeeAssetLocation = (api: ApiPromise, selectedFeeAsset: AssetInfoComplete | null): AnyNumber | object | undefined => {
const genesis = api.genesisHash.toHex();

if (!ALLOWED_CHAINS.includes(genesis) || !selectedFeeAsset) {
return undefined;
}

switch (genesis) {
case ALLOWED_CHAINS[0]: {
return {
interior: {
X2: [
{
PalletInstance: 50
},
{
GeneralIndex: selectedFeeAsset.id.toString()
}
]
},
parents: 0
};
}

default:
return undefined;
}
};

0 comments on commit 76b650b

Please sign in to comment.