Sitelet https://github.com/Arkiv-Network/arkiv-chain-indexer/pull/114/files
Skip to content
Merged
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
24 changes: 24 additions & 0 deletions frontend/entityLifetime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { isPermanentEntity, isPermanentLifetime } from "./src/entityLifetime";

describe("permanent entity lifetimes", () => {
test("requires more than 100 years at the chain's block duration", () => {
for (const secondsPerBlock of [0.5, 1, 2, 12]) {
const centuryBlocks = (100 * 365 * 24 * 60 * 60) / secondsPerBlock;
expect(isPermanentLifetime(centuryBlocks - 1, secondsPerBlock)).toBe(false);
expect(isPermanentLifetime(centuryBlocks, secondsPerBlock)).toBe(false);
expect(isPermanentLifetime(centuryBlocks + 1, secondsPerBlock)).toBe(true);
}
});

test("does not classify unknown, invalid, or nonpositive lifetimes as permanent", () => {
for (const invalid of [0, -1, NaN, Infinity]) {
expect(isPermanentLifetime(invalid, 2)).toBe(false);
expect(isPermanentLifetime(4_000_000_000, invalid)).toBe(false);
}
expect(isPermanentEntity(null, 4_000_000_000, 2)).toBe(false);
expect(isPermanentEntity(0, null, 2)).toBe(false);
expect(isPermanentEntity(0, 4_000_000_000, null)).toBe(false);
expect(isPermanentEntity(200, 100, 2)).toBe(false);
});
});
68 changes: 68 additions & 0 deletions frontend/entityResults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { EntityResults } from "./src/EntityResults";
import { decodeEntity, type EntityRecord } from "./src/dataQuery";
import type { BlockTiming } from "./src/dataRpc";

const timing: BlockTiming = {
currentBlock: 100,
currentBlockTime: 1_700_000_000,
blockDurationSeconds: 2,
};
const centuryBlocks = (100 * 365 * 24 * 60 * 60) / timing.blockDurationSeconds;

function renderEntity(expiresAt: string | number | null, blockTiming: BlockTiming | null = timing, createdAt = 0): string {
const entity: EntityRecord = decodeEntity({ key: `0x${"ab".repeat(32)}`, createdAt, expiresAt });
return renderToStaticMarkup(createElement(EntityResults, {
executedQuery: "*",
entities: [entity],
loadedCount: 1,
cursor: null,
blockNumber: 100,
timing: blockTiming,
durationMs: 1,
running: null,
error: null,
expirationFilter: "all",
timeZone: "UTC",
onLoadMore: () => {},
onQueryOnly: () => {},
onAddToQuery: () => {},
onLocationChange: () => {},
}));
}

describe("entity expiry display", () => {
test("renders uint64-max expiry without constructing an out-of-range date", () => {
const html = renderEntity("0xffffffffffffffff");
expect(html).toContain("Permanent");
expect(html).not.toContain("% left");
});

test("uses total lifetime and hides the countdown above 100 years", () => {
const html = renderEntity(centuryBlocks + 1);
expect(html).toContain("Permanent");
expect(html).not.toContain("% left");
});

test("keeps expiry dates and progress at exactly 100 years or less", () => {
for (const expiresAt of [200, centuryBlocks - 1, centuryBlocks]) {
const html = renderEntity(expiresAt);
expect(html).not.toContain("Permanent");
expect(html).toContain("% left");
}
});

test("subtracts the creation block instead of treating expiry as a duration", () => {
const html = renderEntity(centuryBlocks + 200, { ...timing, currentBlock: centuryBlocks + 100 }, centuryBlocks);
expect(html).not.toContain("Permanent");
expect(html).toContain("% left");
});

test("handles missing timing and expiry without inventing a lifetime", () => {
expect(renderEntity(centuryBlocks + 1, null)).not.toContain("Permanent");
expect(renderEntity(null)).not.toContain("Permanent");
expect(renderEntity("0xffffffffffffffff", null)).toContain("Permanent");
});
});
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gas-price-tracker-frontend",
"version": "0.5.20",
"version": "0.5.21",
"private": true,
"type": "module",
"scripts": {
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/EntityResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type EntityRecord,
} from "./dataQuery";
import type { BlockTiming } from "./dataRpc";
import { isPermanentEntity } from "./entityLifetime";
import { fmtDate, fmtInteger } from "./format";
import { entityDetailHref, shouldHandleClientNavigation, writeEntityPermalink } from "./permalinks";
import { AddressCell, copyText } from "./TransactionsView";
Expand Down Expand Up @@ -182,6 +183,7 @@ function EntityCard({
)
: [];
const creatorDiffers = entity.creator !== null && entity.owner !== null && entity.creator.toLowerCase() !== entity.owner.toLowerCase();
const permanent = isPermanentEntity(entity.createdAt, entity.expiresAt, timing?.blockDurationSeconds ?? null);

return (
<Card className="gap-0 overflow-hidden py-0">
Expand Down Expand Up @@ -261,11 +263,13 @@ function EntityCard({
</MetaRow>
) : null}
<MetaRow label="Expires">
<BlockStamp block={entity.expiresAt} timing={timing} nowMs={nowMs} timeZone={timeZone} onLocationChange={onLocationChange} />
{permanent ? "Permanent" : (
<BlockStamp block={entity.expiresAt} timing={timing} nowMs={nowMs} timeZone={timeZone} onLocationChange={onLocationChange} />
)}
</MetaRow>
</div>

{entity.createdAt !== null && entity.expiresAt !== null && timing ? (
{!permanent && entity.createdAt !== null && entity.expiresAt !== null && timing ? (
<LifetimeBar createdAt={entity.createdAt} expiresAt={entity.expiresAt} timing={timing} nowMs={nowMs} />
) : null}
</div>
Expand Down
27 changes: 15 additions & 12 deletions frontend/src/EntityView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { fetchEntityByKey, type EntityByKeyResponse, type StoredEntityOperation } from "./api";
import { isPermanentEntity, isPermanentLifetime } from "./entityLifetime";
import { fmtBytes, fmtDurationSeconds, fmtInteger } from "./format";
import { PageBreadcrumbs } from "./PageBreadcrumbs";
import { writeEntityPermalink } from "./permalinks";
Expand Down Expand Up @@ -247,7 +248,9 @@ function EntityDetail({
<AddressCell address={genesis.creator} />
</Row>
<Row label="Expires" title={`Absolute expiry block ${genesis.expiresAt}`}>
{genesis.expiresAt === NEVER_EXPIRES ? "never" : `block ${fmtInteger(Number(genesis.expiresAt))}`}
{isPermanentEntity(0, Number(genesis.expiresAt), blockTimeMs / 1000)
? "Permanent"
: `block ${fmtInteger(genesis.expiresAt)}`}
</Row>
{genesis.creationFlags ? (
<Row label="Flags">{describeCreationFlags(genesis.creationFlags)}</Row>
Expand Down Expand Up @@ -289,9 +292,9 @@ function EntityDetail({
label="Expires"
title={`Set by the ${latestExpiry.operation} in block ${latestExpiry.blockNumberDecimal}`}
>
{fmtInteger(latestExpiry.expiresAtBlocks)} blocks (~
{fmtDurationSeconds((latestExpiry.expiresAtBlocks * blockTimeMs) / 1000)}) from block{" "}
{latestExpiry.blockNumberDecimal}
{isPermanentLifetime(latestExpiry.expiresAtBlocks, blockTimeMs / 1000)
? "Permanent"
: `${fmtInteger(latestExpiry.expiresAtBlocks)} blocks (~${fmtDurationSeconds((latestExpiry.expiresAtBlocks * blockTimeMs) / 1000)}) from block ${latestExpiry.blockNumberDecimal}`}
</Row>
) : null}
</dl>
Expand Down Expand Up @@ -384,7 +387,9 @@ function EntityDetail({
{operation.payloadSizeBytes > 0 ? fmtBytes(operation.payloadSizeBytes) : "—"}
</TableCell>
<TableCell>
{operation.expiresAtBlocks > 0 ? `${fmtInteger(operation.expiresAtBlocks)} blocks` : "—"}
{isPermanentLifetime(operation.expiresAtBlocks, blockTimeMs / 1000)
? "Permanent"
: operation.expiresAtBlocks > 0 ? `${fmtInteger(operation.expiresAtBlocks)} blocks` : "—"}
</TableCell>
<TableCell>{operation.newOwner ? <AddressCell address={operation.newOwner} /> : "—"}</TableCell>
</TableRow>
Expand All @@ -396,13 +401,6 @@ function EntityDetail({
);
}

/**
* Lifecycle badge derived from the newest stored operation: a trailing delete
* means the entity is gone; a trailing expire means the chain reaped it;
* anything else leaves it active as far as the stored history knows.
*/
const NEVER_EXPIRES = "18446744073709551615";

function describeCreationFlags(flags: number): string {
const names: string[] = [];
if (flags & 1) names.push("readonly");
Expand All @@ -412,6 +410,11 @@ function describeCreationFlags(flags: number): string {
return names.join(", ");
}

/**
* Lifecycle badge derived from the newest stored operation: a trailing delete
* means the entity is gone; a trailing expire means the chain reaped it;
* anything else leaves it active as far as the stored history knows.
*/
function lifecycleInfo(latest: StoredEntityOperation): { label: string; tone: StatusTone } {
if (latest.operation === "delete") return { label: "Deleted", tone: "fail" };
if (latest.operation === "expire") return { label: "Expired", tone: "fail" };
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/TransactionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export { CopyButton } from "@/components/copy-cell";
import { OpBadge, StatusBadge, type StatusTone } from "@/components/op-badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { isPermanentLifetime } from "./entityLifetime";
import {
fmtBytes,
fmtDate,
Expand Down Expand Up @@ -500,7 +501,9 @@ function OperationCard({
) : null}
{operation.expiresAtBlocks > 0 ? (
<Row label="Expires">
{fmtInteger(operation.expiresAtBlocks)} blocks (~{fmtDurationSeconds(expirySeconds)})
{isPermanentLifetime(operation.expiresAtBlocks, blockTimeMs / 1000)
? "Permanent"
: `${fmtInteger(operation.expiresAtBlocks)} blocks (~${fmtDurationSeconds(expirySeconds)})`}
</Row>
) : null}
{operation.newOwner ? (
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/entityLifetime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Display convention: a year is 365 days, matching the relative-date formatter.
const PERMANENT_LIFETIME_SECONDS = 100 * 365 * 24 * 60 * 60;
// Query results already decode block quantities as numbers.
const NEVER_EXPIRES = Number(0xffffffffffffffffn);

export function isPermanentLifetime(lifetimeBlocks: number, blockDurationSeconds: number): boolean {
return Number.isFinite(lifetimeBlocks) && Number.isFinite(blockDurationSeconds) &&
lifetimeBlocks > 0 && blockDurationSeconds > 0 &&
lifetimeBlocks * blockDurationSeconds > PERMANENT_LIFETIME_SECONDS;
}

export function isPermanentEntity(
createdAt: number | null,
expiresAt: number | null,
blockDurationSeconds: number | null,
): boolean {
if (expiresAt === NEVER_EXPIRES) return true;
if (createdAt === null || expiresAt === null || blockDurationSeconds === null) return false;
return isPermanentLifetime(expiresAt - createdAt, blockDurationSeconds);
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gas-price-tracker",
"version": "0.5.20",
"version": "0.5.21",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading