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:
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
268
src/pages/Extrinsics/CustomSignedExt.tsx
Normal file
268
src/pages/Extrinsics/CustomSignedExt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user