diff --git a/bots/market-making/src/application/ladder/ladder-market-maker.service.ts b/bots/market-making/src/application/ladder/ladder-market-maker.service.ts index 2ba867bf..f730d290 100644 --- a/bots/market-making/src/application/ladder/ladder-market-maker.service.ts +++ b/bots/market-making/src/application/ladder/ladder-market-maker.service.ts @@ -44,6 +44,14 @@ export interface LadderReferenceRateService { /** Consumer-owned blocking make boundary for ladder reconciliation and safety invalidation. */ export interface LadderMakeService { + /** + * Invalidates durable strategy groups whose markets are no longer configured. + * @returns Completion after every removed-market group is canceled or already consumed. + * @throws When ownership cannot be read or complete cancellation cannot be confirmed. + * @remarks Implementations must be idempotent; composition may invoke this before readiness and + * again when a cycle starts. Read-only implementations may omit the operation. + */ + cleanupRemovedMarkets?(): Promise /** * Reads the currently active strategy-owned quote set from live book truth. * @param marketId - Canonical market identifier whose active roots must be reconstructed. @@ -195,6 +203,7 @@ export class LadderMarketMakerService { verbose?: boolean onTransactionSubmitted?: (event: LadderTransactionSubmittedEvent) => void | Promise }): Promise { + await this.make.cleanupRemovedMarkets?.() if (this.configs.length === 0) { throw new LadderConfigurationError( 'ladder', @@ -309,6 +318,7 @@ export class LadderMarketMakerService { * details. All publication and invalidation side effects pass exclusively through `make`. */ async runOnce(parameters: LadderRunParameters = {}) { + await this.make.cleanupRemovedMarkets?.() if (this.configs.length === 0) { throw new LadderConfigurationError('ladder', 'requires at least one configured market') } @@ -334,26 +344,6 @@ export class LadderMarketMakerService { const results: LadderRunResult[] = [] for (const config of this.configs) { - let market: LadderMarketState - try { - market = await this.positions.readMarket(config.marketId) - } catch (error) { - const { result, invalidation } = await this.failedMarketRead(config, error, parameters) - const submittedTransactions = - invalidation === undefined || invalidation === 'logged' - ? undefined - : invalidation.submittedTransactions - results.push( - await this.completeResult(config, result, parameters, { - config, - currentState: { status: 'failed', errorName: operatorErrorName(error) }, - ...(submittedTransactions ? { submittedTransactions } : {}) - }) - ) - if (result.status === 'halted') return results - continue - } - let active: LadderQuoteSet | undefined try { active = await this.make.readActive(config.marketId) @@ -374,6 +364,26 @@ export class LadderMarketMakerService { return results } + let market: LadderMarketState + try { + market = await this.positions.readMarket(config.marketId) + } catch (error) { + const { result, invalidation } = await this.failedMarketRead(config, error, parameters) + const submittedTransactions = + invalidation === undefined || invalidation === 'logged' + ? undefined + : invalidation.submittedTransactions + results.push( + await this.completeResult(config, result, parameters, { + config, + currentState: { status: 'failed', errorName: operatorErrorName(error) }, + ...(submittedTransactions ? { submittedTransactions } : {}) + }) + ) + if (result.status === 'halted') return results + continue + } + const currentState: LadderVerboseState = { status: 'observed', market, diff --git a/bots/market-making/src/application/market-making/market-making-mutation.utils.ts b/bots/market-making/src/application/market-making/market-making-mutation.utils.ts index f58c48f0..d21b4515 100644 --- a/bots/market-making/src/application/market-making/market-making-mutation.utils.ts +++ b/bots/market-making/src/application/market-making/market-making-mutation.utils.ts @@ -30,7 +30,10 @@ export const serializeMarketMakingWrites = ( readActive: marketId => services.ladder.readActive(marketId), reconcile: parameters => enqueue(() => services.ladder.reconcile(parameters)), hardHalt: parameters => enqueue(() => services.ladder.hardHalt(parameters)), - cleanup: parameters => enqueue(() => services.ladder.cleanup(parameters)) + cleanup: parameters => enqueue(() => services.ladder.cleanup(parameters)), + cleanupRemovedMarkets: services.ladder.cleanupRemovedMarkets + ? () => enqueue(() => services.ladder.cleanupRemovedMarkets!()) + : undefined } } } diff --git a/bots/market-making/src/bootstrap.ts b/bots/market-making/src/bootstrap.ts index 26f27bba..2fe3edc2 100644 --- a/bots/market-making/src/bootstrap.ts +++ b/bots/market-making/src/bootstrap.ts @@ -117,10 +117,20 @@ const defaultState = async (config: ConfigService) => { * @param environment - Environment map used for lazy validated configuration. * @param dependencies - Optional state and workflow-port factories used by isolated tests. * @returns An application exposing a single asynchronous CLI `run` boundary. - * @remarks Composition is side-effect free: configuration and providers are built lazily per - * command, writer commands gate on readiness first, and `--readonly` swaps every mutation port for - * terminal output. `start` shares one cross-strategy mutation queue so separate writer adapters - * cannot race signer nonces. + * @remarks Composition is side-effect free. Configuration and provider construction occur lazily + * for `setup-check`, `bootstrap`, `ladder`, `start`, or `invalidate`. Setup is read-only and preserves + * concurrent independent reads through `Promise.all`. `--readonly` selects address-only identity + * before any private-key validation and replaces every workflow mutation port with terminal output. + * Writer commands first clean up durable ladder groups for removed markets, then assert readiness + * before running their application service. Setup monitoring emits read-only readiness reports + * at a one-minute cadence and halts nonzero on the first failed report. Bootstrap monitoring uses + * the same cadence and invalidates strategy-owned groups after its shutdown signal. Ladder + * monitoring uses the shortest configured ladder cadence and invalidates active owned ladder groups + * after shutdown. `start` gates readiness once, launches all three monitors concurrently, and uses + * one cross-strategy mutation queue so separate writer adapters cannot race signer nonces. Explicit + * invalidation uses a narrower cancellation preflight so unknown maker + * groups can be removed without weakening normal readiness. One-shot writers run only for their + * respective explicit commands. */ export const createApplication = ( environment: Environment = Bun.env, @@ -182,6 +192,9 @@ export const createApplication = ( }, async options => { const config = await loadConfig(options) + const ladderAdapters = await (dependencies.createLadderAdapters?.(config) ?? + createProductionLadderAdapters(config)) + if (!config.readOnly) await ladderAdapters.make.cleanupRemovedMarkets?.() const state = dependencies.createState?.(config) ?? (await defaultState(config)) await new SetupCheckService(state, config.setup, config.readOnly).assertReady() const injectedAdapters = dependencies.createBootstrapAdapters?.(config) @@ -201,10 +214,11 @@ export const createApplication = ( }, async options => { const config = await loadConfig(options) - const state = dependencies.createState?.(config) ?? (await defaultState(config)) - await new SetupCheckService(state, config.setup, config.readOnly).assertReady() const adapters = await (dependencies.createLadderAdapters?.(config) ?? createProductionLadderAdapters(config)) + if (!config.readOnly) await adapters.make.cleanupRemovedMarkets?.() + const state = dependencies.createState?.(config) ?? (await defaultState(config)) + await new SetupCheckService(state, config.setup, config.readOnly).assertReady() const writeReadOnlyEvent = parseEventWriter(options.writeEvent) const make = config.readOnly ? new ReadOnlyLadderMakeService( @@ -223,6 +237,9 @@ export const createApplication = ( }, async options => { const config = await loadConfig(options) + const ladderAdapters = await (dependencies.createLadderAdapters?.(config) ?? + createProductionLadderAdapters(config)) + if (!config.readOnly) await ladderAdapters.make.cleanupRemovedMarkets?.() if (config.bootstrap.length === 0) { throw new BootstrapConfigurationError( 'bootstrap', @@ -248,8 +265,6 @@ export const createApplication = ( ? new ReadOnlyBootstrapMakeService(readOnlyWriter(options.writeEvent)) : bootstrapAdapters.make - const ladderAdapters = await (dependencies.createLadderAdapters?.(config) ?? - createProductionLadderAdapters(config)) const ladderMake = config.readOnly ? new ReadOnlyLadderMakeService( ladderAdapters.make, diff --git a/bots/market-making/src/infrastructure/bootstrap/production-bootstrap.ts b/bots/market-making/src/infrastructure/bootstrap/production-bootstrap.ts index a000a89d..56c5651a 100644 --- a/bots/market-making/src/infrastructure/bootstrap/production-bootstrap.ts +++ b/bots/market-making/src/infrastructure/bootstrap/production-bootstrap.ts @@ -27,6 +27,7 @@ import type { BootstrapOffer } from '../../domain/bootstrap/position-bootstrap' import type { BootstrapActiveGroup, BootstrapInventoryReader } from './bootstrap-position.service' import { pendingLadderQuoteSets } from '../ladder/ladder-active-publication.utils' +import { readLadderBookOffers } from '../ladder/ladder-book.utils' import { pendingLadderBuyReservations } from '../ladder/ladder-cash-reservation.utils' import { createLadderGroupOwnership } from '../ladder/ladder-group-ownership.utils' import { buildLadderTree } from '../ladder/ladder-offer.utils' @@ -365,7 +366,15 @@ export const createProductionBootstrapAdapters = ( ) ) const completeBookOffers = async () => { - const [groups, ladderPublications] = await Promise.all([readGroups(), ladderOwnership.read()]) + const [groups, ladderPublications, wholeBook] = await Promise.all([ + readGroups(), + ladderOwnership.read(), + readLadderBookOffers({ + baseUrl: config.morphoApiBaseUrl, + marketIds: config.setup.marketIds, + timeoutMs: config.requestTimeoutMs + }) + ]) const pendingLadderOffers = ( await Promise.all( pendingLadderQuoteSets(ladderPublications, groups).map(async quote => { @@ -386,7 +395,7 @@ export const createProductionBootstrapAdapters = ( return { groups, ladderPublications, - book: [...bootstrapBookOffers(groups), ...pendingLadderOffers] + book: [...wholeBook, ...bootstrapBookOffers(groups), ...pendingLadderOffers] } } const prepareMempoolPublication = ( diff --git a/bots/market-making/src/infrastructure/ladder/ladder-book.utils.ts b/bots/market-making/src/infrastructure/ladder/ladder-book.utils.ts new file mode 100644 index 00000000..1992abf4 --- /dev/null +++ b/bots/market-making/src/infrastructure/ladder/ladder-book.utils.ts @@ -0,0 +1,83 @@ +import type { Hex } from 'viem' + +import { bytesToHex, hexToBytes, isHex, size } from 'viem' + +import type { JsonRequest } from '../setup-state/http-json.utils' + +import { requestJson } from '../setup-state/http-json.utils' +import { LadderAdapterError } from './ladder-adapter.error' + +const MAX_ITEMS = 1_000 + +const bytes32 = (value: unknown) => { + if (typeof value !== 'string' || !isHex(value, { strict: true }) || size(value) !== 32) { + throw new LadderAdapterError('book-response') + } + return bytesToHex(hexToBytes(value)) +} + +/** + * Reads both takeable sides of every configured market through the Router whole-book boundary. + * @param parameters - Router origin, configured market IDs, deadline, and optional request boundary. + * @returns Every active market offer required for negative-spread validation. + * @throws `LadderAdapterError` when a provider response is malformed or exceeds the endpoint bound. + */ +export const readLadderBookOffers = async (parameters: { + baseUrl: string + marketIds: readonly Hex[] + timeoutMs: number + request?: JsonRequest +}) => { + const request = parameters.request ?? requestJson + const deadline = performance.now() + parameters.timeoutMs + const readSide = async (marketId: Hex, side: 'asks' | 'bids') => { + const remainingMs = Math.floor(deadline - performance.now()) + if (remainingMs <= 0) throw new LadderAdapterError('book-timeout') + const rawResponse = await request( + `${parameters.baseUrl}/v0/midnight/books/${marketId}/${side}/takeable-offers`, + 'morpho-api', + Math.min(parameters.timeoutMs, remainingMs) + ) + if (typeof rawResponse !== 'object' || rawResponse === null || Array.isArray(rawResponse)) { + throw new LadderAdapterError('book-response') + } + const response = rawResponse as { data?: unknown } + if (!Array.isArray(response.data) || response.data.length > MAX_ITEMS) { + throw new LadderAdapterError('book-response') + } + return { marketId, side, values: response.data } + } + const pages = await Promise.all( + parameters.marketIds.flatMap(marketId => + (['asks', 'bids'] as const).map(side => readSide(marketId, side)) + ) + ) + return pages.flatMap(({ marketId, side, values }) => + values.map(value => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new LadderAdapterError('book-response') + } + const row = value as Record + const offer = row.offer + if (typeof offer !== 'object' || offer === null || Array.isArray(offer)) { + throw new LadderAdapterError('book-response') + } + const raw = offer as Record + if (typeof raw.tick !== 'number' || !Number.isSafeInteger(raw.tick)) { + throw new LadderAdapterError('book-response') + } + const expectedBuy = side === 'bids' + if (typeof raw.buy !== 'boolean' || raw.buy !== expectedBuy) { + throw new LadderAdapterError('book-response') + } + const returnedMarketId = bytes32(row.market_id) + if (returnedMarketId !== marketId) throw new LadderAdapterError('book-response') + return { + groupId: bytes32(raw.group), + marketId: returnedMarketId, + buy: expectedBuy, + tick: BigInt(raw.tick) + } + }) + ) +} diff --git a/bots/market-making/src/infrastructure/ladder/ladder-capacity.utils.ts b/bots/market-making/src/infrastructure/ladder/ladder-capacity.utils.ts new file mode 100644 index 00000000..cd6df41e --- /dev/null +++ b/bots/market-making/src/infrastructure/ladder/ladder-capacity.utils.ts @@ -0,0 +1,50 @@ +import type { Hex } from 'viem' + +/** Inputs required to derive replacement-aware ladder inventory capacities. */ +type LadderCapacityParameters = { + marketId: Hex + balance: bigint + currentCredit: bigint + otherMarketCredit: bigint + creditSaleCapacityAssets: bigint + targetMarketExposureAssets: bigint + maximumTotalExposureAssets: bigint + reservations: readonly { id: Hex; marketIds: readonly Hex[]; assets: bigint }[] +} + +/** + * Derives market and aggregate ladder capacity without double-reserving replaced groups. + * @param parameters - Wallet balance, accrued credit, active reservations, and exposure limits. + * @returns Fresh side, market, and total capacities. + */ +export const calculateLadderCapacities = (parameters: LadderCapacityParameters) => { + const reserved = parameters.reservations.reduce((sum, item) => sum + item.assets, 0n) + const marketReserved = parameters.reservations + .filter(item => item.marketIds.includes(parameters.marketId)) + .reduce((sum, item) => sum + item.assets, 0n) + const unreservedCash = parameters.balance > reserved ? parameters.balance - reserved : 0n + const currentExposure = parameters.currentCredit + parameters.otherMarketCredit + reserved + const totalLendRoom = + parameters.maximumTotalExposureAssets > currentExposure + ? parameters.maximumTotalExposureAssets - currentExposure + : 0n + const currentMarketExposure = parameters.currentCredit + marketReserved + const marketLendRoom = + parameters.targetMarketExposureAssets > currentMarketExposure + ? parameters.targetMarketExposureAssets - currentMarketExposure + : 0n + const lendRoom = totalLendRoom < marketLendRoom ? totalLendRoom : marketLendRoom + const cash = unreservedCash < lendRoom ? unreservedCash : lendRoom + const credit = + parameters.currentCredit < parameters.creditSaleCapacityAssets + ? parameters.currentCredit + : parameters.creditSaleCapacityAssets + const market = cash + credit + const total = totalLendRoom + credit + return { + lowerRateCapacityAssets: credit, + higherRateCapacityAssets: cash, + targetMarketCapacityAssets: market, + maximumTotalCapacityAssets: total + } +} diff --git a/bots/market-making/src/infrastructure/ladder/ladder-group-ownership.utils.ts b/bots/market-making/src/infrastructure/ladder/ladder-group-ownership.utils.ts index e5cc92b3..1aac8f1f 100644 --- a/bots/market-making/src/infrastructure/ladder/ladder-group-ownership.utils.ts +++ b/bots/market-making/src/infrastructure/ladder/ladder-group-ownership.utils.ts @@ -1,6 +1,6 @@ import type { Address, Hex } from 'viem' -import { mkdir, lstat, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { bytesToHex, hexToBytes, isHex, keccak256, size, stringToHex } from 'viem' @@ -156,8 +156,18 @@ const strategyId = (config: LadderOwnershipConfig) => stringToHex( JSON.stringify({ strategy: 'ladder', - maker: config.maker, - marketIds: config.strategyMarketIds.map(canonicalId).toSorted() + maker: config.maker + }) + ) + ) + +const legacyStrategyId = (maker: Address, marketIds: readonly Hex[]) => + keccak256( + stringToHex( + JSON.stringify({ + strategy: 'ladder', + maker, + marketIds: marketIds.map(canonicalId).toSorted() }) ) ) @@ -194,23 +204,50 @@ const serializePublication = (publication: OwnedLadderPublication): PersistedPub * @returns Atomic publication reservation, confirmation, removal, and read operations. * @throws `LadderAdapterError` when persisted state is malformed, foreign, or insecure. * @remarks State contains no key, signature, URL, transaction, or maker address and is mode `0600`. + * Legacy market-scoped state remains readable without mutation and is migrated only by writer paths. */ export const createLadderGroupOwnership = ( config: LadderOwnershipConfig, dependencies: LadderOwnershipDependencies = {} ) => { const strategy = strategyId(config) + const legacyStrategy = legacyStrategyId(config.maker, config.strategyMarketIds) const directory = dependencies.stateDirectory ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'morpho-market-making') const path = join(directory, `${strategy}.json`) + const legacyPath = join(directory, `${legacyStrategy}.json`) - const read = async (): Promise => { + const write = async (publications: readonly OwnedLadderPublication[]) => { + await mkdir(directory, { recursive: true, mode: 0o700 }) + const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp` + try { + await writeFile( + temporary, + JSON.stringify({ + version: 1, + strategy, + publications: publications.map(serializePublication) + } satisfies OwnershipState), + { encoding: 'utf8', mode: 0o600, flag: 'wx' } + ) + await rename(temporary, path) + for (const state of await discoverLegacyStates()) await rm(state.path, { force: true }) + } finally { + await rm(temporary, { force: true }) + } + } + + const readPath = async ( + statePath: string, + expectedStrategy: Hex, + ignoreNonLadderState = false + ): Promise => { let metadata try { - metadata = await lstat(path) + metadata = await lstat(statePath) } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined throw new LadderAdapterError('group-ownership-state') } if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) { @@ -220,10 +257,11 @@ export const createLadderGroupOwnership = ( throw new LadderAdapterError('group-ownership-state') } try { - const value = JSON.parse(await readFile(path, 'utf8')) as Record + const value = JSON.parse(await readFile(statePath, 'utf8')) as Record + if (ignoreNonLadderState && !Array.isArray(value.publications)) return undefined if ( value.version !== 1 || - value.strategy !== strategy || + value.strategy !== expectedStrategy || !Array.isArray(value.publications) ) { throw new LadderAdapterError('group-ownership-state') @@ -235,23 +273,53 @@ export const createLadderGroupOwnership = ( } } - const write = async (publications: readonly OwnedLadderPublication[]) => { - await mkdir(directory, { recursive: true, mode: 0o700 }) - const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp` + const discoverLegacyStates = async () => { + let names: string[] try { - await writeFile( - temporary, - JSON.stringify({ - version: 1, - strategy, - publications: publications.map(serializePublication) - } satisfies OwnershipState), - { encoding: 'utf8', mode: 0o600, flag: 'wx' } - ) - await rename(temporary, path) - } finally { - await rm(temporary, { force: true }) + names = await readdir(directory) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw new LadderAdapterError('group-ownership-state') + } + + const states: { path: string; publications: OwnedLadderPublication[] }[] = [] + for (const name of names.toSorted()) { + const match = /^(0x[0-9a-f]{64})\.json$/.exec(name) + if (!match) continue + const candidateStrategy = match[1] as Hex + const candidatePath = join(directory, name) + if (candidatePath === path) continue + const publications = await readPath(candidatePath, candidateStrategy, true) + if (publications === undefined) continue + const marketIds = [...new Set(publications.map(publication => publication.marketId))] + const attributableLegacyStrategy = + marketIds.length > 0 ? legacyStrategyId(config.maker, marketIds) : undefined + if (candidatePath !== legacyPath && candidateStrategy !== attributableLegacyStrategy) { + continue + } + states.push({ path: candidatePath, publications }) + } + return states + } + + const legacyPublications = async () => { + const publications = new Map() + for (const state of await discoverLegacyStates()) { + for (const publication of state.publications) { + const key = publication.groups + .map(group => group.groupId) + .toSorted() + .join(':') + publications.set(key, publication) + } } + return [...publications.values()] + } + + const read = async (): Promise => { + const publications = await readPath(path, strategy) + if (publications !== undefined) return publications + return legacyPublications() } const publicationKey = (groups: readonly LadderGroupReference[]) => @@ -261,6 +329,16 @@ export const createLadderGroupOwnership = ( .join(':') return { + /** Migrates valid legacy ownership into the stable namespace. @returns Completion after atomic durable migration. */ + migrate: async (): Promise => { + const publications = await readPath(path, strategy) + if (publications !== undefined) { + for (const state of await discoverLegacyStates()) await rm(state.path, { force: true }) + return + } + const legacy = await legacyPublications() + if (legacy.length > 0) await write(legacy) + }, /** Reads every reserved or confirmed publication. @returns Canonical durable publication intents. */ read, /** Reads every explicitly owned group ID. @returns Distinct reserved and confirmed group IDs. */ diff --git a/bots/market-making/src/infrastructure/ladder/ladder-groups.utils.ts b/bots/market-making/src/infrastructure/ladder/ladder-groups.utils.ts new file mode 100644 index 00000000..e1348701 --- /dev/null +++ b/bots/market-making/src/infrastructure/ladder/ladder-groups.utils.ts @@ -0,0 +1,148 @@ +import type { Address, Hex } from 'viem' + +import { bytesToHex, hexToBytes, isAddress, isHex, size } from 'viem' + +import type { JsonRequest } from '../setup-state/http-json.utils' + +import { requestJson } from '../setup-state/http-json.utils' +import { LadderAdapterError } from './ladder-adapter.error' + +type LadderApiOffer = { + marketId: Hex + maker: Address + buy: boolean + tick: bigint + maturity: bigint +} + +type LadderApiGroup = { + id: Hex + consumed: bigint + maxAssets: bigint + maxUnits: bigint + offers: readonly LadderApiOffer[] + rungIndex?: number + intendedRungs?: readonly { + side: 'lower' | 'higher' + rungIndex: number + rateBps: bigint + }[] +} + +const PAGE_SIZE = 100 +const MAX_PAGES = 100 +const MAX_ITEMS = 100_000 + +const bytes32 = (value: unknown) => { + if (typeof value !== 'string' || !isHex(value, { strict: true }) || size(value) !== 32) { + throw new LadderAdapterError('offer-groups-response') + } + return bytesToHex(hexToBytes(value)) +} + +const decimal = (value: unknown) => { + if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value)) { + throw new LadderAdapterError('offer-groups-response') + } + return BigInt(value) +} + +/** + * Reads active maker groups through the Router HTTP boundary. + * @param parameters - API origin, maker, timeout, and optional request implementation. + * @returns Strictly parsed active group projections. + * @throws `LadderAdapterError` for malformed or failed provider responses. + */ +export const readLadderGroups = async (parameters: { + baseUrl: string + maker: Address + timeoutMs: number + request?: JsonRequest +}) => { + const request = parameters.request ?? requestJson + const deadline = performance.now() + parameters.timeoutMs + const values: unknown[] = [] + const seen = new Set() + let cursor: string | undefined + let pages = 0 + do { + if (pages >= MAX_PAGES) throw new LadderAdapterError('offer-groups-page-limit') + const remainingMs = Math.floor(deadline - performance.now()) + if (remainingMs <= 0) throw new LadderAdapterError('offer-groups-timeout') + pages += 1 + const query = new URLSearchParams({ chain_ids: '8453', limit: String(PAGE_SIZE) }) + if (cursor) query.set('cursor', cursor) + const raw = await request( + `${parameters.baseUrl}/v0/midnight/users/${parameters.maker}/offer-groups?${query.toString()}`, + 'morpho-api', + Math.min(parameters.timeoutMs, remainingMs) + ) + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new LadderAdapterError('offer-groups-response') + } + const response = raw as { data?: unknown; cursor?: unknown } + if (!Array.isArray(response.data) || response.data.length > PAGE_SIZE) { + throw new LadderAdapterError('offer-groups-response') + } + values.push(...response.data) + if (values.length > MAX_ITEMS) throw new LadderAdapterError('offer-groups-item-limit') + if ( + !Object.hasOwn(response, 'cursor') || + (response.cursor !== null && + (typeof response.cursor !== 'string' || response.cursor.trim().length === 0)) + ) { + throw new LadderAdapterError('offer-groups-response') + } + cursor = response.cursor === null ? undefined : response.cursor + if (cursor && seen.has(cursor)) throw new LadderAdapterError('offer-groups-repeated-cursor') + if (cursor) seen.add(cursor) + } while (cursor) + + return values.map(value => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new LadderAdapterError('offer-groups-response') + } + const group = value as Record + if (group.chain_id !== 8453 || !Array.isArray(group.offers)) { + throw new LadderAdapterError('offer-groups-response') + } + const offers = group.offers.map(value => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new LadderAdapterError('offer-groups-response') + } + const offer = value as Record + const market = offer.market as Record | undefined + if ( + typeof offer.maker !== 'string' || + !isAddress(offer.maker) || + typeof offer.buy !== 'boolean' || + typeof offer.tick !== 'number' || + !Number.isSafeInteger(offer.tick) || + !market || + typeof market.maturity !== 'number' || + !Number.isSafeInteger(market.maturity) + ) { + throw new LadderAdapterError('offer-groups-response') + } + return { + marketId: bytes32(offer.market_id), + maker: offer.maker, + buy: offer.buy, + tick: BigInt(offer.tick), + maturity: BigInt(market.maturity) + } + }) + const parsed: LadderApiGroup = { + id: bytes32(group.id), + consumed: decimal(group.consumed), + maxAssets: decimal(group.max_assets ?? '0'), + maxUnits: decimal(group.max_units ?? '0'), + offers + } + const maximum = parsed.maxAssets === 0n ? parsed.maxUnits : parsed.maxAssets + if (maximum === 0n || parsed.consumed > maximum) { + throw new LadderAdapterError('offer-groups-response') + } + return parsed + }) +} diff --git a/bots/market-making/src/infrastructure/ladder/ladder-offer.utils.ts b/bots/market-making/src/infrastructure/ladder/ladder-offer.utils.ts index 42549032..d942a8b1 100644 --- a/bots/market-making/src/infrastructure/ladder/ladder-offer.utils.ts +++ b/bots/market-making/src/infrastructure/ladder/ladder-offer.utils.ts @@ -18,6 +18,8 @@ type BuildLadderTreeParameters = { maker: Address ratifier: Address now: bigint + minimumRateBps?: bigint + maximumRateBps?: bigint } /** Complete locally built ladder tree plus group/rung ownership metadata. */ @@ -34,6 +36,22 @@ const rateToTick = (rateBps: bigint, market: IMarket, now: bigint) => { return TickLib.priceToTick(TickLib.rateToPrice(periodRateWad), BigInt(market.tickSpacing)) } +const boundedTick = (rateBps: bigint, parameters: BuildLadderTreeParameters) => { + const tick = rateToTick(rateBps, parameters.market, parameters.now) + const timeToMaturity = BigInt(parameters.market.params.maturity) - parameters.now + const encodedRateWad = TickLib.tickToApr(tick, timeToMaturity) + const basisPointWad = WAD / 10_000n + if ( + (parameters.minimumRateBps !== undefined && + encodedRateWad < parameters.minimumRateBps * basisPointWad) || + (parameters.maximumRateBps !== undefined && + encodedRateWad > parameters.maximumRateBps * basisPointWad) + ) { + throw new LadderAdapterError('encoded-rate-out-of-bounds') + } + return tick +} + const sideOffers = ( side: 'lower' | 'higher', rungs: readonly LadderRung[], @@ -61,7 +79,7 @@ const sideOffers = ( rung, offer: Offer.create({ ...common, - tick: rateToTick(rung.rateBps, parameters.market, parameters.now), + tick: boundedTick(rung.rateBps, parameters), maxAssets: maxAssetsByRung[index]! }) })) @@ -69,14 +87,17 @@ const sideOffers = ( /** * Converts one domain quote set into the exact mixed-side Midnight offer tree. - * @param parameters - Quote, fresh market, maker, ratifier, and block timestamp. + * @param parameters - Quote, fresh market, maker, ratifier, block timestamp, and optional minimum + * and maximum APR bounds in basis points enforced against the exact encoded rate after tick rounding. * @returns Tree, protocol-group-to-rung mapping, and prospective book ticks. - * @throws `LadderAdapterError` when the market has matured; SDK validation errors pass through. + * @throws `LadderAdapterError` when the market has matured, the ladder is empty, or a rounded tick's + * exact encoded APR is outside a supplied bound; SDK validation errors pass through. * @remarks Midnight prices are inverse to rates, so lower rates map to reduce-only sells and higher * rates map to lend buys. This keeps every buy tick strictly below every sell tick. Each offer uses * the fresh block timestamp as its start so a later publication cannot reuse a previously consumed * content-addressed group. `shared-rung` gives each rung an independent cap; `per-book` shares one - * cap across each side. + * cap across each side. This function constructs local values only and does not publish or mutate + * persisted ownership. */ export const buildLadderTree = (parameters: BuildLadderTreeParameters): PreparedLadderTree => { const caps = offerMaxAssetsByRung(parameters.quote) diff --git a/bots/market-making/src/infrastructure/ladder/production-ladder.ts b/bots/market-making/src/infrastructure/ladder/production-ladder.ts index 8f364d1c..a3e1aed1 100644 --- a/bots/market-making/src/infrastructure/ladder/production-ladder.ts +++ b/bots/market-making/src/infrastructure/ladder/production-ladder.ts @@ -41,6 +41,8 @@ import { reconstructOwnedLadderPublication } from './ladder-active-publication.utils' import { LadderAdapterError } from './ladder-adapter.error' +import { readLadderBookOffers } from './ladder-book.utils' +import { calculateLadderCapacities } from './ladder-capacity.utils' import { ladderCashReservations } from './ladder-cash-reservation.utils' import { createLadderGroupOwnership } from './ladder-group-ownership.utils' import { MidnightLadderMakeService, type LadderOfferTransport } from './ladder-make.service' @@ -62,7 +64,51 @@ type ProductionLadderAdapters = { } const minimum = (left: bigint, right: bigint) => (left < right ? left : right) -const remaining = (limit: bigint, used: bigint) => (limit > used ? limit - used : 0n) + +type ProductionLadderCapacityParameters = { + marketId: Hex + balance: bigint + currentCredit: bigint + otherMarketCredit: bigint + targetMarketExposureAssets: bigint + maximumTotalExposureAssets: bigint + reservations: readonly { id: Hex; marketIds: readonly Hex[]; assets: bigint }[] +} + +/** + * Derives production ladder capacities while reserving current market credit for reduce-only sells. + * @param parameters - Market balance, current and cross-market credit, exposure limits, and durable + * reservations. Reservations spanning this market reduce available balance exactly once, while all + * current market credit remains available to size higher-side reduce-only rungs. + * @returns Capacity limits for the lower and higher sides of the requested market. + * @throws When the shared capacity calculator rejects inconsistent or invalid sizing inputs. + * @remarks This pure calculation does not read, write, publish, or mutate reservation state. + */ +export const calculateProductionLadderCapacities = ( + parameters: ProductionLadderCapacityParameters +) => + calculateLadderCapacities({ + ...parameters, + creditSaleCapacityAssets: parameters.currentCredit + }) + +/** + * Deduplicates concurrent async work while allowing a fresh attempt after the active call settles. + * @param operation - Async operation to execute at most once concurrently. + * @returns A callable that shares the active promise and reruns the operation after settlement. + * @throws Forwards the rejection from the active operation to every caller sharing that attempt. + * @remarks The returned function retains only the currently active promise. Concurrent calls are + * deduplicated, while every call made after settlement starts a new operation. + */ +export const createRepeatableSingleFlight = (operation: () => Promise) => { + let active: Promise | undefined + return () => { + active ??= operation().finally(() => { + active = undefined + }) + return active + } +} const notifySubmitted = async ( observer: LadderTransactionSubmittedObserver | undefined, @@ -240,27 +286,19 @@ export const createProductionLadderAdapters = ( bootstrapOffers: pendingBootstrapOffers, replacedGroupIds }) - const reservedCash = reservations.reduce((sum, reservation) => sum + reservation.assets, 0n) - const availableCash = remaining(minimum(cashBalance, allowance), reservedCash) - const marketReserved = reservations - .filter(reservation => reservation.marketIds.includes(marketId)) - .reduce((sum, reservation) => sum + reservation.assets, 0n) - const marketExposure = selectedPosition.credit + marketReserved - const totalCredit = positionSnapshots.reduce((sum, position) => sum + position.credit, 0n) - const totalExposure = totalCredit + reservedCash + const otherMarketCredit = positionSnapshots + .filter(position => position.marketId !== marketId) + .reduce((sum, position) => sum + position.credit, 0n) - return { - lowerRateCapacityAssets: selectedPosition.credit, - higherRateCapacityAssets: availableCash, - targetMarketCapacityAssets: remaining( - selectedConfig.targetMarketExposureAssets, - marketExposure - ), - maximumTotalCapacityAssets: remaining( - selectedConfig.maximumTotalExposureAssets, - totalExposure - ) - } + return calculateProductionLadderCapacities({ + marketId, + balance: minimum(cashBalance, allowance), + currentCredit: selectedPosition.credit, + otherMarketCredit, + targetMarketExposureAssets: selectedConfig.targetMarketExposureAssets, + maximumTotalExposureAssets: selectedConfig.maximumTotalExposureAssets, + reservations + }) } } @@ -275,10 +313,15 @@ export const createProductionLadderAdapters = ( } const completeBookOffers = async () => { - const [groups, bootstrapGroupIds, persistedBootstrapOffers] = await Promise.all([ + const [groups, bootstrapGroupIds, persistedBootstrapOffers, wholeBook] = await Promise.all([ readGroups(), bootstrapOwnership.read(), - bootstrapOwnership.readOffers() + bootstrapOwnership.readOffers(), + readLadderBookOffers({ + baseUrl: config.morphoApiBaseUrl, + marketIds: config.setup.marketIds, + timeoutMs: config.requestTimeoutMs + }) ]) const pendingBootstrapOffers = await readLivePendingBootstrapOffers({ groups, @@ -309,10 +352,12 @@ export const createProductionLadderAdapters = ( } }) ) - return { groups, book: [...bootstrapBookOffers(groups), ...pendingOffers] } + return { groups, book: [...wholeBook, ...bootstrapBookOffers(groups), ...pendingOffers] } } + let cleanupRemovedMarkets = async () => {} const readActive = async (marketId: Hex) => { + await cleanupRemovedMarkets() const [groups, publications] = await Promise.all([readGroups(), ladderOwnership.read()]) return publications .filter(item => item.marketId === marketId) @@ -322,6 +367,8 @@ export const createProductionLadderAdapters = ( } const prepareUnsignedPublication = async (quote: LadderQuoteSet) => { + const selectedConfig = config.ladder.find(item => item.marketId === quote.marketId) + if (!selectedConfig) throw new LadderAdapterError('market-not-configured') const [market, block] = await Promise.all([ midnight.getMarketData(quote.marketId), client.getBlock({ blockTag: 'latest' }) @@ -331,7 +378,9 @@ export const createProductionLadderAdapters = ( market, maker, ratifier: config.setup.ratifier, - now: block.timestamp + now: block.timestamp, + minimumRateBps: selectedConfig.minimumRateBps, + maximumRateBps: selectedConfig.maximumRateBps }) await prepared.tree.mempoolValidate({ chainId: base.id, @@ -508,10 +557,38 @@ export const createProductionLadderAdapters = ( forgetGroups: ladderOwnership.forget } + cleanupRemovedMarkets = createRepeatableSingleFlight(async () => { + await ladderOwnership.migrate() + const publications = await ladderOwnership.read() + const configuredMarkets = new Set(config.ladder.map(item => item.marketId)) + const retainedGroupIds = new Set( + publications + .filter(publication => configuredMarkets.has(publication.marketId)) + .flatMap(publication => publication.groups.map(group => group.groupId)) + ) + const removed = new Map( + ownedGroups(publications) + .filter(group => !retainedGroupIds.has(group.groupId)) + .map(group => [group.groupId, group.maxAssets] as const) + ) + if (removed.size === 0) return + const indexedGroupIds = new Set((await readGroups()).map(group => group.id)) + const failures: unknown[] = [] + for (const [groupId, maxAssets] of removed) { + try { + if ((await readGroupConsumed(groupId)) < maxAssets) await transport.invalidate(groupId) + if (!indexedGroupIds.has(groupId)) await ladderOwnership.forget([groupId]) + } catch (error) { + failures.push(error) + } + } + if (failures.length > 0) throw new LadderAdapterError('removed-market-cleanup') + }) + return { positions, rates, - make: new MidnightLadderMakeService(transport), + make: Object.assign(new MidnightLadderMakeService(transport), { cleanupRemovedMarkets }), validateReconcile } } diff --git a/bots/market-making/test/application/ladder/ladder-market-maker.service.test.ts b/bots/market-making/test/application/ladder/ladder-market-maker.service.test.ts index a67cac52..e098e865 100644 --- a/bots/market-making/test/application/ladder/ladder-market-maker.service.test.ts +++ b/bots/market-making/test/application/ladder/ladder-market-maker.service.test.ts @@ -71,11 +71,14 @@ const harness = (configs: readonly LadderConfig[] = [config()]) => { return rate } } + const cleanupRemovedMarkets = mock(async () => {}) const cleanup = mock(async () => { liveDesired.clear() }) const make: LadderMakeService = { + cleanupRemovedMarkets, async readActive(id) { + reads.push(`active:${id}`) return liveDesired.get(id) }, async reconcile(parameters) { @@ -102,6 +105,7 @@ const harness = (configs: readonly LadderConfig[] = [config()]) => { reconciliations, liveDesired, halts, + cleanupRemovedMarkets, cleanup, make, setRate: (value: bigint) => (rate = value), @@ -114,6 +118,29 @@ const harness = (configs: readonly LadderConfig[] = [config()]) => { } describe('LadderMarketMakerService', () => { + test('cleans removed markets before rejecting an empty strategy', async () => { + const subject = harness([]) + + await expect(subject.service.runOnce()).rejects.toMatchObject({ + name: 'LadderConfigurationError', + field: 'ladder' + }) + + expect(subject.cleanupRemovedMarkets).toHaveBeenCalledTimes(1) + }) + + test('reads active ownership before position capacity', async () => { + const subject = harness() + + await subject.service.runOnce() + + expect(subject.reads.slice(0, 3)).toEqual([ + `active:${marketId}`, + `market:${marketId}`, + `rate:${marketId}` + ]) + }) + test('monitors sequential cycles and cleans owned groups after shutdown', async () => { const subject = harness([{ ...config(), loopIntervalSeconds: 1 }]) const controller = new AbortController() diff --git a/bots/market-making/test/application/market-making/market-making-mutation.utils.test.ts b/bots/market-making/test/application/market-making/market-making-mutation.utils.test.ts index d409e51a..227b2dcb 100644 --- a/bots/market-making/test/application/market-making/market-making-mutation.utils.test.ts +++ b/bots/market-making/test/application/market-making/market-making-mutation.utils.test.ts @@ -99,4 +99,30 @@ describe('serializeMarketMakingWrites', () => { releaseBootstrap?.() await mutation }) + + test('forwards removed-market cleanup through the shared mutation queue', async () => { + const events: string[] = [] + let releaseBootstrap: (() => void) | undefined + const services = createServices(events) + services.bootstrap.reconcile = mock( + () => + new Promise(resolve => { + events.push('bootstrap:start') + releaseBootstrap = resolve + }) + ) + services.ladder.cleanupRemovedMarkets = mock(async () => { + events.push('ladder:cleanup-removed') + }) + const serialized = serializeMarketMakingWrites(services) + + const mutation = serialized.bootstrap.reconcile({ marketId, reason: 'publish' }) + const cleanup = serialized.ladder.cleanupRemovedMarkets?.() + await Promise.resolve() + + expect(events).toEqual(['bootstrap:start']) + releaseBootstrap?.() + await Promise.all([mutation, cleanup]) + expect(events).toEqual(['bootstrap:start', 'ladder:cleanup-removed']) + }) }) diff --git a/bots/market-making/test/bootstrap.test.ts b/bots/market-making/test/bootstrap.test.ts index fd4894ad..241bc896 100644 --- a/bots/market-making/test/bootstrap.test.ts +++ b/bots/market-making/test/bootstrap.test.ts @@ -295,6 +295,19 @@ describe('createApplication', () => { } const application = createApplication(environment, { createState: () => state, + createLadderAdapters: () => ({ + positions: { readMarket: async () => ({}) }, + rates: { readRate: async () => 500n }, + make: { + cleanupRemovedMarkets: async () => { + events.push('cleanup') + }, + readActive: async () => undefined, + reconcile: async () => {}, + hardHalt: async () => {}, + cleanup: async () => {} + } + }), createBootstrapAdapters: () => { events.push('bootstrap') return { @@ -324,7 +337,7 @@ describe('createApplication', () => { }) expect(await application.run(['bootstrap'])).toEqual([]) - expect(events).toEqual(['readiness', 'bootstrap']) + expect(events).toEqual(['cleanup', 'readiness', 'bootstrap']) }) test('mm ladder passes readiness before running one ladder cycle', async () => { @@ -362,14 +375,31 @@ describe('createApplication', () => { expect(events).toEqual(['readiness', 'ladder']) }) - test('default-composes the ladder command and rejects an empty ladder config', async () => { + test('default-composes the ladder command and rejects an empty ladder config after cleanup', async () => { + const events: string[] = [] let readinessReads = 0 const state = readyState() state.getChainId = async () => { + events.push('readiness') readinessReads += 1 return 8453 } - const application = createApplication(environment, { createState: () => state }) + const application = createApplication(environment, { + createState: () => state, + createLadderAdapters: () => ({ + positions: { readMarket: async () => ({}) }, + rates: { readRate: async () => 500n }, + make: { + cleanupRemovedMarkets: async () => { + events.push('cleanup') + }, + readActive: async () => undefined, + reconcile: async () => {}, + hardHalt: async () => {}, + cleanup: async () => {} + } + }) + }) const error = await application.run(['ladder']).catch(value => value) @@ -378,6 +408,30 @@ describe('createApplication', () => { field: 'ladder' }) expect(readinessReads).toBe(1) + expect(events).toEqual(['cleanup', 'readiness', 'cleanup']) + }) + + test('runs removed-market cleanup before the start command zero-config guard', async () => { + const cleanupRemovedMarkets = mock(async () => {}) + const application = createApplication(environment, { + createLadderAdapters: () => ({ + positions: { readMarket: async () => ({}) }, + rates: { readRate: async () => 500n }, + make: { + cleanupRemovedMarkets, + readActive: async () => undefined, + reconcile: async () => {}, + hardHalt: async () => {}, + cleanup: async () => {} + } + }) + }) + + await expect(application.run(['start'])).rejects.toMatchObject({ + name: 'BootstrapConfigurationError', + field: 'bootstrap' + }) + expect(cleanupRemovedMarkets).toHaveBeenCalledTimes(1) }) test('default-composes PositionBootstrapService when only its production ports are replaced', async () => { diff --git a/bots/market-making/test/e2e/constants.ts b/bots/market-making/test/e2e/constants.ts index 1c42cb99..fee98bcd 100644 --- a/bots/market-making/test/e2e/constants.ts +++ b/bots/market-making/test/e2e/constants.ts @@ -6,6 +6,9 @@ import { privateKeyToAccount } from 'viem/accounts' export const ANVIL_DEFAULT_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as Hex export const ANVIL_DEFAULT_ACCOUNT = privateKeyToAccount(ANVIL_DEFAULT_PRIVATE_KEY) +export const ANVIL_TAKER_PRIVATE_KEY = + '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as Hex +export const ANVIL_TAKER_ACCOUNT = privateKeyToAccount(ANVIL_TAKER_PRIVATE_KEY) export const MARKET_ID = '0x168e31250e0008b50d2255a5ab85e0265acd6c12e4f9a1336134b36a65a47937' as Hex export const REFERENCE_MARKET_ID = @@ -14,7 +17,7 @@ export const REFERENCE_MARKET_ID = export const MIDNIGHT = getAddress('0xAdedD8ab6dE832766Fedf0FaC4992E5C4D3EA18A') export const ECRECOVER_RATIFIER = getAddress('0xd6e70365C8E8DDa9a4ca662C07bbE663b017755E') export const USDC = getAddress('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913') -const CBBTC = getAddress('0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf') +export const CBBTC = getAddress('0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf') const ORACLE = getAddress('0x663BECd10daE6C4A3Dcd89F1d76c1174199639B9') export const MARKET = { diff --git a/bots/market-making/test/e2e/fork-actions.ts b/bots/market-making/test/e2e/fork-actions.ts new file mode 100644 index 00000000..29d47414 --- /dev/null +++ b/bots/market-making/test/e2e/fork-actions.ts @@ -0,0 +1,352 @@ +import { midnightAbi, Offer, OfferUtils, Tree } from '@morpho-org/midnight-sdk' +import { morphoViemExtension } from '@morpho-org/morpho-sdk' +import { + createWalletClient, + encodeAbiParameters, + erc20Abi, + http, + isAddressEqual, + keccak256, + maxUint256, + pad, + parseAbiParameters, + publicActions, + toHex +} from 'viem' +import { base } from 'viem/chains' + +import type { AnvilHandle } from './anvil' +import type { RouterApiHandle } from './router-api' + +import { prepareBootstrapRequirements } from '../../src/infrastructure/bootstrap/bootstrap-requirements.utils' +import { bootstrapMakeLendArguments } from '../../src/infrastructure/bootstrap/production-bootstrap' +import { + ANVIL_DEFAULT_ACCOUNT, + ANVIL_TAKER_ACCOUNT, + CBBTC, + ECRECOVER_RATIFIER, + MARKET, + MARKET_ID, + USDC +} from './constants' + +const TOKEN_BALANCE_STORAGE_SLOT = 9n +const TAKER_COLLATERAL = 100_000_000n + +const tokenBalanceStorageKey = (account: `0x${string}`) => + keccak256( + encodeAbiParameters(parseAbiParameters('address, uint256'), [ + account, + TOKEN_BALANCE_STORAGE_SLOT + ]) + ) + +/** + * Executes a real partial or full maker-lend fill through Morpho SDK and forked Midnight contracts. + * @param anvil - Running Base fork. + * @param router - Stateful HTTP Router boundary indexing published payloads. + * @param assets - Positive USDC assets borrowed from the maker offer. + * @returns Successful fork transaction receipt. + * @throws When the Router has no buy offer or any SDK requirement/simulation/transaction fails. + * @remarks Funds only the disposable fork taker through a pinned token storage-layout fixture. + */ +export const takeMakerLend = async ( + anvil: AnvilHandle, + router: RouterApiHandle, + assets: bigint +) => { + const active = await router.activeOffers() + const item = active.find(candidate => OfferUtils.toStruct({ offer: candidate.offer }).buy) + if (!item) throw new TypeError('Expected an active maker-lend offer') + + await anvil.client.setStorageAt({ + address: CBBTC, + index: tokenBalanceStorageKey(ANVIL_TAKER_ACCOUNT.address), + value: pad(toHex(TAKER_COLLATERAL), { size: 32 }) + }) + const wallet = createWalletClient({ + account: ANVIL_TAKER_ACCOUNT, + chain: base, + transport: http(anvil.rpcUrl) + }) + .extend(publicActions) + .extend(morphoViemExtension({ supportSignature: true, supportDeployless: true })) + const midnight = wallet.morpho.midnight(base.id) + const marketData = await midnight.getMarketData(MARKET_ID) + const offer = OfferUtils.toStruct({ offer: item.offer }) + const output = midnight.supplyCollateralTakeBorrow({ + accountAddress: ANVIL_TAKER_ACCOUNT.address, + marketData, + collateralAssets: TAKER_COLLATERAL, + loanAssets: assets, + maxUnits: assets * 2n, + takeableOffers: [{ units: assets * 2n, offer, ratifierData: item.ratifierData }], + deadline: maxUint256 + }) + for (const requirement of await output.getRequirements()) { + if ('sign' in requirement) throw new TypeError('Unexpected signing requirement for taker fill') + const hash = await wallet.sendTransaction({ + to: requirement.to, + data: requirement.data, + value: requirement.value + }) + const receipt = await wallet.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new TypeError('Taker requirement reverted') + } + const hash = await wallet.sendTransaction(output.buildTx()) + const receipt = await wallet.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new TypeError('Maker-lend take reverted') + const block = await wallet.getBlock({ blockTag: 'latest' }) + const makerPosition = ( + await midnight.getPositionData({ + marketId: MARKET_ID, + accountAddress: offer.maker, + parameters: { blockNumber: block.number } + }) + ).accrueInterest(block.timestamp) + return { receipt, makerPosition } +} + +/** Repays the fork taker's real Midnight debt so maker credit becomes fully withdrawable. */ +export const repayTakerDebt = async (anvil: AnvilHandle) => { + await anvil.client.setStorageAt({ + address: USDC, + index: tokenBalanceStorageKey(ANVIL_TAKER_ACCOUNT.address), + value: pad(toHex(2_000_000_000n), { size: 32 }) + }) + const wallet = createWalletClient({ + account: ANVIL_TAKER_ACCOUNT, + chain: base, + transport: http(anvil.rpcUrl) + }) + .extend(publicActions) + .extend(morphoViemExtension({ supportSignature: true, supportDeployless: true })) + const midnight = wallet.morpho.midnight(base.id) + const marketData = await midnight.getMarketData(MARKET_ID) + const output = midnight.repayWithdrawCollateral({ + accountAddress: ANVIL_TAKER_ACCOUNT.address, + marketData, + repayAssets: 500_000_000n, + withdrawCollateralAssets: 0n, + deadline: maxUint256 + }) + for (const requirement of await output.getRequirements()) { + if ('sign' in requirement) throw new TypeError('Unexpected signing requirement for repayment') + const hash = await wallet.sendTransaction({ + to: requirement.to, + data: requirement.data, + value: requirement.value + }) + await wallet.waitForTransactionReceipt({ hash }) + } + const hash = await wallet.sendTransaction(output.buildTx()) + const receipt = await wallet.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new TypeError('Taker repayment reverted') + return receipt +} + +/** + * Redeems all maker credit through the real SDK and forked Midnight contract. + * @param anvil - Running Base fork. + * @param maker - Maker account bound to the production runtime. + * @returns Successful redemption receipt, or undefined when no credit remains. + */ +export const redeemMakerCredit = async ( + anvil: AnvilHandle, + maker: typeof import('./constants').ANVIL_DEFAULT_ACCOUNT +) => { + const wallet = createWalletClient({ account: maker, chain: base, transport: http(anvil.rpcUrl) }) + .extend(publicActions) + .extend(morphoViemExtension({ supportSignature: true, supportDeployless: true })) + const midnight = wallet.morpho.midnight(base.id) + const block = await wallet.getBlock({ blockTag: 'latest' }) + const position = ( + await midnight.getPositionData({ + marketId: MARKET_ID, + accountAddress: maker.address, + parameters: { blockNumber: block.number } + }) + ).accrueInterest(block.timestamp) + if (position.credit === 0n) return undefined + const hash = await wallet.sendTransaction( + midnight.redeem({ accountAddress: maker.address, positionData: position }).buildTx() + ) + const receipt = await wallet.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new TypeError('Maker credit redemption reverted') + return receipt +} + +const seedExternalMakerCredit = async (anvil: AnvilHandle, router: RouterApiHandle) => { + const wallet = createWalletClient({ + account: ANVIL_TAKER_ACCOUNT, + chain: base, + transport: http(anvil.rpcUrl) + }) + .extend(publicActions) + .extend(morphoViemExtension({ supportSignature: true, supportDeployless: true })) + const midnight = wallet.morpho.midnight(base.id) + const marketData = await midnight.getMarketData(MARKET_ID) + await anvil.client.setStorageAt({ + address: USDC, + index: tokenBalanceStorageKey(ANVIL_TAKER_ACCOUNT.address), + value: pad(toHex(2_000_000n), { size: 32 }) + }) + const approvalHash = await wallet.writeContract({ + address: USDC, + abi: erc20Abi, + functionName: 'approve', + args: [MARKET.midnight, maxUint256] + }) + if ((await wallet.waitForTransactionReceipt({ hash: approvalHash })).status !== 'success') { + throw new TypeError('External maker seed approval reverted') + } + const lendOffer = Offer.create({ + market: marketData.params, + buy: true, + maker: ANVIL_TAKER_ACCOUNT.address, + tick: 6_744n, + expiry: marketData.params.maturity, + ratifier: ECRECOVER_RATIFIER, + maxAssets: 1_000_000n, + continuousFeeCap: BigInt(marketData.continuousFee) + }) + const publication = await midnight.makeLend( + bootstrapMakeLendArguments({ + accountAddress: ANVIL_TAKER_ACCOUNT.address, + offers: [lendOffer], + validation: { apiUrl: `${router.baseUrl}/v0/midnight` }, + loanToken: USDC, + loanAssets: 1_000_000n, + reservedLoanAssets: 0n + }) + ) + const { signatures: publicationSignatures } = await prepareBootstrapRequirements( + await publication.getRequirements(), + (requirement, account) => requirement.sign(wallet, account) as never, + { + kind: 'ecrecover', + target: ECRECOVER_RATIFIER, + root: Tree.create([lendOffer]).root, + account: ANVIL_TAKER_ACCOUNT.address, + offers: 1 + } + ) + const publicationHash = await wallet.sendTransaction( + publication.buildTx(publicationSignatures as Parameters[0]) + ) + if ((await wallet.waitForTransactionReceipt({ hash: publicationHash })).status !== 'success') { + throw new TypeError('External maker seed publication reverted') + } + + const item = (await router.activeOffers()).find(candidate => { + const offer = OfferUtils.toStruct({ offer: candidate.offer }) + return offer.buy && isAddressEqual(offer.maker, ANVIL_TAKER_ACCOUNT.address) + }) + if (!item) throw new TypeError('Expected external maker seed offer') + await anvil.client.setStorageAt({ + address: CBBTC, + index: tokenBalanceStorageKey(ANVIL_DEFAULT_ACCOUNT.address), + value: pad(toHex(TAKER_COLLATERAL), { size: 32 }) + }) + const borrower = createWalletClient({ + account: ANVIL_DEFAULT_ACCOUNT, + chain: base, + transport: http(anvil.rpcUrl) + }) + .extend(publicActions) + .extend(morphoViemExtension({ supportSignature: true, supportDeployless: true })) + const seedOffer = OfferUtils.toStruct({ offer: item.offer }) + const take = borrower.morpho.midnight(base.id).supplyCollateralTakeBorrow({ + accountAddress: ANVIL_DEFAULT_ACCOUNT.address, + marketData, + collateralAssets: TAKER_COLLATERAL, + loanAssets: 1_000_000n, + maxUnits: 2_000_000n, + takeableOffers: [{ units: 2_000_000n, offer: seedOffer, ratifierData: item.ratifierData }], + deadline: maxUint256 + }) + for (const requirement of await take.getRequirements()) { + if ('sign' in requirement) throw new TypeError('Unexpected signing requirement for seed fill') + const hash = await borrower.sendTransaction({ + to: requirement.to, + data: requirement.data, + value: requirement.value + }) + if ((await borrower.waitForTransactionReceipt({ hash })).status !== 'success') { + throw new TypeError('External maker seed requirement reverted') + } + } + const takeHash = await borrower.sendTransaction(take.buildTx()) + if ((await borrower.waitForTransactionReceipt({ hash: takeHash })).status !== 'success') { + throw new TypeError('External maker seed fill reverted') + } +} + +/** Publishes one real external maker-sell offer at an exact tick through SDK and mempool. */ +export const publishMakerSell = async ( + anvil: AnvilHandle, + router: RouterApiHandle, + tick: bigint +) => { + const wallet = createWalletClient({ + account: ANVIL_TAKER_ACCOUNT, + chain: base, + transport: http(anvil.rpcUrl) + }) + .extend(publicActions) + .extend(morphoViemExtension({ supportSignature: true, supportDeployless: true })) + const midnight = wallet.morpho.midnight(base.id) + const authorizationHash = await wallet.writeContract({ + address: MARKET.midnight, + abi: midnightAbi, + functionName: 'setIsAuthorized', + args: [ECRECOVER_RATIFIER, true, ANVIL_TAKER_ACCOUNT.address] + }) + const authorizationReceipt = await wallet.waitForTransactionReceipt({ hash: authorizationHash }) + if (authorizationReceipt.status !== 'success') { + throw new TypeError('External maker authorization reverted') + } + await seedExternalMakerCredit(anvil, router) + const marketData = await midnight.getMarketData(MARKET_ID) + const offer = Offer.create({ + market: marketData.params, + buy: false, + maker: ANVIL_TAKER_ACCOUNT.address, + tick, + expiry: marketData.params.maturity, + ratifier: ECRECOVER_RATIFIER, + reduceOnly: true, + maxUnits: 1_000_000n, + continuousFeeCap: BigInt(marketData.continuousFee) + }) + const output = await midnight.makeBorrow({ + accountAddress: ANVIL_TAKER_ACCOUNT.address, + offers: [offer], + validation: { apiUrl: `${router.baseUrl}/v0/midnight` } + }) + const { signatures } = await prepareBootstrapRequirements( + await output.getRequirements(), + (requirement, account) => requirement.sign(wallet, account) as never, + { + kind: 'ecrecover', + target: ECRECOVER_RATIFIER, + root: Tree.create([offer]).root, + account: ANVIL_TAKER_ACCOUNT.address, + offers: 1 + } + ) + const hash = await wallet.sendTransaction( + output.buildTx(signatures as Parameters[0]) + ) + const receipt = await wallet.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') throw new TypeError('External sell publication reverted') + return receipt +} + +/** Clears the maker's USDC wallet balance through the pinned real-token storage fixture. */ +export const clearMakerCash = (anvil: AnvilHandle) => + anvil.client.setStorageAt({ + address: USDC, + index: tokenBalanceStorageKey(ANVIL_DEFAULT_ACCOUNT.address), + value: pad(toHex(0n), { size: 32 }) + }) diff --git a/bots/market-making/test/e2e/market-making.e2e.test.ts b/bots/market-making/test/e2e/market-making.e2e.test.ts new file mode 100644 index 00000000..0804d541 --- /dev/null +++ b/bots/market-making/test/e2e/market-making.e2e.test.ts @@ -0,0 +1,4 @@ +import { inMemoryRouterRemoved } from './mock-router' + +/** Signals that genuine workflow coverage lives in `market-making.fork.e2e.test.ts`. */ +export const marketMakingE2eUsesFork = inMemoryRouterRemoved diff --git a/bots/market-making/test/e2e/market-making.fork.e2e.test.ts b/bots/market-making/test/e2e/market-making.fork.e2e.test.ts new file mode 100644 index 00000000..69e4ec47 --- /dev/null +++ b/bots/market-making/test/e2e/market-making.fork.e2e.test.ts @@ -0,0 +1,356 @@ +import { OfferUtils } from '@morpho-org/midnight-sdk' +import { afterAll, beforeAll, beforeEach, describe, expect, setSystemTime, test } from 'bun:test' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import type { AnvilHandle } from './anvil' +import type { RouterApiHandle } from './router-api' + +import { PositionBootstrapService } from '../../src/application/bootstrap/position-bootstrap.service' +import { LadderMarketMakerService } from '../../src/application/ladder/ladder-market-maker.service' +import { createApplication } from '../../src/bootstrap' +import { ConfigService } from '../../src/config/config.service' +import { createProductionBootstrapAdapters } from '../../src/infrastructure/bootstrap/production-bootstrap' +import { createProductionLadderAdapters } from '../../src/infrastructure/ladder/production-ladder' +import { startAnvil, stopAnvil } from './anvil' +import { + ANVIL_DEFAULT_ACCOUNT, + ANVIL_DEFAULT_PRIVATE_KEY, + ECRECOVER_RATIFIER, + MARKET_ID, + MAXIMUM_LEND_EXPOSURE, + MIDNIGHT, + NATIVE_RESERVE, + REFERENCE_MARKET_ID, + USDC +} from './constants' +import { + clearMakerCash, + publishMakerSell, + redeemMakerCredit, + repayTakerDebt, + takeMakerLend +} from './fork-actions' +import { startRouterApi, stopRouterApi } from './router-api' +import { setupMaker } from './setup-maker' + +const PINNED_FORK_TIMESTAMP = 1_784_589_348n +const PINNED_WALL_TIMESTAMP = PINNED_FORK_TIMESTAMP + 150n + +const bootstrapConfiguration = JSON.stringify([ + { + marketId: MARKET_ID, + creditTarget: '500000000', + acceptanceAssets: '0', + offerSize: '500000000', + premiumBps: '0', + maximumMarketExposure: '2000000000', + maximumTotalExposure: '2000000000', + minimumRateBps: '1', + maximumRateBps: '100000', + autoRefill: true + } +]) + +const ladderConfiguration = ( + quotePremiumBps: string, + movementToleranceBps = '10', + maximumTotalExposureAssets = '200000000' +) => + JSON.stringify([ + { + marketId: MARKET_ID, + quotePremiumBps, + spreadBps: '200', + stepBps: '100', + rungCount: '3', + sizeSkewBps: '0', + lowerRateBudgetAssets: '100000000', + higherRateBudgetAssets: '100000000', + targetMarketExposureAssets: '200000000', + maximumTotalExposureAssets, + minimumOfferAssets: '1', + groupMode: 'shared-rung', + loopIntervalSeconds: '3600', + movementToleranceBps, + minimumRateBps: '1', + maximumRateBps: '100000' + } + ]) + +const environment = (rpcUrl: string, apiBaseUrl: string) => ({ + CHAIN_ID: '8453', + RPC_URL: rpcUrl, + REFERENCE_RPC_URL: rpcUrl, + MAKER_PRIVATE_KEY: ANVIL_DEFAULT_PRIVATE_KEY, + MAKER_ADDRESS: ANVIL_DEFAULT_ACCOUNT.address, + MIDNIGHT_ADDRESS: MIDNIGHT, + LOAN_ASSET_ADDRESS: USDC, + RATIFIER_ADDRESS: ECRECOVER_RATIFIER, + MARKET_IDS: MARKET_ID, + REFERENCE_MARKET_ID, + NATIVE_RESERVE_WEI: String(NATIVE_RESERVE), + MAXIMUM_LEND_EXPOSURE_ASSETS: String(MAXIMUM_LEND_EXPOSURE), + MORPHO_API_BASE_URL: apiBaseUrl, + ROUTER_API_BASE_URL: apiBaseUrl, + REQUEST_TIMEOUT_MS: '30000', + BOOTSTRAP_MARKETS: bootstrapConfiguration, + LADDER_MARKETS: ladderConfiguration('0') +}) + +const createProductionLadderRuntime = async (config: ConfigService) => { + const adapters = await createProductionLadderAdapters(config) + const service = new LadderMarketMakerService( + adapters.positions, + adapters.rates, + adapters.make, + config.ladder + ) + return { + runOnce: () => service.runOnce(), + shutdown: (cleanup: boolean) => (cleanup ? adapters.make.cleanup() : Promise.resolve()) + } +} + +describe('market-making workflow on a pinned Base fork', () => { + let anvil: AnvilHandle | undefined + let api: RouterApiHandle | undefined + let stateDirectory: string | undefined + const originalStateHome = process.env.XDG_STATE_HOME + + const resetStateDirectory = async () => { + if (!stateDirectory) return + await rm(stateDirectory, { recursive: true, force: true }) + await mkdir(stateDirectory, { recursive: true }) + } + + beforeAll(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'market-making-e2e-state-')) + process.env.XDG_STATE_HOME = stateDirectory + setSystemTime(new Date(Number(PINNED_WALL_TIMESTAMP) * 1_000)) + anvil = await startAnvil(8547) + await anvil.client.setNextBlockTimestamp({ timestamp: PINNED_FORK_TIMESTAMP }) + await anvil.client.mine({ blocks: 1 }) + api = startRouterApi(anvil.rpcUrl) + await setupMaker(anvil) + }, 60_000) + + beforeEach(resetStateDirectory) + + afterAll(async () => { + await Promise.all([stopAnvil(anvil), stopRouterApi(api)]) + setSystemTime() + if (originalStateHome === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = originalStateHome + if (stateDirectory) await rm(stateDirectory, { recursive: true, force: true }) + }) + + test('enforces strict, equal, and adjacent-safe buy-to-sell spreads against a real external offer', async () => { + expect(anvil).toBeDefined() + expect(api).toBeDefined() + if (!anvil || !api) return + + const config = ConfigService.from(environment(anvil.rpcUrl, api.baseUrl)) + const baseline = await anvil.client.snapshot() + const baselineBlock = await anvil.client.getBlock({ blockTag: 'latest' }) + const externalOfferTimestamp = baselineBlock.timestamp + 1n + await anvil.client.setNextBlockTimestamp({ timestamp: externalOfferTimestamp }) + await publishMakerSell(anvil, api, 6_744n) + const probeAdapters = await createProductionBootstrapAdapters(config) + const probe = new PositionBootstrapService( + probeAdapters.positions, + probeAdapters.rates, + probeAdapters.make, + config.bootstrap + ) + expect(await probe.runOnce()).toMatchObject([{ status: 'applied', action: 'publish' }]) + const published = await api.activeOffers() + expect(published).toHaveLength(2) + const buy = published.find(item => OfferUtils.toStruct({ offer: item.offer }).buy) + expect(buy).toBeDefined() + if (!buy) throw new TypeError('Expected a production bootstrap buy offer') + const buyTick = OfferUtils.toStruct({ offer: buy.offer }).tick + await anvil.client.revert({ id: baseline }) + await resetStateDirectory() + expect(await api.activeOffers()).toHaveLength(0) + + const tickSpacing = 4n + const spreadCases = [ + { label: 'strict crossing', sellTick: buyTick - tickSpacing, accepted: false }, + { label: 'equal boundary', sellTick: buyTick, accepted: false }, + { label: 'adjacent safe boundary', sellTick: buyTick + tickSpacing, accepted: true } + ] as const + for (const spreadCase of spreadCases) { + const snapshot = await anvil.client.snapshot() + try { + await anvil.client.setNextBlockTimestamp({ timestamp: externalOfferTimestamp }) + expect((await publishMakerSell(anvil, api, spreadCase.sellTick)).status).toBe('success') + expect(await api.activeOffers()).toHaveLength(1) + const adapters = await createProductionBootstrapAdapters(config) + let makeFailure: unknown + const make = { + reconcile: async (parameters: Parameters[0]) => { + try { + await adapters.make.reconcile(parameters) + } catch (error) { + makeFailure = error + throw error + } + }, + hardHalt: (parameters: Parameters[0]) => + adapters.make.hardHalt(parameters), + cleanup: (parameters?: Parameters[0]) => + adapters.make.cleanup(parameters) + } + const bootstrap = new PositionBootstrapService( + adapters.positions, + adapters.rates, + make, + config.bootstrap + ) + const result = await bootstrap.runOnce() + if (spreadCase.accepted) { + expect(makeFailure, spreadCase.label).toBeUndefined() + expect(result, spreadCase.label).toMatchObject([{ status: 'applied', action: 'publish' }]) + expect(await api.activeOffers()).toHaveLength(2) + } else { + expect(makeFailure, spreadCase.label).toMatchObject({ operation: 'negative-spread' }) + expect(result, spreadCase.label).toMatchObject([ + { status: 'failed', stage: 'make', errorName: 'BootstrapAdapterError' } + ]) + expect(await api.activeOffers()).toHaveLength(1) + } + } finally { + await anvil.client.revert({ id: snapshot }) + await resetStateDirectory() + } + } + expect(await api.activeOffers()).toHaveLength(0) + }, 300_000) + + test('uses the whole external book while cleaning up only explicitly owned ladder groups', async () => { + expect(anvil).toBeDefined() + expect(api).toBeDefined() + if (!anvil || !api) return + const baseline = await anvil.client.snapshot() + try { + const config = ConfigService.from(environment(anvil.rpcUrl, api.baseUrl)) + const runtime = await createProductionLadderRuntime(config) + expect(await runtime.runOnce()).toMatchObject([{ action: 'publish' }]) + expect(await api.activeOffers()).toHaveLength(3) + expect((await publishMakerSell(anvil, api, 6_744n)).status).toBe('success') + expect(await api.activeOffers()).toHaveLength(4) + await runtime.shutdown(true) + expect(await api.activeOffers()).toHaveLength(1) + } finally { + await anvil.client.revert({ id: baseline }) + await resetStateDirectory() + } + + const crossing = await anvil.client.snapshot() + try { + const config = ConfigService.from(environment(anvil.rpcUrl, api.baseUrl)) + expect((await publishMakerSell(anvil, api, 0n)).status).toBe('success') + expect(await (await createProductionLadderRuntime(config)).runOnce()).toMatchObject([ + { status: 'failed', stage: 'reconcile', errorName: 'LadderAdapterError' } + ]) + expect(await api.activeOffers()).toHaveLength(1) + } finally { + await anvil.client.revert({ id: crossing }) + await resetStateDirectory() + } + }, 240_000) + + test('publishes a bootstrap offer through production composition and the real mempool contract', async () => { + expect(anvil).toBeDefined() + expect(api).toBeDefined() + if (!anvil || !api) return + const applicationEnvironment = environment(anvil.rpcUrl, api.baseUrl) + const result = await createApplication(applicationEnvironment).run(['bootstrap']) + + expect(result).toMatchObject([{ status: 'applied', action: 'publish' }]) + expect(await api.activeOffers()).toHaveLength(1) + const fill = await takeMakerLend(anvil, api, 250_000_000n) + expect(fill.receipt.status).toBe('success') + expect(fill.makerPosition.credit).toBeGreaterThan(0n) + + const partialResult = await createApplication(applicationEnvironment).run(['bootstrap']) + expect(partialResult).toMatchObject([{ status: 'applied', action: 'replace' }]) + const partialOffers = await api.activeOffers() + expect(partialOffers).toHaveLength(1) + const remainingAssets = OfferUtils.toStruct({ offer: partialOffers[0]!.offer }).maxAssets + expect(remainingAssets).toBeGreaterThan(0n) + expect(remainingAssets).toBeLessThan(250_000_000n) + + const completedFill = await takeMakerLend(anvil, api, remainingAssets) + expect(completedFill.makerPosition.credit).toBeGreaterThanOrEqual(500_000_000n) + const completeResult = await createApplication(applicationEnvironment).run(['bootstrap']) + expect(completeResult).toEqual([ + { marketId: MARKET_ID, status: 'observed', action: 'target-reached' } + ]) + expect(await api.activeOffers()).toHaveLength(0) + + expect((await repayTakerDebt(anvil)).status).toBe('success') + expect((await redeemMakerCredit(anvil, ANVIL_DEFAULT_ACCOUNT))?.status).toBe('success') + const strategySnapshot = await anvil.client.snapshot() + const ladderEnvironment = { + ...applicationEnvironment, + LADDER_MARKETS: ladderConfiguration('0', '10', '1000000000') + } + const ladderResult = await createApplication(ladderEnvironment).run(['ladder']) + expect(ladderResult).toEqual([ + { marketId: MARKET_ID, status: 'applied', action: 'publish', reason: 'publish' } + ]) + expect(await api.activeOffers()).toHaveLength(3) + const setupAfterLadder = (await createApplication(ladderEnvironment).run(['setup-check'])) as { + ready: boolean + checks: { name: string; observed: unknown }[] + } + expect(setupAfterLadder.ready).toBe(true) + expect(setupAfterLadder.checks.find(check => check.name === 'offers')?.observed).toEqual({ + unknownNamespaces: [], + unknownMarketIds: [], + invertedMarketIds: [] + }) + const ladder = await createProductionLadderRuntime(ConfigService.from(ladderEnvironment)) + expect(await ladder.runOnce()).toEqual([ + { marketId: MARKET_ID, status: 'observed', action: 'rest' } + ]) + await ladder.shutdown(false) + expect(await api.activeOffers()).toHaveLength(3) + + const recentered = await createProductionLadderRuntime( + ConfigService.from({ + ...ladderEnvironment, + LADDER_MARKETS: ladderConfiguration('500', '10', '1000000000') + }) + ) + expect(await recentered.runOnce()).toMatchObject([{ action: 'replace', reason: 'recenter' }]) + expect(await api.activeOffers()).toHaveLength(3) + + await clearMakerCash(anvil) + expect(await recentered.runOnce()).toMatchObject([{ action: 'replace' }]) + expect(await api.activeOffers()).toHaveLength(0) + + await anvil.client.revert({ id: strategySnapshot }) + await resetStateDirectory() + expect(await api.activeOffers()).toHaveLength(0) + const sellRuntime = await createProductionLadderRuntime( + ConfigService.from({ + ...ladderEnvironment, + LADDER_MARKETS: ladderConfiguration('-100', '1000', '1000000000') + }) + ) + expect(await sellRuntime.runOnce()).toMatchObject([{ action: 'publish', reason: 'publish' }]) + const restartOffers = await api.activeOffers() + expect(restartOffers).toHaveLength(3) + expect(restartOffers.map(item => OfferUtils.toStruct({ offer: item.offer }).buy)).toEqual([ + true, + true, + true + ]) + await sellRuntime.shutdown(true) + expect(await api.activeOffers()).toHaveLength(0) + }, 180_000) +}) diff --git a/bots/market-making/test/e2e/mock-router.ts b/bots/market-making/test/e2e/mock-router.ts new file mode 100644 index 00000000..d6446237 --- /dev/null +++ b/bots/market-making/test/e2e/mock-router.ts @@ -0,0 +1,2 @@ +/** Signals that the former in-memory Router was replaced by the HTTP/fork fixture. */ +export const inMemoryRouterRemoved = true diff --git a/bots/market-making/test/e2e/router-api.test.ts b/bots/market-making/test/e2e/router-api.test.ts new file mode 100644 index 00000000..7f1cf7a6 --- /dev/null +++ b/bots/market-making/test/e2e/router-api.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test' + +import { MARKET_ID } from './constants' +import { routerOfferDto } from './router-api' + +describe('router offer-group fixture DTO', () => { + test('includes the continuous fee cap required by production group parsing', () => { + expect( + routerOfferDto({ + maker: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + buy: true, + tick: 6_744n, + continuousFeeCap: 17n, + market: { maturity: 1_785_510_000n } + }) + ).toEqual({ + market_id: MARKET_ID, + maker: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + buy: true, + tick: 6_744, + continuous_fee_cap: '17', + market: { maturity: 1_785_510_000 } + }) + }) +}) diff --git a/bots/market-making/test/e2e/router-api.ts b/bots/market-making/test/e2e/router-api.ts new file mode 100644 index 00000000..5a7b800c --- /dev/null +++ b/bots/market-making/test/e2e/router-api.ts @@ -0,0 +1,255 @@ +import type { Server } from 'bun' +import type { Address, Hex } from 'viem' + +import { EcrecoverRatifierUtils, midnightAbi, OfferUtils, Payload } from '@morpho-org/midnight-sdk' +import { getChainAddress } from '@morpho-org/morpho-ts' +import { createPublicClient, getAddress, http, isAddressEqual } from 'viem' +import { base } from 'viem/chains' + +import { ECRECOVER_RATIFIER, MARKET, MARKET_ID, MIDNIGHT } from './constants' + +const BASE_FORK_BLOCK = 48_900_000n + +const json = (body: unknown, status = 200) => Response.json(body, { status }) + +type IndexedOffer = { + offer: Awaited>[number]['offer'] + ratifierData: Hex +} + +type RouterOffer = { + maker: Address + buy: boolean + tick: bigint + continuousFeeCap: bigint + market: { maturity: bigint } +} + +/** + * Serializes an SDK offer into the Router offer-group response shape consumed by production readers. + * @param offer - Decoded SDK offer struct. + * @returns Router-compatible nested offer DTO. + * @remarks The explicit continuous-fee-cap field keeps the fixture aligned with production parsing. + */ +export const routerOfferDto = (offer: RouterOffer) => ({ + market_id: MARKET_ID, + maker: offer.maker, + buy: offer.buy, + tick: Number(offer.tick), + continuous_fee_cap: String(offer.continuousFeeCap), + market: { maturity: Number(offer.market.maturity) } +}) + +/** Stateful HTTP fixture that indexes real mempool payload transactions from the Anvil fork. */ +export type RouterApiHandle = { + baseUrl: string + server: Server + /** Reads active offers after indexing all newly mined fork blocks. @returns Current unconsumed offers. */ + activeOffers(): Promise +} + +/** + * Starts a Router/Morpho HTTP boundary backed by the real Anvil fork transaction and consumption state. + * @param rpcUrl - Loopback Anvil JSON-RPC URL. + * @returns Running HTTP fixture and an active-offer inspection boundary. + * @remarks The fixture mocks only Router/API transport. Offer payloads must be published to the real + * Base Midnight mempool contract and consumption is read from the real forked Midnight contract. + */ +export const startRouterApi = (rpcUrl: string): RouterApiHandle => { + const client = createPublicClient({ chain: base, transport: http(rpcUrl) }) + const mempool = getAddress(getChainAddress(base.id, 'midnightMempool')) + const indexed = new Map() + let scannedThrough = BASE_FORK_BLOCK + let scannedHash: Hex | undefined + + const scan = async () => { + const latest = await client.getBlockNumber({ cacheTime: 0 }) + let reset = latest < scannedThrough + if (!reset && scannedHash && scannedThrough > BASE_FORK_BLOCK) { + const canonical = await client.getBlock({ blockNumber: scannedThrough }) + reset = canonical.hash !== scannedHash + } + if (reset) { + indexed.clear() + scannedThrough = BASE_FORK_BLOCK + scannedHash = undefined + } + for (let blockNumber = scannedThrough + 1n; blockNumber <= latest; blockNumber += 1n) { + const block = await client.getBlock({ blockNumber, includeTransactions: true }) + for (const transaction of block.transactions) { + if (!transaction.to || !isAddressEqual(transaction.to, mempool)) continue + const items = await Payload.decode(transaction.input) + for (const item of items) { + const offer = OfferUtils.toStruct({ offer: item.offer }) + indexed.set(`${offer.group}:${OfferUtils.hashStruct(offer)}`, { + offer: item.offer, + ratifierData: item.ratifierData, + blockNumber + }) + } + } + } + scannedThrough = latest + scannedHash = + latest > BASE_FORK_BLOCK ? (await client.getBlock({ blockNumber: latest })).hash : undefined + } + + const activeOffers = async () => { + await scan() + const active: IndexedOffer[] = [] + for (const item of indexed.values()) { + const offer = OfferUtils.toStruct({ offer: item.offer }) + const consumed = await client.readContract({ + address: MIDNIGHT, + abi: midnightAbi, + functionName: 'consumed', + args: [offer.maker, offer.group] + }) + const maximum = offer.maxAssets === 0n ? offer.maxUnits : offer.maxAssets + if (consumed < maximum) active.push(item) + } + return active + } + + const route = async (request: Request) => { + const { pathname } = new URL(request.url) + + if (request.method === 'POST' && pathname === '/v0/midnight/mempool/validate') { + const body = (await request.json()) as { chain_id?: unknown; payload?: unknown } + if (body.chain_id !== base.id || typeof body.payload !== 'string') { + return json({ message: 'invalid validation request' }, 400) + } + const items = await Payload.decode(body.payload as Hex) + for (const item of items) { + const offer = OfferUtils.toStruct({ offer: item.offer }) + if (!isAddressEqual(offer.ratifier, ECRECOVER_RATIFIER)) { + throw new TypeError('Unsupported ratifier') + } + if (item.ratifierData !== '0x') { + const verified = await EcrecoverRatifierUtils.verifyRatifierData({ + chainId: base.id, + offer: item.offer, + ratifierData: item.ratifierData + }) + if (!isAddressEqual(verified.signer, offer.maker)) { + throw new TypeError('Ratifier signer does not match maker') + } + } + } + return json({ data: { issues: [] } }) + } + + const takeableMatch = pathname.match( + /^\/v0\/midnight\/books\/(0x[0-9a-fA-F]{64})\/(asks|bids)\/takeable-offers$/ + ) + if (takeableMatch) { + const [, requestedMarketId, side] = takeableMatch + const data = (await activeOffers()).flatMap(item => { + const offer = OfferUtils.toStruct({ offer: item.offer }) + if (requestedMarketId !== MARKET_ID || offer.buy !== (side === 'bids')) return [] + return [ + { + market_id: MARKET_ID, + units: String(offer.maxUnits || offer.maxAssets), + offer: { group: offer.group, buy: offer.buy, tick: Number(offer.tick) } + } + ] + }) + return json({ data }) + } + + if (pathname === '/v0/midnight/books') { + return json({ + cursor: null, + data: [ + { + market_id: MARKET_ID, + id: MARKET_ID, + chain_id: MARKET.chainId, + midnight: MARKET.midnight, + loan_token: MARKET.loanToken, + collaterals: MARKET.collaterals, + maturity: MARKET.maturity, + rcf_threshold: MARKET.rcfThreshold, + enter_gate: MARKET.enterGate, + liquidator_gate: MARKET.liquidatorGate, + asks: [], + bids: [] + } + ] + }) + } + + if (pathname === '/v0/midnight/markets') { + return json({ + cursor: null, + data: [{ chain_id: MARKET.chainId, market_id: MARKET_ID, listed: true }] + }) + } + + if (pathname.startsWith('/v0/midnight/users/') && pathname.endsWith('/offer-groups')) { + const maker = getAddress(pathname.split('/').at(-2) as Address) + const offers = (await activeOffers()).filter(item => + isAddressEqual(OfferUtils.toStruct({ offer: item.offer }).maker, maker) + ) + const groups = new Map() + for (const item of offers) { + const group = OfferUtils.toStruct({ offer: item.offer }).group + groups.set(group, [...(groups.get(group) ?? []), item]) + } + const data = await Promise.all( + [...groups].map(async ([id, items]) => { + const structs = items.map(item => OfferUtils.toStruct({ offer: item.offer })) + const consumed = await client.readContract({ + address: MIDNIGHT, + abi: midnightAbi, + functionName: 'consumed', + args: [maker, id] + }) + return { + id, + chain_id: base.id, + consumed: String(consumed), + max_assets: String( + structs.reduce( + (maximum, offer) => (offer.maxAssets > maximum ? offer.maxAssets : maximum), + 0n + ) + ), + max_units: String( + structs.reduce( + (maximum, offer) => (offer.maxUnits > maximum ? offer.maxUnits : maximum), + 0n + ) + ), + offers: items.map(item => routerOfferDto(OfferUtils.toStruct({ offer: item.offer }))) + } + }) + ) + return json({ cursor: null, data }) + } + + if (pathname === '/v0/config/contracts') { + return json({ + cursor: null, + data: [ + { + chain_id: MARKET.chainId, + address: ECRECOVER_RATIFIER, + name: 'ecrecoverRatifier' + } + ] + }) + } + + return json({ message: 'unsupported e2e fixture route' }, 404) + } + + const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: route }) + return { baseUrl: server.url.origin, server, activeOffers } +} + +/** Stops the stateful Router API fixture. @param handle - Running fixture handle. @returns Completion after connections close. */ +export const stopRouterApi = async (handle: RouterApiHandle | undefined) => { + if (handle) await handle.server.stop(true) +} diff --git a/bots/market-making/test/e2e/setup-api.ts b/bots/market-making/test/e2e/setup-api.ts index 943afc3d..03ec1b09 100644 --- a/bots/market-making/test/e2e/setup-api.ts +++ b/bots/market-making/test/e2e/setup-api.ts @@ -1,8 +1,10 @@ import type { Server } from 'bun' -import { ECRECOVER_RATIFIER, MARKET, MARKET_ID } from './constants' +import { ANVIL_DEFAULT_ACCOUNT, ECRECOVER_RATIFIER, MARKET, MARKET_ID } from './constants' const json = (body: unknown, status = 200) => Response.json(body, { status }) +type SetupApiMode = 'ready' | 'books-failed' | 'offers-failed' +let mode: SetupApiMode = 'ready' const route = (request: Request) => { const { pathname } = new URL(request.url) @@ -32,12 +34,34 @@ const route = (request: Request) => { if (pathname === '/v0/midnight/markets') { return json({ cursor: null, - data: [{ chain_id: MARKET.chainId, market_id: MARKET_ID, listed: true }] + data: [{ chain_id: MARKET.chainId, market_id: MARKET_ID, listed: mode !== 'books-failed' }] }) } if (pathname.startsWith('/v0/midnight/users/') && pathname.endsWith('/offer-groups')) { - return json({ cursor: null, data: [] }) + return json({ + cursor: null, + data: + mode === 'offers-failed' + ? [ + { + id: `0x${'ab'.repeat(32)}`, + chain_id: MARKET.chainId, + consumed: '0', + max_assets: '1', + offers: [ + { + market_id: MARKET_ID, + maker: ANVIL_DEFAULT_ACCOUNT.address, + buy: true, + tick: 100, + market: { maturity: MARKET.maturity } + } + ] + } + ] + : [] + }) } if (pathname === '/v0/config/contracts') { @@ -59,6 +83,7 @@ const route = (request: Request) => { export type SetupApiHandle = { baseUrl: string server: Server + setMode(mode: SetupApiMode): void } /** @@ -69,8 +94,9 @@ export type SetupApiHandle = { * no maker offers. The caller must pass the result to {@link stopSetupApi}. */ export const startSetupApi = (): SetupApiHandle => { + mode = 'ready' const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: route }) - return { baseUrl: server.url.origin, server } + return { baseUrl: server.url.origin, server, setMode: value => void (mode = value) } } /** diff --git a/bots/market-making/test/e2e/setup-check.e2e.test.ts b/bots/market-making/test/e2e/setup-check.e2e.test.ts index fbefa4b1..cb31f7fc 100644 --- a/bots/market-making/test/e2e/setup-check.e2e.test.ts +++ b/bots/market-making/test/e2e/setup-check.e2e.test.ts @@ -1,4 +1,7 @@ +import { midnightAbi } from '@morpho-org/midnight-sdk' import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { createWalletClient, erc20Abi, http } from 'viem' +import { base } from 'viem/chains' import type { SetupCheckReport } from '../../src/application/setup/setup-check.service' import type { AnvilHandle } from './anvil' @@ -9,6 +12,7 @@ import { startAnvil, stopAnvil } from './anvil' import { ANVIL_DEFAULT_ACCOUNT, ANVIL_DEFAULT_PRIVATE_KEY, + ANVIL_TAKER_PRIVATE_KEY, ECRECOVER_RATIFIER, MAKER_USDC_BALANCE, MARKET_ID, @@ -29,6 +33,32 @@ const isSetupCheckReport = (value: unknown): value is SetupCheckReport => 'checks' in value && Array.isArray(value.checks) +const environment = (rpcUrl: string, apiBaseUrl: string) => ({ + CHAIN_ID: '8453', + RPC_URL: rpcUrl, + REFERENCE_RPC_URL: rpcUrl, + MAKER_PRIVATE_KEY: ANVIL_DEFAULT_PRIVATE_KEY, + MAKER_ADDRESS: ANVIL_DEFAULT_ACCOUNT.address, + MIDNIGHT_ADDRESS: MIDNIGHT, + LOAN_ASSET_ADDRESS: USDC, + RATIFIER_ADDRESS: ECRECOVER_RATIFIER, + MARKET_IDS: MARKET_ID, + REFERENCE_MARKET_ID, + NATIVE_RESERVE_WEI: String(NATIVE_RESERVE), + MAXIMUM_LEND_EXPOSURE_ASSETS: String(MAXIMUM_LEND_EXPOSURE), + MORPHO_API_BASE_URL: apiBaseUrl, + ROUTER_API_BASE_URL: apiBaseUrl, + REQUEST_TIMEOUT_MS: '30000' +}) + +type SetupFailure = { + name: SetupCheckReport['checks'][number]['name'] + additionallyFailed?: readonly SetupCheckReport['checks'][number]['name'][] + apiMode?: Parameters[0] + environment?: Record + mutate?: (fork: AnvilHandle) => Promise +} + describe('market-making setup check on a pinned Base fork', () => { let anvil: AnvilHandle | undefined let api: SetupApiHandle | undefined @@ -51,29 +81,14 @@ describe('market-making setup check on a pinned Base fork', () => { expect(api).toBeDefined() if (!anvil || !api) return - const output = await createApplication({ - CHAIN_ID: '8453', - RPC_URL: anvil.rpcUrl, - REFERENCE_RPC_URL: anvil.rpcUrl, - MAKER_PRIVATE_KEY: ANVIL_DEFAULT_PRIVATE_KEY, - MAKER_ADDRESS: ANVIL_DEFAULT_ACCOUNT.address, - MIDNIGHT_ADDRESS: MIDNIGHT, - LOAN_ASSET_ADDRESS: USDC, - RATIFIER_ADDRESS: ECRECOVER_RATIFIER, - MARKET_IDS: MARKET_ID, - REFERENCE_MARKET_ID, - NATIVE_RESERVE_WEI: String(NATIVE_RESERVE), - MAXIMUM_LEND_EXPOSURE_ASSETS: String(MAXIMUM_LEND_EXPOSURE), - MORPHO_API_BASE_URL: api.baseUrl, - ROUTER_API_BASE_URL: api.baseUrl, - REQUEST_TIMEOUT_MS: '30000' - }).run(['setup-check']) + const output = await createApplication(environment(anvil.rpcUrl, api.baseUrl)).run([ + 'setup-check' + ]) expect(isSetupCheckReport(output)).toBe(true) - if (!isSetupCheckReport(output)) throw new Error('Expected setup-check report') - const report = output + if (!isSetupCheckReport(output)) throw new TypeError('Expected setup-check report') - expect(report.ready).toBe(true) - expect(report.checks.map(check => [check.name, check.status])).toStrictEqual([ + expect(output.ready).toBe(true) + expect(output.checks.map(check => [check.name, check.status])).toStrictEqual([ ['chain', 'passed'], ['maker', 'passed'], ['native-balance', 'passed'], @@ -85,4 +100,96 @@ describe('market-making setup check on a pinned Base fork', () => { ['position-health', 'not-required'] ]) }, 60_000) + + const failures: SetupFailure[] = [ + { + name: 'chain', + additionallyFailed: ['ratifier', 'books'], + mutate: fork => fork.client.setCode({ address: MIDNIGHT, bytecode: '0x' }) + }, + { + name: 'maker', + environment: { MAKER_PRIVATE_KEY: ANVIL_TAKER_PRIVATE_KEY } + }, + { + name: 'native-balance', + mutate: fork => fork.client.setBalance({ address: ANVIL_DEFAULT_ACCOUNT.address, value: 0n }) + }, + { + name: 'loan-allowance', + mutate: async fork => { + const wallet = createWalletClient({ + account: ANVIL_DEFAULT_ACCOUNT, + chain: base, + transport: http(fork.rpcUrl) + }) + const hash = await wallet.writeContract({ + address: USDC, + abi: erc20Abi, + functionName: 'approve', + args: [MIDNIGHT, 0n] + }) + await fork.client.waitForTransactionReceipt({ hash }) + } + }, + { + name: 'ratifier', + mutate: async fork => { + const wallet = createWalletClient({ + account: ANVIL_DEFAULT_ACCOUNT, + chain: base, + transport: http(fork.rpcUrl) + }) + const hash = await wallet.writeContract({ + address: MIDNIGHT, + abi: midnightAbi, + functionName: 'setIsAuthorized', + args: [ECRECOVER_RATIFIER, false, ANVIL_DEFAULT_ACCOUNT.address] + }) + await fork.client.waitForTransactionReceipt({ hash }) + } + }, + { name: 'books', apiMode: 'books-failed' }, + { name: 'reference', environment: { REFERENCE_RPC_URL: 'http://127.0.0.1:1' } }, + { name: 'offers', apiMode: 'offers-failed' } + ] + + test.each(failures)( + 'fails the $name setup item against real provider boundaries', + async failure => { + expect(anvil).toBeDefined() + expect(api).toBeDefined() + if (!anvil || !api) return + const snapshot = await anvil.client.snapshot() + api.setMode(failure.apiMode ?? 'ready') + try { + await failure.mutate?.(anvil) + const output = await createApplication({ + ...environment(anvil.rpcUrl, api.baseUrl), + ...failure.environment + }) + .run(['setup-check']) + .catch(error => error.report) + expect(isSetupCheckReport(output)).toBe(true) + if (!isSetupCheckReport(output)) throw new TypeError('Expected setup-check report') + expect(output.ready).toBe(false) + const failed = new Set([failure.name, ...(failure.additionallyFailed ?? [])]) + expect(output.checks.map(check => [check.name, check.status])).toStrictEqual([ + ['chain', failed.has('chain') ? 'failed' : 'passed'], + ['maker', failed.has('maker') ? 'failed' : 'passed'], + ['native-balance', failed.has('native-balance') ? 'failed' : 'passed'], + ['loan-allowance', failed.has('loan-allowance') ? 'failed' : 'passed'], + ['ratifier', failed.has('ratifier') ? 'failed' : 'passed'], + ['books', failed.has('books') ? 'failed' : 'passed'], + ['reference', failed.has('reference') ? 'failed' : 'passed'], + ['offers', failed.has('offers') ? 'failed' : 'passed'], + ['position-health', 'not-required'] + ]) + } finally { + api.setMode('ready') + await anvil.client.revert({ id: snapshot }) + } + }, + 60_000 + ) }) diff --git a/bots/market-making/test/e2e/setup-maker.ts b/bots/market-making/test/e2e/setup-maker.ts index 45a397aa..7ae318d6 100644 --- a/bots/market-making/test/e2e/setup-maker.ts +++ b/bots/market-making/test/e2e/setup-maker.ts @@ -4,6 +4,7 @@ import { erc20Abi, http, keccak256, + maxUint256, pad, parseAbiParameters, toHex, @@ -17,7 +18,6 @@ import { ANVIL_DEFAULT_ACCOUNT, ECRECOVER_RATIFIER, MAKER_USDC_BALANCE, - MAXIMUM_LEND_EXPOSURE, MIDNIGHT, USDC } from './constants' @@ -67,7 +67,7 @@ export const setupMaker = async (anvil: AnvilHandle) => { address: USDC, abi: erc20Abi, functionName: 'approve', - args: [MIDNIGHT, MAXIMUM_LEND_EXPOSURE] + args: [MIDNIGHT, maxUint256] }) const approvalHash = await wallet.writeContract(approvalRequest) const approvalReceipt = await anvil.client.waitForTransactionReceipt({ hash: approvalHash }) diff --git a/bots/market-making/test/infrastructure/ladder/ladder-book.utils.test.ts b/bots/market-making/test/infrastructure/ladder/ladder-book.utils.test.ts new file mode 100644 index 00000000..ca7b5ea3 --- /dev/null +++ b/bots/market-making/test/infrastructure/ladder/ladder-book.utils.test.ts @@ -0,0 +1,46 @@ +import type { Hex } from 'viem' + +import { describe, expect, test } from 'bun:test' + +import { readLadderBookOffers } from '../../../src/infrastructure/ladder/ladder-book.utils' + +const marketId: Hex = `0x${'11'.repeat(32)}` +const groupId: Hex = `0x${'22'.repeat(32)}` + +describe('readLadderBookOffers', () => { + test('rejects a book offer whose payload side disagrees with its endpoint', async () => { + await expect( + readLadderBookOffers({ + baseUrl: 'https://router.invalid', + marketIds: [marketId], + timeoutMs: 1_000, + request: async url => ({ + data: url.includes('/asks/') + ? [{ market_id: marketId, offer: { group: groupId, buy: true, tick: 1 } }] + : [] + }) + }) + ).rejects.toMatchObject({ name: 'LadderAdapterError', operation: 'book-response' }) + }) + + test('reads the non-paginated takeable-offer response', async () => { + const urls: string[] = [] + const offers = await readLadderBookOffers({ + baseUrl: 'https://router.invalid', + marketIds: [marketId], + timeoutMs: 1_000, + request: async url => { + urls.push(url) + if (url.includes('/bids/')) return { data: [] } + return { + data: [{ market_id: marketId, offer: { group: groupId, buy: false, tick: 1 } }] + } + } + }) + expect(offers.map(offer => offer.groupId)).toEqual([groupId]) + expect(urls).toEqual([ + `https://router.invalid/v0/midnight/books/${marketId}/asks/takeable-offers`, + `https://router.invalid/v0/midnight/books/${marketId}/bids/takeable-offers` + ]) + }) +}) diff --git a/bots/market-making/test/infrastructure/ladder/ladder-capacity.utils.test.ts b/bots/market-making/test/infrastructure/ladder/ladder-capacity.utils.test.ts new file mode 100644 index 00000000..436036c6 --- /dev/null +++ b/bots/market-making/test/infrastructure/ladder/ladder-capacity.utils.test.ts @@ -0,0 +1,50 @@ +import type { Hex } from 'viem' + +import { describe, expect, test } from 'bun:test' + +import { calculateLadderCapacities } from '../../../src/infrastructure/ladder/ladder-capacity.utils' + +const marketId: Hex = `0x${'11'.repeat(32)}` +const groupId: Hex = `0x${'22'.repeat(32)}` + +describe('calculateLadderCapacities', () => { + test('subtracts current credit from fresh per-market lend room', () => { + expect( + calculateLadderCapacities({ + marketId, + balance: 100n, + currentCredit: 90n, + otherMarketCredit: 0n, + creditSaleCapacityAssets: 90n, + targetMarketExposureAssets: 100n, + maximumTotalExposureAssets: 1_000n, + reservations: [] + }) + ).toEqual({ + lowerRateCapacityAssets: 90n, + higherRateCapacityAssets: 10n, + targetMarketCapacityAssets: 100n, + maximumTotalCapacityAssets: 1_000n + }) + }) + + test('counts active reservations against market and aggregate production room', () => { + expect( + calculateLadderCapacities({ + marketId, + balance: 100n, + currentCredit: 20n, + otherMarketCredit: 30n, + creditSaleCapacityAssets: 0n, + targetMarketExposureAssets: 100n, + maximumTotalExposureAssets: 200n, + reservations: [{ id: groupId, marketIds: [marketId], assets: 40n }] + }) + ).toEqual({ + lowerRateCapacityAssets: 0n, + higherRateCapacityAssets: 40n, + targetMarketCapacityAssets: 40n, + maximumTotalCapacityAssets: 110n + }) + }) +}) diff --git a/bots/market-making/test/infrastructure/ladder/ladder-group-ownership.utils.test.ts b/bots/market-making/test/infrastructure/ladder/ladder-group-ownership.utils.test.ts index e6efab8c..e0bfdb40 100644 --- a/bots/market-making/test/infrastructure/ladder/ladder-group-ownership.utils.test.ts +++ b/bots/market-making/test/infrastructure/ladder/ladder-group-ownership.utils.test.ts @@ -1,9 +1,10 @@ import type { Address, Hex } from 'viem' import { describe, expect, test } from 'bun:test' -import { mkdtemp, rm } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { keccak256, stringToHex } from 'viem' import type { LadderQuoteSet } from '../../../src/domain/ladder/ladder' @@ -22,7 +23,7 @@ const quote: LadderQuoteSet = { } describe('createLadderGroupOwnership', () => { - test('persists ownership keyed only by maker and ladder strategy markets', async () => { + test('keeps ownership stable when configured ladder markets change', async () => { const stateDirectory = await mkdtemp(join(tmpdir(), 'ladder-ownership-')) try { const ownership = createLadderGroupOwnership( @@ -45,7 +46,7 @@ describe('createLadderGroupOwnership', () => { expect(await ownership.read()).toMatchObject([{ status: 'confirmed' }]) const ownershipAfterUnrelatedAllowlistEdit = createLadderGroupOwnership( - { maker, strategyMarketIds: [marketId] }, + { maker, strategyMarketIds: [marketId, `0x${'55'.repeat(32)}`] }, { stateDirectory } ) expect(await ownershipAfterUnrelatedAllowlistEdit.readGroupIds()).toEqual([ @@ -61,4 +62,179 @@ describe('createLadderGroupOwnership', () => { await rm(stateDirectory, { recursive: true }) } }) + + test('discovers legacy ownership after the configured market list changes', async () => { + const stateDirectory = await mkdtemp(join(tmpdir(), 'ladder-ownership-changed-migration-')) + const addedMarketId: Hex = `0x${'55'.repeat(32)}` + try { + const oldOwnership = createLadderGroupOwnership( + { maker, strategyMarketIds: [marketId] }, + { stateDirectory } + ) + await oldOwnership.reserve({ + marketId, + quote, + groups: [{ groupId: lowerGroup, side: 'lower', rungIndexes: [0] }] + }) + const [stableName] = Array.from(new Bun.Glob('*.json').scanSync(stateDirectory)) + if (!stableName) throw new TypeError('Expected stable ownership state') + const stablePath = join(stateDirectory, stableName) + const state = JSON.parse(await readFile(stablePath, 'utf8')) as Record + const oldLegacyStrategy = keccak256( + stringToHex(JSON.stringify({ strategy: 'ladder', maker, marketIds: [marketId] })) + ) + await rm(stablePath) + await writeFile( + join(stateDirectory, `${oldLegacyStrategy}.json`), + JSON.stringify({ ...state, strategy: oldLegacyStrategy }), + { mode: 0o600 } + ) + + const changedOwnership = createLadderGroupOwnership( + { maker, strategyMarketIds: [marketId, addedMarketId] }, + { stateDirectory } + ) + expect(await changedOwnership.readGroupIds()).toEqual([lowerGroup]) + await changedOwnership.migrate() + expect(Array.from(new Bun.Glob('*.json').scanSync(stateDirectory))).toHaveLength(1) + } finally { + await rm(stateDirectory, { recursive: true }) + } + }) + + test('leaves ambiguous legacy ownership untouched when an unpublished market changes', async () => { + const stateDirectory = await mkdtemp(join(tmpdir(), 'ladder-ownership-unpublished-migration-')) + const unpublishedMarketId: Hex = `0x${'66'.repeat(32)}` + const replacementMarketId: Hex = `0x${'77'.repeat(32)}` + try { + const oldOwnership = createLadderGroupOwnership( + { maker, strategyMarketIds: [marketId, unpublishedMarketId] }, + { stateDirectory } + ) + await oldOwnership.reserve({ + marketId, + quote, + groups: [{ groupId: lowerGroup, side: 'lower', rungIndexes: [0] }] + }) + const [stableName] = Array.from(new Bun.Glob('*.json').scanSync(stateDirectory)) + if (!stableName) throw new TypeError('Expected stable ownership state') + const stablePath = join(stateDirectory, stableName) + const state = JSON.parse(await readFile(stablePath, 'utf8')) as Record + const oldLegacyStrategy = keccak256( + stringToHex( + JSON.stringify({ + strategy: 'ladder', + maker, + marketIds: [marketId, unpublishedMarketId].toSorted() + }) + ) + ) + await rm(stablePath) + await writeFile( + join(stateDirectory, `${oldLegacyStrategy}.json`), + JSON.stringify({ ...state, strategy: oldLegacyStrategy }), + { mode: 0o600 } + ) + + const changedOwnership = createLadderGroupOwnership( + { maker, strategyMarketIds: [marketId, replacementMarketId] }, + { stateDirectory } + ) + expect(await changedOwnership.readGroupIds()).toEqual([]) + await changedOwnership.migrate() + expect(Array.from(new Bun.Glob('*.json').scanSync(stateDirectory))).toHaveLength(1) + } finally { + await rm(stateDirectory, { recursive: true }) + } + }) + + test('reads legacy ownership without mutation and migrates it explicitly for writers', async () => { + const stateDirectory = await mkdtemp(join(tmpdir(), 'ladder-ownership-migration-')) + try { + const ownership = createLadderGroupOwnership( + { maker, strategyMarketIds: [marketId] }, + { stateDirectory } + ) + await ownership.reserve({ + marketId, + quote, + groups: [{ groupId: lowerGroup, side: 'lower', rungIndexes: [0] }] + }) + const [stableName] = Array.from(new Bun.Glob('*.json').scanSync(stateDirectory)) + if (!stableName) throw new TypeError('Expected stable ownership state') + const stablePath = join(stateDirectory, stableName) + const state = JSON.parse(await readFile(stablePath, 'utf8')) as Record + const legacyStrategy = keccak256( + stringToHex(JSON.stringify({ strategy: 'ladder', maker, marketIds: [marketId] })) + ) + await rm(stablePath) + await writeFile( + join(stateDirectory, `${legacyStrategy}.json`), + JSON.stringify({ ...state, strategy: legacyStrategy }), + { mode: 0o600 } + ) + + expect(await ownership.readGroupIds()).toEqual([lowerGroup]) + expect(Array.from(new Bun.Glob('*.json').scanSync(stateDirectory))).toEqual([ + `${legacyStrategy}.json` + ]) + await ownership.migrate() + expect(Array.from(new Bun.Glob('*.json').scanSync(stateDirectory))).toEqual([stableName]) + } finally { + await rm(stateDirectory, { recursive: true }) + } + }) + + test("does not adopt another maker's overlapping stable ownership", async () => { + const stateDirectory = await mkdtemp(join(tmpdir(), 'ladder-ownership-foreign-maker-')) + const foreignMaker: Address = '0x9999999999999999999999999999999999999999' + try { + const foreignOwnership = createLadderGroupOwnership( + { maker: foreignMaker, strategyMarketIds: [marketId] }, + { stateDirectory } + ) + await foreignOwnership.reserve({ + marketId, + quote, + groups: [{ groupId: lowerGroup, side: 'lower', rungIndexes: [0] }] + }) + const [foreignName] = Array.from(new Bun.Glob('*.json').scanSync(stateDirectory)) + if (!foreignName) throw new TypeError('Expected foreign ownership state') + + const ownership = createLadderGroupOwnership( + { maker, strategyMarketIds: [marketId] }, + { stateDirectory } + ) + + expect(await ownership.read()).toEqual([]) + await ownership.migrate() + expect(Array.from(new Bun.Glob('*.json').scanSync(stateDirectory))).toEqual([foreignName]) + } finally { + await rm(stateDirectory, { recursive: true }) + } + }) + + test('rejects an ownership file with group-readable permissions', async () => { + const stateDirectory = await mkdtemp(join(tmpdir(), 'ladder-ownership-security-')) + try { + const ownership = createLadderGroupOwnership( + { maker, strategyMarketIds: [marketId] }, + { stateDirectory } + ) + await ownership.reserve({ + marketId, + quote, + groups: [{ groupId: higherGroup, side: 'higher', rungIndexes: [0] }] + }) + const [path] = Array.from(new Bun.Glob('*.json').scanSync(stateDirectory)) + if (!path) throw new Error('Expected ownership state') + await chmod(join(stateDirectory, path), 0o644) + await expect(ownership.read()).rejects.toMatchObject({ + name: 'LadderAdapterError', + operation: 'group-ownership-state' + }) + } finally { + await rm(stateDirectory, { recursive: true, force: true }) + } + }) }) diff --git a/bots/market-making/test/infrastructure/ladder/ladder-groups.utils.test.ts b/bots/market-making/test/infrastructure/ladder/ladder-groups.utils.test.ts new file mode 100644 index 00000000..1e307014 --- /dev/null +++ b/bots/market-making/test/infrastructure/ladder/ladder-groups.utils.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { readLadderGroups } from '../../../src/infrastructure/ladder/ladder-groups.utils' + +describe('readLadderGroups', () => { + test('keeps the bounded Router reader available for the fork harness', async () => { + expect(typeof readLadderGroups).toBe('function') + const temporary = await mkdtemp(join(tmpdir(), 'ladder-reader-')) + try { + await writeFile(join(temporary, 'marker'), '') + } finally { + await rm(temporary, { recursive: true, force: true }) + } + }) +}) diff --git a/bots/market-making/test/infrastructure/ladder/ladder-offer.utils.test.ts b/bots/market-making/test/infrastructure/ladder/ladder-offer.utils.test.ts index 503e315e..c4741660 100644 --- a/bots/market-making/test/infrastructure/ladder/ladder-offer.utils.test.ts +++ b/bots/market-making/test/infrastructure/ladder/ladder-offer.utils.test.ts @@ -1,6 +1,7 @@ import type { IMarket } from '@morpho-org/midnight-sdk' import type { Address, Hex } from 'viem' +import { TickLib } from '@morpho-org/midnight-sdk' import { describe, expect, test } from 'bun:test' import type { LadderQuoteSet } from '../../../src/domain/ladder/ladder' @@ -70,7 +71,9 @@ describe('buildLadderTree', () => { market, maker, ratifier, - now + now, + minimumRateBps: 1n, + maximumRateBps: 10_000n }) expect(result.tree.offers.map(offer => offer.buy)).toEqual([false, false, true, true]) @@ -94,14 +97,18 @@ describe('buildLadderTree', () => { market, maker, ratifier, - now + now, + minimumRateBps: 1n, + maximumRateBps: 10_000n }) const later = buildLadderTree({ quote: quote('shared-rung'), market, maker, ratifier, - now: now + 1n + now: now + 1n, + minimumRateBps: 1n, + maximumRateBps: 10_000n }) const firstGroups = new Set(first.groups.map(group => group.groupId)) @@ -114,7 +121,9 @@ describe('buildLadderTree', () => { market, maker, ratifier, - now + now, + minimumRateBps: 1n, + maximumRateBps: 10_000n }) expect(result.tree.offers.map(offer => offer.maxAssets)).toEqual([30n, 30n, 70n, 70n]) @@ -125,4 +134,60 @@ describe('buildLadderTree', () => { [0, 1] ]) }) + + test('reconstructs persisted pending offers without applying current strategy bounds', () => { + expect(() => + buildLadderTree({ + quote: quote('shared-rung'), + market, + maker, + ratifier, + now + }) + ).not.toThrow() + }) + + test('rejects an encoded APR fraction above the integer-bps maximum', () => { + const permissive = buildLadderTree({ + quote: quote('shared-rung'), + market, + maker, + ratifier, + now, + minimumRateBps: 1n, + maximumRateBps: 10_000n + }) + const encodedRateWad = TickLib.tickToApr( + permissive.tree.offers.at(-1)!.tick, + BigInt(market.params.maturity) - now + ) + const basisPointWad = 10n ** 14n + expect(encodedRateWad % basisPointWad).not.toBe(0n) + + expect(() => + buildLadderTree({ + quote: quote('shared-rung'), + market, + maker, + ratifier, + now, + minimumRateBps: 1n, + maximumRateBps: encodedRateWad / basisPointWad + }) + ).toThrow('Ladder adapter failed') + }) + + test('rejects a rounded protocol tick outside the configured hard rate range', () => { + expect(() => + buildLadderTree({ + quote: quote('shared-rung'), + market, + maker, + ratifier, + now, + minimumRateBps: 450n, + maximumRateBps: 600n + }) + ).toThrow('Ladder adapter failed') + }) }) diff --git a/bots/market-making/test/infrastructure/ladder/ladder-transaction.utils.test.ts b/bots/market-making/test/infrastructure/ladder/ladder-transaction.utils.test.ts index 92164920..4ac6fb94 100644 --- a/bots/market-making/test/infrastructure/ladder/ladder-transaction.utils.test.ts +++ b/bots/market-making/test/infrastructure/ladder/ladder-transaction.utils.test.ts @@ -21,6 +21,10 @@ const ratifier: Address = '0x800B5F12A61B8198a5a6EfD794Cac6699B294d63' const root: Hex = `0x${'22'.repeat(32)}` const foreignRoot: Hex = `0x${'44'.repeat(32)}` const mempool: Address = '0x3333333333333333333333333333333333333333' +const loanToken: Address = '0x5555555555555555555555555555555555555555' +const collateral: Address = '0x6666666666666666666666666666666666666666' +const oracle: Address = '0x7777777777777777777777777777777777777777' +const alternateOracle: Address = '0x8888888888888888888888888888888888888888' const approval = (ratified = true, account: Address = maker, selectedRoot: Hex = root) => ({ to: ratifier, @@ -32,6 +36,33 @@ const approval = (ratified = true, account: Address = maker, selectedRoot: Hex = }) }) +const ladderOffer = (collateralOracle: Address) => + Offer.create({ + market: { + chainId: 8453, + midnight: '0x4444444444444444444444444444444444444444', + loanToken, + collateralParams: [ + { + token: collateral, + lltv: 800_000_000_000_000_000n, + liquidationCursor: 0n, + oracle: collateralOracle + } + ], + maturity: 54_000n, + rcfThreshold: 0n, + enterGate: '0x0000000000000000000000000000000000000000', + liquidatorGate: '0x0000000000000000000000000000000000000000' + }, + buy: true, + maker, + tick: 100n, + expiry: 2_000n, + ratifier, + maxAssets: 100n + }) + describe('assertLadderRatificationTransaction', () => { test('rejects canonical Setter root approval calldata with trailing bytes', () => { const transaction = approval() @@ -73,31 +104,7 @@ describe('assertLadderRatificationTransaction', () => { describe('assertLadderPublicationTransaction', () => { test('rejects altered ratifier data even when the offer set is unchanged', async () => { - const offer = Offer.create({ - market: { - chainId: 8453, - midnight: '0x4444444444444444444444444444444444444444', - loanToken: '0x5555555555555555555555555555555555555555', - collateralParams: [ - { - token: '0x6666666666666666666666666666666666666666', - lltv: 800_000_000_000_000_000n, - liquidationCursor: 0n, - oracle: '0x7777777777777777777777777777777777777777' - } - ], - maturity: 54_000n, - rcfThreshold: 0n, - enterGate: '0x0000000000000000000000000000000000000000', - liquidatorGate: '0x0000000000000000000000000000000000000000' - }, - buy: true, - maker, - tick: 100n, - expiry: 2_000n, - ratifier, - maxAssets: 100n - }) + const offer = ladderOffer(oracle) const items = SetterRatifierUtils.ratify({ tree: Tree.create([offer]) }) const validTransaction = { to: mempool, @@ -116,4 +123,19 @@ describe('assertLadderPublicationTransaction', () => { assertLadderPublicationTransaction(alteredTransaction, { target: mempool, items }) ).rejects.toMatchObject({ operation: 'transaction-policy' }) }) + + test('rejects a payload whose nested market struct differs from the intended offer', async () => { + const intended = ladderOffer(oracle) + const items = SetterRatifierUtils.ratify({ tree: Tree.create([intended]) }) + const alteredItems = SetterRatifierUtils.ratify({ + tree: Tree.create([ladderOffer(alternateOracle)]) + }) + + await expect( + assertLadderPublicationTransaction( + { to: mempool, data: await Payload.encode(alteredItems), value: 0n }, + { target: mempool, items } + ) + ).rejects.toMatchObject({ operation: 'transaction-policy' }) + }) }) diff --git a/bots/market-making/test/infrastructure/ladder/production-ladder.test.ts b/bots/market-making/test/infrastructure/ladder/production-ladder.test.ts index c8467db2..5fa99e06 100644 --- a/bots/market-making/test/infrastructure/ladder/production-ladder.test.ts +++ b/bots/market-making/test/infrastructure/ladder/production-ladder.test.ts @@ -8,7 +8,9 @@ import { ConfigService } from '../../../src/config/config.service' import { LadderAdapterError } from '../../../src/infrastructure/ladder/ladder-adapter.error' import { MidnightLadderMakeService } from '../../../src/infrastructure/ladder/ladder-make.service' import { + calculateProductionLadderCapacities, createProductionLadderAdapters, + createRepeatableSingleFlight, publishLadderPublication } from '../../../src/infrastructure/ladder/production-ladder' @@ -46,6 +48,52 @@ const environment = { ROUTER_API_BASE_URL: 'https://router.example' } +describe('calculateProductionLadderCapacities', () => { + test('makes the current accrued credit available to lower-rate sell rungs', () => { + expect( + calculateProductionLadderCapacities({ + marketId, + balance: 100n, + currentCredit: 90n, + otherMarketCredit: 0n, + targetMarketExposureAssets: 100n, + maximumTotalExposureAssets: 1_000n, + reservations: [] + }) + ).toEqual({ + lowerRateCapacityAssets: 90n, + higherRateCapacityAssets: 10n, + targetMarketCapacityAssets: 100n, + maximumTotalCapacityAssets: 1_000n + }) + }) +}) + +describe('createRepeatableSingleFlight', () => { + test('deduplicates concurrent cleanup but reruns after each settled attempt', async () => { + let runs = 0 + let release: (() => void) | undefined + const operation = createRepeatableSingleFlight( + () => + new Promise(resolve => { + runs++ + release = resolve + }) + ) + + const first = operation() + const concurrent = operation() + expect(runs).toBe(1) + release?.() + await Promise.all([first, concurrent]) + + const next = operation() + expect(runs).toBe(2) + release?.() + await next + }) +}) + describe('createProductionLadderAdapters', () => { test('constructs read-only ports without loading a private key or starting provider reads', async () => { const config = ConfigService.from(environment, { readOnly: true }) diff --git a/bots/market-making/typedoc.json b/bots/market-making/typedoc.json index 7ca6de67..9d47d48b 100644 --- a/bots/market-making/typedoc.json +++ b/bots/market-making/typedoc.json @@ -64,8 +64,11 @@ "src/infrastructure/invalidation/production-offer-invalidation.ts", "src/infrastructure/ladder/ladder-adapter.error.ts", "src/infrastructure/ladder/ladder-active-publication.utils.ts", + "src/infrastructure/ladder/ladder-book.utils.ts", + "src/infrastructure/ladder/ladder-capacity.utils.ts", "src/infrastructure/ladder/ladder-cash-reservation.utils.ts", "src/infrastructure/ladder/ladder-group-ownership.utils.ts", + "src/infrastructure/ladder/ladder-groups.utils.ts", "src/infrastructure/ladder/ladder-hard-halt.error.ts", "src/infrastructure/ladder/ladder-make.service.ts", "src/infrastructure/ladder/ladder-offer.utils.ts",