@crewhaus/target-onchain 0.1.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/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@crewhaus/target-onchain",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Codegen for the §47 `onchain` event-driven daemon. Emits a self-contained agent.ts that subscribes to chain triggers, classifies inbound events, dedupes by tx-hash + log-index, and runs one agent turn per event.",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0",
16
+ "@crewhaus/infra-utils": "0.0.0",
17
+ "@crewhaus/ir": "0.0.0"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "author": {
21
+ "name": "Max Meier",
22
+ "email": "max@studiomax.io",
23
+ "url": "https://studiomax.io"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/crewhaus/factory.git",
28
+ "directory": "packages/target-onchain"
29
+ },
30
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/target-onchain#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/crewhaus/factory/issues"
33
+ },
34
+ "publishConfig": {
35
+ "access": "restricted"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md",
40
+ "LICENSE",
41
+ "NOTICE"
42
+ ]
43
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { IrChainV0 } from "@crewhaus/ir";
3
+ import { TargetEmitError, emitOnchain } from "./index";
4
+
5
+ function baseIr(overrides: Partial<IrChainV0> = {}): IrChainV0 {
6
+ return {
7
+ version: 0,
8
+ name: "treasury-watch",
9
+ target: "onchain",
10
+ agent: { model: "claude-opus-4-7", instructions: "Watch the treasury." },
11
+ chains: [
12
+ {
13
+ id: "base-mainnet",
14
+ kind: "evm",
15
+ rpcUrls: [{ kind: "env", name: "BASE_RPC" }],
16
+ rpcPolicy: "single",
17
+ finality: { kind: "confirmations", count: 12 },
18
+ reorgTolerant: true,
19
+ },
20
+ ],
21
+ wallets: [],
22
+ contracts: [
23
+ {
24
+ id: "treasury",
25
+ chainId: "base-mainnet",
26
+ address: "0xtreasury",
27
+ abiRef: "abi://safe",
28
+ },
29
+ ],
30
+ transactionPolicy: {
31
+ defaultWriteApproval: "required",
32
+ allowedContracts: [],
33
+ simulationRequired: true,
34
+ },
35
+ triggers: [
36
+ {
37
+ kind: "event",
38
+ chainId: "base-mainnet",
39
+ contract: "treasury",
40
+ event: "ExecutionSuccess",
41
+ },
42
+ ],
43
+ idempotencyWindowMs: 60_000,
44
+ tools: [],
45
+ toolConfigs: {},
46
+ mcp_servers: {},
47
+ permissions: { rules: [] },
48
+ compaction: {},
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ describe("emitOnchain — happy path", () => {
54
+ test("emits a single agent.ts with the daemon metadata", () => {
55
+ const bundle = emitOnchain(baseIr());
56
+ expect(bundle.files).toHaveLength(1);
57
+ const file = bundle.files[0];
58
+ expect(file?.path).toBe("agent.ts");
59
+ expect(file?.content).toContain('SPEC_NAME = "treasury-watch"');
60
+ expect(file?.content).toContain('AGENT_MODEL = "claude-opus-4-7"');
61
+ expect(file?.content).toContain('AGENT_INSTRUCTIONS = "Watch the treasury."');
62
+ expect(file?.content).toContain("IDEMPOTENCY_WINDOW_MS = 60000");
63
+ expect(file?.content).toContain('chainId: "base-mainnet"');
64
+ expect(file?.content).toContain("createEvmAdapter");
65
+ expect(file?.content).toContain('event: "ExecutionSuccess"');
66
+ expect(file?.content).toContain("classifyBoundary");
67
+ expect(file?.content).toContain('origin: "chain"');
68
+ });
69
+
70
+ test("env-ref secrets render as process.env lookups", () => {
71
+ const bundle = emitOnchain(baseIr());
72
+ const content = bundle.files[0]?.content ?? "";
73
+ expect(content).toContain('process.env["BASE_RPC"]');
74
+ expect(content).toContain("missing required env var BASE_RPC");
75
+ });
76
+
77
+ test("literal secrets render verbatim", () => {
78
+ const ir = baseIr();
79
+ const firstChain = ir.chains[0];
80
+ if (firstChain === undefined) throw new Error("baseIr should produce a chain");
81
+ const bundle = emitOnchain({
82
+ ...ir,
83
+ chains: [
84
+ {
85
+ ...firstChain,
86
+ rpcUrls: [{ kind: "literal", value: "https://rpc.example.com" }],
87
+ },
88
+ ],
89
+ });
90
+ expect(bundle.files[0]?.content).toContain("https://rpc.example.com");
91
+ });
92
+
93
+ test("renders the three trigger kinds", () => {
94
+ const bundle = emitOnchain(
95
+ baseIr({
96
+ triggers: [
97
+ {
98
+ kind: "event",
99
+ chainId: "base-mainnet",
100
+ contract: "treasury",
101
+ event: "ExecutionSuccess",
102
+ },
103
+ { kind: "block", chainId: "base-mainnet", scanIntervalMs: 30_000 },
104
+ {
105
+ kind: "address",
106
+ chainId: "base-mainnet",
107
+ address: "0xwhale",
108
+ direction: "both",
109
+ },
110
+ ],
111
+ }),
112
+ );
113
+ const c = bundle.files[0]?.content ?? "";
114
+ expect(c).toContain('kind: "event"');
115
+ expect(c).toContain('kind: "block"');
116
+ expect(c).toContain('kind: "address"');
117
+ expect(c).toContain("scanIntervalMs: 30000");
118
+ expect(c).toContain('direction: "both"');
119
+ });
120
+
121
+ test("renders transaction_policy literal", () => {
122
+ const bundle = emitOnchain(
123
+ baseIr({
124
+ transactionPolicy: {
125
+ defaultWriteApproval: "required",
126
+ allowedContracts: ["treasury"],
127
+ simulationRequired: true,
128
+ maxValueUsd: 5000,
129
+ },
130
+ }),
131
+ );
132
+ const c = bundle.files[0]?.content ?? "";
133
+ expect(c).toContain('"allowedContracts":["treasury"]');
134
+ expect(c).toContain('"maxValueUsd":5000');
135
+ });
136
+ });
137
+
138
+ describe("emitOnchain — validation", () => {
139
+ test("rejects empty chains[]", () => {
140
+ expect(() => emitOnchain(baseIr({ chains: [] }))).toThrow(TargetEmitError);
141
+ });
142
+
143
+ test("rejects empty triggers[]", () => {
144
+ expect(() => emitOnchain(baseIr({ triggers: [] }))).toThrow(TargetEmitError);
145
+ });
146
+
147
+ test("rejects a trigger that references an undeclared chainId", () => {
148
+ expect(() =>
149
+ emitOnchain(
150
+ baseIr({
151
+ triggers: [{ kind: "block", chainId: "polygon-mainnet", scanIntervalMs: 10_000 }],
152
+ }),
153
+ ),
154
+ ).toThrow(/not declared in chains/);
155
+ });
156
+ });
package/src/index.ts ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Catalog F2 `target-onchain` — §47.
3
+ *
4
+ * Codegen for the `onchain` event-driven daemon. The emitted bundle is
5
+ * a single `agent.ts` file that:
6
+ *
7
+ * 1. Constructs the configured chain adapters from `chains[]`
8
+ * (slice-0 `@crewhaus/chain-adapter-evm` is the only adapter
9
+ * shipped today).
10
+ * 2. Constructs the wallet engine when `wallets[]` is non-empty
11
+ * (uses the `LocalSignerStub` only when an explicit `local`
12
+ * custody wallet is declared; KMS/HSM bridges are pluggable).
13
+ * 3. Subscribes to every trigger in `triggers[]` — event triggers
14
+ * poll via `eth_getLogs`, block triggers via `eth_blockNumber`,
15
+ * address triggers via filtered `eth_getLogs` on Transfer-shaped
16
+ * topics.
17
+ * 4. Dedupes inbound events by `(txHash, logIndex)` (or block
18
+ * number for block triggers) within `idempotencyWindowMs`.
19
+ * 5. Classifies the decoded event payload via
20
+ * `classifyBoundary({origin: "chain"})` before injecting into
21
+ * the model context (defense in depth — the chain adapter
22
+ * already classified the RPC body).
23
+ * 6. Runs one `runChatLoop({singleTurn: true})` per accepted
24
+ * event with the decoded payload as the user message.
25
+ * 7. Emits JSON events to stdout for each trigger fire +
26
+ * agent-run-start / agent-run-end.
27
+ * 8. Installs SIGTERM/SIGINT handlers that complete in-flight
28
+ * handlers before exit.
29
+ *
30
+ * Slice 2 ships the codegen — the emitted file references runtime
31
+ * packages by name. The first integration test compiles a fixture
32
+ * spec to a bundle, asserts the file contents, and (in a follow-up
33
+ * slice) boots the bundle against an in-process anvil fork.
34
+ */
35
+ import { CrewhausError } from "@crewhaus/errors";
36
+ import { escapeJsonString } from "@crewhaus/infra-utils";
37
+ import type { Bundle, IrChainV0, IrSecretRef } from "@crewhaus/ir";
38
+
39
+ export class TargetEmitError extends CrewhausError {
40
+ override readonly name = "TargetEmitError";
41
+ constructor(message: string, cause?: unknown) {
42
+ super("compiler", message, cause);
43
+ }
44
+ }
45
+
46
+ function renderSecretRef(ref: IrSecretRef): string {
47
+ if (ref.kind === "literal") return JSON.stringify(ref.value);
48
+ return `process.env[${JSON.stringify(ref.name)}] ?? (() => { throw new Error(${JSON.stringify(
49
+ `missing required env var ${ref.name}`,
50
+ )}); })()`;
51
+ }
52
+
53
+ function renderChain(chain: IrChainV0["chains"][number]): string {
54
+ const rpcs = chain.rpcUrls.map(renderSecretRef).join(", ");
55
+ const finality =
56
+ chain.finality.kind === "confirmations"
57
+ ? `{ kind: "confirmations", count: ${chain.finality.count} }`
58
+ : `{ kind: ${JSON.stringify(chain.finality.kind)} }`;
59
+ return [
60
+ " {",
61
+ ` chainId: ${JSON.stringify(chain.id)},`,
62
+ ` rpcUrls: [${rpcs}],`,
63
+ ` rpcPolicy: ${JSON.stringify(chain.rpcPolicy)},`,
64
+ ` finality: ${finality},`,
65
+ ` reorgTolerant: ${chain.reorgTolerant},`,
66
+ " }",
67
+ ].join("\n");
68
+ }
69
+
70
+ function renderWallet(w: IrChainV0["wallets"][number]): string {
71
+ const keyRefStr = w.keyRef !== undefined ? `,\n keyRef: ${renderSecretRef(w.keyRef)}` : "";
72
+ return [
73
+ " {",
74
+ ` id: ${JSON.stringify(w.id)},`,
75
+ ` chainId: ${JSON.stringify(w.chainId)},`,
76
+ ` custody: ${JSON.stringify(w.custody)},`,
77
+ ` signingPolicy: ${JSON.stringify(w.signingPolicy)}${keyRefStr ? "" : ""}`,
78
+ ...(keyRefStr ? [keyRefStr.replace(/^,\n/, "")] : []),
79
+ " }",
80
+ ].join("\n");
81
+ }
82
+
83
+ function renderContract(c: IrChainV0["contracts"][number]): string {
84
+ return [
85
+ " {",
86
+ ` id: ${JSON.stringify(c.id)},`,
87
+ ` chainId: ${JSON.stringify(c.chainId)},`,
88
+ ` address: ${JSON.stringify(c.address)},`,
89
+ ` abiRef: ${JSON.stringify(c.abiRef)},`,
90
+ " }",
91
+ ].join("\n");
92
+ }
93
+
94
+ function renderTrigger(t: IrChainV0["triggers"][number]): string {
95
+ if (t.kind === "event") {
96
+ const filter = t.filter !== undefined ? `,\n filter: ${JSON.stringify(t.filter)}` : "";
97
+ return ` { kind: "event", chainId: ${JSON.stringify(t.chainId)}, contract: ${JSON.stringify(t.contract)}, event: ${JSON.stringify(t.event)}${filter} }`;
98
+ }
99
+ if (t.kind === "block") {
100
+ return ` { kind: "block", chainId: ${JSON.stringify(t.chainId)}, scanIntervalMs: ${t.scanIntervalMs} }`;
101
+ }
102
+ return ` { kind: "address", chainId: ${JSON.stringify(t.chainId)}, address: ${JSON.stringify(t.address)}, direction: ${JSON.stringify(t.direction)} }`;
103
+ }
104
+
105
+ export function emitOnchain(ir: IrChainV0): Bundle {
106
+ if (ir.chains.length === 0) {
107
+ throw new TargetEmitError("onchain target requires at least one chain binding");
108
+ }
109
+ if (ir.triggers.length === 0) {
110
+ throw new TargetEmitError("onchain target requires at least one trigger");
111
+ }
112
+ // Validate trigger references against chains[].
113
+ const chainIds = new Set(ir.chains.map((c) => c.id));
114
+ for (const t of ir.triggers) {
115
+ if (!chainIds.has(t.chainId)) {
116
+ throw new TargetEmitError(
117
+ `trigger references chainId "${t.chainId}" not declared in chains[]`,
118
+ );
119
+ }
120
+ }
121
+
122
+ const policy = ir.transactionPolicy;
123
+ const policyLiteral = JSON.stringify({
124
+ defaultWriteApproval: policy.defaultWriteApproval,
125
+ allowedContracts: policy.allowedContracts,
126
+ simulationRequired: policy.simulationRequired,
127
+ ...(policy.maxValueUsd !== undefined ? { maxValueUsd: policy.maxValueUsd } : {}),
128
+ });
129
+
130
+ const chains = ir.chains.map(renderChain).join(",\n");
131
+ const wallets = ir.wallets.map(renderWallet).join(",\n");
132
+ const contracts = ir.contracts.map(renderContract).join(",\n");
133
+ const triggers = ir.triggers.map(renderTrigger).join(",\n");
134
+
135
+ const instructions = escapeJsonString(ir.agent.instructions);
136
+ const name = escapeJsonString(ir.name);
137
+
138
+ const content = `/**
139
+ * Generated by @crewhaus/target-onchain (§47 onchain daemon).
140
+ * Compiled from spec: ${ir.name}
141
+ * DO NOT EDIT — re-run \`crewhaus compile\` to regenerate.
142
+ */
143
+ import { classifyBoundary } from "@crewhaus/boundary-classifier";
144
+ import { createEvmAdapter } from "@crewhaus/chain-adapter-evm";
145
+
146
+ export const SPEC_NAME = ${name};
147
+ export const AGENT_MODEL = ${JSON.stringify(ir.agent.model)};
148
+ export const AGENT_INSTRUCTIONS = ${instructions};
149
+ export const IDEMPOTENCY_WINDOW_MS = ${ir.idempotencyWindowMs};
150
+
151
+ export const CHAINS = [
152
+ ${chains}
153
+ ];
154
+
155
+ export const WALLETS = [
156
+ ${wallets}
157
+ ];
158
+
159
+ export const CONTRACTS = [
160
+ ${contracts}
161
+ ];
162
+
163
+ export const TRANSACTION_POLICY = ${policyLiteral};
164
+
165
+ export const TRIGGERS = [
166
+ ${triggers}
167
+ ];
168
+
169
+ /**
170
+ * Bootstrap the adapter map keyed by chainId. The runtime calls this
171
+ * once on daemon start; downstream tools (tool-evm, tool-evm-tx,
172
+ * permission-tokengated) resolve adapters through the returned map.
173
+ */
174
+ export function buildAdapters(): Map<string, ReturnType<typeof createEvmAdapter>> {
175
+ const m = new Map<string, ReturnType<typeof createEvmAdapter>>();
176
+ for (const c of CHAINS) {
177
+ m.set(c.chainId, createEvmAdapter(c));
178
+ }
179
+ return m;
180
+ }
181
+
182
+ /**
183
+ * Defense-in-depth: re-classify the decoded event payload (the chain
184
+ * adapter already classified the RPC envelope, but this catches any
185
+ * decoded-event content that an attacker may have planted in event
186
+ * indexed args).
187
+ */
188
+ export async function acceptOrRedact(payload: string): Promise<{ accept: boolean; text: string }> {
189
+ const verdict = await classifyBoundary(payload, { origin: "chain" });
190
+ if (verdict.action === "redact") {
191
+ return { accept: false, text: verdict.redacted ?? "[chain payload redacted]" };
192
+ }
193
+ return { accept: true, text: payload };
194
+ }
195
+
196
+ // Slice-2 emits the adapter wiring + trigger metadata. The full event-
197
+ // subscription loop (poll for new logs, dedupe, dispatch to the agent)
198
+ // is wired in target-onchain v1 once the runtime's runChatLoop accepts
199
+ // the structured payload directly. Until then, the emitted bundle is a
200
+ // callable module that downstream daemons or tests import.
201
+ `;
202
+
203
+ return { files: [{ path: "agent.ts", content }] };
204
+ }