From d78d0e70027952beeebbc2958e0b44a9994fb50d Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 3 Sep 2026 04:58:05 -0500 Subject: [PATCH 1/3] fix(quoter-bot): require unbounded approval, warn on orphaned groups Readiness compared two decreasing quantities against constants, so ordinary operation eventually halted the whole fail-together lifecycle. The loan-allowance gate required `allowance >= MAXIMUM_LEND_EXPOSURE_ASSETS`. An ERC-20 allowance is a cumulative lifetime spend budget, not an outstanding exposure cap, so relending consumes it and the bot halts well above zero while still solvent. Readiness now requires an unbounded approval, and exposure is bounded by maker funds and the ladder's existing per-market and total caps. `MAXIMUM_LEND_EXPOSURE_ASSETS` has no remaining consumer and is removed. The offers gate failed on any group it could not attribute to itself. Group ownership is a local durable record, so redeploying onto a fresh filesystem orphans the bot's own live groups; readiness then cannot recover until every orphan expires. Unattributable groups now warn without blocking readiness, while offers on unconfigured markets and crossed books still fail. `setup.check-failed` now carries `status`, so the record naming the halt cause ships at error level instead of info alongside a causeless `bot.failed`. Co-Authored-By: Claude Opus 5 (1M context) --- bots/quoter-bot/.env.example | 1 - bots/quoter-bot/README.md | 19 ++++- bots/quoter-bot/docker-compose.yml | 1 - bots/quoter-bot/docs/reference.md | 19 ++++- bots/quoter-bot/helm/quoter-bot/Chart.yaml | 2 +- bots/quoter-bot/helm/quoter-bot/README.md | 1 - bots/quoter-bot/helm/quoter-bot/values.yaml | 1 - bots/quoter-bot/quoter-bot.example.yaml | 1 - bots/quoter-bot/scripts/deploy-railway.ts | 1 - .../monitoring/monitoring-event.ts | 2 +- .../monitoring/setup-monitoring.utils.ts | 21 ++++-- .../application/setup/setup-check.service.ts | 40 +++++----- .../application/setup/setup-check.utils.ts | 20 +++++ .../src/config/config-source.utils.ts | 11 +-- bots/quoter-bot/src/config/config.service.ts | 1 - .../monitoring-projection.utils.test.ts | 6 +- .../terminal-monitoring.utils.test.ts | 6 +- .../setup/setup-check.service.test.ts | 73 +++++++++++++++++-- bots/quoter-bot/test/bootstrap.test.ts | 7 +- .../test/config/config-loading.test.ts | 2 - .../test/config/config-private-key.test.ts | 1 - .../test/config/config.service.test.ts | 2 - .../test/config/key-storage.test.ts | 5 +- .../test/config/ladder-config.test.ts | 1 - bots/quoter-bot/test/e2e/constants.ts | 1 - .../test/e2e/quoter-bot.fork.e2e.test.ts | 2 - bots/quoter-bot/test/e2e/setup-api.ts | 4 +- .../test/e2e/setup-check.e2e.test.ts | 2 - .../bootstrap/production-bootstrap.test.ts | 2 - .../test/infrastructure/cli/cli.test.ts | 1 - .../production-offer-invalidation.test.ts | 1 - .../ladder/production-ladder.test.ts | 1 - 32 files changed, 177 insertions(+), 81 deletions(-) diff --git a/bots/quoter-bot/.env.example b/bots/quoter-bot/.env.example index daf7917e..77f2fe8d 100644 --- a/bots/quoter-bot/.env.example +++ b/bots/quoter-bot/.env.example @@ -28,7 +28,6 @@ MARKET_IDS=0x5555555555555555555555555555555555555555555555555555555555555555 # Required by commands whose active targetRate strategy is variable_rate_avg. REFERENCE_MARKET_ID=0x7777777777777777777777777777777777777777777777777777777777777777 NATIVE_RESERVE_WEI=10000000000000000 -MAXIMUM_LEND_EXPOSURE_ASSETS=10000000000 MORPHO_API_BASE_URL=https://api.example V0_OFFER_GROUP_IDS=0x8888888888888888888888888888888888888888888888888888888888888888 REQUEST_TIMEOUT_MS=10000 diff --git a/bots/quoter-bot/README.md b/bots/quoter-bot/README.md index 900a863e..2d4e3bbe 100644 --- a/bots/quoter-bot/README.md +++ b/bots/quoter-bot/README.md @@ -492,7 +492,6 @@ unit; for six-decimal USDC, `101000000` is 101 USDC. No value is inferred from a | `REFERENCE_MARKET_ID` | `markets.referenceMarketId` | Required when the selected command has an active `variable_rate_avg` target. Must be a 0x-prefixed bytes32 Morpho Blue market ID. | | `V0_OFFER_GROUP_IDS` | `markets.v0OfferGroupIds` | Optional comma-separated list of unique, explicitly strategy-owned bytes32 offer-group IDs; defaults to empty. Use it to adopt known pre-existing groups safely. | | `NATIVE_RESERVE_WEI` | `setup.nativeReserveWei` | Required unsigned integer. Minimum maker native-token balance, in wei, required by readiness for transaction fees. | -| `MAXIMUM_LEND_EXPOSURE_ASSETS` | `setup.maximumLendExposureAssets` | Required unsigned integer in raw loan-token units. Minimum maker allowance to Midnight required by readiness; it is not a strategy position cap. | | `REQUEST_TIMEOUT_MS` | `setup.requestTimeoutMs` | Optional provider-operation and aggregate pagination timeout in milliseconds. Defaults to `10000`; accepted range is `1` through `120000`. | | `TRANSACTION_RECEIPT_TIMEOUT_MS` | `setup.transactionReceiptTimeoutMs` | Optional timeout for confirming an already-submitted transaction, in milliseconds. Defaults to `180000`; accepted range is `1` through `900000`. | | `BOOTSTRAP_MARKETS` | `bootstrap` | Optional exact JSON array of position-bootstrap entries documented below; defaults to `[]` and replaces the complete YAML `bootstrap` list when supplied. | @@ -643,11 +642,16 @@ and `ladder`; unknown keys at any level are rejected. Every supported key appear - `contracts`: `midnightAddress`, `loanAssetAddress`, `ratifierAddress`. - `apis`: `morphoBaseUrl`, `routerBaseUrl`. - `markets`: `allowlist`, `referenceMarketId`, `v0OfferGroupIds`. -- `setup`: `nativeReserveWei`, `maximumLendExposureAssets`, `requestTimeoutMs`, - `transactionReceiptTimeoutMs`. +- `setup`: `nativeReserveWei`, `requestTimeoutMs`, `transactionReceiptTimeoutMs`. - `bootstrap`: an ordered list of the exact per-market objects documented below. - `ladder`: an ordered list of the exact per-market objects documented below. +`setup.maximumLendExposureAssets` was removed. Delete it from existing configuration files: +unknown keys are rejected and the sanitized error does not name the offending key, so a stale entry +crash-loops the bot at startup with no indication of which line is at fault. A stale +`MAXIMUM_LEND_EXPOSURE_ASSETS` environment variable is inert and needs no action — the loader reads +an allowlist and ignores anything outside it. + Addresses and bytes32 IDs should be quoted YAML strings. Every integer field uses exact decimal-integer syntax: unsigned fields accept digits only, while `premiumBps`, `quotePremiumBps`, and `sizeSkewBps` additionally accept one leading minus. Exact raw-unit amounts may be quoted decimal @@ -665,6 +669,15 @@ Setup verifies all of the following from the typed configuration: - Configured chain identity and configured Midnight bytecode. - Native reserve, loan-token allowance, and ratifier readiness for the configured maker address. + The allowance check requires an unbounded (`type(uint256).max`) approval to Midnight: an ERC-20 + allowance is a cumulative lifetime spend budget, not an outstanding-exposure cap, so a finite + approval is consumed by ordinary relending and fails readiness. Exposure is bounded by maker + wallet funds and the strategy's `targetMarketExposureAssets` / `maximumTotalExposureAssets`. +- Active maker offers: an offer on an unconfigured market, or a crossed/inverted book, fails + readiness. A live offer group the bot cannot attribute to itself is reported as `warning` and + does not block readiness — group ownership is a local durable record, so redeploying onto a fresh + filesystem orphans the bot's own groups, and halting on that cannot recover until every orphan + expires. Exposure is derived from live on-chain groups either way. - Maker/private-key agreement in write mode; only this signer-identity check is `not-required` with `--readonly`. Maker identity is reduced to configured/derived/matches status and the address is never included in operator output. diff --git a/bots/quoter-bot/docker-compose.yml b/bots/quoter-bot/docker-compose.yml index 46d326ae..9c43637f 100644 --- a/bots/quoter-bot/docker-compose.yml +++ b/bots/quoter-bot/docker-compose.yml @@ -18,7 +18,6 @@ services: REFERENCE_MARKET_ID: ${REFERENCE_MARKET_ID:-} V0_OFFER_GROUP_IDS: ${V0_OFFER_GROUP_IDS:-} NATIVE_RESERVE_WEI: ${NATIVE_RESERVE_WEI:?set NATIVE_RESERVE_WEI} - MAXIMUM_LEND_EXPOSURE_ASSETS: ${MAXIMUM_LEND_EXPOSURE_ASSETS:?set MAXIMUM_LEND_EXPOSURE_ASSETS} REQUEST_TIMEOUT_MS: ${REQUEST_TIMEOUT_MS:-10000} TRANSACTION_RECEIPT_TIMEOUT_MS: ${TRANSACTION_RECEIPT_TIMEOUT_MS:-180000} BOOTSTRAP_MARKETS: ${BOOTSTRAP_MARKETS:?set BOOTSTRAP_MARKETS} diff --git a/bots/quoter-bot/docs/reference.md b/bots/quoter-bot/docs/reference.md index 05f0c561..0b365476 100644 --- a/bots/quoter-bot/docs/reference.md +++ b/bots/quoter-bot/docs/reference.md @@ -346,7 +346,6 @@ unit; for six-decimal USDC, `101000000` is 101 USDC. No value is inferred from a | `REFERENCE_MARKET_ID` | `markets.referenceMarketId` | Required when the selected command has an active `variable_rate_avg` target. Must be a 0x-prefixed bytes32 Morpho Blue market ID. | | `V0_OFFER_GROUP_IDS` | `markets.v0OfferGroupIds` | Optional comma-separated list of unique, explicitly strategy-owned bytes32 offer-group IDs; defaults to empty. Use it to adopt known pre-existing groups safely. | | `NATIVE_RESERVE_WEI` | `setup.nativeReserveWei` | Required unsigned integer. Minimum maker native-token balance, in wei, required by readiness for transaction fees. | -| `MAXIMUM_LEND_EXPOSURE_ASSETS` | `setup.maximumLendExposureAssets` | Required unsigned integer in raw loan-token units. Minimum maker allowance to Midnight required by readiness; it is not a strategy position cap. | | `REQUEST_TIMEOUT_MS` | `setup.requestTimeoutMs` | Optional provider-operation and aggregate pagination timeout in milliseconds. Defaults to `10000`; accepted range is `1` through `120000`. | | `TRANSACTION_RECEIPT_TIMEOUT_MS` | `setup.transactionReceiptTimeoutMs` | Optional timeout for confirming an already-submitted transaction, in milliseconds. Defaults to `180000`; accepted range is `1` through `900000`. | | `BOOTSTRAP_MARKETS` | `bootstrap` | Optional exact JSON array of position-bootstrap entries documented below; defaults to `[]` and replaces the complete YAML `bootstrap` list when supplied. | @@ -590,11 +589,16 @@ and `ladder`; unknown keys at any level are rejected. Every supported key appear - `contracts`: `midnightAddress`, `loanAssetAddress`, `ratifierAddress`. - `apis`: `morphoBaseUrl`, `routerBaseUrl`. - `markets`: `allowlist`, `referenceMarketId`, `v0OfferGroupIds`. -- `setup`: `nativeReserveWei`, `maximumLendExposureAssets`, `requestTimeoutMs`, - `transactionReceiptTimeoutMs`. +- `setup`: `nativeReserveWei`, `requestTimeoutMs`, `transactionReceiptTimeoutMs`. - `bootstrap`: an ordered list of the exact per-market objects documented below. - `ladder`: an ordered list of the exact per-market objects documented below. +`setup.maximumLendExposureAssets` was removed. Delete it from existing configuration files: +unknown keys are rejected and the sanitized error does not name the offending key, so a stale entry +crash-loops the bot at startup with no indication of which line is at fault. A stale +`MAXIMUM_LEND_EXPOSURE_ASSETS` environment variable is inert and needs no action — the loader reads +an allowlist and ignores anything outside it. + Addresses and bytes32 IDs should be quoted YAML strings. Every integer field uses exact decimal-integer syntax: unsigned fields accept digits only, while `premiumBps`, `quotePremiumBps`, and `sizeSkewBps` additionally accept one leading minus. Exact raw-unit amounts may be quoted decimal @@ -612,6 +616,15 @@ Setup verifies all of the following from the typed configuration: - Configured chain identity and configured Midnight bytecode. - Native reserve, loan-token allowance, and ratifier readiness for the configured maker address. + The allowance check requires an unbounded (`type(uint256).max`) approval to Midnight: an ERC-20 + allowance is a cumulative lifetime spend budget, not an outstanding-exposure cap, so a finite + approval is consumed by ordinary relending and fails readiness. Exposure is bounded by maker + wallet funds and the strategy's `targetMarketExposureAssets` / `maximumTotalExposureAssets`. +- Active maker offers: an offer on an unconfigured market, or a crossed/inverted book, fails + readiness. A live offer group the bot cannot attribute to itself is reported as `warning` and + does not block readiness — group ownership is a local durable record, so redeploying onto a fresh + filesystem orphans the bot's own groups, and halting on that cannot recover until every orphan + expires. Exposure is derived from live on-chain groups either way. - Maker/private-key agreement in write mode; only this signer-identity check is `not-required` with `--readonly`. Maker identity is reduced to configured/derived/matches status and the address is never included in operator output. diff --git a/bots/quoter-bot/helm/quoter-bot/Chart.yaml b/bots/quoter-bot/helm/quoter-bot/Chart.yaml index dc900137..6b40939e 100644 --- a/bots/quoter-bot/helm/quoter-bot/Chart.yaml +++ b/bots/quoter-bot/helm/quoter-bot/Chart.yaml @@ -7,7 +7,7 @@ description: >- Morpho Midnight maker bot: validates Base and Midnight setup, bootstraps target lending positions, and maintains two-sided rate ladders from one long-running container. type: application -version: 0.1.0 +version: 0.2.0 # Default image tag. The Docker Hub repository morphoorg/quoter publishes `latest` plus one # immutable tag per release commit hash; pin a commit tag through `image.tag` for reproducible # deployments. diff --git a/bots/quoter-bot/helm/quoter-bot/README.md b/bots/quoter-bot/helm/quoter-bot/README.md index b3ce5755..f174cc35 100644 --- a/bots/quoter-bot/helm/quoter-bot/README.md +++ b/bots/quoter-bot/helm/quoter-bot/README.md @@ -58,7 +58,6 @@ config: referenceMarketId: '0x7777777777777777777777777777777777777777777777777777777777777777' setup: nativeReserveWei: '10000000000000000' - maximumLendExposureAssets: '10000000000' bootstrap: - marketId: '0x5555555555555555555555555555555555555555555555555555555555555555' targetRate: diff --git a/bots/quoter-bot/helm/quoter-bot/values.yaml b/bots/quoter-bot/helm/quoter-bot/values.yaml index 8f60b9f4..ecba389a 100644 --- a/bots/quoter-bot/helm/quoter-bot/values.yaml +++ b/bots/quoter-bot/helm/quoter-bot/values.yaml @@ -94,7 +94,6 @@ config: {} # referenceMarketId: '0x7777777777777777777777777777777777777777777777777777777777777777' # setup: # nativeReserveWei: '10000000000000000' -# maximumLendExposureAssets: '10000000000' # bootstrap: [] # ladder: # - marketId: '0x5555555555555555555555555555555555555555555555555555555555555555' diff --git a/bots/quoter-bot/quoter-bot.example.yaml b/bots/quoter-bot/quoter-bot.example.yaml index cefca4e4..7b4ef443 100644 --- a/bots/quoter-bot/quoter-bot.example.yaml +++ b/bots/quoter-bot/quoter-bot.example.yaml @@ -43,7 +43,6 @@ markets: setup: nativeReserveWei: '10000000000000000' - maximumLendExposureAssets: '10000000000' requestTimeoutMs: 10000 transactionReceiptTimeoutMs: 180000 diff --git a/bots/quoter-bot/scripts/deploy-railway.ts b/bots/quoter-bot/scripts/deploy-railway.ts index 513acc04..43fb7660 100644 --- a/bots/quoter-bot/scripts/deploy-railway.ts +++ b/bots/quoter-bot/scripts/deploy-railway.ts @@ -45,7 +45,6 @@ const requiredRuntimeVariableNames = [ 'MORPHO_API_BASE_URL', 'MARKET_IDS', 'NATIVE_RESERVE_WEI', - 'MAXIMUM_LEND_EXPOSURE_ASSETS', 'BOOTSTRAP_MARKETS', 'LADDER_MARKETS' ] as const diff --git a/bots/quoter-bot/src/application/monitoring/monitoring-event.ts b/bots/quoter-bot/src/application/monitoring/monitoring-event.ts index a15837f6..dbc7a65c 100644 --- a/bots/quoter-bot/src/application/monitoring/monitoring-event.ts +++ b/bots/quoter-bot/src/application/monitoring/monitoring-event.ts @@ -156,7 +156,7 @@ export type MonitoringEvent = operation: 'cancel' | 'ratify' | 'publish' txHash: Hex } - | { event: 'setup.check-failed'; check: string } + | { event: 'setup.check-failed'; check: string; status: 'failed' | 'warning' } /** * Event names that may be shipped to the log source. diff --git a/bots/quoter-bot/src/application/monitoring/setup-monitoring.utils.ts b/bots/quoter-bot/src/application/monitoring/setup-monitoring.utils.ts index 2ddb1ed1..a1161cb9 100644 --- a/bots/quoter-bot/src/application/monitoring/setup-monitoring.utils.ts +++ b/bots/quoter-bot/src/application/monitoring/setup-monitoring.utils.ts @@ -4,17 +4,22 @@ import type { MonitoringEvent } from './monitoring-event' /** * Projects one setup readiness report into flat monitoring records. * @param report - Complete readiness report from one setup check. - * @returns One record per failed check, and a terminating cycle record carrying overall readiness. - * @remarks A failed check contributes only its stable `check` name: `observed` and `required` are - * typed `unknown` and routinely hold objects, which would violate the flat-scalar rule the event - * contract relies on for grouping. `SetupCheck` carries no error classification, so no `errorName` - * is emitted. Readiness itself is not a separate record: `cycle.completed` already carries it as + * @returns One record per failed or warning check, and a terminating cycle record carrying overall + * readiness. + * @remarks A graded check contributes only its stable `check` name and `status`: `observed` and + * `required` are typed `unknown` and routinely hold objects, which would violate the flat-scalar + * rule the event contract relies on for grouping. `SetupCheck` carries no error classification, so + * no `errorName` is emitted. The `status` is load-bearing beyond grouping — the shipping logger + * derives its level from it, so a `failed` check is what raises the record naming the halt cause + * above `info`. Readiness itself is not a separate record: `cycle.completed` already carries it as * `status`, and emitting both would restate the same fact every minute. */ export const setupMonitoringEvents = (report: SetupCheckReport): readonly MonitoringEvent[] => [ - ...report.checks - .filter(check => check.status === 'failed') - .map((check): MonitoringEvent => ({ event: 'setup.check-failed', check: check.name })), + ...report.checks.flatMap((check): MonitoringEvent[] => + check.status === 'failed' || check.status === 'warning' + ? [{ event: 'setup.check-failed', check: check.name, status: check.status }] + : [] + ), { event: 'cycle.completed', workflow: 'setup-check', diff --git a/bots/quoter-bot/src/application/setup/setup-check.service.ts b/bots/quoter-bot/src/application/setup/setup-check.service.ts index a01beaed..cd3f1a7b 100644 --- a/bots/quoter-bot/src/application/setup/setup-check.service.ts +++ b/bots/quoter-bot/src/application/setup/setup-check.service.ts @@ -1,6 +1,7 @@ import type { Address, Hex } from 'viem' import { waitForMonitorInterval } from '@repo/monitoring' +import { maxUint256 } from 'viem' import type { SupportedChainId } from '../../config/supported-chains.utils' @@ -15,14 +16,23 @@ import { providerFailure, readOnlyMakerCheck, sameAddress, - setupResult + setupResult, + unsafeOffersStatus } from './setup-check.utils' import { SetupFailedError } from './setup-failed.error' import { SetupMonitorConfigurationError } from './setup-monitor-configuration.error' +/** + * Allowance floor that readiness treats as unbounded. Exposure is bounded by maker funds, not by + * the approval, and FiatToken decrements even a `maxUint256` approval on every `transferFrom`, so + * readiness cannot compare against `maxUint256` itself. + * @see https://github.com/circlefin/stablecoin-evm/blob/master/contracts/v1/FiatTokenV1.sol + */ +const UNBOUNDED_ALLOWANCE_FLOOR = maxUint256 / 2n + const SETUP_CHECK_MONITOR_INTERVAL_MS = 60_000 const SETUP_CHECK_TRANSIENT_ATTEMPTS = 3 -type SetupCheckStatus = 'passed' | 'failed' | 'not-required' +type SetupCheckStatus = 'passed' | 'failed' | 'warning' | 'not-required' /** Read-only operator instruction or exact transaction description; never executed by the checker. */ export type SetupRemediation = @@ -46,7 +56,7 @@ export type SetupCheck = { | 'reference' | 'offers' | 'position-health' - /** Whether the requirement passed, failed, or is intentionally outside the V0 scope. */ + /** Whether the requirement passed, failed, warned without blocking readiness, or is outside V0 scope. */ status: SetupCheckStatus /** Sanitized provider-derived value; URLs, credentials, and raw provider messages are excluded. */ observed: unknown @@ -107,8 +117,6 @@ export type SetupCheckConfig = { nativeReserve: bigint /** ERC-20 asset lent by every configured market. */ loanAsset: Address - /** Minimum allowance granted to Midnight. */ - maximumLendExposure: bigint /** Canonical SDK Ecrecover or Setter ratifier expected to authorize the maker. */ ratifier: Address /** Non-empty set of Midnight market identifiers to validate concurrently. */ @@ -366,20 +374,20 @@ export class SetupCheckService { ) const allowanceRequired = { spender: this.config.midnight, - minimum: this.config.maximumLendExposure + minimum: 'unbounded approval (type(uint256).max)' } const allowanceCheck = !allowance.ok ? providerFailure('loan-allowance', allowance.error, allowanceRequired) : setupResult( 'loan-allowance', sameAddress(allowance.value.spender, this.config.midnight) && - allowance.value.amount >= this.config.maximumLendExposure, + allowance.value.amount >= UNBOUNDED_ALLOWANCE_FLOOR, allowance.value, allowanceRequired, { to: this.config.loanAsset, functionName: 'approve', - args: [this.config.midnight, this.config.maximumLendExposure] + args: [this.config.midnight, maxUint256] } ) const ratifierRequired = { @@ -431,17 +439,15 @@ export class SetupCheckService { reference.value, referenceRequired ) - const offersRequired = { unknownNamespaces: [], unknownMarketIds: [], invertedMarketIds: [] } + const offersRequired = { unknownMarketIds: [], invertedMarketIds: [] } const offersCheck = !offers.ok ? providerFailure('offers', offers.error, offersRequired) - : setupResult( - 'offers', - offers.value.unknownNamespaces.length === 0 && - offers.value.unknownMarketIds.length === 0 && - offers.value.invertedMarketIds.length === 0, - offers.value, - offersRequired - ) + : { + name: 'offers' as const, + status: unsafeOffersStatus(offers.value), + observed: offers.value, + required: offersRequired + } const positionCheck = !positionHealth.ok ? providerFailure('position-health', positionHealth.error, 'not-required for V0') : { diff --git a/bots/quoter-bot/src/application/setup/setup-check.utils.ts b/bots/quoter-bot/src/application/setup/setup-check.utils.ts index c837a4f6..c78eed8f 100644 --- a/bots/quoter-bot/src/application/setup/setup-check.utils.ts +++ b/bots/quoter-bot/src/application/setup/setup-check.utils.ts @@ -1,3 +1,5 @@ +import type { Hex } from 'viem' + import { isAddress, isAddressEqual } from 'viem' import type { SafeProviderFailure } from './safe-provider.error' @@ -230,6 +232,24 @@ export const setupResult = ( ...(passed || !remediation ? {} : { remediation }) }) +/** + * Grades inspected maker offers, separating unsafe books from unattributable groups. + * @param offers - Unknown namespaces, unconfigured markets, and crossed markets from one traversal. + * @returns `failed` for offers outside the bot's exposure model, `warning` for live groups it merely + * cannot attribute to itself, `passed` otherwise. + * @remarks Only markets drive readiness. Group ownership is a local durable record, so a redeploy + * onto a fresh filesystem orphans the bot's own live groups; halting on that cannot self-heal until + * every orphan expires, while the ladder derives exposure from live on-chain groups regardless. + */ +export const unsafeOffersStatus = (offers: { + unknownNamespaces: readonly string[] + unknownMarketIds: readonly Hex[] + invertedMarketIds: readonly Hex[] +}): SetupCheck['status'] => { + if (offers.unknownMarketIds.length > 0 || offers.invertedMarketIds.length > 0) return 'failed' + return offers.unknownNamespaces.length > 0 ? 'warning' : 'passed' +} + /** * Builds a failed check from sanitized provider metadata. * @param name - Stable setup check name. diff --git a/bots/quoter-bot/src/config/config-source.utils.ts b/bots/quoter-bot/src/config/config-source.utils.ts index 2a5b8cf3..650022ff 100644 --- a/bots/quoter-bot/src/config/config-source.utils.ts +++ b/bots/quoter-bot/src/config/config-source.utils.ts @@ -73,7 +73,6 @@ const environmentKeys = [ 'MARKET_IDS', 'REFERENCE_MARKET_ID', 'NATIVE_RESERVE_WEI', - 'MAXIMUM_LEND_EXPOSURE_ASSETS', 'MORPHO_API_BASE_URL', 'ROUTER_API_BASE_URL', 'V0_OFFER_GROUP_IDS', @@ -98,12 +97,7 @@ const yamlKeys = { contracts: ['midnightAddress', 'loanAssetAddress', 'ratifierAddress'], apis: ['morphoBaseUrl', 'routerBaseUrl'], markets: ['allowlist', 'referenceMarketId', 'v0OfferGroupIds'], - setup: [ - 'nativeReserveWei', - 'maximumLendExposureAssets', - 'requestTimeoutMs', - 'transactionReceiptTimeoutMs' - ], + setup: ['nativeReserveWei', 'requestTimeoutMs', 'transactionReceiptTimeoutMs'], bootstrap: [ 'marketId', 'targetRate', @@ -173,7 +167,6 @@ const rejectUnsafeYamlNodes = (document: ReturnType) => { const yamlIntegerEnvironmentKeys = new Set([ 'CHAIN_ID', 'NATIVE_RESERVE_WEI', - 'MAXIMUM_LEND_EXPOSURE_ASSETS', 'REQUEST_TIMEOUT_MS', 'TRANSACTION_RECEIPT_TIMEOUT_MS' ]) @@ -257,7 +250,6 @@ const yamlSource = (input: unknown, readOnly: boolean): ConfigurationSource => { }) mapGroup('setup', { nativeReserveWei: 'NATIVE_RESERVE_WEI', - maximumLendExposureAssets: 'MAXIMUM_LEND_EXPOSURE_ASSETS', requestTimeoutMs: 'REQUEST_TIMEOUT_MS', transactionReceiptTimeoutMs: 'TRANSACTION_RECEIPT_TIMEOUT_MS' }) @@ -371,7 +363,6 @@ const yamlEnvironmentPaths: Partial< MARKET_IDS: ['markets', 'allowlist'], REFERENCE_MARKET_ID: ['markets', 'referenceMarketId'], NATIVE_RESERVE_WEI: ['setup', 'nativeReserveWei'], - MAXIMUM_LEND_EXPOSURE_ASSETS: ['setup', 'maximumLendExposureAssets'], MORPHO_API_BASE_URL: ['apis', 'morphoBaseUrl'], ROUTER_API_BASE_URL: ['apis', 'routerBaseUrl'], V0_OFFER_GROUP_IDS: ['markets', 'v0OfferGroupIds'], diff --git a/bots/quoter-bot/src/config/config.service.ts b/bots/quoter-bot/src/config/config.service.ts index 2c25e173..4e2a3c6f 100644 --- a/bots/quoter-bot/src/config/config.service.ts +++ b/bots/quoter-bot/src/config/config.service.ts @@ -105,7 +105,6 @@ export class ConfigService { midnight: addressValue(environment, 'MIDNIGHT_ADDRESS'), nativeReserve: unsignedBigIntValue(environment, 'NATIVE_RESERVE_WEI'), loanAsset: addressValue(environment, 'LOAN_ASSET_ADDRESS'), - maximumLendExposure: unsignedBigIntValue(environment, 'MAXIMUM_LEND_EXPOSURE_ASSETS'), ratifier: addressValue(environment, 'RATIFIER_ADDRESS'), marketIds, referenceMarketId: optionalBytes32Value(environment, 'REFERENCE_MARKET_ID') diff --git a/bots/quoter-bot/test/application/monitoring/monitoring-projection.utils.test.ts b/bots/quoter-bot/test/application/monitoring/monitoring-projection.utils.test.ts index 00fd2aec..5507cc2b 100644 --- a/bots/quoter-bot/test/application/monitoring/monitoring-projection.utils.test.ts +++ b/bots/quoter-bot/test/application/monitoring/monitoring-projection.utils.test.ts @@ -44,7 +44,11 @@ describe('createMonitoringProjection', () => { test('reports each failed readiness check without leaking its unknown-typed observation', () => { const events = createMonitoringProjection().setup(readyReport) - expect(events).toContainEqual({ event: 'setup.check-failed', check: 'native-balance' }) + expect(events).toContainEqual({ + event: 'setup.check-failed', + check: 'native-balance', + status: 'failed' + }) expect(events).toContainEqual({ event: 'cycle.completed', workflow: 'setup-check', diff --git a/bots/quoter-bot/test/application/monitoring/terminal-monitoring.utils.test.ts b/bots/quoter-bot/test/application/monitoring/terminal-monitoring.utils.test.ts index 286ee365..fab5cb01 100644 --- a/bots/quoter-bot/test/application/monitoring/terminal-monitoring.utils.test.ts +++ b/bots/quoter-bot/test/application/monitoring/terminal-monitoring.utils.test.ts @@ -21,7 +21,11 @@ describe('terminalMonitoringEvents', () => { reason: 'setup-failed', errorName: 'SetupFailedError' }) - expect(events).toContainEqual({ event: 'setup.check-failed', check: 'loan-allowance' }) + expect(events).toContainEqual({ + event: 'setup.check-failed', + check: 'loan-allowance', + status: 'failed' + }) expect(events).not.toContainEqual(expect.objectContaining({ check: 'chain' })) }) diff --git a/bots/quoter-bot/test/application/setup/setup-check.service.test.ts b/bots/quoter-bot/test/application/setup/setup-check.service.test.ts index 02d55d2c..587f65a3 100644 --- a/bots/quoter-bot/test/application/setup/setup-check.service.test.ts +++ b/bots/quoter-bot/test/application/setup/setup-check.service.test.ts @@ -1,5 +1,6 @@ import type { Hex } from 'viem' +import { maxUint256 } from 'viem' import { describe, expect, test } from 'vitest' import { SafeProviderError } from '../../../src/application/setup/safe-provider.error' @@ -30,7 +31,6 @@ const config: SetupCheckConfig = { midnight, nativeReserve: 10n, loanAsset, - maximumLendExposure: 100n, ratifier, marketIds: [marketId], referenceMarketId @@ -43,7 +43,7 @@ const readyState = (): SetupStateService => { getCode: async () => '0x1234', getDerivedMaker: async () => maker, getNativeBalance: async () => 10n, - getLoanAllowance: async () => ({ spender: midnight, amount: 100n }), + getLoanAllowance: async () => ({ spender: midnight, amount: maxUint256 }), getRatifier: async () => ({ listed: true, deployed: true, @@ -591,7 +591,7 @@ describe('SetupCheckService', () => { } state.getLoanAllowance = async () => { reads.push('loan-allowance') - return { spender: midnight, amount: 100n } + return { spender: midnight, amount: maxUint256 } } state.getRatifier = async () => { reads.push('ratifier') @@ -850,7 +850,7 @@ describe('SetupCheckService', () => { state.getCode = () => wait('code', '0x1234') state.getDerivedMaker = () => wait('maker', maker) state.getNativeBalance = () => wait('balance', 10n) - state.getLoanAllowance = () => wait('allowance', { spender: midnight, amount: 100n }) + state.getLoanAllowance = () => wait('allowance', { spender: midnight, amount: maxUint256 }) state.getRatifier = () => wait('ratifier', { listed: true, @@ -995,6 +995,69 @@ describe('SetupCheckService', () => { ) }) + test('keeps an unbounded approval ready after fills have decremented it', async () => { + const state = readyState() + state.getLoanAllowance = async () => ({ + spender: midnight, + amount: maxUint256 - 26_780_747_345n + }) + + const report = await new SetupCheckService(state, config).check() + + expect(report.checks.find(check => check.name === 'loan-allowance')?.status).toBe('passed') + }) + + test('fails any finite approval and remediates with an unbounded one', async () => { + const state = readyState() + state.getLoanAllowance = async () => ({ spender: midnight, amount: 16_000_000_000n }) + + const report = await new SetupCheckService(state, config).check() + + expect(report.checks.find(check => check.name === 'loan-allowance')).toMatchObject({ + status: 'failed', + remediation: { functionName: 'approve', args: [midnight, maxUint256] } + }) + }) + + test('pins the unbounded floor at half of maxUint256', async () => { + const statusFor = async (amount: bigint) => { + const state = readyState() + state.getLoanAllowance = async () => ({ spender: midnight, amount }) + const report = await new SetupCheckService(state, config).check() + return report.checks.find(check => check.name === 'loan-allowance')?.status + } + + expect(await statusFor(maxUint256 / 2n)).toBe('passed') + expect(await statusFor(maxUint256 / 2n - 1n)).toBe('failed') + }) + + test('warns without blocking readiness when a live group cannot be attributed', async () => { + const state = readyState() + state.inspectOffers = async () => ({ + unknownNamespaces: [`0x${'ab'.repeat(32)}`], + unknownMarketIds: [], + invertedMarketIds: [] + }) + + const report = await new SetupCheckService(state, config).check() + + expect(report.checks.find(check => check.name === 'offers')?.status).toBe('warning') + expect(report.ready).toBe(true) + }) + + test.each([ + ['unknownMarketIds', { unknownMarketIds: [secondMarketId], invertedMarketIds: [] }], + ['invertedMarketIds', { unknownMarketIds: [], invertedMarketIds: [marketId] }] + ])('fails readiness for offers outside the exposure model via %s', async (_name, unsafe) => { + const state = readyState() + state.inspectOffers = async () => ({ unknownNamespaces: [], ...unsafe }) + + const report = await new SetupCheckService(state, config).check() + + expect(report.checks.find(check => check.name === 'offers')?.status).toBe('failed') + expect(report.ready).toBe(false) + }) + test('reports every unsafe book property so the operator can remediate it', async () => { const state = readyState() state.getBook = async id => ({ @@ -1041,7 +1104,7 @@ describe('SetupCheckService', () => { expect(report.checks.find(check => check.name === 'loan-allowance')?.remediation).toEqual({ to: loanAsset, functionName: 'approve', - args: [midnight, 100n] + args: [midnight, maxUint256] }) expect(report.checks.find(check => check.name === 'ratifier')?.remediation).toBe( 'authorize the configured maker with the selected ratifier' diff --git a/bots/quoter-bot/test/bootstrap.test.ts b/bots/quoter-bot/test/bootstrap.test.ts index 50180cc2..f5e42c67 100644 --- a/bots/quoter-bot/test/bootstrap.test.ts +++ b/bots/quoter-bot/test/bootstrap.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleep } from 'node:timers/promises' +import { maxUint256 } from 'viem' import { describe, expect, test, vi } from 'vitest' import type { @@ -33,7 +34,6 @@ const environment = { MARKET_IDS: marketId, REFERENCE_MARKET_ID: referenceMarketId, NATIVE_RESERVE_WEI: '10', - MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: 'https://api.example', ROUTER_API_BASE_URL: 'https://router.example' } @@ -75,7 +75,7 @@ const readyState = (): SetupStateService => { getCode: async () => '0x1234', getDerivedMaker: async () => maker, getNativeBalance: async () => 10n, - getLoanAllowance: async () => ({ spender: midnight, amount: 100n }), + getLoanAllowance: async () => ({ spender: midnight, amount: maxUint256 }), getRatifier: async () => ({ listed: true, deployed: true, @@ -233,7 +233,7 @@ describe('createApplication', () => { const configPath = join(directory, 'operator.yaml') await writeFile( configPath, - `chain:\n id: 8453\n rpcUrl: https://rpc.example\n archiveRpcUrl: https://archive.example\nidentity:\n makerAddress: ${maker}\n keyStorageMethod: keystore\n keystorePath: /yaml/maker.json\n ${yamlPasswordMode}\ncontracts:\n midnightAddress: ${midnight}\n loanAssetAddress: ${loanAsset}\n ratifierAddress: ${ratifier}\napis:\n morphoBaseUrl: https://api.example\n routerBaseUrl: https://router.example\nmarkets:\n allowlist: [${marketId}]\n referenceMarketId: ${referenceMarketId}\nsetup:\n nativeReserveWei: 10\n maximumLendExposureAssets: 100\n` + `chain:\n id: 8453\n rpcUrl: https://rpc.example\n archiveRpcUrl: https://archive.example\nidentity:\n makerAddress: ${maker}\n keyStorageMethod: keystore\n keystorePath: /yaml/maker.json\n ${yamlPasswordMode}\ncontracts:\n midnightAddress: ${midnight}\n loanAssetAddress: ${loanAsset}\n ratifierAddress: ${ratifier}\napis:\n morphoBaseUrl: https://api.example\n routerBaseUrl: https://router.example\nmarkets:\n allowlist: [${marketId}]\n referenceMarketId: ${referenceMarketId}\nsetup:\n nativeReserveWei: 10\n` ) let password: string | undefined try { @@ -1512,7 +1512,6 @@ markets: referenceMarketId: "${referenceMarketId}" setup: nativeReserveWei: "10" - maximumLendExposureAssets: "100" ` try { await writeFile(join(directory, 'quoter-bot.yml'), configuration) diff --git a/bots/quoter-bot/test/config/config-loading.test.ts b/bots/quoter-bot/test/config/config-loading.test.ts index e3f2b56e..9ec7e2da 100644 --- a/bots/quoter-bot/test/config/config-loading.test.ts +++ b/bots/quoter-bot/test/config/config-loading.test.ts @@ -29,7 +29,6 @@ const environment = { MARKET_IDS: marketId, REFERENCE_MARKET_ID: referenceMarketId, NATIVE_RESERVE_WEI: '10', - MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: 'https://api.env.example', ROUTER_API_BASE_URL: 'https://router.env.example' } @@ -97,7 +96,6 @@ markets: v0OfferGroupIds: [] setup: nativeReserveWei: "10" - maximumLendExposureAssets: "100" requestTimeoutMs: 10000 transactionReceiptTimeoutMs: 180000 bootstrap: diff --git a/bots/quoter-bot/test/config/config-private-key.test.ts b/bots/quoter-bot/test/config/config-private-key.test.ts index 6655ef54..42f8c2d7 100644 --- a/bots/quoter-bot/test/config/config-private-key.test.ts +++ b/bots/quoter-bot/test/config/config-private-key.test.ts @@ -17,7 +17,6 @@ const environment = { MARKET_IDS: `0x${'55'.repeat(32)}`, REFERENCE_MARKET_ID: `0x${'77'.repeat(32)}`, NATIVE_RESERVE_WEI: '10', - MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: 'https://api.example', ROUTER_API_BASE_URL: 'https://router.example', V0_OFFER_GROUP_IDS: `0x${'66'.repeat(32)}` diff --git a/bots/quoter-bot/test/config/config.service.test.ts b/bots/quoter-bot/test/config/config.service.test.ts index d1e57b27..f8ce1c09 100644 --- a/bots/quoter-bot/test/config/config.service.test.ts +++ b/bots/quoter-bot/test/config/config.service.test.ts @@ -25,7 +25,6 @@ const environment = { MARKET_IDS: marketId, REFERENCE_MARKET_ID: referenceMarketId, NATIVE_RESERVE_WEI: '10', - MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: 'https://api.example', ROUTER_API_BASE_URL: 'https://router.example', V0_OFFER_GROUP_IDS: groupId @@ -66,7 +65,6 @@ describe('ConfigService', () => { midnight: environment.MIDNIGHT_ADDRESS, nativeReserve: 10n, loanAsset: environment.LOAN_ASSET_ADDRESS, - maximumLendExposure: 100n, ratifier: environment.RATIFIER_ADDRESS, marketIds: [marketId], referenceMarketId diff --git a/bots/quoter-bot/test/config/key-storage.test.ts b/bots/quoter-bot/test/config/key-storage.test.ts index 74f55dea..7fdd86ff 100644 --- a/bots/quoter-bot/test/config/key-storage.test.ts +++ b/bots/quoter-bot/test/config/key-storage.test.ts @@ -19,14 +19,13 @@ const baseEnvironment = { MARKET_IDS: `0x${'55'.repeat(32)}`, REFERENCE_MARKET_ID: `0x${'77'.repeat(32)}`, NATIVE_RESERVE_WEI: '10', - MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: 'https://api.example', ROUTER_API_BASE_URL: 'https://router.example' } const directories: string[] = [] const configurationYaml = (identity: string) => - `chain:\n id: 8453\n rpcUrl: https://yaml-rpc.example\n archiveRpcUrl: https://archive.example\nidentity:\n${identity}\ncontracts:\n midnightAddress: 0x2222222222222222222222222222222222222222\n loanAssetAddress: 0x3333333333333333333333333333333333333333\n ratifierAddress: 0x4444444444444444444444444444444444444444\napis:\n morphoBaseUrl: https://api.example\n routerBaseUrl: https://router.example\nmarkets:\n allowlist: [0x${'55'.repeat(32)}]\n referenceMarketId: 0x${'77'.repeat(32)}\nsetup:\n nativeReserveWei: 10\n maximumLendExposureAssets: 100\n` + `chain:\n id: 8453\n rpcUrl: https://yaml-rpc.example\n archiveRpcUrl: https://archive.example\nidentity:\n${identity}\ncontracts:\n midnightAddress: 0x2222222222222222222222222222222222222222\n loanAssetAddress: 0x3333333333333333333333333333333333333333\n ratifierAddress: 0x4444444444444444444444444444444444444444\napis:\n morphoBaseUrl: https://api.example\n routerBaseUrl: https://router.example\nmarkets:\n allowlist: [0x${'55'.repeat(32)}]\n referenceMarketId: 0x${'77'.repeat(32)}\nsetup:\n nativeReserveWei: 10\n` afterEach(async () => { vi.restoreAllMocks() @@ -271,7 +270,7 @@ describe('maker key storage configuration', () => { const path = join(directory, 'operator.yaml') await writeFile( path, - `chain:\n id: 8453\n rpcUrl: https://yaml-rpc.example\n archiveRpcUrl: https://archive.example\nidentity:\n makerAddress: 0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A\n keyStorageMethod: private-key\n makerPrivateKey: ${privateKey}\ncontracts:\n midnightAddress: 0x2222222222222222222222222222222222222222\n loanAssetAddress: 0x3333333333333333333333333333333333333333\n ratifierAddress: 0x4444444444444444444444444444444444444444\napis:\n morphoBaseUrl: https://api.example\n routerBaseUrl: https://router.example\nmarkets:\n allowlist: [0x${'55'.repeat(32)}]\n referenceMarketId: 0x${'77'.repeat(32)}\nsetup:\n nativeReserveWei: 10\n maximumLendExposureAssets: 100\n` + `chain:\n id: 8453\n rpcUrl: https://yaml-rpc.example\n archiveRpcUrl: https://archive.example\nidentity:\n makerAddress: 0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A\n keyStorageMethod: private-key\n makerPrivateKey: ${privateKey}\ncontracts:\n midnightAddress: 0x2222222222222222222222222222222222222222\n loanAssetAddress: 0x3333333333333333333333333333333333333333\n ratifierAddress: 0x4444444444444444444444444444444444444444\napis:\n morphoBaseUrl: https://api.example\n routerBaseUrl: https://router.example\nmarkets:\n allowlist: [0x${'55'.repeat(32)}]\n referenceMarketId: 0x${'77'.repeat(32)}\nsetup:\n nativeReserveWei: 10\n` ) const config = await ConfigService.load( { diff --git a/bots/quoter-bot/test/config/ladder-config.test.ts b/bots/quoter-bot/test/config/ladder-config.test.ts index 97742c23..84fa82c9 100644 --- a/bots/quoter-bot/test/config/ladder-config.test.ts +++ b/bots/quoter-bot/test/config/ladder-config.test.ts @@ -22,7 +22,6 @@ const baseEnvironment = { MARKET_IDS: marketId, REFERENCE_MARKET_ID: `0x${'77'.repeat(32)}`, NATIVE_RESERVE_WEI: '10', - MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: 'https://api.example', ROUTER_API_BASE_URL: 'https://router.example' } diff --git a/bots/quoter-bot/test/e2e/constants.ts b/bots/quoter-bot/test/e2e/constants.ts index fee98bcd..e8278436 100644 --- a/bots/quoter-bot/test/e2e/constants.ts +++ b/bots/quoter-bot/test/e2e/constants.ts @@ -39,5 +39,4 @@ export const MARKET = { } as const export const MAKER_USDC_BALANCE = parseUnits('1000000', 6) -export const MAXIMUM_LEND_EXPOSURE = parseUnits('100000', 6) export const NATIVE_RESERVE = parseEther('1') diff --git a/bots/quoter-bot/test/e2e/quoter-bot.fork.e2e.test.ts b/bots/quoter-bot/test/e2e/quoter-bot.fork.e2e.test.ts index f952154a..b21c18ce 100644 --- a/bots/quoter-bot/test/e2e/quoter-bot.fork.e2e.test.ts +++ b/bots/quoter-bot/test/e2e/quoter-bot.fork.e2e.test.ts @@ -19,7 +19,6 @@ import { ANVIL_DEFAULT_PRIVATE_KEY, ECRECOVER_RATIFIER, MARKET_ID, - MAXIMUM_LEND_EXPOSURE, MIDNIGHT, NATIVE_RESERVE, REFERENCE_MARKET_ID, @@ -91,7 +90,6 @@ const environment = (rpcUrl: string, apiBaseUrl: string) => ({ MARKET_IDS: MARKET_ID, REFERENCE_MARKET_ID, NATIVE_RESERVE_WEI: String(NATIVE_RESERVE), - MAXIMUM_LEND_EXPOSURE_ASSETS: String(MAXIMUM_LEND_EXPOSURE), MORPHO_API_BASE_URL: apiBaseUrl, ROUTER_API_BASE_URL: apiBaseUrl, REQUEST_TIMEOUT_MS: '30000', diff --git a/bots/quoter-bot/test/e2e/setup-api.ts b/bots/quoter-bot/test/e2e/setup-api.ts index 1c53704b..75d12db1 100644 --- a/bots/quoter-bot/test/e2e/setup-api.ts +++ b/bots/quoter-bot/test/e2e/setup-api.ts @@ -40,7 +40,9 @@ const route = (request: Request) => { max_assets: '1', offers: [ { - market_id: MARKET_ID, + // Unconfigured market: outside the bot's exposure model, so readiness must fail + // rather than warn the way an unattributable group on a configured market does. + market_id: `0x${'cd'.repeat(32)}`, maker: ANVIL_DEFAULT_ACCOUNT.address, buy: true, tick: 100, diff --git a/bots/quoter-bot/test/e2e/setup-check.e2e.test.ts b/bots/quoter-bot/test/e2e/setup-check.e2e.test.ts index fe600091..70d8a01a 100644 --- a/bots/quoter-bot/test/e2e/setup-check.e2e.test.ts +++ b/bots/quoter-bot/test/e2e/setup-check.e2e.test.ts @@ -16,7 +16,6 @@ import { ECRECOVER_RATIFIER, MAKER_USDC_BALANCE, MARKET_ID, - MAXIMUM_LEND_EXPOSURE, MIDNIGHT, NATIVE_RESERVE, REFERENCE_MARKET_ID, @@ -45,7 +44,6 @@ const environment = (rpcUrl: string, apiBaseUrl: string) => ({ MARKET_IDS: MARKET_ID, REFERENCE_MARKET_ID, NATIVE_RESERVE_WEI: String(NATIVE_RESERVE), - MAXIMUM_LEND_EXPOSURE_ASSETS: String(MAXIMUM_LEND_EXPOSURE), MORPHO_API_BASE_URL: apiBaseUrl, REQUEST_TIMEOUT_MS: '30000' }) diff --git a/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts b/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts index 4c652593..81fc4323 100644 --- a/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts +++ b/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts @@ -182,7 +182,6 @@ describe('createProductionBootstrapAdapters', () => { MARKET_IDS: marketId, REFERENCE_MARKET_ID: secondMarketId, NATIVE_RESERVE_WEI: '10', - MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: 'https://api.example', ROUTER_API_BASE_URL: 'https://router.example', BOOTSTRAP_MARKETS: JSON.stringify([ @@ -227,7 +226,6 @@ describe('createProductionBootstrapAdapters', () => { MARKET_IDS: marketId, REFERENCE_MARKET_ID: secondMarketId, NATIVE_RESERVE_WEI: '10', - MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: 'https://api.example', ROUTER_API_BASE_URL: 'https://router.example' }) diff --git a/bots/quoter-bot/test/infrastructure/cli/cli.test.ts b/bots/quoter-bot/test/infrastructure/cli/cli.test.ts index 53df265c..b5370add 100644 --- a/bots/quoter-bot/test/infrastructure/cli/cli.test.ts +++ b/bots/quoter-bot/test/infrastructure/cli/cli.test.ts @@ -121,7 +121,6 @@ describe('Cli', () => { MARKET_IDS: `0x${'55'.repeat(32)}`, REFERENCE_MARKET_ID: `0x${'77'.repeat(32)}`, NATIVE_RESERVE_WEI: '10', - MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: `http://127.0.0.1:1/morpho?key=${markers[4]}`, ROUTER_API_BASE_URL: `http://127.0.0.1:1/router?key=${markers[5]}`, REQUEST_TIMEOUT_MS: '50' diff --git a/bots/quoter-bot/test/infrastructure/invalidation/production-offer-invalidation.test.ts b/bots/quoter-bot/test/infrastructure/invalidation/production-offer-invalidation.test.ts index 5eb24fdc..20ab176a 100644 --- a/bots/quoter-bot/test/infrastructure/invalidation/production-offer-invalidation.test.ts +++ b/bots/quoter-bot/test/infrastructure/invalidation/production-offer-invalidation.test.ts @@ -20,7 +20,6 @@ const environment = { MARKET_IDS: marketId, REFERENCE_MARKET_ID: `0x${'66'.repeat(32)}`, NATIVE_RESERVE_WEI: '10', - MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: 'https://api.example', ROUTER_API_BASE_URL: 'https://router.example' } diff --git a/bots/quoter-bot/test/infrastructure/ladder/production-ladder.test.ts b/bots/quoter-bot/test/infrastructure/ladder/production-ladder.test.ts index 2561c11a..ff44caea 100644 --- a/bots/quoter-bot/test/infrastructure/ladder/production-ladder.test.ts +++ b/bots/quoter-bot/test/infrastructure/ladder/production-ladder.test.ts @@ -47,7 +47,6 @@ const environment = { MARKET_IDS: marketId, REFERENCE_MARKET_ID: referenceMarketId, NATIVE_RESERVE_WEI: '10', - MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: 'https://api.example', ROUTER_API_BASE_URL: 'https://router.example' } From e94d162d70a7edff090abc19b53de219ef5ec452 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 3 Sep 2026 05:11:01 -0500 Subject: [PATCH 2/3] count every live maker buy group toward exposure Devin flagged that downgrading unattributable groups to a warning let them bypass exposure limits: both reservation calculators selected through owned IDs, so an orphaned buy group reserved nothing and the bot sized fresh offers against capacity that ignored its remaining amount. The hard halt had been implicitly covering that blind spot. Reservations now count every live buy group in the maker's own book, whether or not durable ownership still attributes it. Ownership continues to gate reconciliation, cancellation, and replacement. Co-Authored-By: Claude Opus 5 (1M context) --- .../application/setup/setup-check.utils.ts | 3 ++- .../bootstrap/bootstrap-groups.utils.ts | 16 ++++--------- .../bootstrap/production-bootstrap.ts | 2 +- .../ladder/ladder-cash-reservation.utils.ts | 15 ++++++------ .../ladder/production-ladder.ts | 1 - .../bootstrap/production-bootstrap.test.ts | 8 +++---- .../ladder-cash-reservation.utils.test.ts | 23 ++++++++++++++++--- 7 files changed, 39 insertions(+), 29 deletions(-) diff --git a/bots/quoter-bot/src/application/setup/setup-check.utils.ts b/bots/quoter-bot/src/application/setup/setup-check.utils.ts index c78eed8f..bac7afb7 100644 --- a/bots/quoter-bot/src/application/setup/setup-check.utils.ts +++ b/bots/quoter-bot/src/application/setup/setup-check.utils.ts @@ -239,7 +239,8 @@ export const setupResult = ( * cannot attribute to itself, `passed` otherwise. * @remarks Only markets drive readiness. Group ownership is a local durable record, so a redeploy * onto a fresh filesystem orphans the bot's own live groups; halting on that cannot self-heal until - * every orphan expires, while the ladder derives exposure from live on-chain groups regardless. + * every orphan expires. Downgrading to a warning is only safe because reservations count every live + * maker buy group rather than only attributed ones — see {@link ladderCashReservations}. */ export const unsafeOffersStatus = (offers: { unknownNamespaces: readonly string[] diff --git a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts index bc0e3aeb..fa707764 100644 --- a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts +++ b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts @@ -266,27 +266,21 @@ export const bootstrapGroupRateBps = (parameters: { : 0n /** - * Totals the unfilled cash reserve of every distinct explicitly owned buy group. + * Totals the unfilled cash reserve of every distinct live buy group. * @param groups - Canonical maker groups, which may contain one projection per offer market. - * @param ownedGroupIds - Durable explicit ownership candidates for this strategy. * @param excludedGroupIds - Groups being replaced and therefore not reserved alongside the new offer. - * @returns Aggregate remaining loan assets reserved by distinct owned buy groups. + * @returns Aggregate remaining loan assets reserved by distinct live buy groups. + * @remarks Attribution deliberately does not filter this total; see {@link ladderCashReservations} + * for why exposure counts every live maker buy group. */ export const bootstrapReservedLoanAssets = ( groups: readonly BootstrapRawGroup[], - ownedGroupIds: readonly Hex[], excludedGroupIds: ReadonlySet = new Set() ) => { - const ownedGroups = new Set(ownedGroupIds) return [ ...new Map( groups - .filter( - group => - ownedGroups.has(group.id) && - !excludedGroupIds.has(group.id) && - group.offers.some(offer => offer.buy) - ) + .filter(group => !excludedGroupIds.has(group.id) && group.offers.some(offer => offer.buy)) .map(group => [group.id, group]) ).values() ] diff --git a/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts b/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts index 0dc37860..5e10a80c 100644 --- a/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts +++ b/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts @@ -542,7 +542,7 @@ export const createProductionBootstrapAdapters = ( validation: { apiUrl: `${config.morphoApiBaseUrl}/v0/midnight` }, loanToken: config.setup.loanAsset, loanAssets: offer.assets, - reservedLoanAssets: bootstrapReservedLoanAssets(groups, ownedIds, replacedGroupIds) + reservedLoanAssets: bootstrapReservedLoanAssets(groups, replacedGroupIds) }) ) ) diff --git a/bots/quoter-bot/src/infrastructure/ladder/ladder-cash-reservation.utils.ts b/bots/quoter-bot/src/infrastructure/ladder/ladder-cash-reservation.utils.ts index fec38448..e6828e49 100644 --- a/bots/quoter-bot/src/infrastructure/ladder/ladder-cash-reservation.utils.ts +++ b/bots/quoter-bot/src/infrastructure/ladder/ladder-cash-reservation.utils.ts @@ -37,29 +37,28 @@ export const pendingLadderBuyReservations = ( /** * Combines indexed and persisted cross-strategy buy reservations for ladder sizing. - * @param parameters - API groups, durable ownership, bootstrap intents, and replaced ladder IDs. + * @param parameters - API groups, ladder publications, bootstrap intents, and replaced ladder IDs. * @returns One reservation per group, including groups still awaiting API indexing. - * @remarks Current-market groups being replaced are excluded; all other owned buys remain reserved. + * @remarks Every live maker buy group reserves, attributed to this strategy or not: the groups come + * from the maker's own book, so each one commits maker cash whether or not durable ownership still + * records it. Reserving only attributed groups would let a lost ownership store hide live + * commitments and size fresh offers past the configured exposure caps. Ownership still gates + * reconciliation, cancellation, and replacement; it must not gate exposure. Current-market groups + * being replaced are excluded. */ export const ladderCashReservations = (parameters: { groups: readonly BootstrapRawGroup[] publications: readonly OwnedLadderPublication[] - bootstrapGroupIds: readonly Hex[] bootstrapOffers: readonly BootstrapOfferIntent[] replacedGroupIds: ReadonlySet ignoredGroupIds?: ReadonlySet }): LadderCashReservation[] => { - const ladderGroupIds = parameters.publications.flatMap(publication => - publication.groups.map(group => group.groupId) - ) - const owned = new Set([...parameters.bootstrapGroupIds, ...ladderGroupIds]) const indexedIds = new Set(parameters.groups.map(group => group.id)) const reservations = new Map() for (const group of parameters.groups) { if ( reservations.has(group.id) || - !owned.has(group.id) || parameters.ignoredGroupIds?.has(group.id) || parameters.replacedGroupIds.has(group.id) ) { diff --git a/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts b/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts index f9c0e8df..c2cedec0 100644 --- a/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts +++ b/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts @@ -418,7 +418,6 @@ export const createProductionLadderAdapters = ( const reservations = ladderCashReservations({ groups, publications, - bootstrapGroupIds, bootstrapOffers: pendingBootstrapOffers, replacedGroupIds, ignoredGroupIds: removedGroupTombstones diff --git a/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts b/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts index 81fc4323..333fae94 100644 --- a/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts +++ b/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts @@ -753,8 +753,8 @@ describe('readBootstrapGroups', () => { } ) - expect(bootstrapReservedLoanAssets(groups, [groupId])).toBe(100n) - expect(bootstrapReservedLoanAssets(groups, [groupId], new Set([groupId]))).toBe(0n) + expect(bootstrapReservedLoanAssets(groups)).toBe(100n) + expect(bootstrapReservedLoanAssets(groups, new Set([groupId]))).toBe(0n) }) test('excludes durably owned sell-only groups from the loan-token cash reserve', async () => { @@ -774,7 +774,7 @@ describe('readBootstrapGroups', () => { ) expect(strategyBootstrapGroups(groups, [groupId, secondGroupId])).toEqual([]) - expect(bootstrapReservedLoanAssets(groups, [groupId, secondGroupId])).toBe(0n) + expect(bootstrapReservedLoanAssets(groups)).toBe(0n) }) test('passes the full distinct owned reserve in the actual makeLend argument shape', async () => { @@ -801,7 +801,7 @@ describe('readBootstrapGroups', () => { validation: { apiUrl: 'https://api.example/v0/midnight' }, loanToken, loanAssets: 100n, - reservedLoanAssets: bootstrapReservedLoanAssets(groups, [groupId, secondGroupId]) + reservedLoanAssets: bootstrapReservedLoanAssets(groups) }) ) diff --git a/bots/quoter-bot/test/infrastructure/ladder/ladder-cash-reservation.utils.test.ts b/bots/quoter-bot/test/infrastructure/ladder/ladder-cash-reservation.utils.test.ts index 4376d016..37d1ae63 100644 --- a/bots/quoter-bot/test/infrastructure/ladder/ladder-cash-reservation.utils.test.ts +++ b/bots/quoter-bot/test/infrastructure/ladder/ladder-cash-reservation.utils.test.ts @@ -41,7 +41,6 @@ describe('ladder cash reservations', () => { ladderCashReservations({ groups: [], publications: [publication], - bootstrapGroupIds: [bootstrapGroupId], bootstrapOffers: [ { groupId: bootstrapGroupId, @@ -62,7 +61,6 @@ describe('ladder cash reservations', () => { ladderCashReservations({ groups: [], publications: [publication], - bootstrapGroupIds: [], bootstrapOffers: [], replacedGroupIds: new Set([ladderGroupId]) }) @@ -81,11 +79,30 @@ describe('ladder cash reservations', () => { } ], publications: [publication], - bootstrapGroupIds: [], bootstrapOffers: [], replacedGroupIds: new Set(), ignoredGroupIds: new Set([ladderGroupId]) }) ).toEqual([]) }) + + test('reserves a live buy group the durable ownership store no longer attributes', () => { + const orphanGroupId: Hex = `0x${'44'.repeat(32)}` + + expect( + ladderCashReservations({ + groups: [ + { + id: orphanGroupId, + maxAssets: 80n, + consumed: 10n, + offers: [{ marketId, maker, buy: true, tick: 1n }] + } + ], + publications: [], + bootstrapOffers: [], + replacedGroupIds: new Set() + }) + ).toEqual([{ id: orphanGroupId, marketIds: [marketId], assets: 70n }]) + }) }) From ee559df28900faefb925617558b1ed82cd062781 Mon Sep 17 00:00:00 2001 From: Hayden Shively Date: Thu, 3 Sep 2026 13:39:05 -0500 Subject: [PATCH 3/3] reserve orphaned bootstrap groups and split the warning event Addresses two Codex findings. P1: the bootstrap inventory had the same attribution blind spot as the ladder, one layer up. `readGroupInventory` derived `cashReservations` from `strategyBootstrapGroups(groups, ladderPublicationIds)`, so a group in neither `activeGroups` nor the ladder's publications reserved nothing and dropped out of both `reservedCash` and `totalExposure`. It now projects every live buy group; the position service already removes this strategy's own active groups. P2: warnings reused the `setup.check-failed` discriminator, which the operator contract documents as a failed check. A consumer alerting on the event name would have reported a failure every cycle for a check that left readiness intact. Warnings now ship as `setup.check-warning`, and both documented tables carry the new event and the `status` field. Co-Authored-By: Claude Opus 5 (1M context) --- bots/quoter-bot/README.md | 6 +++-- bots/quoter-bot/docs/reference.md | 7 +++--- .../monitoring/monitoring-event.ts | 6 +++-- .../monitoring/setup-monitoring.utils.ts | 17 +++++++------ .../application/setup/setup-check.utils.ts | 3 ++- .../bootstrap/bootstrap-groups.utils.ts | 4 ++-- .../bootstrap/production-bootstrap.ts | 12 +++------- .../monitoring-projection.utils.test.ts | 24 +++++++++++++++++++ .../bootstrap-position.service.test.ts | 23 ++++++++++++++++++ 9 files changed, 76 insertions(+), 26 deletions(-) diff --git a/bots/quoter-bot/README.md b/bots/quoter-bot/README.md index 2d4e3bbe..907ec2b4 100644 --- a/bots/quoter-bot/README.md +++ b/bots/quoter-bot/README.md @@ -574,7 +574,8 @@ no field is human-scaled — resolve decimals downstream from the `loanAsset` ad | `book.observed` | A verbose ladder cycle observed post-check market state (both sides always) | `marketId`, `side`, `state`, `rungs`, `totalAssets`, `bestRateBps?`, `worstRateBps?`, `centerRateBps?` | | `offer.consumed` | A group's monotonic `consumed` grew since the previous cycle | `marketId`, `side`, `consumedDeltaAssets`, `groupRateBps`, `remainingAssets`, `groupId` _(trace only)_ | | `transaction.settled` | A submitted transaction confirmed; `marketId` is absent for strategy-wide halt/invalidation cancellations | `workflow`, `marketId?`, `operation`, `txHash` _(trace only)_ | -| `setup.check-failed` | One named setup check failed | `check` | +| `setup.check-failed` | One named setup check failed, blocking readiness | `check`, `status` | +| `setup.check-warning` | One named setup check warned without blocking readiness | `check`, `status` | `txHash` and `groupId` are unbounded trace-only correlation fields: use them to join records, never as grouping dimensions. The safe dimensions are `workflow`, `marketId`, `side`, `status`, `stage`, @@ -601,7 +602,8 @@ Absence alerts are scoped per market by `market.configured`, which names the mar **Known limits.** -- Everything beyond `cycle.completed`, `guardrail.halted`, and `setup.check-failed` requires +- Everything beyond `cycle.completed`, `guardrail.halted`, `setup.check-failed`, and + `setup.check-warning` requires `--verbose`. Full shipping configuration auto-enables it; running `start`, `bootstrap`, or `ladder` manually without `--verbose` yields far fewer records. - Fills are diffed from monotonic per-group `consumed`. A group first seen establishes a baseline and diff --git a/bots/quoter-bot/docs/reference.md b/bots/quoter-bot/docs/reference.md index 0b365476..bdcc813b 100644 --- a/bots/quoter-bot/docs/reference.md +++ b/bots/quoter-bot/docs/reference.md @@ -473,7 +473,8 @@ human-scaled: a consumer resolves decimals from the `loanAsset` address shipped | `book.observed` | A verbose ladder cycle observed post-check market state; one record per side, on every observed cycle | `marketId`, `side`, `state` (`quoting` \| `empty`), `rungs`, `totalAssets`, `bestRateBps?`, `worstRateBps?`, `centerRateBps?` (absent when no quote is active) | | `offer.consumed` | A group's monotonic `consumed` grew relative to the previous cycle | `marketId`, `side`, `consumedDeltaAssets`, `groupRateBps`, `remainingAssets`, `groupId` _(trace only)_ | | `transaction.settled` | A submitted bootstrap or ladder transaction confirmed; `marketId` is absent for strategy-wide halt/invalidation cancellations | `workflow`, `marketId?`, `operation` (`cancel` \| `ratify` \| `publish`), `txHash` _(trace only)_ | -| `setup.check-failed` | One named readiness check failed; `observed`/`required` are typed `unknown` and are omitted | `check` | +| `setup.check-failed` | One named readiness check failed, blocking readiness; `observed`/`required` are typed `unknown` and are omitted | `check`, `status` | +| `setup.check-warning` | One named readiness check warned without blocking readiness; `observed`/`required` are likewise omitted | `check`, `status` | `bot.started`, `bot.stopped`, `bot.unexpected-error`, `heartbeat.failed`, `ladder.transaction-submitted`, `bootstrap.transaction-submitted`, and @@ -537,8 +538,8 @@ only and cannot prove a particular market was read or quoted. The per-market `cy #### Known limits -- **Verbose gating.** Everything beyond `cycle.completed`, `guardrail.halted`, `bot.failed`, and - `setup.check-failed` is projected from verbose diagnostics. Full shipping configuration +- **Verbose gating.** Everything beyond `cycle.completed`, `guardrail.halted`, `bot.failed`, + `setup.check-failed`, and `setup.check-warning` is projected from verbose diagnostics. Full shipping configuration auto-enables `--verbose` for `start`, `bootstrap`, and `ladder`; an operator running those commands manually without `--verbose` gets far fewer records. - **Fill baseline.** `offer.consumed` is a cycle-over-cycle delta of monotonic per-group `consumed`, diff --git a/bots/quoter-bot/src/application/monitoring/monitoring-event.ts b/bots/quoter-bot/src/application/monitoring/monitoring-event.ts index dbc7a65c..a773a093 100644 --- a/bots/quoter-bot/src/application/monitoring/monitoring-event.ts +++ b/bots/quoter-bot/src/application/monitoring/monitoring-event.ts @@ -156,7 +156,8 @@ export type MonitoringEvent = operation: 'cancel' | 'ratify' | 'publish' txHash: Hex } - | { event: 'setup.check-failed'; check: string; status: 'failed' | 'warning' } + | { event: 'setup.check-failed'; check: string; status: 'failed' } + | { event: 'setup.check-warning'; check: string; status: 'warning' } /** * Event names that may be shipped to the log source. @@ -184,7 +185,8 @@ const MONITORING_EVENT_NAMES = [ 'book.observed', 'offer.consumed', 'transaction.settled', - 'setup.check-failed' + 'setup.check-failed', + 'setup.check-warning' ] as const satisfies readonly MonitoringEvent['event'][] type MissingFromAllowlist = Exclude< diff --git a/bots/quoter-bot/src/application/monitoring/setup-monitoring.utils.ts b/bots/quoter-bot/src/application/monitoring/setup-monitoring.utils.ts index a1161cb9..a5810601 100644 --- a/bots/quoter-bot/src/application/monitoring/setup-monitoring.utils.ts +++ b/bots/quoter-bot/src/application/monitoring/setup-monitoring.utils.ts @@ -4,8 +4,9 @@ import type { MonitoringEvent } from './monitoring-event' /** * Projects one setup readiness report into flat monitoring records. * @param report - Complete readiness report from one setup check. - * @returns One record per failed or warning check, and a terminating cycle record carrying overall - * readiness. + * @returns One `setup.check-failed` per failed check, one `setup.check-warning` per warning check, + * and a terminating cycle record carrying overall readiness. The two keep separate discriminators so + * a consumer alerting on the failure event never fires for a check that left readiness intact. * @remarks A graded check contributes only its stable `check` name and `status`: `observed` and * `required` are typed `unknown` and routinely hold objects, which would violate the flat-scalar * rule the event contract relies on for grouping. `SetupCheck` carries no error classification, so @@ -15,11 +16,13 @@ import type { MonitoringEvent } from './monitoring-event' * `status`, and emitting both would restate the same fact every minute. */ export const setupMonitoringEvents = (report: SetupCheckReport): readonly MonitoringEvent[] => [ - ...report.checks.flatMap((check): MonitoringEvent[] => - check.status === 'failed' || check.status === 'warning' - ? [{ event: 'setup.check-failed', check: check.name, status: check.status }] - : [] - ), + ...report.checks.flatMap((check): MonitoringEvent[] => { + if (check.status === 'failed') + return [{ event: 'setup.check-failed', check: check.name, status: 'failed' }] + if (check.status === 'warning') + return [{ event: 'setup.check-warning', check: check.name, status: 'warning' }] + return [] + }), { event: 'cycle.completed', workflow: 'setup-check', diff --git a/bots/quoter-bot/src/application/setup/setup-check.utils.ts b/bots/quoter-bot/src/application/setup/setup-check.utils.ts index bac7afb7..31331b89 100644 --- a/bots/quoter-bot/src/application/setup/setup-check.utils.ts +++ b/bots/quoter-bot/src/application/setup/setup-check.utils.ts @@ -240,7 +240,8 @@ export const setupResult = ( * @remarks Only markets drive readiness. Group ownership is a local durable record, so a redeploy * onto a fresh filesystem orphans the bot's own live groups; halting on that cannot self-heal until * every orphan expires. Downgrading to a warning is only safe because reservations count every live - * maker buy group rather than only attributed ones — see {@link ladderCashReservations}. + * maker buy group rather than only attributed ones — see `ladderCashReservations` in + * `infrastructure/ladder/ladder-cash-reservation.utils.ts`. */ export const unsafeOffersStatus = (offers: { unknownNamespaces: readonly string[] diff --git a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts index fa707764..8c098f2a 100644 --- a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts +++ b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts @@ -270,8 +270,8 @@ export const bootstrapGroupRateBps = (parameters: { * @param groups - Canonical maker groups, which may contain one projection per offer market. * @param excludedGroupIds - Groups being replaced and therefore not reserved alongside the new offer. * @returns Aggregate remaining loan assets reserved by distinct live buy groups. - * @remarks Attribution deliberately does not filter this total; see {@link ladderCashReservations} - * for why exposure counts every live maker buy group. + * @remarks Attribution deliberately does not filter this total; see `ladderCashReservations` in + * `../ladder/ladder-cash-reservation.utils.ts` for why exposure counts every live maker buy group. */ export const bootstrapReservedLoanAssets = ( groups: readonly BootstrapRawGroup[], diff --git a/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts b/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts index 5e10a80c..fd2a5fa2 100644 --- a/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts +++ b/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts @@ -385,15 +385,9 @@ export const createProductionBootstrapAdapters = ( return { activeGroups: [...project(strategyBootstrapGroups(groups, ownedIds), true), ...pendingGroups], cashReservations: [ - ...project( - strategyBootstrapGroups( - groups, - ladderPublications.flatMap(publication => - publication.groups.map(group => group.groupId) - ) - ), - false - ), + // Every live buy group, attributed or not: the caller removes this strategy's own active + // groups, so anything left still commits maker cash. See `ladderCashReservations`. + ...project(groups, false), ...pendingLadderBuyReservations(groups, ladderPublications).flatMap(reservation => reservation.marketIds.map(marketId => ({ id: reservation.id, diff --git a/bots/quoter-bot/test/application/monitoring/monitoring-projection.utils.test.ts b/bots/quoter-bot/test/application/monitoring/monitoring-projection.utils.test.ts index 5507cc2b..57c277b5 100644 --- a/bots/quoter-bot/test/application/monitoring/monitoring-projection.utils.test.ts +++ b/bots/quoter-bot/test/application/monitoring/monitoring-projection.utils.test.ts @@ -58,6 +58,30 @@ describe('createMonitoringProjection', () => { expect(JSON.stringify(events)).not.toContain('required') }) + test('keeps a non-blocking warning off the failure discriminator', () => { + const warningReport: SetupCheckReport = { + ready: true, + checks: [ + { name: 'offers', status: 'warning', observed: {}, required: {} }, + { name: 'chain', status: 'passed', observed: 8453, required: 8453 } + ] + } + + const events = createMonitoringProjection().setup(warningReport) + + expect(events).toContainEqual({ + event: 'setup.check-warning', + check: 'offers', + status: 'warning' + }) + expect(events.filter(event => event.event === 'setup.check-failed')).toHaveLength(0) + expect(events).toContainEqual({ + event: 'cycle.completed', + workflow: 'setup-check', + status: 'ready' + }) + }) + test('reports a spread rejection only for an actual cross-book rejection', () => { const failure = (adapterOperation?: string) => [ { diff --git a/bots/quoter-bot/test/infrastructure/bootstrap/bootstrap-position.service.test.ts b/bots/quoter-bot/test/infrastructure/bootstrap/bootstrap-position.service.test.ts index b535d56c..6e57148e 100644 --- a/bots/quoter-bot/test/infrastructure/bootstrap/bootstrap-position.service.test.ts +++ b/bots/quoter-bot/test/infrastructure/bootstrap/bootstrap-position.service.test.ts @@ -317,4 +317,27 @@ describe('MidnightBootstrapPositionService', () => { expect(position.maturityTimestamp).toBe(1_000n) expect(position.observedTimestamp).toBe(1_200n) }) + + test('counts an unattributed live buy group toward reserved cash and exposure', async () => { + const orphanGroup: Hex = `0x${'66'.repeat(32)}` + const service = new MidnightBootstrapPositionService( + { + readPositions: async () => [{ marketId, credit: 0n, debt: 0n }], + readCashBalance: async () => 100n, + readMarketContinuousFeeCap: async () => 17n, + readMarketMaturity: async () => ({ maturityTimestamp: 2_000n, observedTimestamp: 1_000n }), + readGroupInventory: async () => ({ + activeGroups: [], + cashReservations: [{ id: orphanGroup, marketId, assets: 30n, rateBps: 500n }] + }) + }, + maker + ) + + const position = await service.readPosition(marketId) + + expect(position.cashBalance).toBe(70n) + expect(position.marketExposure).toBe(30n) + expect(position.totalExposure).toBe(30n) + }) })