Skip to content
Open
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
7 changes: 5 additions & 2 deletions applications/viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ A live Cardano explorer for your Yaci DevKit devnet, or any [Yaci Store](https:/
- **Script Lab:** in your browser, with the [julc](https://github.com/bloxbean/julc) WebAssembly engine:
- decode any Plutus script into searchable UPLC, see the builtins it uses and verify its hash;
- re-run every script of a transaction with the cost model of its epoch, compare the budget with what was declared
on chain, and see the exact ScriptContext the validator received.
on chain, and see the exact ScriptContext the validator received;
- step through any run in a time-travel debugger: step, over, out, continue and back, breakpoints on lines,
builtins, traces and failures, environment, frames and budget per step, and links to an exact step
(`/transactions/<hash>/debug/<purpose>/<index>?step=N`).
- Addresses, stake accounts, assets, epochs and protocol parameters, pools, rewards, certificates, Ada pots and
Conway governance.
- Light and dark themes; works on a phone.
Expand Down Expand Up @@ -71,7 +74,7 @@ into `static/julc/` (git-ignored). The engine is only downloaded by the browser
release's `julc-wasm-<version>-SHA256SUMS.txt`.
- The engine needs WebAssembly GC and exception handling (exnref): current Chrome, Edge, Firefox and Safari. Other
browsers get a message instead. Playwright's bundled Chromium is too old, so run the Script Lab test with Chrome:
`SCRIPT_LAB_HASH=<plutus script hash> SCRIPT_LAB_TX=<tx with redeemers> PW_CHANNEL=chrome npx playwright test script-lab`.
`SCRIPT_LAB_HASH=<plutus script hash> SCRIPT_LAB_TX=<tx with redeemers> SCRIPT_LAB_RUN=<purpose/index> PW_CHANNEL=chrome npx playwright test script-lab`.
- Re-runs rebuild the transaction from Yaci Store data (`src/lib/julc/replay/`). Until julc builds contexts exactly
as the ledger does ([julc#244](https://github.com/bloxbean/julc/issues/244)), the viewer corrects julc's context
(asset order, the other redeemers, datums given by hash, Plutus V1 shapes) and runs the script again; each result
Expand Down
66 changes: 61 additions & 5 deletions applications/viewer/src/lib/julc/CodeView.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { tokenize, splitMatches, type CodeLanguage, type TokenKind } from './highlight';
import type { Span } from './types';
import { copyText } from '$lib/ui/toast.svelte';
import Search from '@lucide/svelte/icons/search';
import ChevronUp from '@lucide/svelte/icons/chevron-up';
Expand All @@ -12,15 +13,30 @@
/*
* A read-only code viewer for large generated code (UPLC of a 16 KB script is tens of thousands of lines).
* Only the lines on screen are rendered and highlighted. Search jumps between matching lines.
* For the debugger: `active` highlights the current term's lines and keeps them in view, and with `ontoggle`
* the line numbers set and clear breakpoints.
*/
let {
text,
language = 'plain',
filename = 'code.txt',
height = 520,
label = 'Code',
query = $bindable('')
}: { text: string; language?: CodeLanguage; filename?: string; height?: number; label?: string; query?: string } = $props();
query = $bindable(''),
active = null,
breakpoints = undefined,
ontoggle = undefined
}: {
text: string;
language?: CodeLanguage;
filename?: string;
height?: number;
label?: string;
query?: string;
active?: Span | null;
breakpoints?: Set<number>;
ontoggle?: (line: number) => void;
} = $props();

const LINE = 20;
const OVERSCAN = 30;
Expand Down Expand Up @@ -59,6 +75,27 @@
if (hits.length) untrack(() => jump(0));
});

let probe = $state<HTMLSpanElement>();

// Keep the debugger's current term in view: scroll only when it is off screen (or near the edges), vertically and,
// as UPLC is deeply indented, horizontally too.
$effect(() => {
const line = active?.startLine;
const column = active?.startColumn ?? 1;
if (!line || !viewport) return;
untrack(() => {
const v = viewport!;
const y = (line - 1) * LINE;
if (y < v.scrollTop + LINE * 2 || y > v.scrollTop + viewHeight - LINE * 3) v.scrollTop = Math.max(0, y - viewHeight / 3);
const ch = probe ? probe.getBoundingClientRect().width / 10 : 7.5;
const gutterPx = (gutter + 3) * ch + 36;
const x = (column - 1) * ch;
if (x < v.scrollLeft + ch * 4 || x > v.scrollLeft + v.clientWidth - gutterPx - ch * 30) v.scrollLeft = Math.max(0, x - ch * 8);
});
});

const inActive = (n: number) => !!active && n + 1 >= active.startLine && n + 1 <= active.endLine;

function onKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
Expand Down Expand Up @@ -123,11 +160,30 @@
aria-label={label}
tabindex="-1"
>
<div style="height:{lines.length * LINE + 16}px; min-width:calc({widest + gutter + 4}ch + 2rem)">
<span bind:this={probe} class="invisible absolute" aria-hidden="true">0000000000</span>
<div style="height:{lines.length * LINE + 16}px; min-width:calc({widest + gutter + 4}ch + 3rem)">
<div style="transform:translateY({first * LINE + 8}px)">
{#each visible as line (line.n)}
<div class="flex h-5 {hitSet.has(line.n) ? (hits[current] === line.n ? 'bg-amber/20' : 'bg-amber/[0.07]') : ''}">
<span class="sticky left-0 shrink-0 select-none border-r border-line bg-bg-2 pl-3 pr-3 text-right text-faint" style="width:calc({gutter}ch + 1.5rem)">{line.n + 1}</span>
{@const isActive = inActive(line.n)}
{@const isStart = isActive && active?.startLine === line.n + 1}
{@const isBreak = breakpoints?.has(line.n + 1)}
<div
class="flex h-5 {isStart ? 'bg-brand/20' : isActive ? 'bg-brand/[0.07]' : hitSet.has(line.n) ? (hits[current] === line.n ? 'bg-amber/20' : 'bg-amber/[0.07]') : ''}"
data-line={line.n + 1}
>
{#if ontoggle}
<button
class="sticky left-0 flex shrink-0 select-none items-center justify-end gap-1.5 border-r border-line bg-bg-2 pl-2 pr-3 text-right {isStart ? 'text-brand' : 'text-faint'} hover:text-ink"
style="width:calc({gutter}ch + 2.25rem)"
title={isBreak ? 'Remove breakpoint' : 'Break on this line'}
aria-label="{isBreak ? 'Remove breakpoint at' : 'Break at'} line {line.n + 1}"
onclick={() => ontoggle(line.n + 1)}
>
<span class="h-2 w-2 shrink-0 rounded-full {isBreak ? 'bg-danger' : 'bg-transparent'}"></span>{line.n + 1}
</button>
{:else}
<span class="sticky left-0 shrink-0 select-none border-r border-line bg-bg-2 pl-3 pr-3 text-right text-faint" style="width:calc({gutter}ch + 1.5rem)">{line.n + 1}</span>
{/if}
<span class="whitespace-pre pl-3 pr-4 text-ink">{#each line.tokens as t}{#if needle.length >= 2 && hitSet.has(line.n)}{#each splitMatches(t.text, needle) as part}<span class="{tone[t.kind]} {part.hit ? 'rounded-sm bg-amber/40 text-ink' : ''}">{part.text}</span>{/each}{:else}<span class={tone[t.kind]}>{t.text}</span>{/if}{/each}</span>
</div>
{/each}
Expand Down
102 changes: 17 additions & 85 deletions applications/viewer/src/lib/julc/ScriptRuns.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
import ScriptCard from '$lib/tx/ScriptCard.svelte';
import PlutusView from '$lib/ui/PlutusView.svelte';
import { julc, wasmSupported } from './engine.svelte';
import { buildRuns, purposeOf, type RunPlan, type StoreScriptRun } from './replay/build';
import { loadReplayInput, type PageTx } from './replay/load';
import { TAG_ORDER, fixContext, ownRedeemer } from './replay/fix-context';
import type { Evaluation } from './types';
import { purposeOf, type RunPlan, type StoreScriptRun } from './replay/build';
import type { PageTx } from './replay/load';
import { prepareRuns, runAll, type RunResult } from './replay/run';
import Sparkles from '@lucide/svelte/icons/sparkles';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import CircleCheck from '@lucide/svelte/icons/circle-check';
Expand All @@ -17,6 +16,7 @@
import BadgeCheck from '@lucide/svelte/icons/badge-check';
import Info from '@lucide/svelte/icons/info';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import Bug from '@lucide/svelte/icons/bug';

/*
* The transaction's script runs, each re-executable in the browser with julc: the transaction is rebuilt from
Expand All @@ -30,15 +30,7 @@
maxSteps
}: { page: PageTx; invalid?: boolean; maxMem: number; maxSteps: number } = $props();

interface Result {
state: 'running' | 'done' | 'error' | 'skipped';
plan: RunPlan;
evaluation?: Evaluation;
error?: string;
ms?: number;
/** The context was corrected and the script run again (julc#244 workarounds). */
fixed?: boolean;
}
type Result = RunResult;

let installed = $state<boolean | null>(null);
let supported = $state(true);
Expand Down Expand Up @@ -94,86 +86,17 @@
phase = 'loading';
let plans: RunPlan[];
try {
plans = buildRuns(await loadReplayInput(page));
plans = await prepareRuns(page);
} catch (e) {
failure = `Couldn’t load what the re-run needs from Yaci Store: ${(e as Error)?.message ?? e}`;
phase = 'idle';
return;
}
for (const plan of plans) results[plan.key] = plan.request ? { state: 'running', plan } : { state: 'skipped', plan, error: plan.skip };
phase = 'running';
for (const plan of plans) {
if (!plan.request) continue;
const started = performance.now();
try {
const evaluation = await julc.run((c) => {
if (!c.uplc) throw new Error('This julc engine can’t evaluate transactions (VM-only build).');
return c.uplc.evaluateTransaction(plan.request!);
});
const ms = Math.round(performance.now() - started);
results[plan.key] = evaluation.ok === false && evaluation.error ? { state: 'error', plan, error: evaluation.error, ms } : { state: 'done', plan, evaluation, ms };
} catch (e) {
results[plan.key] = { state: 'error', plan, error: (e as Error)?.message ?? String(e) };
if (julc.status === 'unsupported' || julc.status === 'error') {
failure = julc.error ?? (e as Error)?.message;
break;
}
}
}
await fixContexts(plans);
failure = await runAll(plans, (key, r) => (results[key] = r));
phase = 'done';
}

/*
* julc#244 workarounds (see replay/fix-context.ts): put values in ledger order and, when every run produced its
* context, give each context the complete redeemer map. Runs whose context changed are evaluated again with the
* VM. Redeemers are only merged when all scripts share one Plutus version (V2 or V3): purposes are encoded
* differently per version.
*/
async function fixContexts(plans: RunPlan[]) {
const runnable = plans.filter((p) => p.request && results[p.key]?.evaluation?.scriptContextCbor);
const language = plans[0]?.language;
let entries: NonNullable<ReturnType<typeof ownRedeemer>>[] | null = null;
if (plans.length > 1 && runnable.length === plans.length && language !== 'V1' && plans.every((p) => p.language === language)) {
const ordered = [...plans].sort((a, b) => TAG_ORDER[a.purpose] - TAG_ORDER[b.purpose] || a.index - b.index);
const found = ordered.map((p) => ownRedeemer(results[p.key].evaluation!.scriptContextCbor!));
if (found.every((e) => e)) entries = found as NonNullable<(typeof found)[number]>[];
}
for (const plan of runnable) {
const first = results[plan.key];
const fixed = fixContext(first.evaluation!.scriptContextCbor!, plan.language!, entries, plan.hashedDatums, plan.witnessDatums);
if (!fixed) continue;
const req = plan.request!;
const cbor = (value: string) => ({ format: 'cbor' as const, value });
const args =
plan.language === 'V3' ? [cbor(fixed.cbor)] : plan.purpose === 'spend' ? (plan.datum ? [cbor(plan.datum), cbor(plan.redeemer!), cbor(fixed.cbor)] : null) : [cbor(plan.redeemer!), cbor(fixed.cbor)];
if (!args) continue;
const started = performance.now();
try {
const evaluation = await julc.run((c) => c.vm.evaluate({ script: req.script, args, target: req.target, costModel: req.costModel, maxCpu: req.maxCpu, maxMem: req.maxMem }));
if (evaluation.ok === false && evaluation.error) continue;
const did = [
fixed.sortedValues && 'put asset values in ledger order',
fixed.completedRedeemers && `added the other ${plans.length - 1} redeemer${plans.length > 2 ? 's' : ''}`,
fixed.restoredDatums && 'added the datums given by hash under their on-chain hashes',
fixed.v1Shape && 'gave V1 withdrawals and datums their list shape'
].filter(Boolean);
results[plan.key] = {
...first,
evaluation: { ...evaluation, scriptContextCbor: fixed.cbor, language: first.evaluation!.language },
ms: Math.round(performance.now() - started),
fixed: true,
plan: {
...plan,
notes: [`The viewer ${did.length > 1 ? did.slice(0, -1).join(', ') + ' and ' + did.at(-1) : did[0]} in the context, which julc doesn’t do yet (julc#244).`, ...plan.notes.filter((n) => !(fixed.completedRedeemers && n.includes('julc#244')))]
}
};
} catch {
/* keep the first result */
}
}
}

function toggleAuto(on: boolean) {
auto = on;
julc.auto = on;
Expand Down Expand Up @@ -258,7 +181,16 @@
{:else}
<CircleX class="h-4 w-4 text-danger" /><span class="font-medium text-danger">{r.evaluation?.status === 'budgetExhausted' ? 'Out of budget' : 'Failed'}</span>
{/if}
{#if r.ms != null}<span class="ml-auto text-xs tabular-nums text-faint">{r.ms} ms</span>{/if}
<span class="ml-auto flex items-center gap-3">
{#if r.ms != null}<span class="text-xs tabular-nums text-faint">{r.ms} ms</span>{/if}
{#if r.args && r.state === 'done'}
<a
class="y-btn h-7 gap-1.5 px-2.5 text-xs"
href="/transactions/{page.hash}/debug/{r.plan.purpose}/{r.plan.index}{passed(r) ? '' : '?at=failure'}"
title="Step through this run in the debugger"><Bug class="h-3.5 w-3.5" />{passed(r) ? 'Debug' : 'Debug the failure'}</a
>
{/if}
</span>
</div>

{#if r.error}<p class="mt-2 break-words text-sm text-muted">{r.error}</p>{/if}
Expand Down
Loading