Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion bots/quoter-bot/scripts/check-jsdoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ const providerMethods = new Set([
'ViemSetupStateService.getLoanAllowance',
'ViemSetupStateService.getRatifier',
'ViemSetupStateService.getBook',
'ViemSetupStateService.getLatestTimestamp',
'ViemSetupStateService.checkReference',
'ViemSetupStateService.inspectOffers'
])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand Down
82 changes: 79 additions & 3 deletions bots/quoter-bot/src/application/ladder/ladder-quoter.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<LadderMakeResult>
/**
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -663,6 +675,70 @@ export class LadderQuoterService {
return seconds * 1_000
}

private async settleMaturedMarket(
config: LadderConfig,
currentState: LadderVerboseState,
parameters: LadderRunParameters,
startedAt: number
): Promise<LadderRunResult> {
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,
Expand Down
2 changes: 1 addition & 1 deletion bots/quoter-bot/src/application/ladder/ladder-verbose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
15 changes: 15 additions & 0 deletions bots/quoter-bot/src/application/market-maturity.utils.ts
Original file line number Diff line number Diff line change
@@ -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
8 changes: 1 addition & 7 deletions bots/quoter-bot/src/application/setup/setup-check.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,6 @@ export type BookSetup = {
loanAsset: Address
/** On-chain tick spacing; must be positive. */
tickSpacing: number
/** On-chain maturity timestamp. */
maturity: bigint
}

/**
Expand Down Expand Up @@ -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<BookSetup>
/** Reads the latest connected-chain block timestamp. @returns Timestamp of the latest connected-chain block. */
getLatestTimestamp(): Promise<bigint>
/** Checks historical reference-market readability. @returns Archive-readability and exact reference-market identity facts. */
checkReference(): Promise<{ marketId: Hex; referenceReadable: boolean; archiveReadable: boolean }>
/**
Expand Down Expand Up @@ -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'),
Expand All @@ -330,7 +325,6 @@ export class SetupCheckService {
nativeBalance,
allowance,
ratifier,
latestTimestamp,
books,
reference,
offers,
Expand Down Expand Up @@ -463,7 +457,7 @@ export class SetupCheckService {
nativeCheck,
allowanceCheck,
ratifierCheck,
booksCheck(this.config, latestTimestamp, books),
booksCheck(this.config, books),
referenceCheck,
offersCheck,
positionCheck
Expand Down
46 changes: 8 additions & 38 deletions bots/quoter-bot/src/application/setup/setup-check.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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) => {
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
const reasons: unknown[] = []
if (book.id !== requestedId) reasons.push(`provider returned ${book.id}`)
if (!book.allowlisted) reasons.push('not allowlisted')
Expand All @@ -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}`)
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
return { id: requestedId, reasons }
}

Expand Down Expand Up @@ -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<bigint>,
books: readonly { requestedId: `0x${string}`; response: Captured<BookSetup> }[]
) => {
const required = 'all configured books valid'
Expand All @@ -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)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

return problem.reasons.length === 0 ? [] : [problem]
})
Expand Down
8 changes: 7 additions & 1 deletion bots/quoter-bot/src/domain/bootstrap/position-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
4 changes: 3 additions & 1 deletion bots/quoter-bot/src/domain/ladder/ladder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand Down
Loading
Loading