Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e9885b7
feat(midnight-crossed-books): add readonly mode
prd-carapulse[bot] Aug 12, 2026
e19cbba
fix(midnight-crossed-books): address review findings
prd-carapulse[bot] Aug 12, 2026
d211480
fix(midnight-crossed-books): harden readonly configuration
prd-carapulse[bot] Aug 12, 2026
2403415
fix(midnight-crossed-books): preserve readonly caller semantics
prd-carapulse[bot] Aug 12, 2026
79acc32
fix(midnight-crossed-books): harden readonly mode transitions
prd-carapulse[bot] Aug 12, 2026
97fa2d5
fix(midnight-crossed-books): scope Railway variable mutations
prd-carapulse[bot] Aug 12, 2026
471dc18
fix(midnight-crossed-books): avoid secret variable listing
prd-carapulse[bot] Aug 12, 2026
cb337bb
fix(midnight-crossed-books): isolate simulation caller by mode
prd-carapulse[bot] Aug 12, 2026
b9f2c9c
fix(midnight-crossed-books): bound Railway metadata pagination
prd-carapulse[bot] Aug 12, 2026
db2c6c4
fix(midnight-crossed-books): preserve Railway CLI provisioning
prd-carapulse[bot] Aug 13, 2026
f4fbfe7
fix(midnight-crossed-books): use supported Railway variable flags
prd-carapulse[bot] Aug 13, 2026
91efac1
fix(midnight-crossed-books): address Railway review feedback
prd-carapulse[bot] Aug 13, 2026
f9a13e9
fix(midnight-crossed-books): make Railway mode transitions safe
prd-carapulse[bot] Aug 13, 2026
90e8d32
fix(midnight-crossed-books): scope Railway variable lookup
prd-carapulse[bot] Aug 13, 2026
9f6fbb0
fix(midnight-crossed-books): avoid reading stale signing keys
prd-carapulse[bot] Aug 13, 2026
5be87ef
fix(midnight-crossed-books): make Railway deletes secret-safe
prd-carapulse[bot] Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion bots/midnight-crossed-books/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ The generated files live under each infrastructure adapter's `generated/` direct

- `CHAIN_ID` — required, currently `8453`.
- `RPC_URL` — required. `RPC_URL_FALLBACK` is optional.
- `RESOLVER_PRIVATE_KEY` — required `0x`-prefixed 32-byte bot key.
- `READONLY` — optional; `true`/`1` enables simulation-only mode, while absent/`false`/`0` selects write mode. Other values are rejected.
- `SIMULATION_CALLER_ADDRESS` — required in readonly mode. Set it to the non-zero public EOA that would execute resolutions in write mode so `msg.sender`, profit transfers, and reverts match execution without loading its private key. The operator is responsible for supplying this public caller address; the zero address is rejected.
- `RESOLVER_PRIVATE_KEY` — required `0x`-prefixed 32-byte bot key unless `READONLY` is enabled.
Comment thread
prd-carapulse[bot] marked this conversation as resolved.
- `RESOLVER_ADDRESS` — optional deterministic deployment override.
- `API_BASE_URL` — Morpho API origin, default `https://api.morpho.org`.
- `ROUTER_API_BASE_URL` — Router API origin, defaults to `API_BASE_URL` for the public gateway.
Expand All @@ -57,6 +59,18 @@ pnpm --filter @repo/contracts run deploy:crossed-books-resolver

## Run

```sh
CHAIN_ID=8453 RPC_URL=https://… READONLY=true \
SIMULATION_CALLER_ADDRESS=0x… \
pnpm --filter @morpho-org/midnight-crossed-books run start
```

Readonly mode uses `SIMULATION_CALLER_ADDRESS` as the execution-equivalent simulation caller, logs
each profitable result as `match.computed`, and never creates a signer, transaction queue, or
submission. Only the public EOA address is required; do not provide or derive its private key.

To execute profitable resolutions instead, provide the signer key:

```sh
CHAIN_ID=8453 RPC_URL=https://… RESOLVER_PRIVATE_KEY=0x… \
pnpm --filter @morpho-org/midnight-crossed-books run start
Expand All @@ -75,6 +89,22 @@ RPC_URL=https://… RESOLVER_PRIVATE_KEY=0x… \
pnpm --filter @morpho-org/midnight-crossed-books run deploy:railway
```

For keyless readonly Railway provisioning, replace the private key with the public caller address:

```sh
RAILWAY_PROJECT_ID=… RAILWAY_ENVIRONMENT=staging \
RPC_URL=https://… READONLY=true SIMULATION_CALLER_ADDRESS=0x… \
pnpm --filter @morpho-org/midnight-crossed-books run deploy:railway
```

The deploy script validates and propagates `READONLY` and `SIMULATION_CALLER_ADDRESS`; it does not
require or install `RESOLVER_PRIVATE_KEY` in readonly mode. It also removes a stale private key when
switching to readonly and removes a stale simulation caller when switching to write mode, aborting
before the mode change if deletion fails. Deletion uses `RAILWAY_TOKEN` (or `RAILWAY_API_TOKEN`) with
Railway's key-only variable metadata and an explicitly project/environment/service/name-scoped
mutation; it never runs `railway variable list` or retrieves variable values. Write mode still
requires a valid key.

CI subsequently runs the same command with `DEPLOY_ONLY=true`, so GitHub holds only a
project/environment-scoped Railway token. Pushes to `main` deploy staging through the
`crossed-books-staging` GitHub Environment. Production deploys use the `release-crossed-books`
Expand Down
4 changes: 3 additions & 1 deletion bots/midnight-crossed-books/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ services:
environment:
CHAIN_ID: '8453'
RPC_URL: ${RPC_URL:?set RPC_URL}
RESOLVER_PRIVATE_KEY: ${RESOLVER_PRIVATE_KEY:?set RESOLVER_PRIVATE_KEY}
READONLY: ${READONLY:-false}
SIMULATION_CALLER_ADDRESS: ${SIMULATION_CALLER_ADDRESS:-}
RESOLVER_PRIVATE_KEY: ${RESOLVER_PRIVATE_KEY:-}
RESOLVER_ADDRESS: ${RESOLVER_ADDRESS:-}
API_BASE_URL: ${API_BASE_URL:-https://api.morpho.org}
ROUTER_API_BASE_URL: ${ROUTER_API_BASE_URL:-}
Expand Down
71 changes: 55 additions & 16 deletions bots/midnight-crossed-books/scripts/deploy-railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,23 @@ import { $ } from 'execa'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

import { parseLatestStatus, parseServices } from './railway'
import {
deleteRailwayVariable,
isRailwayVariableMissingError,
parseLatestStatus,
parseServices,
railwayVariableDeleteArgs,
railwayVariableSetArgs,
resolveRailwayAccessToken,
resolveProvisioningConfiguration,
synchronizeModeVariables
} from './railway'
import { RailwayVariableOperationError } from './railway-variable-operation.error'

const PROJECT_ID = required(process.env, 'RAILWAY_PROJECT_ID')
const ENVIRONMENT = process.env.RAILWAY_ENVIRONMENT?.trim() || 'production'
const SERVICE = ENVIRONMENT === 'production' ? 'bot' : `${ENVIRONMENT}-bot`
const VARIABLE_TARGET = { environment: ENVIRONMENT, projectId: PROJECT_ID, service: SERVICE }
const DOCKERFILE_PATH = 'bots/midnight-crossed-books/Dockerfile'
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const DEPLOY_ONLY = /^(1|true)$/i.test(process.env.DEPLOY_ONLY?.trim() || '')
Expand Down Expand Up @@ -52,12 +64,6 @@ function errorDetails(error: unknown) {
return error instanceof Error ? error.message : String(error)
}

function assertPrivateKey(key: string) {
if (!/^0x[0-9a-fA-F]{64}$/.test(key)) {
throw new Error('RESOLVER_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string')
}
}

async function assertCli() {
const { error } = await tryCatch($`railway --version`)
if (error) {
Expand Down Expand Up @@ -88,29 +94,59 @@ async function listServices() {
async function ensureService() {
if ((await listServices()).some(service => service.name === SERVICE)) {
console.log(`Service ${SERVICE} already exists.`)
return
return false
}

const { error } = await tryCatch($`railway add --service ${SERVICE} --json`)
if (error) throw new Error(`Failed to create service ${SERVICE}: ${errorDetails(error)}`)
console.log(`Created service ${SERVICE}.`)
return true
}

async function setVariable(value: string) {
const key = value.split('=')[0]
const { error } = await tryCatch($`railway variable set ${value} -s ${SERVICE} --skip-deploys`)
if (error) throw new Error(`Failed to set ${key} on ${SERVICE}: ${errorDetails(error)}`)
const { error } = await tryCatch($('railway', railwayVariableSetArgs(value, VARIABLE_TARGET)))
if (error) throw new RailwayVariableOperationError('set', key)
console.log(`Set ${key} on ${SERVICE}.`)
}

async function setSecret(name: string, value: string) {
const { error } = await tryCatch(
$({ input: value })`railway variable set ${name} --stdin -s ${SERVICE} --skip-deploys`
$({ input: value })('railway', railwayVariableSetArgs(name, VARIABLE_TARGET, { stdin: true }))
)
if (error) throw new Error(`Failed to set ${name} on ${SERVICE}`)
if (error) throw new RailwayVariableOperationError('set', name)
console.log(`Set ${name} on ${SERVICE} (secret).`)
}

const deleteVariable = async (name: string) => {
if (!process.env.RAILWAY_TOKEN?.trim() && !process.env.RAILWAY_API_TOKEN?.trim()) {
const { error } = await tryCatch($('railway', railwayVariableDeleteArgs(name, VARIABLE_TARGET)))
Comment thread
prd-carapulse[bot] marked this conversation as resolved.
if (error && !isRailwayVariableMissingError(name, errorDetails(error))) {
throw new RailwayVariableOperationError('delete', name)
}
if (error) {
console.log(`${name} is already absent on ${SERVICE}.`)
return
}
console.log(`Deleted ${name} on ${SERVICE} (stale).`)
return
}

const deleted = await deleteRailwayVariable({
Comment thread
prd-carapulse[bot] marked this conversation as resolved.
fetcher: fetch,
name,
target: VARIABLE_TARGET,
token: resolveRailwayAccessToken(process.env)
Comment thread
prd-carapulse[bot] marked this conversation as resolved.
})
console.log(
deleted ? `Deleted ${name} on ${SERVICE} (stale).` : `${name} is already absent on ${SERVICE}.`
)
}

const skipVariableDeletion = async (name: string) => {
console.log(`${name} is already absent on newly created service ${SERVICE}.`)
}

async function deployService() {
const message = `deploy midnight crossed-books ${ENVIRONMENT}`
const { error } = await tryCatch(
Expand Down Expand Up @@ -165,15 +201,18 @@ if (DEPLOY_ONLY) {
reportStatus(await waitForDeploy())
} else {
const rpcUrl = required(process.env, 'RPC_URL')
const resolverPrivateKey = required(process.env, 'RESOLVER_PRIVATE_KEY')
assertPrivateKey(resolverPrivateKey)
const config = resolveProvisioningConfiguration(process.env)

await ensureContext()
await ensureService()
const serviceCreated = await ensureService()
await setVariable('CHAIN_ID=8453')
await synchronizeModeVariables(config, {
deleteVariable: serviceCreated ? skipVariableDeletion : deleteVariable,
setSecret,
setVariable
})
await setVariable(`RAILWAY_DOCKERFILE_PATH=${DOCKERFILE_PATH}`)
await setSecret('RPC_URL', rpcUrl)
await setSecret('RESOLVER_PRIVATE_KEY', resolverPrivateKey)
await deployService()
reportStatus(await waitForDeploy())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** Raised when secret-safe Railway API operations have no supported API credential. */
export class RailwayAccessTokenRequiredError extends Error {
/** Creates a credential-free provisioning failure. */
constructor() {
super('RAILWAY_TOKEN or RAILWAY_API_TOKEN is required for safe Railway variable deletion')
this.name = 'RailwayAccessTokenRequiredError'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
type RailwayVariableOperation = 'delete' | 'set'

/** Raised when a Railway variable operation fails without exposing variable values. */
export class RailwayVariableOperationError extends Error {
/**
* Creates a secret-safe CLI operation failure.
* @param operation - Failed Railway operation.
* @param variableName - Variable name for a targeted operation; never a value.
*/
constructor(operation: RailwayVariableOperation, variableName?: string) {
super(
variableName
? `Failed to ${operation} Railway variable ${variableName}`
: `Failed to ${operation} Railway variables`
)
this.name = 'RailwayVariableOperationError'
}
}
Loading
Loading