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..907ec2b4 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. | @@ -575,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`, @@ -602,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 @@ -643,11 +644,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 +671,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..bdcc813b 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. | @@ -474,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 @@ -538,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`, @@ -590,11 +590,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 +617,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..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 } + | { 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 2ddb1ed1..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,17 +4,25 @@ 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 `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 + * 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[] => { + 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.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..31331b89 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,26 @@ 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. Downgrading to a warning is only safe because reservations count every live + * 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[] + 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/src/infrastructure/bootstrap/bootstrap-groups.utils.ts b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts index bc0e3aeb..8c098f2a 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 `ladderCashReservations` in + * `../ladder/ladder-cash-reservation.utils.ts` 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..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, @@ -542,7 +536,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/application/monitoring/monitoring-projection.utils.test.ts b/bots/quoter-bot/test/application/monitoring/monitoring-projection.utils.test.ts index 00fd2aec..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 @@ -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', @@ -54,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/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/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) + }) }) 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..333fae94 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' }) @@ -755,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 () => { @@ -776,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 () => { @@ -803,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/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/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 }]) + }) }) 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' }