Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ jobs:
else
echo "run_db_types_validation=false" >> "$GITHUB_OUTPUT"
fi
if grep -qE '^apps/ui/|^packages/client/|^packages/contract/|^packages/ui-kit/' changed-files.txt; then
if grep -qE '^apps/ui/|^packages/chain-summaries/|^packages/client/|^packages/contract/|^packages/ui-kit/' changed-files.txt; then
echo "run_ui_validation=true" >> "$GITHUB_OUTPUT"
else
echo "run_ui_validation=false" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -797,6 +797,19 @@ jobs:
exit 1
fi

# Same drift-check shape as packages/client/packages/ui-kit above, same
# reason: packages/chain-summaries/dist/{index.js,index.cjs} are
# committed (see packages/chain-summaries/.gitignore) so apps/ui's
# Cloudflare Workers Builds deploy never depends on rebuilding a sibling
# workspace package first (#8525).
- name: Build packages/chain-summaries (drift check)
run: |
npm run build --workspace=packages/chain-summaries
if ! git diff --exit-code -- packages/chain-summaries/dist; then
echo "::error::packages/chain-summaries/dist is stale -- run 'npm run build --workspace=packages/chain-summaries' and commit the result."
exit 1
fi

# Same drift-check shape as packages/client/packages/ui-kit above
# (#1652's docs-freshness acceptance line): content/docs/api-reference/
# is entirely generated from public/metagraph/openapi.json (except the
Expand Down Expand Up @@ -841,6 +854,20 @@ jobs:
- name: Lint packages/ui-kit (app-logic import guardrail)
run: npm run lint --workspace=packages/ui-kit

- name: Typecheck packages/chain-summaries
run: npm run typecheck --workspace=packages/chain-summaries

- name: Test packages/chain-summaries
run: npm test --workspace=packages/chain-summaries

# Enforces #8525's core invariant: this package stays a real,
# standalone, framework-free library both apps/ui and workers/ import
# identically. no-restricted-imports (packages/chain-summaries/
# eslint.config.ts) fails on react or any import resolving into
# apps/ui/**.
- name: Lint packages/chain-summaries (app-logic import guardrail)
run: npm run lint --workspace=packages/chain-summaries

- name: Lint (ESLint + Prettier)
run: npm run lint --workspace=apps/ui && npm run format:check --workspace=apps/ui

Expand Down
1 change: 1 addition & 0 deletions apps/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"screenshots": "node tests/e2e/capture-pr-screenshots.ts"
},
"dependencies": {
"@jsonbored/chain-summaries": "*",
"@jsonbored/metagraphed": "*",
"@jsonbored/ui-kit": "*",
"@polkadot/api": "^16.5.6",
Expand Down
84 changes: 3 additions & 81 deletions apps/ui/src/lib/metagraphed/bytes.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,3 @@
// Raw byte-blob (Vec<u8> / BoundedVec<u8> / Bytes) shape reconciliation
// between D1 (fetch-events.py) and Postgres (indexer-rs) call_args (#4669,
// #4689). D1 is itself inconsistent for this Rust type family -- hex string
// for opaque payloads (PoW work, weight commits), UTF-8-decoded text for a
// few known textual fields (System.remark) -- while Postgres always emits a
// flat or newtype-wrapped integer array with no type metadata to tell them
// apart generically. This picks ONE canonical target per named field (a
// curated allowlist, defaulting to hex for everything not explicitly
// listed) rather than sniffing byte content, which risks exactly the kind
// of silent corruption already found in D1's own Ethereum.transact.input
// field (force-decoded as UTF-8/Latin1 today, producing mojibake with
// embedded control characters on every occurrence -- fixed here by NOT
// adding `input` to the textual allowlist, so it renders as clean hex
// instead of reproducing that bug).

function isIntArray(value: unknown): value is number[] {
return (
Array.isArray(value) &&
value.every((n) => typeof n === "number" && Number.isInteger(n) && n >= 0 && n <= 255)
);
}

/** Recursively peels indexer-rs's newtype-wrap array layers -- while the
* value is a single-element array wrapping another array, unwrap one level
* -- until it bottoms out at a flat array of byte values, or returns null
* if it never resolves to one. Depth-agnostic: handles both a flat `[u8; N]`
* field with zero wraps (e.g. Multisig's raw `call_hash`) and a
* newtype-wrapped `Hash`/`H256`/`BoundedVec<u8>` field with one or more
* wraps (e.g. a `commit_hash`, or `commit`/`ciphertext`'s variable-length
* payload) with the same function, rather than a hardcoded wrap-depth
* assumption tuned to only one of the two. */
export function unwrapByteArray(value: unknown): number[] | null {
let current = value;
while (Array.isArray(current) && current.length === 1 && Array.isArray(current[0])) {
current = current[0];
}
return isIntArray(current) ? current : null;
}

/** Canonical lowercase `0x`-prefixed hex, matching D1's existing convention
* for opaque byte blobs. */
export function bytesToHex(bytes: number[]): string {
return "0x" + bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
}

/** `(callModule, callFunction, fieldName)` triples D1 renders as UTF-8 text
* rather than hex -- verified against real production data (System.
* remark_with_event, block 8512299/extrinsic_index 12: D1 `remark:
* "module-test-5f758613"`, Postgres the same bytes undecoded). NOT YET
* covered: SubtensorModule.set_identity/set_subnet_identity's textual
* fields (name/url/discord/description-style) -- D1's exact behavior and
* field names for these are unverified (no occurrence in either store's
* current retention window as of this writing), so they default to hex
* below rather than guessing; add them here once confirmed against a real
* example. */
const TEXTUAL_FIELDS = new Set(["System.remark.remark", "System.remark_with_event.remark"]);

/** Decodes a byte-blob call-arg field to its canonical representation: UTF-8
* text for the small, verified allowlist of genuinely textual fields above,
* hex for everything else (the safe default for opaque payloads, and the
* fix for D1's own Ethereum.transact.input mojibake bug -- that field is
* deliberately NOT in the allowlist). */
export function decodeBytesField(
callModule: string | null | undefined,
callFunction: string | null | undefined,
fieldName: string,
bytes: number[],
): string {
const key = `${callModule ?? ""}.${callFunction ?? ""}.${fieldName}`;
if (TEXTUAL_FIELDS.has(key)) {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bytes));
} catch {
// Malformed UTF-8 for a field expected to be textual -- fall back to
// hex rather than producing mojibake (the exact class of bug this
// module exists to avoid reproducing).
return bytesToHex(bytes);
}
}
return bytesToHex(bytes);
}
// Moved to @jsonbored/chain-summaries (#8525). Compatibility shim only (no
// logic of its own) -- see chain-summaries.ts's identical note.
export { unwrapByteArray, bytesToHex, decodeBytesField } from "@jsonbored/chain-summaries";
80 changes: 3 additions & 77 deletions apps/ui/src/lib/metagraphed/chain-event-args.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,3 @@
import { encodeSs58 } from "./ss58";

// #3984: chain-event args arrive as decoded SCALE values, where account ids are
// raw 32-byte number arrays. Rendered verbatim (`JSON.stringify`) they read like
// `{"who":[[109,111,100,101,...]]}` — unreadable and unbounded. This walks the
// value and rewrites 32-byte arrays into a human-readable form: an SS58 address
// when the field name marks it as an account, otherwise a 0x-hex string (so a
// 32-byte hash isn't mislabelled as an address). Everything else is untouched.

const ACCOUNT_KEYS = new Set([
"who",
"account",
"account_id",
"accountid",
"coldkey",
"hotkey",
"from",
"to",
"dest",
"destination",
"source",
"delegate",
"nominator",
"owner",
"target",
"validator",
"address",
]);

function isByteArray(v: unknown, len: number): v is number[] {
return (
Array.isArray(v) &&
v.length === len &&
v.every((n) => typeof n === "number" && Number.isInteger(n) && n >= 0 && n <= 255)
);
}

function toHex(bytes: number[]): string {
return "0x" + bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
}

function decode(value: unknown, keyHint: string | undefined): unknown {
if (isByteArray(value, 32)) {
if (keyHint && ACCOUNT_KEYS.has(keyHint.toLowerCase())) {
return encodeSs58(Uint8Array.from(value)) ?? toHex(value);
}
return toHex(value);
}
// Arrays inherit the parent key hint (e.g. `who: [<accountId bytes>]`).
if (Array.isArray(value)) return value.map((item) => decode(item, keyHint));
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(value as Record<string, unknown>))
out[k] = decode(val, k);
return out;
}
return value;
}

/** Decode account ids inside a chain-event args value (leaves everything else as-is). */
export function decodeChainEventArgs(args: unknown): unknown {
return decode(args, undefined);
}

/**
* Human-readable one-line string for a chain event's args, with account-id byte
* arrays decoded to SS58 (or 0x-hex where the field isn't an account). Callers
* pair this with a truncating cell + a copy button (see blocks.$ref.tsx).
*/
export function formatChainEventArgs(args: unknown): string {
if (args == null) return "—";
try {
return JSON.stringify(decodeChainEventArgs(args)) ?? "—";
} catch {
return "[Unserializable value]";
}
}
// Moved to @jsonbored/chain-summaries (#8525). Compatibility shim only (no
// logic of its own) -- see chain-summaries.ts's identical note.
export { decodeChainEventArgs, formatChainEventArgs } from "@jsonbored/chain-summaries";
128 changes: 8 additions & 120 deletions apps/ui/src/lib/metagraphed/chain-event-summary.ts
Original file line number Diff line number Diff line change
@@ -1,120 +1,8 @@
import { decodeChainEventArgs } from "./chain-event-args";

// #8253: the /chain events feed rendered every row as just
// `Pallet.Method · #8,705,088 · 1m ago` — no amounts, no addresses, no
// subnet — even though the API has returned fully-decoded `args` all along.
// This turns those args into the structured fields a row needs to answer
// who / what / how-much / where without clicking through.

const RAO_PER_TAO = 1e9;

/** Account-ish arg keys, in the order a row should prefer them for "from". */
const FROM_KEYS = ["from", "source", "who", "account", "coldkey", "hotkey", "sender"];
/** Account-ish arg keys, in the order a row should prefer them for "to". */
const TO_KEYS = ["to", "dest", "destination", "target", "delegate", "validator", "recipient"];
/** Amount-ish arg keys, in preference order. */
const AMOUNT_KEYS = [
"amount",
"amount_tao",
"value",
"stake",
"actual_fee",
"fee",
"tip",
"balance",
];

/**
* The high-volume plumbing events that dominate an unfiltered feed. These are
* the same rows block detail already collapses by default: they fire on
* essentially every extrinsic and carry no information a reader is looking
* for when scanning "what happened on chain".
*
* Measured live 2026-07-26 against the newest 100 events: 68% of the feed was
* these three. Hiding them by default is what makes the feed readable.
*/
export const NOISE_EVENTS = new Set([
"System.ExtrinsicSuccess",
"System.ExtrinsicFailed",
"TransactionPayment.TransactionFeePaid",
]);

export function isNoiseEvent(pallet: string | null, method: string | null): boolean {
if (!pallet || !method) return false;
return NOISE_EVENTS.has(`${pallet}.${method}`);
}

/**
* SCALE-decoded args arrive with scalars wrapped in single-element arrays
* (verified live: `netuid: [102]`, `actual_fee: [0]`, `tip: [0]`), so a naive
* read of these fields yields an array where a number is expected.
*/
function unwrap(value: unknown): unknown {
return Array.isArray(value) && value.length === 1 ? value[0] : value;
}

function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}

/** First present key from `keys`, unwrapped. Case-insensitive on arg names. */
function pick(args: Record<string, unknown>, keys: string[]): unknown {
const lower = new Map(Object.entries(args).map(([k, v]) => [k.toLowerCase(), v]));
for (const key of keys) {
if (lower.has(key)) {
const value = unwrap(lower.get(key));
if (value != null) return value;
}
}
return undefined;
}

/** An ss58 address is a base58 string; a 0x-hex hash is not an address. */
function asAddress(value: unknown): string | null {
return typeof value === "string" && value.length > 40 && !value.startsWith("0x") ? value : null;
}

function asNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
// Large rao amounts can arrive as numeric strings to survive JSON precision.
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
return Number(value);
}
return null;
}

export interface ChainEventSummary {
/** τ amount, already converted from rao. Null when the event carries none. */
amountTao: number | null;
from: string | null;
to: string | null;
netuid: number | null;
}

/**
* Extract the display fields for one chain-event row from its decoded args.
*
* Amounts are assumed to be rao (the chain's own base unit, 1e9 per TAO) --
* every balance-shaped field in these events is rao on the wire. A field that
* isn't a finite number is reported as null rather than guessed at.
*/
export function summarizeChainEvent(args: unknown): ChainEventSummary {
const decoded = asRecord(decodeChainEventArgs(args));
if (!decoded) return { amountTao: null, from: null, to: null, netuid: null };

const rawAmount = asNumber(pick(decoded, AMOUNT_KEYS));
const netuid = asNumber(pick(decoded, ["netuid", "net_uid", "subnet"]));
const from = asAddress(pick(decoded, FROM_KEYS));
const to = asAddress(pick(decoded, TO_KEYS));

return {
amountTao: rawAmount == null ? null : rawAmount / RAO_PER_TAO,
from,
// A single-account event (e.g. Commitments.Commitment's `who`) must not
// render the same address as both sides of a transfer.
to: to && to !== from ? to : null,
netuid: netuid == null ? null : Math.trunc(netuid),
};
}
// Moved to @jsonbored/chain-summaries (#8525). Compatibility shim only (no
// logic of its own) -- see chain-summaries.ts's identical note.
export {
summarizeChainEvent,
isNoiseEvent,
NOISE_EVENTS,
type ChainEventSummary,
} from "@jsonbored/chain-summaries";
Loading
Loading