diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a9c792e..62ea1c01 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,7 +64,7 @@ jobs: run: forge test --offline -vvv packages: - name: Kernel packages and production node + name: Package build freshness runs-on: ubuntu-latest permissions: contents: read @@ -90,9 +90,3 @@ jobs: - name: Verify package build output is fresh run: git diff --exit-code -- packages - - - name: Install production node dependencies - run: npm --prefix node/production ci --ignore-scripts - - - name: Test production node - run: npm --prefix node/production test diff --git a/.gitignore b/.gitignore index 630bc352..aa554876 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,3 @@ agent/node_modules agent-library/agents/**/.swap-state*.json agent-library/agents/**/config.local.json agent/.state/ - -# Production node local configuration, keys, state, and deployment broadcasts -node/production/config.local.json -node/production/.state/ -contracts/broadcast/ diff --git a/README.md b/README.md index 2ef078c7..86eabf68 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,6 @@ The copied `default` module now starts as a minimal standard scaffold using the - Agent module layout and new-commitment workflow: `agent-library/README.md` - Runner config, message APIs, publication nodes, and harness usage: `agent/README.md` - Standalone node daemons and startup commands: `node/README.md` -- Oya production node, Logger deployment, and local end-to-end smoke: `node/production/README.md` - Deployment and configuration: `docs/deployment.md` - Signer options and `with-signer` helper: `docs/signers.md` - Offchain agent usage: `docs/agent.md` diff --git a/contracts/README.md b/contracts/README.md index 6e4b6e22..ee3adc51 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -60,21 +60,3 @@ This snapshot excludes randomized fuzz cases so gas comparisons use fixed inputs Tests run in Forge's local EVM without an RPC endpoint or private key. The first build may download the pinned compiler; subsequent tests run offline. `--offline` also avoids optional signature lookups that can trigger a Foundry 1.5.1 macOS proxy-settings crash inside a sandbox. Generated `out/` and `cache/` directories are ignored. See [`logger-execplan.md`](logger-execplan.md) for implementation decisions and validation evidence. See [AGENTS.md](AGENTS.md) for local agent instructions and [CONTRIBUTING.md](../CONTRIBUTING.md) for the shared contributor workflow. - -## Deploy Logger - -[`script/DeployLogger.s.sol`](script/DeployLogger.s.sol) deploys Logger and requires an explicit expected chain ID. Load `LOGGER_DEPLOYER_PK` securely into the environment, set `LOGGER_CHAIN_ID` to the target network ID, and set `LOGGER_RPC_URL` to its RPC endpoint. The deployer needs native currency for deployment gas. From the repository root, simulate first: - -```sh -forge script --root contracts contracts/script/DeployLogger.s.sol:DeployLogger --rpc-url "$LOGGER_RPC_URL" --offline -``` - -After checking the intended chain and simulation, deploy: - -```sh -forge script --root contracts contracts/script/DeployLogger.s.sol:DeployLogger --rpc-url "$LOGGER_RPC_URL" --broadcast --offline -``` - -The script rejects a chain that differs from `LOGGER_CHAIN_ID`. Record the contract address and successful receipt from `contracts/broadcast/DeployLogger.s.sol//run-latest.json`; reuse that address in the node's `loggerContract` config. Each fresh deployment creates another Logger. `--offline` disables compiler downloads, not RPC access, so run the build first. - -For a complete local deployment plus signed-message/IPFS/Logger verification, run `npm --prefix node/production run smoke:local` after following the [production node setup](../node/production/README.md). This deploys only to its isolated Anvil chain (31337), using generated local accounts. diff --git a/contracts/script/DeployLogger.s.sol b/contracts/script/DeployLogger.s.sol deleted file mode 100644 index 36db53f7..00000000 --- a/contracts/script/DeployLogger.s.sol +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.23; - -import {Script} from "forge-std/Script.sol"; -import {Logger} from "../src/Logger.sol"; - -contract DeployLogger is Script { - function run() external returns (Logger logger) { - uint256 expectedChainId = vm.envUint("LOGGER_CHAIN_ID"); - require(block.chainid == expectedChainId, "Unexpected deployment chain"); - vm.startBroadcast(vm.envUint("LOGGER_DEPLOYER_PK")); - logger = new Logger(); - vm.stopBroadcast(); - } -} diff --git a/node/README.md b/node/README.md index 14d0d33d..9be88939 100644 --- a/node/README.md +++ b/node/README.md @@ -2,8 +2,6 @@ `node/` is the primary home for standalone Oya node daemons. -The Oya production runtime lives in [`production/`](production/README.md). It installs independently, accepts signed messages over HTTP, publishes to IPFS, and logs CIDs through the hardened kernel packages, with durable transaction recovery. See its guide for setup and local end-to-end deployment. The experimental daemons described below use the earlier shared agent infrastructure. - These daemons are separate from the commitment-serving agent loop in `agent/`: - the message publication node archives signed agent-authored messages to IPFS diff --git a/node/production/.env.example b/node/production/.env.example deleted file mode 100644 index 6da590b1..00000000 --- a/node/production/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -# Store real values in an ignored .env file or inject them with a secret manager. -OYA_NODE_PRIVATE_KEY= -# Optional HTTP Authorization header values for the providers: -OYA_RPC_AUTHORIZATION= -OYA_IPFS_AUTHORIZATION= -# Needed only by scripts/send-message.mjs, not by the node: -OYA_AGENT_PRIVATE_KEY= diff --git a/node/production/README.md b/node/production/README.md deleted file mode 100644 index 7900bd3f..00000000 --- a/node/production/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# Oya production node - -This standalone runtime accepts an agent's signed text, publishes the signed JSON to IPFS, and submits its CID to Logger using the node's own account. A `202` response includes the CID, transaction hash, block number, and node address after the kernel verifies a successful receipt and the matching Logger event. - -The runtime imports `@oyaprotocol/messages`, `@oyaprotocol/ipfs`, and `@oyaprotocol/ethereum` through their package roots. It has no dependency on the legacy agent runner or node daemons. Reimbursement verification, Safe proposals, and DeFi actions are later milestones. - -## Install and validate - -Use Node 22 or newer, npm, Foundry, and Kubo/IPFS for the local smoke. From the repository root: - -```sh -git submodule update --init lib/forge-std -npm --prefix packages ci -npm --prefix packages run build -npm --prefix node/production ci -npm --prefix node/production test -forge build --root contracts --sizes -forge test --root contracts --offline -vv -npm --prefix node/production run smoke:local -``` - -The smoke starts isolated Anvil and offline Kubo processes on loopback ports, deploys Logger through `contracts/script/DeployLogger.s.sol`, and exercises real signed HTTP requests. It checks IPFS retrieval, Logger receipts, rejected signatures, duplicates, concurrent submissions, and restart recovery both before broadcast and after mining. It also rejects startup on a wrong chain or missing contract. It stops its services when finished and prints a temporary directory containing `evidence.json` and service logs. - -To leave a working local stack running: - -```sh -npm --prefix node/production run smoke:local -- --keep-running -``` - -This prints the actual node URL, RPC URL, IPFS URL, Logger address, and state directory. The temporary `.env` contains generated local-only node and agent keys with mode `0600`; the keys are not printed. The local chain is disposable, and offline Kubo makes content available through its local API only. Press Ctrl-C to stop all three services. - -## Configure and start - -Copy `config.example.json` to the ignored `config.local.json`. Replace the example Logger and agent addresses with your deployment and allowlisted signer addresses. Set `chainId`, `rpcUrl`, and `ipfsUrl` for the intended environment. `ipfsUrl` must be a Kubo-compatible API, with `/api/v0/add` support; a read-only gateway or unrelated pinning API is insufficient. - -The node account must have gas funds and be dedicated to one runtime. The agent signing key is distinct; it does not need gas to sign a message. Store `OYA_NODE_PRIVATE_KEY` in the ignored `node/production/.env`, or inject it through your process supervisor. Optional `OYA_RPC_AUTHORIZATION` and `OYA_IPFS_AUTHORIZATION` contain complete HTTP Authorization header values. Keep RPC URLs containing credentials in private local config too. - -From the repository root: - -```sh -node --env-file=node/production/.env node/production/src/main.mjs node/production/config.local.json -curl http://127.0.0.1:8787/healthz -``` - -Alternatively, with environment variables already loaded: - -```sh -npm --prefix node/production start -- /absolute/path/to/config.json -``` - -Config paths in `stateDir` are relative to the config file. Keep that directory across restarts and on a filesystem that supports atomic rename and fsync. Startup checks the RPC chain and deployed bytecode, checks the state directory's chain/Logger/account identity, acquires its process lock, and attempts to resume an unfinished publication before serving traffic. - -`host` defaults to `127.0.0.1`, and `port` to `8787`. To host it remotely, choose the binding explicitly and provide HTTPS through your hosting environment. Other optional settings are `maxBodyBytes` (16,384), `maxTextBytes` (8,192), `bodyTimeoutMs` (10,000), `receiptTimeoutMs` (60,000), `pollIntervalMs` (1,000), `gasLimit` (200,000), and `maxFeePerGasWei` (decimal string, default 30,000,000,000). Gas and fee values are ceilings; requests above them stop before signing. Transport attempts have a 10-second timeout and up to two kernel-managed retries. Transaction preparation has the kernel's 30-second deadline. - -## Submit a message - -`POST /v1/messages` accepts `Content-Type: application/json` and exactly: - -```json -{ "text": "Your exact ASCII message", "signer": "0x...", "signature": "0x..." } -``` - -The signature must be EIP-191 over exactly `text`; the signer must be in `allowedSigners`. The node caps bytes while reading the HTTP stream, and the kernel validates JSON, message size, schema, signature, and authorization before publication. The signed text should contain any context that its readers need; this first runtime does not interpret commitment-specific fields. - -Put ASCII text in a file, load the agent's key as `OYA_AGENT_PRIVATE_KEY`, and run: - -```sh -node --env-file=node/production/.env node/production/scripts/send-message.mjs http://127.0.0.1:8787 /absolute/path/to/message.txt -``` - -The script signs the complete file, including any final newline. A successful response looks like: - -```json -{ - "status": "accepted", - "signer": "0x...", - "publication": { - "messageId": "...", - "status": "logged", - "cid": "bafk...", - "uri": "ipfs://bafk...", - "transactionHash": "0x...", - "blockNumber": "2", - "nodeAddress": "0x...", - "loggerContract": "0x..." - } -} -``` - -Retrieve the original signed JSON with `ipfs cat ` against the relevant Kubo repository, or `POST /api/v0/cat?arg=`. Logger's indexed node address identifies the node transaction signer, while the JSON retains the agent's separate signature. - -## Persistence and recovery - -The node publishes each pair of case-insensitive signer address and exact text once per state directory. A repeated valid request returns its original result even if the signature encoding or address casing differs. To record a new observation, sign new text (for example, include an observation ID). The original accepted envelope is retained on IPFS. - -One new publication may be active at a time. Another new request receives `503` with `node_busy`; retry it later. A valid duplicate of a completed publication remains available during other work. Publication failures return `503` with `publication_incomplete` and any known CID/hash. Provider responses and secret-bearing errors are not returned to clients. - -Each operation is saved before IPFS upload, after publication, and after signing but before broadcast. A successful result is saved after receipt verification. If an operation is incomplete, other new messages receive `recovery_required` until it is reconciled. Retry the original signed message to resume, or restart the node to attempt recovery automatically. Retained signed bytes are reused. An already-mined receipt is verified without another broadcast. A successful response means mined execution as reported by the configured RPC; it does not claim additional confirmations or protection against later chain reorganizations. - -`GET /healthz` reports `ready` or `recovery_required`, plus whether work is active and its message ID. It describes local lifecycle state; it is not a continuous RPC/IPFS health probe. A missing signature, invalid signature, disallowed signer, wrong method, or oversized request never produces a publication. - -SIGINT/SIGTERM stops accepting requests and drains active work before releasing `runtime.lock`. Following a hard crash, inspect `runtime.lock` (hostname, PID, start time), confirm that process is no longer running, and remove only the stale lock before restarting. Do not delete transaction records to clear a pending operation: the transaction may already be onchain. Persistent reverted transactions, missing events, fee problems, or external nonce use require operator investigation. This first runtime does not implement replacement transactions, automatic fee bumping, or a repair API. Avoid using the same account from another process or host; the local lock cannot coordinate them. - -The journal is operator-owned data and grows with accepted messages. Back it up and retain it for the lifetime of the node identity. IPFS content is public once published, including the signed text. Logger accepts opaque CID claims; consumers still verify retrieved content and the agent signature. diff --git a/node/production/config.example.json b/node/production/config.example.json deleted file mode 100644 index d71a146a..00000000 --- a/node/production/config.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "host": "127.0.0.1", - "port": 8787, - "chainId": 31337, - "loggerContract": "0x1111111111111111111111111111111111111111", - "allowedSigners": ["0x2222222222222222222222222222222222222222"], - "rpcUrl": "http://127.0.0.1:8545", - "ipfsUrl": "http://127.0.0.1:5001", - "stateDir": ".state", - "gasLimit": 200000, - "maxFeePerGasWei": "30000000000" -} diff --git a/node/production/package-lock.json b/node/production/package-lock.json deleted file mode 100644 index a02513f8..00000000 --- a/node/production/package-lock.json +++ /dev/null @@ -1,174 +0,0 @@ -{ - "name": "@oyaprotocol/node-runtime", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@oyaprotocol/node-runtime", - "version": "0.1.0", - "dependencies": { - "@oyaprotocol/ethereum": "file:../../packages/ethereum", - "@oyaprotocol/ipfs": "file:../../packages/ipfs", - "@oyaprotocol/messages": "file:../../packages/messages", - "@oyaprotocol/utils": "file:../../packages/utils", - "ethers": "6.17.0" - }, - "engines": { - "node": ">=22" - } - }, - "../../packages/ethereum": { - "name": "@oyaprotocol/ethereum", - "version": "0.0.0", - "dependencies": { - "@noble/hashes": "2.2.0", - "@oyaprotocol/utils": "0.0.0" - } - }, - "../../packages/ipfs": { - "name": "@oyaprotocol/ipfs", - "version": "0.0.0", - "dependencies": { - "@oyaprotocol/utils": "0.0.0" - } - }, - "../../packages/messages": { - "name": "@oyaprotocol/messages", - "version": "0.0.0", - "dependencies": { - "@noble/curves": "2.2.0", - "@noble/hashes": "2.2.0", - "@oyaprotocol/ethereum": "0.0.0", - "@oyaprotocol/ipfs": "0.0.0", - "@oyaprotocol/utils": "0.0.0" - } - }, - "../../packages/utils": { - "name": "@oyaprotocol/utils", - "version": "0.0.0" - }, - "node_modules/@adraffy/ens-normalize": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", - "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", - "license": "MIT" - }, - "node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@oyaprotocol/ethereum": { - "resolved": "../../packages/ethereum", - "link": true - }, - "node_modules/@oyaprotocol/ipfs": { - "resolved": "../../packages/ipfs", - "link": true - }, - "node_modules/@oyaprotocol/messages": { - "resolved": "../../packages/messages", - "link": true - }, - "node_modules/@oyaprotocol/utils": { - "resolved": "../../packages/utils", - "link": true - }, - "node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/aes-js": { - "version": "4.0.0-beta.5", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", - "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", - "license": "MIT" - }, - "node_modules/ethers": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", - "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/ethers-io/" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "1.11.1", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.2", - "@types/node": "22.7.5", - "aes-js": "4.0.0-beta.5", - "tslib": "2.7.0", - "ws": "8.21.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "license": "0BSD" - }, - "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "license": "MIT" - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/node/production/package.json b/node/production/package.json deleted file mode 100644 index f95e2851..00000000 --- a/node/production/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@oyaprotocol/node-runtime", - "version": "0.1.0", - "private": true, - "type": "module", - "engines": { "node": ">=22" }, - "scripts": { - "start": "node src/main.mjs", - "test": "node --test test/*.test.mjs", - "smoke:local": "node scripts/smoke-local.mjs" - }, - "dependencies": { - "@oyaprotocol/ethereum": "file:../../packages/ethereum", - "@oyaprotocol/ipfs": "file:../../packages/ipfs", - "@oyaprotocol/messages": "file:../../packages/messages", - "@oyaprotocol/utils": "file:../../packages/utils", - "ethers": "6.17.0" - } -} diff --git a/node/production/scripts/send-message.mjs b/node/production/scripts/send-message.mjs deleted file mode 100644 index 969485af..00000000 --- a/node/production/scripts/send-message.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { Wallet } from 'ethers'; - -try { - const [nodeUrl, textPath] = process.argv.slice(2); - if (!nodeUrl || !textPath || process.argv.length !== 4) throw new Error(); - const wallet = new Wallet(process.env.OYA_AGENT_PRIVATE_KEY); - const text = await readFile(textPath, 'utf8'); - if (!text.length || !/^[\x00-\x7f]+$/.test(text)) throw new Error(); - const response = await fetch(`${nodeUrl.replace(/\/$/, '')}/v1/messages`, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ text, signer: wallet.address, signature: await wallet.signMessage(text) }), - }); - console.log(JSON.stringify(await response.json(), null, 2)); - if (response.status !== 202) process.exitCode = 1; -} catch { - console.error('Could not submit message. Supply OYA_AGENT_PRIVATE_KEY, a node URL, and a nonempty ASCII text file.'); - process.exitCode = 1; -} diff --git a/node/production/scripts/smoke-local.mjs b/node/production/scripts/smoke-local.mjs deleted file mode 100644 index 20148c27..00000000 --- a/node/production/scripts/smoke-local.mjs +++ /dev/null @@ -1,219 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawn, execFile } from 'node:child_process'; -import { once } from 'node:events'; -import { createWriteStream } from 'node:fs'; -import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; -import { createServer } from 'node:net'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; -import { setTimeout as delay } from 'node:timers/promises'; -import { Wallet } from 'ethers'; -import { createTransactionPreparer, encodeLoggerCall } from '@oyaprotocol/ethereum'; -import { publishSignedMessage } from '@oyaprotocol/messages'; -import { parseConfig } from '../src/config.mjs'; -import { createLocalSigner } from '../src/signer.mjs'; -import { startNode } from '../src/main.mjs'; -import { messageId, openStore } from '../src/store.mjs'; - -const run = promisify(execFile); -const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); -const directory = await mkdtemp(join(tmpdir(), 'oya-kernel-local-')); -const children = []; -let runtime; - -async function freePort() { - const server = createServer(); - await new Promise((resolveListen, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolveListen); - }); - const port = server.address().port; - await new Promise((resolveClose) => server.close(resolveClose)); - return port; -} - -function background(command, args, env = process.env) { - const output = createWriteStream(join(directory, `${command}.log`), { mode: 0o600 }); - const child = spawn(command, args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] }); - child.stdout.pipe(output); - child.stderr.pipe(output); - children.push(child); - return child; -} - -async function until(check, child) { - for (let i = 0; i < 150; i++) { - if (child?.exitCode !== null && child?.exitCode !== undefined) throw new Error('Local service exited; inspect its log.'); - try { if (await check()) return; } catch {} - await delay(100); - } - throw new Error('Local service did not become ready.'); -} - -async function cleanup() { - if (runtime) { await runtime.close(); runtime = undefined; } - await Promise.all(children.map(async (child) => { - if (child.exitCode !== null || child.signalCode !== null) return; - const exited = once(child, 'exit'); - child.kill('SIGTERM'); - const timer = setTimeout(() => child.kill('SIGKILL'), 5000); - try { await exited; } finally { clearTimeout(timer); } - })); -} - -try { - const [rpcPort, ipfsPort, gatewayPort, nodePort] = await Promise.all([freePort(), freePort(), freePort(), freePort()]); - const rpcUrl = `http://127.0.0.1:${rpcPort}`; - const ipfsUrl = `http://127.0.0.1:${ipfsPort}`; - const nodeUrl = `http://127.0.0.1:${nodePort}`; - const anvil = background('anvil', ['--host', '127.0.0.1', '--port', String(rpcPort), '--chain-id', '31337', '--silent']); - const rawRpc = async (method, params = []) => { - const response = await fetch(rpcUrl, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - }); - const body = await response.json(); - if (body.error) throw new Error(`Local RPC rejected ${method}.`); - return body.result; - }; - await until(async () => await rawRpc('eth_chainId') === '0x7a69', anvil); - - const ipfsEnv = { ...process.env, IPFS_PATH: join(directory, 'ipfs') }; - await run('ipfs', ['init', '--profile=test'], { env: ipfsEnv }); - const ipfsConfigPath = join(ipfsEnv.IPFS_PATH, 'config'); - const ipfsConfig = JSON.parse(await readFile(ipfsConfigPath, 'utf8')); - ipfsConfig.Addresses.API = `/ip4/127.0.0.1/tcp/${ipfsPort}`; - ipfsConfig.Addresses.Gateway = `/ip4/127.0.0.1/tcp/${gatewayPort}`; - ipfsConfig.Addresses.Swarm = []; - await writeFile(ipfsConfigPath, JSON.stringify(ipfsConfig), { mode: 0o600 }); - const ipfs = background('ipfs', ['daemon', '--offline'], ipfsEnv); - await until(async () => (await fetch(`${ipfsUrl}/api/v0/version`, { method: 'POST' })).ok, ipfs); - - const deployer = Wallet.createRandom(); - const nodeWallet = Wallet.createRandom(); - const agent = Wallet.createRandom(); - for (const wallet of [deployer, nodeWallet]) await rawRpc('anvil_setBalance', [wallet.address, '0x56bc75e2d63100000']); - await run('forge', [ - 'script', '--root', 'contracts', 'contracts/script/DeployLogger.s.sol:DeployLogger', - '--rpc-url', rpcUrl, '--broadcast', '--offline', - ], { - cwd: root, timeout: 60_000, - env: { ...process.env, LOGGER_CHAIN_ID: '31337', LOGGER_DEPLOYER_PK: deployer.privateKey }, - }); - const broadcast = JSON.parse(await readFile(join(root, 'contracts/broadcast/DeployLogger.s.sol/31337/run-latest.json'), 'utf8')); - const deployment = broadcast.transactions.find((transaction) => transaction.contractName === 'Logger'); - assert.ok(deployment?.contractAddress); - const input = { - host: '127.0.0.1', port: nodePort, chainId: 31337, loggerContract: deployment.contractAddress, - allowedSigners: [agent.address], rpcUrl, ipfsUrl, stateDir: join(directory, 'state'), - receiptTimeoutMs: 3000, pollIntervalMs: 50, - }; - const config = parseConfig(input, { env: {} }); - const signer = createLocalSigner(nodeWallet.privateKey); - await assert.rejects(startNode({ ...config, chainId: 1 }, signer), /chain ID/); - await assert.rejects(startNode({ ...config, loggerContract: agent.address }, signer), /bytecode/); - runtime = await startNode(config, signer); - assert.equal((await fetch(`${nodeUrl}/healthz`)).status, 200); - const text = 'First message through the Oya kernel node and deployed Logger.'; - const message = { text, signer: agent.address, signature: await agent.signMessage(text) }; - const post = (body) => fetch(`${nodeUrl}/v1/messages`, { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), - }); - assert.equal((await post({ ...message, text: 'tampered' })).status, 401); - const response = await post(message); - const body = await response.json(); - assert.equal(response.status, 202, JSON.stringify(body)); - const publication = body.publication; - const content = await fetch(`${ipfsUrl}/api/v0/cat?arg=${publication.cid}`, { method: 'POST' }); - assert.deepEqual(await content.json(), message); - const receipt = await rawRpc('eth_getTransactionReceipt', [publication.transactionHash]); - assert.equal(receipt.status, '0x1'); - assert.equal(receipt.logs.length, 1); - assert.equal(publication.nodeAddress.toLowerCase(), signer.address.toLowerCase()); - const nonce = await rawRpc('eth_getTransactionCount', [signer.address, 'pending']); - const duplicate = await post(message); - assert.deepEqual((await duplicate.json()).publication, publication); - assert.equal(await rawRpc('eth_getTransactionCount', [signer.address, 'pending']), nonce); - await runtime.close(); runtime = undefined; - - // Simulate a crash after mining, before saving success: retain the real signed transaction. - const recordPath = join(config.stateDir, `${messageId(message)}.json`); - const record = JSON.parse(await readFile(recordPath, 'utf8')); - delete record.result; - await writeFile(recordPath, `${JSON.stringify(record)}\n`, { mode: 0o600 }); - runtime = await startNode(config, signer); - assert.deepEqual((await (await post(message)).json()).publication, publication); - assert.equal(await rawRpc('eth_getTransactionCount', [signer.address, 'pending']), nonce); - await runtime.close(); runtime = undefined; - - // Simulate a crash after durable preparation, before broadcast. Recovery must use those exact bytes. - const recoveryText = 'Resume a prepared Logger transaction after restart.'; - const recoveryMessage = { text: recoveryText, signer: agent.address, signature: await agent.signMessage(recoveryText) }; - const recoveryPublication = await publishSignedMessage(recoveryMessage, { config: config.ipfs, fetch }); - const prepare = createTransactionPreparer({ config: config.rpc, fetch, chainId: config.chainId, signer, limits: config.limits }); - const signed = await prepare({ to: config.loggerContract, data: encodeLoggerCall(recoveryPublication.cid), value: 0n }); - const store = await openStore(config.stateDir, { - version: 1, chainId: config.chainId, loggerContract: config.loggerContract.toLowerCase(), nodeAddress: signer.address.toLowerCase(), - }); - await store.save({ id: messageId(recoveryMessage), message: recoveryMessage, cid: recoveryPublication.cid, signed }); - await store.close(); - runtime = await startNode(config, signer); - const recovered = await (await post(recoveryMessage)).json(); - assert.equal(recovered.publication.transactionHash, signed.transactionHash); - assert.equal(recovered.publication.status, 'logged'); - - // Hold mining to demonstrate one lifecycle at a time without nonce collisions. - await rawRpc('evm_setAutomine', [false]); - const concurrentText = 'Serialize node signing while a transaction is pending.'; - const concurrentMessage = { text: concurrentText, signer: agent.address, signature: await agent.signMessage(concurrentText) }; - const pendingResponse = post(concurrentMessage); - await until(async () => (await (await fetch(`${nodeUrl}/healthz`)).json()).busy); - const busyResponse = await post({ ...message, text: recoveryText, signature: recoveryMessage.signature }); - // Previously completed requests can return their durable result even while a new one is active. - assert.equal(busyResponse.status, 202); - const newText = 'Concurrent new message through the Oya kernel node.'; - const rejected = await post({ text: newText, signer: agent.address, signature: await agent.signMessage(newText) }); - assert.equal(rejected.status, 503); - assert.equal((await rejected.json()).code, 'node_busy'); - await until(async () => { - const pendingBlock = await rawRpc('eth_getBlockByNumber', ['pending', false]); - return pendingBlock.transactions.length > 0; - }); - await rawRpc('evm_mine'); - await rawRpc('evm_setAutomine', [true]); - assert.equal((await pendingResponse).status, 202); - - const evidence = { - chainId: 31337, loggerContract: config.loggerContract, deploymentTransactionHash: deployment.hash, - nodeUrl, rpcUrl, ipfsUrl, nodeAddress: signer.address, agentAddress: agent.address, - publication, recoveryTransactionHash: signed.transactionHash, - checks: ['signed HTTP ingestion', 'IPFS retrieval', 'Logger event', 'invalid signature rejection', - 'duplicate suppression', 'mined receipt recovery', 'prepared transaction recovery', - 'serialized submissions', 'chain and contract startup checks'], - }; - await writeFile(join(directory, 'evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`); - await writeFile(join(directory, 'config.json'), `${JSON.stringify(input, null, 2)}\n`); - await writeFile(join(directory, '.env'), `OYA_NODE_PRIVATE_KEY=${nodeWallet.privateKey}\nOYA_AGENT_PRIVATE_KEY=${agent.privateKey}\n`, { mode: 0o600 }); - console.log(JSON.stringify({ event: 'smoke_passed', directory, ...evidence }, null, 2)); - if (process.argv.includes('--keep-running')) { - await runtime.close(); runtime = undefined; - const daemon = background('node', [ - `--env-file=${join(directory, '.env')}`, 'node/production/src/main.mjs', join(directory, 'config.json'), - ]); - await until(async () => (await fetch(`${nodeUrl}/healthz`)).ok, daemon); - console.log('Local node, Anvil, and isolated IPFS remain running. Press Ctrl-C to stop.'); - await new Promise((resolveStop) => { - process.once('SIGINT', resolveStop); - process.once('SIGTERM', resolveStop); - }); - } -} catch (error) { - console.error(`Local smoke failed; artifacts: ${directory}`); - // Child-process exceptions retain their complete environment; never print those objects. - console.error(error.message?.slice(0, 1200)); - process.exitCode = 1; -} finally { - await cleanup(); -} diff --git a/node/production/src/config.mjs b/node/production/src/config.mjs deleted file mode 100644 index a7a56215..00000000 --- a/node/production/src/config.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; -import { createSignedMessageAuthorizer } from '@oyaprotocol/messages'; -import { createHttpConfig } from '@oyaprotocol/ethereum'; -import { createIpfsConfig } from '@oyaprotocol/ipfs'; - -function integer(value, name, fallback, max = 2_147_483_647) { - const selected = value ?? fallback; - if (!Number.isSafeInteger(selected) || selected < 1 || selected > max) { - throw new Error(`${name} must be a positive integer no greater than ${max}.`); - } - return selected; -} - -function endpoint(value, name) { - try { - const url = new URL(value); - if (!['http:', 'https:'].includes(url.protocol)) throw new Error(); - return url.href; - } catch { - throw new Error(`${name} must be an HTTP or HTTPS URL.`); - } -} - -export function parseConfig(input, { baseDir = process.cwd(), env = process.env } = {}) { - if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Config must be an object.'); - const fields = new Set(['host', 'port', 'chainId', 'loggerContract', 'allowedSigners', 'rpcUrl', 'ipfsUrl', - 'stateDir', 'maxBodyBytes', 'maxTextBytes', 'bodyTimeoutMs', 'receiptTimeoutMs', 'pollIntervalMs', - 'gasLimit', 'maxFeePerGasWei']); - for (const key of Object.keys(input)) { - if (!fields.has(key)) throw new Error('Config contains an unsupported field.'); - } - if (!/^0x[0-9a-fA-F]{40}$/.test(input.loggerContract ?? '') || /^0x0{40}$/i.test(input.loggerContract)) { - throw new Error('loggerContract must be a nonzero Ethereum address.'); - } - const authorize = createSignedMessageAuthorizer(input.allowedSigners); - if (input.allowedSigners.length === 0) throw new Error('allowedSigners must not be empty.'); - if (input.host !== undefined && (typeof input.host !== 'string' || !input.host.trim())) { - throw new Error('host must be a nonempty string.'); - } - if (typeof input.stateDir !== 'string' || !input.stateDir.trim()) throw new Error('stateDir is required.'); - if (input.maxFeePerGasWei !== undefined && !/^[1-9][0-9]{0,77}$/.test(input.maxFeePerGasWei)) { - throw new Error('maxFeePerGasWei must be a positive decimal string.'); - } - const transport = (url, authorization) => ({ - url, headers: authorization ? { authorization } : {}, timeoutMs: 10_000, - maxRetries: 2, retryDelayMs: 250, - }); - return Object.freeze({ - host: input.host ?? '127.0.0.1', port: integer(input.port, 'port', 8787, 65535), - chainId: integer(input.chainId, 'chainId', undefined, Number.MAX_SAFE_INTEGER), - loggerContract: input.loggerContract, authorize, - stateDir: resolve(baseDir, input.stateDir), - maxBodyBytes: integer(input.maxBodyBytes, 'maxBodyBytes', 16_384, 1_048_576), - maxTextBytes: integer(input.maxTextBytes, 'maxTextBytes', 8192, 1_048_576), - bodyTimeoutMs: integer(input.bodyTimeoutMs, 'bodyTimeoutMs', 10_000), - receiptTimeoutMs: integer(input.receiptTimeoutMs, 'receiptTimeoutMs', 60_000), - pollIntervalMs: integer(input.pollIntervalMs, 'pollIntervalMs', 1000), - limits: { - gasLimit: BigInt(integer(input.gasLimit, 'gasLimit', 200_000)), - feePerGas: BigInt(input.maxFeePerGasWei ?? '30000000000'), - }, - rpc: createHttpConfig(transport(endpoint(input.rpcUrl, 'rpcUrl'), env.OYA_RPC_AUTHORIZATION)), - ipfs: createIpfsConfig(transport(endpoint(input.ipfsUrl, 'ipfsUrl'), env.OYA_IPFS_AUTHORIZATION)), - }); -} - -export async function loadConfig(path, env = process.env) { - return parseConfig(JSON.parse(await readFile(path, 'utf8')), { baseDir: dirname(resolve(path)), env }); -} diff --git a/node/production/src/main.mjs b/node/production/src/main.mjs deleted file mode 100644 index 43fc0850..00000000 --- a/node/production/src/main.mjs +++ /dev/null @@ -1,72 +0,0 @@ -import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { requestEthereumJsonRpc } from '@oyaprotocol/ethereum'; -import { loadConfig } from './config.mjs'; -import { createLocalSigner } from './signer.mjs'; -import { openStore } from './store.mjs'; -import { createPublisher } from './publication.mjs'; -import { createNodeServer } from './server.mjs'; - -export async function startNode(config, signer) { - const rpc = async (method, params = []) => (await requestEthereumJsonRpc({ - config: config.rpc, fetch: globalThis.fetch, method, params, - })).result; - if (BigInt(await rpc('eth_chainId')) !== BigInt(config.chainId)) throw new Error('RPC chain ID does not match configuration.'); - const code = await rpc('eth_getCode', [config.loggerContract, 'latest']); - if (typeof code !== 'string' || !/^0x[0-9a-fA-F]+$/.test(code) || code === '0x0') { - throw new Error('No contract bytecode exists at loggerContract.'); - } - const store = await openStore(config.stateDir, { - version: 1, chainId: config.chainId, - loggerContract: config.loggerContract.toLowerCase(), nodeAddress: signer.address.toLowerCase(), - }); - try { - const publisher = createPublisher({ config, signer, store }); - try { await publisher.recover(); } catch { - console.error(JSON.stringify({ event: 'recovery_required', ...publisher.status() })); - } - const server = createNodeServer({ config, publisher, nodeAddress: signer.address }); - await new Promise((resolveListening, reject) => { - server.once('error', reject); - server.listen(config.port, config.host, resolveListening); - }); - return { - server, - async close() { - await new Promise((resolveClosed, reject) => server.close((error) => error ? reject(error) : resolveClosed())); - await publisher.waitForIdle(); - await store.close(); - }, - }; - } catch (error) { - await store.close(); - throw error; - } -} - -async function main() { - const args = process.argv.slice(2); - if (args.length !== 1) throw new Error('Usage: npm start -- /absolute/path/to/config.json'); - const config = await loadConfig(args[0]); - const signer = createLocalSigner(process.env.OYA_NODE_PRIVATE_KEY); - const runtime = await startNode(config, signer); - console.log(JSON.stringify({ - event: 'listening', host: config.host, port: config.port, chainId: config.chainId, - loggerContract: config.loggerContract, nodeAddress: signer.address, - })); - let stopping = false; - for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { - if (stopping) return; - stopping = true; - // Drain accepted work so state is not discarded when the client disconnects. - runtime.close().catch(() => { process.exitCode = 1; }); - }); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - main().catch(() => { - // RPC URLs, IPFS headers, and wallet errors may contain secrets. - console.error('Node startup failed. Check config, signer, RPC/Logger availability, and the state-directory lock.'); - process.exitCode = 1; - }); -} diff --git a/node/production/src/publication.mjs b/node/production/src/publication.mjs deleted file mode 100644 index b34791bf..00000000 --- a/node/production/src/publication.mjs +++ /dev/null @@ -1,120 +0,0 @@ -import { publishSignedMessage } from '@oyaprotocol/messages'; -import { - createTransactionPreparer, decodeLoggerEvent, ethGetTransactionReceipt, - ethWaitForTransactionReceipt, logCid, -} from '@oyaprotocol/ethereum'; -import { messageId } from './store.mjs'; - -export class PublicationUnavailable extends Error { - constructor(code, record) { - super(code); - this.code = code; - this.record = record; - } -} - -export function publicRecord(record) { - return { - messageId: record.id, - status: record.result ? 'logged' : 'pending', - ...(record.cid ? { cid: record.cid, uri: `ipfs://${record.cid}` } : {}), - ...(record.signed ? { transactionHash: record.signed.transactionHash } : {}), - ...record.result, - }; -} - -export function createPublisher({ config, signer, store, fetch: transport = globalThis.fetch }) { - const prepare = createTransactionPreparer({ - config: config.rpc, fetch: transport, chainId: config.chainId, signer, limits: config.limits, - }); - let active = null; - let idle = Promise.resolve(); - let unfinished = [...store.records.values()].find((record) => !record.result) ?? null; - if ([...store.records.values()].filter((record) => !record.result).length > 1) { - throw new Error('Multiple unfinished records require operator reconciliation.'); - } - const processMessage = async (message) => { - const id = messageId(message); - let record = store.records.get(id); - if (record?.result) return publicRecord(record); - if (active) throw new PublicationUnavailable('node_busy', record); - if (unfinished && unfinished.id !== id) throw new PublicationUnavailable('recovery_required', unfinished); - // Set the guard before the first await, spanning publication through receipt and persistence. - active = id; - let resolveIdle; - idle = new Promise((resolve) => { resolveIdle = resolve; }); - record ??= { id, message, createdAt: new Date().toISOString() }; - unfinished = record; - try { - await store.save(record); - if (!record.cid) { - const publication = await publishSignedMessage(record.message, { config: config.ipfs, fetch: transport }); - record = { ...record, cid: publication.cid }; - unfinished = record; - await store.save(record); - } - const loggerOptions = { - config: config.rpc, fetch: transport, loggerContract: config.loggerContract, - nodeAddress: signer.address, timeoutMs: config.receiptTimeoutMs, pollIntervalMs: config.pollIntervalMs, - transactionPreparer: async (request) => { - if (record.signed) return record.signed; - const signed = await prepare(request); - record = { ...record, signed }; - unfinished = record; - // The kernel cannot broadcast until the signed bytes are durable. - await store.save(record); - return signed; - }, - }; - const checkReceipt = (receipt) => { - if (receipt.status !== 'success') throw new Error('Logger transaction did not succeed.'); - const event = receipt.logs.map((log) => decodeLoggerEvent(log, config.loggerContract)).find((entry) => - entry && entry.removed !== true && entry.cid === record.cid && - entry.node.toLowerCase() === signer.address.toLowerCase()); - if (!event) throw new Error('Receipt has no matching Logger event.'); - return { receipt, event }; - }; - // A restart may find a transaction already mined. Do not rebroadcast it. - const observed = record.signed ? await ethGetTransactionReceipt({ - config: config.rpc, fetch: transport, transactionHash: record.signed.transactionHash, - }) : null; - let logging; - if (observed?.receipt) { - logging = checkReceipt(observed.receipt); - } else { - try { logging = await logCid(record.cid, loggerOptions); } catch (error) { - if (!record.signed) throw error; - // Includes already-known / nonce-too-low races on exact-byte rebroadcast. - const { receipt } = await ethWaitForTransactionReceipt({ - config: config.rpc, fetch: transport, transactionHash: record.signed.transactionHash, - timeoutMs: config.receiptTimeoutMs, pollIntervalMs: config.pollIntervalMs, - }); - logging = checkReceipt(receipt); - } - } - record = { - ...record, result: { - blockNumber: logging.receipt.blockNumber.toString(), - nodeAddress: logging.event.node, loggerContract: config.loggerContract, - }, - }; - await store.save(record); - unfinished = null; - return publicRecord(record); - } catch { - // Keep partial progress; provider error messages can contain credentials. - throw new PublicationUnavailable('publication_incomplete', unfinished); - } finally { - active = null; - resolveIdle(); - } - }; - return { - publish: processMessage, - status: () => ({ busy: active !== null, pendingMessageId: unfinished?.id ?? null }), - waitForIdle: () => idle, - async recover() { - if (unfinished) return await processMessage(unfinished.message); - }, - }; -} diff --git a/node/production/src/server.mjs b/node/production/src/server.mjs deleted file mode 100644 index a9b81989..00000000 --- a/node/production/src/server.mjs +++ /dev/null @@ -1,90 +0,0 @@ -import { createServer } from 'node:http'; -import { handleSignedMessage } from '@oyaprotocol/messages'; -import { PublicationUnavailable, publicRecord } from './publication.mjs'; - -function respond(response, status, body, headers = {}) { - if (response.destroyed || response.writableEnded) return; - response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers }); - response.end(JSON.stringify(body)); -} - -function readBody(request, maxBytes, timeoutMs) { - return new Promise((resolve, reject) => { - let size = 0; - const chunks = []; - const cleanup = () => { - clearTimeout(timer); - request.off('data', onData); - request.off('end', onEnd); - request.off('error', onError); - request.off('aborted', onAborted); - }; - const fail = (status, code) => { - cleanup(); - request.pause(); - reject(Object.assign(new Error(code), { status, code })); - }; - const onData = (chunk) => { - size += chunk.length; - if (size > maxBytes) return fail(413, 'body_too_large'); - chunks.push(chunk); - }; - const onEnd = () => { cleanup(); resolve(Buffer.concat(chunks, size)); }; - const onError = () => fail(400, 'body_read_failed'); - const onAborted = () => fail(400, 'body_aborted'); - const timer = setTimeout(() => fail(408, 'body_timeout'), timeoutMs); - request.on('data', onData); - request.once('end', onEnd); - request.once('error', onError); - request.once('aborted', onAborted); - const length = request.headers['content-length']; - if (length !== undefined && Number(length) > maxBytes) fail(413, 'body_too_large'); - }); -} - -export function createNodeServer({ config, publisher, nodeAddress }) { - const server = createServer({ maxHeaderSize: 8192 }, async (request, response) => { - // A disconnected upload can emit an error after the body reader has detached. - request.on('error', () => {}); - try { - if (request.url === '/healthz' && request.method === 'GET') { - const status = publisher.status(); - return respond(response, status.pendingMessageId && !status.busy ? 503 : 200, { - status: status.pendingMessageId && !status.busy ? 'recovery_required' : 'ready', - chainId: config.chainId, loggerContract: config.loggerContract, nodeAddress, ...status, - }, { connection: 'close' }); - } - if (request.url !== '/v1/messages') { - return respond(response, 404, { code: 'not_found' }, { connection: 'close' }); - } - if (request.method !== 'POST') { - return respond(response, 405, { code: 'method_not_allowed' }, { allow: 'POST', connection: 'close' }); - } - const body = await readBody(request, config.maxBodyBytes, config.bodyTimeoutMs); - const result = await handleSignedMessage({ method: request.method, contentType: request.headers['content-type'], body }, { - authorize: config.authorize, maxBodyBytes: config.maxBodyBytes, maxTextBytes: config.maxTextBytes, - onAcceptedMessage: publisher.publish, - }); - respond(response, result.status, { - ...result.body, - ...(result.status === 202 ? { publication: result.handleSignedMessageResult } : {}), - }); - } catch (error) { - if (error instanceof PublicationUnavailable) { - return respond(response, 503, { - code: error.code, - ...(error.record ? { publication: publicRecord(error.record) } : {}), - }, { 'retry-after': '5' }); - } - if (error.status && error.code) { - return respond(response, error.status, { code: error.code }, { connection: 'close' }); - } - respond(response, 500, { code: 'internal_error' }, { connection: 'close' }); - } - }); - server.requestTimeout = config.bodyTimeoutMs; - server.headersTimeout = Math.min(config.bodyTimeoutMs, 10_000); - server.keepAliveTimeout = 5000; - server.maxConnections = 64; - return server; -} diff --git a/node/production/src/signer.mjs b/node/production/src/signer.mjs deleted file mode 100644 index 6758ebb7..00000000 --- a/node/production/src/signer.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { Wallet, keccak256 } from 'ethers'; - -export function createLocalSigner(privateKey) { - let wallet; - try { - if (typeof privateKey !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(privateKey)) throw new Error(); - wallet = new Wallet(privateKey); - } catch { - throw new Error('OYA_NODE_PRIVATE_KEY must contain a valid Ethereum private key.'); - } - return Object.freeze({ - address: wallet.address, - async signTransaction(transaction, signal) { - signal?.throwIfAborted(); - const rawTransaction = await wallet.signTransaction({ ...transaction, accessList: [] }); - signal?.throwIfAborted(); - return { rawTransaction, transactionHash: keccak256(rawTransaction) }; - }, - }); -} diff --git a/node/production/src/store.mjs b/node/production/src/store.mjs deleted file mode 100644 index 725d503b..00000000 --- a/node/production/src/store.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { mkdir, open, readFile, readdir, rename, unlink } from 'node:fs/promises'; -import { join } from 'node:path'; -import { hostname } from 'node:os'; - -// Deduplicate signature encodings/casing too: one publication per signer + exact text. -export function messageId(message) { - return createHash('sha256').update(JSON.stringify([message.signer.toLowerCase(), message.text])).digest('hex'); -} - -async function writeAtomic(directory, filename, value) { - const temporary = join(directory, `.${filename}.${randomUUID()}.tmp`); - const file = await open(temporary, 'wx', 0o600); - try { - await file.writeFile(`${JSON.stringify(value)}\n`); - await file.sync(); - } finally { - await file.close(); - } - await rename(temporary, join(directory, filename)); - const parent = await open(directory, 'r'); - try { await parent.sync(); } finally { await parent.close(); } -} - -export async function openStore(directory, identity) { - await mkdir(directory, { recursive: true, mode: 0o700 }); - const lockPath = join(directory, 'runtime.lock'); - let lock; - try { lock = await open(lockPath, 'wx', 0o600); } catch (error) { - if (error.code === 'EEXIST') throw new Error('State directory is locked. Check runtime.lock and confirm the previous process has stopped before removing it.'); - throw error; - } - const close = async () => { await lock.close(); await unlink(lockPath); }; - try { - await lock.writeFile(JSON.stringify({ pid: process.pid, hostname: hostname(), startedAt: new Date().toISOString() })); - await lock.sync(); - let existing; - try { existing = JSON.parse(await readFile(join(directory, 'identity.json'), 'utf8')); } catch (error) { - if (error.code !== 'ENOENT') throw error; - } - if (existing && JSON.stringify(existing) !== JSON.stringify(identity)) { - throw new Error('State directory belongs to a different chain, Logger, or node account.'); - } - if (!existing) await writeAtomic(directory, 'identity.json', identity); - const records = new Map(); - for (const filename of await readdir(directory)) { - if (!/^[0-9a-f]{64}\.json$/.test(filename)) continue; - const record = JSON.parse(await readFile(join(directory, filename), 'utf8')); - if (record.id !== filename.slice(0, -5) || messageId(record.message) !== record.id) { - throw new Error('Stored message identifier is invalid.'); - } - records.set(record.id, record); - } - return { - records, - async save(record) { - await writeAtomic(directory, `${record.id}.json`, record); - records.set(record.id, record); - }, - close, - }; - } catch (error) { - await close(); - throw error; - } -} diff --git a/node/production/test/publication.test.mjs b/node/production/test/publication.test.mjs deleted file mode 100644 index 09e8b3d7..00000000 --- a/node/production/test/publication.test.mjs +++ /dev/null @@ -1,164 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtemp, readFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { Interface, Wallet, keccak256, toUtf8Bytes } from 'ethers'; -import { parseConfig } from '../src/config.mjs'; -import { createLocalSigner } from '../src/signer.mjs'; -import { messageId, openStore } from '../src/store.mjs'; -import { createPublisher } from '../src/publication.mjs'; - -const fixtures = JSON.parse(await readFile(new URL('../../../packages/ethereum/test/fixtures/logger-abi.json', import.meta.url), 'utf8')); -const cid = fixtures.cases.find((entry) => entry.name === 'message').cid; -const loggerContract = '0x1111111111111111111111111111111111111111'; - -async function fixture(t) { - const directory = await mkdtemp(join(tmpdir(), 'oya-publisher-test-')); - const wallet = Wallet.createRandom(); - const agent = Wallet.createRandom(); - const localSigner = createLocalSigner(wallet.privateKey); - const config = parseConfig({ - chainId: 31337, loggerContract, allowedSigners: [agent.address], - rpcUrl: 'http://rpc.example', ipfsUrl: 'http://ipfs.example', stateDir: directory, - receiptTimeoutMs: 50, pollIntervalMs: 1, - }); - const identity = { chainId: 31337, loggerContract, nodeAddress: wallet.address }; - let store = await openStore(directory, identity); - t.after(async () => { await store?.close(); }); - const message = { text: 'publish test', signer: agent.address, signature: await agent.signMessage('publish test') }; - const state = { signs: 0, uploads: 0, sends: 0, submitted: null, failSend: false, failReceipt: false, missingEvent: false }; - let releaseUpload; - let enteredUpload; - let holdUpload = null; - const signer = { - address: wallet.address, - async signTransaction(transaction, signal) { - state.signs++; - const durable = JSON.parse(await readFile(join(directory, `${messageId(message)}.json`), 'utf8')); - assert.equal(durable.cid, cid, 'CID must be durable before signing'); - return localSigner.signTransaction(transaction, signal); - }, - }; - const eventAbi = new Interface(['event Log(address indexed node, bytes32 indexed cidKeccak256Hash, string cid)']); - const transport = async (url, request) => { - if (url.startsWith(config.ipfs.url)) { - state.uploads++; - enteredUpload?.(); - if (holdUpload) await holdUpload; - return new Response(JSON.stringify({ Hash: cid })); - } - const { id, method, params } = JSON.parse(request.body); - let result; - switch (method) { - case 'eth_chainId': result = '0x7a69'; break; - case 'eth_getTransactionCount': result = '0x0'; break; - case 'eth_getBlockByNumber': result = { baseFeePerGas: '0x1', gasLimit: '0x1c9c380' }; break; - case 'eth_maxPriorityFeePerGas': result = '0x1'; break; - case 'eth_estimateGas': result = '0x8000'; break; - case 'eth_sendRawTransaction': { - state.sends++; - const durable = JSON.parse(await readFile(join(directory, `${messageId(message)}.json`), 'utf8')); - assert.equal(durable.signed.rawTransaction, params[0], 'signed bytes must be durable before broadcasting'); - assert.equal(durable.signed.transactionHash, keccak256(params[0])); - if (state.failSend) throw new Error('provider-secret-marker'); - state.submitted = params[0]; - result = keccak256(params[0]); - break; - } - case 'eth_getTransactionReceipt': { - if (state.failReceipt) throw new Error('provider-secret-marker'); - if (!state.submitted) { result = null; break; } - const transactionHash = keccak256(state.submitted); - const blockHash = `0x${'ab'.repeat(32)}`; - const event = eventAbi.encodeEventLog(eventAbi.getEvent('Log'), [wallet.address, keccak256(toUtf8Bytes(cid)), cid]); - result = { - transactionHash, blockHash, blockNumber: '0x1', transactionIndex: '0x0', - from: wallet.address, to: loggerContract, contractAddress: null, - cumulativeGasUsed: '0x8000', gasUsed: '0x8000', logsBloom: `0x${'00'.repeat(256)}`, status: '0x1', - logs: state.missingEvent ? [] : [{ - ...event, address: loggerContract, transactionHash, blockHash, blockNumber: '0x1', - transactionIndex: '0x0', logIndex: '0x0', removed: false, - }], - }; - break; - } - default: throw new Error(`Unexpected RPC method ${method}`); - } - return new Response(JSON.stringify({ jsonrpc: '2.0', id, result })); - }; - const publisher = () => createPublisher({ config, signer, store, fetch: transport }); - return { - publisher, state, message, store: () => store, - async reopen() { await store.close(); store = null; store = await openStore(directory, identity); }, - holdUpload() { - holdUpload = new Promise((resolve) => { releaseUpload = resolve; }); - return new Promise((resolve) => { enteredUpload = resolve; }); - }, - releaseUpload() { releaseUpload(); }, - async anotherMessage() { - const text = 'another signed message'; - return { text, signer: agent.address, signature: await agent.signMessage(text) }; - }, - }; -} - -test('persists before side effects, serializes work, and deduplicates completed messages', async (t) => { - const setup = await fixture(t); - const publisher = setup.publisher(); - const entered = setup.holdUpload(); - const pending = publisher.publish(setup.message); - await entered; - const idle = publisher.waitForIdle(); - let drained = false; - idle.then(() => { drained = true; }); - await assert.rejects(publisher.publish(await setup.anotherMessage()), { code: 'node_busy' }); - assert.equal(drained, false); - setup.releaseUpload(); - const result = await pending; - await idle; - assert.equal(drained, true); - assert.equal(result.status, 'logged'); - assert.equal(result.cid, cid); - assert.deepEqual(await publisher.publish(setup.message), result); - assert.equal(setup.state.uploads, 1); - assert.equal(setup.state.signs, 1); - assert.equal(setup.state.sends, 1); -}); - -test('uncertain submission blocks new work and resumes the same signed transaction after restart', async (t) => { - const setup = await fixture(t); - setup.state.failSend = true; - setup.state.failReceipt = true; - let publisher = setup.publisher(); - await assert.rejects(publisher.publish(setup.message), (error) => { - assert.equal(error.code, 'publication_incomplete'); - assert.ok(error.record.signed.transactionHash); - assert.equal(error.message.includes('provider-secret-marker'), false); - return true; - }); - await assert.rejects(publisher.publish(await setup.anotherMessage()), { code: 'recovery_required' }); - const signed = setup.store().records.get(messageId(setup.message)).signed; - await setup.reopen(); - setup.state.failSend = false; - setup.state.failReceipt = false; - publisher = setup.publisher(); - const recovered = await publisher.recover(); - assert.equal(recovered.transactionHash, signed.transactionHash); - assert.equal(setup.state.submitted, signed.rawTransaction); - assert.equal(setup.state.signs, 1); - assert.equal(setup.state.uploads, 1); - assert.equal(publisher.status().pendingMessageId, null); -}); - -test('rejects a receipt without the expected event and later reconciles it without another broadcast', async (t) => { - const setup = await fixture(t); - setup.state.missingEvent = true; - const publisher = setup.publisher(); - await assert.rejects(publisher.publish(setup.message), { code: 'publication_incomplete' }); - assert.equal(setup.store().records.get(messageId(setup.message)).result, undefined); - setup.state.missingEvent = false; - assert.equal((await publisher.recover()).status, 'logged'); - assert.equal(setup.state.sends, 1); - assert.equal(setup.state.signs, 1); -}); diff --git a/node/production/test/runtime.test.mjs b/node/production/test/runtime.test.mjs deleted file mode 100644 index d4702309..00000000 --- a/node/production/test/runtime.test.mjs +++ /dev/null @@ -1,126 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtemp, readFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { request as httpRequest } from 'node:http'; -import { test } from 'node:test'; -import { setTimeout as delay } from 'node:timers/promises'; -import { Transaction, Wallet } from 'ethers'; -import { parseConfig } from '../src/config.mjs'; -import { createLocalSigner } from '../src/signer.mjs'; -import { messageId, openStore } from '../src/store.mjs'; -import { createNodeServer } from '../src/server.mjs'; - -const agent = Wallet.createRandom(); -const configInput = { - chainId: 31337, loggerContract: '0x1111111111111111111111111111111111111111', - allowedSigners: [agent.address], rpcUrl: 'http://127.0.0.1:8545', ipfsUrl: 'http://127.0.0.1:5001', stateDir: './state', -}; -const signedMessage = async (wallet, text = 'Oya kernel signed message') => ({ - text, signer: wallet.address, signature: await wallet.signMessage(text), -}); - -test('config fails closed on missing allowlist, chain, and invalid endpoints', () => { - for (const change of [ - { allowedSigners: [] }, { allowedSigners: undefined }, { chainId: undefined }, { chainId: 1.5 }, - { loggerContract: '0x' }, { rpcUrl: 'file:///tmp/rpc' }, { stateDir: '' }, { port: 65536 }, - { maxFeePerGasWei: '-1' }, { typo: true }, - ]) assert.throws(() => parseConfig({ ...configInput, ...change })); - const config = parseConfig(configInput, { baseDir: '/tmp/oya-test', env: {} }); - assert.equal(config.stateDir, '/tmp/oya-test/state'); - assert.equal(config.host, '127.0.0.1'); -}); - -test('signer preserves EIP-1559 fields and does not disclose invalid secret values', async () => { - const wallet = Wallet.createRandom(); - const signer = createLocalSigner(wallet.privateKey); - const input = { - to: configInput.loggerContract, data: '0x1234', value: 0n, type: 2, chainId: 31337, - nonce: 4, gasLimit: 45_000n, maxFeePerGas: 2_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n, - }; - const signed = await signer.signTransaction(input); - const decoded = Transaction.from(signed.rawTransaction); - assert.equal(decoded.hash, signed.transactionHash); - assert.equal(decoded.from, wallet.address); - for (const [key, value] of Object.entries(input)) assert.equal(decoded[key], key === 'chainId' ? BigInt(value) : value); - assert.deepEqual(decoded.accessList, []); - assert.throws(() => createLocalSigner('secret-marker'), (error) => !error.message.includes('secret-marker')); - const aborted = AbortSignal.abort(); - await assert.rejects(signer.signTransaction(input, aborted)); -}); - -test('journal persists records, rejects a second process and a different deployment', async () => { - const directory = await mkdtemp(join(tmpdir(), 'oya-store-test-')); - const identity = { chainId: 31337, loggerContract: configInput.loggerContract, nodeAddress: agent.address }; - const store = await openStore(directory, identity); - const message = await signedMessage(agent); - const id = messageId(message); - assert.equal(id, messageId({ ...message, signer: message.signer.toLowerCase(), signature: 'alternate' })); - const record = { id, message }; - await store.save(record); - assert.deepEqual(JSON.parse(await readFile(join(directory, `${id}.json`), 'utf8')), record); - await assert.rejects(openStore(directory, identity), /locked/); - await store.close(); - await assert.rejects(openStore(directory, { ...identity, chainId: 1 }), /different/); - const reopened = await openStore(directory, identity); - assert.deepEqual(reopened.records.get(id), record); - await reopened.close(); -}); - -test('HTTP rejects unauthenticated and oversized requests before publication', async (t) => { - let accepted = 0; - const config = parseConfig({ ...configInput, maxBodyBytes: 1024, maxTextBytes: 100, bodyTimeoutMs: 100 }); - const server = createNodeServer({ config, nodeAddress: agent.address, publisher: { - status: () => ({ busy: false, pendingMessageId: null }), - publish: async () => { accepted++; return { status: 'logged', cid: 'example' }; }, - } }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - t.after(() => new Promise((resolve) => server.close(resolve))); - const url = `http://127.0.0.1:${server.address().port}`; - const post = (body, options = {}) => fetch(`${url}/v1/messages`, { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), ...options, - }); - const message = await signedMessage(agent); - assert.equal((await fetch(`${url}/healthz`)).status, 200); - assert.equal((await fetch(`${url}/v1/messages`)).status, 405); - assert.equal((await fetch(`${url}/unknown`)).status, 404); - assert.equal((await post(message, { headers: { 'content-type': 'text/plain' } })).status, 415); - assert.equal((await post(null)).status, 400); - assert.equal((await post(message, { body: '{bad json' })).status, 400); - assert.equal((await post({ ...message, text: 'tampered' })).status, 401); - assert.equal((await post(await signedMessage(Wallet.createRandom()))).status, 403); - assert.equal((await post({ ...message, text: 'x'.repeat(101) })).status, 413); - assert.equal((await post({ text: 'x'.repeat(2000) })).status, 413); - assert.equal(accepted, 0); - const chunkedStatus = await new Promise((resolve, reject) => { - const request = httpRequest(`${url}/v1/messages`, { - method: 'POST', headers: { 'content-type': 'application/json', 'transfer-encoding': 'chunked' }, - }, (response) => { response.resume(); resolve(response.statusCode); }); - request.on('error', reject); - request.write('x'.repeat(600)); - request.end('x'.repeat(600)); - }); - assert.equal(chunkedStatus, 413); - const timeoutStatus = await new Promise((resolve, reject) => { - const request = httpRequest(`${url}/v1/messages`, { - method: 'POST', headers: { 'content-type': 'application/json', 'content-length': '20' }, - }, (response) => { response.resume(); resolve(response.statusCode); request.destroy(); }); - request.on('error', reject); - request.write('{'); - }); - assert.equal(timeoutStatus, 408); - const disconnected = httpRequest(`${url}/v1/messages`, { - method: 'POST', headers: { 'content-type': 'application/json', 'content-length': '20' }, - }); - disconnected.on('error', () => {}); - disconnected.write('{'); - await delay(20); - disconnected.destroy(); - await delay(20); - assert.equal((await fetch(`${url}/healthz`)).status, 200); - assert.equal(accepted, 0); - const response = await post(message); - assert.equal(response.status, 202); - assert.equal((await response.json()).publication.status, 'logged'); - assert.equal(accepted, 1); -}); diff --git a/plans/kernel-node-logger-execplan.md b/plans/kernel-node-logger-execplan.md deleted file mode 100644 index bd28e508..00000000 --- a/plans/kernel-node-logger-execplan.md +++ /dev/null @@ -1,117 +0,0 @@ -# Run a kernel-backed Oya message node and deploy Logger - -This ExecPlan is a living document maintained according to `PLANS.md`. - -## Purpose / Big Picture - -An operator can start an Oya HTTP node, submit an allowlisted agent's Ethereum-signed text message, retrieve the published JSON from IPFS, and observe its CID in a mined Logger event attributed to the node's account. This establishes a running node built on the hardened kernel packages and a deployed Logger. Commitments continue to use Safe and Optimistic Governor; reimbursement verification and DeFi integrations are subsequent work. - -## Progress - -- [x] 2026-09-07: Read root, package, and contract instructions; inspected kernel APIs and existing node entrypoints. -- [x] 2026-09-07: Confirmed Foundry, Node 23.10.0, and Kubo/IPFS 0.40.1 are installed; dependencies are not installed in this checkout. -- [x] 2026-09-07: Implemented the standalone kernel runtime, explicit config, signing adapter, and durable publication progress. -- [x] 2026-09-07: Added Logger deployment tooling, operator instructions, signed-message client, and CI runtime tests. -- [x] 2026-09-07: Seven host tests passed, covering HTTP limits/authentication, signing, persistence, concurrency, ambiguous submission, and receipt verification. -- [x] 2026-09-07: Deployed Logger on isolated Anvil; real Kubo/HTTP/chain smoke passed including two restart-recovery paths and concurrency. -- [x] 2026-09-07: Final runtime tests and formatting/whitespace checks passed; a fresh validated local stack is running through the real CLI entrypoint. -- [x] 2026-09-07: Recorded the running local endpoints and deployment evidence below. -- [x] 2026-09-07: Reworded documentation and sample messages around kernel-node behavior for reuse in the upstream repository. -- [x] 2026-09-07: Moved the standalone runtime to `node/production/` and updated documentation, CI, CLI startup paths, and ignore rules. -- [x] 2026-09-07: All seven runtime tests and the full local smoke, including CLI startup, passed from `node/production/`. Verified ignore rules and removed all old directory references. -- [ ] If public deployment is desired, obtain the selected chain, host, funded signer, RPC, and IPFS access; the deployment-scope question remains unanswered. - -## Surprises & Discoveries - -- Existing `node/` daemons import legacy `agent/` infrastructure. Kernel code explicitly excludes that dependency direction. A separate package under `node/production/` lets this runtime install and run independently. -- `createTransactionPreparer` already reads chain, nonce, gas, and fee data. The host only needs a signing adapter. Its documentation requires serialization through receipt observation and reconciliation of ambiguous submissions. -- `publishAndLogSignedMessage` does not persist intermediate progress. The host can compose `publishSignedMessage` and `logCid` to save the CID before signing, and wrap the transaction preparer to save signed bytes before broadcasting. -- Deployment target is pending user input. Local Anvil and an isolated Kubo repository provide a complete validation path without external credentials. -- Foundry's script target is relative to the invoking working directory even with `--root contracts`: use `contracts/script/DeployLogger.s.sol:DeployLogger` from the repository root. An initial smoke failed before deployment with `No such file or directory`; the corrected smoke passed. -- The kernel's raw-transaction duplicate recovery applies to retries within one invocation. Host restart recovery must first inspect an existing receipt and handle an already-known rebroadcast by observing the retained hash. This is implemented locally in `node/production/src/publication.mjs`. -- The sandbox blocks loopback listeners and some dependency downloads. Dependency setup and HTTP/local integration tests required command escalation; all were run successfully after access was granted. -- HTTP connection shutdown alone does not prove a disconnected client's publication finished. The host explicitly waits for the publication lifecycle before releasing its state lock. - -## Decision Log - -- Decision: Add an independent ESM Node package at `node/production/`, importing all hardened libraries through package roots. Rationale: runtime wiring belongs in host code, and installing it must not require the legacy agent. Date/Author: 2026-09-07 / Codex. -- Decision: Serialize accepted publication work and persist signed transactions before submission. Rationale: one dedicated node account needs nonce coordination, and retrying retained signed bytes supports recovery without creating new transactions. Date/Author: 2026-09-07 / Codex. -- Decision: Validate against real Anvil and Kubo first while the user chooses deployment scope. Rationale: provides observable end-to-end evidence without guessing a public chain or funded account. Date/Author: 2026-09-07 / Codex. -- Decision: Complete and leave running the local milestone while deployment scope is unanswered. Rationale: the local flow is fully usable, and choosing a public chain or accessing funds requires concrete environment details. Date/Author: 2026-09-07 / Codex. -- Decision: Name the standalone runtime directory `node/production/`. Rationale: the directory identifies the intended production node, while `packages/` contains its hardened kernel dependencies and older daemons remain experimental. Existing operational limitations remain documented. Date/Author: 2026-09-07 / Codex. - -## Outcomes & Retrospective - -The local milestone is implemented and running. Seven host tests and eight Logger contract tests pass, as do contract formatting and `git diff --check`. A real smoke deployed Logger, published/retrieved signed JSON through Kubo, checked Logger events, deduplicated requests, recovered both prepared and mined transactions, and rejected concurrent new work. The final smoke leaves Anvil, offline Kubo, and the actual node CLI running; its health check returned success after CLI startup. CI now installs the standalone runtime and runs its host tests after checking package build freshness. No public network deployment has been attempted because its chain, credentials, and hosting are not selected. - -Remaining limitations are explicit in `node/production/README.md`: a dedicated single-process signer, one in-flight publication, local durable journal without a repair API, operator handling of stale crash locks and persistently unresolved transactions, and receipt verification without additional confirmation depth. Public hosting and integration-specific verification remain separate work. - -The runtime now lives under `node/production/`, distinguishing its intended role from the experimental daemons. CI, docs, CLI startup, and ignore rules use that path. Package dependencies and their relative paths did not change. The rename passed all seven runtime tests plus the full local deployment/publication/recovery smoke and CLI health check. The temporary stack used for rename validation was stopped afterward. - -## Context and Orientation - -`packages/messages` authenticates EIP-191 signatures over exact ASCII text. Its ingress function takes raw HTTP-shaped data; it does not own a server. `packages/ipfs` publishes deterministic message JSON using a Kubo-compatible API and returns a canonical CID (content identifier). `packages/ethereum` prepares, submits, and verifies Logger transactions; signing remains the host's responsibility. `contracts/src/Logger.sol` emits `Log(address indexed node, bytes32 indexed cidKeccak256Hash, string cid)` and stores no history. The node address in the event is the account calling Logger, distinct from the agent who signs text. - -`node/production/` will own configuration, an HTTP adapter, a local-key signer, durable state, startup, tests, and local smoke tooling. `contracts/script/DeployLogger.s.sol` will own contract deployment. No kernel package functionality or agent-specific behavior needs to change. - -## Plan of Work - -First implement the host and configuration. Require expected chain ID, Logger address, signer allowlist, RPC and IPFS endpoints, and a dedicated node signing key loaded from environment. Use bounded request buffering and timeouts. Authenticate before side effects. Return publication metadata only after a successful checked Logger receipt. - -Save each authenticated operation under a stable identifier in a private state directory. Persist the message, published CID, and prepared signed transaction before each subsequent irreversible stage. Permit only one lifecycle at a time. On restart, resume incomplete records using retained signed bytes; do not allocate another nonce while an earlier signed transaction is unresolved. Prevent two processes sharing a state directory. Report partial failures with safe identifiers rather than provider errors or secrets. - -Next add deployment tooling local to `contracts/` and document startup, health, posting signed messages, and recovery. Finally run focused host tests and a real isolated Anvil/Kubo smoke flow: deploy Logger, start node, send signed message, fetch its IPFS bytes, inspect Logger receipt, submit a duplicate, and restart to verify persistence. - -## Concrete Steps - -Run commands from the repository root: - - npm --prefix packages ci - npm --prefix packages run build - npm --prefix node/production ci - npm --prefix node/production test - forge fmt --root contracts - forge build --root contracts --sizes - forge test --root contracts --offline -vv - npm --prefix node/production run smoke:local - npm --prefix node/production run smoke:local -- --keep-running - -The local smoke script uses Anvil, offline Kubo, and a temporary directory without touching the operator's existing IPFS repository. The last command keeps the validated stack running until SIGINT/SIGTERM. Exact operator startup and deployment commands are in `node/production/README.md` and `contracts/README.md`. Dependency installation and loopback sockets require network escalation in this workspace. - -## Validation and Acceptance - -Acceptance requires a genuine signed HTTP request producing retrievable IPFS JSON and a successful Logger event with the exact CID and configured node address. Invalid signatures, non-allowlisted signers, oversized requests, and wrong methods must cause no publication or transaction. Duplicate submissions must reuse the stored result. Concurrent requests must not allocate colliding nonces. Persisted prepared transactions must survive restart and resume without a new signature. Startup must reject a mismatched chain or missing Logger bytecode. - -Local deployment uses disposable funded Anvil accounts. Public deployment requires an explicitly selected network, suitable RPC endpoint, funded deployment/node signer, IPFS provider, and host details. Do not infer these from unrelated deployment examples or print private keys. Local success alone is not evidence of public deployment. - -## Idempotence and Recovery - -Builds and tests are repeatable. The local smoke uses isolated directories and processes. Normal duplicate message requests return the original publication result; they do not create another event. Persisting a prepared transaction before submission allows exact-byte rebroadcast after uncertain transport failures. A pending or unrecoverable signed transaction blocks later signing until reconciled. The signer must be dedicated to this runtime; the state-directory process lock does not coordinate other hosts or unrelated users of the same account. Retain the state directory across restarts. Logger deployment creates a new contract each time; record and reuse a successful deployed address. - -## Artifacts and Notes - -Commands in this plan use the current `node/production/` location. The earlier local deployment artifacts below were recorded before the directory rename; their historical temporary paths, addresses, and hashes are preserved. - -Rename validation used `npm --prefix node/production test` and `npm --prefix node/production run smoke:local -- --keep-running`. Both passed; the temporary smoke stack was stopped after confirming CLI startup. Its evidence is recorded at `/var/folders/l4/r069cwsn6gv75xdvj4r28gw40000gn/T/oya-kernel-local-SE5wvZ/evidence.json`. Logger was deployed at `0x70d927deff90141ec1d5eed5aa4231e764fa13f9` on that disposable Anvil chain. - -Initial successful smoke evidence: `/var/folders/l4/r069cwsn6gv75xdvj4r28gw40000gn/T/oya-kernel-local-BoC2F7/evidence.json`. That isolated chain has stopped. Logger address was `0x6fda21f1158b344477dfec3218519b0e9bb5b7f5`; deployment transaction `0xb83fa27a3141ac916dcec0539027afb11c184bca568f0edfee4661edee05cdc4`; first message log transaction `0x830d5b5382cd93ab115154d3be67ab5d731dff614e118e634a117f21328543bb`; CID `bafkreifnizzetmyn3uayctnsn6ozym6m5xj7tf57cbl7pay2phvigpzqua`. Each smoke generates different accounts and ports. Record the final running stack separately. Secret material must never appear in this document. - -Final running local stack, started 2026-09-07 using `npm --prefix node/production run smoke:local -- --keep-running`: - -- Node: `http://127.0.0.1:61723`; health: `GET /healthz`; message ingress: `POST /v1/messages`. -- Anvil RPC: `http://127.0.0.1:61720`, chain ID `31337`. -- Offline Kubo API: `http://127.0.0.1:61721`. -- Logger: `0xe4b379e76e212dda440ac0066c4015aaaff0c0ac`. -- Deployment transaction: `0x2634f7173348c092ecd1880d36e8e5b4b79ab20a1347d119da483ffbc1c6903b`. -- Node account: `0x9F9cE079a054E80Bca11F0EB1D9972eb2bC9061e`. -- Allowlisted agent: `0x516eA222a1Faf1FC1A3Af1b78ddc0aB0f1A4c96A`. -- First logged CID: `bafkreiaxso6ixj6pjt6i2t4huuqxk2sivq2beqgehqzwdgldvhvq7ezon4`. -- First Logger transaction: `0x29454ea33a5cbf921c36470db0fb84187395e6d08ca14ebbe9b75b2968bdcdae`. -- Prepared-transaction recovery hash: `0xbd83d554a3244e502021e6277094574b10a2920c0a5071a8650e7bf4452a55d9`. -- Artifacts: `/var/folders/l4/r069cwsn6gv75xdvj4r28gw40000gn/T/oya-kernel-local-flMwF0/` contains `evidence.json`, `config.json`, process logs, `state/`, isolated `ipfs/`, and a private local-only `.env`. - -The running command is attached to execution session `84445`. Stop it with SIGINT/Ctrl-C to shut down all its children. These endpoints last only while that local process is running; Anvil state is disposable. To recreate the stack later, rerun the keep-running command and use the newly printed endpoints and evidence. - -## Interfaces and Dependencies - -Public kernel imports: `createSignedMessageAuthorizer`, `handleSignedMessage`, `publishSignedMessage`, `createIpfsConfig`, `createHttpConfig`, `createTransactionPreparer`, `logCid`, and `requestEthereumJsonRpc`. The host signing adapter will use ethers and return `{ rawTransaction, transactionHash }` without broadcasting. The runtime uses built-in Node HTTP, filesystem, and crypto modules. Operators provide an EIP-1559 RPC endpoint and a Kubo-compatible IPFS API. Foundry deployment loads a deployment key from the environment. The node uses a separate explicit environment variable for its signing key.