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:
Victor Oliva
2026-06-16 11:08:58 +02:00
committed by GitHub
parent 0c7528efc2
commit 90c43f9efd
16 changed files with 1390 additions and 36 deletions
+1 -1
View File
@@ -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.
+6
View File
@@ -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
View File
@@ -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.*"],
}
+40 -17
View File
@@ -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>
)
}
+22 -4
View File
@@ -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>
)
}
+150
View File
@@ -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
+159
View File
@@ -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>
)
+83
View File
@@ -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>
)
}
+62
View File
@@ -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(),
)
+187
View File
@@ -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: [],
},
]
}),
),
),
)
+135
View File
@@ -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([]),
),
)
+444
View File
@@ -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
}
}
+57
View File
@@ -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$>>
>
+1 -1
View File
@@ -28,4 +28,4 @@ const RsprConsensusLog = Struct({
storageRoot: Hex(32),
blockNumber: compactNumber,
})
const decodeRsprConsensus = createDecodeDigestFn(RsprConsensusLog)
export const decodeRsprConsensus = createDecodeDigestFn(RsprConsensusLog)