feat: extrinsic analyzer
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import { Chopsticks } from "@/components/Icons"
|
||||
import { Loading } from "@/components/Loading"
|
||||
import { getHashParams } from "@/hashParams"
|
||||
import { groupBy } from "@/lib/groupBy"
|
||||
import { runtimeCtx$, runtimeCtxAt$ } from "@/state/chains/chain.state"
|
||||
import { Blake2256 } from "@polkadot-api/substrate-bindings"
|
||||
import { getExtrinsicDecoder } from "@polkadot-api/tx-utils"
|
||||
import * as Tabs from "@radix-ui/react-tabs"
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import { toHex } from "polkadot-api/utils"
|
||||
import { FC, useState } from "react"
|
||||
import { useLocation } from "react-router-dom"
|
||||
import { catchError, combineLatest, filter, map, take } from "rxjs"
|
||||
@@ -12,9 +16,6 @@ import { BlockInfo, blockInfoState$ } from "../block.state"
|
||||
import { BlockEvents } from "./BlockEvents"
|
||||
import { BlockStorageDiff } from "./BlockStorageDiff"
|
||||
import { ApplyExtrinsicEvent, Extrinsic } from "./Extrinsic"
|
||||
import { getHashParams } from "@/hashParams"
|
||||
import { Blake2256 } from "@polkadot-api/substrate-bindings"
|
||||
import { toHex } from "polkadot-api/utils"
|
||||
|
||||
const blockExtrinsics$ = state((hash: string) => {
|
||||
const body$ = blockInfoState$(hash).pipe(
|
||||
@@ -25,10 +26,13 @@ const blockExtrinsics$ = state((hash: string) => {
|
||||
|
||||
return combineLatest([
|
||||
body$,
|
||||
runtimeCtxAt$(hash).pipe(catchError(() => runtimeCtx$)),
|
||||
runtimeCtxAt$(hash).pipe(
|
||||
catchError(() => runtimeCtx$),
|
||||
map((v) => getExtrinsicDecoder(v.metadataRaw)),
|
||||
),
|
||||
]).pipe(
|
||||
take(1),
|
||||
map(([body, { txDecoder }]) =>
|
||||
map(([body, txDecoder]) =>
|
||||
body.map((raw, idx) => ({
|
||||
idx,
|
||||
hash: toHex(Blake2256(raw)),
|
||||
|
||||
@@ -92,7 +92,7 @@ export const Extrinsic: FC<{
|
||||
)
|
||||
}
|
||||
|
||||
const SignedExtensions: FC<{ extra: Record<string, unknown> }> = ({
|
||||
export const SignedExtensions: FC<{ extra: Record<string, unknown> }> = ({
|
||||
extra,
|
||||
}) => (
|
||||
<div className="space-y-2">
|
||||
@@ -173,7 +173,7 @@ export const EventDisplay: FC<{
|
||||
)
|
||||
}
|
||||
|
||||
const Sender: React.FC<{
|
||||
export const Sender: React.FC<{
|
||||
sender: Enum<{ Id: SS58String }> | SS58String | HexString
|
||||
}> = ({ sender }) => {
|
||||
const value: string | null =
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { CopyBinary } from "@/codec-components/ViewCodec/CopyBinary"
|
||||
import { JsonDisplay } from "@/components/JsonDisplay"
|
||||
import { TextInputField } from "@/components/TextInputField"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { client$ } from "@/state/chains/chain.state"
|
||||
import { polkadot_people } from "@polkadot-api/descriptors"
|
||||
import { DecodedExtrinsic, getExtrinsicDecoder } from "@polkadot-api/tx-utils"
|
||||
import { state, Subscribe, useStateObservable } from "@react-rxjs/core"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { Binary, HexString, TxCallData } from "polkadot-api"
|
||||
import { toHex } from "polkadot-api/utils"
|
||||
import { FC, useEffect, useMemo, useState } from "react"
|
||||
import { combineLatest, firstValueFrom, map, merge, switchMap } from "rxjs"
|
||||
import { Sender, SignedExtensions } from "../Explorer/Detail/Extrinsic"
|
||||
import { BlockPicker, selectedBlock$ } from "../Storage/BlockPicker"
|
||||
|
||||
const extDecoder$ = state(
|
||||
selectedBlock$.pipe(
|
||||
map((block) => getExtrinsicDecoder(block.ctx.metadataRaw)),
|
||||
),
|
||||
)
|
||||
const selectedBlockHex$ = state(selectedBlock$.pipe(map((v) => v.hash)))
|
||||
|
||||
export const ExtrinsicAnalyzer: FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const [extrinsicHex, setExtrinsicHex] = useState("")
|
||||
|
||||
return (
|
||||
<div className="p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="has-[>svg]:px-1"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<h2 className="text-lg font-bold">Analyze Extrinsic</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<div>
|
||||
<label>
|
||||
Block
|
||||
<BlockPicker />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<label>
|
||||
Extrinsic
|
||||
<TextInputField
|
||||
className="w-full"
|
||||
value={extrinsicHex}
|
||||
onChange={setExtrinsicHex}
|
||||
placeholder="Extrinsic Hex"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Subscribe source$={extAnalyzer$} fallback={null}>
|
||||
{extrinsicHex ? <DecodeExtrinsic extrinsic={extrinsicHex} /> : null}
|
||||
</Subscribe>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DecodeExtrinsic: FC<{
|
||||
extrinsic: HexString
|
||||
}> = ({ extrinsic }) => {
|
||||
const extrinsicDecoder = useStateObservable(extDecoder$)
|
||||
const decodeResult = useMemo(() => {
|
||||
try {
|
||||
return {
|
||||
type: "success" as const,
|
||||
value: extrinsicDecoder(extrinsic),
|
||||
}
|
||||
} catch (ex) {
|
||||
console.error(ex)
|
||||
return {
|
||||
type: "error" as const,
|
||||
value: ex as Error,
|
||||
}
|
||||
}
|
||||
}, [extrinsicDecoder, extrinsic])
|
||||
|
||||
if (decodeResult.type === "error") {
|
||||
return <div>Can't decode: {decodeResult.value.message}</div>
|
||||
}
|
||||
|
||||
const decoded = decodeResult.value
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="capitalize text-xl font-bold">
|
||||
{decoded.type} Transaction v{decoded.version}
|
||||
</h2>
|
||||
{decoded.type === "signed" ? (
|
||||
<SignedInfo extrinsic={extrinsic} decoded={decoded} />
|
||||
) : decoded.type === "general" ? (
|
||||
<div>TODO</div>
|
||||
) : (
|
||||
<AnalyzePriority extrinsic={extrinsic} />
|
||||
)}
|
||||
<CallData call={decoded.call as TxCallData} callData={decoded.callData} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SignedInfo: FC<{
|
||||
extrinsic: HexString
|
||||
decoded: DecodedExtrinsic & { type: "signed" }
|
||||
}> = ({ extrinsic, decoded }) => {
|
||||
const mortality = decoded.extra.CheckMortality
|
||||
const txPayment =
|
||||
decoded.extra.ChargeAssetTxPayment ?? decoded.extra.ChargeTxPayment
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mb-4">
|
||||
<Sender sender={decoded.address} />
|
||||
<SignedExtensions extra={decoded.extra} />
|
||||
{mortality ? <AnalyzeMortality mortality={mortality} /> : null}
|
||||
<AnalyzePriority extrinsic={extrinsic} txPayment={txPayment ?? {}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CallData: FC<{ call: TxCallData; callData: Uint8Array }> = ({
|
||||
call,
|
||||
callData,
|
||||
}) => (
|
||||
<div>
|
||||
<div className="flex gap-2 items-baseline">
|
||||
<div className="text-lg">
|
||||
{call.type}.{call.value.type}
|
||||
</div>
|
||||
<div className="flex gap-1 items-center overflow-hidden">
|
||||
<div className="shrink overflow-hidden text-ellipsis text-muted-foreground">
|
||||
{toHex(callData)}
|
||||
</div>
|
||||
<CopyBinary value={callData} />
|
||||
</div>
|
||||
</div>
|
||||
<JsonDisplay src={call.value.value} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const selectedBlockNumber$ = state(
|
||||
combineLatest([selectedBlockHex$, client$]).pipe(
|
||||
switchMap(([selectedBlock, client]) =>
|
||||
selectedBlock
|
||||
? (client
|
||||
.getTypedApi(polkadot_people)
|
||||
.query.System.Number.getValue({
|
||||
at: selectedBlock,
|
||||
})
|
||||
.catch(() => null) as Promise<number | null>)
|
||||
: client.finalizedBlock$.pipe(map((block) => block.number)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const AnalyzeMortality: FC<{ mortality: { type: string; value: number } }> = ({
|
||||
mortality,
|
||||
}) => {
|
||||
const selectedBlockNumber = useStateObservable(selectedBlockNumber$)
|
||||
|
||||
if (mortality.type === "Immortal") return null
|
||||
const parseResult = /^Mortal(\d+)$/.exec(mortality.type)
|
||||
if (parseResult === null) return null
|
||||
const first = BigInt(parseResult[1])
|
||||
const second = BigInt(mortality.value)
|
||||
// from polkadot-sdk primitives runtime generic era fn decode
|
||||
const encoded = first + (second << 8n)
|
||||
const period = Number(2n << (encoded % (1n << 4n)))
|
||||
const factor = period >> 12 || 1
|
||||
const phase = Number(encoded >> 4n) * factor
|
||||
|
||||
// From fn birth
|
||||
const birthBlock = selectedBlockNumber
|
||||
? Math.floor((Math.max(selectedBlockNumber, phase) - phase) / period) *
|
||||
period +
|
||||
phase
|
||||
: null
|
||||
|
||||
const referencedBlocks = birthBlock
|
||||
? new Array(5)
|
||||
.fill(0)
|
||||
.map((_, i) => (birthBlock - period * i).toLocaleString())
|
||||
: null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<b>Mortality:</b> period={period} phase={phase}{" "}
|
||||
{referencedBlocks ? (
|
||||
<>
|
||||
currentBlock={selectedBlockNumber?.toLocaleString()} nextPeriod=
|
||||
{(birthBlock! + period).toLocaleString()} oldPeriods=
|
||||
{referencedBlocks.join(" ")}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const maxBlockSize$ = state(
|
||||
combineLatest([selectedBlockHex$, client$]).pipe(
|
||||
switchMap(([selectedBlock, client]) =>
|
||||
Promise.all([
|
||||
client.getTypedApi(polkadot_people).constants.System.BlockLength({
|
||||
at: selectedBlock,
|
||||
}),
|
||||
client.getTypedApi(polkadot_people).constants.System.BlockWeights({
|
||||
at: selectedBlock,
|
||||
}),
|
||||
]).then(
|
||||
([length, weights]) => ({
|
||||
length,
|
||||
maxWeight: weights.max_block,
|
||||
}),
|
||||
() => null,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const AnalyzePriority: FC<{
|
||||
txPayment?: { tip?: bigint }
|
||||
extrinsic: HexString
|
||||
}> = ({ txPayment, extrinsic }) => {
|
||||
const blockHex = useStateObservable(selectedBlockHex$)
|
||||
const maxBlockSize = useStateObservable(maxBlockSize$)
|
||||
const [queryInfo, setQueryInfo] = useState<{
|
||||
weight: Weight
|
||||
class: { type: "Normal" | "Operational" | "Mandatory" }
|
||||
partial_fee: bigint
|
||||
} | null>(null)
|
||||
const length = (extrinsic.length - 2) / 8
|
||||
const tip = txPayment?.tip ?? 0n
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setQueryInfo(null)
|
||||
const client = await firstValueFrom(client$)
|
||||
console.log(extrinsic.length)
|
||||
const result = await client
|
||||
.getUnsafeApi()
|
||||
.apis.TransactionPaymentApi.query_info(
|
||||
Binary.fromOpaque(extrinsic),
|
||||
length,
|
||||
{
|
||||
at: blockHex,
|
||||
},
|
||||
)
|
||||
if (!cancelled) {
|
||||
setQueryInfo(result as any)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [extrinsic, blockHex, length])
|
||||
|
||||
if (!queryInfo) return null
|
||||
|
||||
const priority = (() => {
|
||||
if (!maxBlockSize) return null
|
||||
console.log(queryInfo)
|
||||
const maxLength =
|
||||
maxBlockSize.length[
|
||||
queryInfo.class.type.toLocaleLowerCase() as keyof typeof maxBlockSize.length
|
||||
]
|
||||
|
||||
const maxTxPerBlockWeight = divWeight(
|
||||
maxBlockSize.maxWeight,
|
||||
queryInfo.weight,
|
||||
)
|
||||
const maxTxPerBlockLength = BigInt(Math.floor(maxLength / length))
|
||||
const maxTxPerBlock =
|
||||
maxTxPerBlockWeight < maxTxPerBlockLength
|
||||
? maxTxPerBlockWeight
|
||||
: maxTxPerBlockLength
|
||||
const priority = (tip + 1n) * maxTxPerBlock
|
||||
|
||||
return { priority, maxTxPerBlockLength, maxTxPerBlockWeight }
|
||||
})()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<b>Priority:</b> tip={tip.toLocaleString()} fee=
|
||||
{queryInfo.partial_fee.toLocaleString()} class={queryInfo.class.type}{" "}
|
||||
weight=
|
||||
{queryInfo.weight.proof_size.toLocaleString() +
|
||||
"/" +
|
||||
queryInfo.weight.ref_time.toLocaleString()}{" "}
|
||||
{priority ? (
|
||||
<>
|
||||
txPerBlockLength={priority.maxTxPerBlockLength.toLocaleString()}{" "}
|
||||
txPerBlockWeight={priority.maxTxPerBlockWeight.toLocaleString()}{" "}
|
||||
priority={priority.priority.toLocaleString()}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const extAnalyzer$ = merge(
|
||||
extDecoder$,
|
||||
selectedBlockHex$,
|
||||
selectedBlockNumber$,
|
||||
maxBlockSize$,
|
||||
)
|
||||
|
||||
type Weight = { ref_time: bigint; proof_size: bigint }
|
||||
const divWeight = (a: Weight, b: Weight) => {
|
||||
const ref_time = b.ref_time === 0n ? 1n : a.ref_time / b.ref_time
|
||||
const proof_size = b.proof_size === 0n ? 1n : a.proof_size / b.proof_size
|
||||
return ref_time < proof_size ? ref_time : proof_size
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BinaryDisplay } from "@/codec-components/LookupTypeEdit"
|
||||
import { ActionButton } from "@/components/ActionButton"
|
||||
import { ButtonGroup } from "@/components/ButtonGroup"
|
||||
import { LoadingMetadata } from "@/components/Loading"
|
||||
import { withSubscribe } from "@/components/withSuspense"
|
||||
@@ -8,18 +9,18 @@ import {
|
||||
CodecComponentType,
|
||||
CodecComponentValue,
|
||||
} from "@polkadot-api/react-builder"
|
||||
import { fromHex, toHex } from "polkadot-api/utils"
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import { FileSearch, Settings } from "lucide-react"
|
||||
import { fromHex, toHex } from "polkadot-api/utils"
|
||||
import { useLayoutEffect, useState } from "react"
|
||||
import { useLocation } from "react-router-dom"
|
||||
import { map } from "rxjs"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { ExtrinsicAnalyzer } from "./ExtrinsicAnalyzer"
|
||||
import { CustomSignedExt, customSignedExtensions$ } from "./CustomSignedExt"
|
||||
import { EditMode } from "./EditMode"
|
||||
import { JsonMode } from "./JsonMode"
|
||||
import { ExtrinsicModal } from "./SubmitTx/SubmitTx"
|
||||
import { ActionButton } from "@/components/ActionButton"
|
||||
import { Settings } from "lucide-react"
|
||||
import { CustomSignedExt, customSignedExtensions$ } from "./CustomSignedExt"
|
||||
|
||||
const extrinsicProps$ = state(
|
||||
runtimeCtx$.pipe(
|
||||
@@ -55,7 +56,7 @@ const customExtensionsCount$ = state(
|
||||
export const Extrinsics = withSubscribe(
|
||||
() => {
|
||||
const [viewMode, setViewMode] = useState<"edit" | "json">("edit")
|
||||
const [editingExtensions, setEditingExtensions] = useState(false)
|
||||
const [page, setPage] = useState<"analyze" | "extensions" | null>(null)
|
||||
const extrinsicProps = useStateObservable(extrinsicProps$)
|
||||
const location = useLocation()
|
||||
|
||||
@@ -83,8 +84,10 @@ export const Extrinsics = withSubscribe(
|
||||
}
|
||||
}, [binaryValue])
|
||||
|
||||
if (editingExtensions)
|
||||
return <CustomSignedExt onClose={() => setEditingExtensions(false)} />
|
||||
if (page === "extensions")
|
||||
return <CustomSignedExt onClose={() => setPage(null)} />
|
||||
if (page === "analyze")
|
||||
return <ExtrinsicAnalyzer onClose={() => setPage(null)} />
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -119,9 +122,15 @@ export const Extrinsics = withSubscribe(
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActionButton
|
||||
className="text-foreground/70"
|
||||
onClick={() => setPage("analyze")}
|
||||
>
|
||||
<FileSearch />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
className="text-foreground/70 flex items-center gap-1"
|
||||
onClick={() => setEditingExtensions(true)}
|
||||
onClick={() => setPage("extensions")}
|
||||
>
|
||||
{customExtensionsCount$}
|
||||
<Settings />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Spinner } from "@/components/Icons"
|
||||
import { runtimeCtx$, runtimeCtxAt$ } from "@/state/chains/chain.state"
|
||||
import { RuntimeContext } from "@polkadot-api/observable-client"
|
||||
import {
|
||||
CachedRuntime,
|
||||
runtimeCtx$,
|
||||
runtimeCtxAt$,
|
||||
} from "@/state/chains/chain.state"
|
||||
import { Input } from "@polkahub/ui-components"
|
||||
import {
|
||||
liftSuspense,
|
||||
@@ -29,7 +32,7 @@ const loadedCtx$ = state(
|
||||
debounceTime(200),
|
||||
switchMap((v) => {
|
||||
const inner$: Observable<{
|
||||
ctx: Pick<RuntimeContext, "lookup" | "dynamicBuilder">
|
||||
ctx: CachedRuntime
|
||||
hash?: string
|
||||
}> =
|
||||
!v.trim() || v === "Latest"
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { createChopsticksProvider } from "@/chopsticks/chopsticks"
|
||||
import { getHashParams, setHashParams } from "@/hashParams"
|
||||
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
|
||||
import type {
|
||||
ChainHead$,
|
||||
RuntimeContext,
|
||||
} from "@polkadot-api/observable-client"
|
||||
import {
|
||||
decAnyMetadata,
|
||||
HexString,
|
||||
unifyMetadata,
|
||||
} from "@polkadot-api/substrate-bindings"
|
||||
import { getExtrinsicDecoder } from "@polkadot-api/tx-utils"
|
||||
import { fromHex, toHex } from "polkadot-api/utils"
|
||||
import { liftSuspense, sinkSuspense, state, SUSPENSE } from "@react-rxjs/core"
|
||||
import { createSignal } from "@react-rxjs/utils"
|
||||
import { get, update } from "idb-keyval"
|
||||
import { createClient } from "polkadot-api"
|
||||
import { withLogsRecorder } from "polkadot-api/logs-provider"
|
||||
import { fromHex, toHex } from "polkadot-api/utils"
|
||||
import {
|
||||
catchError,
|
||||
concat,
|
||||
@@ -47,7 +50,6 @@ import {
|
||||
getWebsocketProvider,
|
||||
WebsocketSource,
|
||||
} from "./websocket"
|
||||
import type { ChainHead$ } from "@polkadot-api/observable-client"
|
||||
|
||||
export type ChainSource = WebsocketSource | SmoldotSource
|
||||
|
||||
@@ -254,14 +256,6 @@ const uncachedRuntimeCtx$ = chainClient$.pipeState(
|
||||
filter(Boolean),
|
||||
)
|
||||
|
||||
const withTxDecoder: <T extends { metadataRaw: Uint8Array }>(
|
||||
input: Observable<T>,
|
||||
) => Observable<T & { txDecoder: ReturnType<typeof getExtrinsicDecoder> }> =
|
||||
map((ctx) => ({
|
||||
...ctx,
|
||||
txDecoder: getExtrinsicDecoder(ctx.metadataRaw),
|
||||
}))
|
||||
|
||||
export const runtimeCtxAt$ = state((atBlock: string) =>
|
||||
chainClient$.pipe(
|
||||
take(1),
|
||||
@@ -272,17 +266,20 @@ export const runtimeCtxAt$ = state((atBlock: string) =>
|
||||
client.chainHead.getRuntimeContext$(atBlock)
|
||||
)
|
||||
}),
|
||||
withTxDecoder,
|
||||
),
|
||||
)
|
||||
|
||||
export type CachedRuntime = Pick<
|
||||
RuntimeContext,
|
||||
"metadataRaw" | "lookup" | "dynamicBuilder"
|
||||
>
|
||||
export const runtimeCtx$ = chainClient$.pipeState(
|
||||
switchMap(({ id }) =>
|
||||
get<MetadataCache>(IDB_KEY).then((cache) =>
|
||||
cache ? [...cache.entries()].find(([, v]) => v.id === id) : undefined,
|
||||
),
|
||||
),
|
||||
switchMap((cached) => {
|
||||
switchMap((cached): Observable<RuntimeContext | CachedRuntime> => {
|
||||
if (!cached) return uncachedRuntimeCtx$
|
||||
const metadata = unifyMetadata(decAnyMetadata(cached[1].data))
|
||||
const lookup = getLookupFn(metadata)
|
||||
@@ -296,7 +293,6 @@ export const runtimeCtx$ = chainClient$.pipeState(
|
||||
}),
|
||||
)
|
||||
}),
|
||||
withTxDecoder,
|
||||
)
|
||||
|
||||
export const lookup$ = runtimeCtx$.pipeState(map((ctx) => ctx.lookup))
|
||||
|
||||
Reference in New Issue
Block a user