movehat 0.2.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,8 @@
21
21
  - **Hardhat-style Harness API** — `Harness.createLocal`, `createFork`, `createLive` factory methods with explicit lifecycle (`cleanup()`) and use-after-cleanup safety (Proxy poisoning).
22
22
  - **Three execution modes** — full local blockchain, read-only fork of a remote network, or live testnet/mainnet binding.
23
23
  - **Auto-deploy in tests + auto-detect named addresses** — contracts compile and deploy automatically; no manual address wiring.
24
+ - **Fast local boot with movelite** — on supported platforms an auto-installed [movelite](https://github.com/gilbertsahumada/movelite) binary boots the local test chain in under a second instead of ~15s, with transparent fallback to the full Movement node.
25
+ - **Foundry-style execution traces** — raise verbosity (`-vv` … `-vvvv`) to render an indented call tree for each `contract.call(...)` with decoded arguments, gas, events, and the abort stack (full tree on the movelite backend; the Movement node renders a degraded flat trace — events, state changes, and gas).
24
26
  - **Native fork system** — local JSON-backed snapshots of Movement L1 state, no BCS compatibility issues.
25
27
  - **TypeScript-first** — single `PRIVATE_KEY` across all networks (Hardhat-style); deployments tracked per-network in `deployments/`.
26
28
  - **SLSA-provenance releases** — every npm release ships with [Trusted Publishers](https://docs.npmjs.com/trusted-publishers) provenance. Verify with `npm view movehat@<version>`.
package/dist/cli.js CHANGED
@@ -44,7 +44,7 @@ program
44
44
  .version(version)
45
45
  .option('--network <name>', 'Network to use (testnet, mainnet, local, etc.)')
46
46
  .option('--redeploy', 'Force redeploy even if already deployed')
47
- .option('-v, --verbose', 'Show subprocess output (movement node, aptos move) for debugging')
47
+ .option('-v, --verbose', 'Increase output verbosity (repeatable: -v subprocess output, -vv..-vvvv transaction traces on movelite)', (_value, previous) => previous + 1, 0)
48
48
  .hook('preAction', (thisCommand) => {
49
49
  // Store network option in environment for commands to access
50
50
  const options = thisCommand.opts();
@@ -54,8 +54,13 @@ program
54
54
  if (options.redeploy) {
55
55
  process.env.MH_CLI_REDEPLOY = 'true';
56
56
  }
57
- if (options.verbose) {
57
+ // `-v` is counted (0..4). Level >= 1 keeps the legacy MOVEHAT_VERBOSE=1
58
+ // contract (isVerbose + its callers); MOVEHAT_VERBOSITY carries the
59
+ // numeric level across the spawned test/script subprocess boundary.
60
+ const level = options.verbose ?? 0;
61
+ if (level >= 1) {
58
62
  process.env.MOVEHAT_VERBOSE = '1';
63
+ process.env.MOVEHAT_VERBOSITY = String(level);
59
64
  }
60
65
  });
61
66
  program
@@ -8,9 +8,10 @@ export declare class MoveContract {
8
8
  private aptos;
9
9
  private moduleAddress;
10
10
  private moduleName;
11
- constructor(aptos: Aptos, moduleAddress: string, moduleName: string);
11
+ private traceRpcUrl?;
12
+ constructor(aptos: Aptos, moduleAddress: string, moduleName: string, traceRpcUrl?: string | undefined);
12
13
  call(signer: Account, functionName: string, args?: any[], typeArgs?: string[]): Promise<TransactionResult>;
13
14
  view<T = unknown>(functionName: string, args?: any[], typeArgs?: string[]): Promise<T>;
14
15
  getModuleId(): string;
15
16
  }
16
- export declare function getContract(aptos: Aptos, moduleAddress: string, moduleName: string): MoveContract;
17
+ export declare function getContract(aptos: Aptos, moduleAddress: string, moduleName: string, traceRpcUrl?: string): MoveContract;
@@ -1,12 +1,21 @@
1
1
  import { logger } from "../ui/index.js";
2
+ import { traceTransaction } from "./trace/client.js";
3
+ import { renderTrace } from "./trace/renderer.js";
4
+ import { renderNodeTrace } from "./trace/nodeRenderer.js";
2
5
  export class MoveContract {
3
6
  aptos;
4
7
  moduleAddress;
5
8
  moduleName;
6
- constructor(aptos, moduleAddress, moduleName) {
9
+ traceRpcUrl;
10
+ constructor(aptos, moduleAddress, moduleName,
11
+ // movelite RPC base (ending in `/v1`). Presence enables Foundry-style
12
+ // execution traces at verbosity level >= 2. Undefined on the Movement node
13
+ // (no trace endpoint) — calls use the normal submit path.
14
+ traceRpcUrl) {
7
15
  this.aptos = aptos;
8
16
  this.moduleAddress = moduleAddress;
9
17
  this.moduleName = moduleName;
18
+ this.traceRpcUrl = traceRpcUrl;
10
19
  }
11
20
  async call(signer, functionName,
12
21
  // any[]: Move entry-function arguments are heterogeneous primitives
@@ -29,6 +38,33 @@ export class MoveContract {
29
38
  signer,
30
39
  transaction,
31
40
  });
41
+ // Trace path: on movelite at raised verbosity, route through the
42
+ // instrumented `/transactions/trace?commit=true` endpoint, which executes
43
+ // AND commits in one pass (so we must NOT also submit), then render the
44
+ // returned call tree.
45
+ const traceLevel = logger.getVerbosityLevel();
46
+ if (this.traceRpcUrl && traceLevel >= 2) {
47
+ const { response, elapsedMs } = await traceTransaction({
48
+ rpcUrl: this.traceRpcUrl,
49
+ transaction,
50
+ senderAuthenticator: signature,
51
+ });
52
+ // A render failure must never fail a transaction that already committed.
53
+ try {
54
+ renderTrace(response, { level: traceLevel, elapsedMs });
55
+ }
56
+ catch (renderError) {
57
+ const msg = renderError instanceof Error ? renderError.message : String(renderError);
58
+ logger.warning(`Failed to render trace: ${msg}`);
59
+ }
60
+ logger.success(`Transaction ${response.txn_hash} committed with status: ${response.vm_status}`);
61
+ logger.newline();
62
+ return {
63
+ hash: response.txn_hash,
64
+ success: response.success,
65
+ vm_status: response.vm_status,
66
+ };
67
+ }
32
68
  const committedTxn = await this.aptos.transaction.submit.simple({
33
69
  transaction,
34
70
  senderAuthenticator: signature,
@@ -36,6 +72,24 @@ export class MoveContract {
36
72
  const response = await this.aptos.waitForTransaction({
37
73
  transactionHash: committedTxn.hash,
38
74
  });
75
+ // Degraded trace: the Movement node does not expose internal call frames,
76
+ // but the REST response already carries the events, write-set, and gas.
77
+ // At raised verbosity render that flat view (a render failure must never
78
+ // fail a transaction that already committed).
79
+ //
80
+ // waitForTransaction returns the CommittedTransactionResponse union; the
81
+ // `in` guards narrow it to the user-transaction shape that carries events
82
+ // and changes, so renderNodeTrace receives a typed value without a cast.
83
+ const nodeLevel = logger.getVerbosityLevel();
84
+ if (nodeLevel >= 2 && "events" in response && "changes" in response) {
85
+ try {
86
+ renderNodeTrace(response, { level: nodeLevel });
87
+ }
88
+ catch (renderError) {
89
+ const msg = renderError instanceof Error ? renderError.message : String(renderError);
90
+ logger.warning(`Failed to render trace: ${msg}`);
91
+ }
92
+ }
39
93
  logger.success(`Transaction ${committedTxn.hash} committed with status: ${response.vm_status}`);
40
94
  logger.newline();
41
95
  return {
@@ -61,6 +115,6 @@ export class MoveContract {
61
115
  return `${this.moduleAddress}::${this.moduleName}`;
62
116
  }
63
117
  }
64
- export function getContract(aptos, moduleAddress, moduleName) {
65
- return new MoveContract(aptos, moduleAddress, moduleName);
118
+ export function getContract(aptos, moduleAddress, moduleName, traceRpcUrl) {
119
+ return new MoveContract(aptos, moduleAddress, moduleName, traceRpcUrl);
66
120
  }
@@ -0,0 +1,18 @@
1
+ import { type AccountAuthenticator, type AnyRawTransaction } from "@aptos-labs/ts-sdk";
2
+ import type { TraceResponse } from "./types.js";
3
+ /**
4
+ * Execute a signed transaction through movelite's instrumented VM and return
5
+ * the Foundry-style call tree. `commit=true` runs the trace AND commits in a
6
+ * single pass, so this is the sole execution — the caller must NOT also submit.
7
+ *
8
+ * @param rpcUrl movelite RPC base, already ending in `/v1`.
9
+ * @throws if the endpoint returns a non-2xx status (a submission failure).
10
+ */
11
+ export declare function traceTransaction(args: {
12
+ rpcUrl: string;
13
+ transaction: AnyRawTransaction;
14
+ senderAuthenticator: AccountAuthenticator;
15
+ }): Promise<{
16
+ response: TraceResponse;
17
+ elapsedMs: number;
18
+ }>;
@@ -0,0 +1,58 @@
1
+ import { generateSignedTransaction, } from "@aptos-labs/ts-sdk";
2
+ /** Content type movelite requires for BCS-signed transaction bodies. */
3
+ const BCS_SIGNED_TXN_CONTENT_TYPE = "application/x.aptos.signed_transaction+bcs";
4
+ /**
5
+ * Pull a readable detail out of a movelite error body. movelite (>= 0.2.1)
6
+ * returns structured JSON errors (`{ message, error_code, vm_error_code }`);
7
+ * older versions return plain text. Returns the `message` plus any codes when
8
+ * the body is JSON, otherwise the raw text unchanged.
9
+ */
10
+ function extractErrorDetail(body) {
11
+ if (!body)
12
+ return "";
13
+ try {
14
+ const parsed = JSON.parse(body);
15
+ if (parsed && typeof parsed.message === "string") {
16
+ const codes = [parsed.error_code, parsed.vm_error_code].filter((c) => c !== undefined && c !== null);
17
+ return codes.length > 0
18
+ ? `${parsed.message} (${codes.join(", ")})`
19
+ : parsed.message;
20
+ }
21
+ }
22
+ catch {
23
+ // Not JSON (e.g. older movelite plain-text errors) — fall through.
24
+ }
25
+ return body;
26
+ }
27
+ /**
28
+ * Execute a signed transaction through movelite's instrumented VM and return
29
+ * the Foundry-style call tree. `commit=true` runs the trace AND commits in a
30
+ * single pass, so this is the sole execution — the caller must NOT also submit.
31
+ *
32
+ * @param rpcUrl movelite RPC base, already ending in `/v1`.
33
+ * @throws if the endpoint returns a non-2xx status (a submission failure).
34
+ */
35
+ export async function traceTransaction(args) {
36
+ const { rpcUrl, transaction, senderAuthenticator } = args;
37
+ const bytes = generateSignedTransaction({ transaction, senderAuthenticator });
38
+ const url = `${rpcUrl}/transactions/trace?commit=true`;
39
+ const start = performance.now();
40
+ const res = await fetch(url, {
41
+ method: "POST",
42
+ // No Accept header (response is JSON); no auth header (movelite runs --no-auth).
43
+ // Copy into a fresh ArrayBuffer-backed Uint8Array: the SDK returns
44
+ // `Uint8Array<ArrayBufferLike>`, which the fetch `BodyInit` type rejects
45
+ // under this TS lib config (the ArrayBuffer / SharedArrayBuffer split).
46
+ headers: { "Content-Type": BCS_SIGNED_TXN_CONTENT_TYPE },
47
+ body: new Uint8Array(bytes),
48
+ });
49
+ const elapsedMs = performance.now() - start;
50
+ if (!res.ok) {
51
+ const body = await res.text().catch(() => "");
52
+ const detail = extractErrorDetail(body);
53
+ throw new Error(`movelite trace request failed (${res.status} ${res.statusText})` +
54
+ (detail ? `: ${detail}` : ""));
55
+ }
56
+ const response = (await res.json());
57
+ return { response, elapsedMs };
58
+ }
@@ -0,0 +1,27 @@
1
+ export declare const brightBlue: (s: string) => string;
2
+ export declare const indent: (depth: number) => string;
3
+ /** Shorten a 0x-prefixed address for display (`0xf903..9b16`); leave short
4
+ * framework addresses like `0x1` untouched. */
5
+ export declare const shortAddr: (addr: string) => string;
6
+ /** Shorten the address part of a `address::module[::Name]` path. */
7
+ export declare const shortenPath: (path: string) => string;
8
+ /** Format a decoded value. Struct (and vector) values arrive as objects with
9
+ * by-index keys; their fields can themselves be structs, so recurse rather
10
+ * than `String()`-ing a nested object into `[object Object]`. */
11
+ export declare const formatValue: (value: unknown) => string;
12
+ export declare const formatData: (data: unknown) => string;
13
+ /** One emitted-event line: `emit <module>::<Event> {…data}`. Reads only `type`
14
+ * and `data`, so it accepts both the movelite `TracedEvent` and the SDK's
15
+ * `Event` shape. */
16
+ export declare const formatEventLine: (event: {
17
+ type: string;
18
+ data: unknown;
19
+ }) => string;
20
+ /** One storage / state-change line: `<op> <module>::<Resource> [@addr]`. The
21
+ * `op` object is duck-typed so both movelite storage ops and SDK write-set
22
+ * changes (after reshaping) feed it. */
23
+ export declare const storageLine: (op: {
24
+ op: string;
25
+ type: string;
26
+ address: string | null;
27
+ }) => string;
@@ -0,0 +1,61 @@
1
+ import { colors, rgbToAnsi, shouldUseColor } from "../../ui/colors.js";
2
+ // Generic, stateless formatting helpers shared by the movelite call-tree
3
+ // renderer (renderer.ts) and the node degraded-trace renderer (nodeRenderer.ts).
4
+ // None of these are tied to a specific response shape.
5
+ // rgbToAnsi emits raw escapes unconditionally, so guard these ourselves
6
+ // (unlike colors.*, which already no-op when color is disabled). `orange` is
7
+ // module-private (only `leafValue` uses it); `brightBlue` is consumed by the
8
+ // movelite renderer's footer.
9
+ const orange = (s) => shouldUseColor() ? `${rgbToAnsi(255, 165, 0)}${s}\x1b[0m` : s;
10
+ export const brightBlue = (s) => shouldUseColor() ? `${rgbToAnsi(90, 170, 255)}${s}\x1b[0m` : s;
11
+ export const indent = (depth) => " ".repeat(depth);
12
+ /** Shorten a 0x-prefixed address for display (`0xf903..9b16`); leave short
13
+ * framework addresses like `0x1` untouched. */
14
+ export const shortAddr = (addr) => addr.startsWith("0x") && addr.length > 12
15
+ ? `${addr.slice(0, 6)}..${addr.slice(-4)}`
16
+ : addr;
17
+ /** Shorten the address part of a `address::module[::Name]` path. */
18
+ export const shortenPath = (path) => {
19
+ const [addr, ...rest] = path.split("::");
20
+ if (addr === undefined || rest.length === 0)
21
+ return path;
22
+ return [shortAddr(addr), ...rest].join("::");
23
+ };
24
+ const NUMERIC = /^\d+$/;
25
+ const leafValue = (value) => {
26
+ const s = String(value);
27
+ if (NUMERIC.test(s))
28
+ return orange(s);
29
+ if (s.startsWith("0x") && s.length > 12)
30
+ return shortAddr(s);
31
+ return s;
32
+ };
33
+ /** Format a decoded value. Struct (and vector) values arrive as objects with
34
+ * by-index keys; their fields can themselves be structs, so recurse rather
35
+ * than `String()`-ing a nested object into `[object Object]`. */
36
+ export const formatValue = (value) => {
37
+ if (value !== null && typeof value === "object") {
38
+ return `{ ${Object.values(value)
39
+ .map(formatValue)
40
+ .join(", ")} }`;
41
+ }
42
+ return leafValue(value);
43
+ };
44
+ export const formatData = (data) => {
45
+ if (data === null || data === undefined)
46
+ return "";
47
+ try {
48
+ return colors.dim(JSON.stringify(data));
49
+ }
50
+ catch {
51
+ return colors.dim(String(data));
52
+ }
53
+ };
54
+ /** One emitted-event line: `emit <module>::<Event> {…data}`. Reads only `type`
55
+ * and `data`, so it accepts both the movelite `TracedEvent` and the SDK's
56
+ * `Event` shape. */
57
+ export const formatEventLine = (event) => `${colors.warning(`emit ${shortenPath(event.type)}`)} ${formatData(event.data)}`.trimEnd();
58
+ /** One storage / state-change line: `<op> <module>::<Resource> [@addr]`. The
59
+ * `op` object is duck-typed so both movelite storage ops and SDK write-set
60
+ * changes (after reshaping) feed it. */
61
+ export const storageLine = (op) => `${colors.primary(`${op.op} ${shortenPath(op.type)}`)}${op.address ? colors.dim(` @${shortAddr(op.address)}`) : ""}`;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Minimal structural view of a committed transaction, as the Aptos SDK's
3
+ * `waitForTransaction` returns it (`UserTransactionResponse`). Typed loosely so
4
+ * unit tests can feed plain fixtures and so we never depend on the full SDK
5
+ * response union. The real Movement node REST API does NOT expose internal call
6
+ * frames, so this is a flat, degraded trace — events + write-set + gas — rather
7
+ * than the movelite call tree.
8
+ */
9
+ export interface NodeTxView {
10
+ success: boolean;
11
+ vm_status: string;
12
+ /** Transaction gas in octas (external unit). */
13
+ gas_used: string;
14
+ events: {
15
+ type: string;
16
+ data: unknown;
17
+ }[];
18
+ changes: NodeWriteSetChange[];
19
+ }
20
+ /** Loose shape of an SDK `WriteSetChange` — only the fields we render. */
21
+ interface NodeWriteSetChange {
22
+ type: string;
23
+ address?: string;
24
+ /** `write_resource` payload (a `MoveResource` `{type,data}`); typed `unknown`
25
+ * and narrowed at the use sites since the shape varies by change type. */
26
+ data?: unknown;
27
+ /** `delete_resource` carries the resource type as a string here. */
28
+ resource?: string;
29
+ /** Table-item changes carry a `handle` instead of an `address`. */
30
+ handle?: string;
31
+ }
32
+ /**
33
+ * Pure formatter for the node degraded trace. Snapshot-testable without
34
+ * touching stdout. `level` is the verbosity level (2..4): L2 = events, L3 =
35
+ * + state changes, L4 = + each change's decoded data. The footer carries the
36
+ * status and the octa `gas_used` (there is no per-frame trace timing off the
37
+ * node).
38
+ */
39
+ export declare function formatNodeTraceLines(tx: NodeTxView, level: number): string[];
40
+ /** Render a node degraded trace to the terminal. Wraps {@link formatNodeTraceLines}. */
41
+ export declare function renderNodeTrace(tx: NodeTxView, opts: {
42
+ level: number;
43
+ }): void;
44
+ export {};
@@ -0,0 +1,90 @@
1
+ import { logger } from "../../ui/index.js";
2
+ import { colors } from "../../ui/colors.js";
3
+ import { symbols } from "../../ui/symbols.js";
4
+ import { formatData, formatEventLine, storageLine } from "./format.js";
5
+ /** Reshape an SDK write-set change into the shared `storageLine` duck-type. */
6
+ const changeToOp = (c) => {
7
+ const address = typeof c.address === "string" ? c.address : null;
8
+ switch (c.type) {
9
+ case "write_resource": {
10
+ const t = c.data && typeof c.data === "object" && "type" in c.data
11
+ ? String(c.data.type ?? "?")
12
+ : "?";
13
+ return { op: c.type, type: t, address };
14
+ }
15
+ case "delete_resource":
16
+ return { op: c.type, type: c.resource ?? "?", address };
17
+ case "write_module":
18
+ case "delete_module":
19
+ return { op: c.type, type: "module", address };
20
+ case "write_table_item":
21
+ case "delete_table_item":
22
+ // Table items have no resource type; key off the table `handle`.
23
+ return { op: c.type, type: "table_item", address: c.handle ?? null };
24
+ default:
25
+ return { op: c.type, type: "?", address };
26
+ }
27
+ };
28
+ /** Level-4 payload for a change: the resource's decoded fields when present. */
29
+ const changePayload = (c) => {
30
+ if (c.data && typeof c.data === "object" && "data" in c.data) {
31
+ return c.data.data;
32
+ }
33
+ return c.data;
34
+ };
35
+ const nodeFooter = (tx) => {
36
+ const status = tx.success
37
+ ? colors.success(`${symbols.success} ${tx.vm_status}`)
38
+ : colors.error(`${symbols.error} ${tx.vm_status}`);
39
+ const sep = colors.dim(" · ");
40
+ const gas = colors.dim(`gas_used: ${tx.gas_used} octas`);
41
+ return `${status}${sep}${gas}`;
42
+ };
43
+ /**
44
+ * Pure formatter for the node degraded trace. Snapshot-testable without
45
+ * touching stdout. `level` is the verbosity level (2..4): L2 = events, L3 =
46
+ * + state changes, L4 = + each change's decoded data. The footer carries the
47
+ * status and the octa `gas_used` (there is no per-frame trace timing off the
48
+ * node).
49
+ */
50
+ export function formatNodeTraceLines(tx, level) {
51
+ const lines = [];
52
+ // Events (level 2+).
53
+ if (tx.events.length === 0) {
54
+ lines.push(colors.dim("(no events emitted)"));
55
+ }
56
+ else {
57
+ lines.push(colors.bold("Events"));
58
+ for (const e of tx.events)
59
+ lines.push(" " + formatEventLine(e));
60
+ }
61
+ // State changes (level 3+).
62
+ if (level >= 3) {
63
+ lines.push("");
64
+ if (tx.changes.length === 0) {
65
+ lines.push(colors.dim("(no state changes)"));
66
+ }
67
+ else {
68
+ lines.push(colors.bold("State changes"));
69
+ for (const c of tx.changes) {
70
+ lines.push(" " + storageLine(changeToOp(c)));
71
+ if (level >= 4) {
72
+ const payload = formatData(changePayload(c));
73
+ if (payload)
74
+ lines.push(" " + payload);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ lines.push("");
80
+ lines.push(nodeFooter(tx));
81
+ return lines;
82
+ }
83
+ /** Render a node degraded trace to the terminal. Wraps {@link formatNodeTraceLines}. */
84
+ export function renderNodeTrace(tx, opts) {
85
+ logger.newline();
86
+ for (const line of formatNodeTraceLines(tx, opts.level)) {
87
+ logger.plain(line);
88
+ }
89
+ logger.newline();
90
+ }
@@ -0,0 +1,12 @@
1
+ import type { TraceResponse } from "./types.js";
2
+ /**
3
+ * Pure formatter — turns a trace into display lines. Snapshot-testable without
4
+ * touching stdout. `level` is the verbosity level (2..4); per-frame `gas` is in
5
+ * internal VM units while the footer `gas_used` is in octas (never mixed).
6
+ */
7
+ export declare function formatTraceLines(response: TraceResponse, level: number, elapsedMs: number): string[];
8
+ /** Render a trace to the terminal. Wraps {@link formatTraceLines}. */
9
+ export declare function renderTrace(response: TraceResponse, opts: {
10
+ level: number;
11
+ elapsedMs: number;
12
+ }): void;
@@ -0,0 +1,149 @@
1
+ import { logger } from "../../ui/index.js";
2
+ import { colors } from "../../ui/colors.js";
3
+ import { symbols } from "../../ui/symbols.js";
4
+ import { brightBlue, formatEventLine, formatValue, indent, shortenPath, storageLine, } from "./format.js";
5
+ const isFramework = (module) => module !== null && module.startsWith("0x1::");
6
+ /** Visible at level 3 (user-module tree): not a framework frame, not a native. */
7
+ const isUserFrame = (node) => !isFramework(node.module) && node.kind !== "native";
8
+ const formatArgValue = (arg) => {
9
+ if (arg.value === null)
10
+ return "()";
11
+ return formatValue(arg.value);
12
+ };
13
+ const formatArgs = (args) => args.map(formatArgValue).join(", ");
14
+ const frameName = (node) => {
15
+ const base = node.module
16
+ ? `${shortenPath(node.module)}::${node.function ?? "?"}`
17
+ : node.function ?? `<${node.kind}>`;
18
+ return base;
19
+ };
20
+ const frameLabel = (node) => {
21
+ const name = frameName(node);
22
+ const colored = isFramework(node.module) || node.kind === "native"
23
+ ? colors.dim(name)
24
+ : colors.bold(colors.info(name));
25
+ const gas = colors.dim(` [${node.gas}]`);
26
+ return `${colored}(${formatArgs(node.args)})${gas}`;
27
+ };
28
+ /** Non-unit return values only; null when nothing to show. */
29
+ const returnLine = (ret) => {
30
+ const meaningful = ret.filter((r) => r.type !== "()" && r.value !== null);
31
+ if (meaningful.length === 0)
32
+ return null;
33
+ return colors.success(`← ${meaningful.map(formatArgValue).join(", ")}`);
34
+ };
35
+ /** Collect every event in the tree with its emitting module — for the flat
36
+ * level-2 view. */
37
+ const collectEvents = (node, out) => {
38
+ for (const e of node.events)
39
+ out.push({ module: node.module, event: e });
40
+ for (const c of node.children)
41
+ collectEvents(c, out);
42
+ };
43
+ /** Level 3: descend through hidden (framework / native) frames, bubbling their
44
+ * events up and surfacing the nearest visible frames as children. */
45
+ const gatherHidden = (children, bubbled, visible) => {
46
+ for (const child of children) {
47
+ if (isUserFrame(child)) {
48
+ visible.push(child);
49
+ }
50
+ else {
51
+ bubbled.push(...child.events);
52
+ gatherHidden(child.children, bubbled, visible);
53
+ }
54
+ }
55
+ };
56
+ const renderNode = (node, depth, showFull, lines) => {
57
+ lines.push(indent(depth) + frameLabel(node));
58
+ const childDepth = depth + 1;
59
+ if (showFull) {
60
+ for (const e of node.events)
61
+ lines.push(indent(childDepth) + formatEventLine(e));
62
+ for (const s of node.storage)
63
+ lines.push(indent(childDepth) + storageLine(s));
64
+ const ret = returnLine(node.return);
65
+ if (ret)
66
+ lines.push(indent(childDepth) + ret);
67
+ for (const c of node.children)
68
+ renderNode(c, childDepth, showFull, lines);
69
+ return;
70
+ }
71
+ // Level 3: own events + events bubbled from hidden descendants, then the
72
+ // nearest visible child frames.
73
+ const bubbled = [];
74
+ const visibleChildren = [];
75
+ gatherHidden(node.children, bubbled, visibleChildren);
76
+ for (const e of [...node.events, ...bubbled]) {
77
+ lines.push(indent(childDepth) + formatEventLine(e));
78
+ }
79
+ for (const c of visibleChildren)
80
+ renderNode(c, childDepth, showFull, lines);
81
+ };
82
+ const formatAbort = (abort) => {
83
+ const lines = [];
84
+ let header = colors.error(`${symbols.error} Aborted: code ${abort.code}`);
85
+ if (abort.sub_status !== null) {
86
+ header += colors.error(` (sub_status ${abort.sub_status})`);
87
+ }
88
+ if (abort.module !== null) {
89
+ header += colors.dim(` in ${shortenPath(abort.module)}`);
90
+ }
91
+ lines.push(header);
92
+ for (const entry of abort.stack) {
93
+ const mod = entry.module !== null ? shortenPath(entry.module) : "<unknown>";
94
+ const fn = entry.function ?? "<unknown>";
95
+ const off = entry.offset !== null ? ` @${entry.offset}` : "";
96
+ lines.push(" " + colors.error(`at ${mod}::${fn}${off}`));
97
+ }
98
+ return lines;
99
+ };
100
+ const formatFooter = (response, elapsedMs) => {
101
+ const status = response.success
102
+ ? colors.success(`${symbols.success} Executed successfully`)
103
+ : colors.error(`${symbols.error} Aborted`);
104
+ const sep = colors.dim(" · ");
105
+ const gas = colors.dim(`gas_used: ${response.gas_used} octas`);
106
+ const timed = brightBlue(`traced in ${Math.round(elapsedMs)}ms`);
107
+ return `${status}${sep}${gas}${sep}${timed}`;
108
+ };
109
+ /**
110
+ * Pure formatter — turns a trace into display lines. Snapshot-testable without
111
+ * touching stdout. `level` is the verbosity level (2..4); per-frame `gas` is in
112
+ * internal VM units while the footer `gas_used` is in octas (never mixed).
113
+ */
114
+ export function formatTraceLines(response, level, elapsedMs) {
115
+ const lines = [];
116
+ if (level <= 2) {
117
+ const events = [];
118
+ collectEvents(response.root, events);
119
+ if (events.length === 0) {
120
+ lines.push(colors.dim("(no events emitted)"));
121
+ }
122
+ else {
123
+ lines.push(colors.bold("Events"));
124
+ for (const { event } of events)
125
+ lines.push(" " + formatEventLine(event));
126
+ }
127
+ }
128
+ else {
129
+ // Aborts always show the full tree so the failing frame is visible.
130
+ const showFull = level >= 4 || !response.success;
131
+ lines.push(colors.bold("Trace"));
132
+ renderNode(response.root, 0, showFull, lines);
133
+ }
134
+ if (!response.success && response.abort) {
135
+ lines.push("");
136
+ lines.push(...formatAbort(response.abort));
137
+ }
138
+ lines.push("");
139
+ lines.push(formatFooter(response, elapsedMs));
140
+ return lines;
141
+ }
142
+ /** Render a trace to the terminal. Wraps {@link formatTraceLines}. */
143
+ export function renderTrace(response, opts) {
144
+ logger.newline();
145
+ for (const line of formatTraceLines(response, opts.level, opts.elapsedMs)) {
146
+ logger.plain(line);
147
+ }
148
+ logger.newline();
149
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * TypeScript mirror of the JSON contract movelite's `/v1/transactions/trace`
3
+ * endpoint serializes. Field names and shapes match the Rust structs exactly;
4
+ * do not rename keys (`return` is keyed literally, `address` is nullable, etc.).
5
+ */
6
+ /** A decoded argument or return value. `value` shape depends on `type`:
7
+ * primitives like `u64` arrive as strings (`"5"`); a `struct` arrives as an
8
+ * object keyed by field index (`{ "0": .., "1": .. }`); the unit type `()` has
9
+ * `value: null`. */
10
+ export interface TracedArg {
11
+ type: string;
12
+ value: unknown;
13
+ }
14
+ /** An event emitted within a frame. `data` is the decoded event payload. */
15
+ export interface TracedEvent {
16
+ type: string;
17
+ data: unknown;
18
+ }
19
+ /** A storage access. `address` is populated for `load_resource` and null for
20
+ * `move_to` / `borrow_global_mut`. */
21
+ export interface StorageOp {
22
+ op: string;
23
+ type: string;
24
+ address: string | null;
25
+ }
26
+ /** One frame of the abort stack, innermost first. Any field may be null when
27
+ * the VM could not resolve it. */
28
+ export interface AbortStackEntry {
29
+ module: string | null;
30
+ function: string | null;
31
+ offset: number | null;
32
+ }
33
+ /** Abort details, present only when `TraceResponse.success` is false. */
34
+ export interface AbortInfo {
35
+ code: number;
36
+ sub_status: number | null;
37
+ module: string | null;
38
+ stack: AbortStackEntry[];
39
+ }
40
+ /** A single call frame in the execution tree. */
41
+ export interface CallNode {
42
+ kind: "function" | "native" | "script";
43
+ module: string | null;
44
+ function: string | null;
45
+ type_args: string[];
46
+ args: TracedArg[];
47
+ /** Keyed literally as `return` to match the wire format. */
48
+ return: TracedArg[];
49
+ /**
50
+ * Self gas of this frame in **internal** VM gas units. These are much larger
51
+ * than, and NOT comparable to, the external octa `gas_used` on the response —
52
+ * never mix the two when rendering.
53
+ */
54
+ gas: number;
55
+ events: TracedEvent[];
56
+ storage: StorageOp[];
57
+ children: CallNode[];
58
+ }
59
+ /** The full trace response. */
60
+ export interface TraceResponse {
61
+ txn_hash: string;
62
+ success: boolean;
63
+ /** Transaction-level gas in octas (the external unit). */
64
+ gas_used: number;
65
+ vm_status: string;
66
+ abort: AbortInfo | null;
67
+ root: CallNode;
68
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * TypeScript mirror of the JSON contract movelite's `/v1/transactions/trace`
3
+ * endpoint serializes. Field names and shapes match the Rust structs exactly;
4
+ * do not rename keys (`return` is keyed literally, `address` is nullable, etc.).
5
+ */
6
+ export {};
@@ -171,9 +171,14 @@ async function setupWithLocalNode(options, accountLabels, autoFund, defaultBalan
171
171
  if (!deployerPrivateKey) {
172
172
  throw new Error("Failed to get deployer private key");
173
173
  }
174
+ // movelite exposes the /transactions/trace endpoint that powers
175
+ // Foundry-style execution traces; a real Movement node does not. Compute
176
+ // this once and reuse it for both the trace wiring and SDK publish below.
177
+ const isMovelite = localNode instanceof MoveliteManager;
174
178
  const runtime = await initRuntime({
175
179
  network: "local",
176
180
  accountManager,
181
+ ...(isMovelite ? { traceRpcUrl: `${nodeInfo.rpcUrl}/v1` } : {}),
177
182
  configOverride: {
178
183
  networks: {
179
184
  local: {
@@ -192,7 +197,7 @@ async function setupWithLocalNode(options, accountLabels, autoFund, defaultBalan
192
197
  process.env.MH_CLI_REDEPLOY = 'true';
193
198
  // movelite's REST responses can't drive the Movement CLI publish flow,
194
199
  // so deploy through the TypeScript SDK when it is the spawned backend.
195
- const sdkPublish = localNode instanceof MoveliteManager;
200
+ const sdkPublish = isMovelite;
196
201
  try {
197
202
  for (const moduleName of options.autoDeploy) {
198
203
  try {
@@ -94,7 +94,7 @@ export class LocalNodeManager {
94
94
  ...(usesDefaultAdapter ? { env: sanitizeMovementEnv() } : {}),
95
95
  stdio: this.options.silent ? "ignore" : "pipe",
96
96
  });
97
- // Subprocess output handling (see §9 Console UX in CLAUDE.md):
97
+ // Subprocess output handling (verbosity-gated console UX):
98
98
  // - stdout chatter is hidden by default; gated by isVerbose()
99
99
  // - lines matching CRITICAL_NODE_OUTPUT always surface as warnings
100
100
  // so the user is never silenced through a real failure
package/dist/runtime.d.ts CHANGED
@@ -17,6 +17,14 @@ export interface InitRuntimeOptions {
17
17
  * key it extracts ends up on the same manager the runtime exposes.
18
18
  */
19
19
  accountManager?: AccountManager;
20
+ /**
21
+ * movelite RPC base URL (already ending in `/v1`) enabling Foundry-style
22
+ * execution traces on `contract.call(...)` at verbosity level >= 2. Set only
23
+ * when the active backend is movelite (its `/transactions/trace` endpoint
24
+ * does not exist on a real Movement node). When omitted, contracts use the
25
+ * normal submit path and never trace.
26
+ */
27
+ traceRpcUrl?: string;
20
28
  }
21
29
  /**
22
30
  * Initialize the Movehat Runtime Environment.
package/dist/runtime.js CHANGED
@@ -55,7 +55,7 @@ export async function initRuntime(options = {}) {
55
55
  };
56
56
  // Helper functions
57
57
  const getContractHelper = (address, moduleName) => {
58
- return getContract(aptos, address, moduleName);
58
+ return getContract(aptos, address, moduleName, options.traceRpcUrl);
59
59
  };
60
60
  const deployContract = async (moduleName, options) => {
61
61
  // Thin orchestrator; the actual logic lives in core/Publisher.ts.
@@ -29,6 +29,21 @@ export interface LoggerConfig {
29
29
  * callers opt in before the CLI parses args, e.g. in shell scripts).
30
30
  */
31
31
  export declare const isVerbose: () => boolean;
32
+ /**
33
+ * Numeric verbosity level (0..4) for level-gated output such as the
34
+ * transaction-trace renderer. Driven by the counted `-v` CLI flag via the
35
+ * `MOVEHAT_VERBOSITY` env var, so it survives the spawned test/script
36
+ * subprocess boundary the same way {@link isVerbose} reads `MOVEHAT_VERBOSE`.
37
+ * Falls back to the legacy boolean `MOVEHAT_VERBOSE=1` (level 1) for callers
38
+ * that set only that.
39
+ *
40
+ * - 0 default — system logs only
41
+ * - 1 `-v` — + subprocess stdout (see {@link isVerbose})
42
+ * - 2 `-vv` — + decoded events per call
43
+ * - 3 `-vvv` — + user-module call tree
44
+ * - 4 `-vvvv` — + framework frames, natives, storage, return values
45
+ */
46
+ export declare const getVerbosityLevel: () => number;
32
47
  /**
33
48
  * Configure logger globally
34
49
  *
@@ -202,6 +217,7 @@ export declare const item: (text: string, indent?: number) => void;
202
217
  export declare const logger: {
203
218
  configure: (newConfig: Partial<LoggerConfig>) => void;
204
219
  isVerbose: () => boolean;
220
+ getVerbosityLevel: () => number;
205
221
  info: (message: string, indent?: number) => void;
206
222
  success: (message: string, indent?: number) => void;
207
223
  error: (message: string, indent?: number) => void;
package/dist/ui/logger.js CHANGED
@@ -16,6 +16,29 @@ let config = {
16
16
  * callers opt in before the CLI parses args, e.g. in shell scripts).
17
17
  */
18
18
  export const isVerbose = () => config.verbosity === 'verbose' || process.env.MOVEHAT_VERBOSE === '1';
19
+ /**
20
+ * Numeric verbosity level (0..4) for level-gated output such as the
21
+ * transaction-trace renderer. Driven by the counted `-v` CLI flag via the
22
+ * `MOVEHAT_VERBOSITY` env var, so it survives the spawned test/script
23
+ * subprocess boundary the same way {@link isVerbose} reads `MOVEHAT_VERBOSE`.
24
+ * Falls back to the legacy boolean `MOVEHAT_VERBOSE=1` (level 1) for callers
25
+ * that set only that.
26
+ *
27
+ * - 0 default — system logs only
28
+ * - 1 `-v` — + subprocess stdout (see {@link isVerbose})
29
+ * - 2 `-vv` — + decoded events per call
30
+ * - 3 `-vvv` — + user-module call tree
31
+ * - 4 `-vvvv` — + framework frames, natives, storage, return values
32
+ */
33
+ export const getVerbosityLevel = () => {
34
+ const raw = process.env.MOVEHAT_VERBOSITY;
35
+ if (raw !== undefined) {
36
+ const n = parseInt(raw, 10);
37
+ if (!Number.isNaN(n))
38
+ return Math.max(0, Math.min(4, n));
39
+ }
40
+ return process.env.MOVEHAT_VERBOSE === '1' ? 1 : 0;
41
+ };
19
42
  /**
20
43
  * Configure logger globally
21
44
  *
@@ -261,6 +284,7 @@ export const item = (text, indent = 0) => {
261
284
  export const logger = {
262
285
  configure: configureLogger,
263
286
  isVerbose,
287
+ getVerbosityLevel,
264
288
  info,
265
289
  success,
266
290
  error,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "movehat",
3
- "version": "0.2.9",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Hardhat-like development framework for Movement L1 smart contracts",
6
6
  "bin": {
@@ -71,7 +71,7 @@
71
71
  "tsx": "^4.7.0"
72
72
  },
73
73
  "optionalDependencies": {
74
- "movelite": "^0.1.0"
74
+ "movelite": "^0.2.1"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@types/js-yaml": "^4.0.9",