From 300f66c327d733867beb2ebe14251d2a42749352 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:49:01 +0000 Subject: [PATCH] fix(quoter-bot): handle bootstrap ladder overlap Rebase the bootstrap overlap fix onto current main while preserving the latest cleanup fail-safes. Squashed into one GitHub-App-signed commit for conflict resolution. --- .../bootstrap/position-bootstrap.service.ts | 56 ++- .../quoter-bot/quoter-bot-mutation.utils.ts | 10 +- bots/quoter-bot/src/bootstrap.ts | 5 + .../bootstrap/bootstrap-groups.utils.ts | 24 +- .../bootstrap/bootstrap-make.service.ts | 150 +++++-- .../bootstrap/bootstrap-offer.utils.ts | 10 +- .../bootstrap/bootstrap-overlap.utils.ts | 160 ++++++++ .../bootstrap/production-bootstrap.ts | 166 +++++++- .../intentional-overlap.utils.ts | 48 +++ .../ladder/ladder-make.service.ts | 3 +- .../ladder/ladder-offer.utils.ts | 14 +- .../ladder/ladder-spread.utils.ts | 16 +- .../ladder/production-ladder.ts | 40 +- .../make/read-only-bootstrap-make.service.ts | 35 +- .../setup-state/viem-setup-state.service.ts | 15 +- .../setup-state/viem-setup-state.utils.ts | 45 ++- .../position-bootstrap.service.test.ts | 72 ++++ .../quoter-bot-mutation.utils.test.ts | 39 ++ .../bootstrap/bootstrap-make.service.test.ts | 369 +++++++++++++++++- .../bootstrap/production-bootstrap.test.ts | 122 ++++++ .../ladder/ladder-spread.utils.test.ts | 33 ++ .../make/read-only-make.service.test.ts | 30 ++ .../viem-setup-state.service.test.ts | 67 +++- bots/quoter-bot/typedoc.json | 2 + .../TIB-2026-07-27-midnight-quoter-bot.md | 28 +- 25 files changed, 1447 insertions(+), 112 deletions(-) create mode 100644 bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-overlap.utils.ts create mode 100644 bots/quoter-bot/src/infrastructure/intentional-overlap.utils.ts 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 89eff729..865a6f1a 100644 --- a/bots/quoter-bot/src/application/bootstrap/position-bootstrap.service.ts +++ b/bots/quoter-bot/src/application/bootstrap/position-bootstrap.service.ts @@ -75,6 +75,19 @@ export interface BootstrapReferenceRateService { /** Port for reconciling market offers and invalidating the complete bootstrap strategy. */ export interface BootstrapMakeService { + /** + * Resolves the exact safe offer used for cross-market reservation planning. + * @param parameters - Desired offer and configured inclusive rate bounds. + * @returns The adjusted offer, or `undefined` when owned ladder liquidity covers it completely. + * @throws When current book evidence cannot prove a safe publication. + * @remarks Read-only and live adapters must use the same overlap rules; no mutation occurs. + */ + preview?(parameters: { + marketId: Hex + desiredOffer: BootstrapOffer + minimumRateBps: bigint + maximumRateBps: bigint + }): Promise /** * Reconciles one market's desired bootstrap offer or invalidates that market group. * @param parameters - Canonical market, optional desired offer, and stable action reason. @@ -87,6 +100,9 @@ export interface BootstrapMakeService { reconcile(parameters: { marketId: Hex desiredOffer?: BootstrapOffer + maximumAssets?: bigint + minimumRateBps?: bigint + maximumRateBps?: bigint reason: | 'publish' | 'replace' @@ -191,6 +207,7 @@ type BootstrapRunPlan = | { config: BootstrapConfig decision: PositionBootstrapDecision + plannedOfferAssets?: bigint verbose?: BootstrapVerbosePlan } | { result: BootstrapRunResult } @@ -564,9 +581,38 @@ export class PositionBootstrapService { } } + let plannedOfferAssets: bigint | undefined + if (decision.kind === 'publish' || decision.kind === 'replace') { + try { + plannedOfferAssets = this.make.preview + ? (( + await this.make.preview({ + marketId: config.marketId, + desiredOffer: decision.offer, + minimumRateBps: config.minimumRateBps, + maximumRateBps: config.maximumRateBps + }) + )?.assets ?? 0n) + : decision.offer.assets + } catch (error) { + const halt = await this.haltStrategy( + config.marketId, + 'decision', + error, + 'bootstrap-decision-failed', + this.transactionObserver(parameters, verbose) + ) + return [ + ...preflightResults(), + await this.withVerboseDetails(halt.result, verbose, undefined, true, halt.makeResult) + ] + } + } + plans.push({ config, decision, + ...(plannedOfferAssets === undefined ? {} : { plannedOfferAssets }), ...(verbose ? { verbose: { @@ -588,7 +634,7 @@ export class PositionBootstrapService { if (decision.kind === 'publish' || decision.kind === 'replace') { const replacedAssets = decision.kind === 'replace' ? (position.activeOffer?.assets ?? 0n) : 0n - const exposureDelta = decision.offer.assets - replacedAssets + const exposureDelta = (plannedOfferAssets ?? decision.offer.assets) - replacedAssets reservedAssetsDelta += exposureDelta reservedAssetsDeltaByMarket.set(config.marketId, marketReservationDelta + exposureDelta) } else if (decision.kind === 'invalidate' && position.activeOffer) { @@ -709,9 +755,15 @@ export class PositionBootstrapService { let reconciliation: BootstrapMakeResult try { + const desiredOffer = plan.plannedOfferAssets === 0n ? undefined : decision.offer reconciliation = await this.make.reconcile({ marketId: config.marketId, - desiredOffer: decision.offer, + desiredOffer, + ...(desiredOffer !== undefined && + plan.plannedOfferAssets !== undefined && + plan.plannedOfferAssets !== desiredOffer.assets + ? { maximumAssets: plan.plannedOfferAssets } + : {}), reason: decision.kind, onTransactionSubmitted: this.transactionObserver(parameters, verbose, config.marketId) }) diff --git a/bots/quoter-bot/src/application/quoter-bot/quoter-bot-mutation.utils.ts b/bots/quoter-bot/src/application/quoter-bot/quoter-bot-mutation.utils.ts index 01e86282..fcafa250 100644 --- a/bots/quoter-bot/src/application/quoter-bot/quoter-bot-mutation.utils.ts +++ b/bots/quoter-bot/src/application/quoter-bot/quoter-bot-mutation.utils.ts @@ -11,9 +11,10 @@ type QuoterBotMakeServices = { /** * Wraps both strategy make ports in one failure-tolerant serial mutation queue. * @param services - Independently serialized bootstrap and ladder mutation ports. - * @returns Equivalent ports whose reconcile, hard-halt, and cleanup calls cannot overlap. - * @remarks Ladder state reads remain concurrent. Serializing writes across strategies prevents - * separate wallet nonce managers from submitting concurrently and ensures shutdown cleanups drain. + * @returns Equivalent ports whose reconcile, preview, hard-halt, and cleanup calls cannot overlap. + * @remarks Ladder state reads remain concurrent. Serializing bootstrap projections with writes keeps + * the prepared publication cache stable; serializing writes across strategies prevents separate + * wallet nonce managers from submitting concurrently and ensures shutdown cleanups drain. */ export const serializeQuoterBotWrites = ( services: QuoterBotMakeServices @@ -22,6 +23,9 @@ export const serializeQuoterBotWrites = ( return { bootstrap: { + ...(services.bootstrap.preview + ? { preview: parameters => enqueue(() => services.bootstrap.preview!(parameters)) } + : {}), reconcile: parameters => enqueue(() => services.bootstrap.reconcile(parameters)), hardHalt: parameters => enqueue(() => services.bootstrap.hardHalt(parameters)), cleanup: parameters => enqueue(() => services.bootstrap.cleanup(parameters)) diff --git a/bots/quoter-bot/src/bootstrap.ts b/bots/quoter-bot/src/bootstrap.ts index c62167e9..d16d2cd3 100644 --- a/bots/quoter-bot/src/bootstrap.ts +++ b/bots/quoter-bot/src/bootstrap.ts @@ -137,6 +137,11 @@ const defaultState = async (config: ConfigService) => { readOwnedGroupIds: async () => [ ...new Set([...(await ownership.read()), ...(await ladderOwnership.readGroupIds())]) ], + readBootstrapGroupIds: ownership.read, + readLadderSellGroupIds: async () => + (await ladderOwnership.read()).flatMap(publication => + publication.groups.filter(group => group.side === 'lower').map(group => group.groupId) + ), requestTimeoutMs: config.requestTimeoutMs } ) 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 5bc5d791..1f77ca0b 100644 --- a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts +++ b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-groups.utils.ts @@ -17,6 +17,8 @@ export type BootstrapBookOffer = { maker: Address buy: boolean tick: bigint + /** Market maturity when the provider included a valid embedded market projection. */ + maturity?: bigint /** Maximum market continuous fee accepted by this offer. */ continuousFeeCap?: bigint } @@ -63,6 +65,11 @@ const parseOffer = (value: unknown, maker: Address): BootstrapBookOffer => { throw new BootstrapAdapterError('offer-groups-response') } const offer = value as Record + const market = + typeof offer.market === 'object' && offer.market !== null + ? (offer.market as Record) + : undefined + const maturity = market?.maturity if ( typeof offer.maker !== 'string' || typeof offer.buy !== 'boolean' || @@ -84,6 +91,9 @@ const parseOffer = (value: unknown, maker: Address): BootstrapBookOffer => { maker, buy: offer.buy, tick: BigInt(offer.tick), + ...(typeof maturity === 'number' && Number.isSafeInteger(maturity) + ? { maturity: BigInt(maturity) } + : {}), continuousFeeCap: unsignedDecimal(offer.continuous_fee_cap) } } @@ -265,13 +275,23 @@ export const bootstrapReservedLoanAssets = ( */ export const bootstrapBookOffers = (groups: readonly BootstrapRawGroup[]) => { const visitedGroups = new Set() - const offers = new Map() + const offers = new Map< + string, + BootstrapBookOffer & { + groupId: Hex + remainingAssets: bigint + } + >() for (const group of groups) { if (visitedGroups.has(group.id)) continue visitedGroups.add(group.id) for (const offer of group.offers) { const key = `${group.id}:${offer.marketId}:${offer.buy ? 'buy' : 'sell'}:${offer.tick}` - offers.set(key, { ...offer, groupId: group.id }) + offers.set(key, { + ...offer, + groupId: group.id, + remainingAssets: group.maxAssets - group.consumed + }) } } return [...offers.values()] diff --git a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-make.service.ts b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-make.service.ts index 255dd63e..43beddff 100644 --- a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-make.service.ts +++ b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-make.service.ts @@ -7,32 +7,30 @@ import type { } from '../../application/bootstrap/position-bootstrap-verbose' import type { BootstrapMakeService } from '../../application/bootstrap/position-bootstrap.service' import type { BootstrapOffer } from '../../domain/bootstrap/position-bootstrap' +import type { BootstrapOverlapBookOffer } from './bootstrap-overlap.utils' import type { BootstrapActiveGroup } from './bootstrap-position.service' import { BootstrapOwnershipCleanupError } from '../../application/bootstrap/bootstrap-ownership-cleanup.error' import { operatorErrorName } from '../../application/operator-error-name.utils' import { BootstrapAdapterError } from './bootstrap-adapter.error' import { BootstrapHardHaltError } from './bootstrap-hard-halt.error' -import { assertBootstrapProspectiveSpread, bootstrapMarketGroupIds } from './bootstrap-spread.utils' +import { resolveBootstrapProspectiveOffer } from './bootstrap-overlap.utils' +import { bootstrapMarketGroupIds } from './bootstrap-spread.utils' -type BootstrapBookOffer = { - groupId?: Hex - marketId: Hex - buy: boolean - tick: bigint - continuousFeeCap?: bigint -} +type BootstrapBookOffer = BootstrapOverlapBookOffer & { continuousFeeCap?: bigint } /** Protocol transport for confirmed Midnight publication and group invalidation. */ interface BootstrapOfferTransport { + /** Reads configured bootstrap rate bounds. @param marketId - Selected market. @returns Inclusive hard rate bounds. */ + rateBounds?(marketId: Hex): { minimumRateBps: bigint; maximumRateBps: bigint } | undefined /** Lists active strategy groups from Mempool truth. @returns Current active group projections. */ listActiveGroups(): Promise /** Lists explicitly owned groups that are not conclusively canceled. @returns Group IDs requiring exhaustive cleanup. */ listOwnedGroupIds?(): Promise /** Lists the maker's complete current book. @returns Every active offer needed for spread safety. */ listBookOffers(): Promise - /** Projects a domain offer into its exact protocol tick. @param offer - Desired offer. @returns Prospective book offer. */ - toProspectiveBookOffer(offer: BootstrapOffer): Promise + /** Projects a domain offer into its exact protocol tick. @param offer - Desired offer. @param exactTick - Existing owned sell tick required for an intentional overlap. @returns Prospective book offer. */ + toProspectiveBookOffer(offer: BootstrapOffer, exactTick?: bigint): Promise /** Prepares one policy-checked publication without broadcasting it. @param offer - Desired offer. @returns Reserved group ID and a one-shot confirmed ratifier/publisher. */ preparePublication(offer: BootstrapOffer): Promise<{ groupId: Hex @@ -79,6 +77,9 @@ export class MidnightBootstrapMakeService implements BootstrapMakeService { reconcile(parameters: { marketId: Hex desiredOffer?: BootstrapOffer + maximumAssets?: bigint + minimumRateBps?: bigint + maximumRateBps?: bigint reason: | 'publish' | 'replace' @@ -92,41 +93,76 @@ export class MidnightBootstrapMakeService implements BootstrapMakeService { const submittedTransactions: BootstrapSubmittedTransaction[] = [] const groups = await this.strategyGroups() const activeMarketGroupIds = new Set(bootstrapMarketGroupIds(groups, parameters.marketId)) - const spreadReplacedGroupIds = new Set([ - ...activeMarketGroupIds, - ...this.confirmedCanceledGroups - ]) let publication: | Awaited> | undefined let retainedGroup: BootstrapActiveGroup | undefined + let resolvedOffer: BootstrapOffer | undefined if (parameters.desiredOffer) { - const prospective = await this.transport.toProspectiveBookOffer(parameters.desiredOffer) - assertBootstrapProspectiveSpread({ - marketId: parameters.marketId, + const [book, durableOwnedGroupIds, prospective] = await Promise.all([ + this.transport.listBookOffers(), + this.transport.listOwnedGroupIds?.() ?? Promise.resolve([]), + this.transport.toProspectiveBookOffer(parameters.desiredOffer) + ]) + const spreadReplacedGroupIds = new Set([ + ...activeMarketGroupIds, + ...durableOwnedGroupIds, + ...this.confirmedCanceledGroups + ]) + const configuredBounds = this.transport.rateBounds?.(parameters.marketId) + const minimumRateBps = + parameters.minimumRateBps ?? + configuredBounds?.minimumRateBps ?? + parameters.desiredOffer.rateBps + const maximumRateBps = + parameters.maximumRateBps ?? + configuredBounds?.maximumRateBps ?? + parameters.desiredOffer.rateBps + const resolved = await resolveBootstrapProspectiveOffer({ + desiredOffer: parameters.desiredOffer, + prospective, replacedGroupIds: spreadReplacedGroupIds, - book: await this.transport.listBookOffers(), - prospective + book, + minimumRateBps, + maximumRateBps, + toProspectiveBookOffer: (offer, exactTick) => + this.transport.toProspectiveBookOffer(offer, exactTick) }) - retainedGroup = groups.find( - group => - group.marketId === parameters.marketId && - group.assets === parameters.desiredOffer?.assets && - group.tick === prospective.tick && - group.offerCount === 1 && - group.continuousFeeCap !== undefined && - group.continuousFeeCap === prospective.continuousFeeCap && - !this.confirmedCanceledGroups.has(group.id) - ) - if (!retainedGroup) { - publication = await this.transport.preparePublication(parameters.desiredOffer) - await this.transport.reserveGroup(publication.groupId, { - ...parameters.desiredOffer, - ...(publication.tick === undefined ? {} : { tick: publication.tick }), - ...(prospective.continuousFeeCap === undefined - ? {} - : { continuousFeeCap: prospective.continuousFeeCap }) - }) + if (resolved) { + const cappedOffer = + parameters.maximumAssets !== undefined && + resolved.offer.assets > parameters.maximumAssets + ? { ...resolved.offer, assets: parameters.maximumAssets } + : resolved.offer + const publicationProspective = + cappedOffer === resolved.offer + ? resolved.prospective + : await this.transport.toProspectiveBookOffer(cappedOffer, resolved.prospective.tick) + const publicationRateBps = publicationProspective.effectiveRateBps ?? cappedOffer.rateBps + if (publicationRateBps < minimumRateBps || publicationRateBps > maximumRateBps) { + throw new BootstrapAdapterError('negative-spread') + } + resolvedOffer = cappedOffer + retainedGroup = groups.find( + group => + group.marketId === parameters.marketId && + group.assets === cappedOffer.assets && + group.tick === publicationProspective.tick && + group.offerCount === 1 && + group.continuousFeeCap !== undefined && + group.continuousFeeCap === publicationProspective.continuousFeeCap && + !this.confirmedCanceledGroups.has(group.id) + ) + if (!retainedGroup) { + publication = await this.transport.preparePublication(cappedOffer) + await this.transport.reserveGroup(publication.groupId, { + ...cappedOffer, + ...(publication.tick === undefined ? {} : { tick: publication.tick }), + ...(publicationProspective.continuousFeeCap === undefined + ? {} + : { continuousFeeCap: publicationProspective.continuousFeeCap }) + }) + } } } const invalidatedGroupIds = new Set( @@ -165,10 +201,10 @@ export class MidnightBootstrapMakeService implements BootstrapMakeService { } throw error } - if (retainedGroup && parameters.desiredOffer) { + if (retainedGroup && resolvedOffer) { try { await this.transport.reserveGroup(retainedGroup.id, { - ...parameters.desiredOffer, + ...resolvedOffer, assets: retainedGroup.maximumAssets ?? retainedGroup.assets, ...(retainedGroup.tick === undefined ? {} : { tick: retainedGroup.tick }), ...(retainedGroup.continuousFeeCap === undefined @@ -217,6 +253,40 @@ export class MidnightBootstrapMakeService implements BootstrapMakeService { }) } + /** + * Resolves the exact offer used by cross-market reservation planning without mutating protocol state. + * @param parameters - Desired offer and configured inclusive rate bounds. + * @returns Adjusted offer, or `undefined` when the owned ladder sell covers it completely. + * @throws `BootstrapAdapterError` when current book or ownership evidence cannot prove safety. + * @remarks Independent book, ownership, and projection reads run concurrently after active groups + * are loaded; no reservation, cancellation, publication, or durable ownership write occurs. + */ + async preview(parameters: Parameters>[0]) { + const groups = await this.strategyGroups() + const [book, durableOwnedGroupIds, prospective] = await Promise.all([ + this.transport.listBookOffers(), + this.transport.listOwnedGroupIds?.() ?? Promise.resolve([]), + this.transport.toProspectiveBookOffer(parameters.desiredOffer) + ]) + const replacedGroupIds = new Set([ + ...bootstrapMarketGroupIds(groups, parameters.marketId), + ...durableOwnedGroupIds, + ...this.confirmedCanceledGroups + ]) + return ( + await resolveBootstrapProspectiveOffer({ + desiredOffer: parameters.desiredOffer, + prospective, + replacedGroupIds, + book, + minimumRateBps: parameters.minimumRateBps, + maximumRateBps: parameters.maximumRateBps, + toProspectiveBookOffer: (offer, exactTick) => + this.transport.toProspectiveBookOffer(offer, exactTick) + }) + )?.offer + } + /** * Invalidates every currently re-derived strategy bootstrap group serially. * @param parameters - Stable strategy-wide halt reason. diff --git a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-offer.utils.ts b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-offer.utils.ts index f811fc97..e8896a14 100644 --- a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-offer.utils.ts +++ b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-offer.utils.ts @@ -48,8 +48,9 @@ export const bootstrapContinuousFeeCap = (market: { continuousFee: unknown }) => /** * Recreates the exact protocol offer for a persisted or prospective bootstrap intent. - * @param parameters - Offer intent, fresh market state, maker policy, and current block time. - * @returns A Midnight buy offer with the live maturity-adjusted tick and fee cap. + * @param parameters - Offer intent, fresh market state, maker policy, current block time, and an + * optional exact owned ladder-sell tick for intentional overlap. + * @returns A Midnight buy offer with the exact overlap tick or live maturity-adjusted tick and fee cap. * @throws `BootstrapAdapterError` when a required live market fee is malformed; SDK validation failures propagate. * @remarks The fresh block timestamp prevents a later publication from reusing a consumed * content-addressed group while preserving the market maturity as the offer expiry. @@ -60,13 +61,16 @@ export const createBootstrapOffer = (parameters: { maker: Address ratifier: Address now: bigint + exactTick?: bigint }) => { return Offer.create({ market: parameters.market.params, buy: true, maker: parameters.maker, start: parameters.now, - tick: bootstrapOfferTick(parameters.offer.rateBps, parameters.market, parameters.now), + tick: + parameters.exactTick ?? + bootstrapOfferTick(parameters.offer.rateBps, parameters.market, parameters.now), expiry: parameters.market.params.maturity, ratifier: parameters.ratifier, maxAssets: parameters.offer.assets, diff --git a/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-overlap.utils.ts b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-overlap.utils.ts new file mode 100644 index 00000000..9f0c1d94 --- /dev/null +++ b/bots/quoter-bot/src/infrastructure/bootstrap/bootstrap-overlap.utils.ts @@ -0,0 +1,160 @@ +import type { BookOffer } from '@repo/offers' +import type { Hex } from 'viem' + +import { TickLib } from '@morpho-org/midnight-sdk' +import { hasNegativeSpread } from '@repo/offers' + +import type { BootstrapOffer } from '../../domain/bootstrap/position-bootstrap' +import type { BootstrapRawGroup } from './bootstrap-groups.utils' + +import { BootstrapAdapterError } from './bootstrap-adapter.error' +import { bootstrapBookOffers } from './bootstrap-groups.utils' + +const BPS_WAD = 100_000_000_000_000n + +/** Live ladder-sell evidence that permits one intentional bootstrap overlap. */ +export type BootstrapOverlapBookOffer = BookOffer & { + continuousFeeCap?: bigint + effectiveRateBps?: bigint + bootstrapOverlap?: { + remainingAssets: bigint + effectiveRateBps: bigint + } +} + +const negativeSpread = () => new BootstrapAdapterError('negative-spread') + +const currentEffectiveRateBps = (tick: bigint, maturity: bigint | undefined, now: bigint) => { + if (maturity === undefined || maturity <= now) return undefined + try { + const rateBps = TickLib.tickToApr(tick, maturity - now) / BPS_WAD + return rateBps >= 0n ? rateBps : undefined + } catch { + return undefined + } +} + +/** + * Enriches indexed ladder sells with the only evidence accepted for bootstrap overlap handling. + * @param parameters - Complete maker groups, ladder-owned sell IDs, rebuilt pending ladder offers, + * and current block timestamp. + * @returns The complete indexed and pending book, with positive remaining size and current rate + * attached only to eligible indexed ladder sells whose exact published tick is available. + * @remarks Rebuilt pending, unknown, matured, or malformed sells remain in the book without + * eligibility so any crossing they cause still fails closed in `resolveBootstrapProspectiveOffer`. + */ +export const bootstrapLadderSellOverlapBookOffers = (parameters: { + groups: readonly BootstrapRawGroup[] + eligibleSellGroupIds: ReadonlySet + currentTimestamp: bigint + pendingLadderOffers?: readonly (BootstrapOverlapBookOffer & { + remainingAssets: bigint + effectiveRateBps: bigint + })[] +}): BootstrapOverlapBookOffer[] => { + const indexed = bootstrapBookOffers(parameters.groups).map(offer => { + if ( + offer.buy || + offer.remainingAssets <= 0n || + !parameters.eligibleSellGroupIds.has(offer.groupId) + ) { + return offer + } + const effectiveRateBps = currentEffectiveRateBps( + offer.tick, + offer.maturity, + parameters.currentTimestamp + ) + if (effectiveRateBps === undefined) return offer + return { + ...offer, + bootstrapOverlap: { remainingAssets: offer.remainingAssets, effectiveRateBps } + } + }) + const pending = parameters.pendingLadderOffers ?? [] + return [...indexed, ...pending] +} + +/** + * Resolves a premium-adjusted bootstrap buy against the current maker book. + * @param parameters - Desired offer, its exact prospective tick, retained book, replacement IDs, + * and exact tick projector used only when overlap requires repricing at the selected sell tick. + * @returns The original safe offer, a sell-rate-adjusted positive remainder, or `undefined` when + * the selected sell already covers the expected bootstrap assets. + * @throws `BootstrapAdapterError` when a crossing is pre-existing, ambiguous, not caused by the + * prospective buy, or lacks positive ladder-owned size and current effective-rate evidence. + * @remarks Only the unique lowest-tick (highest-rate) eligible ladder sell may overlap. The + * adjusted offer is re-projected and must remain non-crossing against every other retained offer. + */ +export const resolveBootstrapProspectiveOffer = async (parameters: { + desiredOffer: BootstrapOffer + prospective: BootstrapOverlapBookOffer + replacedGroupIds: ReadonlySet + book: readonly BootstrapOverlapBookOffer[] + toProspectiveBookOffer: ( + offer: BootstrapOffer, + exactTick?: bigint + ) => Promise + minimumRateBps: bigint + maximumRateBps: bigint +}) => { + const retained = parameters.book.filter( + offer => + offer.marketId === parameters.desiredOffer.marketId && + (offer.groupId === undefined || !parameters.replacedGroupIds.has(offer.groupId)) + ) + if (hasNegativeSpread(retained)) throw negativeSpread() + + const prospective = parameters.prospective + if (!hasNegativeSpread([...retained, prospective])) { + return { offer: parameters.desiredOffer, prospective } + } + if (!prospective.buy || prospective.marketId !== parameters.desiredOffer.marketId) { + throw negativeSpread() + } + + const sells = retained.filter(offer => !offer.buy) + const highestRateTick = sells.reduce( + (lowest, offer) => (lowest === undefined || offer.tick < lowest ? offer.tick : lowest), + undefined + ) + const selected = sells.filter(offer => offer.tick === highestRateTick) + if (selected.length !== 1) throw negativeSpread() + + const crossingSell = selected[0]! + const evidence = crossingSell.bootstrapOverlap + if ( + prospective.tick < crossingSell.tick || + evidence === undefined || + evidence.remainingAssets <= 0n || + evidence.effectiveRateBps < parameters.minimumRateBps || + evidence.effectiveRateBps > parameters.maximumRateBps + ) { + throw negativeSpread() + } + + const assets = parameters.desiredOffer.assets - evidence.remainingAssets + if (assets <= 0n) return undefined + + const offer = { + ...parameters.desiredOffer, + assets, + rateBps: evidence.effectiveRateBps + } + const adjustedProspective = await parameters.toProspectiveBookOffer(offer, crossingSell.tick) + const adjustedRateBps = adjustedProspective.effectiveRateBps ?? evidence.effectiveRateBps + if ( + adjustedRateBps < parameters.minimumRateBps || + adjustedRateBps > parameters.maximumRateBps || + hasNegativeSpread([ + ...retained.filter(candidate => candidate !== crossingSell), + adjustedProspective + ]) + ) { + throw negativeSpread() + } + return { + offer: { ...offer, rateBps: adjustedRateBps }, + prospective: adjustedProspective + } +} diff --git a/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts b/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts index a5e1e18c..246a15b0 100644 --- a/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts +++ b/bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts @@ -38,7 +38,6 @@ import { BootstrapAdapterError } from './bootstrap-adapter.error' import { bootstrapExposureMarketIds } from './bootstrap-exposure.utils' import { createBootstrapGroupOwnership } from './bootstrap-group-ownership.utils' import { - bootstrapBookOffers, bootstrapReservedLoanAssets, readBootstrapGroups, strategyBootstrapGroups @@ -49,6 +48,10 @@ import { validateBootstrapMempoolPublication } from './bootstrap-mempool-validation.utils' import { bootstrapContinuousFeeCap, createBootstrapOffer } from './bootstrap-offer.utils' +import { + bootstrapLadderSellOverlapBookOffers, + resolveBootstrapProspectiveOffer +} from './bootstrap-overlap.utils' import { readLivePendingBootstrapOffers, readOwnedGroupIdsForCleanup @@ -60,7 +63,7 @@ import { } from './bootstrap-reference-rate.service' import { createBootstrapRequirementClient } from './bootstrap-requirement-client.utils' import { prepareBootstrapRequirements } from './bootstrap-requirements.utils' -import { assertBootstrapProspectiveSpread, bootstrapMarketGroupIds } from './bootstrap-spread.utils' +import { bootstrapMarketGroupIds } from './bootstrap-spread.utils' import { assertBootstrapTransaction } from './bootstrap-transaction.utils' const WAD = 10n ** 18n @@ -83,6 +86,46 @@ export const bootstrapMakeLendArguments = ( parameters: BootstrapMakeLendArguments ): BootstrapMakeLendArguments => parameters +type PrepareCappedBootstrapOfferParameters = { + offer: BootstrapOffer + maximumAssets?: bigint + created: Offer + exactTick?: bigint + minimumRateBps?: bigint + maximumRateBps?: bigint + prepareOffer: ( + offer: BootstrapOffer, + exactTick?: bigint + ) => Promise<{ created: Offer; effectiveRateBps?: bigint }> +} + +/** + * Applies a reconciliation asset cap and re-projects the exact Midnight offer when it changes. + * @param parameters - Resolved domain offer, optional cap, current projection, and projection port. + * @returns The capped domain offer and matching Midnight offer used for read-only validation. + * @throws Forwards projection failures when applying the cap requires a fresh Midnight offer. + */ +export const prepareCappedBootstrapOffer = async ( + parameters: PrepareCappedBootstrapOfferParameters +) => { + if ( + parameters.maximumAssets === undefined || + parameters.offer.assets <= parameters.maximumAssets + ) { + return { offer: parameters.offer, created: parameters.created } + } + const offer = { ...parameters.offer, assets: parameters.maximumAssets } + const { created, effectiveRateBps } = await parameters.prepareOffer(offer, parameters.exactTick) + if ( + effectiveRateBps !== undefined && + ((parameters.minimumRateBps !== undefined && effectiveRateBps < parameters.minimumRateBps) || + (parameters.maximumRateBps !== undefined && effectiveRateBps > parameters.maximumRateBps)) + ) { + throw new BootstrapAdapterError('negative-spread') + } + return { offer, created } +} + type PublishBootstrapPublicationParameters = { ratifierType: 'ecrecover' | 'setter' payload: Hex @@ -178,18 +221,20 @@ export const createProductionBootstrapAdapters = ( morphoApiBaseUrl: config.morphoApiBaseUrl, requestTimeoutMs: config.requestTimeoutMs }) - const prepareOffer = async (offer: BootstrapOffer) => { + const prepareOfferAtLatest = async (offer: BootstrapOffer, exactTick?: bigint) => { const [market, block] = await Promise.all([ midnight.getMarketData(offer.marketId), client.getBlock({ blockTag: 'latest' }) ]) - return createBootstrapOffer({ + const created = createBootstrapOffer({ offer, market, maker, ratifier: config.setup.ratifier, - now: block.timestamp + now: block.timestamp, + exactTick }) + return { created, timestamp: block.timestamp, maturity: market.params.maturity } } const readGroupConsumed = (groupId: Hex, blockNumber: bigint) => client.readContract({ @@ -378,7 +423,11 @@ export const createProductionBootstrapAdapters = ( blueRates ) const completeBookOffers = async () => { - const [groups, ladderPublications] = await Promise.all([readGroups(), ladderOwnership.read()]) + const [block, groups, ladderPublications] = await Promise.all([ + client.getBlock({ blockTag: 'latest' }), + readGroups(), + ladderOwnership.read() + ]) const pendingLadderOffers = ( await Promise.all( pendingLadderQuoteSets(ladderPublications, groups).map(async quote => { @@ -399,7 +448,18 @@ export const createProductionBootstrapAdapters = ( return { groups, ladderPublications, - book: [...bootstrapBookOffers(groups), ...pendingLadderOffers] + book: [ + ...bootstrapLadderSellOverlapBookOffers({ + groups, + eligibleSellGroupIds: new Set( + ladderPublications.flatMap(publication => + publication.groups.filter(group => group.side === 'lower').map(group => group.groupId) + ) + ), + currentTimestamp: block.timestamp, + pendingLadderOffers + }) + ] } } const prepareMempoolPublication = ( @@ -424,28 +484,82 @@ export const createProductionBootstrapAdapters = ( if (config.identity.readOnly) { const validate = async (parameters: Parameters[0]) => { - if (!parameters.desiredOffer) return + if (!parameters.desiredOffer) return parameters - const [bookState, ownedIds, prospectiveOffer, activeStrategyGroups] = await Promise.all([ + const [bookState, ownedIds, activeStrategyGroups, initialPrepared] = await Promise.all([ completeBookOffers(), ownership.read(), - prepareOffer(parameters.desiredOffer), - activeGroups() + activeGroups(), + prepareOfferAtLatest(parameters.desiredOffer) ]) const marketGroupIds = bootstrapMarketGroupIds(activeStrategyGroups, parameters.marketId) - assertBootstrapProspectiveSpread({ - marketId: parameters.marketId, - replacedGroupIds: marketGroupIds, - book: bookState.book, + const spreadReplacedGroupIds = new Set([ + ...bootstrapMarketGroupIds(activeStrategyGroups, parameters.marketId), + ...ownedIds + ]) + const bounds = config.bootstrap.find(item => item.marketId === parameters.marketId) + if (!bounds) throw new BootstrapAdapterError('negative-spread') + let created = initialPrepared.created + const resolved = await resolveBootstrapProspectiveOffer({ + desiredOffer: parameters.desiredOffer, prospective: { marketId: parameters.marketId, buy: true, - tick: prospectiveOffer.tick + tick: created.tick, + continuousFeeCap: created.continuousFeeCap + }, + replacedGroupIds: spreadReplacedGroupIds, + book: bookState.book, + minimumRateBps: bounds.minimumRateBps, + maximumRateBps: bounds.maximumRateBps, + toProspectiveBookOffer: async (offer, exactTick) => { + const prepared = await prepareOfferAtLatest(offer, exactTick) + created = prepared.created + return { + marketId: offer.marketId, + buy: true, + tick: created.tick, + continuousFeeCap: created.continuousFeeCap, + ...(exactTick === undefined + ? {} + : { + effectiveRateBps: + TickLib.tickToApr(created.tick, prepared.maturity - prepared.timestamp) / + (WAD / 10_000n) + }) + } + } + }) + if (!resolved) return { ...parameters, desiredOffer: undefined } + const capped = await prepareCappedBootstrapOffer({ + offer: resolved.offer, + maximumAssets: parameters.maximumAssets, + created, + exactTick: resolved.prospective.tick, + minimumRateBps: bounds.minimumRateBps, + maximumRateBps: bounds.maximumRateBps, + prepareOffer: async (offer, exactTick) => { + const prepared = await prepareOfferAtLatest(offer, exactTick) + return { + created: prepared.created, + ...(exactTick === undefined + ? {} + : { + effectiveRateBps: + TickLib.tickToApr( + prepared.created.tick, + prepared.maturity - prepared.timestamp + ) / + (WAD / 10_000n) + }) + } } }) + const resolvedOffer = capped.offer + created = capped.created await prepareMempoolPublication( - parameters.desiredOffer, - prospectiveOffer, + resolvedOffer, + created, bookState.groups, [ ...ownedIds, @@ -455,6 +569,7 @@ export const createProductionBootstrapAdapters = ( ], marketGroupIds ) + return { ...parameters, desiredOffer: resolvedOffer } } return { positions, @@ -503,17 +618,26 @@ export const createProductionBootstrapAdapters = ( const preparedOffers = new Map() const make = new MidnightBootstrapMakeService({ + rateBounds: marketId => config.bootstrap.find(item => item.marketId === marketId), listActiveGroups: activeGroups, listOwnedGroupIds: uncanceledOwnedGroupIds, listBookOffers: async () => (await completeBookOffers()).book, - toProspectiveBookOffer: async offer => { - const created = await prepareOffer(offer) + toProspectiveBookOffer: async (offer, exactTick) => { + const prepared = await prepareOfferAtLatest(offer, exactTick) + const created = prepared.created preparedOffers.set(offer.marketId, created) return { marketId: offer.marketId, buy: true, tick: created.tick, - continuousFeeCap: created.continuousFeeCap + continuousFeeCap: created.continuousFeeCap, + ...(exactTick === undefined + ? {} + : { + effectiveRateBps: + TickLib.tickToApr(created.tick, prepared.maturity - prepared.timestamp) / + (WAD / 10_000n) + }) } }, invalidate: async (group, onTransactionSubmitted) => { diff --git a/bots/quoter-bot/src/infrastructure/intentional-overlap.utils.ts b/bots/quoter-bot/src/infrastructure/intentional-overlap.utils.ts new file mode 100644 index 00000000..4ecf6528 --- /dev/null +++ b/bots/quoter-bot/src/infrastructure/intentional-overlap.utils.ts @@ -0,0 +1,48 @@ +import type { BookOffer } from '@repo/offers' + +/** Ownership evidence attached only after durable strategy-state validation. */ +export type OwnedOverlapBookOffer = BookOffer & { + overlapOwner?: 'bootstrap-buy' | 'ladder-sell' +} + +/** + * Detects a crossed maker book while exempting only one exact, durably owned bootstrap-buy / + * ladder-sell equality per market. + * @param offers - Complete selected maker book with optional validated ownership roles. + * @returns `true` for any strict crossing, tie, missing ownership evidence, wrong-side evidence, or + * multiple best-side offers; `false` for positive spreads and the narrow intentional equality. + * @remarks This pure check fails closed. Ownership must be attached by each boundary from durable + * bootstrap and ladder state; market membership alone is never evidence. + */ +export const hasInvalidOwnedBootstrapLadderSpread = ( + offers: readonly OwnedOverlapBookOffer[] +): boolean => { + const marketIds = new Set(offers.map(offer => offer.marketId)) + for (const marketId of marketIds) { + const market = offers.filter(offer => offer.marketId === marketId) + const buys = market.filter(offer => offer.buy) + const sells = market.filter(offer => !offer.buy) + if (buys.length === 0 || sells.length === 0) continue + const highestBuyTick = buys.reduce( + (highest, offer) => (offer.tick > highest ? offer.tick : highest), + buys[0]!.tick + ) + const lowestSellTick = sells.reduce( + (lowest, offer) => (offer.tick < lowest ? offer.tick : lowest), + sells[0]!.tick + ) + if (highestBuyTick < lowestSellTick) continue + if (highestBuyTick > lowestSellTick) return true + const highestBuys = buys.filter(offer => offer.tick === highestBuyTick) + const lowestSells = sells.filter(offer => offer.tick === lowestSellTick) + if ( + highestBuys.length !== 1 || + lowestSells.length !== 1 || + highestBuys[0]!.overlapOwner !== 'bootstrap-buy' || + lowestSells[0]!.overlapOwner !== 'ladder-sell' + ) { + return true + } + } + return false +} diff --git a/bots/quoter-bot/src/infrastructure/ladder/ladder-make.service.ts b/bots/quoter-bot/src/infrastructure/ladder/ladder-make.service.ts index 3ebdc0dd..db74aba5 100644 --- a/bots/quoter-bot/src/infrastructure/ladder/ladder-make.service.ts +++ b/bots/quoter-bot/src/infrastructure/ladder/ladder-make.service.ts @@ -7,6 +7,7 @@ import type { LadderTransactionSubmittedObserver } from '../../application/ladder/ladder-verbose' import type { LadderQuoteSet } from '../../domain/ladder/ladder' +import type { OwnedOverlapBookOffer } from '../intentional-overlap.utils' import type { LadderGroupReference } from './ladder-group-ownership.utils' import { LadderOwnershipCleanupError } from '../../application/ladder/ladder-ownership-cleanup.error' @@ -15,7 +16,7 @@ import { LadderAdapterError } from './ladder-adapter.error' import { LadderHardHaltError } from './ladder-hard-halt.error' import { assertLadderProspectiveSpread } from './ladder-spread.utils' -type LadderBookOffer = { groupId?: Hex; marketId: Hex; buy: boolean; tick: bigint } +type LadderBookOffer = OwnedOverlapBookOffer type LadderOwnedGroup = { groupId: Hex; maxAssets: bigint } /** Blocking transport used by the serialized ladder make adapter. */ diff --git a/bots/quoter-bot/src/infrastructure/ladder/ladder-offer.utils.ts b/bots/quoter-bot/src/infrastructure/ladder/ladder-offer.utils.ts index 42549032..fe6c4202 100644 --- a/bots/quoter-bot/src/infrastructure/ladder/ladder-offer.utils.ts +++ b/bots/quoter-bot/src/infrastructure/ladder/ladder-offer.utils.ts @@ -24,7 +24,13 @@ type BuildLadderTreeParameters = { type PreparedLadderTree = { tree: Tree groups: readonly LadderGroupReference[] - bookOffers: readonly { marketId: Hex; buy: boolean; tick: bigint }[] + bookOffers: readonly { + marketId: Hex + buy: boolean + tick: bigint + remainingAssets: bigint + effectiveRateBps: bigint + }[] } const rateToTick = (rateBps: bigint, market: IMarket, now: bigint) => { @@ -127,10 +133,12 @@ export const buildLadderTree = (parameters: BuildLadderTreeParameters): Prepared return { tree, groups, - bookOffers: tree.offers.map(offer => ({ + bookOffers: tree.offers.map((offer, index) => ({ marketId: parameters.quote.marketId, buy: offer.buy, - tick: offer.tick + tick: offer.tick, + remainingAssets: offer.maxAssets, + effectiveRateBps: tagged[index]!.rung.rateBps })) } } diff --git a/bots/quoter-bot/src/infrastructure/ladder/ladder-spread.utils.ts b/bots/quoter-bot/src/infrastructure/ladder/ladder-spread.utils.ts index 020dc720..64217ead 100644 --- a/bots/quoter-bot/src/infrastructure/ladder/ladder-spread.utils.ts +++ b/bots/quoter-bot/src/infrastructure/ladder/ladder-spread.utils.ts @@ -1,8 +1,8 @@ -import type { BookOffer } from '@repo/offers' import type { Hex } from 'viem' -import { batchProspectiveBook, hasNegativeSpread } from '@repo/offers' +import type { OwnedOverlapBookOffer } from '../intentional-overlap.utils' +import { hasInvalidOwnedBootstrapLadderSpread } from '../intentional-overlap.utils' import { LadderAdapterError } from './ladder-adapter.error' /** @@ -14,10 +14,16 @@ import { LadderAdapterError } from './ladder-adapter.error' export const assertLadderProspectiveSpread = (parameters: { marketId: Hex replacedGroupIds: ReadonlySet - book: readonly BookOffer[] - prospective: readonly BookOffer[] + book: readonly OwnedOverlapBookOffer[] + prospective: readonly OwnedOverlapBookOffer[] }) => { - if (hasNegativeSpread(batchProspectiveBook(parameters))) { + const retained = parameters.book.filter( + offer => + offer.marketId === parameters.marketId && + (offer.groupId === undefined || !parameters.replacedGroupIds.has(offer.groupId)) + ) + const prospective = parameters.prospective.filter(offer => offer.marketId === parameters.marketId) + if (hasInvalidOwnedBootstrapLadderSpread([...retained, ...prospective])) { throw new LadderAdapterError('negative-spread') } } diff --git a/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts b/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts index a04117f0..4b31e657 100644 --- a/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts +++ b/bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts @@ -66,6 +66,13 @@ type ProductionLadderAdapters = { const minimum = (left: bigint, right: bigint) => (left < right ? left : right) const remaining = (limit: bigint, used: bigint) => (limit > used ? limit - used : 0n) +const ownedLadderProspectiveOffers = ( + offers: readonly { marketId: Hex; buy: boolean; tick: bigint }[] +) => + offers.map(offer => ({ + ...offer, + ...(!offer.buy ? { overlapOwner: 'ladder-sell' as const } : {}) + })) const notifySubmitted = async ( observer: LadderTransactionSubmittedObserver | undefined, @@ -286,21 +293,27 @@ export const createProductionLadderAdapters = ( } const completeBookOffers = async () => { - const [groups, bootstrapGroupIds, persistedBootstrapOffers] = await Promise.all([ + const [groups, durableBootstrapIds, persistedBootstrapOffers] = await Promise.all([ readGroups(), bootstrapOwnership.read(), bootstrapOwnership.readOffers() ]) const pendingBootstrapOffers = await readLivePendingBootstrapOffers({ groups, - ownedGroupIds: bootstrapGroupIds, + ownedGroupIds: durableBootstrapIds, offers: persistedBootstrapOffers, readGroupConsumed }) const pendingOffers = await Promise.all( pendingBootstrapOffers.map(async offer => { if (offer.tick !== undefined) { - return { marketId: offer.marketId, buy: true, tick: offer.tick } + return { + groupId: offer.groupId, + marketId: offer.marketId, + buy: true, + tick: offer.tick, + overlapOwner: 'bootstrap-buy' as const + } } const market = await midnight.getMarketData(offer.marketId) const recoveredTick = recoverLegacyBootstrapOfferTick({ @@ -314,13 +327,26 @@ export const createProductionLadderAdapters = ( const conservativeTick = recoveredTick ?? legacyBootstrapOfferTickUpperBound({ offer, market }) ?? MAX_TICK return { + groupId: offer.groupId, marketId: offer.marketId, buy: true, - tick: conservativeTick + tick: conservativeTick, + overlapOwner: 'bootstrap-buy' as const } }) ) - return { groups, book: [...bootstrapBookOffers(groups), ...pendingOffers] } + const durableBootstrapGroupIds = new Set([ + ...config.v0OfferGroupIds, + ...durableBootstrapIds, + ...persistedBootstrapOffers.map(offer => offer.groupId) + ]) + const indexedOffers = bootstrapBookOffers(groups).map(offer => ({ + ...offer, + ...(offer.buy && durableBootstrapGroupIds.has(offer.groupId) + ? { overlapOwner: 'bootstrap-buy' as const } + : {}) + })) + return { groups, book: [...indexedOffers, ...pendingOffers] } } const readActive = async (marketId: Hex) => { @@ -364,7 +390,7 @@ export const createProductionLadderAdapters = ( activeOwnedLadderGroupIds(publications, bookState.groups, parameters.marketId) ), book: bookState.book, - prospective: prepared.bookOffers + prospective: ownedLadderProspectiveOffers(prepared.bookOffers) }) } @@ -443,7 +469,7 @@ export const createProductionLadderAdapters = ( return { groupIds, groups: prepared.groups, - prospective: prepared.bookOffers, + prospective: ownedLadderProspectiveOffers(prepared.bookOffers), publish: onTransactionSubmitted => publishLadderPublication({ approve: async () => { diff --git a/bots/quoter-bot/src/infrastructure/make/read-only-bootstrap-make.service.ts b/bots/quoter-bot/src/infrastructure/make/read-only-bootstrap-make.service.ts index 01ed9c29..fb529e48 100644 --- a/bots/quoter-bot/src/infrastructure/make/read-only-bootstrap-make.service.ts +++ b/bots/quoter-bot/src/infrastructure/make/read-only-bootstrap-make.service.ts @@ -7,29 +7,50 @@ export class ReadOnlyBootstrapMakeService implements BootstrapMakeService { /** * Creates a bootstrap dry-run adapter. * @param write - JSON Lines terminal writer; defaults to standard output. - * @param validate - Optional fresh whole-book validation performed before a reconcile is logged. + * @param validate - Optional fresh whole-book validation and adjustment performed before logging; + * returns the exact safe request that live mode would publish or invalidate. * @remarks Construction performs no signing, provider calls, or offer mutations. Each later make - * request is validated and emitted as one independently parseable JSON record. + * request is validated, adjusted when eligible ladder overlap exists, and emitted as one + * independently parseable JSON record. */ constructor( private readonly write: (line: string) => void | Promise = console.log, private readonly validate: ( parameters: Parameters[0] - ) => Promise = async () => {} + ) => Promise[0]> = async parameters => parameters ) {} + /** + * Resolves the same adjusted offer used by the later read-only reconciliation. + * @param parameters - Desired offer and configured hard bounds. + * @returns Adjusted offer or no offer when ladder liquidity covers it completely. + * @throws When fresh whole-book validation rejects the publication. + * @remarks Read-only; performs no signing, persistence, or protocol mutation. + */ + async preview(parameters: Parameters>[0]) { + const validated = await this.validate({ + marketId: parameters.marketId, + desiredOffer: parameters.desiredOffer, + minimumRateBps: parameters.minimumRateBps, + maximumRateBps: parameters.maximumRateBps, + reason: 'publish' + }) + return validated.desiredOffer + } + /** * Logs the exact desired bootstrap reconciliation instead of submitting it. * @param parameters - Market, desired offer or invalidation, and stable reconciliation reason. * @returns `logged` after the terminal writer accepts one JSON line. * @throws When the injected terminal writer rejects the line. * @remarks Production read-only composition reloads active groups and the complete maker book, - * derives the exact protocol tick, and applies the same negative-spread guard as live mode. No - * signing, publication, replacement, or invalidation occurs. + * derives the exact protocol tick, applies the same fail-closed overlap handling as live mode, + * and logs the adjusted positive remainder or an invalidation-only request. No signing, + * publication, replacement, or invalidation occurs. */ async reconcile(parameters: Parameters[0]) { - await this.validate(parameters) - await this.write(formatReadOnlyMakeEvent('bootstrap', 'reconcile', parameters)) + const validated = await this.validate(parameters) + await this.write(formatReadOnlyMakeEvent('bootstrap', 'reconcile', validated)) return 'logged' as const } 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 3febc559..767de370 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 @@ -85,6 +85,8 @@ type SetupStateOptions = { marketIds: readonly Hex[] v0OfferGroupIds: readonly Hex[] readOwnedGroupIds: () => Promise + readBootstrapGroupIds?: () => Promise + readLadderSellGroupIds?: () => Promise referenceMarketId: Hex referenceLookbackBlocks?: bigint requestTimeoutMs?: number @@ -654,10 +656,12 @@ export class ViemSetupStateService implements SetupStateService { 'Morpho API active offer maker does not match requested maker' ) } - const knownGroups = new Set([ - ...this.options.v0OfferGroupIds, - ...(await this.options.readOwnedGroupIds()) + const [ownedGroupIds, bootstrapGroupIds, ladderSellGroupIds] = await Promise.all([ + this.options.readOwnedGroupIds(), + this.options.readBootstrapGroupIds?.() ?? Promise.resolve([]), + this.options.readLadderSellGroupIds?.() ?? Promise.resolve([]) ]) + const knownGroups = new Set([...this.options.v0OfferGroupIds, ...ownedGroupIds]) const configuredMarkets = new Set(this.options.marketIds) return { unknownNamespaces: [ @@ -668,7 +672,10 @@ export class ViemSetupStateService implements SetupStateService { offers.map(offer => offer.marketId).filter(marketId => !configuredMarkets.has(marketId)) ) ], - invertedMarketIds: invertedMarketIds(offers) + invertedMarketIds: invertedMarketIds(offers, { + bootstrapBuyGroupIds: new Set([...this.options.v0OfferGroupIds, ...bootstrapGroupIds]), + ladderSellGroupIds: new Set(ladderSellGroupIds) + }) } } 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 25650f3f..4465a606 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 @@ -1,10 +1,12 @@ import type { Hex } from 'viem' -import { crossedMarketIds } from '@repo/offers' import { bytesToHex, getAddress, hexToBytes, isAddress, isHex, size } from 'viem' +import type { OwnedOverlapBookOffer } from '../intentional-overlap.utils' + import { SafeProviderError } from '../../application/setup/safe-provider.error' import { BASE_CHAIN_ID } from '../../config/config.utils' +import { hasInvalidOwnedBootstrapLadderSpread } from '../intentional-overlap.utils' import { ProviderResponseError } from './provider-response.error' export const PAGE_SIZE = 100 @@ -210,15 +212,48 @@ export const routerRatifiers = (value: unknown) => { }) } +type OverlapOwnership = { + bootstrapBuyGroupIds?: ReadonlySet + ladderSellGroupIds?: ReadonlySet +} + +const ownedOverlapRole = (offer: ReturnType, ownership: OverlapOwnership) => { + if (offer.buy && ownership.bootstrapBuyGroupIds?.has(offer.group)) { + return 'bootstrap-buy' as const + } + if (!offer.buy && ownership.ladderSellGroupIds?.has(offer.group)) { + return 'ladder-sell' as const + } + return undefined +} + /** * Detects every active market whose maker buy and sell ticks cross. * @param offers - All validated active maker offers, including unconfigured markets. + * @param ownership - Strategy-owned bootstrap buys and ladder sells allowed to overlap. * @returns Canonical IDs having a highest buy tick at or above the lowest sell tick. */ -export const invertedMarketIds = (offers: readonly ReturnType[]) => - crossedMarketIds( - offers.map(offer => ({ marketId: offer.marketId, buy: offer.buy, tick: BigInt(offer.tick) })) - ) +export const invertedMarketIds = ( + offers: readonly ReturnType[], + ownership: OverlapOwnership = {} +) => { + const offersByMarket = new Map() + for (const offer of offers) { + const overlapOwner = ownedOverlapRole(offer, ownership) + const projected = { + marketId: offer.marketId, + buy: offer.buy, + tick: BigInt(offer.tick), + ...(overlapOwner === undefined ? {} : { overlapOwner }) + } + const marketOffers = offersByMarket.get(offer.marketId) + if (marketOffers) marketOffers.push(projected) + else offersByMarket.set(offer.marketId, [projected]) + } + return [...offersByMarket] + .filter(([, marketOffers]) => hasInvalidOwnedBootstrapLadderSpread(marketOffers)) + .map(([marketId]) => marketId) +} /** * Creates a sanitized aggregate Morpho API deadline failure. 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 14fabcdc..84e6286c 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 @@ -616,6 +616,78 @@ describe('PositionBootstrapService', () => { }) }) + test('does not reserve a bootstrap offer fully covered by ladder liquidity', async () => { + const capped = { + ...config(), + maximumMarketExposure: 600n, + maximumTotalExposure: 600n + } + const { service, make, reconcile } = setup({ + configs: [capped, { ...capped, marketId: secondMarketId }] + }) + const preview = vi.fn(async parameters => + parameters.marketId === marketId ? undefined : parameters.desiredOffer + ) + make.preview = preview + + expect(await service.runOnce()).toEqual([ + { marketId, status: 'applied', action: 'publish' }, + { marketId: secondMarketId, status: 'applied', action: 'publish' } + ]) + expect(preview).toHaveBeenCalledTimes(2) + expect(reconcile).toHaveBeenNthCalledWith(2, { + marketId: secondMarketId, + desiredOffer: { + marketId: secondMarketId, + assets: 500n, + rateBps: 450n, + referenceObservationId: 'static:500' + }, + reason: 'publish' + }) + }) + + test('passes the original offer with the size reserved by the live preview as a cap', async () => { + const capped = { + ...config(), + maximumMarketExposure: 600n, + maximumTotalExposure: 600n + } + const { service, make, reconcile } = setup({ + configs: [capped, { ...capped, marketId: secondMarketId }] + }) + make.preview = vi.fn(async parameters => + parameters.marketId === marketId + ? { ...parameters.desiredOffer, assets: 200n } + : parameters.desiredOffer + ) + + await service.runOnce() + + expect(reconcile).toHaveBeenNthCalledWith(1, { + marketId, + desiredOffer: { + marketId, + assets: 500n, + rateBps: 450n, + referenceObservationId: 'static:500' + }, + maximumAssets: 200n, + onTransactionSubmitted: undefined, + reason: 'publish' + }) + expect(reconcile).toHaveBeenNthCalledWith(2, { + marketId: secondMarketId, + desiredOffer: { + marketId: secondMarketId, + assets: 400n, + rateBps: 450n, + referenceObservationId: 'static:500' + }, + reason: 'publish' + }) + }) + test('reserves only the net replacement delta before deciding a later market', async () => { const capped = { ...config(), diff --git a/bots/quoter-bot/test/application/quoter-bot/quoter-bot-mutation.utils.test.ts b/bots/quoter-bot/test/application/quoter-bot/quoter-bot-mutation.utils.test.ts index bb866059..60ce7651 100644 --- a/bots/quoter-bot/test/application/quoter-bot/quoter-bot-mutation.utils.test.ts +++ b/bots/quoter-bot/test/application/quoter-bot/quoter-bot-mutation.utils.test.ts @@ -38,6 +38,45 @@ const createServices = (events: string[]) => { } describe('serializeQuoterBotWrites', () => { + test('serializes bootstrap previews with publication mutations', async () => { + const events: string[] = [] + let releaseReconcile: (() => void) | undefined + const services = createServices(events) + const desiredOffer = { + marketId, + assets: 200n, + rateBps: 450n, + referenceObservationId: 'static:500' + } + services.bootstrap.reconcile = vi.fn( + () => + new Promise(resolve => { + events.push('bootstrap:reconcile:start') + releaseReconcile = resolve + }) + ) + services.bootstrap.preview = vi.fn(async () => { + events.push('bootstrap:preview') + return desiredOffer + }) + const serialized = serializeQuoterBotWrites(services) + + const reconcile = serialized.bootstrap.reconcile({ marketId, reason: 'publish' }) + const preview = serialized.bootstrap.preview?.({ + marketId, + desiredOffer, + minimumRateBps: 200n, + maximumRateBps: 800n + }) + await Promise.resolve() + + expect(events).toEqual(['bootstrap:reconcile:start']) + releaseReconcile?.() + await expect(preview).resolves.toEqual(desiredOffer) + await reconcile + expect(events).toEqual(['bootstrap:reconcile:start', 'bootstrap:preview']) + }) + test('serializes bootstrap and ladder mutations through one queue', async () => { const events: string[] = [] let releaseBootstrap: (() => void) | undefined diff --git a/bots/quoter-bot/test/infrastructure/bootstrap/bootstrap-make.service.test.ts b/bots/quoter-bot/test/infrastructure/bootstrap/bootstrap-make.service.test.ts index b970f80f..4e79a29d 100644 --- a/bots/quoter-bot/test/infrastructure/bootstrap/bootstrap-make.service.test.ts +++ b/bots/quoter-bot/test/infrastructure/bootstrap/bootstrap-make.service.test.ts @@ -1,6 +1,6 @@ import type { Hex } from 'viem' -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import type { BootstrapSubmittedTransaction } from '../../../src/application/bootstrap/position-bootstrap-verbose' @@ -23,6 +23,373 @@ const desiredOffer = { } describe('MidnightBootstrapMakeService', () => { + test('uses the highest-rate ladder sell to resize and reprice a crossing bootstrap offer', async () => { + const prepared: (typeof desiredOffer)[] = [] + const reserved: (typeof desiredOffer)[] = [] + const projected: { offer: typeof desiredOffer; exactTick?: bigint }[] = [] + const service = new MidnightBootstrapMakeService({ + listActiveGroups: async () => [], + listBookOffers: async () => [ + { + groupId, + marketId, + buy: false, + tick: 100n, + bootstrapOverlap: { remainingAssets: 40n, effectiveRateBps: 450n } + }, + { + groupId: publishedGroupId, + marketId, + buy: false, + tick: 110n, + bootstrapOverlap: { remainingAssets: 80n, effectiveRateBps: 400n } + } + ], + toProspectiveBookOffer: async (offer, exactTick) => { + projected.push({ offer, ...(exactTick === undefined ? {} : { exactTick }) }) + return { + marketId, + buy: true, + tick: exactTick ?? (offer.rateBps === desiredOffer.rateBps ? 105n : 101n) + } + }, + preparePublication: async offer => { + prepared.push(offer) + return { groupId: publishedGroupId, publish: async () => publicationHash } + }, + reserveGroup: async (_id, offer) => { + reserved.push(offer) + }, + confirmPublishedGroup: async () => {}, + releaseGroupReservation: async () => {}, + invalidate: async () => {} + }) + + expect( + await service.reconcile({ + marketId, + desiredOffer, + maximumAssets: 50n, + minimumRateBps: 400n, + maximumRateBps: 600n, + reason: 'publish' + }) + ).toEqual({ + submittedTransactions: [{ operation: 'publish', txHash: publicationHash }] + }) + const adjusted = { ...desiredOffer, assets: 50n, rateBps: 450n } + expect(prepared).toEqual([adjusted]) + expect(reserved).toEqual([adjusted]) + expect(projected).toEqual([ + { offer: desiredOffer }, + { offer: { ...desiredOffer, assets: 60n, rateBps: 450n }, exactTick: 100n }, + { offer: adjusted, exactTick: 100n } + ]) + }) + + test('retains the same adjusted overlap offer on the next cycle', async () => { + const preparePublication = vi.fn(async () => ({ + groupId: publishedGroupId, + publish: async () => publicationHash + })) + const invalidate = vi.fn(async () => cancellationHash) + const service = new MidnightBootstrapMakeService({ + listActiveGroups: async () => [ + { + id: publishedGroupId, + marketId, + assets: 60n, + maximumAssets: 60n, + rateBps: 450n, + tick: 100n, + offerCount: 1, + continuousFeeCap: 17n + } + ], + listOwnedGroupIds: async () => [publishedGroupId], + listBookOffers: async () => [ + { groupId: publishedGroupId, marketId, buy: true, tick: 100n }, + { + groupId, + marketId, + buy: false, + tick: 100n, + bootstrapOverlap: { remainingAssets: 40n, effectiveRateBps: 450n } + } + ], + toProspectiveBookOffer: async (_offer, exactTick) => ({ + marketId, + buy: true, + tick: exactTick ?? 105n, + continuousFeeCap: 17n + }), + preparePublication, + reserveGroup: async () => {}, + confirmPublishedGroup: async () => {}, + releaseGroupReservation: async () => {}, + invalidate + }) + + expect( + await service.reconcile({ + marketId, + desiredOffer, + minimumRateBps: 400n, + maximumRateBps: 600n, + reason: 'replace' + }) + ).toBe('unchanged') + expect(preparePublication).not.toHaveBeenCalled() + expect(invalidate).not.toHaveBeenCalled() + }) + + test('rejects an overlap-derived rate outside bootstrap hard bounds', async () => { + const service = new MidnightBootstrapMakeService({ + listActiveGroups: async () => [], + listBookOffers: async () => [ + { + groupId, + marketId, + buy: false, + tick: 100n, + bootstrapOverlap: { remainingAssets: 40n, effectiveRateBps: 700n } + } + ], + toProspectiveBookOffer: async () => ({ marketId, buy: true, tick: 105n }), + preparePublication: async () => ({ groupId: publishedGroupId, publish: async () => {} }), + reserveGroup: async () => {}, + confirmPublishedGroup: async () => {}, + releaseGroupReservation: async () => {}, + invalidate: async () => {} + }) + + const error = await service + .reconcile({ + marketId, + desiredOffer, + minimumRateBps: 400n, + maximumRateBps: 600n, + reason: 'publish' + }) + .catch(value => value) + + expect(error).toBeInstanceOf(BootstrapAdapterError) + expect(error).toMatchObject({ operation: 'negative-spread' }) + }) + + test('rechecks the exact-tick rate at the publication timestamp', async () => { + const service = new MidnightBootstrapMakeService({ + listActiveGroups: async () => [], + listBookOffers: async () => [ + { + groupId, + marketId, + buy: false, + tick: 100n, + bootstrapOverlap: { remainingAssets: 40n, effectiveRateBps: 450n } + } + ], + toProspectiveBookOffer: async (_offer, exactTick) => ({ + marketId, + buy: true, + tick: exactTick ?? 105n, + ...(exactTick === undefined ? {} : { effectiveRateBps: 700n }) + }), + preparePublication: async () => ({ groupId: publishedGroupId, publish: async () => {} }), + reserveGroup: async () => {}, + confirmPublishedGroup: async () => {}, + releaseGroupReservation: async () => {}, + invalidate: async () => {} + }) + + const error = await service + .reconcile({ + marketId, + desiredOffer, + minimumRateBps: 400n, + maximumRateBps: 600n, + reason: 'publish' + }) + .catch(value => value) + + expect(error).toBeInstanceOf(BootstrapAdapterError) + expect(error).toMatchObject({ operation: 'negative-spread' }) + }) + + test('rejects a capped exact-tick rate that drifts outside bootstrap hard bounds', async () => { + let exactTickProjections = 0 + const preparePublication = vi.fn(async () => ({ + groupId: publishedGroupId, + publish: async () => publicationHash + })) + const service = new MidnightBootstrapMakeService({ + listActiveGroups: async () => [], + listBookOffers: async () => [ + { + groupId, + marketId, + buy: false, + tick: 100n, + bootstrapOverlap: { remainingAssets: 40n, effectiveRateBps: 450n } + } + ], + toProspectiveBookOffer: async (_offer, exactTick) => { + if (exactTick === undefined) return { marketId, buy: true, tick: 105n } + exactTickProjections += 1 + return { + marketId, + buy: true, + tick: exactTick, + effectiveRateBps: exactTickProjections === 1 ? 450n : 700n + } + }, + preparePublication, + reserveGroup: async () => {}, + confirmPublishedGroup: async () => {}, + releaseGroupReservation: async () => {}, + invalidate: async () => {} + }) + + const error = await service + .reconcile({ + marketId, + desiredOffer, + maximumAssets: 50n, + minimumRateBps: 400n, + maximumRateBps: 600n, + reason: 'publish' + }) + .catch(value => value) + + expect(error).toBeInstanceOf(BootstrapAdapterError) + expect(error).toMatchObject({ operation: 'negative-spread' }) + expect(preparePublication).not.toHaveBeenCalled() + }) + + test('excludes a fully consumed durably owned bootstrap group from reconciliation', async () => { + const preparePublication = vi.fn(async () => ({ + groupId: publishedGroupId, + publish: async () => publicationHash + })) + const service = new MidnightBootstrapMakeService({ + listActiveGroups: async () => [], + listOwnedGroupIds: async () => [publishedGroupId], + listBookOffers: async () => [ + { groupId: publishedGroupId, marketId, buy: true, tick: 100n }, + { + groupId, + marketId, + buy: false, + tick: 100n, + bootstrapOverlap: { remainingAssets: 40n, effectiveRateBps: 450n } + } + ], + toProspectiveBookOffer: async (_offer, exactTick) => ({ + marketId, + buy: true, + tick: exactTick ?? 105n + }), + preparePublication, + reserveGroup: async () => {}, + confirmPublishedGroup: async () => {}, + releaseGroupReservation: async () => {}, + invalidate: async () => {} + }) + + await service.reconcile({ + marketId, + desiredOffer, + minimumRateBps: 400n, + maximumRateBps: 600n, + reason: 'replace' + }) + + expect(preparePublication).toHaveBeenCalledWith({ + ...desiredOffer, + assets: 60n, + rateBps: 450n + }) + }) + + test('publishes nothing when the highest-rate ladder sell covers the bootstrap amount', async () => { + const events: string[] = [] + const service = new MidnightBootstrapMakeService({ + listActiveGroups: async () => [], + listBookOffers: async () => [ + { + groupId, + marketId, + buy: false, + tick: 100n, + bootstrapOverlap: { remainingAssets: 100n, effectiveRateBps: 450n } + } + ], + toProspectiveBookOffer: async () => ({ marketId, buy: true, tick: 100n }), + preparePublication: async () => { + events.push('prepare') + return { + groupId: publishedGroupId, + publish: async () => { + events.push('publish') + } + } + }, + reserveGroup: async () => { + events.push('reserve') + }, + confirmPublishedGroup: async () => { + events.push('confirm') + }, + releaseGroupReservation: async () => { + events.push('release') + }, + invalidate: async () => { + events.push('invalidate') + } + }) + + expect( + await service.reconcile({ + marketId, + desiredOffer, + minimumRateBps: 400n, + maximumRateBps: 600n, + reason: 'publish' + }) + ).toEqual({ + submittedTransactions: [] + }) + expect(events).toEqual([]) + }) + + test('fails closed when a crossing sell has malformed overlap sizing evidence', async () => { + const service = new MidnightBootstrapMakeService({ + listActiveGroups: async () => [], + listBookOffers: async () => [ + { + groupId, + marketId, + buy: false, + tick: 100n, + bootstrapOverlap: { remainingAssets: 0n, effectiveRateBps: 450n } + } + ], + toProspectiveBookOffer: async () => ({ marketId, buy: true, tick: 100n }), + preparePublication: async () => ({ groupId: publishedGroupId, publish: async () => {} }), + reserveGroup: async () => {}, + confirmPublishedGroup: async () => {}, + releaseGroupReservation: async () => {}, + invalidate: async () => {} + }) + + const error = await service + .reconcile({ marketId, desiredOffer, reason: 'publish' }) + .catch(value => value) + + expect(error).toBeInstanceOf(BootstrapAdapterError) + expect(error).toMatchObject({ operation: 'negative-spread' }) + }) + test('never publishes a prospective buy that crosses the current whole book', async () => { let published = false const service = new MidnightBootstrapMakeService({ 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 cc6df0c3..f7367870 100644 --- a/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts +++ b/bots/quoter-bot/test/infrastructure/bootstrap/production-bootstrap.test.ts @@ -33,11 +33,13 @@ import { legacyBootstrapOfferTickUpperBound, recoverLegacyBootstrapOfferTick } from '../../../src/infrastructure/bootstrap/bootstrap-offer.utils' +import { bootstrapLadderSellOverlapBookOffers } from '../../../src/infrastructure/bootstrap/bootstrap-overlap.utils' import { prepareBootstrapRequirements } from '../../../src/infrastructure/bootstrap/bootstrap-requirements.utils' import { assertBootstrapTransaction } from '../../../src/infrastructure/bootstrap/bootstrap-transaction.utils' import { bootstrapMakeLendArguments, createProductionBootstrapAdapters, + prepareCappedBootstrapOffer, publishBootstrapPublication } from '../../../src/infrastructure/bootstrap/production-bootstrap' import { ReadOnlyBootstrapMakeService } from '../../../src/infrastructure/make/read-only-bootstrap-make.service' @@ -108,6 +110,63 @@ const group = (overrides: Record = {}) => ({ }) describe('createProductionBootstrapAdapters', () => { + test('re-prepares a read-only offer after applying the reconciliation asset cap', async () => { + const desiredOffer = { + marketId, + assets: 100n, + rateBps: 400n, + referenceObservationId: 'test' + } + const prepared: { assets: bigint; exactTick?: bigint }[] = [] + + const result = await prepareCappedBootstrapOffer({ + offer: desiredOffer, + maximumAssets: 40n, + created: publicationOffer(), + exactTick: 123n, + prepareOffer: async (offer, exactTick) => { + prepared.push({ assets: offer.assets, exactTick }) + return { + created: Offer.create({ + market: publicationMarket, + buy: true, + maker, + tick: 100n, + expiry: 54_000n, + ratifier, + maxAssets: offer.assets + }) + } + } + }) + + expect(prepared).toEqual([{ assets: 40n, exactTick: 123n }]) + expect(result.offer.assets).toBe(40n) + expect(result.created.maxAssets).toBe(40n) + }) + + test('rejects a capped read-only projection outside the configured rate bounds', async () => { + await expect( + prepareCappedBootstrapOffer({ + offer: { + marketId, + assets: 100n, + rateBps: 400n, + referenceObservationId: 'test' + }, + maximumAssets: 40n, + created: publicationOffer(), + exactTick: 124n, + minimumRateBps: 200n, + maximumRateBps: 800n, + prepareOffer: async () => ({ + created: publicationOffer(124n), + effectiveRateBps: 801n + }) + }) + ).rejects.toMatchObject({ operation: 'negative-spread' }) + }) + test('constructs address-only readers and selects the configured hardcoded bootstrap rate', async () => { const config = ConfigService.from( { @@ -698,6 +757,69 @@ describe('readBootstrapGroups', () => { expect(bootstrapReservedLoanAssets(groups, [groupId, secondGroupId])).toBe(0n) }) + test('keeps rebuilt pending ladder sells ineligible as exact overlap evidence', () => { + expect( + bootstrapLadderSellOverlapBookOffers({ + groups: [], + eligibleSellGroupIds: new Set(), + currentTimestamp: 1_000n, + pendingLadderOffers: [ + { + marketId, + buy: false, + tick: 200n, + remainingAssets: 40n, + effectiveRateBps: 450n + } + ] + }) + ).toEqual([ + { + marketId, + buy: false, + tick: 200n, + remainingAssets: 40n, + effectiveRateBps: 450n + } + ]) + }) + + test('retains a sell missing maturity but keeps it ineligible for overlap', async () => { + const sellOnly = { ...group().offers[0], buy: false, market: undefined } + const groups = await readBootstrapGroups( + { maker, requestTimeoutMs: 1_000 }, + { + request: async () => ({ + data: [group({ max_assets: '75', consumed: '5', offers: [sellOnly] })], + cursor: null + }) + } + ) + + expect(groups).toHaveLength(1) + expect(groups[0]!.offers[0]).not.toHaveProperty('maturity') + expect( + bootstrapLadderSellOverlapBookOffers({ + groups, + eligibleSellGroupIds: new Set([groupId]), + currentTimestamp: 1_000n + }) + ).toEqual([ + expect.objectContaining({ + groupId, + buy: false, + remainingAssets: 70n + }) + ]) + expect( + bootstrapLadderSellOverlapBookOffers({ + groups, + eligibleSellGroupIds: new Set([groupId]), + currentTimestamp: 1_000n + })[0] + ).not.toHaveProperty('bootstrapOverlap') + }) + test('passes the full distinct owned reserve in the actual makeLend argument shape', async () => { const secondGroupId: Hex = `0x${'ef'.repeat(32)}` const groups = await readBootstrapGroups( diff --git a/bots/quoter-bot/test/infrastructure/ladder/ladder-spread.utils.test.ts b/bots/quoter-bot/test/infrastructure/ladder/ladder-spread.utils.test.ts index 244c1f36..d4eb8ba8 100644 --- a/bots/quoter-bot/test/infrastructure/ladder/ladder-spread.utils.test.ts +++ b/bots/quoter-bot/test/infrastructure/ladder/ladder-spread.utils.test.ts @@ -38,6 +38,39 @@ describe('assertLadderProspectiveSpread', () => { expect((caught as LadderAdapterError).operation).toBe('negative-spread') }) + test('allows the exact owned bootstrap-buy and prospective ladder-sell equality', () => { + expect(() => + assertLadderProspectiveSpread({ + marketId, + replacedGroupIds: new Set(), + book: [ + { + groupId: replacedGroupId, + marketId, + buy: true, + tick: 10n, + overlapOwner: 'bootstrap-buy' + } + ], + prospective: [{ marketId, buy: false, tick: 10n, overlapOwner: 'ladder-sell' }] + }) + ).not.toThrow() + }) + + test('rejects ownership ties even at the intentional overlap tick', () => { + expect(() => + assertLadderProspectiveSpread({ + marketId, + replacedGroupIds: new Set(), + book: [ + { marketId, buy: true, tick: 10n, overlapOwner: 'bootstrap-buy' }, + { marketId, buy: true, tick: 10n, overlapOwner: 'bootstrap-buy' } + ], + prospective: [{ marketId, buy: false, tick: 10n, overlapOwner: 'ladder-sell' }] + }) + ).toThrow(LadderAdapterError) + }) + test('rejects a self-crossing prospective ladder even with an empty book', () => { expect(() => assertLadderProspectiveSpread({ diff --git a/bots/quoter-bot/test/infrastructure/make/read-only-make.service.test.ts b/bots/quoter-bot/test/infrastructure/make/read-only-make.service.test.ts index b7365b95..068afdb2 100644 --- a/bots/quoter-bot/test/infrastructure/make/read-only-make.service.test.ts +++ b/bots/quoter-bot/test/infrastructure/make/read-only-make.service.test.ts @@ -90,6 +90,36 @@ describe('read-only make adapters', () => { expect(lines).toEqual([]) }) + test('logs the same adjusted bootstrap offer returned by read-only validation', async () => { + const lines: string[] = [] + const service = new ReadOnlyBootstrapMakeService( + line => { + lines.push(line) + }, + async parameters => ({ + ...parameters, + desiredOffer: parameters.desiredOffer + ? { ...parameters.desiredOffer, assets: 60n, rateBps: 450n } + : undefined + }) + ) + + await service.reconcile({ + marketId, + desiredOffer: { + marketId, + assets: 100n, + rateBps: 500n, + referenceObservationId: 'block:100' + }, + reason: 'publish' + }) + + expect(JSON.parse(lines[0]!)).toMatchObject({ + request: { desiredOffer: { assets: '60', rateBps: '450' } } + }) + }) + test('reads active ladder roots but logs every requested mutation', async () => { const lines: string[] = [] const reads: Hex[] = [] 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 4edf0888..2c3aaf90 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 @@ -85,6 +85,9 @@ const createState = ( v0OfferGroupIds?: readonly Hex[] readOnly?: boolean persistedGroupIds?: readonly Hex[] + readOwnedGroupIds?: () => Promise + bootstrapGroupIds?: readonly Hex[] + ladderSellGroupIds?: readonly Hex[] } = {} ) => { const calls: string[] = [] @@ -209,7 +212,10 @@ const createState = ( marketIds: overrides.marketIds ?? [marketId], referenceMarketId, v0OfferGroupIds: overrides.v0OfferGroupIds ?? [knownGroup], - readOwnedGroupIds: async () => overrides.persistedGroupIds ?? [], + readOwnedGroupIds: + overrides.readOwnedGroupIds ?? (async () => overrides.persistedGroupIds ?? []), + readBootstrapGroupIds: async () => overrides.bootstrapGroupIds ?? [], + readLadderSellGroupIds: async () => overrides.ladderSellGroupIds ?? [], referenceLookbackBlocks: 1n, requestTimeoutMs: overrides.requestTimeoutMs, now: overrides.now @@ -745,6 +751,65 @@ describe('ViemSetupStateService', () => { }) }) + test('allows only the exact durably owned bootstrap-buy and ladder-sell overlap', async () => { + const { state } = createState( + { + '/v0/midnight/users/': { + cursor: null, + data: [ + { + id: knownGroup, + chain_id: 8453, + offers: [{ market_id: marketId, maker, buy: true, tick: 20 }] + }, + { + id: unknownGroup, + chain_id: 8453, + offers: [{ market_id: marketId, maker, buy: false, tick: 20 }] + } + ] + } + }, + { + persistedGroupIds: [unknownGroup], + bootstrapGroupIds: [knownGroup], + ladderSellGroupIds: [unknownGroup] + } + ) + + expect(await state.inspectOffers(maker)).toEqual({ + unknownNamespaces: [], + unknownMarketIds: [], + invertedMarketIds: [] + }) + }) + + test('reads ownership after pagination so newly published groups are recognized', async () => { + let providerReadCompleted = false + const { state } = createState( + {}, + { + onRequest: async url => { + if (!url.includes('/v0/midnight/users/')) throw new Error('unexpected request') + providerReadCompleted = true + return { + cursor: null, + data: [ + { + id: unknownGroup, + chain_id: 8453, + offers: [{ market_id: marketId, maker, buy: true, tick: 20 }] + } + ] + } + }, + readOwnedGroupIds: async () => (providerReadCompleted ? [unknownGroup] : []) + } + ) + + expect((await state.inspectOffers(maker)).unknownNamespaces).toEqual([]) + }) + test('reads active offer groups from the Morpho API origin', async () => { const { state, calls } = createState({ '/v0/midnight/users/': { cursor: null, data: [] } diff --git a/bots/quoter-bot/typedoc.json b/bots/quoter-bot/typedoc.json index 7d4c1341..202840b8 100644 --- a/bots/quoter-bot/typedoc.json +++ b/bots/quoter-bot/typedoc.json @@ -41,6 +41,7 @@ "src/infrastructure/bootstrap/bootstrap-mempool-validation.error.ts", "src/infrastructure/bootstrap/bootstrap-mempool-validation.utils.ts", "src/infrastructure/bootstrap/bootstrap-offer.utils.ts", + "src/infrastructure/bootstrap/bootstrap-overlap.utils.ts", "src/infrastructure/bootstrap/bootstrap-pending-offer.utils.ts", "src/infrastructure/bootstrap/bootstrap-adapter.error.ts", "src/infrastructure/bootstrap/bootstrap-group-ownership.utils.ts", @@ -60,6 +61,7 @@ "src/infrastructure/invalidation/offer-invalidation-group.utils.ts", "src/infrastructure/invalidation/offer-invalidation-transaction.utils.ts", "src/infrastructure/invalidation/production-offer-invalidation.ts", + "src/infrastructure/intentional-overlap.utils.ts", "src/infrastructure/ladder/ladder-adapter.error.ts", "src/infrastructure/ladder/ladder-active-publication.utils.ts", "src/infrastructure/ladder/ladder-cash-reservation.utils.ts", diff --git a/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md b/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md index aee9e5a3..d7307a3c 100644 --- a/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md +++ b/docs/decisions/TIB-2026-07-27-midnight-quoter-bot.md @@ -68,7 +68,8 @@ boundaries. maintaining the ordinary bid and ask ladders from the first quote cycle. - Serialize every bootstrap and ladder invalidation/sign/publication through one blocking `MakeService`, including a prospective-book check that rejects inverted spreads with a typed - `NEGATIVE_SPREAD` error. + `NEGATIVE_SPREAD` error except for the explicitly evidenced bootstrap/ladder overlap described + below. - Support explicit startup cleanup of every maker offer or one group, plus opt-in cleanup of both strategy namespaces through on-chain invalidation transactions during graceful shutdown, followed by a terminal report. @@ -146,7 +147,8 @@ one job at a time: 1. reload the active maker offers immediately before mutation; 2. merge the proposed change with the still-live offer set; 3. reject with the typed `NEGATIVE_SPREAD` error if any proposed or live V0 offer would create an - inverted spread; + inverted spread, unless a bootstrap buy overlaps the unique highest-rate owned ladder sell and + all current rate and remaining-size evidence is complete; 4. invalidate the prior root/group when the job is a replacement; 5. sign and publish the exact validated replacement; and 6. settle the caller's promise only after the resulting active set is observable. @@ -352,6 +354,17 @@ For each allowlisted market: 6. invalidate the active bootstrap group as soon as the observed credit enters the accepted target range. +When the premium-adjusted bootstrap buy reaches or crosses the unique highest-rate existing ladder +sell, `MakeService` treats that overlap as intentional only when the complete current book proves the +sell is ladder-owned and provides its group cap, consumption, tick, and maturity. It derives the +sell's current effective rate from that exact tick and remaining time to maturity, reprices the new +bootstrap offer to that rate, and publishes only `expected bootstrap assets - sell remaining +assets`. A zero or negative remainder produces no publication. Unknown ownership, ties at the +highest rate, pending offers without indexed size, malformed size/rate evidence, pre-existing +crossings, or a crossing against any other offer still fail closed with `NEGATIVE_SPREAD`. Live and +`--readonly` use the same resolver; read-only output records the adjusted request without mutating +the book. + The temporary offer lends at a worse rate for a limited period, making it attractive for a taker and paying the bootstrap cost through reduced yield. It is the only discounted offer. Normal ladder roots remain present and continue to use their configured quote premium. @@ -372,9 +385,9 @@ When `AUTO_REFILL=true`, the workflow resumes this behavior whenever credit fall false, bootstrap runs until the initial target transition and then remains observational until restarted with an explicit operator decision. -Bootstrap does not proactively take standing offers. That avoids a separate taker transaction, -and supersedes the original 10,000 USDC active-take sketch. A later iteration may opportunistically -take when a standing offer is available at a strictly better rate than the expected bootstrap rate. +Bootstrap does not proactively take standing offers. That avoids a separate taker transaction and +supersedes the original 10,000 USDC active-take sketch. The overlap handling above instead accounts +for the maker's own already-resting ladder sell while keeping both offers passive. ### 6. Process 3 — ladder quoter @@ -453,8 +466,9 @@ No offer is published unless all invariants hold for the exact encoded offer: - exactly one of `maxUnits` and `maxAssets` is non-zero; - shared groups contain only compatible direction, loan asset, and cap semantics; - the prospective set, evaluated together with every already-published maker offer, does not cross - or create an inverted/negative spread on any market; `MakeService` rejects the whole job with - `NEGATIVE_SPREAD` before invalidating or publishing anything; + or create an inverted/negative spread on any market, except for the single evidenced + bootstrap/ladder overlap above; unresolved crossings are rejected with `NEGATIVE_SPREAD` before + invalidating or publishing anything; - offer start, expiry, maturity, tick spacing, settlement-fee assumptions, continuous-fee cap, callback, receiver, maker, and ratifier match policy; - the generated root contains only the expected allowlisted offers; and