diff --git a/bots/midnight-crossed-books/README.md b/bots/midnight-crossed-books/README.md index a7c2eb39..1834ca8b 100644 --- a/bots/midnight-crossed-books/README.md +++ b/bots/midnight-crossed-books/README.md @@ -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. - `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. @@ -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 @@ -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` diff --git a/bots/midnight-crossed-books/docker-compose.yml b/bots/midnight-crossed-books/docker-compose.yml index f19771e7..15836537 100644 --- a/bots/midnight-crossed-books/docker-compose.yml +++ b/bots/midnight-crossed-books/docker-compose.yml @@ -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:-} diff --git a/bots/midnight-crossed-books/scripts/deploy-railway.ts b/bots/midnight-crossed-books/scripts/deploy-railway.ts index eb7760ea..55c0cac7 100644 --- a/bots/midnight-crossed-books/scripts/deploy-railway.ts +++ b/bots/midnight-crossed-books/scripts/deploy-railway.ts @@ -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() || '') @@ -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) { @@ -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))) + 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({ + fetcher: fetch, + name, + target: VARIABLE_TARGET, + token: resolveRailwayAccessToken(process.env) + }) + 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( @@ -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()) } diff --git a/bots/midnight-crossed-books/scripts/railway-access-token-required.error.ts b/bots/midnight-crossed-books/scripts/railway-access-token-required.error.ts new file mode 100644 index 00000000..ea46f81d --- /dev/null +++ b/bots/midnight-crossed-books/scripts/railway-access-token-required.error.ts @@ -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' + } +} diff --git a/bots/midnight-crossed-books/scripts/railway-variable-operation.error.ts b/bots/midnight-crossed-books/scripts/railway-variable-operation.error.ts new file mode 100644 index 00000000..499a3fa2 --- /dev/null +++ b/bots/midnight-crossed-books/scripts/railway-variable-operation.error.ts @@ -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' + } +} diff --git a/bots/midnight-crossed-books/scripts/railway.ts b/bots/midnight-crossed-books/scripts/railway.ts index 1af38de1..5f563fb2 100644 --- a/bots/midnight-crossed-books/scripts/railway.ts +++ b/bots/midnight-crossed-books/scripts/railway.ts @@ -1,6 +1,279 @@ import { tryCatch } from '@repo/utils' +import { getAddress, isAddress, isAddressEqual, isHex, zeroAddress } from 'viem' + +import { InvalidConfigurationError } from '../src/config/invalid-configuration.error' +import { InvalidSimulationCallerAddressError } from '../src/config/invalid-simulation-caller-address.error' +import { parseReadonly } from '../src/config/readonly.utils' +import { ResolverPrivateKeyRequiredError } from '../src/config/resolver-private-key-required.error' +import { RailwayAccessTokenRequiredError } from './railway-access-token-required.error' +import { RailwayVariableOperationError } from './railway-variable-operation.error' type RailwayService = { name: string } +type Env = Record +type RailwayVariableTarget = { environment: string; projectId: string; service: string } +type RailwayAccessToken = { + header: 'authorization' | 'project-access-token' + value: string +} +type RailwayVariableTargetIds = { environmentId: string; serviceId: string } +type ProvisioningConfiguration = + | { readOnly: true; resolverPrivateKey: undefined; simulationCaller: `0x${string}` } + | { readOnly: false; resolverPrivateKey: string; simulationCaller: undefined } +type ModeVariableOperations = { + deleteVariable: (name: string) => Promise + setSecret: (name: string, value: string) => Promise + setVariable: (value: string) => Promise +} + +const railwayVariableTargetArgs = (target: RailwayVariableTarget) => [ + '-s', + target.service, + '-e', + target.environment, + '-p', + target.projectId +] + +/** + * Resolves an API credential without exposing it in command arguments or logs. + * @param env - Deploy environment containing a project or account Railway token. + * @returns The supported Railway API authentication header and value. + * @throws `RailwayAccessTokenRequiredError` when no API credential is available. + */ +export const resolveRailwayAccessToken = (env: Env): RailwayAccessToken => { + const projectToken = env.RAILWAY_TOKEN?.trim() + if (projectToken) return { header: 'project-access-token', value: projectToken } + + const apiToken = env.RAILWAY_API_TOKEN?.trim() + if (apiToken) return { header: 'authorization', value: `Bearer ${apiToken}` } + throw new RailwayAccessTokenRequiredError() +} + +const RAILWAY_GRAPHQL_ENDPOINT = 'https://backboard.railway.com/graphql/v2' +const TARGET_QUERY = `query RailwayVariableTarget($projectId: String!) { + project(id: $projectId) { + environments { edges { node { id name } } } + services { edges { node { id name } } } + } +}` +const VARIABLE_DELETE_MUTATION = `mutation RailwayVariableDelete( + $projectId: String! + $environmentId: String! + $serviceId: String! + $name: String! +) { + variableDelete(input: { + projectId: $projectId + environmentId: $environmentId + serviceId: $serviceId + name: $name + }) +}` + +const recordField = (value: unknown, field: string) => + isRecord(value) && isRecord(value[field]) ? value[field] : undefined + +const edgesOf = (value: unknown) => { + const edges = isRecord(value) ? value.edges : undefined + return Array.isArray(edges) ? edges : [] +} + +const postRailwayGraphql = async ({ + body, + error, + fetcher, + token +}: { + body: { query: string; variables: Record } + error: RailwayVariableOperationError + fetcher: typeof fetch + token: RailwayAccessToken +}) => { + const result = await tryCatch( + fetcher(RAILWAY_GRAPHQL_ENDPOINT, { + body: JSON.stringify(body), + headers: { 'content-type': 'application/json', [token.header]: token.value }, + method: 'POST' + }) + ) + if (result.error || !result.data.ok) throw error + + const json = await tryCatch(result.data.json()) + if (json.error || !isRecord(json.data) || Array.isArray(json.data.errors)) throw error + return json.data.data +} + +const resolveRailwayVariableTarget = async ({ + error, + fetcher, + target, + token +}: { + error: RailwayVariableOperationError + fetcher: typeof fetch + target: RailwayVariableTarget + token: RailwayAccessToken +}): Promise => { + const data = await postRailwayGraphql({ + body: { query: TARGET_QUERY, variables: { projectId: target.projectId } }, + error, + fetcher, + token + }) + const project = recordField(data, 'project') + const environment = edgesOf(recordField(project, 'environments')) + .map(edge => recordField(edge, 'node')) + .find(node => node?.name === target.environment) + const service = edgesOf(recordField(project, 'services')) + .map(edge => recordField(edge, 'node')) + .find(node => node?.name === target.service) + if (typeof environment?.id !== 'string' || typeof service?.id !== 'string') throw error + return { environmentId: environment.id, serviceId: service.id } +} + +/** + * Deletes one Railway variable from an explicitly scoped deployment target. + * @param parameters - Fetch implementation, credential, exact target names, and variable name. + * @returns `true` when Railway deletes the variable, or `false` when it is already absent. + * @throws `RailwayVariableOperationError` when target lookup, transport, or deletion fails. + * @remarks Calls the key-only deletion mutation directly so no service variable values enter this process. + */ +export const deleteRailwayVariable = async ({ + fetcher, + name, + target, + token +}: { + fetcher: typeof fetch + name: string + target: RailwayVariableTarget + token: RailwayAccessToken +}) => { + const error = new RailwayVariableOperationError('delete', name) + const ids = await resolveRailwayVariableTarget({ error, fetcher, target, token }) + const data = await postRailwayGraphql({ + body: { + query: VARIABLE_DELETE_MUTATION, + variables: { + environmentId: ids.environmentId, + name, + projectId: target.projectId, + serviceId: ids.serviceId + } + }, + error, + fetcher, + token + }) + if (!isRecord(data) || typeof data.variableDelete !== 'boolean') throw error + return data.variableDelete +} + +/** + * Recognizes Railway CLI's documented missing-variable deletion failure. + * @param name - Exact variable name passed to `railway variable delete`. + * @param details - Captured CLI stderr or error message. + * @returns Whether the failure says that exact variable is absent. + * @throws Nothing. + * @remarks Performs no logging or mutation and should only turn this one idempotent case into success. + */ +export const isRailwayVariableMissingError = (name: string, details: string) => + details.includes(`Variable '${name}' not found`) + +/** + * Builds arguments for deleting one variable from an exact Railway deployment target. + * @param name - Name of the variable to delete; no variable value enters command arguments. + * @param target - Project, environment, and service from which the variable must be deleted. + * @returns CLI arguments with explicit project, environment, and service context. + * @throws Nothing; the caller handles Railway CLI failures, including a missing variable in a + * linked CLI session. + * @remarks Builds arguments only and performs no I/O or mutation itself. + */ +export const railwayVariableDeleteArgs = (name: string, target: RailwayVariableTarget) => [ + 'variable', + 'delete', + name, + ...railwayVariableTargetArgs(target) +] + +/** + * Builds arguments for setting one variable on one Railway deployment target. + * @param value - Public `KEY=VALUE` assignment, or only a variable name when stdin is enabled. + * @param target - Project, environment, and service that must receive the variable. + * @param options - Enables secret-safe stdin input without placing the value in command arguments. + * @returns CLI arguments including supported service, environment, and no-deploy flags. + * @remarks Secret values remain on stdin when `stdin` is true; only the variable name enters args. + */ +export const railwayVariableSetArgs = ( + value: string, + target: RailwayVariableTarget, + { stdin = false }: { stdin?: boolean } = {} +) => [ + 'variable', + 'set', + value, + ...(stdin ? ['--stdin'] : []), + ...railwayVariableTargetArgs(target), + '--skip-deploys' +] + +/** + * Validates mode-specific Railway provisioning values without retaining unused signing material. + * @param env - Local deploy environment containing mode, caller, and optional signing key. + * @returns Canonical readonly, simulation-caller, and write-key values for Railway installation. + * @throws `InvalidConfigurationError` when mode, caller, or key syntax is invalid. + * @throws `ResolverPrivateKeyRequiredError` when write mode has no signing key. + * @remarks Readonly mode never reads or returns `RESOLVER_PRIVATE_KEY`. + */ +export const resolveProvisioningConfiguration = (env: Env): ProvisioningConfiguration => { + const readOnly = parseReadonly(env.READONLY) + if (readOnly) { + const caller = env.SIMULATION_CALLER_ADDRESS?.trim() + if (!caller || !isAddress(caller, { strict: false }) || isAddressEqual(caller, zeroAddress)) { + throw new InvalidSimulationCallerAddressError() + } + return { + readOnly, + resolverPrivateKey: undefined, + simulationCaller: getAddress(caller) + } + } + + const resolverPrivateKey = env.RESOLVER_PRIVATE_KEY?.trim() + if (!resolverPrivateKey) throw new ResolverPrivateKeyRequiredError() + if (!isHex(resolverPrivateKey, { strict: true }) || resolverPrivateKey.length !== 66) { + throw new InvalidConfigurationError( + 'RESOLVER_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string' + ) + } + + return { readOnly, resolverPrivateKey, simulationCaller: undefined } +} + +/** + * Synchronizes Railway's mutually exclusive mode variables before changing the active mode. + * @param config - Validated mode-specific provisioning values. + * @param operations - Secret-safe Railway variable mutation operations. + * @returns A promise that resolves after the replacement variable and mode are set, then the + * incompatible variable is removed. + * @throws The underlying mutation error. A failed replacement or mode update leaves the old + * mode-specific variable in place. + * @remarks Secret values are passed only to `setSecret` and are never logged by this helper. + */ +export const synchronizeModeVariables = async ( + config: ReturnType, + operations: ModeVariableOperations +) => { + if (config.readOnly) { + await operations.setVariable(`SIMULATION_CALLER_ADDRESS=${config.simulationCaller}`) + await operations.setVariable('READONLY=true') + await operations.deleteVariable('RESOLVER_PRIVATE_KEY') + } else { + await operations.setSecret('RESOLVER_PRIVATE_KEY', config.resolverPrivateKey) + await operations.setVariable('READONLY=false') + await operations.deleteVariable('SIMULATION_CALLER_ADDRESS') + } +} function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null diff --git a/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts b/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts index b86d1f62..f9494820 100644 --- a/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts +++ b/bots/midnight-crossed-books/src/application/crossed-books-bot.service.ts @@ -29,6 +29,17 @@ interface BotLogger { } export class CrossedBooksBotService { + /** + * Creates one resolver workflow. + * @param markets - Listed-market discovery port. + * @param books - Takeable-book reader. + * @param matching - Pure crossed-offer matcher. + * @param resolver - Simulation and optional submission port. + * @param maxMatches - Maximum matches encoded into one resolution. + * @param inflightMarketIds - Current write-mode transaction labels. + * @param readOnly - Whether successful simulations are logged instead of submitted. + * @param logger - Structured operator logger. + */ constructor( private readonly markets: ListedMarketsService, private readonly books: OrderBookService, @@ -36,12 +47,20 @@ export class CrossedBooksBotService { private readonly resolver: ResolverService, private readonly maxMatches: number, private readonly inflightMarketIds: () => ReadonlySet, + private readonly readOnly: boolean, private readonly logger: BotLogger ) {} + /** + * Computes the first profitable crossed resolution for one block. + * @param blockNumber - Block label used only when queueing a write-mode transaction. + * @returns Submission status and the number of listed markets inspected. + * @remarks Always simulates first. Readonly mode logs `match.computed` and performs no submission. + */ async run({ blockNumber }: { blockNumber: bigint }) { const markets = await this.markets.listListedActiveMarkets() const inflight = this.inflightMarketIds() + let computed = false for (const { marketId } of markets) { if (inflight.has(marketId)) continue @@ -65,16 +84,24 @@ export class CrossedBooksBotService { continue } - await this.resolver.submit(simulation.prepared, blockNumber) - this.logger.info('match.submitted', { + const fields = { marketId, units: matches.reduce((total, match) => total + match.units, 0n), profit: simulation.prepared.profit - }) + } + if (this.readOnly) { + this.logger.info('match.computed', fields) + computed = true + continue + } + + await this.resolver.submit(simulation.prepared, blockNumber) + this.logger.info('match.submitted', fields) return { submitted: true, markets: markets.length } } + if (computed) return { submitted: false, markets: markets.length } this.logger.info('tick.no_match', { markets: markets.length }) return { submitted: false, markets: markets.length } } diff --git a/bots/midnight-crossed-books/src/bootstrap.ts b/bots/midnight-crossed-books/src/bootstrap.ts index ba18d3ce..0b02a555 100644 --- a/bots/midnight-crossed-books/src/bootstrap.ts +++ b/bots/midnight-crossed-books/src/bootstrap.ts @@ -17,6 +17,8 @@ import { getBlockNumber } from 'viem/actions' import { CrossedBooksBotService } from './application/crossed-books-bot.service' import { ConfigService } from './config/config.service' +import { InvalidConfigurationError } from './config/invalid-configuration.error' +import { ResolverPrivateKeyRequiredError } from './config/resolver-private-key-required.error' import { MatchingService } from './domain/matching.service' import { createMorphoApiClient } from './infrastructure/morpho-api/client' import { MorphoApiService } from './infrastructure/morpho-api/service' @@ -32,6 +34,15 @@ function resolverSelector() { return toFunctionSelector(resolveAbi) } +/** + * Composes the crossed-books resolver runtime for the selected environment mode. + * @param environment - Runtime configuration and optional observability values. + * @returns A lifecycle handle that polls immediately and then follows new blocks when started. + * @throws `InvalidConfigurationError` when readonly mode or its caller is invalid. + * @throws `Error` when other configuration is invalid or required contracts are not deployed. + * @remarks Readonly composition creates no signer, pending transaction queue, or balance monitor; + * both modes perform RPC deployment checks during composition. + */ export async function createApplication( environment: Record = process.env ) { @@ -43,23 +54,45 @@ export async function createApplication( ...railwayContext() } }) - const signer = createSigner({ - chain: config.chain, - rpcUrl: config.rpcUrl, - rpcUrlFallback: config.rpcUrlFallback, - privateKey: config.privateKey, - policy: { - chainId: config.chainId, - executor: config.resolver, - selector: resolverSelector(), - maxFeePerGasWei: config.maxFeeWei, - maxGasLimit: DEFAULT_MAX_GAS_LIMIT, - maxDataBytes: DEFAULT_MAX_DATA_BYTES - }, - logger - }) - const sender = signer.account.address const chainClient = createDeploylessClient(config) + let sender = config.resolver + let signer: ReturnType | undefined + let queue: ReturnType | undefined + + if (config.readOnly) { + if (!config.simulationCaller) { + throw new InvalidConfigurationError('Readonly mode requires SIMULATION_CALLER_ADDRESS') + } + sender = config.simulationCaller + } else { + const privateKey = config.privateKey + if (!privateKey) throw new ResolverPrivateKeyRequiredError() + signer = createSigner({ + chain: config.chain, + rpcUrl: config.rpcUrl, + rpcUrlFallback: config.rpcUrlFallback, + privateKey, + policy: { + chainId: config.chainId, + executor: config.resolver, + selector: resolverSelector(), + maxFeePerGasWei: config.maxFeeWei, + maxGasLimit: DEFAULT_MAX_GAS_LIMIT, + maxDataBytes: DEFAULT_MAX_DATA_BYTES + }, + logger + }) + sender = signer.account.address + queue = createPendingQueue({ + send: signer.send, + getReceipt: signer.getReceipt, + getBaseFee: signer.getBaseFee, + syncNonce: signer.syncNonce, + getConsumedNonce: signer.consumedNonce, + maxFeeWei: config.maxFeeWei, + logger + }) + } await assertContractDeployed(chainClient, config.midnight, 'Midnight singleton') await assertContractDeployed( @@ -69,28 +102,18 @@ export async function createApplication( 'deploy it with `pnpm --filter @repo/contracts run deploy:crossed-books-resolver`' ) - const queue = createPendingQueue({ - send: signer.send, - getReceipt: signer.getReceipt, - getBaseFee: signer.getBaseFee, - syncNonce: signer.syncNonce, - getConsumedNonce: signer.consumedNonce, - maxFeeWei: config.maxFeeWei, - logger - }) const markets = new MorphoApiService( createMorphoApiClient(config.apiBaseUrl), config.chainId as 8453 ) const books = new RouterApiService(createRouterApiClient(config.routerApiBaseUrl)) const matching = new MatchingService() + const submission = queue && signer ? { queue, signer, maxFeeWei: config.maxFeeWei } : undefined const resolverTransport = new ViemResolverTransport( chainClient, sender, config.resolver, - queue, - signer, - config.maxFeeWei + submission ) const resolver = new ResolverExecutionService( resolverTransport, @@ -103,10 +126,13 @@ export async function createApplication( matching, resolver, config.maxMatches, - () => queue.inflightLabels(), + () => queue?.inflightLabels() ?? new Set(), + config.readOnly, logger ) - const balance = createBalanceMonitor({ address: sender, read: signer.balance, logger }) + const balance = signer + ? createBalanceMonitor({ address: sender, read: signer.balance, logger }) + : undefined const heartbeat = createHeartbeatMonitor({ url: environment.BETTERSTACK_HEARTBEAT_URL, logger @@ -116,13 +142,13 @@ export async function createApplication( const runner = createRunner({ getBlockNumber: () => getBlockNumber(chainClient), tick: async blockNumber => { - if (Date.now() < nextScanAt || queue.size > 0) return + if (Date.now() < nextScanAt || (queue?.size ?? 0) > 0) return nextScanAt = Date.now() + config.scanIntervalMs await bot.run({ blockNumber }) }, maintain: async blockNumber => { - await queue.onBlock(blockNumber) - await balance.maybeLog(blockNumber) + await queue?.onBlock(blockNumber) + await balance?.maybeLog(blockNumber) }, logger }) @@ -130,6 +156,7 @@ export async function createApplication( return { async start() { logger.info('startup', { + readOnly: config.readOnly, sender, midnight: config.midnight, resolver: config.resolver, diff --git a/bots/midnight-crossed-books/src/config/config.service.ts b/bots/midnight-crossed-books/src/config/config.service.ts index 3d058426..cc76147a 100644 --- a/bots/midnight-crossed-books/src/config/config.service.ts +++ b/bots/midnight-crossed-books/src/config/config.service.ts @@ -1,10 +1,13 @@ import type { Address, Chain, Hex } from 'viem' import { CrossedBooksResolver } from '@repo/contracts' -import { getAddress, isAddress, isHex, parseGwei } from 'viem' +import { getAddress, isAddress, isAddressEqual, isHex, parseGwei, zeroAddress } from 'viem' import { base } from 'viem/chains' import { DEFAULT_MAX_MATCHES } from '../domain/matching.service' +import { InvalidSimulationCallerAddressError } from './invalid-simulation-caller-address.error' +import { parseReadonly } from './readonly.utils' +import { ResolverPrivateKeyRequiredError } from './resolver-private-key-required.error' const MIDNIGHT = getAddress('0xAdedD8ab6dE832766Fedf0FaC4992E5C4D3EA18A') const PRIVATE_KEY_HEX_LENGTH = 66 @@ -24,14 +27,29 @@ function unsignedDecimal(environment: Environment, name: string, fallback?: stri } export class ConfigService { + /** + * Loads and validates resolver configuration from environment values. + * @param environment - Runtime environment; defaults to `process.env`. + * @returns Immutable configuration with signer material omitted in readonly mode. + * @throws `InvalidConfigurationError` when readonly mode or its caller is invalid. + * @throws `ResolverPrivateKeyRequiredError` when write mode has no signing key. + * @throws `Error` when another required runtime value is invalid. + * @remarks This method performs no network access and does not retain `RESOLVER_PRIVATE_KEY` when + * readonly mode is enabled. + */ static from(environment: Environment = process.env) { const chainId = Number(unsignedDecimal(environment, 'CHAIN_ID')) if (chainId !== base.id) { throw new Error(`Unsupported CHAIN_ID ${chainId}; supported: ${base.id}`) } - const privateKey = required(environment, 'RESOLVER_PRIVATE_KEY') - if (!isHex(privateKey, { strict: true }) || privateKey.length !== PRIVATE_KEY_HEX_LENGTH) { + const readOnly = parseReadonly(environment.READONLY) + const privateKey = readOnly ? undefined : environment.RESOLVER_PRIVATE_KEY?.trim() + if (!readOnly && !privateKey) throw new ResolverPrivateKeyRequiredError() + if ( + privateKey !== undefined && + (!isHex(privateKey, { strict: true }) || privateKey.length !== PRIVATE_KEY_HEX_LENGTH) + ) { throw new Error('RESOLVER_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string') } @@ -40,6 +58,16 @@ export class ConfigService { throw new Error('RESOLVER_ADDRESS must be an EVM address') } + const simulationCaller = readOnly ? environment.SIMULATION_CALLER_ADDRESS?.trim() : undefined + if ( + readOnly && + (!simulationCaller || + !isAddress(simulationCaller, { strict: false }) || + isAddressEqual(getAddress(simulationCaller), zeroAddress)) + ) { + throw new InvalidSimulationCallerAddressError() + } + const apiBaseUrl = (environment.API_BASE_URL?.trim() || 'https://api.morpho.org').replace( /\/$/, '' @@ -74,7 +102,9 @@ export class ConfigService { : CrossedBooksResolver.with(MIDNIGHT).address, rpcUrl: required(environment, 'RPC_URL'), rpcUrlFallback: environment.RPC_URL_FALLBACK?.trim() || undefined, + readOnly, privateKey, + simulationCaller: simulationCaller ? getAddress(simulationCaller) : undefined, apiBaseUrl, routerApiBaseUrl, scanIntervalMs, @@ -93,7 +123,9 @@ export class ConfigService { resolver: Address rpcUrl: string rpcUrlFallback: string | undefined - privateKey: Hex + readOnly: boolean + privateKey: Hex | undefined + simulationCaller: Address | undefined apiBaseUrl: string routerApiBaseUrl: string scanIntervalMs: number @@ -125,10 +157,21 @@ export class ConfigService { return this.values.rpcUrlFallback } + /** Returns the validated signer key in write mode and `undefined` in readonly mode. */ get privateKey() { return this.values.privateKey } + /** Returns whether transaction signing and submission are disabled. */ + get readOnly() { + return this.values.readOnly + } + + /** Returns the validated execution-equivalent caller used only for keyless simulation. */ + get simulationCaller() { + return this.values.simulationCaller + } + get apiBaseUrl() { return this.values.apiBaseUrl } diff --git a/bots/midnight-crossed-books/src/config/invalid-configuration.error.ts b/bots/midnight-crossed-books/src/config/invalid-configuration.error.ts new file mode 100644 index 00000000..737e5baa --- /dev/null +++ b/bots/midnight-crossed-books/src/config/invalid-configuration.error.ts @@ -0,0 +1,2 @@ +/** Raised when an operator-provided runtime value cannot be used safely. */ +export class InvalidConfigurationError extends Error {} diff --git a/bots/midnight-crossed-books/src/config/invalid-simulation-caller-address.error.ts b/bots/midnight-crossed-books/src/config/invalid-simulation-caller-address.error.ts new file mode 100644 index 00000000..1c2ce37b --- /dev/null +++ b/bots/midnight-crossed-books/src/config/invalid-simulation-caller-address.error.ts @@ -0,0 +1,10 @@ +import { InvalidConfigurationError } from './invalid-configuration.error' + +/** Raised when a simulation caller is missing, malformed, or the zero address. */ +export class InvalidSimulationCallerAddressError extends InvalidConfigurationError { + /** Creates a credential-free caller validation failure. */ + constructor() { + super('SIMULATION_CALLER_ADDRESS must be a public non-zero EVM address') + this.name = 'InvalidSimulationCallerAddressError' + } +} diff --git a/bots/midnight-crossed-books/src/config/readonly.utils.ts b/bots/midnight-crossed-books/src/config/readonly.utils.ts new file mode 100644 index 00000000..11adfbd6 --- /dev/null +++ b/bots/midnight-crossed-books/src/config/readonly.utils.ts @@ -0,0 +1,15 @@ +import { InvalidConfigurationError } from './invalid-configuration.error' + +/** + * Parses the fail-closed readonly mode switch. + * @param value - Raw `READONLY` environment value. + * @returns `true` for `true`/`1`; `false` for absent, empty, `false`, or `0` values. + * @throws `InvalidConfigurationError` for every other nonempty value. + * @remarks Parsing is case-insensitive and performs no side effects. + */ +export const parseReadonly = (value: string | undefined) => { + const normalized = value?.trim().toLowerCase() || '' + if (normalized === 'true' || normalized === '1') return true + if (normalized === '' || normalized === 'false' || normalized === '0') return false + throw new InvalidConfigurationError('READONLY must be one of: true, 1, false, 0') +} diff --git a/bots/midnight-crossed-books/src/config/resolver-private-key-required.error.ts b/bots/midnight-crossed-books/src/config/resolver-private-key-required.error.ts new file mode 100644 index 00000000..9bd586dd --- /dev/null +++ b/bots/midnight-crossed-books/src/config/resolver-private-key-required.error.ts @@ -0,0 +1,7 @@ +/** Raised when write-mode composition is missing resolver signing authority. */ +export class ResolverPrivateKeyRequiredError extends Error { + /** Creates a credential-free configuration invariant failure. */ + constructor() { + super('Write mode requires a resolver private key') + } +} diff --git a/bots/midnight-crossed-books/src/infrastructure/resolver/readonly-mutation.error.ts b/bots/midnight-crossed-books/src/infrastructure/resolver/readonly-mutation.error.ts new file mode 100644 index 00000000..921b7ced --- /dev/null +++ b/bots/midnight-crossed-books/src/infrastructure/resolver/readonly-mutation.error.ts @@ -0,0 +1,7 @@ +/** Raised when a readonly resolver transport is asked to mutate chain state. */ +export class ReadonlyMutationError extends Error { + /** Creates a credential-free, operator-safe readonly mutation failure. */ + constructor() { + super('Readonly resolver transport cannot submit transactions') + } +} diff --git a/bots/midnight-crossed-books/src/infrastructure/resolver/resolver.transport.ts b/bots/midnight-crossed-books/src/infrastructure/resolver/resolver.transport.ts index 13b25cdf..b40bd26b 100644 --- a/bots/midnight-crossed-books/src/infrastructure/resolver/resolver.transport.ts +++ b/bots/midnight-crossed-books/src/infrastructure/resolver/resolver.transport.ts @@ -8,6 +8,8 @@ import { call } from 'viem/actions' import type { PreparedResolution } from '../../domain/order-book' +import { ReadonlyMutationError } from './readonly-mutation.error' + export type ResolverSimulation = | { status: 'success'; data: Hex } | { status: 'revert'; reason: string } @@ -18,13 +20,22 @@ export interface ResolverTransport { } export class ViemResolverTransport implements ResolverTransport { + /** + * Creates an RPC simulation transport with optional write capabilities. + * @param client - Public chain client used for `eth_call`. + * @param sender - Simulation caller and write-mode profit recipient. + * @param resolver - Resolver contract target. + * @param submission - Signer and queue dependencies; omission makes submission fail closed. + */ constructor( private readonly client: Client, private readonly sender: Address, private readonly resolver: Address, - private readonly queue: PendingQueue, - private readonly signer: Signer, - private readonly maxFeeWei: bigint + private readonly submission?: { + queue: PendingQueue + signer: Signer + maxFeeWei: bigint + } ) {} async simulate(data: Hex): Promise { @@ -48,10 +59,18 @@ export class ViemResolverTransport implements ResolverTransport { return { status: 'success', data: result.data.data } } + /** + * Queues the immutable request prepared by simulation. + * @param prepared - Resolver target calldata and market label. + * @param blockNumber - Block used to seed queue fee and replacement policy. + * @returns A promise that resolves once the request is accepted by the queue. + * @throws `ReadonlyMutationError` when submission dependencies were intentionally omitted. + */ async submit(prepared: PreparedResolution, blockNumber: bigint) { - const fees = initialFees(await this.signer.getBaseFee(), this.maxFeeWei) + if (!this.submission) throw new ReadonlyMutationError() + const fees = initialFees(await this.submission.signer.getBaseFee(), this.submission.maxFeeWei) - await this.queue.submit({ + await this.submission.queue.submit({ request: { to: this.resolver, data: prepared.data }, label: prepared.marketId, ...fees, diff --git a/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts b/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts index 437d424e..8963c5bc 100644 --- a/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts +++ b/bots/midnight-crossed-books/test/application/crossed-books-bot.service.test.ts @@ -37,6 +37,7 @@ function setup( simulation?: SimulationResult booksError?: Error maxMatches?: number + readOnly?: boolean } = {} ) { const listListedActiveMarkets = vi.fn(async () => overrides.markets ?? [MARKET]) @@ -68,6 +69,7 @@ function setup( resolver, overrides.maxMatches ?? 10, () => overrides.inflight ?? new Set(), + overrides.readOnly ?? false, logger ) @@ -78,7 +80,8 @@ function setup( getTakeableBook, match, simulate, - submit + submit, + logger } } @@ -161,6 +164,51 @@ describe('CrossedBooksBotService', () => { expect(result).toEqual({ submitted: true, markets: 1 }) }) + test('logs the computed result without submitting in readonly mode', async () => { + const prepared: PreparedResolution = { + marketId: MARKET_ID, + data: '0x1234', + profit: 42n + } + const { service, submit, logger } = setup({ + readOnly: true, + simulation: { status: 'ok', prepared } + }) + + const result = await service.run({ blockNumber: 10n }) + + expect(submit).not.toHaveBeenCalled() + expect(logger.info).toHaveBeenCalledWith('match.computed', { + marketId: MARKET_ID, + units: 5n, + profit: 42n + }) + expect(result).toEqual({ submitted: false, markets: 1 }) + }) + + test('logs every computed result without submitting in readonly mode', async () => { + const { service, getTakeableBook, simulate, submit, logger } = setup({ + readOnly: true, + markets: [MARKET, OTHER_MARKET] + }) + + const result = await service.run({ blockNumber: 10n }) + + expect(getTakeableBook).toHaveBeenCalledTimes(2) + expect(simulate).toHaveBeenCalledTimes(2) + expect(submit).not.toHaveBeenCalled() + expect(logger.info).toHaveBeenCalledWith( + 'match.computed', + expect.objectContaining({ marketId: MARKET_ID }) + ) + expect(logger.info).toHaveBeenCalledWith( + 'match.computed', + expect.objectContaining({ marketId: OTHER_MARKET_ID }) + ) + expect(logger.info).not.toHaveBeenCalledWith('tick.no_match', expect.anything()) + expect(result).toEqual({ submitted: false, markets: 2 }) + }) + test('isolates a book failure and continues to the next market', async () => { let calls = 0 const { service, books, submit } = setup({ markets: [MARKET, OTHER_MARKET] }) diff --git a/bots/midnight-crossed-books/test/config/config.service.test.ts b/bots/midnight-crossed-books/test/config/config.service.test.ts index d162b482..a0107dc6 100644 --- a/bots/midnight-crossed-books/test/config/config.service.test.ts +++ b/bots/midnight-crossed-books/test/config/config.service.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'vitest' import { ConfigService } from '../../src/config/config.service' +import { InvalidConfigurationError } from '../../src/config/invalid-configuration.error' +import { InvalidSimulationCallerAddressError } from '../../src/config/invalid-simulation-caller-address.error' +import { ResolverPrivateKeyRequiredError } from '../../src/config/resolver-private-key-required.error' const KEY = `0x${'11'.repeat(32)}` const REQUIRED = { @@ -20,6 +23,89 @@ describe('ConfigService', () => { expect(config.minimumProfit).toBe(1n) expect(config.maxMatches).toBe(10) expect(config.resolver).toMatch(/^0x[0-9a-fA-F]{40}$/) + expect(config.readOnly).toBe(false) + expect(config.privateKey).toBe(KEY) + }) + + test('does not require a resolver private key in readonly mode', () => { + const config = ConfigService.from({ + CHAIN_ID: '8453', + RPC_URL: 'http://rpc.example', + READONLY: 'true', + SIMULATION_CALLER_ADDRESS: `0x${'33'.repeat(20)}`, + RESOLVER_PRIVATE_KEY: 'ignored-in-readonly-mode' + }) + + expect(config.readOnly).toBe(true) + expect(config.privateKey).toBeUndefined() + expect(config.simulationCaller).toBe(`0x${'33'.repeat(20)}`) + }) + + test.each(['true', 'TRUE', '1'])('accepts READONLY=%s as readonly mode', value => { + const config = ConfigService.from({ + CHAIN_ID: '8453', + RPC_URL: 'http://rpc.example', + READONLY: value, + SIMULATION_CALLER_ADDRESS: `0x${'33'.repeat(20)}` + }) + + expect(config.readOnly).toBe(true) + }) + + test.each([undefined, '', 'false', 'FALSE', '0'])('accepts READONLY=%s as write mode', value => { + expect(ConfigService.from({ ...REQUIRED, READONLY: value }).readOnly).toBe(false) + }) + + test.each([ + 'not-an-address', + '0x0000000000000000000000000000000000000000', + `0x${'33'.repeat(20)}` + ])('ignores stale SIMULATION_CALLER_ADDRESS=%s in write mode', simulationCaller => { + const config = ConfigService.from({ + ...REQUIRED, + SIMULATION_CALLER_ADDRESS: simulationCaller + }) + + expect(config.readOnly).toBe(false) + expect(config.privateKey).toBe(KEY) + expect(config.simulationCaller).toBeUndefined() + }) + + test.each(['yes', '2', 'truthy'])('rejects malformed READONLY=%s fail-closed', value => { + expect(() => ConfigService.from({ ...REQUIRED, READONLY: value })).toThrow( + InvalidConfigurationError + ) + }) + + test('requires a validated keyless simulation caller in readonly mode', () => { + expect(() => + ConfigService.from({ CHAIN_ID: '8453', RPC_URL: 'http://rpc.example', READONLY: 'true' }) + ).toThrow(InvalidConfigurationError) + expect(() => + ConfigService.from({ + CHAIN_ID: '8453', + RPC_URL: 'http://rpc.example', + READONLY: 'true', + SIMULATION_CALLER_ADDRESS: 'not-an-address' + }) + ).toThrow(InvalidConfigurationError) + }) + + test('rejects the zero simulation caller address with a named error', () => { + expect(() => + ConfigService.from({ + CHAIN_ID: '8453', + RPC_URL: 'http://rpc.example', + READONLY: 'true', + SIMULATION_CALLER_ADDRESS: '0x0000000000000000000000000000000000000000' + }) + ).toThrow(InvalidSimulationCallerAddressError) + }) + + test('requires a resolver private key in normal mode with a named error', () => { + expect(() => ConfigService.from({ CHAIN_ID: '8453', RPC_URL: 'http://rpc.example' })).toThrow( + ResolverPrivateKeyRequiredError + ) }) test('normalizes a trailing slash from the API URL', () => { @@ -51,6 +137,7 @@ describe('ConfigService', () => { test.each([ [{ ...REQUIRED, CHAIN_ID: '1' }, 'Unsupported CHAIN_ID'], [{ ...REQUIRED, CHAIN_ID: '0x2105' }, 'CHAIN_ID'], + [{ ...REQUIRED, READONLY: 'treu' }, 'READONLY'], [{ ...REQUIRED, RESOLVER_PRIVATE_KEY: '0x12' }, 'RESOLVER_PRIVATE_KEY'], [{ ...REQUIRED, RESOLVER_ADDRESS: 'not-an-address' }, 'RESOLVER_ADDRESS'], [{ ...REQUIRED, API_BASE_URL: 'not-a-url' }, 'API_BASE_URL'], diff --git a/bots/midnight-crossed-books/test/infrastructure/resolver/resolver.transport.test.ts b/bots/midnight-crossed-books/test/infrastructure/resolver/resolver.transport.test.ts new file mode 100644 index 00000000..9b5a9d91 --- /dev/null +++ b/bots/midnight-crossed-books/test/infrastructure/resolver/resolver.transport.test.ts @@ -0,0 +1,52 @@ +import { createPublicClient, custom } from 'viem' +import { base } from 'viem/chains' +import { describe, expect, test, vi } from 'vitest' + +import { ReadonlyMutationError } from '../../../src/infrastructure/resolver/readonly-mutation.error' +import { ViemResolverTransport } from '../../../src/infrastructure/resolver/resolver.transport' +import { MARKET_ID } from '../../fixtures/offers' + +const ADDRESS = `0x${'11'.repeat(20)}` as const +const CALLER = `0x${'22'.repeat(20)}` as const + +const client = createPublicClient({ + chain: base, + transport: custom({ + request: async () => { + throw new Error('unexpected RPC request') + } + }) +}) + +describe('ViemResolverTransport', () => { + test('simulates the exact calldata from the configured execution-equivalent caller', async () => { + const request = vi.fn(async () => '0xabcd' as const) + const simulationClient = createPublicClient({ chain: base, transport: custom({ request }) }) + const transport = new ViemResolverTransport(simulationClient, CALLER, ADDRESS) + + await expect(transport.simulate('0x1234')).resolves.toEqual({ + status: 'success', + data: '0xabcd' + }) + expect(request).toHaveBeenCalledWith({ + method: 'eth_call', + params: [ + { + data: '0x1234', + from: CALLER, + to: ADDRESS, + value: '0x0' + }, + 'latest' + ] + }) + }) + + test('fails closed when readonly composition attempts to submit', async () => { + const transport = new ViemResolverTransport(client, ADDRESS, ADDRESS) + + await expect( + transport.submit({ marketId: MARKET_ID, data: '0x1234', profit: 42n }, 10n) + ).rejects.toBeInstanceOf(ReadonlyMutationError) + }) +}) diff --git a/bots/midnight-crossed-books/test/scripts/railway.test.ts b/bots/midnight-crossed-books/test/scripts/railway.test.ts index 5cc52429..b8a9b5e8 100644 --- a/bots/midnight-crossed-books/test/scripts/railway.test.ts +++ b/bots/midnight-crossed-books/test/scripts/railway.test.ts @@ -1,6 +1,333 @@ -import { describe, expect, test } from 'vitest' +import { readFileSync } from 'node:fs' +import { describe, expect, test, vi } from 'vitest' -import { parseLatestStatus, parseServices } from '../../scripts/railway' +import { + deleteRailwayVariable, + isRailwayVariableMissingError, + parseLatestStatus, + parseServices, + railwayVariableDeleteArgs, + railwayVariableSetArgs, + resolveRailwayAccessToken, + resolveProvisioningConfiguration, + synchronizeModeVariables +} from '../../scripts/railway' +import { InvalidConfigurationError } from '../../src/config/invalid-configuration.error' +import { InvalidSimulationCallerAddressError } from '../../src/config/invalid-simulation-caller-address.error' +import { ResolverPrivateKeyRequiredError } from '../../src/config/resolver-private-key-required.error' + +const KEY = `0x${'11'.repeat(32)}` +const CALLER: `0x${string}` = `0x${'22'.repeat(20)}` +const TARGET = { environment: 'production', projectId: 'project-id', service: 'bot' } +const TOKEN = { header: 'project-access-token' as const, value: 'railway-token' } + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, status }) + +const targetResponse = { + data: { + project: { + environments: { edges: [{ node: { id: 'environment-id', name: 'production' } }] }, + services: { edges: [{ node: { id: 'service-id', name: 'bot' } }] } + } + } +} + +const operations = () => ({ + deleteVariable: vi.fn().mockResolvedValue(undefined), + setSecret: vi.fn().mockResolvedValue(undefined), + setVariable: vi.fn().mockResolvedValue(undefined) +}) + +describe('Railway provisioning configuration', () => { + test('supports keyless readonly provisioning with an execution-equivalent caller', () => { + expect( + resolveProvisioningConfiguration({ + READONLY: 'TRUE', + SIMULATION_CALLER_ADDRESS: CALLER, + RESOLVER_PRIVATE_KEY: 'ignored-in-readonly-mode' + }) + ).toEqual({ + readOnly: true, + resolverPrivateKey: undefined, + simulationCaller: CALLER + }) + }) + + test('preserves write-mode key requirements and ignores the readonly caller', () => { + expect( + resolveProvisioningConfiguration({ + RESOLVER_PRIVATE_KEY: KEY, + SIMULATION_CALLER_ADDRESS: 'invalid-and-ignored-in-write-mode' + }) + ).toEqual({ + readOnly: false, + resolverPrivateKey: KEY, + simulationCaller: undefined + }) + expect(() => resolveProvisioningConfiguration({})).toThrow(ResolverPrivateKeyRequiredError) + }) + + test.each([ + [{ READONLY: 'yes', RESOLVER_PRIVATE_KEY: KEY }, 'READONLY'], + [{ READONLY: 'true' }, 'SIMULATION_CALLER_ADDRESS'], + [{ READONLY: 'true', SIMULATION_CALLER_ADDRESS: 'invalid' }, 'SIMULATION_CALLER_ADDRESS'], + [{ RESOLVER_PRIVATE_KEY: '0x12' }, 'RESOLVER_PRIVATE_KEY'] + ])('rejects invalid provisioning configuration %#', (environment, message) => { + expect(() => resolveProvisioningConfiguration(environment)).toThrow(InvalidConfigurationError) + expect(() => resolveProvisioningConfiguration(environment)).toThrow(message) + }) + + test('rejects the zero simulation caller address with a named error', () => { + expect(() => + resolveProvisioningConfiguration({ + READONLY: 'true', + SIMULATION_CALLER_ADDRESS: '0x0000000000000000000000000000000000000000' + }) + ).toThrow(InvalidSimulationCallerAddressError) + }) + + test('switches to readonly mode before deleting the stale private key', async () => { + const events: string[] = [] + const railway = { + deleteVariable: vi.fn(async name => { + events.push(`delete:${name}`) + }), + setSecret: vi.fn(async (name, _value) => { + events.push(`secret:${name}`) + }), + setVariable: vi.fn(async value => { + events.push(`set:${value}`) + }) + } + + await synchronizeModeVariables( + { readOnly: true, resolverPrivateKey: undefined, simulationCaller: CALLER }, + railway + ) + + expect(events).toEqual([ + `set:SIMULATION_CALLER_ADDRESS=${CALLER}`, + 'set:READONLY=true', + 'delete:RESOLVER_PRIVATE_KEY' + ]) + expect(railway.setSecret).not.toHaveBeenCalled() + }) + + test('switches to write mode before deleting the stale caller', async () => { + const events: string[] = [] + const railway = { + deleteVariable: vi.fn(async name => { + events.push(`delete:${name}`) + }), + setSecret: vi.fn(async (name, _value) => { + events.push(`secret:${name}`) + }), + setVariable: vi.fn(async value => { + events.push(`set:${value}`) + }) + } + + await synchronizeModeVariables( + { readOnly: false, resolverPrivateKey: KEY, simulationCaller: undefined }, + railway + ) + + expect(events).toEqual([ + 'secret:RESOLVER_PRIVATE_KEY', + 'set:READONLY=false', + 'delete:SIMULATION_CALLER_ADDRESS' + ]) + }) + + test('does not delete the private key when switching to readonly mode fails', async () => { + const railway = operations() + railway.setVariable.mockRejectedValueOnce(new Error('set failed')) + const config = resolveProvisioningConfiguration({ + READONLY: 'true', + SIMULATION_CALLER_ADDRESS: CALLER + }) + + await expect(synchronizeModeVariables(config, railway)).rejects.toThrow('set failed') + expect(railway.deleteVariable).not.toHaveBeenCalled() + }) + + test('does not delete the caller when switching to write mode fails', async () => { + const railway = operations() + railway.setVariable.mockRejectedValueOnce(new Error('set failed')) + const config = resolveProvisioningConfiguration({ RESOLVER_PRIVATE_KEY: KEY }) + + await expect(synchronizeModeVariables(config, railway)).rejects.toThrow('set failed') + expect(railway.deleteVariable).not.toHaveBeenCalled() + }) + + test('wires fail-closed mode synchronization into Railway provisioning', () => { + const deploy = readFileSync(new URL('../../scripts/deploy-railway.ts', import.meta.url), 'utf8') + + expect(deploy).toMatch(/resolveProvisioningConfiguration\(\s*process\.env\s*\)/) + expect(deploy).toContain('await synchronizeModeVariables(') + expect(deploy).toContain('deleteVariable') + expect(deploy).not.toContain('variable list') + expect(deploy).not.toContain('railwayVariableListArgs') + expect(deploy).toContain("throw new RailwayVariableOperationError('set', key)") + expect(deploy).toContain("throw new RailwayVariableOperationError('set', name)") + expect(deploy).not.toMatch(/Failed to set.*errorDetails/) + }) + + test('preserves fresh-service and CLI-login provisioning without an API token', () => { + const deploy = readFileSync(new URL('../../scripts/deploy-railway.ts', import.meta.url), 'utf8') + + expect(deploy).toContain('const serviceCreated = await ensureService()') + expect(deploy).toContain( + 'deleteVariable: serviceCreated ? skipVariableDeletion : deleteVariable' + ) + expect(deploy).toContain("$('railway', railwayVariableDeleteArgs(name, VARIABLE_TARGET))") + }) + + test('includes explicit project context when setting variables', () => { + expect(railwayVariableSetArgs('READONLY=true', TARGET)).toEqual([ + 'variable', + 'set', + 'READONLY=true', + '-s', + 'bot', + '-e', + 'production', + '-p', + 'project-id', + '--skip-deploys' + ]) + expect(railwayVariableSetArgs('RPC_URL', TARGET, { stdin: true })).toEqual([ + 'variable', + 'set', + 'RPC_URL', + '--stdin', + '-s', + 'bot', + '-e', + 'production', + '-p', + 'project-id', + '--skip-deploys' + ]) + }) + + test('includes explicit project context in CLI deletion', () => { + expect(railwayVariableDeleteArgs('RESOLVER_PRIVATE_KEY', TARGET)).toEqual([ + 'variable', + 'delete', + 'RESOLVER_PRIVATE_KEY', + '-s', + 'bot', + '-e', + 'production', + '-p', + 'project-id' + ]) + }) + + test('recognizes only the Railway CLI missing-variable failure as idempotent', () => { + expect( + isRailwayVariableMissingError( + 'RESOLVER_PRIVATE_KEY', + "Error: Variable 'RESOLVER_PRIVATE_KEY' not found" + ) + ).toBe(true) + expect( + isRailwayVariableMissingError('RESOLVER_PRIVATE_KEY', "Variable 'OTHER_KEY' not found") + ).toBe(false) + expect(isRailwayVariableMissingError('RESOLVER_PRIVATE_KEY', 'Unauthorized')).toBe(false) + }) +}) + +describe('Railway variable deletion API', () => { + test('resolves supported API authentication without logging or CLI arguments', () => { + expect(resolveRailwayAccessToken({ RAILWAY_TOKEN: 'project-token' })).toEqual({ + header: 'project-access-token', + value: 'project-token' + }) + expect(resolveRailwayAccessToken({ RAILWAY_API_TOKEN: 'account-token' })).toEqual({ + header: 'authorization', + value: 'Bearer account-token' + }) + expect(() => resolveRailwayAccessToken({})).toThrow('RAILWAY_TOKEN or RAILWAY_API_TOKEN') + }) + + test.each(['RESOLVER_PRIVATE_KEY', 'SIMULATION_CALLER_ADDRESS'])( + 'deletes %s without reading Railway variable values', + async name => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: true } })) + + await expect( + deleteRailwayVariable({ fetcher, name, target: TARGET, token: TOKEN }) + ).resolves.toBe(true) + + expect(fetcher).toHaveBeenCalledTimes(2) + const requests = fetcher.mock.calls.map(([, init]) => + JSON.parse(typeof init?.body === 'string' ? init.body : '') + ) + expect(requests.every(request => !request.query.includes('variables('))).toBe(true) + expect(requests[1]).toMatchObject({ + variables: { + environmentId: 'environment-id', + name, + projectId: 'project-id', + serviceId: 'service-id' + } + }) + } + ) + + test('treats a false direct-delete result as an idempotent no-op', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(jsonResponse({ data: { variableDelete: false } })) + + await expect( + deleteRailwayVariable({ + fetcher, + name: 'RESOLVER_PRIVATE_KEY', + target: TARGET, + token: TOKEN + }) + ).resolves.toBe(false) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + test.each([ + jsonResponse({ errors: [{ message: 'secret-bearing upstream failure' }] }), + jsonResponse({ data: { variableDelete: true } }, 503) + ])('fails closed with a named sanitized deletion error', async failure => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(jsonResponse(targetResponse)) + .mockResolvedValueOnce(failure) + + const result = deleteRailwayVariable({ + fetcher, + name: 'RESOLVER_PRIVATE_KEY', + target: TARGET, + token: TOKEN + }) + await expect(result).rejects.toMatchObject({ name: 'RailwayVariableOperationError' }) + await expect(result).rejects.toThrow('Failed to delete Railway variable RESOLVER_PRIVATE_KEY') + await expect(result).rejects.not.toThrow('secret-bearing upstream failure') + }) + + test('uses no Railway variable query or list command that could read raw values', () => { + const source = readFileSync(new URL('../../scripts/railway.ts', import.meta.url), 'utf8') + + expect(source).not.toContain("'variable',\n 'list'") + expect(source).not.toContain('railway variable list') + expect(source).not.toContain('query Variables(') + expect(source).not.toContain('variables(') + expect(source).not.toContain('variablesForServiceDeployment') + }) +}) describe('Railway CLI output parsing', () => { test('parses service arrays and ignores nameless entries', () => {