@crewhaus/target-onchain-game 0.1.3 → 0.1.5

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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Catalog F2 `target-onchain-game` — §47.
3
+ *
4
+ * Codegen for the `onchain-game` perceive-act-perceive loop. The
5
+ * emitted bundle is a single `agent.ts` file that:
6
+ *
7
+ * 1. Constructs the chain adapter from `chain` (slice-0
8
+ * `@crewhaus/chain-adapter-evm` is the only adapter shipped
9
+ * today).
10
+ * 2. Constructs a wallet engine bound to the single player wallet.
11
+ * 3. Defines a `playOneTurn()` function that:
12
+ * a. Reads game state via `game.stateReader` (a view function).
13
+ * b. Classifies the raw state payload via
14
+ * `classifyBoundary({origin: "chain"})`.
15
+ * c. Calls the agent with the state + objective in the user
16
+ * message (one `runChatLoop({singleTurn: true})` call).
17
+ * d. Parses the agent's terminal output for a move action and
18
+ * dispatches via the wallet-engine
19
+ * `requestSignAndBroadcast()` flow.
20
+ * e. Waits for confirmation up to `moveTimeoutMs` (real-time)
21
+ * or indefinitely (turn-based / async).
22
+ * 4. For `turn-based`: loops `playOneTurn()` until the agent
23
+ * reports the objective met or the move count hits a configured
24
+ * ceiling. For `real-time`: drives the loop on a fixed cadence.
25
+ * For `async`: subscribes to a state-change event and runs one
26
+ * turn per inbound state mutation.
27
+ * 5. Emits JSON events for turn-start / turn-end / move-broadcast /
28
+ * move-confirmed / game-over.
29
+ *
30
+ * Slice 2 ships the codegen surface — the emitted file references
31
+ * runtime packages by name. A follow-up slice wires the agent run
32
+ * loop end-to-end against an anvil fork.
33
+ */
34
+ import { CrewhausError } from "@crewhaus/errors";
35
+ import type { Bundle, IrChainGameV0 } from "@crewhaus/ir";
36
+ export declare class TargetEmitError extends CrewhausError {
37
+ readonly name = "TargetEmitError";
38
+ constructor(message: string, cause?: unknown);
39
+ }
40
+ export declare function emitOnchainGame(ir: IrChainGameV0): Bundle;
@@ -33,22 +33,17 @@
33
33
  */
34
34
  import { CrewhausError } from "@crewhaus/errors";
35
35
  import { escapeJsonString } from "@crewhaus/infra-utils";
36
- import type { Bundle, IrChainGameV0, IrSecretRef } from "@crewhaus/ir";
37
-
38
36
  export class TargetEmitError extends CrewhausError {
39
- override readonly name = "TargetEmitError";
40
- constructor(message: string, cause?: unknown) {
41
- super("compiler", message, cause);
42
- }
37
+ name = "TargetEmitError";
38
+ constructor(message, cause) {
39
+ super("compiler", message, cause);
40
+ }
43
41
  }
44
-
45
- function renderSecretRef(ref: IrSecretRef): string {
46
- if (ref.kind === "literal") return JSON.stringify(ref.value);
47
- return `process.env[${JSON.stringify(ref.name)}] ?? (() => { throw new Error(${JSON.stringify(
48
- `missing required env var ${ref.name}`,
49
- )}); })()`;
42
+ function renderSecretRef(ref) {
43
+ if (ref.kind === "literal")
44
+ return JSON.stringify(ref.value);
45
+ return `process.env[${JSON.stringify(ref.name)}] ?? (() => { throw new Error(${JSON.stringify(`missing required env var ${ref.name}`)}); })()`;
50
46
  }
51
-
52
47
  /**
53
48
  * #159 (CWE-798) — a wallet signing key must never be a bare literal in
54
49
  * the emitted DO-NOT-EDIT artifact. The compiler's `lowerWalletKeyRef`
@@ -58,76 +53,56 @@ function renderSecretRef(ref: IrSecretRef): string {
58
53
  * key handles are permitted as literals.
59
54
  */
60
55
  const KEY_HANDLE_RE = /^(kms|hsm):\/\/.+/;
61
- function renderWalletKeyRef(walletId: string, ref: IrSecretRef): string {
62
- if (ref.kind === "literal" && !KEY_HANDLE_RE.test(ref.value)) {
63
- throw new TargetEmitError(
64
- `wallet "${walletId}" keyRef is a literal signing key; refusing to embed it in the generated bundle. Use an environment reference or a kms:// / hsm:// handle.`,
65
- );
66
- }
67
- return renderSecretRef(ref);
56
+ function renderWalletKeyRef(walletId, ref) {
57
+ if (ref.kind === "literal" && !KEY_HANDLE_RE.test(ref.value)) {
58
+ throw new TargetEmitError(`wallet "${walletId}" keyRef is a literal signing key; refusing to embed it in the generated bundle. Use an environment reference or a kms:// / hsm:// handle.`);
59
+ }
60
+ return renderSecretRef(ref);
68
61
  }
69
-
70
- export function emitOnchainGame(ir: IrChainGameV0): Bundle {
71
- if (ir.chain.id !== ir.wallet.chainId) {
72
- throw new TargetEmitError(
73
- `wallet.chainId "${ir.wallet.chainId}" does not match chain.id "${ir.chain.id}"`,
74
- );
75
- }
76
- if (ir.chain.id !== ir.game.contract.chainId) {
77
- throw new TargetEmitError(
78
- `game.contract.chainId "${ir.game.contract.chainId}" does not match chain.id "${ir.chain.id}"`,
79
- );
80
- }
81
- if (ir.game.turnSemantics === "real-time" && ir.game.moveTimeoutMs === undefined) {
82
- throw new TargetEmitError(
83
- "real-time games require game.moveTimeoutMs so the loop can bound per-move spend",
84
- );
85
- }
86
-
87
- const rpcs = ir.chain.rpcUrls.map(renderSecretRef).join(", ");
88
- const finality =
89
- ir.chain.finality.kind === "confirmations"
90
- ? `{ kind: "confirmations", count: ${ir.chain.finality.count} }`
91
- : `{ kind: ${JSON.stringify(ir.chain.finality.kind)} }`;
92
-
93
- const policy = ir.transactionPolicy;
94
- // #151 — resolve the declared game contract (id -> address) into the policy
95
- // so the wallet-engine can bind `tx.to` to the address registered for the
96
- // claimed contractId. A game is single-contract, so the map carries exactly
97
- // the one entry the move-broadcast flow can target.
98
- const contractAddresses: Record<string, string> = {
99
- [ir.game.contract.id]: ir.game.contract.address,
100
- };
101
- const policyLiteral = JSON.stringify({
102
- defaultWriteApproval: policy.defaultWriteApproval,
103
- allowedContracts: policy.allowedContracts,
104
- simulationRequired: policy.simulationRequired,
105
- ...(policy.maxValueUsd !== undefined ? { maxValueUsd: policy.maxValueUsd } : {}),
106
- ...(policy.maxValueWei !== undefined ? { maxValueWei: policy.maxValueWei } : {}),
107
- contractAddresses,
108
- });
109
-
110
- const walletKeyRef =
111
- ir.wallet.keyRef !== undefined
112
- ? `, keyRef: ${renderWalletKeyRef(ir.wallet.id, ir.wallet.keyRef)}`
113
- : "";
114
-
115
- const objectiveField =
116
- ir.game.objective !== undefined ? `\n objective: ${escapeJsonString(ir.game.objective)},` : "";
117
- const moveTimeoutField =
118
- ir.game.moveTimeoutMs !== undefined ? `\n moveTimeoutMs: ${ir.game.moveTimeoutMs},` : "";
119
- const actionsContractField =
120
- ir.game.actionsContract !== undefined
121
- ? `\n actionsContract: ${JSON.stringify(ir.game.actionsContract)},`
122
- : "";
123
-
124
- const instructions = escapeJsonString(ir.agent.instructions);
125
- const name = escapeJsonString(ir.name);
126
-
127
- // Header is a `//` line comment using the JSON-escaped name: a raw ir.name in
128
- // a block comment lets a crafted spec.name containing `*/` (or a newline) break
129
- // out of the comment and inject top-level code — RCE on build/run (#147).
130
- const content = `// Generated by @crewhaus/target-onchain-game (§47 onchain-game).
62
+ export function emitOnchainGame(ir) {
63
+ if (ir.chain.id !== ir.wallet.chainId) {
64
+ throw new TargetEmitError(`wallet.chainId "${ir.wallet.chainId}" does not match chain.id "${ir.chain.id}"`);
65
+ }
66
+ if (ir.chain.id !== ir.game.contract.chainId) {
67
+ throw new TargetEmitError(`game.contract.chainId "${ir.game.contract.chainId}" does not match chain.id "${ir.chain.id}"`);
68
+ }
69
+ if (ir.game.turnSemantics === "real-time" && ir.game.moveTimeoutMs === undefined) {
70
+ throw new TargetEmitError("real-time games require game.moveTimeoutMs so the loop can bound per-move spend");
71
+ }
72
+ const rpcs = ir.chain.rpcUrls.map(renderSecretRef).join(", ");
73
+ const finality = ir.chain.finality.kind === "confirmations"
74
+ ? `{ kind: "confirmations", count: ${ir.chain.finality.count} }`
75
+ : `{ kind: ${JSON.stringify(ir.chain.finality.kind)} }`;
76
+ const policy = ir.transactionPolicy;
77
+ // #151 — resolve the declared game contract (id -> address) into the policy
78
+ // so the wallet-engine can bind `tx.to` to the address registered for the
79
+ // claimed contractId. A game is single-contract, so the map carries exactly
80
+ // the one entry the move-broadcast flow can target.
81
+ const contractAddresses = {
82
+ [ir.game.contract.id]: ir.game.contract.address,
83
+ };
84
+ const policyLiteral = JSON.stringify({
85
+ defaultWriteApproval: policy.defaultWriteApproval,
86
+ allowedContracts: policy.allowedContracts,
87
+ simulationRequired: policy.simulationRequired,
88
+ ...(policy.maxValueUsd !== undefined ? { maxValueUsd: policy.maxValueUsd } : {}),
89
+ ...(policy.maxValueWei !== undefined ? { maxValueWei: policy.maxValueWei } : {}),
90
+ contractAddresses,
91
+ });
92
+ const walletKeyRef = ir.wallet.keyRef !== undefined
93
+ ? `, keyRef: ${renderWalletKeyRef(ir.wallet.id, ir.wallet.keyRef)}`
94
+ : "";
95
+ const objectiveField = ir.game.objective !== undefined ? `\n objective: ${escapeJsonString(ir.game.objective)},` : "";
96
+ const moveTimeoutField = ir.game.moveTimeoutMs !== undefined ? `\n moveTimeoutMs: ${ir.game.moveTimeoutMs},` : "";
97
+ const actionsContractField = ir.game.actionsContract !== undefined
98
+ ? `\n actionsContract: ${JSON.stringify(ir.game.actionsContract)},`
99
+ : "";
100
+ const instructions = escapeJsonString(ir.agent.instructions);
101
+ const name = escapeJsonString(ir.name);
102
+ // Header is a `//` line comment using the JSON-escaped name: a raw ir.name in
103
+ // a block comment lets a crafted spec.name containing `*/` (or a newline) break
104
+ // out of the comment and inject top-level code — RCE on build/run (#147).
105
+ const content = `// Generated by @crewhaus/target-onchain-game (§47 onchain-game).
131
106
  // Compiled from spec: ${name}
132
107
  // DO NOT EDIT — re-run \`crewhaus compile\` to regenerate.
133
108
  import { classifyBoundary } from "@crewhaus/boundary-classifier";
@@ -209,6 +184,5 @@ function selectorOf(method: string): string {
209
184
  );
210
185
  }
211
186
  `;
212
-
213
- return { files: [{ path: "agent.ts", content }] };
187
+ return { files: [{ path: "agent.ts", content }] };
214
188
  }
package/package.json CHANGED
@@ -1,20 +1,23 @@
1
1
  {
2
2
  "name": "@crewhaus/target-onchain-game",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Codegen for the §47 `onchain-game` perceive-act-perceive loop. Emits a daemon that reads game state, asks the model for a move, broadcasts the move, awaits confirmation, and repeats.",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.3",
16
- "@crewhaus/infra-utils": "0.1.3",
17
- "@crewhaus/ir": "0.1.3"
18
+ "@crewhaus/errors": "0.1.5",
19
+ "@crewhaus/infra-utils": "0.1.5",
20
+ "@crewhaus/ir": "0.1.5"
18
21
  },
19
22
  "license": "Apache-2.0",
20
23
  "author": {
@@ -34,5 +37,5 @@
34
37
  "publishConfig": {
35
38
  "access": "public"
36
39
  },
37
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
40
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
38
41
  }
package/src/index.test.ts DELETED
@@ -1,179 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { IrChainGameV0 } from "@crewhaus/ir";
3
- import { TargetEmitError, emitOnchainGame } from "./index";
4
-
5
- function baseIr(overrides: Partial<IrChainGameV0> = {}): IrChainGameV0 {
6
- return {
7
- version: 0,
8
- name: "tic-tac-toe-agent",
9
- target: "onchain-game",
10
- agent: { model: "claude-opus-4-7", instructions: "Play optimal moves." },
11
- chain: {
12
- id: "base-sepolia",
13
- kind: "evm",
14
- rpcUrls: [{ kind: "env", name: "BASE_SEPOLIA_RPC" }],
15
- rpcPolicy: "single",
16
- finality: { kind: "confirmations", count: 1 },
17
- reorgTolerant: true,
18
- },
19
- wallet: {
20
- id: "player",
21
- chainId: "base-sepolia",
22
- custody: "local",
23
- signingPolicy: "automated",
24
- keyRef: { kind: "env", name: "PLAYER_KEY" },
25
- },
26
- game: {
27
- contract: {
28
- id: "tictactoe",
29
- chainId: "base-sepolia",
30
- address: "0xgame",
31
- abiRef: "abi://tictactoe",
32
- },
33
- stateReader: "9af14a16",
34
- turnSemantics: "turn-based",
35
- objective: "Win or draw — never lose.",
36
- },
37
- transactionPolicy: {
38
- defaultWriteApproval: "policy",
39
- allowedContracts: ["tictactoe"],
40
- simulationRequired: true,
41
- },
42
- tools: [],
43
- toolConfigs: {},
44
- mcp_servers: {},
45
- permissions: { rules: [] },
46
- compaction: {},
47
- ...overrides,
48
- };
49
- }
50
-
51
- describe("emitOnchainGame — happy path", () => {
52
- test("emits a single agent.ts with game metadata", () => {
53
- const bundle = emitOnchainGame(baseIr());
54
- expect(bundle.files).toHaveLength(1);
55
- const file = bundle.files[0];
56
- expect(file?.path).toBe("agent.ts");
57
- expect(file?.content).toContain('SPEC_NAME = "tic-tac-toe-agent"');
58
- expect(file?.content).toContain('AGENT_MODEL = "claude-opus-4-7"');
59
- expect(file?.content).toContain('AGENT_INSTRUCTIONS = "Play optimal moves."');
60
- expect(file?.content).toContain('chainId: "base-sepolia"');
61
- expect(file?.content).toContain('id: "tictactoe"');
62
- expect(file?.content).toContain('stateReader: "9af14a16"');
63
- expect(file?.content).toContain('turnSemantics: "turn-based"');
64
- expect(file?.content).toContain('objective: "Win or draw');
65
- expect(file?.content).toContain("createEvmAdapter");
66
- expect(file?.content).toContain("classifyBoundary");
67
- expect(file?.content).toContain('origin: "chain"');
68
- });
69
-
70
- test("wallet keyRef env-secret renders as process.env lookup", () => {
71
- const bundle = emitOnchainGame(baseIr());
72
- const c = bundle.files[0]?.content ?? "";
73
- expect(c).toContain('process.env["PLAYER_KEY"]');
74
- });
75
-
76
- test("renders the transaction_policy literal", () => {
77
- const bundle = emitOnchainGame(baseIr());
78
- const c = bundle.files[0]?.content ?? "";
79
- expect(c).toContain('"allowedContracts":["tictactoe"]');
80
- expect(c).toContain('"simulationRequired":true');
81
- });
82
-
83
- // #151 activation — the single game contract is resolved into the policy's
84
- // contractId -> address map so the wallet-engine binds tx.to.
85
- test("populates transaction_policy.contractAddresses from the game contract", () => {
86
- const bundle = emitOnchainGame(baseIr());
87
- const c = bundle.files[0]?.content ?? "";
88
- expect(c).toContain('"contractAddresses":{"tictactoe":"0xgame"}');
89
- });
90
-
91
- // #159 (CWE-798) — a kms:// / hsm:// key handle is a permitted literal.
92
- test("renders a kms:// keyRef handle verbatim", () => {
93
- const bundle = emitOnchainGame(
94
- baseIr({
95
- wallet: {
96
- id: "player",
97
- chainId: "base-sepolia",
98
- custody: "kms",
99
- signingPolicy: "automated",
100
- keyRef: { kind: "literal", value: "kms://aws/player-key" },
101
- },
102
- }),
103
- );
104
- const c = bundle.files[0]?.content ?? "";
105
- expect(c).toContain('"kms://aws/player-key"');
106
- });
107
- });
108
-
109
- describe("emitOnchainGame — validation", () => {
110
- test("rejects when wallet.chainId differs from chain.id", () => {
111
- expect(() =>
112
- emitOnchainGame(
113
- baseIr({
114
- wallet: {
115
- id: "player",
116
- chainId: "polygon-mainnet",
117
- custody: "local",
118
- signingPolicy: "automated",
119
- keyRef: { kind: "env", name: "PLAYER_KEY" },
120
- },
121
- }),
122
- ),
123
- ).toThrow(TargetEmitError);
124
- });
125
-
126
- test("rejects when game.contract.chainId differs from chain.id", () => {
127
- const ir = baseIr();
128
- expect(() =>
129
- emitOnchainGame({
130
- ...ir,
131
- game: {
132
- ...ir.game,
133
- contract: { ...ir.game.contract, chainId: "polygon-mainnet" },
134
- },
135
- }),
136
- ).toThrow(TargetEmitError);
137
- });
138
-
139
- test("rejects real-time games without moveTimeoutMs", () => {
140
- const ir = baseIr();
141
- expect(() =>
142
- emitOnchainGame({
143
- ...ir,
144
- game: { ...ir.game, turnSemantics: "real-time" },
145
- }),
146
- ).toThrow(/moveTimeoutMs/);
147
- });
148
-
149
- // #159 (CWE-798) — a literal (raw hex private key) wallet keyRef must not be
150
- // baked into the emitted artifact.
151
- test("rejects a literal (hex private key) wallet keyRef", () => {
152
- expect(() =>
153
- emitOnchainGame(
154
- baseIr({
155
- wallet: {
156
- id: "player",
157
- chainId: "base-sepolia",
158
- custody: "local",
159
- signingPolicy: "automated",
160
- keyRef: {
161
- kind: "literal",
162
- value: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
163
- },
164
- },
165
- }),
166
- ),
167
- ).toThrow(TargetEmitError);
168
- });
169
- });
170
-
171
- describe("emitOnchainGame — spec-name codegen injection (#147)", () => {
172
- test("a crafted name cannot break out of the header comment", () => {
173
- const evil = "safe */ globalThis.__PWNED_GAME__ = 1; /*\nmore";
174
- const content = emitOnchainGame(baseIr({ name: evil })).files[0]?.content ?? "";
175
- expect(content).toMatch(/^\/\/ Compiled from spec:/m);
176
- expect(content).not.toMatch(/^globalThis\.__PWNED_GAME__/m);
177
- expect(content).not.toContain("*/\nglobalThis");
178
- });
179
- });