feat: initial Pezkuwi Apps rebrand from polkadot-apps

Rebranded terminology:
- Polkadot → Pezkuwi
- Kusama → Dicle
- Westend → Zagros
- Rococo → PezkuwiChain
- Substrate → Bizinikiwi
- parachain → teyrchain

Custom logos with Kurdistan brand colors (#e6007a → #86e62a):
- bizinikiwi-hexagon.svg
- sora-bizinikiwi.svg
- hezscanner.svg
- heztreasury.svg
- pezkuwiscan.svg
- pezkuwistats.svg
- pezkuwiassembly.svg
- pezkuwiholic.svg
This commit is contained in:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
@@ -0,0 +1,69 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
// augment package
import '@pezkuwi/api-augment/bizinikiwi';
import type { PalletAssetsAssetAccount } from '@pezkuwi/types/lookup';
import type { bool } from '@pezkuwi/types-codec';
import type { BN } from '@pezkuwi/util';
import React from 'react';
import { AddressSmall } from '@pezkuwi/react-components';
import { FormatBalance } from '@pezkuwi/react-query';
import { useTranslation } from '../translate.js';
import Transfer from './Transfer.js';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore This looks correct in the editor, but incorrect in composite mode
interface AccountExt extends PalletAssetsAssetAccount {
isFrozen?: bool;
sufficient?: bool
}
interface Props {
account: AccountExt;
accountId: string;
assetId: BN;
className?: string;
minBalance: BN;
siFormat: [number, string];
}
function Account ({ account: { balance, isFrozen, reason, sufficient }, accountId, assetId, minBalance, siFormat }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
return (
<>
<td className='address'>
<AddressSmall value={accountId} />
</td>
<td className='start'>
{isFrozen?.isTrue ? t('Yes') : t('No')}
</td>
<td className='start'>
{sufficient
? sufficient.isTrue ? t('Yes') : t('No')
: reason?.toString()}
</td>
<td className='number all'>
<FormatBalance
format={siFormat}
value={balance}
/>
</td>
<td className='button'>
<Transfer
accountId={accountId}
assetId={assetId}
minBalance={minBalance}
siFormat={siFormat}
/>
</td>
</>
);
}
export default React.memo(Account);
@@ -0,0 +1,83 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AssetInfoComplete } from '@pezkuwi/react-hooks/types';
import React, { useMemo } from 'react';
import { styled } from '@pezkuwi/react-components';
import { formatNumber } from '@pezkuwi/util';
import Account from './Account.js';
import useBalances from './useBalances.js';
interface Props {
asset: AssetInfoComplete,
className?: string;
searchValue: string;
}
const Asset = ({ asset: { details, id, metadata }, className, searchValue }: Props) => {
const balances = useBalances(id);
const siFormat = useMemo(
(): [number, string] => metadata
? [metadata.decimals.toNumber(), metadata.symbol.toUtf8().toUpperCase()]
: [0, 'NONE'],
[metadata]
);
const shouldShowAsset = useMemo(() => metadata.name.toUtf8().toLowerCase().includes(searchValue) ||
formatNumber(id).toString().replaceAll(',', '').includes(searchValue), [id, metadata.name, searchValue]);
if (!balances?.length || !shouldShowAsset) {
return <></>;
}
return balances.map(({ account, accountId }, index) => {
return (
<StyledTr
className={`isExpanded ${className}`}
isFirstItem={index === 0}
isLastItem={index === balances.length - 1}
key={accountId}
>
<td className='all'>
{index === 0 && <>{metadata.name.toUtf8()} ({formatNumber(id)})</>}
</td>
<Account
account={account}
accountId={accountId}
assetId={id}
key={accountId}
minBalance={details.minBalance}
siFormat={siFormat}
/>
</StyledTr>
);
});
};
const BASE_BORDER = 0.125;
const BORDER_TOP = `${BASE_BORDER * 3}rem solid var(--bg-page)`;
const BORDER_RADIUS = `${BASE_BORDER * 4}rem`;
const StyledTr = styled.tr<{isFirstItem: boolean; isLastItem: boolean}>`
td {
border-top: ${(props) => props.isFirstItem && BORDER_TOP};
border-radius: 0rem !important;
&:first-child {
padding-block: 1rem !important;
border-top-left-radius: ${(props) => props.isFirstItem ? BORDER_RADIUS : '0rem'}!important;
border-bottom-left-radius: ${(props) => props.isLastItem ? BORDER_RADIUS : '0rem'}!important;
}
&:last-child {
border-top-right-radius: ${(props) => props.isFirstItem ? BORDER_RADIUS : '0rem'}!important;
border-bottom-right-radius: ${(props) => props.isLastItem ? BORDER_RADIUS : '0rem'}!important;
}
}
`;
export default Asset;
@@ -0,0 +1,109 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import React, { useState } from 'react';
import { Button, InputAddress, InputBalance, Modal, Toggle, TxButton } from '@pezkuwi/react-components';
import { useApi, useToggle } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
interface Props {
accountId: string;
assetId: BN;
className?: string;
minBalance: BN;
siFormat: [number, string];
}
function Transfer ({ accountId, assetId, className, minBalance, siFormat: [siDecimals, siSymbol] }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [isOpen, toggleOpen] = useToggle();
const [amount, setAmount] = useState<BN | undefined>();
const [recipientId, setRecipientId] = useState<string | null>(null);
const [isProtected, setIsProtected] = useState(true);
return (
<>
<Button
icon='paper-plane'
label={t('send')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
className={className}
header={t('transfer asset')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The account to transfer from. This account should have sufficient assets for this transfer.')}>
<InputAddress
defaultValue={accountId}
isDisabled
label={t('send from')}
/>
</Modal.Columns>
<Modal.Columns hint={t('The beneficiary will have access to the transferred asset when the transaction is included in a block.')}>
<InputAddress
label={t('send to address')}
onChange={setRecipientId}
type='allPlus'
/>
</Modal.Columns>
<Modal.Columns hint={t('The amount of tokens to transfer to the account.')}>
<InputBalance
autoFocus
label={t('amount to transfer')}
onChange={setAmount}
siDecimals={siDecimals}
siSymbol={siSymbol}
/>
</Modal.Columns>
<Modal.Columns hint={t('The minimum balance allowed for the asset.')}>
<InputBalance
defaultValue={minBalance}
isDisabled
label={t('minimum balance')}
siDecimals={siDecimals}
siSymbol={siSymbol}
/>
</Modal.Columns>
<Modal.Columns hint={t('With the keep-alive option set, the account is protected against removal due to low balances.')}>
<Toggle
className='typeToggle'
label={
isProtected
? t('Transfer with account keep-alive checks')
: t('Normal transfer without keep-alive checks')
}
onChange={setIsProtected}
value={isProtected}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='paper-plane'
isDisabled={!recipientId || !amount}
label={t('Send')}
onStart={toggleOpen}
params={[assetId, recipientId, amount]}
tx={
isProtected
? api.tx.assets.transferKeepAlive
: api.tx.assets.transfer}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(Transfer);
@@ -0,0 +1,80 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AssetInfo, AssetInfoComplete } from '@pezkuwi/react-hooks/types';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Input, styled, Table } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
import Asset from './Asset.js';
interface Props {
className?: string;
infos?: AssetInfo[];
}
function Balances ({ className, infos = [] }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [{ searchValue }, onApplySearch] = useState({ searchValue: '' });
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('asset'), 'start'],
[t('accounts'), 'start'],
[t('frozen'), 'start'],
[t('sufficient'), 'start'],
[t('free balance'), 'start'],
[]
]);
const onChangeInput = useCallback((e: string) => {
onApplySearch({ searchValue: e });
}, []);
const completeAssets = useMemo(
() => infos
.filter((i): i is AssetInfoComplete => !!(i.details && i.metadata) && !i.details.supply.isZero())
,
[infos]
);
return (
<StyledDiv className={className}>
<Input
aria-label={t('Search by asset id or name')}
className='full isSmall'
label={t('Search')}
onChange={onChangeInput}
placeholder={t('Search by asset id or name')}
value={searchValue}
/>
<Table
empty={t('No accounts with balances found for the asset')}
header={headerRef.current}
>
{completeAssets.map((asset) => {
return (
<Asset
asset={asset}
key={asset.id.toString()}
searchValue={searchValue.toLowerCase()}
/>
);
})}
</Table>
</StyledDiv>
);
}
const StyledDiv = styled.div`
input {
max-width: 250px !important;
}
table {
overflow: auto;
}
`;
export default React.memo(Balances);
@@ -0,0 +1,60 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletAssetsAssetAccount } from '@pezkuwi/types/lookup';
import type { Option } from '@pezkuwi/types-codec';
import type { BN } from '@pezkuwi/util';
import { useMemo } from 'react';
import { createNamedHook, useAccounts, useApi, useCall } from '@pezkuwi/react-hooks';
interface AccountResult {
accountId: string;
account: PalletAssetsAssetAccount;
}
interface Result {
assetId: BN;
accounts: AccountResult[];
}
function isOptional (value: PalletAssetsAssetAccount | Option<PalletAssetsAssetAccount>): value is Option<PalletAssetsAssetAccount> {
return (value as Option<PalletAssetsAssetAccount>).isSome || (value as Option<PalletAssetsAssetAccount>).isNone;
}
const OPTS = {
transform: ([[params], accounts]: [[[BN, string][]], (PalletAssetsAssetAccount | Option<PalletAssetsAssetAccount>)[]]): Result => ({
accounts: params
.map(([, accountId], index) => {
const o = accounts[index];
return {
account: isOptional(o)
? o.unwrapOr(null)
: o,
accountId
};
})
.filter((a): a is AccountResult =>
!!a.account &&
!a.account.balance.isZero()
),
assetId: params[0][0]
}),
withParamsTransform: true
};
function useBalancesImpl (id?: BN | null): AccountResult[] | null {
const { api } = useApi();
const { allAccounts } = useAccounts();
const keys = useMemo(
() => [allAccounts.map((a) => [id, a]).filter((tup) => !!tup[0])],
[allAccounts, id]
);
const query = useCall(keys && api.query.assets.account.multi, keys, OPTS);
return (query && id && (query.assetId === id) && query.accounts) || null;
}
export default createNamedHook('useBalances', useBalancesImpl);
@@ -0,0 +1,55 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AssetInfo } from '@pezkuwi/react-hooks/types';
import React, { useMemo } from 'react';
import { AddressSmall } from '@pezkuwi/react-components';
import { FormatBalance } from '@pezkuwi/react-query';
import Mint from './Mint/index.js';
interface Props {
className?: string;
value: AssetInfo;
}
function Asset ({ className, value: { details, id, isIssuerMe, metadata } }: Props): React.ReactElement<Props> {
const format = useMemo(
(): [number, string] => metadata
? [metadata.decimals.toNumber(), metadata.symbol.toUtf8()]
: [0, '---'],
[metadata]
);
return (
<tr className={className}>
<td></td>
<td className='together'>
<span style={{ fontSize: 18, marginRight: 10 }}>
{id.toString()}
</span>
{metadata?.name.toUtf8()}</td>
<td className='address media--1000'>{details && <AddressSmall value={details.owner} />}</td>
<td className='address media--1300'>{details && <AddressSmall value={details.admin} />}</td>
<td className='address media--1600'>{details && <AddressSmall value={details.issuer} />}</td>
<td className='address media--1900'>{details && <AddressSmall value={details.freezer} />}</td>
<td className='number all'>{details && (
<FormatBalance
format={format}
value={details.supply}
/>
)}</td>
<td className='button'>{details && metadata && isIssuerMe && (
<Mint
details={details}
id={id}
metadata={metadata}
/>
)}</td>
</tr>
);
}
export default React.memo(Asset);
@@ -0,0 +1,47 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AssetInfo } from '@pezkuwi/react-hooks/types';
import React, { useRef } from 'react';
import { Table } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
import Asset from './Asset.js';
interface Props {
className?: string;
infos?: AssetInfo[];
}
function Assets ({ className, infos }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('assets'), 'start', 2],
[t('owner'), 'address media--1000'],
[t('admin'), 'address media--1300'],
[t('issuer'), 'address media--1600'],
[t('freezer'), 'address media--1900'],
[t('supply')],
[]
]);
return (
<Table
className={className}
empty={infos && t('No assets found')}
header={headerRef.current}
>
{infos?.map((info) => (
<Asset
key={info.key}
value={info}
/>
))}
</Table>
);
}
export default React.memo(Assets);
@@ -0,0 +1,113 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BatchOptions } from '@pezkuwi/react-hooks/types';
import type { BN } from '@pezkuwi/util';
import type { InfoState, TeamState } from './types.js';
import React, { useMemo, useState } from 'react';
import { Button, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useStepper, useTxBatch } from '@pezkuwi/react-hooks';
import { useTranslation } from '../../translate.js';
import Info from './Info.js';
import Team from './Team.js';
interface Props {
assetIds: BN[];
className?: string;
onClose: () => void;
openId: BN;
}
const BATCH_OPTS: BatchOptions = { type: 'all' };
function Create ({ assetIds, className, onClose, openId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [step, nextStep, prevStep] = useStepper();
const [asset, setAsset] = useState<InfoState | null>(null);
const [team, setTeam] = useState<TeamState | null>(null);
const [createTx, metadataTx] = useMemo(
() => asset
? [
api.tx.assets.create(asset.assetId, asset.accountId, asset.minBalance),
api.tx.assets.setMetadata(asset.assetId, asset.assetName, asset.assetSymbol, asset.assetDecimals)
]
: [null, null],
[api, asset]
);
const teamTx = useMemo(
() => asset && team && (team.adminId !== asset.accountId || team.freezerId !== asset.accountId || team.issuerId !== asset.accountId)
? api.tx.assets.setTeam(asset.assetId, team.issuerId, team.adminId, team.freezerId)
: null,
[api, asset, team]
);
const txs = useMemo(
() => createTx && metadataTx && team && (
teamTx
? [createTx, metadataTx, teamTx]
: [createTx, metadataTx]
),
[createTx, metadataTx, team, teamTx]
);
const extrinsic = useTxBatch(txs, BATCH_OPTS);
return (
<Modal
className={className}
header={t('create asset {{step}}/{{steps}}', { replace: { step, steps: 2 } })}
onClose={onClose}
size='large'
>
{step === 1 && (
<Info
assetIds={assetIds}
defaultValue={asset}
onChange={setAsset}
openId={openId}
/>
)}
{step === 2 && asset && (
<Team
accountId={asset.accountId}
defaultValue={team}
onChange={setTeam}
/>
)}
<Modal.Actions>
{step === 1 &&
<Button
icon='step-forward'
isDisabled={!asset}
label={t('Next')}
onClick={nextStep}
/>
}
{step === 2 && (
<>
<Button
icon='step-backward'
label={t('Prev')}
onClick={prevStep}
/>
<TxButton
accountId={asset?.accountId}
extrinsic={extrinsic}
icon='plus'
label={t('Create')}
onStart={onClose}
/>
</>
)}
</Modal.Actions>
</Modal>
);
}
export default React.memo(Create);
@@ -0,0 +1,132 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BitLength } from '@pezkuwi/react-components/types';
import type { BN } from '@pezkuwi/util';
import type { InfoState } from './types.js';
import React, { useEffect, useMemo, useState } from 'react';
import { Input, InputAddress, InputBalance, InputNumber, Modal } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { BN_ZERO } from '@pezkuwi/util';
import { useTranslation } from '../../translate.js';
interface Props {
assetIds: BN[];
className?: string;
defaultValue: InfoState | null;
onChange: (info: InfoState | null) => void;
openId: BN;
}
const ASSET_ID_BIT_LENGTH: BitLength = 128;
function Info ({ assetIds, className = '', defaultValue, onChange, openId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [initial] = useState(() => defaultValue);
const [initialId] = useState(() => openId);
const [accountId, setAccountId] = useState<string | null>(null);
const [assetId, setAssetId] = useState<BN | undefined>();
const [assetDecimals, setAssetDecimals] = useState<BN | undefined>();
const [assetName, setAssetName] = useState<string | null | undefined>(() => defaultValue?.assetName);
const [assetSymbol, setAssetSymbol] = useState<string | null | undefined>(() => defaultValue?.assetSymbol);
const [minBalance, setMinBalance] = useState<BN | undefined>();
const [siDecimals, siSymbol] = useMemo(
() => assetDecimals && assetSymbol
? [assetDecimals.toNumber(), assetSymbol.toUpperCase()]
: [0, 'NONE'],
[assetDecimals, assetSymbol]
);
const isValidDecimals = useMemo(
() => !!assetDecimals && assetDecimals.lten(20),
[assetDecimals]
);
const isValidName = useMemo(
() => !!assetName && assetName.length >= 3 && assetName.length <= 32,
[assetName]
);
const isValidSymbol = useMemo(
() => !!assetSymbol && assetSymbol.length >= 3 && assetSymbol.length <= 7,
[assetSymbol]
);
const isValidId = useMemo(
() => !!assetId && assetId.gt(BN_ZERO) && !assetIds.some((a) => a.eq(assetId)),
[assetId, assetIds]
);
useEffect((): void => {
onChange(
assetId && assetName && assetSymbol && assetDecimals && isValidId && isValidName && isValidSymbol && isValidDecimals && accountId && minBalance && !minBalance.isZero()
? { accountId, assetDecimals, assetId, assetName, assetSymbol, minBalance }
: null
);
}, [api, accountId, assetDecimals, assetId, assetIds, assetName, assetSymbol, isValidId, isValidName, isValidSymbol, isValidDecimals, minBalance, onChange]);
return (
<Modal.Content className={className}>
<Modal.Columns hint={t('The account that is to be used to create this asset and setup the initial metadata.')}>
<InputAddress
defaultValue={initial?.accountId}
label={t('creator account')}
onChange={setAccountId}
type='account'
/>
</Modal.Columns>
<Modal.Columns hint={t('The descriptive name for this asset.')}>
<Input
autoFocus
defaultValue={initial?.assetName}
isError={!isValidName}
label={t('asset name')}
onChange={setAssetName}
/>
</Modal.Columns>
<Modal.Columns hint={t('The symbol that will represent this asset.')}>
<Input
defaultValue={initial?.assetSymbol}
isError={!isValidSymbol}
label={t('asset symbol')}
onChange={setAssetSymbol}
/>
</Modal.Columns>
<Modal.Columns hint={t('The number of decimals for this token. Max allowed via the UI is set to 20.')}>
<InputNumber
defaultValue={initial?.assetDecimals}
isError={!isValidDecimals}
label={t('asset decimals')}
onChange={setAssetDecimals}
/>
</Modal.Columns>
<Modal.Columns hint={t('The minimum balance for the asset. This is specified in the units and decimals as requested.')}>
<InputBalance
defaultValue={initial?.minBalance}
isZeroable={false}
label={t('minimum balance')}
onChange={setMinBalance}
siDecimals={siDecimals}
siSymbol={siSymbol}
/>
</Modal.Columns>
<Modal.Columns hint={t('The selected id for the asset. This should not match an already-existing asset id.')}>
<InputNumber
bitLength={ASSET_ID_BIT_LENGTH}
defaultValue={initial?.assetId || initialId}
isError={!isValidId}
isZeroable={false}
label={t('asset id')}
onChange={setAssetId}
/>
</Modal.Columns>
</Modal.Content>
);
}
export default React.memo(Info);
@@ -0,0 +1,66 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { TeamState } from './types.js';
import React, { useEffect, useState } from 'react';
import { InputAddress, Modal } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { useTranslation } from '../../translate.js';
interface Props {
accountId: string;
className?: string;
defaultValue: TeamState | null;
onChange: (info: TeamState | null) => void;
}
function Team ({ accountId, className = '', defaultValue, onChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [initial] = useState(() => defaultValue);
const [adminId, setAdminId] = useState<string | null>(null);
const [freezerId, setFreezerId] = useState<string | null>(null);
const [issuerId, setIssuerId] = useState<string | null>(null);
useEffect((): void => {
onChange(
adminId && freezerId && issuerId
? { adminId, freezerId, issuerId }
: null
);
}, [api, adminId, freezerId, issuerId, onChange]);
return (
<Modal.Content className={className}>
<Modal.Columns hint={t('The account that is to be used for ongoing admin on the token.')}>
<InputAddress
defaultValue={initial?.adminId || accountId}
label={t('admin account')}
onChange={setAdminId}
type='account'
/>
</Modal.Columns>
<Modal.Columns hint={t('The account that is to be used for issuing this token.')}>
<InputAddress
defaultValue={initial?.issuerId || accountId}
label={t('issuer account')}
onChange={setIssuerId}
type='account'
/>
</Modal.Columns>
<Modal.Columns hint={t('The account that is to be used for performing freezing.')}>
<InputAddress
defaultValue={initial?.freezerId || accountId}
label={t('freezer account')}
onChange={setFreezerId}
type='account'
/>
</Modal.Columns>
</Modal.Content>
);
}
export default React.memo(Team);
@@ -0,0 +1,45 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import React from 'react';
import { Button } from '@pezkuwi/react-components';
import { useAccounts, useToggle } from '@pezkuwi/react-hooks';
import { useTranslation } from '../../translate.js';
import Create from './Create.js';
interface Props {
assetIds?: BN[];
className?: string;
openId: BN;
}
function CreateButton ({ assetIds, className, openId }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { hasAccounts } = useAccounts();
const [isOpen, toggleOpen] = useToggle();
return (
<>
<Button
icon='plus'
isDisabled={!assetIds || !hasAccounts}
label={t('Create')}
onClick={toggleOpen}
/>
{isOpen && assetIds && (
<Create
assetIds={assetIds}
className={className}
onClose={toggleOpen}
openId={openId}
/>
)}
</>
);
}
export default React.memo(CreateButton);
@@ -0,0 +1,19 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
export interface InfoState {
accountId: string;
assetDecimals: BN;
assetId: BN;
assetName: string;
assetSymbol: string;
minBalance: BN;
}
export interface TeamState {
adminId: string;
issuerId: string;
freezerId: string;
}
@@ -0,0 +1,96 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletAssetsAssetDetails, PalletAssetsAssetMetadata } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import React, { useMemo, useState } from 'react';
import { InputAddress, InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { useTranslation } from '../../translate.js';
interface Props {
className?: string;
details: PalletAssetsAssetDetails;
id: BN;
metadata: PalletAssetsAssetMetadata;
onClose: () => void;
}
function Mint ({ className, details: { issuer, minBalance }, id, metadata, onClose }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [amount, setAmount] = useState<BN | undefined>();
const [recipientId, setRecipientId] = useState<string | null>(null);
const isAmountValid = useMemo(
() => amount && amount.gte(minBalance),
[amount, minBalance]
);
const [siDecimals, siSymbol] = useMemo(
() => [metadata.decimals.toNumber(), metadata.symbol.toUtf8().toUpperCase()],
[metadata]
);
return (
<Modal
className={className}
header={t('mint asset')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The recipient account for this minting operation.')}>
<InputAddress
defaultValue={issuer}
isDisabled
label={t('issuer account')}
/>
</Modal.Columns>
<Modal.Columns hint={t('The recipient account for this minting operation.')}>
<InputAddress
label={t('mint to address')}
onChange={setRecipientId}
type='allPlus'
/>
</Modal.Columns>
<Modal.Columns hint={t('The amount of tokens to issue to the account.')}>
<InputBalance
autoFocus
isError={!isAmountValid}
isZeroable={false}
label={t('amount to issue')}
onChange={setAmount}
siDecimals={siDecimals}
siSymbol={siSymbol}
/>
</Modal.Columns>
<Modal.Columns hint={t('The minimum balance allowed for the asset.')}>
<InputBalance
defaultValue={minBalance}
isDisabled
label={t('minimum balance')}
siDecimals={siDecimals}
siSymbol={siSymbol}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={issuer}
icon='plus'
isDisabled={!recipientId || !isAmountValid}
label={t('Mint')}
onStart={onClose}
params={[id, recipientId, amount]}
tx={api.tx.assets.mint}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(Mint);
@@ -0,0 +1,47 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletAssetsAssetDetails, PalletAssetsAssetMetadata } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import React from 'react';
import { Button } from '@pezkuwi/react-components';
import { useToggle } from '@pezkuwi/react-hooks';
import { useTranslation } from '../../translate.js';
import Modal from './Mint.js';
interface Props {
className?: string;
details: PalletAssetsAssetDetails;
id: BN;
metadata: PalletAssetsAssetMetadata;
}
function Mint ({ className, details, id, metadata }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [isOpen, toggleOpen] = useToggle();
return (
<>
<Button
icon='plus'
isDisabled={metadata.isFrozen.isTrue}
label={t('Mint')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
className={className}
details={details}
id={id}
metadata={metadata}
onClose={toggleOpen}
/>
)}
</>
);
}
export default React.memo(Mint);
@@ -0,0 +1,55 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useState } from 'react';
import { Button, FilterOverlay, Input, styled } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
onQuery?: (value: string) => void;
}
function Query ({ className = '', onQuery }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [input, setInput] = useState('');
const _onChange = useCallback(
(value: string): void => setInput(value),
[]
);
const _onQuery = useCallback(
(): void => {
onQuery && onQuery(input);
},
[input, onQuery]
);
return (
<StyledFilterOverlay className={`${className} ui--FilterOverlay hasOwnMaxWidth`}>
<Input
className='asset--query'
onChange={_onChange}
onEnter={_onQuery}
placeholder={t('asset id or name to query')}
withLabel={false}
>
<Button
icon='play'
onClick={_onQuery}
/>
</Input>
</StyledFilterOverlay>
);
}
const StyledFilterOverlay = styled(FilterOverlay)`
.asset--query {
width: 20em;
}
`;
export default React.memo(Query);
@@ -0,0 +1,28 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
numAssets?: number;
}
function Summary ({ className, numAssets }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
return (
<SummaryBox className={className}>
<CardSummary label={t('assets')}>
{formatNumber(numAssets)}
</CardSummary>
</SummaryBox>
);
}
export default React.memo(Summary);
@@ -0,0 +1,42 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AssetInfo } from '@pezkuwi/react-hooks/types';
import type { BN } from '@pezkuwi/util';
import React, { useState } from 'react';
import { Button } from '@pezkuwi/react-components';
import Create from './Create/index.js';
import Assets from './Assets.js';
import Query from './Query.js';
import Summary from './Summary.js';
interface Props {
className?: string;
ids?: BN[];
infos?: AssetInfo[];
openId: BN;
}
function Overview ({ className, ids, infos, openId }: Props): React.ReactElement<Props> {
const [keyword, setKeyword] = useState('');
const filteredInfos = keyword ? infos?.filter(({ key, metadata }) => key === keyword || metadata?.name.toUtf8().includes(keyword)) : infos;
return (
<div className={className}>
<Summary numAssets={ids?.length} />
<Query onQuery={setKeyword} />
<Button.Group>
<Create
assetIds={ids}
openId={openId}
/>
</Button.Group>
<Assets infos={filteredInfos} />
</div>
);
}
export default React.memo(Overview);
@@ -0,0 +1,71 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ForeignAssetInfo } from '../useForeignAssetInfos.js';
import React, { useMemo } from 'react';
import { AddressSmall, CopyButton, Expander, styled } from '@pezkuwi/react-components';
import { FormatBalance } from '@pezkuwi/react-query';
interface Props {
className?: string;
value: ForeignAssetInfo;
}
function Asset ({ className, value: { details, location, metadata } }: Props): React.ReactElement<Props> {
const format = useMemo(
(): [number, string] => metadata
? [metadata.decimals.toNumber(), metadata.symbol.toUtf8()]
: [0, '---'],
[metadata]
);
return (
<tr className={className}>
<Location>
<Expander
isLeft
summary={JSON.stringify(location?.toJSON()).substring(0, 40)}
>
<pre>
{JSON.stringify(location?.toJSON(), null, 2)}
</pre>
</Expander>
<CopyButton value={JSON.stringify(location?.toJSON(), null, 2)} />
</Location>
<td className='together'>{metadata?.name.toUtf8()}</td>
<td className='address media--1000'>{details && <AddressSmall value={details.owner} />}</td>
<td className='address media--1300'>{details && <AddressSmall value={details.admin} />}</td>
<td className='address media--1600'>{details && <AddressSmall value={details.issuer} />}</td>
<td className='address media--1900'>{details && <AddressSmall value={details.freezer} />}</td>
<td className='number media--800'>{details && (
<FormatBalance
format={format}
value={details.supply}
/>
)}</td>
<td className='number all'>{details?.accounts.toString() || '0'}</td>
</tr>
);
}
const Location = styled.td`
display: flex;
align-items: center;
gap: 0.6rem;
white-space: nowrap;
font-style: italic;
pre {
overflow: visible;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.ui--Expander-summary svg {
padding: 0.3rem 0.4rem;
border-radius: 0.25rem;
border: 1px solid var(--border-input);
}
`;
export default React.memo(Asset);
@@ -0,0 +1,48 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ForeignAssetInfo } from '../useForeignAssetInfos.js';
import React, { useRef } from 'react';
import { Table } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
import Asset from './Asset.js';
interface Props {
className?: string;
infos?: ForeignAssetInfo[];
}
function Assets ({ className, infos }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('foreign assets'), 'start'],
[],
[t('owner'), 'address media--1000'],
[t('admin'), 'address media--1300'],
[t('issuer'), 'address media--1600'],
[t('freezer'), 'address media--1900'],
[t('supply'), 'media--800'],
[t('holders')]
]);
return (
<Table
className={className}
empty={infos && t('No assets found')}
header={headerRef.current}
>
{infos?.map((info) => (
<Asset
key={info.key}
value={info}
/>
))}
</Table>
);
}
export default React.memo(Assets);
@@ -0,0 +1,28 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
numAssets?: number;
}
function Summary ({ className, numAssets }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
return (
<SummaryBox className={className}>
<CardSummary label={t('foreign assets')}>
{formatNumber(numAssets)}
</CardSummary>
</SummaryBox>
);
}
export default React.memo(Summary);
@@ -0,0 +1,27 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { StagingXcmV3MultiLocation } from '@pezkuwi/types/lookup';
import type { ForeignAssetInfo } from '../useForeignAssetInfos.js';
import React from 'react';
import Assets from './Assets.js';
import Summary from './Summary.js';
interface Props {
className?: string;
foreignAssetInfos?: ForeignAssetInfo[]
locations?: StagingXcmV3MultiLocation[];
}
function ForeignAssets ({ className, foreignAssetInfos, locations }: Props): React.ReactElement<Props> {
return (
<div className={className}>
<Summary numAssets={locations?.length} />
<Assets infos={foreignAssetInfos} />
</div>
);
}
export default React.memo(ForeignAssets);
+136
View File
@@ -0,0 +1,136 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
// augment package
import '@pezkuwi/api-augment/bizinikiwi';
import type { BN } from '@pezkuwi/util';
import type { HexString } from '@pezkuwi/util/types';
import React, { useMemo, useRef } from 'react';
import { Route, Routes } from 'react-router';
import { getGenesis } from '@pezkuwi/apps-config';
import { Tabs } from '@pezkuwi/react-components';
import { useAccounts, useApi, useAssetIds, useAssetInfos } from '@pezkuwi/react-hooks';
import { BN_ONE } from '@pezkuwi/util';
import Balances from './Balances/index.js';
import ForeignAssets from './foreignAssets/index.js';
import Overview from './Overview/index.js';
import { useTranslation } from './translate.js';
import { useForeignAssetInfos } from './useForeignAssetInfos.js';
import { useForeignAssetLocations } from './useForeignAssetLocations.js';
interface Props {
basePath: string;
className?: string;
}
// Chains in which next asset id should be incremented from 1
const GENESIS_HASHES = [getGenesis('statemint'), getGenesis('statemine')];
function findOpenId (genesisHash: HexString, ids?: BN[]): BN {
if (!ids?.length) {
return BN_ONE;
}
if (GENESIS_HASHES.includes(genesisHash)) {
return ids.sort((a, b) => a.cmp(b))[ids.length - 1].add(BN_ONE);
}
const lastTaken = ids.find((id, index) =>
index === 0
? !id.eq(BN_ONE)
: !id.sub(BN_ONE).eq(ids[index - 1])
);
return lastTaken
? lastTaken.add(BN_ONE)
: ids[ids.length - 1].add(BN_ONE);
}
function AssetApp ({ basePath, className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { hasAccounts } = useAccounts();
const ids = useAssetIds();
const infos = useAssetInfos(ids);
const foreignAssetLocations = useForeignAssetLocations();
const foreignAssetInfos = useForeignAssetInfos(foreignAssetLocations);
const tabsRef = useRef([
{
isRoot: true,
name: 'overview',
text: t('Overview')
},
{
name: 'foreignAssets',
text: t('Foreign assets')
},
{
name: 'balances',
text: t('Balances')
}
]);
const showForeignAssetsTab = useMemo(() => !!foreignAssetLocations.length, [foreignAssetLocations.length]);
const showBalancesTab = useMemo(() => hasAccounts && infos && infos.some(({ details, metadata }) => !!(details && metadata)), [hasAccounts, infos]);
const hidden = useMemo(
() =>
[!showForeignAssetsTab && 'foreignAssets', !showBalancesTab && 'balances'].filter((a) => typeof a === 'string'),
[showBalancesTab, showForeignAssetsTab]
);
const openId = useMemo(
() => findOpenId(
api.genesisHash.toHex(),
// Check if id is valid digit
ids?.filter((id) => /^\d{1,3}(,\d{3})*$/.test(id.toString()))),
[api.genesisHash, ids]
);
return (
<main className={className}>
<Tabs
basePath={basePath}
hidden={hidden}
items={tabsRef.current}
/>
<Routes>
<Route path={basePath}>
<Route
element={
<Balances infos={infos} />
}
path='balances'
/>
<Route
element={
<ForeignAssets
foreignAssetInfos={foreignAssetInfos}
locations={foreignAssetLocations}
/>
}
path='foreignAssets'
/>
<Route
element={
<Overview
ids={ids}
infos={infos}
openId={openId}
/>
}
index
/>
</Route>
</Routes>
</main>
);
}
export default React.memo(AssetApp);
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useTranslation as useTranslationBase } from 'react-i18next';
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
return useTranslationBase('app-assets');
}
@@ -0,0 +1,82 @@
// Copyright 2017-2025 @pezkuwi/react-hooks authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@pezkuwi/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { PalletAssetsAssetDetails, PalletAssetsAssetMetadata, StagingXcmV3MultiLocation } from '@pezkuwi/types/lookup';
import { useEffect, useMemo, useState } from 'react';
import { createNamedHook, useAccounts, useApi, useCall } from '@pezkuwi/react-hooks';
const EMPTY_FLAGS = {
isAdminMe: false,
isFreezerMe: false,
isIssuerMe: false,
isOwnerMe: false
};
export interface ForeignAssetInfo {
details: PalletAssetsAssetDetails | null;
location: StagingXcmV3MultiLocation;
isAdminMe: boolean;
isIssuerMe: boolean;
isFreezerMe: boolean;
isOwnerMe: boolean;
key: string;
metadata: PalletAssetsAssetMetadata | null;
}
const QUERY_OPTS = { withParams: true };
function isAccount (allAccounts: string[], accountId: AccountId): boolean {
const address = accountId.toString();
return allAccounts.some((a) => a === address);
}
function extractInfo (allAccounts: string[], location: StagingXcmV3MultiLocation, optDetails: Option<PalletAssetsAssetDetails>, metadata: PalletAssetsAssetMetadata): ForeignAssetInfo {
const details = optDetails.unwrapOr(null);
return {
...(details
? {
isAdminMe: isAccount(allAccounts, details.admin),
isFreezerMe: isAccount(allAccounts, details.freezer),
isIssuerMe: isAccount(allAccounts, details.issuer),
isOwnerMe: isAccount(allAccounts, details.owner)
}
: EMPTY_FLAGS
),
details,
key: String(location),
location,
metadata: metadata.isEmpty
? null
: metadata
};
}
function useForeignAssetInfosImpl (locations?: StagingXcmV3MultiLocation[]): ForeignAssetInfo[] | undefined {
const { api } = useApi();
const { allAccounts } = useAccounts();
const isReady = useMemo(() => !!locations?.length && !!api.tx.foreignAssets?.setMetadata && !!api.tx.foreignAssets?.transferKeepAlive, [api.tx.foreignAssets?.setMetadata, api.tx.foreignAssets?.transferKeepAlive, locations?.length]);
const metadata = useCall<[[StagingXcmV3MultiLocation[]], PalletAssetsAssetMetadata[]]>(isReady && api.query.foreignAssets.metadata.multi, [locations], QUERY_OPTS);
const details = useCall<[[StagingXcmV3MultiLocation[]], Option<PalletAssetsAssetDetails>[]]>(isReady && api.query.foreignAssets.asset.multi, [locations], QUERY_OPTS);
const [state, setState] = useState<ForeignAssetInfo[] | undefined>();
useEffect((): void => {
details && metadata && (details[0][0].length === metadata[0][0].length) &&
setState(
details[0][0].map((location, index) =>
extractInfo(allAccounts, location, details[1][index], metadata[1][index])
)
);
}, [allAccounts, details, locations, metadata]);
return state;
}
export const useForeignAssetInfos = createNamedHook('useForeignAssetInfos', useForeignAssetInfosImpl);
@@ -0,0 +1,24 @@
// Copyright 2017-2025 @pezkuwi/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { StorageKey } from '@pezkuwi/types';
import type { StagingXcmV3MultiLocation } from '@pezkuwi/types/lookup';
import { createNamedHook, useApi, useMapKeys } from '@pezkuwi/react-hooks';
const EMPTY_PARAMS: unknown[] = [];
const OPT_KEY = {
transform: (keys: StorageKey<[StagingXcmV3MultiLocation]>[]): StagingXcmV3MultiLocation[] =>
keys.flatMap(({ args }) => args)
};
function useForeignAssetLocationsImpl () {
const { api, isApiReady } = useApi();
const values = useMapKeys(isApiReady && api.query.foreignAssets?.asset, EMPTY_PARAMS, OPT_KEY) || [];
return values;
}
export const useForeignAssetLocations = createNamedHook('useForeignAssetLocations', useForeignAssetLocationsImpl);