diff --git a/packages/extension-base/src/services/balance-service/helpers/subscribe/substrate/index.ts b/packages/extension-base/src/services/balance-service/helpers/subscribe/substrate/index.ts index 5b5a58b73dd..a09419781e0 100644 --- a/packages/extension-base/src/services/balance-service/helpers/subscribe/substrate/index.ts +++ b/packages/extension-base/src/services/balance-service/helpers/subscribe/substrate/index.ts @@ -16,7 +16,7 @@ import { getPSP22ContractPromise } from '@subwallet/extension-base/koni/api/cont import { getDefaultWeightV2 } from '@subwallet/extension-base/koni/api/contract-handler/wasm/utils'; import { _BALANCE_CHAIN_GROUP, _MANTA_ZK_CHAIN_GROUP, _ZK_ASSET_PREFIX, USE_MULTILOCATION_INDEX } from '@subwallet/extension-base/services/chain-service/constants'; import { _EvmApi, _SubstrateAdapterSubscriptionArgs, _SubstrateApi } from '@subwallet/extension-base/services/chain-service/types'; -import { _checkSmartContractSupportByChain, _getAssetExistentialDeposit, _getAssetNetuid, _getChainExistentialDeposit, _getChainNativeTokenSlug, _getContractAddressOfToken, _getTokenOnChainAssetId, _getTokenOnChainInfo, _getTokenTypesSupportedByChain, _getXcmAssetMultilocation, _isBridgedToken, _isChainEvmCompatible } from '@subwallet/extension-base/services/chain-service/utils'; +import { _checkSmartContractSupportByChain, _getAssetDecimals, _getAssetExistentialDeposit, _getAssetNetuid, _getChainExistentialDeposit, _getChainNativeTokenSlug, _getContractAddressOfToken, _getTokenOnChainAssetId, _getTokenOnChainInfo, _getTokenTypesSupportedByChain, _getXcmAssetMultilocation, _isBridgedToken, _isChainEvmCompatible } from '@subwallet/extension-base/services/chain-service/utils'; import { TaoStakeInfo } from '@subwallet/extension-base/services/earning-service/handlers/native-staking/tao'; import { BalanceItem, SubscribeBasePalletBalance, SubscribeSubstratePalletBalance } from '@subwallet/extension-base/types'; import { filterAlphaAssetsByChain, filterAssetsByChainAndType } from '@subwallet/extension-base/utils'; @@ -28,7 +28,7 @@ import { ContractPromise } from '@polkadot/api-contract'; import { subscribeERC20Interval } from '../evm'; import { subscribeEquilibriumTokenBalance } from './equilibrium'; import { subscribeGRC20Balance, subscribeVftBalance } from './gear'; -import { buildLockedDetails, getSpecialStakingBalances } from './utils'; +import { buildLockedDetails, getSpecialStakingBalancesWithDetails } from './utils'; export const subscribeSubstrateBalance = async (addresses: string[], chainInfo: _ChainInfo, assetMap: Record, substrateApi: _SubstrateApi, evmApi: _EvmApi, callback: (rs: BalanceItem[]) => void, extrinsicType?: ExtrinsicType) => { let unsubNativeToken: () => void; @@ -124,7 +124,7 @@ export const subscribeSubstrateBalance = async (addresses: string[], chainInfo: }; // eslint-disable-next-line @typescript-eslint/require-await -const subscribeWithSystemAccountPallet = async ({ addresses, callback, chainInfo, extrinsicType, substrateApi }: SubscribeSubstratePalletBalance) => { +const subscribeWithSystemAccountPallet = async ({ addresses, assetMap, callback, chainInfo, extrinsicType, substrateApi }: SubscribeSubstratePalletBalance) => { const systemAccountKey = 'query_system_account'; const poolMembersKey = 'query_nominationPools_poolMembers'; @@ -154,7 +154,15 @@ const subscribeWithSystemAccountPallet = async ({ addresses, callback, chainInfo const balances = rs[systemAccountKey]; const poolMemberInfos = rs[poolMembersKey]; - const bittensorStakingBalances = await getSpecialStakingBalances(chainInfo, addresses, substrateApi); + const nativeTokenSlug = _getChainNativeTokenSlug(chainInfo); + const nativeTokenInfo = assetMap[nativeTokenSlug]; + + const bittensorStakingDetails = await getSpecialStakingBalancesWithDetails( + chainInfo, + addresses, + substrateApi, + _getAssetDecimals(nativeTokenInfo) + ); // Precompute totalLockedFromTransfer for each account to decide if need fetch locks/holds const preItems = balances.map((_balance, index) => { @@ -170,7 +178,7 @@ const subscribeWithSystemAccountPallet = async ({ addresses, callback, chainInfo totalLockedFromTransfer += nominationPoolBalance; } - totalLockedFromTransfer += BigInt(bittensorStakingBalances[index].toString()); + totalLockedFromTransfer += BigInt(bittensorStakingDetails[index].native.toString()); return { index, totalLockedFromTransfer, balanceInfo }; }); @@ -221,15 +229,18 @@ const subscribeWithSystemAccountPallet = async ({ addresses, callback, chainInfo const allLockEntries = [...lockItems, ...holdItems, ...freezeItems]; + const transferableBalance = _getSystemPalletTransferable(balanceInfo, _getChainExistentialDeposit(chainInfo), extrinsicType); + + const stakingDetails = bittensorStakingDetails[index]; + const lockedDetails = buildLockedDetails( allLockEntries, totalLockedFromTransfer, _getSystemPalletReservedBalance(balanceInfo), - bittensorStakingBalances[index] + stakingDetails?.native, + stakingDetails?.total ); - const transferableBalance = _getSystemPalletTransferable(balanceInfo, _getChainExistentialDeposit(chainInfo), extrinsicType); - return { address: addresses[index], tokenSlug: _getChainNativeTokenSlug(chainInfo), diff --git a/packages/extension-base/src/services/balance-service/helpers/subscribe/substrate/utils.ts b/packages/extension-base/src/services/balance-service/helpers/subscribe/substrate/utils.ts index f98306aa3ef..828cdf9347f 100644 --- a/packages/extension-base/src/services/balance-service/helpers/subscribe/substrate/utils.ts +++ b/packages/extension-base/src/services/balance-service/helpers/subscribe/substrate/utils.ts @@ -6,30 +6,10 @@ import { FrameBalancesFreezesInfo, FrameBalancesHoldsInfo, FrameBalancesLocksInf import { _BALANCE_CHAIN_GROUP, _BALANCE_LOCKED_ID_GROUP } from '@subwallet/extension-base/services/chain-service/constants'; import { _SubstrateApi } from '@subwallet/extension-base/services/chain-service/types'; import { TaoStakeInfo } from '@subwallet/extension-base/services/earning-service/handlers/native-staking/tao'; +import { getAlphaToTaoRate } from '@subwallet/extension-base/services/earning-service/utils/alpha-price'; import { LockedBalanceDetails } from '@subwallet/extension-base/types'; import BigN from 'bignumber.js'; -export async function getSpecialStakingBalances (chainInfo: _ChainInfo, addresses: string[], substrateApi: _SubstrateApi): Promise { - // Default: 0 for all addresses - let balances = new Array(addresses.length).fill(new BigN(0)); - - // --- Bittensor ---------------------------------------------------------------- - if (_BALANCE_CHAIN_GROUP.bittensor.includes(chainInfo.slug)) { - const rawData = await substrateApi.api.call.stakeInfoRuntimeApi.getStakeInfoForColdkeys(addresses); - const values: Array<[string, TaoStakeInfo[]]> = rawData.toPrimitive() as Array<[string, TaoStakeInfo[]]>; - - balances = values.map(([, stakes]) => - stakes - .filter((i) => i.netuid === 0) - .reduce((prev, curr) => prev.plus(curr.stake), BigN(0)) - ); - - return balances; - } - - return balances; -} - // handler according to different logic const extractId = (id: string | Record | undefined): string => { if (!id) { @@ -45,7 +25,7 @@ const extractId = (id: string | Record | undefined): string => return keys.length ? keys[0] : ''; }; -export function buildLockedDetails (item: (FrameBalancesLocksInfo | FrameBalancesHoldsInfo | FrameBalancesFreezesInfo)[], totalLockedFromTransfer: bigint, reserved: bigint, externalStaking?: BigN): LockedBalanceDetails { +export function buildLockedDetails (item: (FrameBalancesLocksInfo | FrameBalancesHoldsInfo | FrameBalancesFreezesInfo)[], totalLockedFromTransfer: bigint, reserved: bigint, externalStaking?: BigN, totalStakingEquivalent?: BigN): LockedBalanceDetails { let stakingBalance = externalStaking || new BigN(0); let govBalance = new BigN(0); let democracyBalance = new BigN(0); @@ -73,6 +53,75 @@ export function buildLockedDetails (item: (FrameBalancesLocksInfo | FrameBalance governance: govBalance.toFixed(), democracy: democracyBalance.toFixed(), reserved: reservedBN.toFixed(), - others: others.gt(0) ? others.toFixed() : '0' + others: others.gt(0) ? others.toFixed() : '0', + totalStakingEquivalent: totalStakingEquivalent?.toFixed(0) || undefined }; } + +export type SpecialStakingBalance = { + /** Total staking value in native token (TAO) */ + total: BigN; + + /** Native TAO stake (netuid = 0) */ + native: BigN; + + /** Alpha stake converted to native TAO */ + alphaConverted: BigN; +}; + +export async function getSpecialStakingBalancesWithDetails (chainInfo: _ChainInfo, addresses: string[], substrateApi: _SubstrateApi, nativeDecimals: number): Promise { + const result: SpecialStakingBalance[] = addresses.map(() => ({ + total: new BigN(0), + native: new BigN(0), + alphaConverted: new BigN(0) + })); + + // Only apply for Bittensor + if (!_BALANCE_CHAIN_GROUP.bittensor.includes(chainInfo.slug)) { + return result; + } + + const api = await substrateApi.isReady; + + const rawData = + await api.api.call.stakeInfoRuntimeApi.getStakeInfoForColdkeys(addresses); + + const values = rawData.toPrimitive() as Array<[string, TaoStakeInfo[]]>; + + for (let i = 0; i < values.length; i++) { + const [, stakes] = values[i]; + const alphaByNetuid = new Map(); + + // Separate native & alpha + for (const stake of stakes) { + const amount = new BigN(stake.stake); + + if (stake.netuid === 0) { + result[i].native = result[i].native.plus(amount); + } else { + const prev = alphaByNetuid.get(stake.netuid) || new BigN(0); + + alphaByNetuid.set(stake.netuid, prev.plus(amount)); + } + } + + // Convert alpha → native + for (const [netuid, totalAlpha] of alphaByNetuid.entries()) { + try { + const rate = new BigN((await getAlphaToTaoRate(substrateApi, chainInfo.slug, netuid, nativeDecimals)).toString()); + const taoEquivalent = totalAlpha.multipliedBy(rate); + + result[i].alphaConverted = + result[i].alphaConverted.plus(taoEquivalent); + } catch (e) { + console.warn(`Failed to convert alpha for netuid ${netuid}`, e); + } + } + + // Final total + result[i].total = + result[i].native.plus(result[i].alphaConverted); + } + + return result; +} diff --git a/packages/extension-base/src/services/balance-service/index.ts b/packages/extension-base/src/services/balance-service/index.ts index b16dd801562..d8a426af2d4 100644 --- a/packages/extension-base/src/services/balance-service/index.ts +++ b/packages/extension-base/src/services/balance-service/index.ts @@ -270,6 +270,15 @@ export class BalanceService implements StoppableServiceInterface { } break; + + case BalanceType.TOTAL_STAKE_EQUIVALENT: + value = rs.lockedDetails?.totalStakingEquivalent || '0'; + break; + + case BalanceType.TOTAL_EQUIVALENT: + value = new BigN(rs.lockedDetails?.totalStakingEquivalent || 0).plus(new BigN(rs.free)).toFixed(); + break; + default: value = rs.free; } diff --git a/packages/extension-base/src/services/earning-service/handlers/delegated-staking/index.ts b/packages/extension-base/src/services/earning-service/handlers/delegated-staking/index.ts new file mode 100644 index 00000000000..dec560ac451 --- /dev/null +++ b/packages/extension-base/src/services/earning-service/handlers/delegated-staking/index.ts @@ -0,0 +1,4 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +export { default as TaoDelegateStakingPoolHandler } from './tao'; diff --git a/packages/extension-base/src/services/earning-service/handlers/delegated-staking/tao.ts b/packages/extension-base/src/services/earning-service/handlers/delegated-staking/tao.ts new file mode 100644 index 00000000000..5d42318b008 --- /dev/null +++ b/packages/extension-base/src/services/earning-service/handlers/delegated-staking/tao.ts @@ -0,0 +1,706 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { TransactionError } from '@subwallet/extension-base/background/errors/TransactionError'; +import { ChainType, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; +import { ALL_ACCOUNT_KEY, BITTENSOR_REFRESH_STAKE_INFO } from '@subwallet/extension-base/constants'; +import KoniState from '@subwallet/extension-base/koni/background/handlers/State'; +import { _BTC_SERVICE_TOKEN } from '@subwallet/extension-base/services/chain-service/constants'; +import { _getAssetDecimals } from '@subwallet/extension-base/services/chain-service/utils'; +import { BalanceType, BasicTxErrorType, DelegatedStrategyInfo, DelegatedYieldPoolInfo, EarningStatus, HandleYieldStepData, OptimalYieldPath, PrimitiveSubstrateProxyAccountItem, RequestDelegateStakingSubmit, RequestEarlyValidateYield, ResponseEarlyValidateYield, StakeCancelWithdrawalParams, StakingTxErrorType, StrategyInfo, SubmitJoinDelegateStaking, SubmitYieldJoinData, TransactionData, UnstakingInfo, YieldPoolInfo, YieldPoolMethodInfo, YieldPoolType, YieldPositionInfo, YieldTokenBaseInfo } from '@subwallet/extension-base/types'; +import { BN_TEN, isSameAddress, toBNString } from '@subwallet/extension-base/utils'; +import BigNumber from 'bignumber.js'; +import { t } from 'i18next'; + +import { getAlphaToTaoRate } from '../../utils/alpha-price'; +import BaseNativeStakingPoolHandler from '../native-staking/base'; +import { TaoStakeInfo } from '../native-staking/tao'; + +export interface TrustedStakeStrategy { + id: string; + name: string; + description: string; + proxyAddress: string; + strategyType: 'STANDARD_ETF' | string; + + targetConstituents: { + subnetWeights: Record; + }; + + minBalance: number; + isActive: boolean; + type: 'official' | 'custom' | string; +} + +interface TrustedStakeApyStrategy { + strategyId: string; + strategyName: string; + weightedApy: string; + subnetsWithData: number; + totalSubnets: number; + computationStatus: string; + computedAt: string; +} + +interface TrustedStakeApyResponse { + count: number; + lastRefreshed: string; + strategies: TrustedStakeApyStrategy[]; +} + +// TrustedStake APIs proxied through btc-api.koni.studio — strategies list and APY data +const BITCOIN_API_URL = 'https://btc-api.koni.studio'; +const trustedStakeApi = `${BITCOIN_API_URL}/proxy/trustedstake/strategies/active`; +const trustedStakeApyApi = `${BITCOIN_API_URL}/proxy/trustedstake/tmc-apy`; +const trustedStakeHeaders = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${_BTC_SERVICE_TOKEN}` +}; + +export default class TaoDelegateStakingPoolHandler extends BaseNativeStakingPoolHandler { + // @ts-ignore + public override readonly type = YieldPoolType.DELEGATED_STAKING; + + // TTL caches for TrustedStake API responses — avoids repeated HTTP calls across subscriptions. + // Each cache pair: a stored value + an in-flight promise to prevent duplicate concurrent fetches. + private trustedStakeStrategiesCache: { value: TrustedStakeStrategy[]; expiredAt: number } | null = null; + private trustedStakeStrategiesPromise: Promise | null = null; + private trustedStakeApyCache: { value: Map; expiredAt: number } | null = null; + private trustedStakeApyPromise: Promise> | null = null; + + override readonly availableMethod: YieldPoolMethodInfo = { + join: true, + defaultUnstake: true, + fastUnstake: false, + cancelUnstake: false, + withdraw: false, + claimReward: false, + changeValidator: false + }; + + constructor (state: KoniState, chain: string) { + super(state, chain); + + const symbol = this.nativeToken.symbol; + const chainSlug = this.chainInfo.slug; + + this.slug = `${symbol}___delegated_staking___${chainSlug}`; + } + + override async checkAccountHaveStake (): Promise> { + return Promise.resolve([]); + } + + public override async earlyValidate (data: RequestEarlyValidateYield): Promise { + const { address } = data; + + if (address === ALL_ACCOUNT_KEY) { + return { passed: true }; + } + + const poolInfo = await this.getPoolInfo() as DelegatedYieldPoolInfo | undefined; + + if (!poolInfo) { + return { + passed: false, + errorMessage: t('bg.EARNING.services.service.earning.delegatedStaking.tao.fetchPoolInfoError') + }; + } + + const decimals = _getAssetDecimals(this.nativeToken); + const symbol = this.nativeToken.symbol; + const pow = BN_TEN.pow(decimals); + + // maintainBalance for earlyValidate = pool-level joinThreshold (minimum across all strategies) + const maintainBalance = poolInfo.metadata.maintainBalance || '0'; + const proxyDeposit = poolInfo.proxyDeposit || '0'; + + const [totalEquivalent, transferableBalance] = await Promise.all([ + this.state.balanceService.getBalanceByType( + address, + this.chain, + this.nativeToken.slug, + BalanceType.TOTAL_EQUIVALENT + ), + this.state.balanceService.getTransferableBalance( + address, + this.chain, + this.nativeToken.slug + ) + ]); + + const bnTotalEquivalent = new BigNumber(totalEquivalent.value || '0'); + const bnTransferable = new BigNumber(transferableBalance.value || '0'); + const bnMaintainBalance = new BigNumber(maintainBalance); + const bnProxyDeposit = new BigNumber(proxyDeposit); + + const parsedMaintainBalance = bnMaintainBalance.dividedBy(pow).toFixed(); + const parsedProxyDeposit = bnProxyDeposit.dividedBy(pow).toFixed(); + + if (bnTotalEquivalent.lt(bnMaintainBalance) || bnTransferable.lt(bnProxyDeposit)) { + return { + passed: false, + errorMessage: t('bg.EARNING.services.service.earning.delegatedStaking.tao.minimumBalanceToStartEarning', { + replace: { + chainName: this.chainInfo.name, + parsedMaintainBalance, + parsedProxyDeposit, + symbol + } + }) + }; + } + + return { passed: true }; + } + + private normalizeStrategyName (name: string): string { + return name.trim().toLowerCase(); + } + + /** + * Fetch active TrustedStake strategies with TTL cache. + * Reuses in-flight promise if a fetch is already in progress to prevent duplicate requests. + * Falls back to stale cache on network error. + */ + private async getTrustedStakeStrategies (): Promise { + const now = Date.now(); + + if (this.trustedStakeStrategiesCache && this.trustedStakeStrategiesCache.expiredAt > now) { + return this.trustedStakeStrategiesCache.value; + } + + if (this.trustedStakeStrategiesPromise) { + return this.trustedStakeStrategiesPromise; + } + + this.trustedStakeStrategiesPromise = (async () => { + try { + const res = await fetch(trustedStakeApi, { headers: trustedStakeHeaders }); + const data = await res.json() as TrustedStakeStrategy[]; + const activeStrategies = data.filter((s) => s.isActive); + + this.trustedStakeStrategiesCache = { + value: activeStrategies, + expiredAt: Date.now() + BITTENSOR_REFRESH_STAKE_INFO + }; + + return activeStrategies; + } catch (e) { + console.warn('Failed to fetch TrustedStake strategies', e); + + return this.trustedStakeStrategiesCache?.value || []; + } finally { + this.trustedStakeStrategiesPromise = null; + } + })(); + + return this.trustedStakeStrategiesPromise; + } + + /** + * Fetch weighted APY per strategy with TTL cache. + * APY is keyed by both normalized strategy name (lowercase trim) AND strategyId (UUID) + * for reliable lookup, since strategyId in the APY endpoint differs from id in strategies-active. + * Returns a Map. + */ + private async getTrustedStakeApyMap (): Promise> { + const now = Date.now(); + + if (this.trustedStakeApyCache && this.trustedStakeApyCache.expiredAt > now) { + return this.trustedStakeApyCache.value; + } + + if (this.trustedStakeApyPromise) { + return this.trustedStakeApyPromise; + } + + this.trustedStakeApyPromise = (async () => { + try { + const res = await fetch(trustedStakeApyApi, { headers: trustedStakeHeaders }); + const data = await res.json() as TrustedStakeApyResponse; + const apyMap = new Map(); + + data.strategies.forEach((item) => { + if (item.computationStatus !== 'success') { + return; + } + + const apy = Number(item.weightedApy); + + // Map by normalized name for name-based lookup + apyMap.set(this.normalizeStrategyName(item.strategyName), apy); + + // Also map by strategyId (UUID) as a more reliable fallback key + apyMap.set(item.strategyId, apy); + }); + + this.trustedStakeApyCache = { + value: apyMap, + expiredAt: Date.now() + BITTENSOR_REFRESH_STAKE_INFO + }; + + return apyMap; + } catch (e) { + console.warn('Failed to fetch TrustedStake APY', e); + + return this.trustedStakeApyCache?.value || new Map(); + } finally { + this.trustedStakeApyPromise = null; + } + })(); + + return this.trustedStakeApyPromise; + } + + // Pool-level join threshold = minimum minBalance across all active strategies. + // Used as maintainBalance in poolInfo so the UI can show the lowest possible entry bar. + private async getJoinThresholdFromTrustedStake (): Promise { + const activeStrategies = await this.getTrustedStakeStrategies(); + + if (!activeStrategies.length) { + return null; + } + + const minBalance = Math.min( + ...activeStrategies.map((s) => s.minBalance) + ); + + return minBalance.toString(); + } + + async subscribePoolInfo (callback: (data: YieldPoolInfo) => void): Promise { + let cancel = false; + + const defaultCallback = async () => { + const chainApi = await this.substrateApi.isReady; + const apyMap = await this.getTrustedStakeApyMap(); + // totalApy shown on pool overview = highest APY across all strategies. + const totalApy = this.chain === 'bittensor' ? Array.from(apyMap.values()).reduce((maxApy, apy) => Math.max(maxApy, apy), 0) : 0; + + const proxyDepositBase = chainApi.api.consts.proxy.proxyDepositBase; + const proxyDepositFactor = chainApi.api.consts.proxy.proxyDepositFactor; + + // Calculate earning threshold as baseDeposit + depositFactor + const proxyDeposit = proxyDepositBase.add(proxyDepositFactor).toString(); + + // joinThreshold = min strategy minBalance (raw units). Falls back to proxyDeposit if API unavailable. + const joinThreshold = toBNString((await this.getJoinThresholdFromTrustedStake() || 0), _getAssetDecimals(this.nativeToken)) || proxyDeposit; + + const data: DelegatedYieldPoolInfo = { + ...this.baseInfo, + type: this.type, + metadata: { + ...this.metadataInfo, + description: this.getDescription(joinThreshold), + minValidate: joinThreshold, + maintainBalance: joinThreshold, + name: 'TAO Delegated Staking' + }, + statistic: { + assetEarning: [{ slug: this.nativeToken.slug }], + maxCandidatePerFarmer: 1, + maxWithdrawalRequestPerFarmer: 1, + earningThreshold: { + join: joinThreshold, + defaultUnstake: '0', + fastUnstake: '0' + }, + eraTime: 4, + era: 0, + unstakingPeriod: 0, + totalApy + }, + proxyDeposit: proxyDeposit + }; + + const poolInfo = await this.getPoolInfo(); + + !poolInfo && callback(data); + }; + + if (!this.isActive) { + await defaultCallback(); + + return () => { + cancel = true; + }; + } + + await defaultCallback(); + + const intervalId = setInterval(() => { + if (!cancel) { + defaultCallback().catch(console.error); + } + }, BITTENSOR_REFRESH_STAKE_INFO); + + return () => { + cancel = true; + clearInterval(intervalId); + }; + } + + override async subscribePoolPosition (useAddresses: string[], rsCallback: (rs: YieldPositionInfo) => void): Promise { + let cancel = false; + + const chainApi = await this.substrateApi.isReady; + const defaultInfo = this.baseInfo; + const chainInfo = this.chainInfo; + + const proxiesList = await chainApi.api.query.proxy.proxies.multi(useAddresses); + + const getPoolPosition = async () => { + // Re-fetch strategies & APY each interval so data stays fresh within each poll cycle. + const trustedStrategies = await this.getTrustedStakeStrategies(); + const apyMap = await this.getTrustedStakeApyMap(); + const rawStakeInfos = await chainApi.api.call.stakeInfoRuntimeApi.getStakeInfoForColdkeys(useAddresses); + + const stakeInfos = rawStakeInfos.toPrimitive() as Array<[string, TaoStakeInfo[]]>; + + for (let i = 0; i < stakeInfos.length; i++) { + const [owner, delegateStateInfo] = stakeInfos[i]; + + const [proxies] = proxiesList[i].toPrimitive() as unknown as [PrimitiveSubstrateProxyAccountItem[], string]; + + const stakingProxies = proxies?.filter((p) => p.proxyType === 'Staking') || []; + + if (!stakingProxies.length || !delegateStateInfo?.length) { + rsCallback({ + ...defaultInfo, + type: this.type, + address: owner, + balanceToken: this.nativeToken.slug, + totalStake: '0', + activeStake: '0', + unstakeBalance: '0', + status: EarningStatus.NOT_STAKING, + isBondedBefore: false, + nominations: [], + unstakings: [] + }); + + continue; + } + + // ===== 1. Accumulate stakes by token type ===== + // netuid=0 → native TAO stake; netuid>0 → alpha token stake on that subnet. + // Transferable balance is included because it already represents liquid TAO held by the user. + const transferableBalance = await this.state.balanceService.getTransferableBalance( + owner, + this.chain, + this.nativeToken.slug + ); + + let taoNativeTotal = new BigNumber(transferableBalance.value || '0'); + + const alphaByNetuid = new Map(); + + for (const delegate of delegateStateInfo) { + const stake = new BigNumber(delegate.stake); + const netuid = delegate.netuid; + + if (netuid === 0) { + taoNativeTotal = taoNativeTotal.plus(stake); + } else { + const prev = alphaByNetuid.get(netuid) || new BigNumber(0); + + alphaByNetuid.set(netuid, prev.plus(stake)); + } + } + + // ===== 2. Convert alpha → TAO using on-chain swap price ===== + // alphaPrice is fetched per netuid and cached to avoid repeated RPC calls. + // rate = price / 10^decimals to normalize to human units. + let totalTao = taoNativeTotal; + + for (const [netuid, totalAlpha] of alphaByNetuid.entries()) { + try { + const rate = await getAlphaToTaoRate( + this.substrateApi, + this.chain, + netuid, + _getAssetDecimals(this.nativeToken) + ); + + const taoEquivalent = totalAlpha.multipliedBy(rate); + + totalTao = totalTao.plus(taoEquivalent); + } catch (e) { + console.warn(`Failed alpha price for netuid ${netuid}`, e); + } + } + + // ===== 3. Match on-chain proxies to TrustedStake strategies ===== + // Only proxy accounts whose delegate address matches a known TrustedStake strategy + // are counted as "trusted" — unrecognized proxies are excluded from nominations. + const constituentsSet = new Set(); + + const decimals = _getAssetDecimals(this.nativeToken); + + const nominations = stakingProxies + .map((proxy) => { + const strategy = trustedStrategies.find((strategy) => + isSameAddress(strategy.proxyAddress, proxy.delegate) + ); + + if (!strategy) { + return null; + } + + Object.keys(strategy.targetConstituents.subnetWeights).forEach((subnet) => { + constituentsSet.add(subnet); + }); + + return { + status: EarningStatus.EARNING_REWARD, + chain: chainInfo.slug, + validatorAddress: proxy.delegate, + activeStake: totalTao.toFixed(), + validatorMinStake: toBNString(strategy.minBalance, decimals), + validatorIdentity: strategy.name, + // Lookup APY by name first, fallback to strategy id + expectedReturn: apyMap.get(this.normalizeStrategyName(strategy.name)) ?? apyMap.get(strategy.id), + substrateProxyType: proxy.proxyType, + delay: proxy.delay + }; + }).filter((n) => n !== null) as DelegatedStrategyInfo[]; + + // isTrusted = account has at least one proxy pointing at a known TrustedStake strategy. + // constituents = union of all subnet IDs across the matched strategies. + const isTrusted = nominations.length > 0; + const constituents = Array.from(constituentsSet); + + rsCallback({ + ...defaultInfo, + type: this.type, + address: owner, + balanceToken: this.nativeToken.slug, + + totalStake: isTrusted ? totalTao.toFixed() : '0', + activeStake: isTrusted ? totalTao.toFixed() : '0', + + unstakeBalance: '0', + status: isTrusted + ? EarningStatus.EARNING_REWARD + : EarningStatus.NOT_STAKING, + + isBondedBefore: isTrusted, + nominations: isTrusted ? nominations : [], + unstakings: [], + metadata: isTrusted && constituents.length + ? { constituents } + : undefined + }); + } + }; + + const getStakingPositionInterval = async () => { + if (!cancel) { + await getPoolPosition(); + } + }; + + await getStakingPositionInterval(); + + const intervalId = setInterval(() => { + getStakingPositionInterval().catch(console.error); + }, BITTENSOR_REFRESH_STAKE_INFO); + + return () => { + cancel = true; + clearInterval(intervalId); + }; + } + + public override async getPoolTargets (): Promise { + try { + const [strategies, apyMap] = await Promise.all([ + this.getTrustedStakeStrategies(), + this.getTrustedStakeApyMap() + ]); + + const decimals = _getAssetDecimals(this.nativeToken); + + return strategies + .map((strategy): StrategyInfo => ({ + address: strategy.proxyAddress, + chain: this.chain, + minBond: toBNString(strategy.minBalance, decimals), + constituents: Object.keys(strategy.targetConstituents.subnetWeights), + identity: strategy.name, + // Lookup APY by name first, fallback to strategy id + expectedReturn: apyMap.get(this.normalizeStrategyName(strategy.name)) ?? apyMap.get(strategy.id) + })); + } catch (e) { + console.warn('Failed to fetch pool targets', e); + + return []; + } + } + + /** + * Validate before submitting a join transaction. + * Two balance requirements must both be met: + * 1. transferable >= proxyDeposit (to pay for adding a substrate proxy) + * 2. totalEquivalent >= minBond (strategy-specific minimum; in TAO-equivalent terms) + */ + public override async validateYieldJoin (data: SubmitYieldJoinData, path: OptimalYieldPath): Promise { + const { address, minBond, slug, substrateProxyAddress } = data as SubmitJoinDelegateStaking; + + const poolInfo = await this.getPoolInfo(slug) as DelegatedYieldPoolInfo; + + if (!poolInfo) { + return Promise.resolve([new TransactionError(BasicTxErrorType.INTERNAL_ERROR)]); + } + + if (isSameAddress(address, substrateProxyAddress)) { + return Promise.resolve([new TransactionError(BasicTxErrorType.INVALID_PARAMS)]); + } + + const errors: TransactionError[] = []; + + const transferableBalance = await this.state.balanceService.getTransferableBalance( + address, + this.chain, + this.nativeToken.slug + ); + + const totalEquilvalent = await this.state.balanceService.getBalanceByType( + address, + this.chain, + this.nativeToken.slug, + BalanceType.TOTAL_EQUIVALENT + ); + + const bnTransferable = new BigNumber(transferableBalance.value || '0'); + const bnTotalEquivalent = new BigNumber(totalEquilvalent.value || '0'); + + const chainApi = await this.substrateApi.isReady; + + const proxyDeposit = poolInfo.proxyDeposit || '0'; + + // Check if account already has a proxy + let hasStakingProxy = false; + + try { + const proxies = await chainApi.api.query.proxy.proxies(address); + + if (proxies) { + const [proxyDefs] = proxies as unknown as [PrimitiveSubstrateProxyAccountItem[], string]; + + hasStakingProxy = (proxyDefs || []).length > 0; + } + } catch (e) { + // ignore + } + + const decimals = _getAssetDecimals(this.nativeToken); + const symbol = this.nativeToken.symbol; + const pow = BN_TEN.pow(decimals); + + const bnProxyDeposit = new BigNumber(proxyDeposit); + const parsedProxyDeposit = bnProxyDeposit.dividedBy(pow).toFixed(); + + // If account does not have staking proxy and transferable balance is less than proxy deposit -> error + if (!hasStakingProxy && bnTransferable.lt(bnProxyDeposit)) { + errors.push(new TransactionError(StakingTxErrorType.NOT_ENOUGH_MIN_STAKE, t('bg.EARNING.services.service.earning.delegatedStaking.tao.insufficientBalanceToDelegate', { + replace: { + symbol + } + }))); + + return errors; + } + + if (bnTotalEquivalent.lt(minBond)) { + errors.push(new TransactionError(StakingTxErrorType.NOT_ENOUGH_MIN_STAKE, t('bg.EARNING.services.service.earning.delegatedStaking.tao.minimumTransferableBalanceToStartEarning', { + replace: { + parsedProxyDeposit, + symbol + } + }))); + + return errors; + } + + return errors; + } + + /** + * Build the join extrinsic. + * Flow: remove all existing proxies (if any) then add the new TrustedStake proxy. + * Batched via utility.batchAll when there are proxies to remove first; + * otherwise a single proxy.addProxy call is used to save fees. + */ + override async createJoinExtrinsic (data: SubmitJoinDelegateStaking): Promise<[TransactionData, YieldTokenBaseInfo]> { + const chainApi = await this.substrateApi.isReady; + const { address, substrateProxyAddress, substrateProxyDeposit, substrateProxyType } = data; + + if (!substrateProxyAddress) { + return Promise.reject(new TransactionError(BasicTxErrorType.INVALID_PARAMS)); + } + + const txList = []; + const proxies = await chainApi.api.query.proxy.proxies(address); + + if (proxies) { + txList.push( + chainApi.api.tx.proxy.removeProxies() + ); + } + + txList.push(chainApi.api.tx.proxy.addProxy(substrateProxyAddress, substrateProxyType, 0)); + const extrinsic = txList.length === 1 ? txList[0] : chainApi.api.tx.utility.batchAll(txList); + + return [extrinsic, { slug: this.nativeToken.slug, amount: txList.length === 1 ? substrateProxyDeposit : '0' }]; + } + + override async handleYieldJoin (_data: SubmitYieldJoinData, path: OptimalYieldPath, currentStep: number): Promise { + const data = _data as SubmitJoinDelegateStaking; + const { address, amount, slug, substrateProxyAddress, substrateProxyDeposit, substrateProxyType } = data; + + const positionInfo = await this.getPoolPosition(address, slug); + + if (!positionInfo) { + return Promise.reject(new TransactionError(BasicTxErrorType.INVALID_PARAMS)); + } + + const [extrinsic, yieldTokenInfo] = await this.createJoinExtrinsic(data); + + const delegateData: RequestDelegateStakingSubmit = { + poolPosition: positionInfo, + slug: this.slug, + amount, + address, + substrateProxyAddress, + substrateProxyDeposit, + substrateProxyType + }; + + return { + txChain: this.chain, + extrinsicType: ExtrinsicType.ADD_SUBSTRATE_PROXY_ACCOUNT, + extrinsic, + txData: delegateData, + transferNativeAmount: yieldTokenInfo.amount || '0', + chainType: ChainType.SUBSTRATE + }; + } + + public override validateYieldLeave (amount: string, address: string, fastLeave: boolean, selectedTarget?: string, slug?: string, poolInfo?: YieldPoolInfo): Promise { + return Promise.resolve([]); + } + + protected override handleYieldUnstake (amount: string, address: string, selectedTarget?: string): Promise<[ExtrinsicType, TransactionData]> { + throw new Error('Method not implemented.'); // Handle by remove proxy in substrate proxy service + } + + public override handleYieldWithdraw (address: string, unstakingInfo: UnstakingInfo): Promise { + throw new Error('Method not implemented.'); + } + + public override handleYieldCancelUnstake (params: StakeCancelWithdrawalParams): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/extension-base/src/services/earning-service/handlers/native-staking/base.ts b/packages/extension-base/src/services/earning-service/handlers/native-staking/base.ts index 9c5869da5b2..52d9c8bf778 100644 --- a/packages/extension-base/src/services/earning-service/handlers/native-staking/base.ts +++ b/packages/extension-base/src/services/earning-service/handlers/native-staking/base.ts @@ -5,7 +5,7 @@ import { TransactionError } from '@subwallet/extension-base/background/errors/Tr import { ChainType, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; import KoniState from '@subwallet/extension-base/koni/background/handlers/State'; import { STAKING_IDENTITY_API_SLUG } from '@subwallet/extension-base/services/earning-service/constants'; -import { BasicTxErrorType, EarningRewardHistoryItem, EarningRewardItem, HandleYieldStepData, OptimalYieldPath, OptimalYieldPathParams, RequestBondingSubmit, SubmitChangeValidatorStaking, SubmitJoinNativeStaking, SubmitYieldJoinData, TransactionData, ValidatorInfo, YieldPoolMethodInfo, YieldPoolType, YieldPositionInfo, YieldStepBaseInfo, YieldStepType, YieldTokenBaseInfo } from '@subwallet/extension-base/types'; +import { BasicTxErrorType, EarningRewardHistoryItem, EarningRewardItem, HandleYieldStepData, OptimalYieldPath, OptimalYieldPathParams, RequestBondingSubmit, SubmitChangeValidatorStaking, SubmitJoinDelegateStaking, SubmitJoinNativeStaking, SubmitYieldJoinData, TransactionData, ValidatorInfo, YieldPoolMethodInfo, YieldPoolType, YieldPositionInfo, YieldStepBaseInfo, YieldStepType, YieldTokenBaseInfo } from '@subwallet/extension-base/types'; import { noop } from '@polkadot/util'; @@ -135,7 +135,7 @@ export default abstract class BaseNativeStakingPoolHandler extends BasePoolHandl ]; } - abstract createJoinExtrinsic (data: SubmitJoinNativeStaking, positionInfo?: YieldPositionInfo, bondDest?: string, netuid?: number): Promise<[TransactionData, YieldTokenBaseInfo]> + abstract createJoinExtrinsic (data: SubmitJoinNativeStaking | SubmitJoinDelegateStaking, positionInfo?: YieldPositionInfo, bondDest?: string, netuid?: number): Promise<[TransactionData, YieldTokenBaseInfo]> protected async getSubmitStep (params: OptimalYieldPathParams): Promise { const { address, amount, netuid, slug, targets } = params; diff --git a/packages/extension-base/src/services/earning-service/handlers/native-staking/dtao.ts b/packages/extension-base/src/services/earning-service/handlers/native-staking/dtao.ts index a7f5ce852b6..a0e4532f5d3 100644 --- a/packages/extension-base/src/services/earning-service/handlers/native-staking/dtao.ts +++ b/packages/extension-base/src/services/earning-service/handlers/native-staking/dtao.ts @@ -299,7 +299,6 @@ export default class SubnetTaoStakingPoolHandler extends TaoNativeStakingPoolHan const getPoolPosition = async () => { const rawDelegateStateInfos = await substrateApi.api.call.stakeInfoRuntimeApi.getStakeInfoForColdkeys(useAddresses); const delegateStateInfos = rawDelegateStateInfos.toPrimitive() as Array<[string, TaoStakeInfo[]]>; - const alphaToTaoRateMap = await getAlphaToTaoRateMap(this.substrateApi, this.getAlphaPriceScaleDecimals()); if (!delegateStateInfos || delegateStateInfos.length === 0) { diff --git a/packages/extension-base/src/services/earning-service/service.ts b/packages/extension-base/src/services/earning-service/service.ts index 572e35cd807..dbca8fcb761 100644 --- a/packages/extension-base/src/services/earning-service/service.ts +++ b/packages/extension-base/src/services/earning-service/service.ts @@ -18,6 +18,7 @@ import { addLazy, createPromiseHandler, filterAddressByChainInfo, PromiseHandler import { fetchStaticCache } from '@subwallet/extension-base/utils/fetchStaticCache'; import { BehaviorSubject, combineLatest } from 'rxjs'; +import { TaoDelegateStakingPoolHandler } from './handlers/delegated-staking'; import { EarningImpactResult } from './handlers/native-staking/dtao'; import TanssiNativeStakingPoolHandler from './handlers/native-staking/tanssi'; import { AcalaLiquidStakingPoolHandler, AmplitudeNativeStakingPoolHandler, AstarNativeStakingPoolHandler, BasePoolHandler, BifrostLiquidStakingPoolHandler, BifrostMantaLiquidStakingPoolHandler, EnergyNativeStakingPoolHandler, InterlayLendingPoolHandler, NominationPoolHandler, ParallelLiquidStakingPoolHandler, ParaNativeStakingPoolHandler, RelayNativeStakingPoolHandler, StellaSwapLiquidStakingPoolHandler, SubnetTaoStakingPoolHandler, TaoNativeStakingPoolHandler } from './handlers'; @@ -48,7 +49,7 @@ export default class EarningService implements StoppableServiceInterface, Persis private dbService: DatabaseService; private eventService: EventService; - private useOnlineCacheOnly = true; + private useOnlineCacheOnly = false; private validatorInfoCachingInterval: NodeJS.Timeout | undefined; private inactivePoolReady: PromiseHandler = createPromiseHandler(); @@ -114,6 +115,7 @@ export default class EarningService implements StoppableServiceInterface, Persis if (_STAKING_CHAIN_GROUP.bittensor.includes(chain)) { handlers.push(new TaoNativeStakingPoolHandler(this.state, chain)); + handlers.push(new TaoDelegateStakingPoolHandler(this.state, chain)); handlers.push(new SubnetTaoStakingPoolHandler(this.state, chain)); } diff --git a/packages/extension-base/src/services/earning-service/utils/alpha-price.ts b/packages/extension-base/src/services/earning-service/utils/alpha-price.ts new file mode 100644 index 00000000000..4934502f591 --- /dev/null +++ b/packages/extension-base/src/services/earning-service/utils/alpha-price.ts @@ -0,0 +1,102 @@ +// Copyright 2019-2022 @subwallet/extension-base +// SPDX-License-Identifier: Apache-2.0 + +import { BITTENSOR_REFRESH_STAKE_INFO } from '@subwallet/extension-base/constants'; +import { _SubstrateApi } from '@subwallet/extension-base/services/chain-service/types'; +import BigNumber from 'bignumber.js'; + +type AllCacheItem = { + values: Map; + expiredAt: number; +} + +type SubnetPriceItem = { + netuid: number; + price: string | number; +} + +class AlphaPriceCache { + // Cache one full price table per chain: netuid -> raw alpha price. + private readonly allValueCache = new Map(); + private readonly TTL = BITTENSOR_REFRESH_STAKE_INFO; + + public async getAlphaPriceMap ( + chain: string, + fetcher: () => Promise> + ): Promise> { + // Return hot cache when it is still inside TTL window. + const now = Date.now(); + const allCached = this.allValueCache.get(chain); + + if (allCached && allCached.expiredAt > now) { + return allCached.values; + } + + const allValues = await fetcher(); + const expiredAt = now + this.TTL; + + // Store the whole map once so callers can do O(1) lookup by netuid. + this.allValueCache.set(chain, { + values: allValues, + expiredAt + }); + + return allValues; + } + + public clearAll () { + this.allValueCache.clear(); + } +} + +export const alphaPriceCache = new AlphaPriceCache(); + +export const getAlphaToTaoRate = async ( + substrateApi: _SubstrateApi, + chain: string, + netuid: number, + nativeTokenDecimals: number +): Promise => { + // netuid=0 is native TAO, so alpha->TAO rate is always 1. + if (netuid === 0) { + return new BigNumber(1); + } + + const priceMap = await alphaPriceCache.getAlphaPriceMap( + chain, + async () => { + // Runtime API returns all subnet prices in one call, then we index by netuid. + const pricesRaw = await substrateApi.api.call.swapRuntimeApi.currentAlphaPriceAll(); + const prices = pricesRaw.toPrimitive() as Array; + const result = new Map(); + + prices.forEach((item) => { + if (Array.isArray(item)) { + const [rawNetuid, rawPrice] = item; + + result.set(Number(rawNetuid), new BigNumber(rawPrice)); + + return; + } + + const netuid = Number(item?.netuid); + const rawPrice = item?.price; + + if (!Number.isNaN(netuid) && !!rawPrice) { + result.set(netuid, new BigNumber(rawPrice)); + } + }); + + return result; + } + ); + + const price = priceMap.get(netuid); + + if (!price) { + throw new Error(`Missing alpha price for netuid ${netuid}`); + } + + // Convert raw runtime price to TAO-denominated rate by native token decimals. + return price.dividedBy(new BigNumber(10).pow(nativeTokenDecimals)); +}; diff --git a/packages/extension-base/src/services/earning-service/utils/index.ts b/packages/extension-base/src/services/earning-service/utils/index.ts index fb1e2f0a65c..c28d5d86180 100644 --- a/packages/extension-base/src/services/earning-service/utils/index.ts +++ b/packages/extension-base/src/services/earning-service/utils/index.ts @@ -116,7 +116,7 @@ export async function parseIdentity (substrateApi: _SubstrateApi, address: strin } export function isActionFromValidator (stakingType: YieldPoolType, chain: string) { - if (stakingType === YieldPoolType.NOMINATION_POOL || stakingType === YieldPoolType.LIQUID_STAKING || stakingType === YieldPoolType.LENDING) { + if (stakingType === YieldPoolType.NOMINATION_POOL || stakingType === YieldPoolType.LIQUID_STAKING || stakingType === YieldPoolType.LENDING || stakingType === YieldPoolType.DELEGATED_STAKING) { return false; } diff --git a/packages/extension-base/src/services/inapp-notification-service/index.ts b/packages/extension-base/src/services/inapp-notification-service/index.ts index bd90dee2e86..adef13596dc 100644 --- a/packages/extension-base/src/services/inapp-notification-service/index.ts +++ b/packages/extension-base/src/services/inapp-notification-service/index.ts @@ -490,6 +490,9 @@ export class InappNotificationService implements CronServiceInterface { case YieldPoolType.SUBNET_STAKING: method = 'Subnet staking'; // todo: confirm with tester break; + case YieldPoolType.DELEGATED_STAKING: + method = 'Delegated staking'; + break; } title = '[{{accountName}}] STAKED {{asset}}' diff --git a/packages/extension-base/src/types/balance/index.ts b/packages/extension-base/src/types/balance/index.ts index 3c8aee14b4f..23ce94d5d51 100644 --- a/packages/extension-base/src/types/balance/index.ts +++ b/packages/extension-base/src/types/balance/index.ts @@ -18,7 +18,9 @@ export enum BalanceType { TOTAL = 'total', // free + locked TOTAL_MINUS_RESERVED = 'totalMinusReserved', // free + locked - reserved KEEP_ALIVE = 'keepAlive', - STAKING = 'staking' // staking balance + STAKING = 'staking', // staking balance + TOTAL_STAKE_EQUIVALENT = 'totalStakeEquivalent', // only for bittensor (delegate staking) + TOTAL_EQUIVALENT = 'totalEquivalent' // only for bittensor (delegate staking) } /** @@ -38,8 +40,8 @@ export interface LockedBalanceDetails { democracy: string; reserved: string; others: string; + totalStakingEquivalent?: string; // currtently only used in Bittensor delegate staking to show total native token equivalent (locked alpha token convert to native token) } - export interface BalanceItem { // metadata address: string; diff --git a/packages/extension-base/src/types/substrateProxyAccount/actions/index.ts b/packages/extension-base/src/types/substrateProxyAccount/actions/index.ts index 208b3919f55..dbc13947adc 100644 --- a/packages/extension-base/src/types/substrateProxyAccount/actions/index.ts +++ b/packages/extension-base/src/types/substrateProxyAccount/actions/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { BaseRequestSign, InternalRequestSign } from '../../transaction'; +import { YieldPoolInfo } from '../../yield'; import { SubstrateProxyAccountItem, SubstrateProxyType } from '..'; export interface AddSubstrateProxyAccountParams extends BaseRequestSign { @@ -19,6 +20,7 @@ export interface RemoveSubstrateProxyAccountParams extends BaseRequestSign { chain: string; selectedSubstrateProxyAccounts: SubstrateProxyAccountItem[] isRemoveAll?: boolean; + poolInfo?: YieldPoolInfo // use for delegated staking } export type RequestRemoveSubstrateProxyAccount = InternalRequestSign; diff --git a/packages/extension-base/src/types/substrateProxyAccount/index.ts b/packages/extension-base/src/types/substrateProxyAccount/index.ts index 0a60db4a563..0eb3b9d4b91 100644 --- a/packages/extension-base/src/types/substrateProxyAccount/index.ts +++ b/packages/extension-base/src/types/substrateProxyAccount/index.ts @@ -33,6 +33,12 @@ export interface SubstrateProxyAccountGroup { substrateProxyDeposit: string; } +export type PrimitiveSubstrateProxyAccountItem = { + delegate: string; + proxyType: SubstrateProxyType; // type of proxy retrieved from on-chain data + delay: number; +}; + interface ProxyRawMetadata { proxiedAddress: string; } diff --git a/packages/extension-base/src/types/yield/actions/join/submit.ts b/packages/extension-base/src/types/yield/actions/join/submit.ts index 1e66a4f024a..293d8a56a12 100644 --- a/packages/extension-base/src/types/yield/actions/join/submit.ts +++ b/packages/extension-base/src/types/yield/actions/join/submit.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { _Address, AmountData, ChainType, ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; +import { SubstrateProxyType } from '@subwallet/extension-base/types/substrateProxyAccount'; import { BaseProcessRequestSign, BaseRequestSign, InternalRequestSign, TransactionData } from '../../../transaction'; import { BittensorRootClaimType, NominationPoolInfo, ValidatorInfo, YieldPoolType, YieldPositionInfo } from '../../info'; @@ -41,6 +42,13 @@ export interface SubmitJoinNominationPool extends AbstractSubmitYieldJoinData { selectedPool: NominationPoolInfo; } +export interface SubmitJoinDelegateStaking extends AbstractSubmitYieldJoinData { + substrateProxyAddress: string; + substrateProxyDeposit: string; + substrateProxyType: SubstrateProxyType; + minBond: string; +} + export interface SubmitYieldStepData extends AbstractSubmitYieldJoinData { // TODO exchangeRate: number, // reward token amount = input token amount * exchange rate inputTokenSlug: string, @@ -49,7 +57,7 @@ export interface SubmitYieldStepData extends AbstractSubmitYieldJoinData { // TO feeTokenSlug: string } -export type SubmitYieldJoinData = SubmitYieldStepData | SubmitJoinNativeStaking | SubmitJoinNominationPool; +export type SubmitYieldJoinData = SubmitYieldStepData | SubmitJoinNativeStaking | SubmitJoinNominationPool | SubmitJoinDelegateStaking; export enum EarningProcessType { NOMINATION_POOL = 'NOMINATION_POOL', @@ -109,6 +117,18 @@ export interface BondingSubmitParams extends BaseRequestSign { export type RequestBondingSubmit = InternalRequestSign; +export interface DelegateStakingSubmitParams extends BaseRequestSign { + slug: string, + poolPosition: YieldPositionInfo, + amount: string, + address: string, + substrateProxyAddress: string, + substrateProxyDeposit: string, + substrateProxyType: SubstrateProxyType, +} + +export type RequestDelegateStakingSubmit = InternalRequestSign; + export type SubmitChangeValidatorStaking = InternalRequestSign; export interface SubmitBittensorChangeValidatorStaking extends SubmitJoinNativeStaking { diff --git a/packages/extension-base/src/types/yield/info/account/info.ts b/packages/extension-base/src/types/yield/info/account/info.ts index 659bc193062..ff4a0ebae6e 100644 --- a/packages/extension-base/src/types/yield/info/account/info.ts +++ b/packages/extension-base/src/types/yield/info/account/info.ts @@ -64,7 +64,7 @@ export interface AbstractYieldPositionInfo extends BaseYieldPositionInfo { originalTotalStake: string; }; - metadata?: TanssiStakingMetadata | BittensorStakingMetadata; + metadata?: Record; } export interface TanssiStakingMetadata { @@ -78,6 +78,10 @@ export interface BittensorStakingMetadata { bittensorRootClaimType?: BittensorRootClaimType; } +export interface BittensorDelegatedStakingMetadata { + constituents: string[]; +} + export type BittensorRootClaimType = 'Swap' | 'Keep' | 'Others'; /** @@ -138,7 +142,11 @@ export interface SubnetYieldPositionInfo extends AbstractYieldPositionInfo { }; } +export interface DelegatedYieldPositionInfo extends AbstractYieldPositionInfo { + type: YieldPoolType.DELEGATED_STAKING; +} + /** * Info of yield pool * */ -export type YieldPositionInfo = NativeYieldPositionInfo | NominationYieldPositionInfo | LiquidYieldPositionInfo | LendingYieldPositionInfo | SubnetYieldPositionInfo; +export type YieldPositionInfo = NativeYieldPositionInfo | NominationYieldPositionInfo | LiquidYieldPositionInfo | LendingYieldPositionInfo | SubnetYieldPositionInfo | DelegatedYieldPositionInfo; diff --git a/packages/extension-base/src/types/yield/info/account/target.ts b/packages/extension-base/src/types/yield/info/account/target.ts index 3cf1862be12..feec02a223c 100644 --- a/packages/extension-base/src/types/yield/info/account/target.ts +++ b/packages/extension-base/src/types/yield/info/account/target.ts @@ -1,6 +1,8 @@ // Copyright 2019-2022 @subwallet/extension-base // SPDX-License-Identifier: Apache-2.0 +import { SubstrateProxyType } from '@subwallet/extension-base/types/substrateProxyAccount'; + /** * @enum {string} * @description The earning status of an account in a pool. @@ -46,3 +48,9 @@ export interface NominationInfo { /** The staking status of the account */ status: EarningStatus; } + +export interface DelegatedStrategyInfo extends NominationInfo { + substrateProxyType: SubstrateProxyType + delay: number; + expectedReturn?: number; +} diff --git a/packages/extension-base/src/types/yield/info/base.ts b/packages/extension-base/src/types/yield/info/base.ts index c0cc7df8f69..7f20f903fc9 100644 --- a/packages/extension-base/src/types/yield/info/base.ts +++ b/packages/extension-base/src/types/yield/info/base.ts @@ -24,8 +24,11 @@ export enum YieldPoolType { /** Parachain staking */ PARACHAIN_STAKING = 'PARACHAIN_STAKING', - /** Subnet stakingg */ - SUBNET_STAKING = 'SUBNET_STAKING' + /** Subnet staking */ + SUBNET_STAKING = 'SUBNET_STAKING', + + /* Delegate staking */ + DELEGATED_STAKING = 'DELEGATED_STAKING' } /** diff --git a/packages/extension-base/src/types/yield/info/chain/info.ts b/packages/extension-base/src/types/yield/info/chain/info.ts index 2f8ccce8ce6..c275a595916 100644 --- a/packages/extension-base/src/types/yield/info/chain/info.ts +++ b/packages/extension-base/src/types/yield/info/chain/info.ts @@ -332,10 +332,17 @@ export interface NativeYieldPoolInfo extends AbstractYieldPoolInfo { maxPoolMembers?: number; } +export interface DelegatedYieldPoolInfo extends AbstractYieldPoolInfo { + type: YieldPoolType.DELEGATED_STAKING; + statistic?: NormalYieldPoolStatistic; + maxPoolMembers?: number; + proxyDeposit?: string; +} + /** * Info of yield pool * */ -export type YieldPoolInfo = NativeYieldPoolInfo | NominationYieldPoolInfo | LiquidYieldPoolInfo | LendingYieldPoolInfo; +export type YieldPoolInfo = NativeYieldPoolInfo | NominationYieldPoolInfo | LiquidYieldPoolInfo | LendingYieldPoolInfo | DelegatedYieldPoolInfo; /** * @interface YieldAssetExpectedEarning diff --git a/packages/extension-base/src/types/yield/info/chain/target.ts b/packages/extension-base/src/types/yield/info/chain/target.ts index f496b210d57..9b6dd0a86cd 100644 --- a/packages/extension-base/src/types/yield/info/chain/target.ts +++ b/packages/extension-base/src/types/yield/info/chain/target.ts @@ -38,12 +38,21 @@ export interface ValidatorInfo { topQuartile?: boolean; } +export interface StrategyInfo { + address: string; + chain: string; + minBond: string; + constituents: string[] + identity?: string; + expectedReturn?: number; // in %, annually +} + export interface AllValidatorInfo { currentSelectedValidatorList: ValidatorInfo[]; waitingValidatorList: ValidatorInfo[]; } -export type YieldPoolTarget = NominationPoolInfo | ValidatorInfo; +export type YieldPoolTarget = NominationPoolInfo | ValidatorInfo | StrategyInfo; export interface RequestGetYieldPoolTargets { slug: string; diff --git a/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/variants/AddSubstrateProxyAccount.tsx b/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/variants/AddSubstrateProxyAccount.tsx index 4de4e54a13c..0d89f9fda53 100644 --- a/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/variants/AddSubstrateProxyAccount.tsx +++ b/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/variants/AddSubstrateProxyAccount.tsx @@ -1,8 +1,8 @@ // Copyright 2019-2022 @subwallet/extension-koni-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 -import { RequestAddSubstrateProxyAccount } from '@subwallet/extension-base/types'; -import { CommonTransactionInfo } from '@subwallet/extension-koni-ui/components'; +import { RequestAddSubstrateProxyAccount, RequestDelegateStakingSubmit } from '@subwallet/extension-base/types'; +import { AlertBox, CommonTransactionInfo } from '@subwallet/extension-koni-ui/components'; import MetaInfo from '@subwallet/extension-koni-ui/components/MetaInfo/MetaInfo'; import useGetNativeTokenBasicInfo from '@subwallet/extension-koni-ui/hooks/common/useGetNativeTokenBasicInfo'; import CN from 'classnames'; @@ -17,7 +17,9 @@ type Props = BaseTransactionConfirmationProps; const Component: React.FC = (props: Props) => { const { className, transaction } = props; const { t } = useTranslation(); - const data = transaction.data as RequestAddSubstrateProxyAccount; + const data = transaction.data as RequestAddSubstrateProxyAccount | RequestDelegateStakingSubmit; + + const isDelegatedStaking = 'poolPosition' in data; const { decimals, symbol } = useGetNativeTokenBasicInfo(transaction.chain); return ( @@ -52,6 +54,14 @@ const Component: React.FC = (props: Props) => { value={transaction.estimateFee?.value || 0} />} + + {isDelegatedStaking && + } ); }; @@ -70,6 +80,10 @@ const AddSubstrateProxyAccountTransactionConfirmation = styled(Component) '.__selected-validator-type': { color: token['magenta-6'] + }, + + '&.alert-box': { + marginTop: token.marginSM } }; }); diff --git a/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/variants/RemoveSubstrateProxyAccount.tsx b/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/variants/RemoveSubstrateProxyAccount.tsx index 6a1887bc133..f4ddbec66db 100644 --- a/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/variants/RemoveSubstrateProxyAccount.tsx +++ b/packages/extension-koni-ui/src/Popup/Confirmations/variants/Transaction/variants/RemoveSubstrateProxyAccount.tsx @@ -1,8 +1,8 @@ // Copyright 2019-2022 @subwallet/extension-koni-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 -import { RequestRemoveSubstrateProxyAccount } from '@subwallet/extension-base/types'; -import { CommonTransactionInfo, SubstrateProxyAccountListModal } from '@subwallet/extension-koni-ui/components'; +import { RequestRemoveSubstrateProxyAccount, YieldPoolType } from '@subwallet/extension-base/types'; +import { AlertBox, CommonTransactionInfo, SubstrateProxyAccountListModal } from '@subwallet/extension-koni-ui/components'; import MetaInfo from '@subwallet/extension-koni-ui/components/MetaInfo/MetaInfo'; import { SUBSTRATE_PROXY_ACCOUNT_LIST_MODAL } from '@subwallet/extension-koni-ui/constants'; import useGetNativeTokenBasicInfo from '@subwallet/extension-koni-ui/hooks/common/useGetNativeTokenBasicInfo'; @@ -25,6 +25,7 @@ const Component: React.FC = (props: Props) => { const { decimals, symbol } = useGetNativeTokenBasicInfo(transaction.chain); const { activeModal } = useContext(ModalContext); const substrateProxyAccounts = useMemo(() => data.selectedSubstrateProxyAccounts, [data.selectedSubstrateProxyAccounts]); + const isDelegatedStaking = useMemo(() => data.poolInfo?.type === YieldPoolType.DELEGATED_STAKING, [data.poolInfo?.type]); const onClickDetail = useCallback(() => { activeModal(modalId); @@ -68,6 +69,15 @@ const Component: React.FC = (props: Props) => { value={transaction.estimateFee?.value || 0} />} + + {isDelegatedStaking && + } + @@ -104,6 +114,10 @@ const RemoveSubstrateProxyAccountTransactionConfirmation = styled(Component) boolean>(() => { diff --git a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPools/index.tsx b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPools/index.tsx index b80272442b6..8816d0b248a 100644 --- a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPools/index.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPools/index.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { _getAssetDecimals } from '@subwallet/extension-base/services/chain-service/utils'; -import { _STAKING_CHAIN_GROUP, RELAY_HANDLER_DIRECT_STAKING_CHAINS } from '@subwallet/extension-base/services/earning-service/constants'; +import { RELAY_HANDLER_DIRECT_STAKING_CHAINS } from '@subwallet/extension-base/services/earning-service/constants'; import { isLendingPool, isLiquidPool } from '@subwallet/extension-base/services/earning-service/utils'; import { AccountProxyType, YieldPoolInfo, YieldPoolType } from '@subwallet/extension-base/types'; import { EmptyList, FilterModal, Layout, PageWrapper } from '@subwallet/extension-koni-ui/components'; @@ -63,7 +63,8 @@ function Component ({ poolGroup, symbol }: ComponentProps) { { label: t('ui.EARNING.screen.EarningPools.lending'), value: YieldPoolType.LENDING }, { label: t('ui.EARNING.screen.EarningPools.parachainStaking'), value: YieldPoolType.PARACHAIN_STAKING }, { label: t('ui.EARNING.screen.EarningPools.singleFarming'), value: YieldPoolType.SINGLE_FARMING }, - { label: t('ui.EARNING.screen.EarningPools.subnetStaking'), value: YieldPoolType.SUBNET_STAKING } + { label: t('ui.EARNING.screen.EarningPools.subnetStaking'), value: YieldPoolType.SUBNET_STAKING }, + { label: t('ui.EARNING.screen.EarningPools.delegatedStaking'), value: YieldPoolType.DELEGATED_STAKING } ], [t]); const positionSlugs = useMemo(() => { @@ -164,6 +165,8 @@ function Component ({ poolGroup, symbol }: ComponentProps) { return true; } else if (filter === YieldPoolType.SUBNET_STAKING && item.type === YieldPoolType.SUBNET_STAKING) { return true; + } else if (filter === YieldPoolType.DELEGATED_STAKING && item.type === YieldPoolType.DELEGATED_STAKING) { + return true; } // Uncomment the following code block if needed // else if (filter === YieldPoolType.PARACHAIN_STAKING && item.type === YieldPoolType.PARACHAIN_STAKING) { diff --git a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/AccountAndNominationInfoPart/AccountInfoPart.tsx b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/AccountAndNominationInfoPart/AccountInfoPart.tsx index db148a643dd..13bf180e351 100644 --- a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/AccountAndNominationInfoPart/AccountInfoPart.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/AccountAndNominationInfoPart/AccountInfoPart.tsx @@ -29,6 +29,10 @@ type Props = ThemeProps & { poolInfo: YieldPoolInfo; }; +type BittensorClaimableMetadata = NonNullable & { + bittensorRootClaimType?: BittensorStakingMetadata['bittensorRootClaimType']; +}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const NextArrow = ({ currentSlide, slideCount, ...props }: CustomArrowProps) => (
@@ -57,8 +61,10 @@ const PrevArrow = ({ currentSlide, slideCount, ...props }: CustomArrowProps) =>
); -function isBittensorMetadata (metadata: TanssiStakingMetadata | BittensorStakingMetadata | undefined): metadata is BittensorStakingMetadata { - return !!metadata && 'bittensorRootClaimType' in metadata; +function isBittensorMetadata ( + metadata: YieldPositionInfo['metadata'] +): metadata is BittensorClaimableMetadata { + return !!metadata && typeof metadata === 'object' && 'bittensorRootClaimType' in metadata; } function Component ({ className, compound, inputAsset, list, poolInfo }: Props) { @@ -106,6 +112,7 @@ function Component ({ className, compound, inputAsset, list, poolInfo }: Props) const isAllAccount = useMemo(() => isAccountAll(compound.address), [compound.address]); const isSpecial = useMemo(() => [YieldPoolType.LENDING, YieldPoolType.LIQUID_STAKING].includes(type), [type]); + const isDelegatedStaking = useMemo(() => type === YieldPoolType.DELEGATED_STAKING, [type]); const haveNomination = useMemo(() => { return [YieldPoolType.NOMINATION_POOL].includes(poolInfo.type); @@ -216,44 +223,48 @@ function Component ({ className, compound, inputAsset, list, poolInfo }: Props) suffix: item.subnetData?.subnetSymbol } ] - : !isSpecial + : isDelegatedStaking ? [ - metaInfoNumber(detectTranslate('ui.EARNING.screen.EarningPositionDetail.AccountInfoPart.totalStake'), new BigN(item.totalStake)), - { - label: ( -
- {t('ui.EARNING.screen.EarningPositionDetail.AccountInfoPart.activeStake')} - {!!(item?.metadata as TanssiStakingMetadata)?.isShowActiveStakeDetails && new BigN(item.activeStake).gt(0) && ( - - - - )} -
- ), - value: item.activeStake, - decimals: inputAsset?.decimals || 0, - suffix: inputAsset?.symbol - }, - - metaInfoNumber(detectTranslate('ui.EARNING.screen.EarningPositionDetail.AccountInfoPart.unstaked'), item.unstakeBalance) + metaInfoNumber(detectTranslate('ui.EARNING.screen.EarningPositionDetail.AccountInfoPart.totalStake'), new BigN(item.totalStake)) ] - : [ - metaInfoNumber(detectTranslate('ui.EARNING.screen.EarningPositionDetail.AccountInfoPart.totalStake'), new BigN(item.totalStake)), - { - label: t('ui.EARNING.screen.EarningPositionDetail.AccountInfoPart.derivativeTokenBalance'), - value: item.activeStake, - decimals: deriveAsset?.decimals || 0, - suffix: deriveAsset?.symbol - } - ]; + : !isSpecial + ? [ + metaInfoNumber(detectTranslate('ui.EARNING.screen.EarningPositionDetail.AccountInfoPart.totalStake'), new BigN(item.totalStake)), + { + label: ( +
+ {t('ui.EARNING.screen.EarningPositionDetail.AccountInfoPart.activeStake')} + {!!(item?.metadata as TanssiStakingMetadata)?.isShowActiveStakeDetails && new BigN(item.activeStake).gt(0) && ( + + + + )} +
+ ), + value: item.activeStake, + decimals: inputAsset?.decimals || 0, + suffix: inputAsset?.symbol + }, + + metaInfoNumber(detectTranslate('ui.EARNING.screen.EarningPositionDetail.AccountInfoPart.unstaked'), item.unstakeBalance) + ] + : [ + metaInfoNumber(detectTranslate('ui.EARNING.screen.EarningPositionDetail.AccountInfoPart.totalStake'), new BigN(item.totalStake)), + { + label: t('ui.EARNING.screen.EarningPositionDetail.AccountInfoPart.derivativeTokenBalance'), + value: item.activeStake, + decimals: deriveAsset?.decimals || 0, + suffix: deriveAsset?.symbol + } + ]; return ( - {!!item.metadata && isBittensorMetadata(item.metadata) && ( + {!!item?.metadata && isBittensorMetadata(item.metadata) && ( @@ -372,7 +383,7 @@ function Component ({ className, compound, inputAsset, list, poolInfo }: Props) ); }); - }, [list, isSubnetStaking, t, inputAsset, isSpecial, openActiveStakeDetailsModal, deriveAsset?.decimals, deriveAsset?.symbol, isAllAccount, poolInfo.chain, networkPrefix, renderAccount, earningTagType.color, earningTagType.label, openEarningBittensorClaimRewardTypeModal, haveNomination, haveValidator, canChangeValidator, createOpenValidator, createOpenNomination, isShowActiveStakeDetailsModal, selectedPositionInfo, closeActiveStakeDetailsModal, selectedItem, className]); + }, [list, isSubnetStaking, isDelegatedStaking, t, inputAsset, isSpecial, openActiveStakeDetailsModal, deriveAsset?.decimals, deriveAsset?.symbol, isAllAccount, poolInfo.chain, networkPrefix, renderAccount, earningTagType.color, earningTagType.label, openEarningBittensorClaimRewardTypeModal, haveNomination, haveValidator, canChangeValidator, createOpenValidator, createOpenNomination, isShowActiveStakeDetailsModal, selectedPositionInfo, closeActiveStakeDetailsModal, selectedItem, className]); return ( <> diff --git a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/EarningInfoPart.tsx b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/EarningInfoPart.tsx index 3afdd6da234..95946c615cf 100644 --- a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/EarningInfoPart.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/EarningInfoPart.tsx @@ -3,23 +3,38 @@ import { _ChainAsset } from '@subwallet/chain-list/types'; import { calculateReward } from '@subwallet/extension-base/services/earning-service/utils'; -import { NormalYieldPoolStatistic, YieldCompoundingPeriod, YieldPoolInfo, YieldPoolType } from '@subwallet/extension-base/types'; +import { DelegatedStrategyInfo, NormalYieldPoolStatistic, YieldCompoundingPeriod, YieldPoolInfo, YieldPoolType, YieldPositionInfo } from '@subwallet/extension-base/types'; import { CollapsiblePanel, MetaInfo } from '@subwallet/extension-koni-ui/components'; -import { useCreateGetSubnetStakingTokenName, useTranslation } from '@subwallet/extension-koni-ui/hooks'; +import { EarningConstituentsModal } from '@subwallet/extension-koni-ui/components/Modal/Earning'; +import { EARNING_CONSTITUENTS_MODAL } from '@subwallet/extension-koni-ui/constants'; +import { useCreateGetSubnetStakingTokenName, useNotification, useTranslation } from '@subwallet/extension-koni-ui/hooks'; import { ThemeProps } from '@subwallet/extension-koni-ui/types'; -import { getEarningTimeText } from '@subwallet/extension-koni-ui/utils'; -import { Logo } from '@subwallet/react-ui'; +import { getEarningTimeText, toShort } from '@subwallet/extension-koni-ui/utils'; +import { Icon, Logo, ModalContext } from '@subwallet/react-ui'; import CN from 'classnames'; -import React, { useMemo } from 'react'; +import { Copy, Info } from 'phosphor-react'; +import React, { useCallback, useContext, useMemo } from 'react'; +import CopyToClipboard from 'react-copy-to-clipboard'; import styled from 'styled-components'; type Props = ThemeProps & { + compound: YieldPositionInfo; inputAsset: _ChainAsset; + list: YieldPositionInfo[]; poolInfo: YieldPoolInfo; }; -function Component ({ className, inputAsset, poolInfo }: Props) { +function Component ({ className, compound, inputAsset, list, poolInfo }: Props) { const { t } = useTranslation(); + const { activeModal } = useContext(ModalContext); + + const notify = useNotification(); + const _onClickCopyButton = useCallback((e: React.SyntheticEvent) => { + e.stopPropagation(); + notify({ + message: t('ui.EARNING.screen.EarningPositionDetail.EarningInfo.copiedToClipboard') + }); + }, [notify, t]); const totalApy = useMemo((): number | undefined => { return ( @@ -38,6 +53,50 @@ function Component ({ className, inputAsset, poolInfo }: Props) { } }, [poolInfo.statistic]); const isSubnetStaking = useMemo(() => [YieldPoolType.SUBNET_STAKING].includes(poolInfo.type), [poolInfo.type]); + const isDelegatedStaking = useMemo(() => poolInfo.type === YieldPoolType.DELEGATED_STAKING, [poolInfo.type]); + + const delegatedNomination = useMemo(() => { + if (!isDelegatedStaking) { + return undefined; + } + + if (compound.nominations?.[0]) { + return compound.nominations[0] as DelegatedStrategyInfo; + } + + for (const item of list) { + if (item.nominations?.[0]) { + return item.nominations[0] as DelegatedStrategyInfo; + } + } + + return undefined; + }, [compound.nominations, isDelegatedStaking, list]); + + const delegatedConstituents = useMemo(() => { + if (!isDelegatedStaking) { + return [] as string[]; + } + + const itemSet = new Set(); + const fromCompound = compound.metadata?.constituents; + + if (Array.isArray(fromCompound)) { + fromCompound.forEach((item) => itemSet.add(String(item))); + } + + list.forEach((item) => { + const fromItem = item.metadata?.constituents; + + if (Array.isArray(fromItem)) { + fromItem.forEach((subnet) => itemSet.add(String(subnet))); + } + }); + + return Array.from(itemSet); + }, [compound.metadata, isDelegatedStaking, list]); + + const delegatedStrategyApy = delegatedNomination?.expectedReturn; const getSubnetStakingTokenName = useCreateGetSubnetStakingTokenName(); @@ -45,6 +104,10 @@ function Component ({ className, inputAsset, poolInfo }: Props) { return getSubnetStakingTokenName(poolInfo.chain, poolInfo.metadata.subnetData?.netuid || 0); }, [getSubnetStakingTokenName, poolInfo.chain, poolInfo.metadata.subnetData?.netuid]); + const onOpenConstituentsModal = useCallback(() => { + activeModal(EARNING_CONSTITUENTS_MODAL); + }, [activeModal]); + return ( - {!isSubnetStaking - ? ( - + + {delegatedNomination?.validatorIdentity || '-'} + + + + {t('ui.EARNING.components.Modal.Earning.EarningInfo.constituents')} +
+ +
+ + )} + > + {`${delegatedConstituents.length} ${delegatedConstituents.length > 1 + ? t('ui.EARNING.components.Modal.Earning.EarningInfo.subnets') + : t('ui.EARNING.components.Modal.Earning.EarningInfo.subnet')}`} +
+ + {delegatedStrategyApy !== undefined && ( + + )} + + - ) - : ( + -
- - {poolInfo.metadata.shortName} +
+ + {toShort(delegatedNomination?.validatorAddress || '-', 6)} + + + + + + +
- )} - {totalApy !== undefined && ( + + + + )} + + {!isDelegatedStaking && !isSubnetStaking && ( + + )} + + {!isDelegatedStaking && isSubnetStaking && ( + +
+ + {poolInfo.metadata.shortName} +
+
+ )} + + {!isDelegatedStaking && totalApy !== undefined && ( )} - - {unstakePeriod !== undefined && ( + {!isDelegatedStaking && ( + + )} + {!isDelegatedStaking && unstakePeriod !== undefined && ( {(poolInfo.type === YieldPoolType.LIQUID_STAKING || poolInfo.type === YieldPoolType.SUBNET_STAKING) && Up to} {getEarningTimeText(t, unstakePeriod)} @@ -110,6 +251,28 @@ export const EarningInfoPart = styled(Component)(({ theme: { token } }: P '.__label': { paddingRight: token.paddingXXS }, + '.__proxy-address': { + display: 'flex', + gap: '4px' + }, + '.__proxy-address-copy': { + cursor: 'pointer' + }, + '.__constituents-info-label': { + border: 0, + padding: 0, + background: 'transparent', + color: token.colorTextLight4, + display: 'inline-flex', + alignItems: 'center', + gap: token.sizeXXS + + }, + + '.__constituents-info-button': { + cursor: 'pointer' + }, + '.__subnet-wrapper': { display: 'flex', alignItems: 'center', diff --git a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/RewardInfoPart.tsx b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/RewardInfoPart.tsx index 1b759fa1fbe..d4e3f62e7c5 100644 --- a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/RewardInfoPart.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/RewardInfoPart.tsx @@ -53,6 +53,7 @@ function Component ({ className, closeAlert, compound, inputAsset, isShowBalance switch (type) { case YieldPoolType.LENDING: case YieldPoolType.LIQUID_STAKING: + case YieldPoolType.DELEGATED_STAKING: return false; case YieldPoolType.SUBNET_STAKING: case YieldPoolType.NATIVE_STAKING: diff --git a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/index.tsx b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/index.tsx index 956b1bd8e53..fa93891a427 100644 --- a/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/index.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/Earning/EarningPositionDetail/index.tsx @@ -212,6 +212,10 @@ function Component ({ compound, return true; } + if (poolInfo.type === YieldPoolType.DELEGATED_STAKING) { + return true; + } + return !poolInfo.metadata.availableMethod.join; }, [poolInfo.chain, poolInfo.type, poolInfo.metadata.availableMethod.join]); @@ -332,7 +336,9 @@ function Component ({ compound, diff --git a/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailList.tsx b/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailList.tsx index 5a5a533ee2e..b466807d174 100644 --- a/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailList.tsx +++ b/packages/extension-koni-ui/src/Popup/Home/Tokens/DetailList.tsx @@ -3,7 +3,7 @@ import { _getAssetPriceId, _getMultiChainAssetPriceId } from '@subwallet/extension-base/services/chain-service/utils'; import { TON_CHAINS } from '@subwallet/extension-base/services/earning-service/constants'; -import { AccountChainType, AccountProxy, AccountProxyType, BuyTokenInfo } from '@subwallet/extension-base/types'; +import { AccountChainType, AccountProxy, AccountProxyType, BuyTokenInfo, YieldPoolType } from '@subwallet/extension-base/types'; import { detectTranslate } from '@subwallet/extension-base/utils'; import { AccountSelectorModal, AlertBox, CloseIcon, LoadingScreen, ReceiveModal, TonWalletContractSelectorModal } from '@subwallet/extension-koni-ui/components'; import PageWrapper from '@subwallet/extension-koni-ui/components/Layout/PageWrapper'; @@ -12,7 +12,7 @@ import { TokenBalanceDetailItem } from '@subwallet/extension-koni-ui/components/ import { DEFAULT_SWAP_PARAMS, DEFAULT_TRANSFER_PARAMS, IS_SHOW_TON_CONTRACT_VERSION_WARNING, SWAP_TRANSACTION, TON_ACCOUNT_SELECTOR_MODAL, TON_WALLET_CONTRACT_SELECTOR_MODAL, TRANSFER_TRANSACTION } from '@subwallet/extension-koni-ui/constants'; import { DataContext } from '@subwallet/extension-koni-ui/contexts/DataContext'; import { HomeContext } from '@subwallet/extension-koni-ui/contexts/screen/HomeContext'; -import { useCoreReceiveModalHelper, useDefaultNavigate, useGetBannerByScreen, useGetChainAndExcludedTokenByCurrentAccountProxy, useNavigateOnChangeAccount, useNotification, useSelector } from '@subwallet/extension-koni-ui/hooks'; +import { useCoreReceiveModalHelper, useDefaultNavigate, useGetBannerByScreen, useGetChainAndExcludedTokenByCurrentAccountProxy, useGroupYieldPosition, useNavigateOnChangeAccount, useNotification, useSelector } from '@subwallet/extension-koni-ui/hooks'; import { canShowChart } from '@subwallet/extension-koni-ui/messaging'; import { DetailModal } from '@subwallet/extension-koni-ui/Popup/Home/Tokens/DetailModal'; import { DetailUpperBlock } from '@subwallet/extension-koni-ui/Popup/Home/Tokens/DetailUpperBlock'; @@ -44,7 +44,7 @@ function WrapperComponent ({ className = '' }: ThemeProps): React.ReactElement

@@ -77,6 +77,8 @@ function Component (): React.ReactElement { const [, setSwapStorage] = useLocalStorage(SWAP_TRANSACTION, DEFAULT_SWAP_PARAMS); const { banners, dismissBanner, onClickBanner } = useGetBannerByScreen('token_detail', tokenGroupSlug); const { allowedChains, excludedTokens } = useGetChainAndExcludedTokenByCurrentAccountProxy(); + const yieldPositions = useGroupYieldPosition(); + const isTonWalletContactSelectorModalActive = checkActive(tonWalletContractSelectorModalId); const [isShowTonWarning, setIsShowTonWarning] = useLocalStorage(IS_SHOW_TON_CONTRACT_VERSION_WARNING, true); const tonAddress = useMemo(() => { @@ -140,6 +142,32 @@ function Component (): React.ReactElement { return undefined; }, [assetRegistryMap, multiChainAssetMap, tokenGroupSlug]); + const netuid = useMemo(() => { + if (!tokenGroupSlug) { + return; + } + + if (assetRegistryMap[tokenGroupSlug]) { + return assetRegistryMap[tokenGroupSlug].metadata?.netuid; + } + + return undefined; + }, [assetRegistryMap, tokenGroupSlug]); + + const isDelegatedStaking = useMemo(() => { + if (netuid === undefined) { + return false; + } + + const netuidStr = String(netuid); + + return yieldPositions.some( + (pos) => + pos.type === YieldPoolType.DELEGATED_STAKING && + (pos.metadata?.constituents as string[])?.includes(netuidStr) + ); + }, [yieldPositions, netuid]); + const buyInfos = useMemo(() => { const slug = tokenGroupSlug || ''; const slugs = tokenGroupMap[slug] ? tokenGroupMap[slug] : [slug]; @@ -557,6 +585,14 @@ function Component (): React.ReactElement { /> )) } + {isDelegatedStaking && ( + + )} { !isHaveOnlyTonSoloAcc && !isReadonlyAccount && isIncludesTonToken && isShowTonWarning && ( <> @@ -696,6 +732,10 @@ const Tokens = styled(WrapperComponent)(({ theme: { extendToken, tok '.token-balance-detail-item, .token-detail-banner-wrapper, .ton-solo-acc-alert-area': { marginBottom: token.sizeXS + }, + + '.delegated-staking-alert-area': { + marginBottom: token.sizeXS } }); }); diff --git a/packages/extension-koni-ui/src/Popup/Home/Tokens/TotalEquivalentDetailModal.tsx b/packages/extension-koni-ui/src/Popup/Home/Tokens/TotalEquivalentDetailModal.tsx new file mode 100644 index 00000000000..5429ece3172 --- /dev/null +++ b/packages/extension-koni-ui/src/Popup/Home/Tokens/TotalEquivalentDetailModal.tsx @@ -0,0 +1,148 @@ +// Copyright 2019-2022 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { ExtrinsicType } from '@subwallet/extension-base/background/KoniTypes'; +import { BalanceType } from '@subwallet/extension-base/types'; +import { NumberDisplay } from '@subwallet/extension-koni-ui/components'; +import { useGetBalance } from '@subwallet/extension-koni-ui/hooks'; +import useTranslation from '@subwallet/extension-koni-ui/hooks/common/useTranslation'; +import { ThemeProps } from '@subwallet/extension-koni-ui/types'; +import { ActivityIndicator, SwModal } from '@subwallet/react-ui'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; + +type Props = ThemeProps & { + id: string; + onCancel: () => void; + address?: string; + chain?: string; + tokenSlug?: string; + isSubscribe?: boolean; + extrinsicType?: ExtrinsicType; + symbol: string; + decimals?: number; +}; + +function Component ({ address, chain, className, decimals, extrinsicType, id, isSubscribe, onCancel, symbol, tokenSlug }: Props): React.ReactElement { + const { t } = useTranslation(); + + const { error: errorFree, isLoading: isLoadingFree, nativeTokenBalance: transferableNative } = useGetBalance(chain, address, tokenSlug, isSubscribe, extrinsicType, BalanceType.TRANSFERABLE); + const { error: errorTotal, isLoading: isLoadingTotal, nativeTokenBalance: totalStakedInNative } = useGetBalance(chain, address, tokenSlug, isSubscribe, extrinsicType, BalanceType.TOTAL_STAKE_EQUIVALENT); + + const isLoading = isLoadingFree || isLoadingTotal; + const error = errorFree || errorTotal; + + const transferableValue = useMemo(() => { + return transferableNative?.value || 0; + }, [transferableNative]); + + const stakedValue = useMemo(() => { + return totalStakedInNative?.value || 0; + }, [totalStakedInNative]); + + const overviewItems = useMemo(() => ([ + { + key: 'transferable', + label: t('ui.BALANCE.screen.Tokens.TotalEquivalentDetailModal.transferable'), + value: transferableValue + }, + { + key: 'staked', + label: t('ui.BALANCE.screen.Tokens.TotalEquivalentDetailModal.staked'), + value: stakedValue + } + ]), [t, transferableValue, stakedValue]); + + return ( + +

+
+
+ {isLoading && ( +
+ +
+ )} + + {!isLoading && error && ( +
{error}
+ )} + + {!isLoading && !error && overviewItems.map((item) => ( +
+
{item.label}
+ + +
+ ))} +
+
+
+ + ); +} + +export const TotalEquivalentDetailModal = styled(Component)(({ theme: { token } }: Props) => { + return ({ + '.__container': { + borderRadius: token.borderRadiusLG, + backgroundColor: token.colorBgSecondary, + padding: '12px', + minHeight: 80 + }, + + '.__loading': { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: 60 + }, + + '.__row': { + display: 'flex', + justifyContent: 'space-between' + }, + + '.__row:not(:last-child)': { + marginBottom: token.margin + }, + + '.__label': { + paddingRight: token.paddingSM + }, + + '.__locked-others': { + cursor: 'pointer' + }, + + '.error-message': { + color: token.colorError + }, + + '.__locked-others-icon': { + color: token.colorTextLight3, + marginLeft: token.marginXXS + }, + + '.__balance': { + display: 'flex', + justifyContent: 'flex-end' + } + }); +}); diff --git a/packages/extension-koni-ui/src/Popup/Transaction/parts/FreeBalance.tsx b/packages/extension-koni-ui/src/Popup/Transaction/parts/FreeBalance.tsx index 40ff3cc1a56..76cbe484867 100644 --- a/packages/extension-koni-ui/src/Popup/Transaction/parts/FreeBalance.tsx +++ b/packages/extension-koni-ui/src/Popup/Transaction/parts/FreeBalance.tsx @@ -6,12 +6,14 @@ import { BalanceType } from '@subwallet/extension-base/types'; import { useGetBalance } from '@subwallet/extension-koni-ui/hooks'; import useTranslation from '@subwallet/extension-koni-ui/hooks/common/useTranslation'; import { Theme, ThemeProps } from '@subwallet/extension-koni-ui/types'; -import { ActivityIndicator, Icon, Number, Tooltip, Typography } from '@subwallet/react-ui'; +import { ActivityIndicator, Icon, ModalContext, Number, Tooltip, Typography } from '@subwallet/react-ui'; import CN from 'classnames'; import { Info } from 'phosphor-react'; -import React, { useEffect } from 'react'; +import React, { useCallback, useContext, useEffect } from 'react'; import styled, { useTheme } from 'styled-components'; +import { TotalEquivalentDetailModal } from '../../Home/Tokens/TotalEquivalentDetailModal'; + type Props = ThemeProps & { address?: string, tokenSlug?: string; @@ -37,8 +39,19 @@ const Component = ({ address, onBalanceReady, tokenSlug }: Props) => { const { t } = useTranslation(); const { token } = useTheme() as Theme; + const { activeModal, inactiveModal } = useContext(ModalContext); + const totalEquivalentDetailModalId = 'total-equivalent-details-modal'; + const { error, isLoading, nativeTokenBalance, nativeTokenSlug, tokenBalance } = useGetBalance(chain, address, tokenSlug, isSubscribe, extrinsicType, balanceType); + const handleOpenLockedDetailsModal = useCallback(() => { + activeModal(totalEquivalentDetailModalId); + }, [activeModal]); + + const handleCloseLockedDetailsModal = useCallback(() => { + inactiveModal(totalEquivalentDetailModalId); + }, [inactiveModal]); + useEffect(() => { onBalanceReady?.(!isLoading && !error); }, [error, isLoading, onBalanceReady]); @@ -119,6 +132,33 @@ const Component = ({ address, ) } + {balanceType === BalanceType.TOTAL_EQUIVALENT && !isLoading && !error && ( + <> + + + + + + + ) + } ); }; @@ -153,6 +193,10 @@ const FreeBalance = styled(Component)(({ theme: { token } }: Props) => { '&.ant-typography': { marginBottom: 0 + }, + + '.__info-icon-wrapper': { + cursor: 'pointer' } }; }); diff --git a/packages/extension-koni-ui/src/Popup/Transaction/variants/Earn.tsx b/packages/extension-koni-ui/src/Popup/Transaction/variants/Earn.tsx index cb815a58bdc..b0d5a4a2221 100644 --- a/packages/extension-koni-ui/src/Popup/Transaction/variants/Earn.tsx +++ b/packages/extension-koni-ui/src/Popup/Transaction/variants/Earn.tsx @@ -7,15 +7,15 @@ import { _handleDisplayForEarningError, _handleDisplayInsufficientEarningError } import { _getAssetDecimals, _getAssetSymbol } from '@subwallet/extension-base/services/chain-service/utils'; import { isLendingPool, isLiquidPool } from '@subwallet/extension-base/services/earning-service/utils'; import { SWTransactionResponse } from '@subwallet/extension-base/services/transaction-service/types'; -import { AccountProxyType, BalanceType, NominationPoolInfo, OptimalYieldPath, OptimalYieldPathParams, ProcessType, SlippageType, SubmitJoinNativeStaking, SubmitJoinNominationPool, SubmitYieldJoinData, ValidatorInfo, YieldPoolType, YieldStepType } from '@subwallet/extension-base/types'; +import { AccountProxyType, BalanceType, NominationPoolInfo, OptimalYieldPath, OptimalYieldPathParams, ProcessType, SlippageType, StrategyInfo, SubmitJoinDelegateStaking, SubmitJoinNativeStaking, SubmitJoinNominationPool, SubmitYieldJoinData, ValidatorInfo, YieldPoolType, YieldStepType } from '@subwallet/extension-base/types'; import { addLazy } from '@subwallet/extension-base/utils'; import { getId } from '@subwallet/extension-base/utils/getId'; -import { AccountAddressSelector, AlertBox, AmountInput, EarningPoolSelector, EarningValidatorSelector, HiddenInput, InfoIcon, LoadingScreen, MetaInfo } from '@subwallet/extension-koni-ui/components'; +import { AccountAddressSelector, AlertBox, AmountInput, EarningPoolSelector, EarningStrategySelector, EarningValidatorSelector, HiddenInput, InfoIcon, LoadingScreen, MetaInfo, NumberDisplay } from '@subwallet/extension-koni-ui/components'; import { EarningProcessItem } from '@subwallet/extension-koni-ui/components/Earning'; import { getInputValuesFromString } from '@subwallet/extension-koni-ui/components/Field/AmountInput'; -import { EarningInstructionModal } from '@subwallet/extension-koni-ui/components/Modal/Earning'; +import { EarningConstituentsModal, EarningInstructionModal } from '@subwallet/extension-koni-ui/components/Modal/Earning'; import { SlippageModal } from '@subwallet/extension-koni-ui/components/Modal/Swap'; -import { EARNING_INSTRUCTION_MODAL, EARNING_SLIPPAGE_MODAL, STAKE_ALERT_DATA } from '@subwallet/extension-koni-ui/constants'; +import { DELEGATED_STAKING_ALERT_DATA, EARNING_CONSTITUENTS_MODAL, EARNING_INSTRUCTION_MODAL, EARNING_SLIPPAGE_MODAL, STAKE_ALERT_DATA } from '@subwallet/extension-koni-ui/constants'; import { MktCampaignModalContext } from '@subwallet/extension-koni-ui/contexts/MktCampaignModalContext'; import { useChainConnection, useCoreCreateReformatAddress, useCreateGetSubnetStakingTokenName, useExtensionDisplayModes, useFetchChainState, useGetBalance, useGetNativeTokenSlug, useGetYieldPositionForSpecificAccount, useInitValidateTransaction, useNotification, useOneSignProcess, usePreCheckAction, useRestoreTransaction, useSelector, useSidePanelUtils, useTransactionContext, useWatchTransaction, useYieldPositionDetail } from '@subwallet/extension-koni-ui/hooks'; import useGetConfirmationByScreen from '@subwallet/extension-koni-ui/hooks/campaign/useGetConfirmationByScreen'; @@ -24,12 +24,13 @@ import { fetchPoolTarget, getOptimalYieldPath, submitJoinYieldPool, submitProces import { DEFAULT_YIELD_PROCESS, EarningActionType, earningReducer } from '@subwallet/extension-koni-ui/reducer'; import { store } from '@subwallet/extension-koni-ui/stores'; import { AccountAddressItemType, EarnParams, FormCallbacks, FormFieldData, ThemeProps } from '@subwallet/extension-koni-ui/types'; -import { convertFieldToObject, getExtrinsicTypeByPoolInfo, parseNominations, reformatAddress, simpleCheckForm } from '@subwallet/extension-koni-ui/utils'; +import { convertFieldToObject, getExtrinsicTypeByPoolInfo, getValidatorKey, parseDelegatedNominations, parseNominations, reformatAddress, simpleCheckForm, toShort } from '@subwallet/extension-koni-ui/utils'; import { ActivityIndicator, Button, ButtonProps, Form, Icon, Logo, ModalContext, Number, Tooltip } from '@subwallet/react-ui'; import BigN from 'bignumber.js'; import CN from 'classnames'; -import { CheckCircle, Info, PencilSimpleLine, PlusCircle } from 'phosphor-react'; +import { CheckCircle, Copy, Info, PencilSimpleLine, PlusCircle } from 'phosphor-react'; import React, { useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import CopyToClipboard from 'react-copy-to-clipboard'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; @@ -142,7 +143,7 @@ const Component = () => { }, [slug, getCurrentConfirmation]); const mustChooseTarget = useMemo( - () => [YieldPoolType.NATIVE_STAKING, YieldPoolType.SUBNET_STAKING, YieldPoolType.NOMINATION_POOL].includes(poolType), + () => [YieldPoolType.NATIVE_STAKING, YieldPoolType.SUBNET_STAKING, YieldPoolType.NOMINATION_POOL, YieldPoolType.DELEGATED_STAKING].includes(poolType), [poolType] ); @@ -151,6 +152,10 @@ const Component = () => { return BalanceType.TOTAL_MINUS_RESERVED; } + if ([YieldPoolType.DELEGATED_STAKING].includes(poolType)) { + return BalanceType.TOTAL_EQUIVALENT; + } + return undefined; }, [poolType]); @@ -282,6 +287,35 @@ const Component = () => { } }); + return result; + } else if (YieldPoolType.DELEGATED_STAKING === poolType) { + const strategyList = _poolTargets as StrategyInfo[]; + + if (!strategyList) { + return []; + } + + const nominations = parseDelegatedNominations(poolTargetValue); + const strategyMap: Record = {}; + + strategyList.forEach((strategy) => { + const key = getValidatorKey(reformatAddress(strategy.address), strategy.identity); + + strategyMap[key] = strategy; + }); + + const result: StrategyInfo[] = []; + + nominations.forEach((nomination) => { + const [address, identity] = nomination.split('___'); + + const key = getValidatorKey(reformatAddress(address), identity); + + if (strategyMap[key]) { + result.push(strategyMap[key]); + } + }); + return result; } else { return []; @@ -324,7 +358,14 @@ const Component = () => { const onFieldsChange: FormCallbacks['onFieldsChange'] = useCallback((changedFields: FormFieldData[], allFields: FormFieldData[]) => { // TODO: field change - const { empty, error } = simpleCheckForm(allFields, ['--asset', '--fromAccountProxy']); + const requiredFields: string[] = ['--asset', '--fromAccountProxy']; + + // Do not validate amount when pool type is delegated staking + if (poolType === YieldPoolType.DELEGATED_STAKING) { + requiredFields.push('--value'); + } + + const { empty, error } = simpleCheckForm(allFields, requiredFields); const values = convertFieldToObject(allFields); @@ -480,7 +521,7 @@ const Component = () => { let processId = processState.processId; const getData = (submitStep: number): SubmitYieldJoinData => { - if ([YieldPoolType.NOMINATION_POOL, YieldPoolType.NATIVE_STAKING, YieldPoolType.SUBNET_STAKING].includes(poolInfo.type) && target) { + if ([YieldPoolType.NOMINATION_POOL, YieldPoolType.NATIVE_STAKING, YieldPoolType.SUBNET_STAKING, YieldPoolType.DELEGATED_STAKING].includes(poolInfo.type) && target) { const targets = poolTargets; if (poolInfo.type === YieldPoolType.NOMINATION_POOL) { @@ -493,6 +534,19 @@ const Component = () => { selectedPool, selectedValidators: targets } as SubmitJoinNominationPool; + } else if (poolInfo.type === YieldPoolType.DELEGATED_STAKING) { + const selectedStrategy = targets[0] as StrategyInfo; + + return { + slug: slug, + address: from, + amount: 0, + substrateProxyAddress: selectedStrategy.address, + selectedValidators: targets, + substrateProxyDeposit: poolInfo.proxyDeposit, + substrateProxyType: 'Staking', + minBond: selectedStrategy.minBond + } as unknown as SubmitJoinDelegateStaking; } else { return { slug: slug, @@ -657,6 +711,8 @@ const Component = () => { }, [currentConfirmation, mktCampaignModalContext, onSubmit, renderConfirmationButtons]); const isSubnetStaking = useMemo(() => [YieldPoolType.SUBNET_STAKING].includes(poolType), [poolType]); + const isDelegatedStaking = useMemo(() => [YieldPoolType.DELEGATED_STAKING].includes(poolType), [poolType]); + const subnetToken = useMemo(() => { return getSubnetStakingTokenName(poolInfo.chain, poolInfo.metadata.subnetData?.netuid || 0); }, [getSubnetStakingTokenName, poolInfo.chain, poolInfo.metadata.subnetData?.netuid]); @@ -801,14 +857,116 @@ const Component = () => { ); }, [amountValue, assetDecimals, earningRate, inputAsset.symbol, isDisabledSubnetContent, isSlippageAcceptable, maxSlippage.slippage, onOpenSlippageModal, poolChain, poolInfo.metadata.shortName, poolInfo.metadata?.subnetData?.subnetSymbol, subnetToken, t]); + const _onClickCopyButton = useCallback((e: React.SyntheticEvent) => { + e.stopPropagation(); + notify({ + message: t('ui.TRANSACTION.screen.Transaction.Earn.copiedToClipboard') + }); + }, [notify, t]); + + const onOpenConstituentsModal = useCallback(() => { + activeModal(EARNING_CONSTITUENTS_MODAL); + }, [activeModal]); + // For subnet staking + const renderDelegatedStaking = useCallback(() => { + const targeted = poolTargets?.[0]; + + if (!targeted || !('address' in targeted)) { + return null; + } + + const proxyDeposit = poolInfo && 'proxyDeposit' in poolInfo ? poolInfo.proxyDeposit : 0; + const constituents = 'constituents' in targeted && targeted.constituents ? targeted.constituents : []; + + return ( + <> + {constituents.length > 0 && ( + +
+ {`${constituents.length} ${constituents.length > 1 + ? t('ui.TRANSACTION.screen.Transaction.Earn.subnets') + : t('ui.TRANSACTION.screen.Transaction.Earn.subnet')}`} +
+ +
+
+
+ + )} + + + +
+ + {toShort(targeted.address)} + + + + + + + +
+
+ + {!!proxyDeposit && ( + +
+ + +
+ +
+
+
+
+ )} + + ); + }, [_onClickCopyButton, assetDecimals, onOpenConstituentsModal, inputAsset.symbol, poolInfo, poolTargets, t]); const isDisabledButton = useMemo( () => // checkMintLoading || stepLoading || !!connectionError || - !amountValue || + (!amountValue && !isDelegatedStaking) || !isBalanceReady || isFormInvalid || submitLoading || @@ -816,7 +974,7 @@ const Component = () => { !isSlippageAcceptable || (mustChooseTarget && !poolTargetValue), - [stepLoading, connectionError, amountValue, isBalanceReady, isFormInvalid, submitLoading, targetLoading, isSlippageAcceptable, mustChooseTarget, poolTargetValue] + [stepLoading, connectionError, amountValue, isDelegatedStaking, isBalanceReady, isFormInvalid, submitLoading, targetLoading, isSlippageAcceptable, mustChooseTarget, poolTargetValue] ); const renderMetaInfo = useCallback(() => { @@ -882,14 +1040,16 @@ const Component = () => { /> )} - {!isSubnetStaking - ? ( - - ) - : (renderSubnetStaking()) + {(isDelegatedStaking && !!poolTargetValue) + ? (renderDelegatedStaking()) + : !isSubnetStaking + ? ( + + ) + : (renderSubnetStaking()) } {showFee && ( { )} ); - }, [amountValue, assetDecimals, chainAsset, chainValue, currencyData?.isPrefix, currencyData.symbol, estimatedFee, inputAsset.symbol, isSubnetStaking, poolInfo.metadata, poolInfo.statistic, poolInfo?.type, poolTargets, renderSubnetStaking, t]); + }, [amountValue, assetDecimals, chainAsset, chainValue, currencyData?.isPrefix, currencyData.symbol, estimatedFee, inputAsset.symbol, isDelegatedStaking, isSubnetStaking, poolInfo.metadata, poolInfo.statistic, poolInfo?.type, poolTargetValue, poolTargets, renderDelegatedStaking, renderSubnetStaking, t]); const onPreCheck = usePreCheckAction({ chain: chainValue, address: fromValue }); @@ -1250,31 +1410,33 @@ const Component = () => { chain={poolInfo.chain} hidden={[YieldStepType.XCM].includes(submitStepType)} isSubscribe={true} - label={`${t('ui.TRANSACTION.screen.Transaction.Earn.availableBalance')}`} + label={balanceTypeForPool === BalanceType.TOTAL_EQUIVALENT ? `${t('ui.TRANSACTION.screen.Transaction.Earn.totalEquivalent', { symbol: inputAsset.symbol })}` : `${t('ui.TRANSACTION.screen.Transaction.Earn.availableBalance')}`} tokenSlug={inputAsset.slug} />
- - - - - -
- -
- + {!isDelegatedStaking && ( + <> + + + + +
+ +
+ + )} {poolType === YieldPoolType.NOMINATION_POOL && ( { /> )} + + {poolType === YieldPoolType.DELEGATED_STAKING && + ( + + + + )} {renderMetaInfo()} - + {isDelegatedStaking + ? ( + <> + {DELEGATED_STAKING_ALERT_DATA.map((alert, index) => ( + + ))} + + ) + : ( + + )} {!isSlippageAcceptable && (
@@ -1354,6 +1548,7 @@ const Component = () => { openAlert={openAlert} slug={slug} /> + {isSlippageModalVisible && ( (({ theme: { token } }: Props) => { }, '.__label-bottom, .__value': { color: `${token['gray-5']} !important` + }, + + '.__proxy-address': { + display: 'flex', + gap: '4px' + }, + + '.__proxy-address-copy': { + cursor: 'pointer' + }, + + '.__proxy-deposit-value': { + display: 'inline-flex', + alignItems: 'center', + gap: token.sizeXXS + }, + + '.__proxy-deposit-info-button': { + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center' + }, + + '.__constituents-info': { + border: 0, + padding: 0, + background: 'transparent', + color: token.colorTextLight4, + display: 'inline-flex', + alignItems: 'center', + gap: token.sizeXXS + + }, + + '.__constituents-info-button': { + cursor: 'pointer' } }; }); diff --git a/packages/extension-koni-ui/src/Popup/Transaction/variants/Unbond.tsx b/packages/extension-koni-ui/src/Popup/Transaction/variants/Unbond.tsx index 9d2ebadfdd8..db6f20faed6 100644 --- a/packages/extension-koni-ui/src/Popup/Transaction/variants/Unbond.tsx +++ b/packages/extension-koni-ui/src/Popup/Transaction/variants/Unbond.tsx @@ -6,16 +6,17 @@ import { AmountData, ExtrinsicType, NominationInfo } from '@subwallet/extension- import { getValidatorLabel } from '@subwallet/extension-base/koni/api/staking/bonding/utils'; import { _STAKING_CHAIN_GROUP } from '@subwallet/extension-base/services/earning-service/constants'; import { isActionFromValidator } from '@subwallet/extension-base/services/earning-service/utils'; -import { AccountJson, RequestYieldLeave, SlippageType, SpecialYieldPoolMetadata, SubnetYieldPositionInfo, YieldPoolInfo, YieldPoolType, YieldPositionInfo } from '@subwallet/extension-base/types'; +import { AccountJson, DelegatedStrategyInfo, RequestRemoveSubstrateProxyAccount, RequestYieldLeave, SlippageType, SpecialYieldPoolMetadata, SubnetYieldPositionInfo, YieldPoolInfo, YieldPoolType, YieldPositionInfo } from '@subwallet/extension-base/types'; import { AccountSelector, AlertBox, AmountInput, HiddenInput, InstructionItem, MetaInfo, NominationSelector } from '@subwallet/extension-koni-ui/components'; -import { BN_ZERO, UNSTAKE_ALERT_DATA, UNSTAKE_BIFROST_ALERT_DATA, UNSTAKE_BITTENSOR_ALERT_DATA, UNSTAKE_TANSSI_ALERT_DATA } from '@subwallet/extension-koni-ui/constants'; +import { BN_ZERO, UNSTAKE_ALERT_DATA, UNSTAKE_BIFROST_ALERT_DATA, UNSTAKE_BITTENSOR_ALERT_DATA, UNSTAKE_DELEGATED_STRATEGY, UNSTAKE_TANSSI_ALERT_DATA } from '@subwallet/extension-koni-ui/constants'; import { MktCampaignModalContext } from '@subwallet/extension-koni-ui/contexts/MktCampaignModalContext'; import { useHandleSubmitTransaction, useInitValidateTransaction, usePreCheckAction, useRestoreTransaction, useSelector, useTransactionContext, useWatchTransaction, useYieldPositionDetail } from '@subwallet/extension-koni-ui/hooks'; import useGetConfirmationByScreen from '@subwallet/extension-koni-ui/hooks/campaign/useGetConfirmationByScreen'; import { useTaoStakingFee } from '@subwallet/extension-koni-ui/hooks/earning/useTaoStakingFee'; import { yieldSubmitLeavePool } from '@subwallet/extension-koni-ui/messaging'; +import { handleRemoveSubstrateProxyAccount } from '@subwallet/extension-koni-ui/messaging/transaction/substrateProxy'; import { FormCallbacks, FormFieldData, ThemeProps, UnStakeParams } from '@subwallet/extension-koni-ui/types'; -import { convertFieldToObject, getBannerButtonIcon, getEarningTimeText, noop, simpleCheckForm } from '@subwallet/extension-koni-ui/utils'; +import { convertFieldToObject, getBannerButtonIcon, getEarningTimeText, noop, simpleCheckForm, toShort } from '@subwallet/extension-koni-ui/utils'; import { BackgroundIcon, Button, Checkbox, Form, Icon } from '@subwallet/react-ui'; import { getAlphaColor } from '@subwallet/react-ui/lib/theme/themes/default/colorAlgorithm'; import BigN, { BigNumber } from 'bignumber.js'; @@ -119,6 +120,8 @@ const Component: React.FC = () => { // For subnet staking const isSubnetStaking = useMemo(() => [YieldPoolType.SUBNET_STAKING].includes(poolType), [poolType]); + const isDelegateStaking = useMemo(() => [YieldPoolType.DELEGATED_STAKING].includes(poolType), [poolType]); + const [maxSlippage, setMaxSlippage] = useState({ slippage: new BigN(0.005), isCustomType: true }); const isDisabledSubnetContent = useMemo( @@ -280,6 +283,54 @@ const Component: React.FC = () => { [chainInfoMap, chainValue] ); + const nominators = useMemo(() => { + if (fromValue && positionInfo?.nominations && positionInfo.nominations.length) { + return positionInfo.nominations.filter((n) => new BigN(n.activeStake || '0').gt(BN_ZERO)); + } + + return []; + }, [fromValue, positionInfo?.nominations]); + + const exType = useMemo(() => { + if (poolType === YieldPoolType.NOMINATION_POOL || poolType === YieldPoolType.NATIVE_STAKING || poolType === YieldPoolType.SUBNET_STAKING) { + return ExtrinsicType.STAKING_UNBOND; + } + + if (poolType === YieldPoolType.LIQUID_STAKING) { + if (chainValue === 'moonbeam') { + return ExtrinsicType.UNSTAKE_STDOT; + } + + if (chainValue === 'bifrost_dot') { + if (slug === 'MANTA___liquid_staking___bifrost_dot') { + return ExtrinsicType.UNSTAKE_VMANTA; + } + + return ExtrinsicType.UNSTAKE_VDOT; + } + + if (chainValue === 'parallel') { + return ExtrinsicType.UNSTAKE_SDOT; + } + + if (chainValue === 'acala') { + return ExtrinsicType.UNSTAKE_LDOT; + } + } + + if (poolType === YieldPoolType.LENDING) { + if (chainValue === 'interlay') { + return ExtrinsicType.UNSTAKE_QDOT; + } + } + + if (poolType === YieldPoolType.DELEGATED_STAKING) { + return ExtrinsicType.REMOVE_SUBSTRATE_PROXY_ACCOUNT; + } + + return ExtrinsicType.STAKING_UNBOND; + }, [poolType, chainValue, slug]); + const { onError, onSuccess } = useHandleSubmitTransaction(undefined, handleDataForInsufficientAlert); const onValuesChange: FormCallbacks['onValuesChange'] = useCallback((changes: Partial, values: UnStakeParams) => { @@ -341,19 +392,43 @@ const Component: React.FC = () => { request.selectedTarget = currentValidator || ''; } - const unbondingPromise = yieldSubmitLeavePool(request); + // send unstake transaction + const unbondingPromise = () => { + if (isDelegateStaking) { + const nominator = nominators[0] as DelegatedStrategyInfo; + + const selectedSubstrateProxyAccount = { + substrateProxyAddress: nominator.validatorAddress, + substrateProxyType: nominator.substrateProxyType, + delay: nominator.delay + }; + + const request: RequestRemoveSubstrateProxyAccount = { + address: from, + chain: poolInfo.chain, + poolInfo, + selectedSubstrateProxyAccounts: [selectedSubstrateProxyAccount] + }; + + return handleRemoveSubstrateProxyAccount({ ...request }); + } + + return yieldSubmitLeavePool({ + ...request + }); + }; setLoading(true); setTimeout(() => { - unbondingPromise + unbondingPromise() .then(onSuccess) .catch(onError) .finally(() => { setLoading(false); }); }, 300); - }, [currentValidator, maxSlippage.slippage, mustChooseValidator, onError, onSuccess, poolInfo, positionInfo, stakingFee]); + }, [currentValidator, isDelegateStaking, maxSlippage.slippage, mustChooseValidator, nominators, onError, onSuccess, poolInfo, positionInfo, stakingFee]); const onClickSubmit = useCallback((values: UnStakeParams) => { if (currentConfirmation) { @@ -395,14 +470,6 @@ const Component: React.FC = () => { return accounts.filter(filterAccount(allPositions, chainInfoMap, poolInfo)); }, [accounts, allPositions, chainInfoMap, poolInfo]); - const nominators = useMemo(() => { - if (fromValue && positionInfo?.nominations && positionInfo.nominations.length) { - return positionInfo.nominations.filter((n) => new BigN(n.activeStake || '0').gt(BN_ZERO)); - } - - return []; - }, [fromValue, positionInfo?.nominations]); - useEffect(() => { if (poolInfo.metadata.availableMethod.defaultUnstake && poolInfo.metadata.availableMethod.fastUnstake) { // @@ -441,42 +508,6 @@ const Component: React.FC = () => { }; }, [poolType, setCustomScreenTitle, t]); - const exType = useMemo(() => { - if (poolType === YieldPoolType.NOMINATION_POOL || poolType === YieldPoolType.NATIVE_STAKING || poolType === YieldPoolType.SUBNET_STAKING) { - return ExtrinsicType.STAKING_UNBOND; - } - - if (poolType === YieldPoolType.LIQUID_STAKING) { - if (chainValue === 'moonbeam') { - return ExtrinsicType.UNSTAKE_STDOT; - } - - if (chainValue === 'bifrost_dot') { - if (slug === 'MANTA___liquid_staking___bifrost_dot') { - return ExtrinsicType.UNSTAKE_VMANTA; - } - - return ExtrinsicType.UNSTAKE_VDOT; - } - - if (chainValue === 'parallel') { - return ExtrinsicType.UNSTAKE_SDOT; - } - - if (chainValue === 'acala') { - return ExtrinsicType.UNSTAKE_LDOT; - } - } - - if (poolType === YieldPoolType.LENDING) { - if (chainValue === 'interlay') { - return ExtrinsicType.UNSTAKE_QDOT; - } - } - - return ExtrinsicType.STAKING_UNBOND; - }, [poolType, chainValue, slug]); - const handleValidatorLabel = useMemo(() => { const label = getValidatorLabel(chainValue); @@ -484,6 +515,10 @@ const Component: React.FC = () => { }, [chainValue]); const unstakeAlertData = useMemo(() => { + if (poolType === YieldPoolType.DELEGATED_STAKING) { + return UNSTAKE_DELEGATED_STRATEGY; + } + switch (true) { case poolChain === 'bifrost_dot': return UNSTAKE_BIFROST_ALERT_DATA; @@ -496,7 +531,7 @@ const Component: React.FC = () => { default: return UNSTAKE_ALERT_DATA; } - }, [poolChain]); + }, [poolChain, poolType]); return ( <> @@ -544,37 +579,51 @@ const Component: React.FC = () => { poolInfo={poolInfo} /> - - { - mustChooseValidator && ( + {!(poolInfo.type === YieldPoolType.DELEGATED_STAKING) + ? ( <> - {renderBounded()} - - ) - } - - + {mustChooseValidator && ( + <> + {renderBounded()} + + )} + + + + { + !isDisabledSubnetContent && earningRate > 0 && ( + <> + {renderRate()} + + ) + } - { - !isDisabledSubnetContent && earningRate > 0 && ( - <> - {renderRate()} + {!mustChooseValidator && renderBounded()} ) - } - - {!mustChooseValidator && renderBounded()} - + : ( + + + + {nominators[0]?.validatorIdentity || toShort(nominators[0]?.validatorAddress)} + + + )}