diff --git a/.papi/descriptors/package.json b/.papi/descriptors/package.json index 04c04af..a5fdd7c 100644 --- a/.papi/descriptors/package.json +++ b/.papi/descriptors/package.json @@ -1,5 +1,5 @@ { - "version": "0.1.0-autogenerated.6048354284411761388", + "version": "0.1.0-autogenerated.8494276640227355333", "name": "@polkadot-api/descriptors", "files": [ "dist" diff --git a/.papi/metadata/dotAh.scale b/.papi/metadata/dotAh.scale new file mode 100644 index 0000000..d6e9e14 Binary files /dev/null and b/.papi/metadata/dotAh.scale differ diff --git a/.papi/polkadot-api.json b/.papi/polkadot-api.json index a570476..ee0ec1d 100644 --- a/.papi/polkadot-api.json +++ b/.papi/polkadot-api.json @@ -7,6 +7,12 @@ "metadata": ".papi/metadata/polkadot_people.scale", "genesis": "0x67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008", "codeHash": "0x3f0652ba1159157eff567b8e0e5987c8fc67a614f0909ce0f65f0d8a2b532864" + }, + "dotAh": { + "chain": "polkadot_asset_hub", + "metadata": ".papi/metadata/dotAh.scale", + "genesis": "0x68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f", + "codeHash": "0xb160d880adc0954728ebd255ba4d8b28266fae3b564d82670a486c5c63028778" } } } diff --git a/.papi/whitelist.ts b/.papi/whitelist.ts index 7c85182..6be1580 100644 --- a/.papi/whitelist.ts +++ b/.papi/whitelist.ts @@ -1,14 +1,18 @@ -import { Polkadot_peopleWhitelistEntry } from "@polkadot-api/descriptors" +import { WhitelistEntriesByChain } from "@polkadot-api/descriptors" -export const whitelist: Polkadot_peopleWhitelistEntry[] = [ - "query.Identity.IdentityOf", - "query.Identity.SuperOf", - "query.System.Account", - "query.System.Number", - "const.System.BlockLength", - "const.System.BlockWeights", - "tx.Balances.transfer_keep_alive", - "query.Session.Validators", - "query.Session.CurrentIndex", - "api.TransactionPaymentApi.query_info", -] +export const whitelist: WhitelistEntriesByChain = { + polkadot_people: [ + "query.Identity.IdentityOf", + "query.Identity.SuperOf", + "query.System.Account", + "query.System.Number", + "const.System.BlockLength", + "const.System.BlockWeights", + "const.Balances.ExistentialDeposit", + "tx.Balances.transfer_keep_alive", + "query.Session.Validators", + "query.Session.CurrentIndex", + "api.TransactionPaymentApi.query_info", + ], + dotAh: ["query.*", "tx.*", "const.*"], +} diff --git a/src/pages/Accounts/AccountList.tsx b/src/pages/Accounts/AccountList.tsx index 21f7a09..3264f7a 100644 --- a/src/pages/Accounts/AccountList.tsx +++ b/src/pages/Accounts/AccountList.tsx @@ -1,6 +1,7 @@ import { AccountIdDisplay } from "@/components/AccountIdDisplay" import { TokenAmount } from "@/components/TokenAmount" import { Button } from "@/components/ui/button" +import { Link } from "@/hashParams" import { chainProperties$ } from "@/state/chain-props.state" import { client$ } from "@/state/chains/chain.state" import { MultiAddress, polkadot_people } from "@polkadot-api/descriptors" @@ -10,7 +11,7 @@ import { state, useStateObservable, } from "@react-rxjs/core" -import { Send } from "lucide-react" +import { LockKeyhole, Send } from "lucide-react" import { CompatibilityLevel, SS58String } from "polkadot-api" import { toHex } from "polkadot-api/utils" import { @@ -23,8 +24,13 @@ import { walletConnectProviderId, } from "polkahub" import { FC } from "react" -import { Link } from "react-router-dom" -import { catchError, combineLatest, map, switchMap } from "rxjs" +import { + catchError, + combineLatest, + distinctUntilChanged, + map, + switchMap, +} from "rxjs" const knownGroupsNames: Record = { "pjs-wallet": "Browser Extensions", @@ -119,26 +125,34 @@ const SourceTag: FC<{ ) } -const typedApi$ = client$.pipeState(map((v) => v.getTypedApi(polkadot_people))) +export const typedApi$ = client$.pipeState( + map((v) => v.getTypedApi(polkadot_people)), +) -const balance$ = state( +export const balance$ = state( (accountId: SS58String) => typedApi$.pipe( switchMap((typedApi) => - typedApi.query.System.Account.watchValue(accountId), + combineLatest({ + account: typedApi.query.System.Account.watchValue(accountId).pipe( + map((v) => v.value), + distinctUntilChanged(), + ), + ed: typedApi.constants.Balances.ExistentialDeposit(), + }), ), - map((account) => { - const { reserved, free, frozen } = account.value.data + map(({ account, ed }) => { + const { reserved, free, frozen } = account.data const total = reserved + free - // TODO ED - const untouchable = total == 0n ? 0n : maxBigInt(frozen - reserved, 0n) + const untouchable = total == 0n ? 0n : maxBigInt(frozen - reserved, ed) return { total, spendable: free - untouchable, frozen, reserved, + ed, } }), liftSuspense(), @@ -213,13 +227,22 @@ const Balances: FC<{ - {getTransferCallData ? ( - - ) : null} +
+ {getTransferCallData ? ( + + ) : null} + {balance.total - balance.spendable > 0n ? ( + + ) : null} +
) } diff --git a/src/pages/Accounts/Accounts.tsx b/src/pages/Accounts/Accounts.tsx index d203936..ef907a9 100644 --- a/src/pages/Accounts/Accounts.tsx +++ b/src/pages/Accounts/Accounts.tsx @@ -1,12 +1,30 @@ +import { Navigate, Route, Routes } from "react-router-dom" import { CenteredScrollContainer } from "../AppShell" import { AccountList } from "./AccountList" +import { Locks } from "./Locks" import { Providers } from "./Providers" export const Accounts = () => { return ( - - - - + + + + + + } + /> + + + + } + /> + } /> + ) } diff --git a/src/pages/Accounts/BalanceChart.tsx b/src/pages/Accounts/BalanceChart.tsx new file mode 100644 index 0000000..ce5aa48 --- /dev/null +++ b/src/pages/Accounts/BalanceChart.tsx @@ -0,0 +1,150 @@ +import { TokenAmount } from "@/components/TokenAmount" +import { FC, ReactNode } from "react" +import { AccountLocks } from "./locks.state" +import { cn } from "@/utils" + +export const BalanceChart: FC<{ locks: AccountLocks }> = ({ locks }) => { + const { total, spendable, reserved, frozen, ed } = locks.balance + + return ( +
+
+

+ Balance +

+
+ {total} +
+
+ + {total == 0n ? null : ( +
+
+ + +
+
+ +
+ + +
+
+ {frozen > 0n ? ( +
+ +
+
+ ) : null} + + +
+
+ {frozen > 0n ? ( + + ) : ( +
+ )} + +
+
+ )} +
+ ) +} + +const ChartLegend: FC<{ + label: string + value: bigint + tone: BalanceTone +}> = ({ label, value, tone }) => + value > 0n ? ( +
+
+
{label}
+ {value} +
+ ) : null + +const ChartLockedArea: FC<{ + children: ReactNode + locked: bigint + total: bigint +}> = ({ children, locked, total }) => { + if (locked <= 0n || total <= 0n) return null + + return ( +
+ {children} +
+ ) +} + +const ChartSpendableSegment: FC<{ value: bigint; total: bigint }> = ({ + value, + total, +}) => { + if (value <= 0n || total <= 0n) return null + + return ( +
+ ) +} + +const ChartSegment: FC<{ + label: string + value: bigint + total: bigint + tone: BalanceTone +}> = ({ label, value, total, tone }) => { + if (value <= 0n || total <= 0n) return null + + return ( +
+ ) +} + +type BalanceTone = "reserved" | "ed" | "spendable" | "frozen" + +const chartTone: Record = { + reserved: "border-yellow-500/10 bg-yellow-200/80 dark:bg-yellow-400/60", + ed: "border-rose-500/10 bg-rose-200/80 dark:bg-rose-500/40", + spendable: "border-green-500/10 bg-green-200/80 dark:bg-green-500/40", + frozen: "border-sky-500/10 bg-sky-200/80 dark:bg-sky-500/40", +} + +const getPercent = (value: bigint, total: bigint) => + total <= 0n ? 0 : Number((value * 10_000n) / total) / 100 diff --git a/src/pages/Accounts/Locks.tsx b/src/pages/Accounts/Locks.tsx new file mode 100644 index 0000000..5415385 --- /dev/null +++ b/src/pages/Accounts/Locks.tsx @@ -0,0 +1,159 @@ +import { AccountIdDisplay } from "@/components/AccountIdDisplay" +import { TokenAmount } from "@/components/TokenAmount" +import { Link } from "@/hashParams" +import { useStateObservable } from "@react-rxjs/core" +import { ArrowLeft, LockKeyhole, TriangleAlert } from "lucide-react" +import { SS58String } from "polkadot-api" +import { FC } from "react" +import { useParams } from "react-router-dom" +import { BalanceChart } from "./BalanceChart" +import { TransactionButton } from "./TransactionButton" +import { accountLocks$ } from "./locks.state" +import { IdentifiedLock } from "./lockSources/common" + +export const Locks = () => { + const { accountId } = useParams() + + return ( +
+ {accountId ? ( + + ) : ( + + )} +
+ ) +} + +const LocksContent: FC<{ accountId: SS58String }> = ({ accountId }) => { + const locks = useStateObservable(accountLocks$(accountId)) + + return ( +
+ + + {!locks ? ( + + ) : ( + <> + + + + + )} +
+ ) +} + +const LocksHeader = () => { + const { accountId } = useParams() + + return ( +
+
+ + + Accounts + +

Account locks

+
+ +
+ ) +} + +const LockSection: FC<{ title: string; locks: IdentifiedLock[] }> = ({ + title, + locks, +}) => + locks.length ? ( +
+

+ {title} +

+ {locks.map((lock, i) => ( + + ))} +
+ ) : null + +const LockCard: FC<{ lock: IdentifiedLock }> = ({ lock }) => { + const unlockable = lock.unlockable.reduce( + (acc, action) => acc + (action.tx ? action.amount : 0n), + 0n, + ) + + return ( +
+
+
+ +

{lock.id}

+
+
+ {unlockable > 0n ? ( + + ) : null} + +
+
+ {lock.note ? ( +

{lock.note}

+ ) : null} + + {lock.unlockable.length ? ( +
+ {lock.unlockable.map((action, i) => ( + + ))} +
+ ) : null} +
+ ) +} + +const AmountRow: FC<{ label: string; value: bigint }> = ({ label, value }) => ( +
+
{label}
+ {value} +
+) + +const UnlockActionRow: FC<{ + action: IdentifiedLock["unlockable"][number] +}> = ({ action }) => ( +
+
+
+
{action.action}
+ + {action.warn ? ( +
+ + {action.warn} +
+ ) : null} +
+ {action.tx ? : null} +
+
+) + +const EmptyState: FC<{ title: string; description: string }> = ({ + title, + description, +}) => ( +
+
{title}
+
{description}
+
+) diff --git a/src/pages/Accounts/TransactionButton.tsx b/src/pages/Accounts/TransactionButton.tsx new file mode 100644 index 0000000..d99ddc7 --- /dev/null +++ b/src/pages/Accounts/TransactionButton.tsx @@ -0,0 +1,83 @@ +import { Button } from "@/components/ui/button" +import { Link } from "@/hashParams" +import { ExternalLink, LockKeyhole } from "lucide-react" +import { Transaction } from "polkadot-api" +import { toHex } from "polkadot-api/utils" +import { useAvailableAccounts, useSelectedAccount } from "polkahub" +import { FC, useEffect, useState } from "react" +import { useParams } from "react-router-dom" +import { trackTx } from "../Extrinsics/ExtrinsicsWorkspaceEntry" + +export const TransactionButton: FC<{ + tx: Transaction +}> = ({ tx }) => { + const { accountId } = useParams() + const [selectedAccount] = useSelectedAccount() + const allAccounts = useAvailableAccounts() + const [callData, setCallData] = useState({ + tx, + value: "", + }) + const loadedCallData = callData.tx === tx ? callData.value : "" + const account = + selectedAccount?.address === accountId + ? selectedAccount + : Object.values(allAccounts) + .flat() + .find((v) => v.signer && v.address === accountId) + + useEffect(() => { + let cancelled = false + tx.getEncodedData().then( + (value) => { + if (cancelled) return + setCallData({ tx, value: toHex(value) }) + }, + (ex) => { + console.error(ex) + }, + ) + return () => { + cancelled = true + } + }, [tx]) + + const submit = async () => { + if (!account?.signer) return + const signed = await tx.sign(account.signer) + trackTx(signed, tx.decodedCall, account) + } + + return ( +
+ + +
+ ) +} diff --git a/src/pages/Accounts/lockSources/common.ts b/src/pages/Accounts/lockSources/common.ts new file mode 100644 index 0000000..4d67e3b --- /dev/null +++ b/src/pages/Accounts/lockSources/common.ts @@ -0,0 +1,62 @@ +import { decodeRsprConsensus } from "@/pages/Explorer/Detail/digests/rspr" +import { client$ } from "@/state/chains/chain.state" +import { DotAh } from "@polkadot-api/descriptors" +import { liftSuspense, sinkSuspense } from "@react-rxjs/core" +import { PolkadotClient, Transaction, TypedApi } from "polkadot-api" +import { catchError, map, ObservableInput, pipe } from "rxjs" + +export const getRelayBlock = async (client: PolkadotClient) => { + const header = await client.getBlockHeader( + (await client.getBestBlocks())[0].hash, + ) + const RSPRDigest = header.digests.find( + (digest) => digest.type === "consensus" && digest.value.engine === "RPSR", + ) + if (RSPRDigest) { + const decoded = decodeRsprConsensus((RSPRDigest.value as any).payload) + if (decoded) return decoded.blockNumber + } + return null +} + +export const batch = ( + api: TypedApi, + txs: Transaction[], +): Transaction | undefined => + txs.length > 1 + ? api.tx.Utility.batch({ + calls: txs.map((tx) => tx.decodedCall), + }) + : txs[0] + +export interface UnlockAction { + amount: bigint + warn?: string + action: string + tx: Transaction | null +} +export interface IdentifiedLock { + id: string + note?: string + amount: bigint + unlockable: UnlockAction[] +} + +export const unsafeClient$ = client$.pipeState( + map((client) => ({ + client, + unsafeApi: client.getUnsafeApi(), + })), +) + +export const fallbackWhenError = ( + fallback: ObservableInput, +) => + pipe( + liftSuspense(), + catchError((ex) => { + console.log(ex) + return fallback + }), + sinkSuspense(), + ) diff --git a/src/pages/Accounts/lockSources/freezes.ts b/src/pages/Accounts/lockSources/freezes.ts new file mode 100644 index 0000000..707912e --- /dev/null +++ b/src/pages/Accounts/lockSources/freezes.ts @@ -0,0 +1,187 @@ +// Frozen: +// Locks: Vesting, OpenGov `::set_lock(` `Balances.locks` + +import { MultiAddress } from "@polkadot-api/descriptors" +import { Binary } from "polkadot-api" +import { + combineLatest, + map, + Observable, + ObservableInput, + startWith, + switchMap, +} from "rxjs" +import { + batch, + fallbackWhenError, + getRelayBlock, + IdentifiedLock, + UnlockAction, + unsafeClient$, +} from "./common" + +const LOCK_IDS = { + vesting: Binary.toHex(Binary.fromText("vesting ")), + convictionVoting: Binary.toHex(Binary.fromText("pyconvot")), +} + +const getVestingActions$ = ( + accountId: string, + amount: bigint, +): Observable => + unsafeClient$.pipe( + switchMap(async ({ client, unsafeApi }) => ({ + unsafeApi, + relayBlock: await getRelayBlock(client), + })), + switchMap(async ({ unsafeApi, relayBlock }) => { + if (relayBlock == null) { + throw new Error("Unable to get relay block number") + } + const vested = await unsafeApi.query.Vesting.Vesting.getValue(accountId) + const vestLocked = + vested + ?.map((vest) => { + const blockCount = relayBlock - vest.starting_block + const unlockedAmount = BigInt(blockCount) * vest.per_block + const lockedAmount = vest.locked - unlockedAmount + return lockedAmount > 0 ? lockedAmount : 0n + }) + .reduce((a, b) => a + b, 0n) ?? 0n + const unlockable = amount - vestLocked + + return [ + { + amount: unlockable > 0n ? unlockable : 0n, + action: "Vest", + tx: unsafeApi.tx.Vesting.vest(), + }, + ] + }), + fallbackWhenError([]), + startWith([]), + ) + +const getConvictionVoteActions$ = ( + accountId: string, +): Observable => + unsafeClient$.pipe( + switchMap(async ({ client, unsafeApi }) => { + // TODO add actions for removing vote and unlocking + // unsafeApi.constants.ConvictionVoting.VoteLockingPeriod(), + const [votingFor, locks, relayBlock] = await Promise.all([ + unsafeApi.query.ConvictionVoting.VotingFor.getEntries(accountId), + unsafeApi.query.ConvictionVoting.ClassLocksFor.getValue(accountId), + getRelayBlock(client), + ]) + const trackVotes = Object.fromEntries( + votingFor.map(({ value, keyArgs }) => [keyArgs[1], value]), + ) + + const trackStatus = locks + .map(([track, locked]) => { + if (!locked) return null + + const votes = trackVotes[track] + if (!votes) { + return { + track, + locked, + unlockable: locked, + } + } + const [block, balance] = votes.value.prior + + const usedBalance = + votes.type === "Casting" + ? votes.value.votes + .map(([, vote]) => + vote.type === "Standard" + ? vote.value.balance + : vote.value.aye + + vote.value.nay + + (((vote.value as any).abstain as bigint) ?? 0n), + ) + .reduce((a, b) => a + b, 0n) + : votes.value.balance + const unlocking = locked - usedBalance + const unlockable = + relayBlock != null && relayBlock > block + ? unlocking + : unlocking - balance + + return { + track, + locked, + unlockable: unlockable > 0n ? unlockable : 0n, + } + }) + .filter((v) => v !== null) + + const unlockAmount = trackStatus.reduce( + (acc, track) => acc + track.unlockable, + 0n, + ) + const unlockAction = batch( + unsafeApi, + trackStatus + .filter((v) => v.unlockable > 0) + .map(({ track }) => + unsafeApi.tx.ConvictionVoting.unlock({ + class: track, + target: MultiAddress.Id(accountId), + }), + ), + ) + + return unlockAction + ? [ + { + action: "Unlock", + amount: unlockAmount, + tx: unlockAction, + }, + ] + : [] + }), + fallbackWhenError([]), + startWith([]), + ) + +export const getFreezes$ = (accountId: string): Observable => + unsafeClient$.pipe( + switchMap(({ unsafeApi }) => + unsafeApi.query.Balances.Locks.getValue(accountId), + ), + switchMap((locks) => + combineLatest( + locks.map((lock): ObservableInput => { + if (lock.id === LOCK_IDS.vesting) { + return getVestingActions$(accountId, lock.amount).pipe( + map((unlockable) => ({ + id: "Vesting", + amount: lock.amount, + unlockable, + })), + ) + } + if (lock.id === LOCK_IDS.convictionVoting) { + return getConvictionVoteActions$(accountId).pipe( + map((unlockable) => ({ + id: "OpenGov", + amount: lock.amount, + unlockable, + })), + ) + } + return [ + { + id: Binary.toText(Binary.fromHex(lock.id)).trim(), + amount: lock.amount, + unlockable: [], + }, + ] + }), + ), + ), + ) diff --git a/src/pages/Accounts/lockSources/hodls.ts b/src/pages/Accounts/lockSources/hodls.ts new file mode 100644 index 0000000..e7ed79c --- /dev/null +++ b/src/pages/Accounts/lockSources/hodls.ts @@ -0,0 +1,135 @@ +// HODLL!!!!!! +// Reserved: +// Holds: Contracts, DelegatedStaking, Preimage `::hold(` `Balances.holds` + +import { shortStr } from "@/utils" +import { DotAh } from "@polkadot-api/descriptors" +import { SS58String, TypedApi } from "polkadot-api" +import { + combineLatest, + map, + Observable, + ObservableInput, + of, + startWith, + switchMap, +} from "rxjs" +import { + batch, + fallbackWhenError, + IdentifiedLock, + UnlockAction, + unsafeClient$, +} from "./common" +import { createTimedCache } from "./timedCache" + +const preimageCache = createTimedCache(async (unsafeApi) => { + const requestStatusFor = + await unsafeApi.query.Preimage.RequestStatusFor.getEntries() + return requestStatusFor.map(({ keyArgs, value }) => + value.type === "Requested" + ? { + hash: keyArgs[0], + depositor: value.value.maybe_ticket?.[0], + deposit: value.value.maybe_ticket?.[1], + len: undefined, + } + : { + hash: keyArgs[0], + depositor: value.value.ticket[0], + deposit: value.value.ticket[1], + len: value.value.len, + }, + ) +}) +export const getPreimageUnlockables = ( + unsafeApi: TypedApi, + ownPreimages: Array<{ + hash: string + depositor?: SS58String + deposit?: bigint + len?: number + }>, +) => { + const totalUnlockable = ownPreimages + .map(({ deposit }) => deposit ?? 0n) + .reduce((acc, v) => acc + v, 0n) + + const individualActions = ownPreimages.map(({ hash, deposit, len }) => ({ + action: `Remove ${shortStr(hash, 6)} len=${len ?? "N/A"}`, + warn: "If this preimage is being used in a referendum or something else, removing the preimage will cause it to fail", + amount: deposit ?? 0n, + tx: unsafeApi.tx.Preimage.unnote_preimage({ + hash, + }), + })) + const unoteAllTx = batch( + unsafeApi, + ownPreimages.map(({ hash }) => + unsafeApi.tx.Preimage.unnote_preimage({ + hash, + }), + ), + ) + const unnoteAllAction = unoteAllTx + ? { + action: "Remove all", + warn: "If any preimage is being used in a referendum or something else, removing the preimage will cause it to fail", + amount: totalUnlockable, + tx: unoteAllTx, + } + : null + return [ + ...individualActions, + ...(unnoteAllAction && individualActions.length > 1 + ? [unnoteAllAction] + : []), + ] +} +const getPreimageActions$ = (accountId: string): Observable => + unsafeClient$.pipe( + switchMap(async ({ client, unsafeApi }) => { + const preimages = await preimageCache(client) + + const ownPreimages = preimages.filter( + ({ depositor }) => depositor === accountId, + ) + return getPreimageUnlockables(unsafeApi, ownPreimages) + }), + fallbackWhenError([]), + startWith([]), + ) + +export const getHodls$ = (accountId: string): Observable => + unsafeClient$.pipe( + switchMap(({ unsafeApi }) => + unsafeApi.query.Balances.Holds.getValue(accountId), + ), + switchMap((hodls) => + hodls.length + ? combineLatest( + hodls.map((hodl): ObservableInput => { + switch (hodl.id.type) { + case "Preimage": { + return getPreimageActions$(accountId).pipe( + map((unlockable) => ({ + id: "Preimage", + amount: hodl.amount, + unlockable, + })), + ) + } + } + + return [ + { + id: hodl.id.type, + amount: hodl.amount, + unlockable: [], + }, + ] + }), + ) + : of([]), + ), + ) diff --git a/src/pages/Accounts/lockSources/reserves.ts b/src/pages/Accounts/lockSources/reserves.ts new file mode 100644 index 0000000..a54b9aa --- /dev/null +++ b/src/pages/Accounts/lockSources/reserves.ts @@ -0,0 +1,444 @@ +/* +Reserved: +Reserves: Assets, Bounties, ChildBounties, Identity, Multisig, Nfts, Proxy, Referenda `::reserve(` +*/ + +import { shortStr } from "@/utils" +import { DotAh, MultiAddress } from "@polkadot-api/descriptors" +import { getExtrinsicDecoder } from "@polkadot-api/tx-utils" +import { HexString, PolkadotClient, TypedApi } from "polkadot-api" +import { + combineLatest, + from, + map, + Observable, + startWith, + switchMap, +} from "rxjs" +import { + batch, + fallbackWhenError, + IdentifiedLock, + UnlockAction, + unsafeClient$, +} from "./common" +import { createTimedCache } from "./timedCache" +import { getPreimageUnlockables } from "./hodls" + +// Assets +const assetCache = createTimedCache((unsafeApi) => + unsafeApi.query.Assets.Asset.getEntries(), +) +const assetSource$ = (accountId: string): Observable => + unsafeClient$.pipe( + switchMap(async ({ client, unsafeApi }) => { + const assets = await assetCache(client) + const ownAssets = assets.filter( + (asset) => asset.value.owner === accountId, + ) + if (!ownAssets.length) return null + + const actions = ownAssets.map( + (asset): UnlockAction => ({ + action: `Clear metadata ${asset.keyArgs[0]}`, + amount: asset.value.deposit, + tx: + asset.value.status.type === "Live" + ? unsafeApi.tx.Assets.clear_metadata({ id: asset.keyArgs[0] }) + : null, + warn: + asset.value.status.type === "Live" + ? undefined + : "Can't clear: Asset not live", + }), + ) + const unlockable = actions.filter((v) => !!v.tx) + if (unlockable.length > 1) { + actions.push({ + action: "Clear all", + amount: unlockable.reduce((acc, v) => acc + v.amount, 0n), + tx: batch( + unsafeApi, + unlockable.map((v) => v.tx!), + )!, + }) + } + + return { + id: "Assets", + amount: ownAssets.reduce((acc, v) => acc + v.value.deposit, 0n), + unlockable: actions, + } + }), + ) + +// Bounties +const bountyCache = createTimedCache((unsafeApi) => + unsafeApi.query.Bounties.Bounties.getEntries(), +) +const bountySource$ = (accountId: string): Observable => + unsafeClient$.pipe( + switchMap(async ({ client }) => { + const bounties = await bountyCache(client) + const ownBounties = bounties + .map(({ value, keyArgs }): UnlockAction | null => { + let amount = 0n + if (value.proposer === accountId) { + amount += value.bond + } + if ( + value.status.value && + "curator" in value.status.value && + value.status.value.curator === accountId + ) { + amount += value.curator_deposit + } + // TODO implement action(s) + return amount == 0n + ? null + : { + action: `Bounty ${keyArgs}`, + amount, + tx: null, + } + }) + .filter((v) => v !== null) + if (!ownBounties.length) return null + + return { + id: "Bounties", + amount: ownBounties.reduce((acc, v) => acc + v.amount, 0n), + unlockable: ownBounties, + } + }), + ) + +// ChildBounties +const childBountyCache = createTimedCache((unsafeApi) => + unsafeApi.query.ChildBounties.ChildBounties.getEntries(), +) +const childBountySource$ = ( + accountId: string, +): Observable => + unsafeClient$.pipe( + switchMap(async ({ client }) => { + const bounties = await childBountyCache(client) + const ownBounties = bounties + .map(({ value, keyArgs }): UnlockAction | null => { + if ( + value.status.value && + "curator" in value.status.value && + value.status.value.curator === accountId + ) { + return { + action: `ChildBounty ${keyArgs.join("-")}`, + amount: value.curator_deposit, + tx: null, + } + } + return null + }) + .filter((v) => v !== null) + if (!ownBounties.length) return null + + return { + id: "Child Bounties", + amount: ownBounties.reduce((acc, v) => acc + v.amount, 0n), + unlockable: ownBounties, + } + }), + ) + +// Multisigs +const multisigCache = createTimedCache((unsafeApi) => + unsafeApi.query.Multisig.Multisigs.getEntries(), +) + +const getMultisigUnlockable = async ( + client: PolkadotClient, + unsafeApi: TypedApi, + deposit: bigint, + call_hash: HexString, + timepoint: { + height: number + index: number + }, +) => { + try { + const [hash] = await client._request("archive_v1_hashByHeight", [ + timepoint.height, + ]) + const [body, metadata] = await Promise.all([ + client.getBlockBody(hash), + client.getMetadata(hash), + ]) + const decoder = getExtrinsicDecoder(metadata) + const tx = decoder(body[timepoint.index]) + if (tx.call.type !== "Multisig") + throw new Error("Target call not a multisig") + return { + action: `Cancel call ${shortStr(call_hash, 6)}`, + amount: deposit, + tx: unsafeApi.tx.Multisig.cancel_as_multi({ + call_hash, + other_signatories: tx.call.value.value.other_signatories, + threshold: tx.call.value.value.threshold, + timepoint, + }), + } + } catch (ex) { + console.error(ex) + return { + action: `Cancel call ${shortStr(call_hash, 6)}`, + amount: deposit, + warn: "Can't access other signatories", + tx: null, + } + } +} + +const multisigSource$ = ( + accountId: string, +): Observable => + unsafeClient$.pipe( + switchMap(async ({ client, unsafeApi }) => { + const multisigs = await multisigCache(client) + const ownMultisigs = multisigs.filter( + ({ value }) => value.depositor === accountId, + ) + + if (!ownMultisigs.length) return null + + const totalAmount = ownMultisigs + .map((v) => v.value.deposit) + .reduce((acc, v) => acc + v) + const unlockable = await Promise.all( + ownMultisigs.map(({ keyArgs, value }) => + getMultisigUnlockable( + client, + unsafeApi, + value.deposit, + keyArgs[1], + value.when, + ), + ), + ) + return { + id: "Multisig", + amount: totalAmount, + unlockable, + } + }), + ) + +// NFTs +const nftSource$ = (accountId: string): Observable => + unsafeClient$.pipe( + switchMap(({ unsafeApi }) => + from(unsafeApi.query.Nfts.CollectionAccount.getEntries(accountId)).pipe( + switchMap(async (result): Promise => { + const collectionIds = result.map(({ keyArgs }): [number] => [ + keyArgs[1], + ]) + if (!collectionIds.length) return null + + const collections = + await unsafeApi.query.Nfts.Collection.getValues(collectionIds) + const totalAmount = collections.reduce( + (acc, c) => acc + (c?.owner_deposit ?? 0n), + 0n, + ) + if (totalAmount == 0n) return null + + return { + id: "Nfts", + amount: totalAmount, + unlockable: collections.map((c, i) => ({ + action: `Collection ${collectionIds[i]}`, + amount: c?.owner_deposit ?? 0n, + tx: null, + })), + } + }), + ), + ), + ) + +// Preimages (legacy, e.g. hydration) + +const preimageCache = createTimedCache(async (unsafeApi) => { + const statusFor = await unsafeApi.query.Preimage.StatusFor.getEntries() + return statusFor.map(({ keyArgs, value }) => ({ + hash: keyArgs[0], + depositor: value.value.deposit?.[0], + deposit: value.value.deposit?.[1], + len: value.value.len, + })) +}) +const preimageSource$ = ( + accountId: string, +): Observable => + unsafeClient$.pipe( + switchMap(async ({ client, unsafeApi }) => { + const preimages = await preimageCache(client) + + const ownPreimages = preimages.filter( + ({ depositor }) => depositor === accountId, + ) + if (!ownPreimages.length) return null + + const totalUnlockable = ownPreimages + .map(({ deposit }) => deposit ?? 0n) + .reduce((acc, v) => acc + v, 0n) + const unlockable = getPreimageUnlockables(unsafeApi, ownPreimages) + + return { + id: "Preimage", + amount: totalUnlockable, + unlockable, + } + }), + ) + +// Proxies +// To support pure proxies, we need a timepoint that it's not stored anywhere on-chain… So there's really no way around it without an indexer +const proxySource$ = (accountId: string): Observable => + unsafeClient$.pipe( + switchMap(async ({ unsafeApi }) => { + const [proxy, factor] = await Promise.all([ + unsafeApi.query.Proxy.Proxies.getValue(accountId), + unsafeApi.constants.Proxy.ProxyDepositFactor(), + ]) + const amount = proxy?.[1] + if (!amount) return null + + const removeProxyActions = proxy[0].map( + (p): UnlockAction => ({ + action: `Remove ${p.delegate}`, + amount: proxy[0].length > 1 ? factor : amount, + warn: "If you're calling this from a proxy, you could lose access to the account", + tx: unsafeApi.tx.Proxy.remove_proxy({ + ...p, + delegate: MultiAddress.Id(p.delegate), + }), + }), + ) + const removeAllAction: UnlockAction = { + action: `Remove all`, + amount, + warn: "If you're calling this from a proxy, you WILL lose access to the account", + tx: unsafeApi.tx.Proxy.remove_proxies(), + } + + return { + id: "Proxy", + amount, + unlockable: [...removeProxyActions, removeAllAction], + } + }), + ) + +// Referenda +const referendaCache = createTimedCache(async (unsafeApi) => { + const entries = await unsafeApi.query.Referenda.ReferendumInfoFor.getEntries() + + return entries.map(({ keyArgs, value }) => { + const [submission, decision] = + value.type === "Killed" + ? [] + : value.type === "Ongoing" + ? [value.value.submission_deposit, value.value.decision_deposit] + : [value.value[1], value.value[2]] + return { + id: keyArgs[0], + type: value.type, + submission, + decision, + } + }) +}) +const referendaSource$ = ( + accountId: string, +): Observable => + unsafeClient$.pipe( + switchMap(async ({ client, unsafeApi }): Promise => { + const referenda = await referendaCache(client) + const unlockable = referenda.flatMap((referenda): UnlockAction[] => { + const result: UnlockAction[] = [] + if (referenda.submission?.who === accountId) { + const canRefund = ["Approved", "Cancelled"].includes(referenda.type) + result.push({ + action: `Refund submission deposit ${referenda.id}`, + amount: referenda.submission.amount, + tx: canRefund + ? unsafeApi.tx.Referenda.refund_submission_deposit({ + index: referenda.id, + }) + : null, + warn: canRefund + ? undefined + : "Can't refund: referendum status not Approved or Cancelled", + }) + } + if (referenda.decision?.who === accountId) { + const canRefund = referenda.type !== "Ongoing" + result.push({ + action: `Refund decision deposit ${referenda.id}`, + amount: referenda.decision.amount, + tx: canRefund + ? unsafeApi.tx.Referenda.refund_decision_deposit({ + index: referenda.id, + }) + : null, + warn: canRefund + ? undefined + : "Can't refund: referendum status is Ongoing", + }) + } + return result + }) + if (!unlockable.length) return null + + return { + id: "Referenda", + amount: unlockable.map((v) => v.amount).reduce((a, b) => a + b), + unlockable, + } + }), + ) + +const reserveSources = [ + multisigSource$, + referendaSource$, + preimageSource$, + assetSource$, + nftSource$, + bountySource$, + childBountySource$, + proxySource$, +] + +export const getReserves$ = (accountId: string, totalReserves: bigint) => + combineLatest( + reserveSources.map((source) => + source(accountId).pipe(fallbackWhenError([]), startWith(null)), + ), + ).pipe( + map((sourceResults) => { + const completedResults = sourceResults.filter((r) => r !== null) + + const totalIdentified = completedResults.reduce( + (acc, v) => acc + v.amount, + 0n, + ) + if (totalIdentified < totalReserves) { + completedResults.push({ + id: "Unknown reserved", + amount: totalReserves - totalIdentified, + note: "Could be Pure Proxies, Identity deposits, or other custom reserves", + unlockable: [], + }) + } + return completedResults + }), + ) diff --git a/src/pages/Accounts/lockSources/timedCache.ts b/src/pages/Accounts/lockSources/timedCache.ts new file mode 100644 index 0000000..15b0b46 --- /dev/null +++ b/src/pages/Accounts/lockSources/timedCache.ts @@ -0,0 +1,26 @@ +import { DotAh } from "@polkadot-api/descriptors" +import { PolkadotClient, TypedApi } from "polkadot-api" + +export const createTimedCache = ( + fetchValue: ( + unsafeApi: TypedApi, + client: PolkadotClient, + ) => Promise, + timeout = 5 * 60 * 1000, +) => { + const catxé = new WeakMap< + PolkadotClient, + { + timestamp: number + value: Promise + } + >() + + return (client: PolkadotClient) => { + const cached = catxé.get(client) + if (cached && cached.timestamp > Date.now() - timeout) return cached.value + const value = fetchValue(client.getUnsafeApi(), client) + catxé.set(client, { timestamp: Date.now(), value }) + return value + } +} diff --git a/src/pages/Accounts/locks.state.ts b/src/pages/Accounts/locks.state.ts new file mode 100644 index 0000000..8e710a2 --- /dev/null +++ b/src/pages/Accounts/locks.state.ts @@ -0,0 +1,57 @@ +import { liftSuspense, sinkSuspense, state } from "@react-rxjs/core" +import { SS58String } from "polkadot-api" +import { + catchError, + combineLatest, + filter, + map, + ObservedValueOf, + of, + switchMap, +} from "rxjs" +import { balance$ } from "./AccountList" +import { getFreezes$ } from "./lockSources/freezes" +import { getHodls$ } from "./lockSources/hodls" +import { getReserves$ } from "./lockSources/reserves" + +const isNotNull = (value: T | null): value is T => value !== null + +export const accountLocks$ = state( + (accountId: SS58String) => + balance$(accountId).pipe( + filter(isNotNull), + switchMap((balance) => { + const freezes = balance.frozen ? getFreezes$(accountId) : of([]) + const reserves = balance.reserved + ? getHodls$(accountId).pipe( + switchMap((hodls) => { + const totalReserves = + balance.reserved - + hodls.reduce((acc, v) => acc + v.amount, 0n) + return totalReserves > 0n + ? getReserves$(accountId, totalReserves).pipe( + map((reserves) => [...hodls, ...reserves]), + ) + : [hodls] + }), + ) + : of([]) + + return combineLatest({ + balance: of(balance), + freezes, + reserves, + }) + }), + liftSuspense(), + catchError((ex) => { + console.error(ex) + return [null] + }), + sinkSuspense(), + ), + null, +) +export type AccountLocks = NonNullable< + ObservedValueOf> +> diff --git a/src/pages/Explorer/Detail/digests/rspr.tsx b/src/pages/Explorer/Detail/digests/rspr.tsx index 1cc1474..3117d8a 100644 --- a/src/pages/Explorer/Detail/digests/rspr.tsx +++ b/src/pages/Explorer/Detail/digests/rspr.tsx @@ -28,4 +28,4 @@ const RsprConsensusLog = Struct({ storageRoot: Hex(32), blockNumber: compactNumber, }) -const decodeRsprConsensus = createDecodeDigestFn(RsprConsensusLog) +export const decodeRsprConsensus = createDecodeDigestFn(RsprConsensusLog)