diff --git a/bots/quoter-bot/scripts/check-jsdoc.ts b/bots/quoter-bot/scripts/check-jsdoc.ts index 8dcc7845..d7c8f809 100644 --- a/bots/quoter-bot/scripts/check-jsdoc.ts +++ b/bots/quoter-bot/scripts/check-jsdoc.ts @@ -31,7 +31,6 @@ const providerMethods = new Set([ 'ViemSetupStateService.getLoanAllowance', 'ViemSetupStateService.getRatifier', 'ViemSetupStateService.getBook', - 'ViemSetupStateService.getLatestTimestamp', 'ViemSetupStateService.checkReference', 'ViemSetupStateService.inspectOffers' ]) diff --git a/bots/quoter-bot/src/application/bootstrap/position-bootstrap.service.ts b/bots/quoter-bot/src/application/bootstrap/position-bootstrap.service.ts index 49d92faf..377c6539 100644 --- a/bots/quoter-bot/src/application/bootstrap/position-bootstrap.service.ts +++ b/bots/quoter-bot/src/application/bootstrap/position-bootstrap.service.ts @@ -30,6 +30,7 @@ import { validateBootstrapConfig } from '../../domain/bootstrap/position-bootstrap' import { BootstrapAdapterError } from '../../infrastructure/bootstrap/bootstrap-adapter.error' +import { marketObservationMatured } from '../market-maturity.utils' import { adapterOperationField, operatorErrorDetails, @@ -152,7 +153,7 @@ type BootstrapRunOutcome = | { marketId: Hex status: 'observed' - action: 'target-reached' | 'auto-refill-disabled' | 'no-capacity' | 'rest' + action: 'target-reached' | 'auto-refill-disabled' | 'no-capacity' | 'rest' | 'matured' } | { marketId: Hex @@ -392,6 +393,10 @@ export class PositionBootstrapService { * cleanup evidence are returned in the structured result. * @remarks Invalid configuration hard-halts before any position/reference read or publication. * Verbose mode adds a fresh post-check read without exposing signer identity or provider details. + * A market whose fresh read shows maturity already reached reports the non-failing `matured` + * action before any reference read or offer arithmetic, so a normal lifecycle end neither + * computes a negative time to maturity nor stops the remaining configured markets; its owned + * groups can no longer be filled and are cancelled by the monitor's shutdown cleanup. */ async runOnce( parameters: { @@ -476,6 +481,23 @@ export class PositionBootstrapService { continue } const currentState = this.verbosePosition(config.marketId, observedPosition) + if (marketObservationMatured(observedPosition)) { + plans.push({ + result: await this.withVerboseDetails( + { + marketId: config.marketId, + status: 'observed' as const, + action: 'matured' as const + }, + verbose, + { config, currentState: { status: 'observed', position: currentState } }, + false, + undefined, + Date.now() - plannedAt + ) + }) + continue + } const marketReservationDelta = reservedAssetsDeltaByMarket.get(config.marketId) ?? 0n const position = { ...observedPosition, diff --git a/bots/quoter-bot/src/application/ladder/ladder-quoter.service.ts b/bots/quoter-bot/src/application/ladder/ladder-quoter.service.ts index 629bc187..90b0a5dc 100644 --- a/bots/quoter-bot/src/application/ladder/ladder-quoter.service.ts +++ b/bots/quoter-bot/src/application/ladder/ladder-quoter.service.ts @@ -27,6 +27,7 @@ import { } from '../../domain/ladder/ladder' import { LadderConfigurationError } from '../../domain/ladder/ladder-configuration.error' import { LadderAdapterError } from '../../infrastructure/ladder/ladder-adapter.error' +import { marketObservationMatured } from '../market-maturity.utils' import { operatorErrorName } from '../operator-error-name.utils' import { LadderOwnershipCleanupError } from './ladder-ownership-cleanup.error' import { sameLadderQuoteSet } from './ladder-quoter.utils' @@ -107,7 +108,7 @@ export interface LadderMakeService { reconcile(parameters: { marketId: Hex desired?: LadderQuoteSet - reason: 'publish' | 'recenter' | 'resize' | 'rest' | 'market-read-failed' + reason: 'publish' | 'recenter' | 'resize' | 'rest' | 'market-matured' | 'market-read-failed' onTransactionSubmitted?: LadderTransactionSubmittedObserver }): Promise /** @@ -138,6 +139,7 @@ export interface LadderMakeService { type LadderRunOutcome = | { marketId: Hex; status: 'observed'; action: 'rest' } + | { marketId: Hex; status: 'observed' | 'applied' | 'logged'; action: 'matured' } | { marketId: Hex status: 'applied' | 'logged' @@ -229,7 +231,9 @@ export class LadderQuoterService { * @param parameters - Shutdown signal, optional cycle writer, test interval, and verbose hooks. * @returns A terminal report after cleanup has been attempted. * @throws `LadderConfigurationError` before cleanup when no market or interval is usable. - * @remarks Cycles never overlap. Production cadence uses the shortest configured market interval; + * @remarks Configured markets that have reached maturity are rested rather than quoted, which is + * not a cycle failure, so monitoring survives a normal market lifecycle end. + * Cycles never overlap. Production cadence uses the shortest configured market interval; * a test-only `intervalMs` override applies to the complete configured set. Cleanup is serialized * through the make port after the final in-flight cycle. Verbose cycles perform a fresh * market/active-quote read after every check and emit submitted transaction hashes immediately. @@ -356,7 +360,10 @@ export class LadderQuoterService { * provider, decision, and invalidation failures are returned as sanitized outcomes. * @remarks Retains an active center inside the inclusive movement tolerance while still deriving * fresh sizes. Verbose mode adds a fresh post-check read without exposing signer or provider - * details. All publication and invalidation side effects pass exclusively through `make`. + * details. All publication and invalidation side effects pass exclusively through `make`. A market + * whose fresh read shows maturity already reached is not quoted: its owned groups are invalidated + * and it reports the non-failing `matured` action, so the remaining configured markets keep + * quoting and monitoring continues into later cycles. */ async runOnce(parameters: LadderRunParameters = {}) { if (this.configs.length === 0) { @@ -465,6 +472,11 @@ export class LadderQuoterService { ...(active ? { activeQuote: active } : {}) } + if (marketObservationMatured(market)) { + results.push(await this.settleMaturedMarket(config, currentState, parameters, startedAt)) + continue + } + let referenceRateBps: bigint let referenceObservationId: string | undefined let secondsToMaturity: bigint | undefined @@ -663,6 +675,70 @@ export class LadderQuoterService { return seconds * 1_000 } + private async settleMaturedMarket( + config: LadderConfig, + currentState: LadderVerboseState, + parameters: LadderRunParameters, + startedAt: number + ): Promise { + const verbosePlan: LadderVerbosePlan = { config, currentState, decision: 'matured' } + let invalidation: LadderMakeResult + try { + invalidation = await this.make.reconcile({ + marketId: config.marketId, + desired: undefined, + reason: 'market-matured', + onTransactionSubmitted: this.marketObserver(config.marketId, parameters) + }) + } catch (error) { + const ownershipCleanup = error instanceof LadderOwnershipCleanupError ? error : undefined + const confirmedTransactions = + ownershipCleanup?.submittedTransactions ?? + (error instanceof LadderAdapterError ? error.confirmedTransactions : []) + return this.completeResult( + config, + { + marketId: config.marketId, + status: 'failed', + stage: 'reconcile', + invalidated: ownershipCleanup !== undefined, + errorName: operatorErrorName(error), + ...(ownershipCleanup + ? { ownershipCleanupErrorName: ownershipCleanup.cleanupErrorName } + : {}) + }, + parameters, + { + ...verbosePlan, + ...(confirmedTransactions.length > 0 + ? { submittedTransactions: confirmedTransactions } + : {}) + }, + startedAt + ) + } + const submittedTransactions = + invalidation === undefined || invalidation === 'logged' + ? undefined + : invalidation.submittedTransactions + return this.completeResult( + config, + { + marketId: config.marketId, + status: + invalidation === 'logged' + ? 'logged' + : submittedTransactions && submittedTransactions.length > 0 + ? 'applied' + : 'observed', + action: 'matured' + }, + parameters, + { ...verbosePlan, ...(submittedTransactions ? { submittedTransactions } : {}) }, + startedAt + ) + } + private async failedMarketRead( config: LadderConfig, error: unknown, diff --git a/bots/quoter-bot/src/application/ladder/ladder-verbose.ts b/bots/quoter-bot/src/application/ladder/ladder-verbose.ts index 879198cd..c7188792 100644 --- a/bots/quoter-bot/src/application/ladder/ladder-verbose.ts +++ b/bots/quoter-bot/src/application/ladder/ladder-verbose.ts @@ -99,7 +99,7 @@ export type LadderVerboseDetails = { /** Exact desired lower/higher quote set, when decision derivation succeeded. */ ladderOffer?: LadderQuoteSet /** Stable reconciliation reason selected by the application workflow. */ - decision?: 'publish' | 'recenter' | 'resize' | 'rest' + decision?: 'publish' | 'recenter' | 'resize' | 'rest' | 'matured' /** Confirmed transactions submitted for this market check, in submission order. */ submittedTransactions?: readonly LadderSubmittedTransaction[] /** Fresh provider and active-quote state read after the check or mutation completed. */ diff --git a/bots/quoter-bot/src/application/market-maturity.utils.ts b/bots/quoter-bot/src/application/market-maturity.utils.ts new file mode 100644 index 00000000..ad36538c --- /dev/null +++ b/bots/quoter-bot/src/application/market-maturity.utils.ts @@ -0,0 +1,15 @@ +/** One market observation carrying its maturity beside the timestamp it was observed against. */ +type MaturityObservation = { maturityTimestamp?: bigint; observedTimestamp?: bigint } + +/** + * Decides whether a freshly read market has already reached maturity. + * @param observation - Fresh market read carrying maturity and its observation timestamp. + * @returns Whether the observed timestamp is at or after the market's maturity. + * @remarks Both values come from one read, so no wall clock is consulted; an observation missing + * either value is treated as not matured and left to the workflow's normal quoting path, which + * keeps adapters that cannot report maturity behaving exactly as before. + */ +export const marketObservationMatured = (observation: MaturityObservation) => + observation.maturityTimestamp !== undefined && + observation.observedTimestamp !== undefined && + observation.observedTimestamp >= observation.maturityTimestamp 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 397af97a..a01beaed 100644 --- a/bots/quoter-bot/src/application/setup/setup-check.service.ts +++ b/bots/quoter-bot/src/application/setup/setup-check.service.ts @@ -129,8 +129,6 @@ export type BookSetup = { loanAsset: Address /** On-chain tick spacing; must be positive. */ tickSpacing: number - /** On-chain maturity timestamp. */ - maturity: bigint } /** @@ -179,8 +177,6 @@ export interface SetupStateService { }> /** Cross-checks one Midnight market. @param id - Midnight market identifier. @returns Cross-checked API and on-chain market facts. */ getBook(id: Hex): Promise - /** Reads the latest connected-chain block timestamp. @returns Timestamp of the latest connected-chain block. */ - getLatestTimestamp(): Promise /** Checks historical reference-market readability. @returns Archive-readability and exact reference-market identity facts. */ checkReference(): Promise<{ marketId: Hex; referenceReadable: boolean; archiveReadable: boolean }> /** @@ -316,7 +312,6 @@ export class SetupCheckService { capture(() => this.state.getNativeBalance(this.config.maker)), capture(() => this.state.getLoanAllowance(this.config.maker, this.config.loanAsset)), capture(() => this.state.getRatifier(this.config.maker, this.config.ratifier)), - capture(() => this.state.getLatestTimestamp()), Promise.all(bookReads.map(async book => ({ ...book, response: await book.response }))), referenceRead, capture(() => this.state.inspectOffers(this.config.maker), 'morpho-api'), @@ -330,7 +325,6 @@ export class SetupCheckService { nativeBalance, allowance, ratifier, - latestTimestamp, books, reference, offers, @@ -463,7 +457,7 @@ export class SetupCheckService { nativeCheck, allowanceCheck, ratifierCheck, - booksCheck(this.config, latestTimestamp, books), + booksCheck(this.config, books), referenceCheck, offersCheck, positionCheck 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 54b3da80..c837a4f6 100644 --- a/bots/quoter-bot/src/application/setup/setup-check.utils.ts +++ b/bots/quoter-bot/src/application/setup/setup-check.utils.ts @@ -297,28 +297,14 @@ const isTransientFailedCheck = (check: SetupCheck) => { ) } - if (check.name === 'books') { - return ( - Array.isArray(check.observed) && - check.observed.length > 0 && - check.observed.every( - book => - isRecord(book) && - Array.isArray(book.reasons) && - book.reasons.length > 0 && - book.reasons.every( - reason => - isRecord(reason) && - Object.keys(reason).length === 1 && - isTransientProviderFailure(reason.timestampProviderError) - ) - ) - ) - } - // Compound checks can mask a successful peer read that already proved invariant drift, so they // fail closed instead of retrying based only on their provider error. - if (check.name === 'ratifier' || check.name === 'reference' || check.name === 'offers') { + if ( + check.name === 'ratifier' || + check.name === 'reference' || + check.name === 'offers' || + check.name === 'books' + ) { return false } @@ -334,12 +320,7 @@ const isTransientFailedCheck = (check: SetupCheck) => { export const hasOnlyTransientProviderFailures = (report: SetupCheckReport) => !report.ready && report.checks.every(isTransientFailedCheck) -const bookProblems = ( - requestedId: `0x${string}`, - book: BookSetup, - config: SetupCheckConfig, - latestTimestamp?: bigint -) => { +const bookProblems = (requestedId: `0x${string}`, book: BookSetup, config: SetupCheckConfig) => { const reasons: unknown[] = [] if (book.id !== requestedId) reasons.push(`provider returned ${book.id}`) if (!book.allowlisted) reasons.push('not allowlisted') @@ -348,9 +329,6 @@ const bookProblems = ( reasons.push(`unexpected loan asset ${book.loanAsset}`) } if (book.tickSpacing <= 0) reasons.push('tick spacing is inaccessible') - if (latestTimestamp !== undefined && book.maturity <= latestTimestamp) { - reasons.push(`matured at ${book.maturity}`) - } return { id: requestedId, reasons } } @@ -402,13 +380,11 @@ export const chainCheck = ( /** * Evaluates all configured books from already-concurrent captured reads without writes. * @param config - Validated market requirements. - * @param timestamp - Captured latest block timestamp. * @param books - Captured per-market API/chain observations. * @returns The normalized aggregate books check. */ export const booksCheck = ( config: SetupCheckConfig, - timestamp: Captured, books: readonly { requestedId: `0x${string}`; response: Captured }[] ) => { const required = 'all configured books valid' @@ -423,13 +399,7 @@ export const booksCheck = ( const invalidBooks = books.flatMap(({ requestedId, response }) => { const problem = !response.ok ? { id: requestedId, reasons: [{ providerError: response.error }] as unknown[] } - : bookProblems( - requestedId, - response.value, - config, - timestamp.ok ? timestamp.value : undefined - ) - if (!timestamp.ok) problem.reasons.push({ timestampProviderError: timestamp.error }) + : bookProblems(requestedId, response.value, config) return problem.reasons.length === 0 ? [] : [problem] }) diff --git a/bots/quoter-bot/src/domain/bootstrap/position-bootstrap.ts b/bots/quoter-bot/src/domain/bootstrap/position-bootstrap.ts index d1416864..bea93860 100644 --- a/bots/quoter-bot/src/domain/bootstrap/position-bootstrap.ts +++ b/bots/quoter-bot/src/domain/bootstrap/position-bootstrap.ts @@ -25,12 +25,18 @@ export type BootstrapConfig = { autoRefill: boolean } -/** Fresh balance, credit, and exposure inputs used to cap a bootstrap offer. */ +/** + * Fresh balance, credit, and exposure inputs used to cap a bootstrap offer. + * @remarks `maturityTimestamp` and `observedTimestamp` come from the same read, so a workflow can + * recognize a market whose lifecycle has ended without consulting a wall clock. Sizing ignores both. + */ export type BootstrapPosition = { credit: bigint cashBalance: bigint marketExposure: bigint totalExposure: bigint + maturityTimestamp?: bigint + observedTimestamp?: bigint } /** Reference-rate observation and replacement semantics used for offer derivation. */ diff --git a/bots/quoter-bot/src/domain/ladder/ladder.ts b/bots/quoter-bot/src/domain/ladder/ladder.ts index be4c61e5..d523e2fd 100644 --- a/bots/quoter-bot/src/domain/ladder/ladder.ts +++ b/bots/quoter-bot/src/domain/ladder/ladder.ts @@ -56,7 +56,8 @@ export type LadderConfig = { * * The trailing fields are observation-only accounting primitives. Generation ignores them entirely; * they exist because the capacities above are saturating minima from which no position value can be - * reconstructed downstream. + * reconstructed downstream. `maturityTimestamp` and `observedTimestamp` come from the same read, so + * the workflow can recognize a matured market without owning a clock. */ export type LadderMarketState = { lowerRateCapacityAssets?: bigint @@ -70,6 +71,7 @@ export type LadderMarketState = { reservedAssets?: bigint marketReservedAssets?: bigint maturityTimestamp?: bigint + observedTimestamp?: bigint } /** One exact domain rung before protocol-specific tick and buy/sell conversion. */ 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 4ba920f9..bc0e3aeb 100644 --- a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts +++ b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts @@ -1,5 +1,7 @@ import type { Address, Hex } from 'viem' +import { TickLib } from '@morpho-org/midnight-sdk' +import { MathLib } from '@morpho-org/morpho-ts' import { bytesToHex, hexToBytes, isAddressEqual, isHex, size } from 'viem' import type { JsonRequest } from '../setup-state/http-json.utils' @@ -7,6 +9,7 @@ import type { JsonRequest } from '../setup-state/http-json.utils' import { requestJson } from '../setup-state/http-json.utils' import { BootstrapAdapterError } from './bootstrap-adapter.error' +const BPS_WAD = MathLib.WAD / 10_000n const PAGE_SIZE = 100 const MAX_OFFER_PAGES = 100 const MAX_OFFER_ITEMS = 100_000 @@ -243,6 +246,25 @@ export const strategyBootstrapGroups = ( ) } +/** + * Annualizes one indexed group's resting tick over the term it still has left. + * @param parameters - Group tick, group maturity, and the timestamp that maturity is compared + * against. + * @returns Simple APR in basis points, or `0n` once the group's market has matured. + * @remarks A matured group has no remaining term to annualize over, and `TickLib.tickToApr` rejects + * a zero or negative term. Projecting it as a zero rate keeps the group visible as ownership and + * cleanup evidence instead of failing the whole inventory read for every configured market. + */ +export const bootstrapGroupRateBps = (parameters: { + tick: bigint + maturity: bigint + observedTimestamp: bigint +}) => + parameters.maturity > parameters.observedTimestamp + ? TickLib.tickToApr(parameters.tick, parameters.maturity - parameters.observedTimestamp) / + BPS_WAD + : 0n + /** * Totals the unfilled cash reserve of every distinct explicitly owned buy group. * @param groups - Canonical maker groups, which may contain one projection per offer market. diff --git a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-position.service.ts b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-position.service.ts index d5fed7b8..98a4c66c 100644 --- a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-position.service.ts +++ b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-position.service.ts @@ -41,6 +41,10 @@ export interface BootstrapInventoryReader { readMarketContinuousFeeCap(marketId: Hex): Promise /** Reads bootstrap offers and independently owned buy-side reservations in one snapshot. @returns Current grouped inventory without treating reservations as replaceable bootstrap offers. */ readGroupInventory(): Promise + /** Reads one market's immutable maturity beside the timestamp it is compared against. @param marketId - Market whose lifecycle state is required. @returns Market maturity and the observation timestamp, so callers recognize a matured market without a clock. */ + readMarketMaturity( + marketId: Hex + ): Promise<{ maturityTimestamp: bigint; observedTimestamp: bigint }> } /** Concrete position adapter deriving exposure from accrued credit and active lend reserves. */ @@ -54,19 +58,22 @@ export class MidnightBootstrapPositionService implements BootstrapPositionServic /** * Reads one market position and aggregate strategy exposure. * @param marketId - Configured Midnight market identifier. - * @returns Fresh credit, debt, wallet capacity, exposure, representative active offer, and whether - * duplicate groups require reconciliation. + * @returns Fresh credit, debt, wallet capacity, exposure, market maturity beside the timestamp it + * is observed against, representative active offer, and whether duplicate groups require + * reconciliation. * @throws When chain/API inventory reads fail or the market is absent. * @remarks The maker address is retained only to bind this adapter instance to one operator. */ async readPosition(marketId: Hex) { void this.maker - const [positions, cashBalance, marketContinuousFeeCap, groupInventory] = await Promise.all([ - this.reader.readPositions(), - this.reader.readCashBalance(), - this.reader.readMarketContinuousFeeCap(marketId), - this.reader.readGroupInventory() - ]) + const [positions, cashBalance, marketContinuousFeeCap, groupInventory, maturity] = + await Promise.all([ + this.reader.readPositions(), + this.reader.readCashBalance(), + this.reader.readMarketContinuousFeeCap(marketId), + this.reader.readGroupInventory(), + this.reader.readMarketMaturity(marketId) + ]) const position = positions.find(item => item.marketId === marketId) if (!position) throw new BootstrapAdapterError('position-unavailable') const groups = groupInventory.activeGroups @@ -114,6 +121,7 @@ export class MidnightBootstrapPositionService implements BootstrapPositionServic : undefined return { + ...maturity, credit: position.credit, debt: position.debt, cashBalance: availableCash, diff --git a/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts b/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts index 29495f84..0dc37860 100644 --- a/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts +++ b/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts @@ -45,6 +45,7 @@ import { bootstrapExposureMarketIds } from './bootstrap-exposure.utils' import { createBootstrapGroupOwnership } from './bootstrap-group-ownership.utils' import { bootstrapBookOffers, + bootstrapGroupRateBps, bootstrapReservedLoanAssets, readBootstrapGroups, strategyBootstrapGroups @@ -322,11 +323,11 @@ export const createProductionBootstrapAdapters = ( continuousFeeCap: group.continuousFeeCap, rateBps: persisted?.rateBps ?? - TickLib.tickToApr( - group.tick as bigint, - (group.maturity as bigint) - block.timestamp - ) / - (WAD / 10_000n), + bootstrapGroupRateBps({ + tick: group.tick as bigint, + maturity: group.maturity as bigint, + observedTimestamp: block.timestamp + }), ...(persisted ? { referenceObservationId: persisted.referenceObservationId } : {}) } }), @@ -372,11 +373,11 @@ export const createProductionBootstrapAdapters = ( continuousFeeCap: group.continuousFeeCap, rateBps: persisted?.rateBps ?? - TickLib.tickToApr( - group.tick as bigint, - (group.maturity as bigint) - block.timestamp - ) / - (WAD / 10_000n), + bootstrapGroupRateBps({ + tick: group.tick as bigint, + maturity: group.maturity as bigint, + observedTimestamp: block.timestamp + }), ...(persisted ? { referenceObservationId: persisted.referenceObservationId } : {}) } }) @@ -437,6 +438,16 @@ export const createProductionBootstrapAdapters = ( }), readMarketContinuousFeeCap: async marketId => bootstrapContinuousFeeCap(await midnight.getMarketData(marketId)), + readMarketMaturity: async marketId => { + const [market, block] = await Promise.all([ + midnight.getMarketData(marketId), + client.getBlock({ blockTag: 'latest' }) + ]) + return { + maturityTimestamp: market.params.maturity, + observedTimestamp: block.timestamp + } + }, readGroupInventory } diff --git a/bots/quoter-bot/src/infrastructure/ladder/ladder-maturity.utils.ts b/bots/quoter-bot/src/infrastructure/ladder/ladder-maturity.utils.ts deleted file mode 100644 index e1f280fb..00000000 --- a/bots/quoter-bot/src/infrastructure/ladder/ladder-maturity.utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Hex } from 'viem' - -import type { BootstrapRawGroup } from '../bootstrap/bootstrap-groups.utils' - -/** - * Projects one market's maturity timestamp from maker groups already read this cycle. - * @param groups - Current maker groups returned by the Morpho API. - * @param marketId - Market whose maturity is wanted. - * @returns The market maturity, or `undefined` when no read group carries one for this market. - * @remarks Deliberately a projection rather than a market read: monitoring must not add an RPC - * round trip to the quoting path. A maker holding no indexed group in the market therefore reports - * no maturity, so downstream carry attribution must treat the field as optional. - */ -export const ladderMarketMaturity = (groups: readonly BootstrapRawGroup[], marketId: Hex) => { - for (const group of groups) { - if (group.marketId === marketId && group.maturity !== undefined) return group.maturity - for (const offer of group.offers) { - if (offer.marketId === marketId && offer.maturity !== undefined) return offer.maturity - } - } - return undefined -} diff --git a/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts b/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts index a22f9986..f9c0e8df 100644 --- a/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts +++ b/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts @@ -61,7 +61,6 @@ import { calculateLadderCapacities } from './ladder-capacity.utils' import { ladderCashReservations } from './ladder-cash-reservation.utils' import { createLadderGroupOwnership } from './ladder-group-ownership.utils' import { MidnightLadderMakeService, type LadderOfferTransport } from './ladder-make.service' -import { ladderMarketMaturity } from './ladder-maturity.utils' import { buildLadderTree } from './ladder-offer.utils' import { configuredRatifierType, prepareLadderRatification } from './ladder-ratification.utils' import { assertLadderProspectiveSpread } from './ladder-spread.utils' @@ -366,7 +365,8 @@ export const createProductionLadderAdapters = ( persistedBootstrapOffers, cashBalance, allowance, - positionSnapshots + positionSnapshots, + marketData ] = await Promise.all([ readGroups(), ladderOwnership.read(), @@ -398,7 +398,8 @@ export const createProductionLadderAdapters = ( credit: position.credit } }) - ) + ), + midnight.getMarketData(marketId) ]) const selectedPosition = positionSnapshots.find(item => item.marketId === marketId) if (!selectedPosition) throw new LadderAdapterError('position-unavailable') @@ -435,8 +436,6 @@ export const createProductionLadderAdapters = ( now: block.timestamp }) - const maturityTimestamp = ladderMarketMaturity(groups, marketId) - return { ...calculateProductionLadderCapacities({ marketId, @@ -449,7 +448,8 @@ export const createProductionLadderAdapters = ( reservations }), ...(bootstrapBuyRateBps === undefined ? {} : { bootstrapBuyRateBps }), - ...(maturityTimestamp === undefined ? {} : { maturityTimestamp }) + maturityTimestamp: marketData.params.maturity, + observedTimestamp: block.timestamp } } } diff --git a/bots/quoter-bot/src/infrastructure/setup-state/http-json.utils.ts b/bots/quoter-bot/src/infrastructure/setup-state/http-json.utils.ts index 73d9770d..3720aaa8 100644 --- a/bots/quoter-bot/src/infrastructure/setup-state/http-json.utils.ts +++ b/bots/quoter-bot/src/infrastructure/setup-state/http-json.utils.ts @@ -44,24 +44,3 @@ export const requestJson = async (url: string, provider: ProviderId, timeoutMs = }) } } - -/** - * Adapts the JSON transport for the legacy SDK books endpoint. - * @param request - Existing provider-safe JSON transport. - * @param timeoutMs - Explicit per-request timeout passed to the transport. - * @returns A fetch-compatible function backed by the provider-safe JSON transport. - * @throws The transport's sanitized provider error when the book request fails. - * @remarks The Midnight SDK continues to own endpoint construction and book mapping; this adapter - * only bridges the SDK fetch surface to the injected transport. Listing is verified separately - * against the documented markets endpoint because the books endpoint does not prove listing. - */ -export const booksJsonRequestFetch = (request: JsonRequest, timeoutMs: number): typeof fetch => { - const adapter = async (input: Parameters[0]) => { - const inputUrl = input instanceof Request ? input.url : String(input) - const value = await request(inputUrl, 'morpho-api', timeoutMs) - return Response.json(value) - } - // bun's `fetch` carried a `preconnect` property that had to be copied to satisfy `typeof fetch`; - // Node's `fetch` type has no such member, so the adapter alone is assignable. - return adapter -} diff --git a/bots/quoter-bot/src/infrastructure/setup-state/provider-read.error.ts b/bots/quoter-bot/src/infrastructure/setup-state/provider-read.error.ts index c0ca7fcd..e928914e 100644 --- a/bots/quoter-bot/src/infrastructure/setup-state/provider-read.error.ts +++ b/bots/quoter-bot/src/infrastructure/setup-state/provider-read.error.ts @@ -7,13 +7,11 @@ export type ProviderOperation = | 'contract-code' | 'native-balance' | 'loan-allowance' - | 'latest-timestamp' | 'ratifier-registry' | 'ratifier-code' | 'ratifier-midnight' | 'ratifier-root' | 'ratifier-authorization' - | 'book-api' | 'market-listing' | 'book-market' | 'book-tick-spacing' diff --git a/bots/quoter-bot/src/infrastructure/setup-state/viem-setup-state.service.ts b/bots/quoter-bot/src/infrastructure/setup-state/viem-setup-state.service.ts index 1659db1c..fbbf5f7b 100644 --- a/bots/quoter-bot/src/infrastructure/setup-state/viem-setup-state.service.ts +++ b/bots/quoter-bot/src/infrastructure/setup-state/viem-setup-state.service.ts @@ -1,7 +1,6 @@ import type { Address, Hex } from 'viem' import { ecrecoverRatifierAbi, midnightAbi, setterRatifierAbi } from '@morpho-org/midnight-sdk' -import { MidnightApi } from '@morpho-org/midnight-sdk/api' import { blueAbi } from '@morpho-org/morpho-sdk/abis' import { restructure } from '@morpho-org/morpho-sdk/utils' import { getChainAddress } from '@morpho-org/morpho-ts' @@ -13,16 +12,14 @@ import type { SupportedChainId } from '../../config/supported-chains.utils' import type { JsonRequest } from './http-json.utils' import { ratifierRuntimeHash, referenceLookbackBlocks } from '../../config/supported-chains.utils' -import { booksJsonRequestFetch } from './http-json.utils' import { ProviderPaginationError } from './provider-pagination.error' import { executeProviderRead } from './provider-read.utils' import { ProviderResponseError } from './provider-response.error' import { addressValue, + apiMarkets, DEFAULT_REQUEST_TIMEOUT_MS, invertedMarketIds, - listedMarketIds, - marketFromApi, marketFromContract, MAX_OFFER_ITEMS, MAX_OFFER_PAGES, @@ -311,12 +308,14 @@ export class ViemSetupStateService implements SetupStateService { /** * Cross-checks one Midnight market between the Morpho API and on-chain state. * @param id - Configured market ID. - * @returns Validated listing, activity, loan asset, tick spacing, and maturity. - * @throws `ProviderReadError` on a sanitized API/RPC rejection, or `ProviderResponseError` for - * missing/extra rows, malformed values, identity disagreement, or invalid chain data. - * @remarks Book API, explicit listing, market, and tick-spacing reads run concurrently through - * `Promise.all`; no writes. Listing is proven only by the canonical ID in the documented - * `listed=true` markets result set, never inferred from book availability. + * @returns Validated listing, activity, loan asset, and tick spacing. + * @throws `ProviderReadError` on a sanitized API/RPC rejection, or `ProviderResponseError` when + * the market registry omits the configured market, or for malformed values, identity + * disagreement, or invalid chain data. + * @remarks Market registry, market, and tick-spacing reads run concurrently through + * `Promise.all`; no writes. Facts come from the documented markets endpoint rather than the books + * endpoint, which excludes past maturities even when specific IDs are requested, so a configured + * market that reached maturity is still observable here. */ async getBook(id: Hex): Promise { const requestTimeoutMs = this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS @@ -324,19 +323,9 @@ export class ViemSetupStateService implements SetupStateService { const listingQuery = new URLSearchParams({ chain_ids: String(chainId), market_ids: id, - listed: 'true', limit: String(PAGE_SIZE) }) - const [apiResponse, listingResponse, contractResponse, tickSpacing] = await Promise.all([ - executeProviderRead('morpho-api', 'book-api', () => - MidnightApi.fetchBooks({ - baseUrl: `${this.options.morphoApiBaseUrl}/v0/midnight`, - chainIds: [chainId], - marketIds: [id], - limit: 1, - fetch: booksJsonRequestFetch(this.request, requestTimeoutMs) - }) - ), + const [listingResponse, contractResponse, tickSpacing] = await Promise.all([ executeProviderRead('morpho-api', 'market-listing', () => this.request( `${this.options.morphoApiBaseUrl}/v0/midnight/markets?${listingQuery.toString()}`, @@ -361,37 +350,15 @@ export class ViemSetupStateService implements SetupStateService { }) ) ]) - if (apiResponse.data.length !== 1) { + const apiMarket = apiMarkets(listingResponse, chainId).find(market => market.id === id) + if (apiMarket === undefined) { throw new ProviderResponseError( 'morpho-api', - 'book-count', - `Morpho API returned ${apiResponse.data.length} books for ${id}` + 'market-count', + `Morpho API returned no market for ${id}` ) } - const apiMarket = marketFromApi(apiResponse.data[0]) - const allowlisted = listedMarketIds(listingResponse, chainId).includes(id) const contractMarket = marketFromContract(contractResponse) - if (apiMarket.id !== id) { - throw new ProviderResponseError( - 'morpho-api', - 'book-identity', - `Morpho API returned ${apiMarket.id} for ${id}` - ) - } - if (apiMarket.chainId !== chainId) { - throw new ProviderResponseError( - 'morpho-api', - 'book-chain', - 'API market chain id is not the configured chain' - ) - } - if (!isAddressEqual(apiMarket.midnight, this.options.midnight)) { - throw new ProviderResponseError( - 'morpho-api', - 'book-midnight', - 'API market points at an unexpected Midnight contract' - ) - } if (contractMarket.chainId !== BigInt(chainId)) { throw new ProviderResponseError( 'rpc', @@ -429,28 +396,13 @@ export class ViemSetupStateService implements SetupStateService { } return { id, - allowlisted, + allowlisted: apiMarket.listed, active: true, loanAsset: contractMarket.loanToken, - tickSpacing, - maturity: contractMarket.maturity + tickSpacing } } - /** - * Reads the latest timestamp through the current-state provider. - * @returns Latest current-state block timestamp from a read-only RPC call. - * @throws `ProviderReadError` on a sanitized RPC rejection. - * @remarks Read-only; performs no writes. - */ - async getLatestTimestamp() { - return ( - await executeProviderRead('rpc', 'latest-timestamp', () => - this.chain.getBlock({ blockTag: 'latest' }) - ) - ).timestamp - } - /** * Proves the exact reference market is readable at a historical archive block. * @returns Reference-market identity plus current and archive readability flags. diff --git a/bots/quoter-bot/src/infrastructure/setup-state/viem-setup-state.utils.ts b/bots/quoter-bot/src/infrastructure/setup-state/viem-setup-state.utils.ts index 1cf1668c..ca775b6f 100644 --- a/bots/quoter-bot/src/infrastructure/setup-state/viem-setup-state.utils.ts +++ b/bots/quoter-bot/src/infrastructure/setup-state/viem-setup-state.utils.ts @@ -113,39 +113,31 @@ export const marketFromContract = (value: unknown) => { } /** - * Validates the SDK-mapped Midnight book projection used by setup checking. - * @param value - One `MidnightApi.fetchBooks` result item. - * @returns Canonical identity, chain, singleton, asset, and maturity fields. - * @throws When the SDK-trusted response contains malformed runtime values. - */ -export const marketFromApi = (value: unknown) => { - const market = objectValue(value, 'Midnight SDK book') - return { - id: bytes32Value(market.marketId, 'marketId'), - chainId: integerValue(market.chainId, 'chainId'), - midnight: addressValue(market.midnight, 'midnight'), - loanToken: addressValue(market.loanToken, 'loanToken'), - maturity: BigInt(integerValue(market.maturity, 'maturity')) - } -} - -/** - * Extracts canonical market IDs proven listed by the documented markets endpoint. - * @param value - Untrusted `listed=true` markets response. + * Extracts canonical market rows from the documented markets endpoint. + * @param value - Untrusted markets response. * @param chainId - Configured chain every accepted row must identify. - * @returns Canonical IDs whose rows explicitly identify the configured chain and `listed: true`. - * @throws When the response envelope or any listing identity field is malformed. + * @returns Identity, curated listing, loan asset, and maturity for every row identifying the + * configured chain. + * @throws When the response envelope or any market identity field is malformed. * @remarks Rows naming any other chain are dropped rather than rejected, so a provider that widens * its response to further chains cannot promote a foreign market into the configured chain's set. + * This endpoint is the market registry used instead of the books endpoint, which documents that it + * excludes past maturities even when specific IDs are requested. */ -export const listedMarketIds = (value: unknown, chainId: number) => { +export const apiMarkets = (value: unknown, chainId: number) => { const response = objectValue(value, 'Morpho API markets response') return arrayValue(response.data, 'Morpho API markets data').flatMap(item => { - const market = objectValue(item, 'Morpho API listed market') - const id = bytes32Value(market.market_id, 'listed market_id') - const rowChainId = integerValue(market.chain_id, 'listed market chain_id') - const listed = booleanValue(market.listed, 'listed market flag') - return rowChainId === chainId && listed ? [id] : [] + const market = objectValue(item, 'Morpho API market') + const rowChainId = integerValue(market.chain_id, 'market chain_id') + if (rowChainId !== chainId) return [] + return [ + { + id: bytes32Value(market.market_id, 'market_id'), + listed: booleanValue(market.listed, 'market listed flag'), + loanToken: addressValue(market.loan_token, 'market loan_token'), + maturity: BigInt(integerValue(market.maturity, 'market maturity')) + } + ] }) } diff --git a/bots/quoter-bot/test/application/bootstrap/position-bootstrap.service.test.ts b/bots/quoter-bot/test/application/bootstrap/position-bootstrap.service.test.ts index 6033733a..5a1ebf28 100644 --- a/bots/quoter-bot/test/application/bootstrap/position-bootstrap.service.test.ts +++ b/bots/quoter-bot/test/application/bootstrap/position-bootstrap.service.test.ts @@ -1497,4 +1497,60 @@ describe('PositionBootstrapService', () => { expect(await service.runOnce()).toEqual([{ marketId, status: 'applied', action: 'publish' }]) expect(reconcile).toHaveBeenCalledTimes(1) }) + test('observes a matured market as matured and keeps bootstrapping every other market', async () => { + const { service, positions, readRate, reconcile } = setup({ + configs: [config(), config(secondMarketId)] + }) + positions.readPosition = vi.fn(async id => ({ + credit: 0n, + debt: 0n, + cashBalance: 2_000n, + marketExposure: 0n, + totalExposure: 0n, + activeOffer: undefined, + ...(id === marketId + ? { maturityTimestamp: 1_000n, observedTimestamp: 1_000n } + : { maturityTimestamp: 2_000n, observedTimestamp: 1_000n }) + })) + + expect(await service.runOnce()).toEqual([ + { marketId, status: 'observed', action: 'matured' }, + { marketId: secondMarketId, status: 'applied', action: 'publish' } + ]) + expect(readRate).toHaveBeenCalledTimes(1) + expect(readRate).toHaveBeenCalledWith(secondMarketId) + expect(reconcile).toHaveBeenCalledTimes(1) + expect(reconcile).toHaveBeenCalledWith(expect.objectContaining({ marketId: secondMarketId })) + }) + + test('keeps monitoring later cycles when a configured market has matured', async () => { + const controller = new AbortController() + const { service, positions } = setup() + positions.readPosition = vi.fn(async () => ({ + credit: 0n, + debt: 0n, + cashBalance: 2_000n, + marketExposure: 0n, + totalExposure: 0n, + activeOffer: undefined, + maturityTimestamp: 1_000n, + observedTimestamp: 1_500n + })) + const cycles: Record[][] = [] + + const report = await service.runContinuously({ + signal: controller.signal, + intervalMs: 1, + onCycle: results => { + cycles.push([...results]) + if (cycles.length === 2) controller.abort() + } + }) + + expect(report).toMatchObject({ status: 'stopped', reason: 'signal', cycles: 2 }) + expect(cycles).toEqual([ + [{ marketId, status: 'observed', action: 'matured' }], + [{ marketId, status: 'observed', action: 'matured' }] + ]) + }) }) diff --git a/bots/quoter-bot/test/application/ladder/ladder-quoter.service.test.ts b/bots/quoter-bot/test/application/ladder/ladder-quoter.service.test.ts index 85b3f5c4..b465e7c4 100644 --- a/bots/quoter-bot/test/application/ladder/ladder-quoter.service.test.ts +++ b/bots/quoter-bot/test/application/ladder/ladder-quoter.service.test.ts @@ -51,6 +51,7 @@ const harness = (configs: readonly LadderConfig[] = [config()]) => { let maturitySeconds: bigint | undefined let marketState = state() let readFailure: Hex | undefined + const maturedMarkets = new Set() let reconcileFailure: Hex | undefined const reads: string[] = [] const reconciliations: Array<{ @@ -64,7 +65,9 @@ const harness = (configs: readonly LadderConfig[] = [config()]) => { async readMarket(id) { reads.push(`market:${id}`) if (id === readFailure) throw new TypeError('private provider detail') - return marketState + return maturedMarkets.has(id) + ? { ...marketState, maturityTimestamp: 1_000n, observedTimestamp: 1_000n } + : marketState } } const rates = { @@ -123,6 +126,7 @@ const harness = (configs: readonly LadderConfig[] = [config()]) => { setMaturity: (value: bigint | undefined) => (maturitySeconds = value), setCapacity: (value: bigint) => (marketState = state(value)), failMarket: (id: Hex) => (readFailure = id), + matureMarket: (id: Hex) => maturedMarkets.add(id), failReconcile: (id: Hex) => (reconcileFailure = id), expireRoots: (id: Hex) => liveDesired.delete(id), recreateService: () => (service = new LadderQuoterService(positions, rates, make, configs)) @@ -481,6 +485,59 @@ describe('LadderQuoterService', () => { expect(subject.halts).toEqual([]) }) + test('invalidates a matured market and keeps quoting every other configured market', async () => { + const subject = harness([config(), config(secondMarketId)]) + expect(await subject.service.runOnce()).toMatchObject([ + { marketId, action: 'publish' }, + { marketId: secondMarketId, action: 'publish' } + ]) + + subject.matureMarket(marketId) + + expect(await subject.service.runOnce()).toMatchObject([ + { marketId, status: 'observed', action: 'matured' }, + { marketId: secondMarketId, status: 'observed', action: 'rest' } + ]) + expect(subject.reconciliations.at(-2)).toMatchObject({ + marketId, + desired: undefined, + reason: 'market-matured' + }) + expect(subject.reads).not.toContain(`rate:${marketId}`) + expect(subject.liveDesired.has(marketId)).toBe(false) + expect(subject.liveDesired.has(secondMarketId)).toBe(true) + expect(subject.halts).toEqual([]) + }) + + test('keeps monitoring later cycles when a configured market has matured', async () => { + const subject = harness([{ ...config(), loopIntervalSeconds: 1 }]) + subject.matureMarket(marketId) + const controller = new AbortController() + const cycles: unknown[] = [] + + const report = await subject.service.runContinuously({ + signal: controller.signal, + intervalMs: 1, + onCycle: results => { + cycles.push(results) + if (cycles.length === 2) controller.abort() + } + }) + + expect(report).toMatchObject({ status: 'stopped', reason: 'signal', cycles: 2 }) + expect(cycles).toMatchObject([[{ action: 'matured' }], [{ action: 'matured' }]]) + }) + + test('reports a failed reconciliation while invalidating a matured market', async () => { + const subject = harness() + subject.matureMarket(marketId) + subject.failReconcile(marketId) + + expect(await subject.service.runOnce()).toMatchObject([ + { marketId, status: 'failed', stage: 'reconcile', invalidated: false } + ]) + }) + test('retains a confirmed ratification hash when publication later fails', async () => { const subject = harness() subject.make.reconcile = vi.fn(async () => { 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 591aa9db..02d55d2c 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 @@ -56,10 +56,8 @@ const readyState = (): SetupStateService => { allowlisted: true, active: true, loanAsset, - tickSpacing: 1, - maturity: 2_000n + tickSpacing: 1 }), - getLatestTimestamp: async () => 1_000n, checkReference: async () => ({ marketId: referenceMarketId, referenceReadable: true, @@ -163,40 +161,22 @@ describe('SetupCheckService', () => { expect(terminal).toEqual({ status: 'stopped', reason: 'signal', cycles: 1 }) }) - test('retries a transient latest-timestamp failure when every book invariant passes', async () => { - const state = readyState() - let bookReads = 0 - let timestampReads = 0 - state.getBook = async id => { - bookReads += 1 - return { - id, - allowlisted: true, - active: true, - loanAsset, - tickSpacing: 4, - maturity: 2_000n - } - } - state.getLatestTimestamp = async () => { - timestampReads += 1 - if (timestampReads === 1) { - throw new SafeProviderError({ - kind: 'provider-error', - provider: 'rpc', - name: 'TimeoutError', - code: 'REQUEST_TIMEOUT', - context: 'request' - }) + test('reports readiness for configured markets without reading a block timestamp', async () => { + // Regression: readiness derived maturity from a latest-block timestamp read, so an unrelated + // RPC timestamp outage failed every configured market even though maturity is no longer a + // readiness invariant. + const reads: string[] = [] + const state = new Proxy(readyState(), { + get: (target, key) => { + reads.push(String(key)) + return Reflect.get(target, key) } - return 1_000n - } - - await expect(new SetupCheckService(state, config).assertReady()).resolves.toMatchObject({ - ready: true }) - expect(timestampReads).toBe(2) - expect(bookReads).toBe(2) + + const report = await new SetupCheckService(state, config).check() + + expect(report.ready).toBe(true) + expect(reads).not.toContain('getLatestTimestamp') }) test('fails closed without retrying a transient compound book read', async () => { @@ -232,7 +212,7 @@ describe('SetupCheckService', () => { let bookReads = 0 state.getBook = async () => { bookReads += 1 - throw new ProviderReadError('morpho-api', 'book-api') + throw new ProviderReadError('morpho-api', 'market-listing') } const terminal = await new SetupCheckService(state, config).runContinuously({ @@ -349,67 +329,9 @@ describe('SetupCheckService', () => { expect(error).toBeInstanceOf(SetupFailedError) }) - test('does not retry a timestamp timeout that accompanies a book invariant failure', async () => { + test('reports only the sanitized book provider failure when a market read fails', async () => { const state = readyState() - let bookReads = 0 - let timestampReads = 0 - state.getBook = async id => { - bookReads += 1 - return { - id, - allowlisted: true, - active: false, - loanAsset, - tickSpacing: 1, - maturity: 2_000n - } - } - state.getLatestTimestamp = async () => { - timestampReads += 1 - throw new SafeProviderError({ - kind: 'provider-error', - provider: 'rpc', - name: 'TimeoutError', - code: 'REQUEST_TIMEOUT', - context: 'request' - }) - } - - const terminal = await new SetupCheckService(state, config).runContinuously({ - signal: new AbortController().signal, - intervalMs: 1 - }) - - expect(bookReads).toBe(1) - expect(timestampReads).toBe(1) - expect(terminal).toMatchObject({ status: 'halted', reason: 'setup-failed', cycles: 1 }) - if (terminal.reason === 'setup-failed') { - expect(terminal.lastReport.checks.find(check => check.name === 'books')?.observed).toEqual([ - { - id: marketId, - reasons: [ - 'inactive', - { - timestampProviderError: { - kind: 'provider-error', - provider: 'rpc', - name: 'TimeoutError', - code: 'REQUEST_TIMEOUT', - context: 'request' - } - } - ] - } - ]) - } - }) - - test('does not retry transient book outages when the timestamp failure is unknown', async () => { - const state = readyState() - let bookReads = 0 - let timestampReads = 0 state.getBook = async () => { - bookReads += 1 throw new SafeProviderError({ kind: 'provider-error', provider: 'morpho-api', @@ -418,18 +340,12 @@ describe('SetupCheckService', () => { context: 'request' }) } - state.getLatestTimestamp = async () => { - timestampReads += 1 - throw new ProviderReadError('rpc', 'latest-timestamp') - } const terminal = await new SetupCheckService(state, config).runContinuously({ signal: new AbortController().signal, intervalMs: 1 }) - expect(bookReads).toBe(1) - expect(timestampReads).toBe(1) expect(terminal).toMatchObject({ status: 'halted', reason: 'setup-failed', cycles: 1 }) if (terminal.reason === 'setup-failed') { expect(terminal.lastReport.checks.find(check => check.name === 'books')?.observed).toEqual([ @@ -441,12 +357,6 @@ describe('SetupCheckService', () => { provider: 'morpho-api', name: 'TimeoutError' }) - }, - { - timestampProviderError: expect.objectContaining({ - provider: 'rpc', - name: 'ProviderError' - }) } ] } @@ -766,7 +676,7 @@ describe('SetupCheckService', () => { test('preserves a typed provider id when a compound setup read fails', async () => { const state = readyState() state.getBook = async () => { - throw new ProviderReadError('morpho-api', 'book-api') + throw new ProviderReadError('morpho-api', 'market-listing') } const report = await new SetupCheckService(state, config).check() @@ -790,7 +700,7 @@ describe('SetupCheckService', () => { test('preserves a typed response-error provider id in the setup report', async () => { const state = readyState() state.getBook = async () => { - throw new ProviderResponseError('morpho-api', 'book-count', 'invalid count') + throw new ProviderResponseError('morpho-api', 'market-count', 'invalid count') } const report = await new SetupCheckService(state, config).check() @@ -949,15 +859,13 @@ describe('SetupCheckService', () => { surfaceMatches: true, authorized: true }) - state.getLatestTimestamp = () => wait('timestamp', 1_000n) state.getBook = id => wait(`book:${id}`, { id, allowlisted: true, active: true, loanAsset, - tickSpacing: 1, - maturity: 2_000n + tickSpacing: 1 }) state.checkReference = async () => { started.push('reference') @@ -980,7 +888,6 @@ describe('SetupCheckService', () => { 'balance', 'allowance', 'ratifier', - 'timestamp', `book:${marketId}`, 'reference', 'offers', @@ -1005,7 +912,6 @@ describe('SetupCheckService', () => { state.getNativeBalance = unavailable state.getLoanAllowance = unavailable state.getRatifier = unavailable - state.getLatestTimestamp = unavailable state.getBook = unavailable state.checkReference = unavailable state.inspectOffers = unavailable @@ -1040,8 +946,7 @@ describe('SetupCheckService', () => { allowlisted: true, active: true, loanAsset, - tickSpacing: 1, - maturity: 2_000n + tickSpacing: 1 }) const report = await new SetupCheckService(state, config).check() @@ -1080,7 +985,7 @@ describe('SetupCheckService', () => { }) }) - test('accepts exact funding thresholds and rejects maturity at the current timestamp', async () => { + test('accepts exact funding thresholds', async () => { const exactThresholds = await new SetupCheckService(readyState(), config).check() expect(exactThresholds.checks.find(check => check.name === 'native-balance')?.status).toBe( 'passed' @@ -1088,21 +993,6 @@ describe('SetupCheckService', () => { expect(exactThresholds.checks.find(check => check.name === 'loan-allowance')?.status).toBe( 'passed' ) - - const state = readyState() - state.getBook = async id => ({ - id, - allowlisted: true, - active: true, - loanAsset, - tickSpacing: 1, - maturity: 1_000n - }) - const maturityBoundary = await new SetupCheckService(state, config).check() - - expect(maturityBoundary.checks.find(check => check.name === 'books')?.observed).toEqual([ - { id: marketId, reasons: ['matured at 1000'] } - ]) }) test('reports every unsafe book property so the operator can remediate it', async () => { @@ -1112,8 +1002,7 @@ describe('SetupCheckService', () => { allowlisted: false, active: false, loanAsset: ratifier, - tickSpacing: 0, - maturity: 1_000n + tickSpacing: 0 }) const report = await new SetupCheckService(state, config).check() @@ -1128,8 +1017,7 @@ describe('SetupCheckService', () => { 'not allowlisted', 'inactive', `unexpected loan asset ${ratifier}`, - 'tick spacing is inaccessible', - 'matured at 1000' + 'tick spacing is inaccessible' ] } ], @@ -1179,8 +1067,7 @@ describe('SetupCheckService', () => { allowlisted: false, active: false, loanAsset: ratifier, - tickSpacing: 0, - maturity: 1_000n + tickSpacing: 0 }) state.checkReference = async () => ({ marketId: referenceMarketId, diff --git a/bots/quoter-bot/test/bootstrap.test.ts b/bots/quoter-bot/test/bootstrap.test.ts index be5262a4..50180cc2 100644 --- a/bots/quoter-bot/test/bootstrap.test.ts +++ b/bots/quoter-bot/test/bootstrap.test.ts @@ -88,10 +88,8 @@ const readyState = (): SetupStateService => { allowlisted: true, active: true, loanAsset, - tickSpacing: 4, - maturity: 2_000n + tickSpacing: 4 }), - getLatestTimestamp: async () => 1_000n, checkReference: async () => ({ marketId: referenceMarketId, referenceReadable: true, diff --git a/bots/quoter-bot/test/e2e/router-api.ts b/bots/quoter-bot/test/e2e/router-api.ts index fe0c15a0..2e3e4316 100644 --- a/bots/quoter-bot/test/e2e/router-api.ts +++ b/bots/quoter-bot/test/e2e/router-api.ts @@ -185,7 +185,15 @@ export const startRouterApi = async (rpcUrl: string): Promise = if (pathname === '/v0/midnight/markets') { return json({ cursor: null, - data: [{ chain_id: MARKET.chainId, market_id: MARKET_ID, listed: true }] + data: [ + { + chain_id: MARKET.chainId, + market_id: MARKET_ID, + listed: true, + loan_token: MARKET.loanToken, + maturity: MARKET.maturity + } + ] }) } diff --git a/bots/quoter-bot/test/e2e/setup-api.ts b/bots/quoter-bot/test/e2e/setup-api.ts index 424404ec..1c53704b 100644 --- a/bots/quoter-bot/test/e2e/setup-api.ts +++ b/bots/quoter-bot/test/e2e/setup-api.ts @@ -12,35 +12,21 @@ let mode: SetupApiMode = 'ready' const route = (request: Request) => { const { pathname } = new URL(request.url) - if (pathname === '/v0/midnight/books') { + if (pathname === '/v0/midnight/markets') { return json({ cursor: null, data: [ { - market_id: MARKET_ID, - id: MARKET_ID, chain_id: MARKET.chainId, - midnight: MARKET.midnight, + market_id: MARKET_ID, + listed: mode !== 'books-failed', loan_token: MARKET.loanToken, - collaterals: MARKET.collaterals, - maturity: MARKET.maturity, - rcf_threshold: MARKET.rcfThreshold, - enter_gate: MARKET.enterGate, - liquidator_gate: MARKET.liquidatorGate, - asks: [], - bids: [] + maturity: MARKET.maturity } ] }) } - if (pathname === '/v0/midnight/markets') { - return json({ - cursor: null, - data: [{ chain_id: MARKET.chainId, market_id: MARKET_ID, listed: mode !== 'books-failed' }] - }) - } - if (pathname.startsWith('/v0/midnight/users/') && pathname.endsWith('/offer-groups')) { return json({ cursor: null, 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 22b1effe..b535d56c 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 @@ -16,6 +16,7 @@ describe('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: [{ id: firstGroup, marketId, assets: 100n, rateBps: 500n }], cashReservations: [] @@ -40,6 +41,7 @@ describe('MidnightBootstrapPositionService', () => { ], readCashBalance: async () => 100n, readMarketContinuousFeeCap: async () => 17n, + readMarketMaturity: async () => ({ maturityTimestamp: 2_000n, observedTimestamp: 1_000n }), readGroupInventory: async () => ({ activeGroups: [ { id: firstGroup, marketId, assets: 20n, rateBps: 500n }, @@ -68,6 +70,7 @@ describe('MidnightBootstrapPositionService', () => { ], readCashBalance: async () => 100n, readMarketContinuousFeeCap: async () => 17n, + readMarketMaturity: async () => ({ maturityTimestamp: 2_000n, observedTimestamp: 1_000n }), readGroupInventory: async () => ({ activeGroups: [ { id: firstGroup, marketId, assets: 20n, rateBps: 500n }, @@ -96,6 +99,7 @@ describe('MidnightBootstrapPositionService', () => { ], readCashBalance: async () => 100n, readMarketContinuousFeeCap: async () => 17n, + readMarketMaturity: async () => ({ maturityTimestamp: 2_000n, observedTimestamp: 1_000n }), readGroupInventory: async () => ({ activeGroups: [ { id: firstGroup, marketId, assets: 100n, rateBps: 500n }, @@ -122,6 +126,7 @@ describe('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: [ { @@ -152,6 +157,7 @@ describe('MidnightBootstrapPositionService', () => { readPositions: async () => [{ marketId, credit: 10n, debt: 0n }], readCashBalance: async () => 100n, readMarketContinuousFeeCap: async () => 17n, + readMarketMaturity: async () => ({ maturityTimestamp: 2_000n, observedTimestamp: 1_000n }), readGroupInventory: async () => ({ activeGroups: [ { id: firstGroup, marketId, assets: 20n, rateBps: 500n }, @@ -181,6 +187,7 @@ describe('MidnightBootstrapPositionService', () => { readPositions: async () => [{ marketId, credit: 10n, debt: 0n }], readCashBalance: async () => 100n, readMarketContinuousFeeCap: async () => 17n, + readMarketMaturity: async () => ({ maturityTimestamp: 2_000n, observedTimestamp: 1_000n }), readGroupInventory: async () => ({ activeGroups: [{ id: firstGroup, marketId, assets: 20n, rateBps: 500n, offerCount: 2 }], cashReservations: [] @@ -198,6 +205,7 @@ describe('MidnightBootstrapPositionService', () => { readPositions: async () => [{ marketId, credit: 10n, debt: 0n }], readCashBalance: async () => 100n, readMarketContinuousFeeCap: async () => 17n, + readMarketMaturity: async () => ({ maturityTimestamp: 2_000n, observedTimestamp: 1_000n }), readGroupInventory: async () => ({ activeGroups: [{ id: firstGroup, marketId, assets: 20n, rateBps: 500n, offerCount: 1 }], cashReservations: [] @@ -215,6 +223,7 @@ describe('MidnightBootstrapPositionService', () => { readPositions: async () => [{ marketId, credit: 10n, debt: 0n }], readCashBalance: async () => 100n, readMarketContinuousFeeCap: async () => 17n, + readMarketMaturity: async () => ({ maturityTimestamp: 2_000n, observedTimestamp: 1_000n }), readGroupInventory: async () => ({ activeGroups: [ { @@ -241,6 +250,7 @@ describe('MidnightBootstrapPositionService', () => { readPositions: async () => [{ marketId, credit: 10n, debt: 0n }], readCashBalance: async () => 100n, readMarketContinuousFeeCap: async () => 17n, + readMarketMaturity: async () => ({ maturityTimestamp: 2_000n, observedTimestamp: 1_000n }), readGroupInventory: async () => ({ activeGroups: [ { @@ -271,6 +281,7 @@ describe('MidnightBootstrapPositionService', () => { ], readCashBalance: async () => 200n, readMarketContinuousFeeCap: async () => 17n, + readMarketMaturity: async () => ({ maturityTimestamp: 2_000n, observedTimestamp: 1_000n }), readGroupInventory: async () => ({ activeGroups: [], cashReservations: [ @@ -289,4 +300,21 @@ describe('MidnightBootstrapPositionService', () => { expect(position.marketExposure).toBe(80n) expect(position.totalExposure).toBe(125n) }) + test('propagates market maturity beside the timestamp it was observed against', async () => { + const service = new MidnightBootstrapPositionService( + { + readPositions: async () => [{ marketId, credit: 0n, debt: 0n }], + readCashBalance: async () => 100n, + readMarketContinuousFeeCap: async () => 17n, + readMarketMaturity: async () => ({ maturityTimestamp: 1_000n, observedTimestamp: 1_200n }), + readGroupInventory: async () => ({ activeGroups: [], cashReservations: [] }) + }, + maker + ) + + const position = await service.readPosition(marketId) + + expect(position.maturityTimestamp).toBe(1_000n) + expect(position.observedTimestamp).toBe(1_200n) + }) }) 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 ff03fb5c..4c652593 100644 --- a/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts +++ b/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts @@ -6,6 +6,7 @@ import { Offer, Payload, SetterRatifierUtils, + TickLib, Tree, type IMarketParams, setterRatifierAbi @@ -23,6 +24,7 @@ import { bootstrapExposureMarketIds } from '../../../src/infrastructure/bootstra import { createBootstrapGroupOwnership } from '../../../src/infrastructure/bootstrap/bootstrap-group-ownership.utils' import { bootstrapBookOffers, + bootstrapGroupRateBps, bootstrapReservedLoanAssets, readBootstrapGroups, strategyBootstrapGroups @@ -590,6 +592,22 @@ describe('assertBootstrapTransaction', () => { }) }) +describe('bootstrapGroupRateBps', () => { + test('annualizes the resting tick of a group whose market still has a remaining term', () => { + expect(bootstrapGroupRateBps({ tick: 100n, maturity: 2_000n, observedTimestamp: 1_000n })).toBe( + TickLib.tickToApr(100n, 1_000n) / (10n ** 18n / 10_000n) + ) + }) + + test.each([ + ['matured', 1_000n], + ['maturing exactly at the observed block', 2_000n] + ])('projects a %s indexed group without persisted intent as a zero rate', (_label, maturity) => { + expect(() => TickLib.tickToApr(100n, maturity - 2_000n)).toThrow() + expect(bootstrapGroupRateBps({ tick: 100n, maturity, observedTimestamp: 2_000n })).toBe(0n) + }) +}) + describe('bootstrapContinuousFeeCap', () => { test('uses the authoritative live market continuous fee', () => { expect(bootstrapContinuousFeeCap({ continuousFee: 17 })).toBe(17n) diff --git a/bots/quoter-bot/test/infrastructure/ladder/ladder-maturity.utils.test.ts b/bots/quoter-bot/test/infrastructure/ladder/ladder-maturity.utils.test.ts deleted file mode 100644 index fbc87b22..00000000 --- a/bots/quoter-bot/test/infrastructure/ladder/ladder-maturity.utils.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Hex } from 'viem' - -import { describe, expect, test } from 'vitest' - -import type { BootstrapRawGroup } from '../../../src/infrastructure/bootstrap/bootstrap-groups.utils' - -import { ladderMarketMaturity } from '../../../src/infrastructure/ladder/ladder-maturity.utils' - -const marketId: Hex = `0x${'11'.repeat(32)}` -const otherMarketId: Hex = `0x${'22'.repeat(32)}` -const groupId: Hex = `0x${'33'.repeat(32)}` -const maker = '0x4444444444444444444444444444444444444444' as const -const maturity = 1_800_000_000n - -const group = (overrides: Partial = {}): BootstrapRawGroup => ({ - id: groupId, - consumed: 0n, - maxAssets: 100n, - offers: [], - ...overrides -}) - -describe('ladderMarketMaturity', () => { - test('reads the maturity carried by the group itself', () => { - expect(ladderMarketMaturity([group({ marketId, maturity })], marketId)).toBe(maturity) - }) - - test('falls back to a nested offer of the same market', () => { - expect( - ladderMarketMaturity( - [ - group({ marketId: otherMarketId, maturity: 1n }), - group({ offers: [{ marketId, maker, buy: true, tick: 5n, maturity }] }) - ], - marketId - ) - ).toBe(maturity) - }) - - test('reports no maturity for a market the maker holds no group in', () => { - expect( - ladderMarketMaturity( - [ - group({ - marketId: otherMarketId, - maturity, - offers: [{ marketId: otherMarketId, maker, buy: true, tick: 5n, maturity }] - }) - ], - marketId - ) - ).toBeUndefined() - }) - - test('reports no maturity when neither the group nor its offers carry one', () => { - expect( - ladderMarketMaturity( - [group({ marketId, offers: [{ marketId, maker, buy: true, tick: 5n }] })], - marketId - ) - ).toBeUndefined() - }) -}) diff --git a/bots/quoter-bot/test/infrastructure/setup-state/viem-setup-state.service.test.ts b/bots/quoter-bot/test/infrastructure/setup-state/viem-setup-state.service.test.ts index 7b64bdfa..174ead27 100644 --- a/bots/quoter-bot/test/infrastructure/setup-state/viem-setup-state.service.test.ts +++ b/bots/quoter-bot/test/infrastructure/setup-state/viem-setup-state.service.test.ts @@ -181,7 +181,15 @@ const createState = ( if (url.includes('/v0/midnight/markets')) { return { cursor: null, - data: [{ market_id: marketId, chain_id: 8453, listed: true }] + data: [ + { + market_id: marketId, + chain_id: 8453, + listed: true, + loan_token: loanAsset, + maturity: 2_000 + } + ] } } throw new Error(`unexpected URL ${url}`) @@ -250,13 +258,6 @@ describe('ViemSetupStateService', () => { 'loan-allowance', (state: ViemSetupStateService) => state.getLoanAllowance(maker, loanAsset) ], - [ - 'getLatestTimestamp', - 'latest', - 'rpc', - 'latest-timestamp', - (state: ViemSetupStateService) => state.getLatestTimestamp() - ], [ 'getRatifier compound reads', 'other-contract', @@ -264,13 +265,6 @@ describe('ViemSetupStateService', () => { 'ratifier-authorization', (state: ViemSetupStateService) => state.getRatifier(maker, ratifier) ], - [ - 'getBook compound reads', - 'request', - 'morpho-api', - 'book-api', - (state: ViemSetupStateService) => state.getBook(marketId) - ], [ 'getBook listing read', 'market-listing', @@ -503,93 +497,62 @@ describe('ViemSetupStateService', () => { } }) - test('reads a configured book from API allowlist and on-chain state concurrently', async () => { - const { state } = createState({ - '/v0/midnight/books': { - cursor: null, - data: [ - { - market_id: marketId, - chain_id: 8453, - midnight, - loan_token: loanAsset, - maturity: 2_000, - collaterals: [], - rcf_threshold: '0', - enter_gate: maker, - liquidator_gate: maker, - listed: true, - asks: [], - bids: [] - } - ] - } - }) + test('reads a configured book from the market registry and on-chain state concurrently', async () => { + const { state, calls } = createState({}) expect(await state.getBook(marketId)).toEqual({ id: marketId, allowlisted: true, active: true, loanAsset, - tickSpacing: 4, - maturity: 2_000n + tickSpacing: 4 }) + expect(calls).toContain( + `https://api.example/v0/midnight/markets?chain_ids=8453&market_ids=${marketId}&limit=100` + ) + expect(calls.some(call => call.includes('/v0/midnight/books'))).toBe(false) }) - test('verifies the canonical market in the documented listed market result set', async () => { - const { state, calls } = createState({ - '/v0/midnight/books': { + test('reads a configured market that has already reached maturity', async () => { + // Regression: setup facts came from the books endpoint, which documents that it excludes past + // maturities even when specific IDs are requested, so every read of a matured configured + // market failed the whole readiness gate. + const { state } = createState({ + '/v0/midnight/markets': { cursor: null, data: [ { market_id: marketId, chain_id: 8453, - midnight, + listed: true, loan_token: loanAsset, - maturity: 2_000, - collaterals: [], - rcf_threshold: '0', - enter_gate: maker, - liquidator_gate: maker, - asks: [], - bids: [] + maturity: 2_000 } ] - } + }, + '/v0/midnight/books': { cursor: null, data: [] } }) - await expect(state.getBook(marketId)).resolves.toMatchObject({ allowlisted: true }) - expect(calls).toContain( - `https://api.example/v0/midnight/markets?chain_ids=8453&market_ids=${marketId}&listed=true&limit=100` - ) - expect( - calls - .filter(call => call.includes('/v0/midnight/books')) - .every(call => !call.includes('listed=')) - ).toBe(true) + expect(await state.getBook(marketId)).toEqual({ + id: marketId, + allowlisted: true, + active: true, + loanAsset, + tickSpacing: 4 + }) }) test('reports an explicitly unlisted canonical market as not allowlisted', async () => { const { state } = createState({ '/v0/midnight/markets': { - cursor: null, - data: [{ market_id: marketId, chain_id: 8453, listed: false }] - }, - '/v0/midnight/books': { cursor: null, data: [ { market_id: marketId, chain_id: 8453, - midnight, + listed: false, loan_token: loanAsset, - maturity: 2_000, - collaterals: [], - rcf_threshold: '0', - enter_gate: maker, - liquidator_gate: maker, - asks: [], - bids: [] + maturity: 2_000 } ] } @@ -598,33 +561,26 @@ describe('ViemSetupStateService', () => { await expect(state.getBook(marketId)).resolves.toMatchObject({ allowlisted: false }) }) - test('does not trust a listed filter when the result omits the requested canonical market', async () => { + test('rejects a registry result that omits the requested canonical market', async () => { const { state } = createState({ '/v0/midnight/markets': { - cursor: null, - data: [{ market_id: removedMarketId, chain_id: 8453, listed: true }] - }, - '/v0/midnight/books': { cursor: null, data: [ { - market_id: marketId, + market_id: removedMarketId, chain_id: 8453, - midnight, + listed: true, loan_token: loanAsset, - maturity: 2_000, - collaterals: [], - rcf_threshold: '0', - enter_gate: maker, - liquidator_gate: maker, - asks: [], - bids: [] + maturity: 2_000 } ] } }) - await expect(state.getBook(marketId)).resolves.toMatchObject({ allowlisted: false }) + await expect(state.getBook(marketId)).rejects.toMatchObject({ + name: 'ProviderResponseError', + operation: 'market-count' + }) }) test('accepts only the SDK-canonical Ecrecover ratifier without calling a registry endpoint', async () => {