- {/* Kind toggle: literal vs addressRef */}
+ {/* Kind toggle: literal vs addressRef vs read */}
void;
}) {
const isRef = typeof value === "object" && (value as StudioAddressRef).kind === "addressRef";
+ const isRead = typeof value === "object" && (value as StudioReadRef).kind === "read";
const argLabel = `ordered-arg-${stepId}-${index}`;
+ const readContractName = isRead
+ ? (deployTargets.find((dt) => dt.deployId === (value as StudioReadRef).contract)?.contractName ?? "")
+ : "";
+ const readViewFns = isRead ? getViewFunctions(readContractName) : [];
+
return (
{inputName && (
@@ -144,10 +150,14 @@ function OrderedArgInput({
{
if (e.target.value === "ref") {
onChange({ kind: "addressRef", deployId: deployTargets[0]?.deployId ?? "" });
+ } else if (e.target.value === "read") {
+ const firstTarget = deployTargets[0];
+ const firstFn = firstTarget ? getViewFunctions(firstTarget.contractName)[0] : undefined;
+ onChange({ kind: "read", contract: firstTarget?.deployId ?? "", function: firstFn?.name ?? "" });
} else {
onChange(typeof value === "object" ? "" : value);
}
@@ -156,6 +166,7 @@ function OrderedArgInput({
>
literal
address ref
+ read
{isRef ? (
))}
+ ) : isRead ? (
+ <>
+ {
+ const newContract = e.target.value;
+ const newContractName = deployTargets.find((dt) => dt.deployId === newContract)?.contractName ?? "";
+ const firstFn = getViewFunctions(newContractName)[0];
+ onChange({ kind: "read", contract: newContract, function: firstFn?.name ?? "" });
+ }}
+ aria-label={`${argLabel}-read-contract`}
+ >
+ {deployTargets.length === 0 && (
+ — no contracts on canvas —
+ )}
+ {deployTargets.map((dt) => (
+
+ {dt.deployId}
+
+ ))}
+
+ onChange({ ...(value as StudioReadRef), function: e.target.value })}
+ aria-label={`${argLabel}-read-function`}
+ >
+ {readViewFns.length === 0 && (
+ — no view functions —
+ )}
+ {readViewFns.map((fn) => (
+
+ {fn.name}()
+
+ ))}
+
+ >
) : (
- typeof a === "object" &&
- a !== null &&
- (a as StudioAddressRef).kind === "addressRef" &&
- (a as StudioAddressRef).deployId === deployId,
- );
+ return step.args.some((a) => {
+ if (typeof a !== "object" || a === null) return false;
+ if ((a as StudioAddressRef).kind === "addressRef") {
+ return (a as StudioAddressRef).deployId === deployId;
+ }
+ if ((a as StudioReadRef).kind === "read") {
+ const r = a as StudioReadRef;
+ if (r.contract === deployId) return true;
+ return (r.args ?? []).some(
+ (ra) =>
+ typeof ra === "object" &&
+ ra !== null &&
+ (ra as StudioAddressRef).kind === "addressRef" &&
+ (ra as StudioAddressRef).deployId === deployId,
+ );
+ }
+ return false;
+ });
}
return step.accountKind === "ref" && step.accountValue === deployId;
}
diff --git a/apps/studio/src/manifest/index.ts b/apps/studio/src/manifest/index.ts
index e5a6e47..7c27661 100644
--- a/apps/studio/src/manifest/index.ts
+++ b/apps/studio/src/manifest/index.ts
@@ -54,3 +54,32 @@ export function getStateChangingFunctions(name: string): ManifestFunction[] {
}
return result;
}
+
+/**
+ * Return the READ-ONLY functions (stateMutability "view" or "pure") declared
+ * or inherited by a contract, in manifest order, deduped by canonical
+ * signature (overloads are kept as distinct entries).
+ *
+ * Used by the "read" config-call arg kind (issue #147): a config-call arg can
+ * be filled with a value read from a deployed contract's view/pure function
+ * (e.g. `token.decimals()`) instead of a literal or an address ref. This
+ * helper lists the REAL read-only functions of the selected source contract
+ * so the picker doesn't offer synthetic entries. Mirrors
+ * getStateChangingFunctions's shape/dedup logic exactly, just inverted on
+ * stateMutability. Returns an empty array when the contract isn't in the
+ * manifest (free-text fallback) — callers should treat that as "no functions
+ * available" rather than crashing.
+ */
+export function getViewFunctions(name: string): ManifestFunction[] {
+ const manifest = getContract(name);
+ if (!manifest) return [];
+ const seen = new Set();
+ const result: ManifestFunction[] = [];
+ for (const fn of manifest.functions) {
+ if (fn.stateMutability !== "view" && fn.stateMutability !== "pure") continue;
+ if (seen.has(fn.signature)) continue;
+ seen.add(fn.signature);
+ result.push(fn);
+ }
+ return result;
+}
diff --git a/apps/studio/src/spec/graph-to-spec.ts b/apps/studio/src/spec/graph-to-spec.ts
index 8348d72..2366b4f 100644
--- a/apps/studio/src/spec/graph-to-spec.ts
+++ b/apps/studio/src/spec/graph-to-spec.ts
@@ -50,6 +50,7 @@ import type {
ConfigSpec,
ConfigStep,
ConfigArg,
+ ReadCallArg,
} from "@redeploy/config/steps";
import type {
StudioEdgeData,
@@ -58,6 +59,7 @@ import type {
StudioOrderedConfigStep,
StudioConfigArg,
StudioAddressRef,
+ StudioReadRef,
StudioParameter,
} from "./types.js";
import { getContract } from "../manifest/index.js";
@@ -135,11 +137,28 @@ function parseLiteralValue(raw: string): LiteralValue {
return raw;
}
+/**
+ * Normalize a "read" call's OWN positional args (StudioReadRef.args) to
+ * ReadArg.args. Per ReadArg's own restriction these are ref/literal ONLY —
+ * never a nested read — so this is a narrower version of normalizeStudioArg
+ * that never recurses into the "read" branch.
+ */
+function normalizeReadCallArg(arg: string | StudioAddressRef): ReadCallArg {
+ if (typeof arg === "object") {
+ return { kind: "ref", contract: arg.deployId };
+ }
+ return { kind: "literal", value: parseLiteralValue(arg) };
+}
+
/**
* Normalize a studio config arg (StudioConfigArg) to a validated ConfigArg.
*
* - StudioAddressRef { kind: "addressRef", deployId }
* → RefArg { kind: "ref", contract: deployId }
+ * - StudioReadRef { kind: "read", contract, function, args? }
+ * → ReadArg { kind: "read", contract, function, args? } (same field
+ * names — passes straight through, only its OWN args are normalized
+ * via normalizeReadCallArg; nested reads are never emitted)
* - string (literal value)
* → LiteralArg { kind: "literal", value: parseLiteralValue(raw) }
*/
@@ -147,6 +166,16 @@ function normalizeStudioArg(arg: StudioConfigArg): ConfigArg {
if (typeof arg === "object" && (arg as StudioAddressRef).kind === "addressRef") {
return { kind: "ref", contract: (arg as StudioAddressRef).deployId };
}
+ if (typeof arg === "object" && (arg as StudioReadRef).kind === "read") {
+ const readArg = arg as StudioReadRef;
+ const args = (readArg.args ?? []).map(normalizeReadCallArg);
+ return {
+ kind: "read",
+ contract: readArg.contract,
+ function: readArg.function,
+ ...(args.length > 0 ? { args } : {}),
+ };
+ }
return { kind: "literal", value: parseLiteralValue(arg as string) };
}
diff --git a/apps/studio/src/spec/types.ts b/apps/studio/src/spec/types.ts
index 0560512..6170c10 100644
--- a/apps/studio/src/spec/types.ts
+++ b/apps/studio/src/spec/types.ts
@@ -22,6 +22,10 @@
* - StudioAddressRef { kind: "addressRef", deployId } — studio-facing only.
* MUST be normalized to RefArg { kind: "ref", contract: deployId } before
* emitting a ConfigSpec (done in graph-to-spec.ts normalizeStudioArg).
+ * - StudioReadRef { kind: "read", contract, function, args? } — a value read
+ * from a deployed contract's view/pure function (issue #147). Passes
+ * straight through to ReadArg { kind: "read", contract, function, args? }
+ * (same field names) at export time.
*
* ## Edge types
*
@@ -195,13 +199,44 @@ export interface StudioAddressRef {
deployId: string;
}
+/**
+ * Studio-facing "value read from a deployed contract's view/pure function"
+ * arg (issue #147). Mirrors `@redeploy/config`'s `ReadArg` shape
+ * ({ kind: "read", contract, function, args? }) — this type exists on the
+ * studio side only so the canvas can carry the same field names 1:1 and
+ * graph-to-spec.ts's normalizeStudioArg can pass them straight through
+ * without any renaming.
+ *
+ * - `contract` is the deploy-id of the SOURCE contract to read FROM (picked
+ * from the same `deployTargets` list used by the addressRef picker).
+ * - `function` is the bare name of the view/pure function to call on it
+ * (picked via the new `getViewFunctions` manifest helper).
+ * - `args` are the (optional) positional args to the view call itself. v1
+ * only supports no-arg view functions from the UI (e.g. `token.decimals()`)
+ * — the picker never populates `args` — but the field is kept so the type
+ * can carry them once a nested-arg editor is added. Per `ReadArg`'s own
+ * restriction, entries here are `string | StudioAddressRef` only — never a
+ * nested `StudioReadRef` (no nested reads, matching `ReadCallArg`).
+ */
+export interface StudioReadRef {
+ kind: "read";
+ /** The deploy-id of the SOURCE contract to read FROM. */
+ contract: string;
+ /** The bare name of the view/pure function to call on `contract`. */
+ function: string;
+ /** Optional positional args to the view call (literal or addressRef only, no nested reads). */
+ args?: (string | StudioAddressRef)[];
+}
+
/**
* A config call argument as stored in the studio's in-memory state.
*
* - string: a literal value (parseLiteralValue interprets it to bool/number/string/null)
* - StudioAddressRef: an address-of-contract reference (normalized to RefArg at export)
+ * - StudioReadRef: a value read from a deployed contract's view/pure function
+ * (normalized to ReadArg at export — see graph-to-spec.ts's normalizeStudioArg)
*/
-export type StudioConfigArg = string | StudioAddressRef;
+export type StudioConfigArg = string | StudioAddressRef | StudioReadRef;
// ---- Config step shapes (studio-internal) ----------------------------------
diff --git a/apps/studio/test/ConfigPanel.test.tsx b/apps/studio/test/ConfigPanel.test.tsx
index 69fb93e..e7f5d05 100644
--- a/apps/studio/test/ConfigPanel.test.tsx
+++ b/apps/studio/test/ConfigPanel.test.tsx
@@ -1012,3 +1012,173 @@ describe("ConfigPanel — grantRole with ref account", () => {
expect(screen.getByText("Contract ID")).not.toBeNull();
});
});
+
+// ---------------------------------------------------------------------------
+// setX step arg — "read" kind (issue #147): a value read from a deployed
+// contract's view/pure function, selectable via the same arg-kind toggle as
+// literal/addressRef.
+// ---------------------------------------------------------------------------
+
+describe("ConfigPanel — setX step arg (read kind: issue #147)", () => {
+ // Vault (the attached node's contract) has no free-text fallback here —
+ // functionSignature "mint(address,uint256)" on Token gives us a real
+ // per-input arg row to toggle to "read".
+ const dataWithMint = makeData({
+ deployId: "token",
+ contractName: "Token",
+ configSteps: [
+ {
+ kind: "setX",
+ id: "step-mint",
+ functionName: "mint",
+ functionSignature: "mint(address,uint256)",
+ args: [],
+ },
+ ],
+ });
+
+ it("offers a 'read' option alongside literal/address ref in the arg-kind toggle", () => {
+ render(
+ {}}
+ onRemoveStep={() => {}}
+ onUpdateSetXStep={() => {}}
+ onUpdateGrantRoleStep={() => {}}
+ />,
+ );
+ const kindSelect = screen.getByLabelText("setx-arg-n1-step-mint-0-kind") as HTMLSelectElement;
+ const optionValues = Array.from(kindSelect.options).map((o) => o.value);
+ expect(optionValues).toEqual(["literal", "ref", "read"]);
+ });
+
+ it("switching arg kind to 'read' defaults to the first deploy target and its first view function", () => {
+ const onUpdateSetXStep = vi.fn();
+ render(
+ {}}
+ onRemoveStep={() => {}}
+ onUpdateSetXStep={onUpdateSetXStep}
+ onUpdateGrantRoleStep={() => {}}
+ />,
+ );
+ const kindSelect = screen.getByLabelText("setx-arg-n1-step-mint-0-kind");
+ fireEvent.change(kindSelect, { target: { value: "read" } });
+ expect(onUpdateSetXStep).toHaveBeenCalledTimes(1);
+ const [, , update] = onUpdateSetXStep.mock.calls[0];
+ // Token's first view/pure function in manifest order is supportsInterface(bytes4).
+ expect(update.args[0]).toEqual({ kind: "read", contract: "token", function: "supportsInterface" });
+ });
+
+ it("renders read-source-contract and read-function pickers once kind is 'read', and switching the view function updates the arg", () => {
+ const onUpdateSetXStep = vi.fn();
+ const dataWithReadArg = makeData({
+ deployId: "token",
+ contractName: "Token",
+ configSteps: [
+ {
+ kind: "setX",
+ id: "step-mint",
+ functionName: "mint",
+ functionSignature: "mint(address,uint256)",
+ args: [{ kind: "read", contract: "token", function: "supportsInterface" }],
+ },
+ ],
+ });
+ render(
+ {}}
+ onRemoveStep={() => {}}
+ onUpdateSetXStep={onUpdateSetXStep}
+ onUpdateGrantRoleStep={() => {}}
+ />,
+ );
+ const contractSelect = screen.getByLabelText("setx-arg-n1-step-mint-0-read-contract") as HTMLSelectElement;
+ expect(contractSelect.value).toBe("token");
+ const fnSelect = screen.getByLabelText("setx-arg-n1-step-mint-0-read-function") as HTMLSelectElement;
+ expect(fnSelect.value).toBe("supportsInterface");
+ expect(fnSelect.innerHTML).toContain("decimals()");
+
+ fireEvent.change(fnSelect, { target: { value: "decimals" } });
+ expect(onUpdateSetXStep).toHaveBeenCalledWith("n1", "step-mint", {
+ args: [{ kind: "read", contract: "token", function: "decimals" }],
+ });
+ });
+
+ it("changing the read source contract re-picks the first view function of the new contract", () => {
+ const onUpdateSetXStep = vi.fn();
+ // Vault.setRegistry(address) — a real manifest function with one input,
+ // so the per-input ConfigArgInput (not the free-text fallback) renders.
+ const dataWithReadArg = makeData({
+ deployId: "vault",
+ contractName: "Vault",
+ configSteps: [
+ {
+ kind: "setX",
+ id: "step-x",
+ functionName: "setRegistry",
+ functionSignature: "setRegistry(address)",
+ args: [{ kind: "read", contract: "token", function: "decimals" }],
+ },
+ ],
+ });
+ render(
+ {}}
+ onRemoveStep={() => {}}
+ onUpdateSetXStep={onUpdateSetXStep}
+ onUpdateGrantRoleStep={() => {}}
+ />,
+ );
+ const contractSelect = screen.getByLabelText("setx-arg-n1-step-x-0-read-contract") as HTMLSelectElement;
+ fireEvent.change(contractSelect, { target: { value: "vault" } });
+ expect(onUpdateSetXStep).toHaveBeenCalledTimes(1);
+ const [, , update] = onUpdateSetXStep.mock.calls[0];
+ // Vault's first view/pure function in manifest order is balanceOf(address).
+ expect(update.args[0]).toEqual({ kind: "read", contract: "vault", function: "balanceOf" });
+ });
+
+ it("renders the '— no view functions —' fallback option when the read source contract has none in the manifest", () => {
+ // "Widget" is not in the generated manifest at all, so getViewFunctions
+ // resolves to an empty array (free-text-fallback contract, never crashes).
+ const WIDGET_TARGET: DeployTarget = { deployId: "widget", contractName: "Widget" };
+ const dataWithReadArg = makeData({
+ deployId: "token",
+ contractName: "Token",
+ configSteps: [
+ {
+ kind: "setX",
+ id: "step-mint",
+ functionName: "mint",
+ functionSignature: "mint(address,uint256)",
+ args: [{ kind: "read", contract: "widget", function: "" }],
+ },
+ ],
+ });
+ render(
+ {}}
+ onRemoveStep={() => {}}
+ onUpdateSetXStep={() => {}}
+ onUpdateGrantRoleStep={() => {}}
+ />,
+ );
+ const fnSelect = screen.getByLabelText("setx-arg-n1-step-mint-0-read-function") as HTMLSelectElement;
+ expect(fnSelect.options.length).toBe(1);
+ expect(fnSelect.options[0].textContent).toBe("— no view functions —");
+ });
+});
diff --git a/apps/studio/test/config-panel-ordered.test.tsx b/apps/studio/test/config-panel-ordered.test.tsx
index 669b13a..2708616 100644
--- a/apps/studio/test/config-panel-ordered.test.tsx
+++ b/apps/studio/test/config-panel-ordered.test.tsx
@@ -23,7 +23,7 @@ import { useGraph } from "../src/hooks/useGraph.js";
import { validateConfig } from "@redeploy/config";
import { validateSpec } from "@redeploy/core";
import type { GraphNode } from "../src/spec/graph-to-spec.js";
-import type { ContractNodeData, StudioOrderedConfigStep, StudioAddressRef } from "../src/spec/types.js";
+import type { ContractNodeData, StudioOrderedConfigStep, StudioAddressRef, StudioReadRef, StudioSetXStep } from "../src/spec/types.js";
// ---------------------------------------------------------------------------
// Helpers
@@ -394,6 +394,119 @@ describe("per-node config section — ContractNode with configCallbacks", () =>
});
});
+// ---------------------------------------------------------------------------
+// 2b. ContractNode's ConfigArgInput — "read" arg kind (issue #147)
+// ---------------------------------------------------------------------------
+
+describe("per-node config section — ConfigArgInput 'read' kind (issue #147)", () => {
+ // Token.mint(address,uint256) — a real manifest function with two inputs,
+ // so the per-input ConfigArgInput (not the free-text fallback) renders.
+ function makeMintStep(args: StudioSetXStep["args"] = []): ContractNodeData["configSteps"][number] {
+ return {
+ kind: "setX",
+ id: "step-mint",
+ functionName: "mint",
+ functionSignature: "mint(address,uint256)",
+ args,
+ };
+ }
+
+ it("offers a 'read' option alongside literal/address ref in the arg-kind toggle", () => {
+ const configCallbacks = {
+ onAddConfigStep: noop,
+ onRemoveConfigStep: noop,
+ onUpdateSetXStep: noop,
+ onUpdateGrantRoleStep: noop,
+ deployTargets: [{ deployId: "token", contractName: "Token" }],
+ };
+ const data = { ...makeContractNodeData({ configSteps: [makeMintStep()] }), configCallbacks };
+ renderContractNode(data as unknown as ContractNodeData, "n1");
+ const kindSelect = screen.getByLabelText("config-arg-n1-step-mint-0-kind") as HTMLSelectElement;
+ const optionValues = Array.from(kindSelect.options).map((o) => o.value);
+ expect(optionValues).toEqual(["literal", "ref", "read"]);
+ });
+
+ it("switching arg kind to 'read' defaults to the first deploy target and its first view function, calling onUpdateSetXStep", () => {
+ const updates: Array<{ nodeId: string; stepId: string; update: { args: unknown[] } }> = [];
+ const configCallbacks = {
+ onAddConfigStep: noop,
+ onRemoveConfigStep: noop,
+ onUpdateSetXStep: (nodeId: string, stepId: string, update: { args: unknown[] }) =>
+ updates.push({ nodeId, stepId, update }),
+ onUpdateGrantRoleStep: noop,
+ deployTargets: [{ deployId: "token", contractName: "Token" }],
+ };
+ const data = { ...makeContractNodeData({ configSteps: [makeMintStep()] }), configCallbacks };
+ renderContractNode(data as unknown as ContractNodeData, "n1");
+ const kindSelect = screen.getByLabelText("config-arg-n1-step-mint-0-kind");
+ fireEvent.change(kindSelect, { target: { value: "read" } });
+ expect(updates).toHaveLength(1);
+ expect(updates[0].nodeId).toBe("n1");
+ expect(updates[0].stepId).toBe("step-mint");
+ // Token's first view/pure function in manifest order is supportsInterface(bytes4).
+ expect(updates[0].update.args[0]).toEqual({ kind: "read", contract: "token", function: "supportsInterface" });
+ });
+
+ it("renders read-source-contract and read-function pickers once kind is 'read', listing deployTargets and the source's view functions", () => {
+ const step = makeMintStep([{ kind: "read", contract: "token", function: "decimals" }]);
+ const configCallbacks = {
+ onAddConfigStep: noop,
+ onRemoveConfigStep: noop,
+ onUpdateSetXStep: noop,
+ onUpdateGrantRoleStep: noop,
+ deployTargets: [{ deployId: "token", contractName: "Token" }],
+ };
+ const data = { ...makeContractNodeData({ configSteps: [step] }), configCallbacks };
+ renderContractNode(data as unknown as ContractNodeData, "n1");
+ const contractSelect = screen.getByLabelText("config-arg-n1-step-mint-0-read-contract") as HTMLSelectElement;
+ expect(contractSelect.value).toBe("token");
+ expect(contractSelect.innerHTML).toContain("token");
+ const fnSelect = screen.getByLabelText("config-arg-n1-step-mint-0-read-function") as HTMLSelectElement;
+ expect(fnSelect.value).toBe("decimals");
+ expect(fnSelect.innerHTML).toContain("decimals()");
+ });
+
+ it("switching the read view function calls onUpdateSetXStep with the updated function name", () => {
+ const updates: Array<{ update: { args: unknown[] } }> = [];
+ const step = makeMintStep([{ kind: "read", contract: "token", function: "decimals" }]);
+ const configCallbacks = {
+ onAddConfigStep: noop,
+ onRemoveConfigStep: noop,
+ onUpdateSetXStep: (_nodeId: string, _stepId: string, update: { args: unknown[] }) => updates.push({ update }),
+ onUpdateGrantRoleStep: noop,
+ deployTargets: [{ deployId: "token", contractName: "Token" }],
+ };
+ const data = { ...makeContractNodeData({ configSteps: [step] }), configCallbacks };
+ renderContractNode(data as unknown as ContractNodeData, "n1");
+ const fnSelect = screen.getByLabelText("config-arg-n1-step-mint-0-read-function");
+ fireEvent.change(fnSelect, { target: { value: "totalSupply" } });
+ expect(updates).toHaveLength(1);
+ expect(updates[0].update.args[0]).toEqual({ kind: "read", contract: "token", function: "totalSupply" });
+ });
+
+ it("changing the read source contract re-picks the first view function of the new contract (parity w/ ConfigPanel)", () => {
+ const updates: Array<{ update: { args: unknown[] } }> = [];
+ const step = makeMintStep([{ kind: "read", contract: "token", function: "decimals" }]);
+ const configCallbacks = {
+ onAddConfigStep: noop,
+ onRemoveConfigStep: noop,
+ onUpdateSetXStep: (_nodeId: string, _stepId: string, update: { args: unknown[] }) => updates.push({ update }),
+ onUpdateGrantRoleStep: noop,
+ deployTargets: [
+ { deployId: "token", contractName: "Token" },
+ { deployId: "vault", contractName: "Vault" },
+ ],
+ };
+ const data = { ...makeContractNodeData({ configSteps: [step] }), configCallbacks };
+ renderContractNode(data as unknown as ContractNodeData, "n1");
+ const contractSelect = screen.getByLabelText("config-arg-n1-step-mint-0-read-contract");
+ fireEvent.change(contractSelect, { target: { value: "vault" } });
+ expect(updates).toHaveLength(1);
+ // Vault's first view/pure function in manifest order is balanceOf(address).
+ expect(updates[0].update.args[0]).toEqual({ kind: "read", contract: "vault", function: "balanceOf" });
+ });
+});
+
// ---------------------------------------------------------------------------
// 3. OrderedConfigPanel — add/reorder/remove
// ---------------------------------------------------------------------------
@@ -797,6 +910,112 @@ describe("graphToSpec — address ref normalization", () => {
});
});
+// ---------------------------------------------------------------------------
+// 5a-bis. graphToSpec — "read" arg normalization (issue #147)
+// ---------------------------------------------------------------------------
+
+describe("graphToSpec — read arg normalization (issue #147)", () => {
+ it("normalizes a no-arg StudioReadRef to a ReadArg in per-node setX step args", () => {
+ const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" };
+ const node = makeGraphNode("n1", "vault", "Vault", {
+ configSteps: [
+ { kind: "setX", id: "s1", functionName: "setDecimals", args: [readRef] },
+ ],
+ });
+ const { config } = graphToSpec([node], []);
+ const step = config.steps[0] as { kind: "setX"; args?: Array<{ kind: string; contract: string; function: string }> };
+ expect(step.args).toHaveLength(1);
+ expect(step.args![0]).toEqual({ kind: "read", contract: "token", function: "decimals" });
+ });
+
+ it("normalizes a StudioReadRef in ordered step args", () => {
+ const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" };
+ const node = makeGraphNode("n1", "token", "Token");
+ const orderedSteps: StudioOrderedConfigStep[] = [
+ {
+ kind: "setX",
+ id: "os1",
+ functionName: "setDecimals",
+ target: "token",
+ args: [readRef],
+ },
+ ];
+ const { config } = graphToSpec([node], [], orderedSteps);
+ const ordStep = config.orderedSteps![0] as { kind: "setX"; args?: Array<{ kind: string; contract: string; function: string }> };
+ expect(ordStep.args).toHaveLength(1);
+ expect(ordStep.args![0]).toEqual({ kind: "read", contract: "token", function: "decimals" });
+ });
+
+ it("normalizes a StudioReadRef with its own literal/addressRef args (ReadCallArg)", () => {
+ const readRef: StudioReadRef = {
+ kind: "read",
+ contract: "registry",
+ function: "lookup",
+ args: ["minter", { kind: "addressRef", deployId: "token" }],
+ };
+ const node = makeGraphNode("n1", "vault", "Vault", {
+ configSteps: [
+ { kind: "setX", id: "s1", functionName: "setLookup", args: [readRef] },
+ ],
+ });
+ const { config } = graphToSpec([node], []);
+ const step = config.steps[0] as {
+ kind: "setX";
+ args?: Array<{ kind: string; contract: string; function: string; args?: unknown[] }>;
+ };
+ expect(step.args![0]).toEqual({
+ kind: "read",
+ contract: "registry",
+ function: "lookup",
+ args: [
+ { kind: "literal", value: "minter" },
+ { kind: "ref", contract: "token" },
+ ],
+ });
+ });
+
+ it("mixed args: StudioReadRef + literal + addressRef are all normalized correctly", () => {
+ const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" };
+ const addressRef: StudioAddressRef = { kind: "addressRef", deployId: "registry" };
+ const node = makeGraphNode("n1", "vault", "Vault", {
+ configSteps: [
+ { kind: "setX", id: "s1", functionName: "initialize", args: [readRef, "100", addressRef] },
+ ],
+ });
+ const { config } = graphToSpec([node], []);
+ const step = config.steps[0] as { kind: "setX"; args?: Array> };
+ expect(step.args).toHaveLength(3);
+ expect(step.args![0]).toEqual({ kind: "read", contract: "token", function: "decimals" });
+ expect(step.args![1]).toEqual({ kind: "literal", value: 100 });
+ expect(step.args![2]).toEqual({ kind: "ref", contract: "registry" });
+ });
+
+ it("emitted ReadArg has exactly the ReadArg fields — no leftover studio-only keys (e.g. no empty 'args' when the read call has no args of its own)", () => {
+ const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" };
+ const node = makeGraphNode("n1", "vault", "Vault", {
+ configSteps: [
+ { kind: "setX", id: "s1", functionName: "setDecimals", args: [readRef] },
+ ],
+ });
+ const { config } = graphToSpec([node], []);
+ const step = config.steps[0] as { kind: "setX"; args?: Array> };
+ expect(Object.keys(step.args![0]).sort()).toEqual(["contract", "function", "kind"]);
+ });
+
+ it("produces a valid ConfigSpec after read-arg normalization", () => {
+ const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" };
+ const token = makeGraphNode("n1", "token", "Token");
+ const vault = makeGraphNode("n2", "vault", "Vault", {
+ configSteps: [
+ { kind: "setX", id: "s1", functionName: "setFeeBps", args: [readRef] },
+ ],
+ });
+ const { deployment, config } = graphToSpec([token, vault], []);
+ expect(validateSpec(deployment).ok).toBe(true);
+ expect(validateConfig(config, deployment).ok).toBe(true);
+ });
+});
+
// ---------------------------------------------------------------------------
// 5b. OrderedArgInput — literal/addressRef toggle in OrderedStepCard
//
@@ -914,6 +1133,92 @@ describe("OrderedConfigPanel — OrderedArgInput literal/addressRef toggle", ()
});
});
+// ---------------------------------------------------------------------------
+// 5b-bis. OrderedArgInput — "read" arg kind (issue #147)
+// ---------------------------------------------------------------------------
+
+describe("OrderedConfigPanel — OrderedArgInput 'read' kind (issue #147)", () => {
+ function makeRegistryStep(id: string, args: StudioOrderedConfigStep["args"] = ["myKey"]): StudioOrderedConfigStep {
+ return {
+ kind: "setX",
+ id,
+ functionName: "register",
+ functionSignature: "register(string,address)",
+ target: "reg",
+ args,
+ };
+ }
+
+ const deployTargets = [
+ { deployId: "reg", contractName: "Registry" },
+ { deployId: "token", contractName: "Token" },
+ ];
+
+ it("offers a 'read' option alongside literal/address ref in the arg-kind toggle", () => {
+ render(
+
+ );
+ const kindSelect = screen.getByRole("combobox", { name: "ordered-arg-s1-0-kind" }) as HTMLSelectElement;
+ const optionValues = Array.from(kindSelect.options).map((o) => o.value);
+ expect(optionValues).toEqual(["literal", "ref", "read"]);
+ });
+
+ it("switching arg kind to 'read' defaults to the first deploy target and its first view function", () => {
+ const updates: Array<{ stepId: string; update: { args: unknown[] } }> = [];
+ render(
+ updates.push({ stepId, update: update as { args: unknown[] } })}
+ onMoveUp={noop}
+ onMoveDown={noop}
+ />
+ );
+ const kindSelect = screen.getByRole("combobox", { name: "ordered-arg-s1-0-kind" });
+ fireEvent.change(kindSelect, { target: { value: "read" } });
+ expect(updates).toHaveLength(1);
+ // deployTargets[0] is "reg" (Registry); Registry's first view/pure function
+ // in manifest order is lookup(string), used as the default.
+ const newArgs = updates[0].update.args;
+ expect(newArgs[0]).toEqual({ kind: "read", contract: "reg", function: "lookup" });
+ });
+
+ it("renders read-source-contract and read-function pickers once kind is 'read'; switching the view function calls onUpdateStep", () => {
+ const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" };
+ const updates: Array<{ stepId: string; update: { args: unknown[] } }> = [];
+ render(
+ updates.push({ stepId, update: update as { args: unknown[] } })}
+ onMoveUp={noop}
+ onMoveDown={noop}
+ />
+ );
+ const contractSelect = screen.getByRole("combobox", { name: "ordered-arg-s1-0-read-contract" }) as HTMLSelectElement;
+ expect(contractSelect.value).toBe("token");
+ const fnSelect = screen.getByRole("combobox", { name: "ordered-arg-s1-0-read-function" }) as HTMLSelectElement;
+ expect(fnSelect.value).toBe("decimals");
+ expect(fnSelect.innerHTML).toContain("decimals()");
+
+ fireEvent.change(fnSelect, { target: { value: "balanceOf" } });
+ expect(updates).toHaveLength(1);
+ expect(updates[0].update.args[0]).toEqual({ kind: "read", contract: "token", function: "balanceOf" });
+ });
+});
+
// ---------------------------------------------------------------------------
// 5c. graphToSpec — orderedSteps with validateConfig + order preservation
// ---------------------------------------------------------------------------
diff --git a/apps/studio/test/manifest-dedup.test.ts b/apps/studio/test/manifest-dedup.test.ts
new file mode 100644
index 0000000..f8961f4
--- /dev/null
+++ b/apps/studio/test/manifest-dedup.test.ts
@@ -0,0 +1,68 @@
+/**
+ * manifest-dedup.test.ts
+ *
+ * Issue #147 tests-lens finding #5 ("dedup honesty"): manifest.test.ts's
+ * "returned functions have no duplicate signatures" tests for
+ * getStateChangingFunctions/getViewFunctions only run against the REAL
+ * generated manifest (contracts.generated.json), whose entries are already
+ * deduped upstream by deriveManifest's own most-derived-wins `seenSignatures`
+ * pass (src/manifest/derive.ts). No project fixture has two functions in the
+ * SAME contract's final `.functions` array sharing a signature, so those
+ * tests never actually exercise getStateChangingFunctions/getViewFunctions's
+ * own `if (seen.has(fn.signature)) continue;` dedup line — they pass
+ * trivially regardless of whether that line exists.
+ *
+ * This file mocks contracts.generated.json with a synthetic contract whose
+ * `functions` array contains a genuine duplicate signature (something that
+ * can never come out of deriveManifests, but IS a legal ContractManifest
+ * shape per the type, and is exactly the shape getStateChangingFunctions/
+ * getViewFunctions must defend against), and asserts the collision is
+ * actually skipped and the FIRST occurrence wins.
+ */
+
+import { describe, it, expect, vi } from "vitest";
+import type { ContractManifest } from "../src/manifest/types.js";
+
+const DUP_FIXTURE: ContractManifest[] = [
+ {
+ name: "DupFixture",
+ sourcePath: "src/DupFixture.sol",
+ packageSegments: ["src"],
+ constructorArgs: [],
+ inheritance: ["DupFixture", "Base"],
+ functions: [
+ // Two entries sharing "foo()" — a genuine duplicate VIEW signature.
+ { name: "foo", signature: "foo()", declaredIn: "Base", inputs: [], stateMutability: "view" },
+ { name: "foo", signature: "foo()", declaredIn: "DupFixture", inputs: [], stateMutability: "view" },
+ // Two entries sharing "bar()" — a genuine duplicate STATE-CHANGING signature.
+ { name: "bar", signature: "bar()", declaredIn: "DupFixture", inputs: [], stateMutability: "nonpayable" },
+ { name: "bar", signature: "bar()", declaredIn: "Base", inputs: [], stateMutability: "nonpayable" },
+ ],
+ },
+];
+
+vi.mock("../src/manifest/contracts.generated.json", () => ({
+ default: DUP_FIXTURE,
+}));
+
+const { getViewFunctions, getStateChangingFunctions } = await import("../src/manifest/index.js");
+
+describe("getViewFunctions / getStateChangingFunctions — genuine duplicate-signature collision (issue #147 finding #5)", () => {
+ it("getViewFunctions collapses a genuine duplicate view-function signature to ONE entry, keeping the first occurrence", () => {
+ const fns = getViewFunctions("DupFixture");
+ expect(fns).toHaveLength(1);
+ expect(fns[0]).toEqual({ name: "foo", signature: "foo()", declaredIn: "Base", inputs: [], stateMutability: "view" });
+ });
+
+ it("getStateChangingFunctions collapses a genuine duplicate state-changing-function signature to ONE entry, keeping the first occurrence", () => {
+ const fns = getStateChangingFunctions("DupFixture");
+ expect(fns).toHaveLength(1);
+ expect(fns[0]).toEqual({
+ name: "bar",
+ signature: "bar()",
+ declaredIn: "DupFixture",
+ inputs: [],
+ stateMutability: "nonpayable",
+ });
+ });
+});
diff --git a/apps/studio/test/manifest.test.ts b/apps/studio/test/manifest.test.ts
index a52d4c9..33b801d 100644
--- a/apps/studio/test/manifest.test.ts
+++ b/apps/studio/test/manifest.test.ts
@@ -8,7 +8,7 @@
import { describe, it, expect } from "vitest";
import fixtureOutputs from "./fixtures/contracts-build-info.json";
-import { contractManifest, getContract, getStateChangingFunctions } from "../src/manifest/index.js";
+import { contractManifest, getContract, getStateChangingFunctions, getViewFunctions } from "../src/manifest/index.js";
import type { ContractManifest } from "../src/manifest/types.js";
import { deriveManifests, derivePackageSegments } from "../src/manifest/derive.js";
import type { FoundryContractOutput } from "../src/manifest/derive.js";
@@ -334,6 +334,62 @@ describe("getStateChangingFunctions", () => {
});
});
+// ---------------------------------------------------------------------------
+// getViewFunctions — issue #147: the "read" config-call arg kind's view/pure
+// function picker lists a contract's REAL read-only functions.
+// ---------------------------------------------------------------------------
+
+describe("getViewFunctions", () => {
+ it("returns an empty array for a contract not in the manifest (free-text fallback never crashes)", () => {
+ expect(getViewFunctions("NonExistentContract123")).toEqual([]);
+ });
+
+ it("Token: includes real view/pure functions (name, symbol, decimals, totalSupply, balanceOf, allowance, hasRole, getRoleAdmin, supportsInterface)", () => {
+ const names = getViewFunctions("Token").map((f) => f.name);
+ for (const expected of ["name", "symbol", "decimals", "totalSupply", "balanceOf", "allowance", "hasRole", "getRoleAdmin", "supportsInterface"]) {
+ expect(names).toContain(expected);
+ }
+ });
+
+ it("Token: excludes state-changing functions (mint, grantRole, revokeRole, renounceRole, transfer, approve, transferFrom)", () => {
+ const names = getViewFunctions("Token").map((f) => f.name);
+ for (const writeFn of ["mint", "grantRole", "revokeRole", "renounceRole", "transfer", "approve", "transferFrom"]) {
+ expect(names).not.toContain(writeFn);
+ }
+ });
+
+ it("Token: decimals() is a no-arg view function", () => {
+ const decimals = getViewFunctions("Token").find((f) => f.name === "decimals");
+ expect(decimals).toBeDefined();
+ expect(decimals!.stateMutability).toBe("view");
+ expect(decimals!.inputs).toEqual([]);
+ });
+
+ it("returned functions have no duplicate signatures", () => {
+ for (const name of ["Token", "Vault", "Registry", "VaultERC4626"]) {
+ const sigs = getViewFunctions(name).map((f) => f.signature);
+ expect(sigs.length).toBe(new Set(sigs).size);
+ }
+ });
+
+ it("preserves the manifest's declared order", () => {
+ const manifestOrder = getContract("Token")!
+ .functions.filter((f) => f.stateMutability === "view" || f.stateMutability === "pure")
+ .map((f) => f.signature);
+ expect(getViewFunctions("Token").map((f) => f.signature)).toEqual(manifestOrder);
+ });
+
+ it("getStateChangingFunctions and getViewFunctions partition a contract's functions with no overlap", () => {
+ for (const name of ["Token", "Vault", "Registry", "VaultERC4626", "Overloaded"]) {
+ const writeSigs = new Set(getStateChangingFunctions(name).map((f) => f.signature));
+ const viewSigs = new Set(getViewFunctions(name).map((f) => f.signature));
+ for (const sig of viewSigs) {
+ expect(writeSigs.has(sig)).toBe(false);
+ }
+ }
+ });
+});
+
// ---------------------------------------------------------------------------
// Pure derivation tests (against fixture build-info, independent of forge)
// ---------------------------------------------------------------------------
diff --git a/apps/studio/test/useGraph.test.ts b/apps/studio/test/useGraph.test.ts
index 6567a0c..7898838 100644
--- a/apps/studio/test/useGraph.test.ts
+++ b/apps/studio/test/useGraph.test.ts
@@ -705,6 +705,36 @@ describe("stepReferencesDeployId", () => {
expect(stepReferencesDeployId(setXStep, "")).toBe(false);
expect(stepReferencesDeployId(grantRoleStep, "")).toBe(false);
});
+
+ it("matches a setX step with a read arg whose source contract equals the deployId (issue #147)", () => {
+ const step = {
+ kind: "setX" as const,
+ id: "s1",
+ functionName: "setFee",
+ args: [{ kind: "read" as const, contract: "token", function: "decimals" }],
+ };
+ expect(stepReferencesDeployId(step, "token")).toBe(true);
+ expect(stepReferencesDeployId(step, "other")).toBe(false);
+ });
+
+ it("matches a setX step with a read arg whose nested addressRef call-arg equals the deployId (issue #147)", () => {
+ const step = {
+ kind: "setX" as const,
+ id: "s1",
+ functionName: "setFee",
+ args: [
+ {
+ kind: "read" as const,
+ contract: "token",
+ function: "allowance",
+ args: [{ kind: "addressRef" as const, deployId: "oracle" }],
+ },
+ ],
+ };
+ expect(stepReferencesDeployId(step, "oracle")).toBe(true);
+ expect(stepReferencesDeployId(step, "token")).toBe(true);
+ expect(stepReferencesDeployId(step, "other")).toBe(false);
+ });
});
// ---------------------------------------------------------------------------
@@ -823,6 +853,30 @@ describe("useGraph — node deletion (onNodesChange remove) cleans up dangling r
expect(nd(result.current.nodes[0]).configSteps).toHaveLength(0);
});
+ it("removes a read-arg step (setX) on a surviving node referencing the deleted contract's deployId (issue #147)", () => {
+ const { result } = renderHook(() => useGraph());
+ act(() => {
+ result.current.addContractFromManifest(REGISTRY_MANIFEST);
+ result.current.addContractFromManifest(REGISTRY_MANIFEST);
+ });
+ const [n1, n2] = result.current.nodes;
+
+ act(() => nd(result.current.nodes[0]).onUpdateDeployId(n1.id, "registryA"));
+ act(() => result.current.addConfigStep(n2.id, A_WRITE_FN));
+ const stepId = nd(result.current.nodes[1]).configSteps[0].id;
+ act(() =>
+ result.current.updateSetXStep(n2.id, stepId, {
+ functionName: "setRegistry",
+ args: [{ kind: "read", contract: "registryA", function: "owner" }],
+ }),
+ );
+
+ act(() => result.current.onNodesChange([{ id: n1.id, type: "remove" }]));
+
+ expect(result.current.nodes).toHaveLength(1);
+ expect(nd(result.current.nodes[0]).configSteps).toHaveLength(0);
+ });
+
it("removes a global ordered step referencing the deleted contract's deployId", () => {
const { result } = renderHook(() => useGraph());
act(() => result.current.addContractFromManifest(REGISTRY_MANIFEST));