feat: set and view signed extensions (#102)

* feat: add custom signed extensions in extrinsics submit

* feat: show signed extensions in block detail
This commit is contained in:
Victor Oliva
2025-11-12 16:35:17 -03:00
committed by GitHub
parent 2f20ae8e1f
commit 65d5a43182
5 changed files with 361 additions and 18 deletions

View File

@@ -10,7 +10,7 @@ export const ActionButton = forwardRef<
ref={ref}
className={twMerge(
"text-accent-foreground bg-accent/90 px-3 py-1",
"cursor-pointer select-none hover:bg-accent/100",
"cursor-pointer select-none hover:bg-accent",
props.disabled && "opacity-50 pointer-events-none",
props.className,
)}

View File

@@ -1,17 +1,17 @@
import { CopyBinary } from "@/codec-components/ViewCodec/CopyBinary"
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
import { CopyText } from "@/components/Copy"
import { EthAccountDisplay } from "@/components/EthAccountDisplay"
import { ExpandBtn } from "@/components/Expand"
import { JsonDisplay } from "@/components/JsonDisplay"
import { Link } from "@/hashParams"
import { shortStr } from "@/utils"
import { SystemEvent } from "@polkadot-api/observable-client"
import { DecodedExtrinsic } from "@polkadot-api/tx-utils"
import { Edit } from "lucide-react"
import { Enum, HexString, SS58String } from "polkadot-api"
import { Dot, Edit } from "lucide-react"
import { Binary, Enum, HexString, SS58String } from "polkadot-api"
import { FC, useEffect, useRef, useState } from "react"
import { twMerge } from "tailwind-merge"
import { EthAccountDisplay } from "@/components/EthAccountDisplay"
import { CopyText } from "@/components/Copy"
import { shortStr } from "@/utils"
export type ApplyExtrinsicEvent = SystemEvent & {
phase: { type: "ApplyExtrinsic" }
@@ -65,11 +65,6 @@ export const Extrinsic: FC<{
<Sender sender={extrinsic.address} />
</div>
)}
{"extra" in extrinsic && (
<div>
<SignedExtensions extra={extrinsic.extra} />
</div>
)}
<div className="overflow-auto max-h-[80vh] p-2">
<JsonDisplay src={extrinsic.call.value.value} />
</div>
@@ -85,15 +80,60 @@ export const Extrinsic: FC<{
))}
</ol>
</div>
{"extra" in extrinsic && (
<div className="p-2 overflow-auto max-h-[80vh] border-t">
<SignedExtensions extra={extrinsic.extra} />
</div>
)}
</div>
) : null}
</li>
)
}
const SignedExtensions: FC<{ extra: unknown }> = () => {
// TODO maybe?
return null
const SignedExtensions: FC<{ extra: Record<string, unknown> }> = ({
extra,
}) => (
<div className="space-y-2">
<h3>Signed extensions</h3>
<ul className="space-y-2">
{Object.entries(extra).map(([key, value]) => (
<SignedExtension key={key} id={key} value={value} />
))}
</ul>
</div>
)
const SignedExtension: FC<{ id: string; value: unknown }> = ({ id, value }) => {
const [expanded, setExpanded] = useState(false)
const inlineJson = JSON.stringify(value, (_, v) =>
typeof v === "bigint" ? String(v) : v instanceof Binary ? v.asHex() : v,
)
if (!inlineJson || inlineJson.length < 40) {
return (
<li className="flex items-center flex-wrap gap-1">
<div className="flex gap-2 items-center">
<Dot size={16} />
{id}
</div>
{inlineJson ? (
<div className="whitespace-nowrap">
- <span className="font-mono text-sm">{inlineJson}</span>
</div>
) : null}
</li>
)
}
return (
<li className="space-y-2">
<div className="flex gap-2 items-center">
<ExpandBtn expanded={expanded} onClick={() => setExpanded((e) => !e)} />
{id}
</div>
{expanded && <JsonDisplay src={value} />}
</li>
)
}
export const EventDisplay: FC<{

View File

@@ -0,0 +1,268 @@
import {
InlineLookupTypeEdit,
LookupTypeEdit,
} from "@/codec-components/LookupTypeEdit"
import { ActionButton } from "@/components/ActionButton"
import { ExpandBtn } from "@/components/Expand"
import { lookup$, metadata$ } from "@/state/chains/chain.state"
import { getTypeComplexity } from "@/utils"
import { Button } from "@polkahub/ui-components"
import { state, Subscribe, useStateObservable } from "@react-rxjs/core"
import { combineKeys, createSignal, mergeWithKey } from "@react-rxjs/utils"
import { ChevronLeft, Circle } from "lucide-react"
import { FC, useState } from "react"
import { combineLatest, defer, map, merge, scan, startWith } from "rxjs"
import { twMerge } from "tailwind-merge"
export const CustomSignedExt: FC<{ onClose: () => void }> = ({ onClose }) => {
const metadata = useStateObservable(metadata$)
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">Custom Signed Extensions</h2>
</div>
<Subscribe source$={extSub$}>
<ul className="space-y-2">
{metadata.extrinsic.signedExtensions.map((ext) => (
<ExtensionInput key={ext.identifier} {...ext} />
))}
</ul>
</Subscribe>
</div>
)
}
const [extensionValueChange$, setExtensionValue] = createSignal<{
id: string
part: "type" | "additionalSigned"
value: Uint8Array | "partial" | null
}>()
const [extensionValueRemove$, clearExtension] = createSignal<string>()
const extensionValues$ = state(
defer(() => {
const values: Record<
string,
{
type: Uint8Array | "partial" | null
additionalSigned: Uint8Array | "partial" | null
}
> = {}
return mergeWithKey({
change: extensionValueChange$,
remove: extensionValueRemove$,
}).pipe(
scan((acc, evt) => {
const newValue = { ...acc }
if (evt.type === "change") {
const change = evt.payload
newValue[change.id] ??= { type: null, additionalSigned: null }
newValue[change.id] = {
...newValue[change.id],
[change.part]: change.value,
}
} else {
delete newValue[evt.payload]
}
return newValue
}, values),
startWith(values),
)
}),
)
export const customSignedExtensions$ = combineLatest([
extensionValues$,
metadata$,
lookup$,
]).pipe(
map(([ext, metadata, lookup]) =>
Object.fromEntries(
Object.entries(ext)
.map(
([id, { additionalSigned, type }]): [
string,
{
value?: Uint8Array
additionalSigned?: Uint8Array
},
] => {
const ext = metadata.extrinsic.signedExtensions.find(
(v) => v.identifier === id,
)
if (!ext) return [id, null!]
const hasType = lookup(ext.type).type != "void"
const hasAdditional = lookup(ext.additionalSigned).type != "void"
const result: {
value?: Uint8Array
additionalSigned?: Uint8Array
} = {}
if (hasType) {
if (type === "partial" || type == null) return [id, null!]
result.value = type
}
if (hasAdditional) {
if (additionalSigned === "partial" || additionalSigned == null)
return [id, null!]
result.additionalSigned = additionalSigned
}
return [id, result]
},
)
.filter((v) => v[1] != null),
),
),
)
// Persist while app runs
extensionValues$.subscribe()
const extensionValue$ = state((id: string) =>
extensionValues$.pipe(
map(
(v) =>
v[id] as
| {
type: Uint8Array | "partial" | null
additionalSigned: Uint8Array | "partial" | null
}
| undefined,
),
),
)
const extSub$ = merge(
combineKeys(
metadata$.pipe(
map((v) => v.extrinsic.signedExtensions.map((ext) => ext.identifier)),
),
extensionValue$,
),
lookup$,
)
const ExtensionInput: FC<{
identifier: string
type: number
additionalSigned: number
}> = ({ identifier, type, additionalSigned }) => {
const value = useStateObservable(extensionValue$(identifier))
const [expanded, setExpanded] = useState(!!value)
const lookup = useStateObservable(lookup$)
if (!lookup) return null
const hasType = lookup(type).type != "void"
const hasAdditional = lookup(additionalSigned).type != "void"
if (!hasType && !hasAdditional) return null
const getValidity = (
value: Uint8Array<ArrayBufferLike> | "partial" | null,
) => (value === null ? 0 : value === "partial" ? 1 : 2)
const typeValidity = value && hasType ? getValidity(value.type) : 2
const additionalValidity =
value && hasAdditional ? getValidity(value.additionalSigned) : 2
const totalValidity = typeValidity + additionalValidity
return (
<li key={identifier} className="border rounded p-2 w-full">
<div
className={"flex items-center select-none cursor-pointer"}
onClick={() => setExpanded((e) => !e)}
>
<ExpandBtn expanded={expanded} />
<Circle
size={8}
strokeWidth={4}
className={twMerge(
"mr-1",
totalValidity === 4
? "text-green-600"
: totalValidity > 0
? "text-orange-600"
: "text-red-600",
)}
/>
<div className="text-foreground/80">{identifier}</div>
<div className="grow" />
{value != null ? (
<ActionButton
className="py-0"
onClick={(evt) => {
evt.stopPropagation()
clearExtension(identifier)
// There's an issue when clearing fixed-sized binary inputs externally
// And it's tough to solve without resetting the component.
// Something we can do as a quick fix then is just colapsing the extension.
setExpanded(false)
}}
>
Clear
</ActionButton>
) : null}
</div>
{expanded && (
<div>
{hasType ? (
<div className="py-2 max-h-[60svh] overflow-hidden flex flex-col justify-stretch">
<h3 className="text-muted-foreground font-bold">Value</h3>
<ExtensionValueInput id={identifier} part="type" type={type} />
</div>
) : null}
{hasAdditional ? (
<div className="py-2 max-h-[60svh] overflow-hidden flex flex-col justify-stretch">
<h3 className="text-muted-foreground font-bold">
Additional Signed
</h3>
<ExtensionValueInput
id={identifier}
part="additionalSigned"
type={additionalSigned}
/>
</div>
) : null}
</div>
)}
</li>
)
}
const ExtensionValueInput: FC<{
id: string
part: "type" | "additionalSigned"
type: number
}> = ({ id, type, part }) => {
const value = useStateObservable(extensionValue$(id))?.[part] ?? null
const lookup = useStateObservable(lookup$)
if (!lookup) return null
const shape = lookup(type)
const complexity = getTypeComplexity(shape)
return complexity === "inline" ? (
<div className="px-2">
<InlineLookupTypeEdit
type={type}
value={value}
onValueChange={(value) => setExtensionValue({ id, part, value })}
/>
</div>
) : (
<div className="py-2 max-h-[60svh] overflow-hidden flex flex-col justify-stretch">
<LookupTypeEdit
type={type}
value={value}
onValueChange={(value) => setExtensionValue({ id, part, value })}
tree={complexity === "tree"}
/>
</div>
)
}

View File

@@ -18,6 +18,9 @@ import { twMerge } from "tailwind-merge"
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(
@@ -36,9 +39,24 @@ const extrinsicProps$ = state(
),
)
const customExtensionsCount$ = state(
customSignedExtensions$.pipe(
map((v) => Object.keys(v).length),
map((v) =>
v ? (
<div className="px-1.5 rounded-full bg-chart-1 text-white text-sm">
{v}
</div>
) : null,
),
),
null,
)
export const Extrinsics = withSubscribe(
() => {
const [viewMode, setViewMode] = useState<"edit" | "json">("edit")
const [editingExtensions, setEditingExtensions] = useState(false)
const extrinsicProps = useStateObservable(extrinsicProps$)
const location = useLocation()
@@ -66,6 +84,9 @@ export const Extrinsics = withSubscribe(
}
}, [binaryValue])
if (editingExtensions)
return <CustomSignedExt onClose={() => setEditingExtensions(false)} />
return (
<div
className={twMerge(
@@ -98,7 +119,16 @@ export const Extrinsics = withSubscribe(
},
]}
/>
<ExtrinsicModal callData={binaryValue ?? undefined} />
<div className="flex flex-row items-center gap-2">
<ActionButton
className="text-foreground/70 flex items-center gap-1"
onClick={() => setEditingExtensions(true)}
>
{customExtensionsCount$}
<Settings />
</ActionButton>
<ExtrinsicModal callData={binaryValue ?? undefined} />
</div>
</div>
{viewMode === "edit" ? (

View File

@@ -15,8 +15,9 @@ import {
useSelectedAccount,
} from "polkahub"
import { FC, useState } from "react"
import { firstValueFrom } from "rxjs"
import { combineLatest, firstValueFrom } from "rxjs"
import { twMerge } from "tailwind-merge"
import { customSignedExtensions$ } from "../CustomSignedExt"
import { ExtensionProvider } from "./ExtensionProvider"
const SignAndSubmit: FC<{ callData: string; onClose: () => void }> = ({
@@ -33,9 +34,13 @@ const SignAndSubmit: FC<{ callData: string; onClose: () => void }> = ({
setIsSigning(true)
try {
const unsafeApi = await firstValueFrom(unsafeApi$)
const [unsafeApi, signedExt] = await firstValueFrom(
combineLatest([unsafeApi$, customSignedExtensions$]),
)
const tx = await unsafeApi.txFromCallData(Binary.fromHex(callData))
const signedExtrinsic = await tx.sign(account.signer)
const signedExtrinsic = await tx.sign(account.signer, {
customSignedExtensions: signedExt as any,
})
trackSignedTx(signedExtrinsic)
onClose()
} catch (ex) {