Skip to content

Commit 2b232ef

Browse files
authored
feat: add dev contract inspector at /dev/inspect/[id] (#178)
- New page at src/app/dev/inspect/[id]/page.tsx - Calls splitClient.getInvoice(id) and renders raw invoice JSON with syntax highlighting (keys, strings, numbers, booleans) - Fetches contract WASM hash via Stellar RPC getLedgerEntries - Displays storage key format (Invoice:<id>) - Copy JSON button copies raw data to clipboard - Page blocked with clear error when NEXT_PUBLIC_DEV_MODE != true - Mobile responsive (works at 375px)
1 parent f1ee465 commit 2b232ef

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

src/app/dev/inspect/[id]/page.tsx

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"use client";
2+
3+
import { useEffect, useState, useCallback } from "react";
4+
import { useParams } from "next/navigation";
5+
import { splitClient } from "@/lib/stellar";
6+
import type { Invoice } from "@stellar-split/sdk";
7+
8+
const DEV_MODE = process.env.NEXT_PUBLIC_DEV_MODE === "true";
9+
10+
type InspectData = {
11+
invoice: Invoice;
12+
wasmHash: string | null;
13+
storageKey: string;
14+
};
15+
16+
function replacer(_key: string, value: unknown) {
17+
return typeof value === "bigint" ? value.toString() : value;
18+
}
19+
20+
function JsonBlock({ data }: { data: unknown }) {
21+
const json = JSON.stringify(data, replacer, 2);
22+
23+
function highlight(raw: string) {
24+
return raw
25+
.replace(/&/g, "&amp;")
26+
.replace(/</g, "&lt;")
27+
.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, (match) => {
28+
let cls = "text-yellow-300"; // number
29+
if (/^"/.test(match)) {
30+
cls = /:$/.test(match) ? "text-indigo-300" : "text-green-300"; // key or string
31+
} else if (/true|false/.test(match)) {
32+
cls = "text-blue-300";
33+
} else if (/null/.test(match)) {
34+
cls = "text-gray-500";
35+
}
36+
return `<span class="${cls}">${match}</span>`;
37+
});
38+
}
39+
40+
return (
41+
<pre
42+
className="bg-gray-900 rounded-lg p-4 text-xs overflow-x-auto leading-relaxed"
43+
dangerouslySetInnerHTML={{ __html: highlight(json) }}
44+
/>
45+
);
46+
}
47+
48+
function CopyButton({ data }: { data: unknown }) {
49+
const [copied, setCopied] = useState(false);
50+
51+
async function copy() {
52+
await navigator.clipboard.writeText(JSON.stringify(data, replacer, 2));
53+
setCopied(true);
54+
setTimeout(() => setCopied(false), 2000);
55+
}
56+
57+
return (
58+
<button
59+
onClick={copy}
60+
className="text-xs px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 transition-colors"
61+
>
62+
{copied ? "✓ Copied" : "Copy JSON"}
63+
</button>
64+
);
65+
}
66+
67+
async function fetchWasmHash(contractId: string, rpcUrl: string): Promise<string | null> {
68+
try {
69+
const { xdr, rpc } = await import("@stellar/stellar-sdk");
70+
const server = new rpc.Server(rpcUrl, { allowHttp: true });
71+
const contractKey = xdr.LedgerKey.contractData(
72+
new xdr.LedgerKeyContractData({
73+
contract: new xdr.ScAddress({
74+
type: xdr.ScAddressType.scAddressTypeContract(),
75+
contractId: Buffer.from(contractId.replace(/^C/, ""), "base32"),
76+
}),
77+
key: xdr.ScVal.scvLedgerKeyContractInstance(),
78+
durability: xdr.ContractDataDurability.persistent(),
79+
})
80+
);
81+
const result = await server.getLedgerEntries(contractKey);
82+
const entry = result.entries?.[0];
83+
if (!entry) return null;
84+
const data = entry.val.contractData().val();
85+
if (data.switch() !== xdr.ScValType.scvContractInstance()) return null;
86+
const instance = data.instance();
87+
const wasmHash = instance.executable().wasmHash();
88+
return wasmHash ? Buffer.from(wasmHash).toString("hex") : null;
89+
} catch {
90+
return null;
91+
}
92+
}
93+
94+
export default function InspectPage() {
95+
const { id } = useParams<{ id: string }>();
96+
const [data, setData] = useState<InspectData | null>(null);
97+
const [error, setError] = useState<string | null>(null);
98+
const [loading, setLoading] = useState(true);
99+
100+
const load = useCallback(async () => {
101+
setLoading(true);
102+
setError(null);
103+
try {
104+
const invoice = await splitClient.getInvoice(id);
105+
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL ?? "https://soroban-testnet.stellar.org";
106+
const contractId = process.env.NEXT_PUBLIC_CONTRACT_ID ?? "";
107+
const wasmHash = await fetchWasmHash(contractId, rpcUrl);
108+
const storageKey = `Invoice:${id}`;
109+
setData({ invoice, wasmHash, storageKey });
110+
} catch (e) {
111+
setError(e instanceof Error ? e.message : "Failed to fetch invoice");
112+
} finally {
113+
setLoading(false);
114+
}
115+
}, [id]);
116+
117+
useEffect(() => { load(); }, [load]);
118+
119+
if (!DEV_MODE) {
120+
return (
121+
<main className="max-w-xl mx-auto px-4 py-20 text-center">
122+
<p className="text-red-400 text-sm">Dev mode is disabled. Set <code className="bg-gray-800 px-1 rounded">NEXT_PUBLIC_DEV_MODE=true</code> to access this page.</p>
123+
</main>
124+
);
125+
}
126+
127+
return (
128+
<main className="max-w-3xl mx-auto w-full px-4 sm:px-6 py-10 overflow-x-hidden">
129+
<div className="flex flex-wrap items-center gap-2 mb-6">
130+
<span className="text-xs bg-yellow-900 text-yellow-300 px-2 py-0.5 rounded-full font-semibold">DEV MODE</span>
131+
<h1 className="text-xl font-bold">Contract Inspector — Invoice #{id}</h1>
132+
</div>
133+
134+
{loading && <p className="text-gray-400 text-sm">Loading…</p>}
135+
{error && <p className="text-red-400 text-sm" role="alert">{error}</p>}
136+
137+
{data && (
138+
<div className="flex flex-col gap-6">
139+
<section>
140+
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
141+
<h2 className="text-sm font-semibold text-gray-300">Invoice JSON</h2>
142+
<CopyButton data={data.invoice} />
143+
</div>
144+
<JsonBlock data={data.invoice} />
145+
</section>
146+
147+
<section>
148+
<h2 className="text-sm font-semibold text-gray-300 mb-2">Contract WASM Hash</h2>
149+
<p className="font-mono text-xs break-all bg-gray-900 rounded-lg px-4 py-3 text-green-300">
150+
{data.wasmHash ?? <span className="text-gray-500">unavailable</span>}
151+
</p>
152+
</section>
153+
154+
<section>
155+
<h2 className="text-sm font-semibold text-gray-300 mb-2">Storage Key</h2>
156+
<p className="font-mono text-xs bg-gray-900 rounded-lg px-4 py-3 text-indigo-300">
157+
{data.storageKey}
158+
</p>
159+
</section>
160+
</div>
161+
)}
162+
</main>
163+
);
164+
}

0 commit comments

Comments
 (0)