feat: account locks (#157)
* add initial set of locks * add gooey * calculate ED, replace summary with chart * streamify everything * finish reserves: assets, bounties, nfts, proxies and referenda * support preimages in hydration * fix OpenGov unlockable balance * fix locks not loading when switching RPCs, adjust colors * fix bounties, double counting assets
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "0.1.0-autogenerated.6048354284411761388",
|
||||
"version": "0.1.0-autogenerated.8494276640227355333",
|
||||
"name": "@polkadot-api/descriptors",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-13
@@ -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.*"],
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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<{
|
||||
</TokenAmount>
|
||||
</div>
|
||||
</div>
|
||||
{getTransferCallData ? (
|
||||
<Button variant="secondary" asChild>
|
||||
<Link to={`/extrinsics#data=${getTransferCallData(accountId)}`}>
|
||||
<Send /> Transfer
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
{getTransferCallData ? (
|
||||
<Button variant="secondary" asChild>
|
||||
<Link to={`/extrinsics#data=${getTransferCallData(accountId)}`}>
|
||||
<Send /> Transfer
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{balance.total - balance.spendable > 0n ? (
|
||||
<Button variant="secondary" asChild>
|
||||
<Link to={`/accounts/locks/${accountId}`}>
|
||||
<LockKeyhole /> Locks
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<CenteredScrollContainer className="p-4 space-y-4">
|
||||
<Providers />
|
||||
<AccountList />
|
||||
</CenteredScrollContainer>
|
||||
<Routes>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<CenteredScrollContainer className="p-4 space-y-4">
|
||||
<Providers />
|
||||
<AccountList />
|
||||
</CenteredScrollContainer>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="locks/:accountId"
|
||||
element={
|
||||
<CenteredScrollContainer className="p-4">
|
||||
<Locks />
|
||||
</CenteredScrollContainer>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/accounts" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<section className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="mb-3 flex flex-row gap-2 items-center flex-wrap justify-between">
|
||||
<h3 className="text-sm font-semibold uppercase text-muted-foreground">
|
||||
Balance
|
||||
</h3>
|
||||
<div className="text-sm">
|
||||
<TokenAmount className="font-medium">{total}</TokenAmount>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{total == 0n ? null : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<ChartLegend label="Reserved" value={reserved} tone="reserved" />
|
||||
<ChartLegend label="ED" value={ed} tone="ed" />
|
||||
</div>
|
||||
<div className="flex h-8 overflow-hidden rounded-md border border-border bg-background shadow-inner">
|
||||
<ChartLockedArea locked={total - spendable} total={total}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-1/2 min-w-0 overflow-hidden border-b border-border",
|
||||
frozen === 0n ? "h-full border-none" : null,
|
||||
)}
|
||||
>
|
||||
<ChartSegment
|
||||
label="Reserved"
|
||||
value={reserved}
|
||||
total={total - spendable}
|
||||
tone="reserved"
|
||||
/>
|
||||
<ChartSegment
|
||||
label="ED"
|
||||
value={ed}
|
||||
total={total - spendable}
|
||||
tone="ed"
|
||||
/>
|
||||
<div className="min-w-0 flex-1" />
|
||||
</div>
|
||||
{frozen > 0n ? (
|
||||
<div className="flex h-1/2 min-w-0 overflow-hidden">
|
||||
<ChartSegment
|
||||
label="Frozen"
|
||||
value={frozen}
|
||||
total={total - spendable}
|
||||
tone="frozen"
|
||||
/>
|
||||
<div className="min-w-0 flex-1" />
|
||||
</div>
|
||||
) : null}
|
||||
</ChartLockedArea>
|
||||
<ChartSpendableSegment value={spendable} total={total} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
{frozen > 0n ? (
|
||||
<ChartLegend label="Frozen" value={frozen} tone="frozen" />
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<ChartLegend label="Spendable" value={spendable} tone="spendable" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend: FC<{
|
||||
label: string
|
||||
value: bigint
|
||||
tone: BalanceTone
|
||||
}> = ({ label, value, tone }) =>
|
||||
value > 0n ? (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<div
|
||||
className={`h-3 w-3 shrink-0 rounded-sm border ${chartTone[tone]}`}
|
||||
/>
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
<TokenAmount className="text-xs font-medium">{value}</TokenAmount>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const ChartLockedArea: FC<{
|
||||
children: ReactNode
|
||||
locked: bigint
|
||||
total: bigint
|
||||
}> = ({ children, locked, total }) => {
|
||||
if (locked <= 0n || total <= 0n) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-w-0.5 flex-col"
|
||||
style={{ width: `${getPercent(locked, total)}%` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartSpendableSegment: FC<{ value: bigint; total: bigint }> = ({
|
||||
value,
|
||||
total,
|
||||
}) => {
|
||||
if (value <= 0n || total <= 0n) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-full min-w-0.5 border-l ${chartTone.spendable}`}
|
||||
style={{ width: `${getPercent(value, total)}%` }}
|
||||
title={`Spendable: ${value.toLocaleString()}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartSegment: FC<{
|
||||
label: string
|
||||
value: bigint
|
||||
total: bigint
|
||||
tone: BalanceTone
|
||||
}> = ({ label, value, total, tone }) => {
|
||||
if (value <= 0n || total <= 0n) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-full min-w-0.5 border-r ${chartTone[tone]}`}
|
||||
style={{ width: `${getPercent(value, total)}%` }}
|
||||
title={`${label}: ${value.toLocaleString()}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type BalanceTone = "reserved" | "ed" | "spendable" | "frozen"
|
||||
|
||||
const chartTone: Record<BalanceTone, string> = {
|
||||
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
|
||||
@@ -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 (
|
||||
<div className="mx-auto w-full max-w-5xl pb-4">
|
||||
{accountId ? (
|
||||
<LocksContent accountId={accountId} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Missing account"
|
||||
description="No account was provided."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LocksContent: FC<{ accountId: SS58String }> = ({ accountId }) => {
|
||||
const locks = useStateObservable(accountLocks$(accountId))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LocksHeader />
|
||||
|
||||
{!locks ? (
|
||||
<EmptyState
|
||||
title="Loading locks"
|
||||
description="Reading freezes, holds, and reserved balance."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<BalanceChart locks={locks} />
|
||||
<LockSection title="Reserved balance" locks={locks.reserves} />
|
||||
<LockSection title="Frozen balance" locks={locks.freezes} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LocksHeader = () => {
|
||||
const { accountId } = useParams()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Link
|
||||
to="/accounts"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Accounts
|
||||
</Link>
|
||||
<h2 className="text-xl font-semibold">Account locks</h2>
|
||||
</div>
|
||||
<AccountIdDisplay value={accountId!} className="min-w-0 sm:max-w-md" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LockSection: FC<{ title: string; locks: IdentifiedLock[] }> = ({
|
||||
title,
|
||||
locks,
|
||||
}) =>
|
||||
locks.length ? (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold uppercase text-muted-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
{locks.map((lock, i) => (
|
||||
<LockCard lock={lock} key={i} />
|
||||
))}
|
||||
</section>
|
||||
) : null
|
||||
|
||||
const LockCard: FC<{ lock: IdentifiedLock }> = ({ lock }) => {
|
||||
const unlockable = lock.unlockable.reduce(
|
||||
(acc, action) => acc + (action.tx ? action.amount : 0n),
|
||||
0n,
|
||||
)
|
||||
|
||||
return (
|
||||
<article className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<LockKeyhole className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="font-semibold">{lock.id}</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap text-sm gap-x-4">
|
||||
{unlockable > 0n ? (
|
||||
<AmountRow label="Unlockable" value={unlockable} />
|
||||
) : null}
|
||||
<AmountRow label="Locked" value={lock.amount} />
|
||||
</div>
|
||||
</div>
|
||||
{lock.note ? (
|
||||
<p className="text-sm text-muted-foreground">{lock.note}</p>
|
||||
) : null}
|
||||
|
||||
{lock.unlockable.length ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{lock.unlockable.map((action, i) => (
|
||||
<UnlockActionRow key={i} action={action} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
const AmountRow: FC<{ label: string; value: bigint }> = ({ label, value }) => (
|
||||
<div className="flex items-center gap-3 rounded-md bg-background/60">
|
||||
<div className="text-muted-foreground">{label}</div>
|
||||
<TokenAmount className="font-medium">{value}</TokenAmount>
|
||||
</div>
|
||||
)
|
||||
|
||||
const UnlockActionRow: FC<{
|
||||
action: IdentifiedLock["unlockable"][number]
|
||||
}> = ({ action }) => (
|
||||
<div className="rounded-md border bg-background/60 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="font-medium">{action.action}</div>
|
||||
<AmountRow label="Value" value={action.amount} />
|
||||
{action.warn ? (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-sm text-amber-800 dark:text-amber-200">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{action.warn}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{action.tx ? <TransactionButton tx={action.tx} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const EmptyState: FC<{ title: string; description: string }> = ({
|
||||
title,
|
||||
description,
|
||||
}) => (
|
||||
<div className="rounded-lg border border-dashed bg-card p-6 text-center">
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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 (
|
||||
<div className="flex min-w-48 flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!account?.signer}
|
||||
onClick={submit}
|
||||
className="h-9 justify-center gap-2"
|
||||
>
|
||||
<LockKeyhole className="h-4 w-4" />
|
||||
Sign and submit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!loadedCallData}
|
||||
asChild={!!loadedCallData}
|
||||
className="h-9 justify-center gap-2"
|
||||
>
|
||||
{loadedCallData ? (
|
||||
<Link to={`/extrinsics#data=${loadedCallData}`}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open in extrinsics
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open in extrinsics
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<DotAh, boolean>,
|
||||
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<DotAh>(),
|
||||
})),
|
||||
)
|
||||
|
||||
export const fallbackWhenError = <T, A extends T>(
|
||||
fallback: ObservableInput<A>,
|
||||
) =>
|
||||
pipe(
|
||||
liftSuspense<T>(),
|
||||
catchError((ex) => {
|
||||
console.log(ex)
|
||||
return fallback
|
||||
}),
|
||||
sinkSuspense(),
|
||||
)
|
||||
@@ -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<UnlockAction[]> =>
|
||||
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<UnlockAction[]> =>
|
||||
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<IdentifiedLock[]> =>
|
||||
unsafeClient$.pipe(
|
||||
switchMap(({ unsafeApi }) =>
|
||||
unsafeApi.query.Balances.Locks.getValue(accountId),
|
||||
),
|
||||
switchMap((locks) =>
|
||||
combineLatest(
|
||||
locks.map((lock): ObservableInput<IdentifiedLock> => {
|
||||
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: [],
|
||||
},
|
||||
]
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -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<DotAh, boolean>,
|
||||
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<UnlockAction[]> =>
|
||||
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<IdentifiedLock[]> =>
|
||||
unsafeClient$.pipe(
|
||||
switchMap(({ unsafeApi }) =>
|
||||
unsafeApi.query.Balances.Holds.getValue(accountId),
|
||||
),
|
||||
switchMap((hodls) =>
|
||||
hodls.length
|
||||
? combineLatest(
|
||||
hodls.map((hodl): ObservableInput<IdentifiedLock> => {
|
||||
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([]),
|
||||
),
|
||||
)
|
||||
@@ -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<IdentifiedLock | null> =>
|
||||
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<IdentifiedLock | null> =>
|
||||
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<IdentifiedLock | null> =>
|
||||
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<DotAh, false>,
|
||||
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<IdentifiedLock | null> =>
|
||||
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<IdentifiedLock | null> =>
|
||||
unsafeClient$.pipe(
|
||||
switchMap(({ unsafeApi }) =>
|
||||
from(unsafeApi.query.Nfts.CollectionAccount.getEntries(accountId)).pipe(
|
||||
switchMap(async (result): Promise<IdentifiedLock | null> => {
|
||||
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<IdentifiedLock | null> =>
|
||||
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<IdentifiedLock | null> =>
|
||||
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<IdentifiedLock | null> =>
|
||||
unsafeClient$.pipe(
|
||||
switchMap(async ({ client, unsafeApi }): Promise<IdentifiedLock | null> => {
|
||||
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
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
import { DotAh } from "@polkadot-api/descriptors"
|
||||
import { PolkadotClient, TypedApi } from "polkadot-api"
|
||||
|
||||
export const createTimedCache = <T>(
|
||||
fetchValue: (
|
||||
unsafeApi: TypedApi<DotAh, false>,
|
||||
client: PolkadotClient,
|
||||
) => Promise<T>,
|
||||
timeout = 5 * 60 * 1000,
|
||||
) => {
|
||||
const catxé = new WeakMap<
|
||||
PolkadotClient,
|
||||
{
|
||||
timestamp: number
|
||||
value: Promise<T>
|
||||
}
|
||||
>()
|
||||
|
||||
return (client: PolkadotClient) => {
|
||||
const cached = catxé.get(client)
|
||||
if (cached && cached.timestamp > Date.now() - timeout) return cached.value
|
||||
const value = fetchValue(client.getUnsafeApi<DotAh>(), client)
|
||||
catxé.set(client, { timestamp: Date.now(), value })
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -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 = <T>(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<ReturnType<typeof accountLocks$>>
|
||||
>
|
||||
@@ -28,4 +28,4 @@ const RsprConsensusLog = Struct({
|
||||
storageRoot: Hex(32),
|
||||
blockNumber: compactNumber,
|
||||
})
|
||||
const decodeRsprConsensus = createDecodeDigestFn(RsprConsensusLog)
|
||||
export const decodeRsprConsensus = createDecodeDigestFn(RsprConsensusLog)
|
||||
|
||||
Reference in New Issue
Block a user