diff --git a/bots/market-making/.env.example b/bots/market-making/.env.example index 1e0d740e..2d4f2d41 100644 --- a/bots/market-making/.env.example +++ b/bots/market-making/.env.example @@ -1,6 +1,7 @@ # Environment values override the corresponding YAML values. CHAIN_ID=8453 RPC_URL=https://base-rpc.example +# Required by commands whose active targetRate strategy is variable_rate_avg. REFERENCE_RPC_URL=https://base-archive-rpc.example # Exactly one write-mode signer source is required. The method is derived for legacy # MAKER_PRIVATE_KEY-only deployments, but setting it explicitly is recommended. @@ -19,6 +20,7 @@ MIDNIGHT_ADDRESS=0x2222222222222222222222222222222222222222 LOAN_ASSET_ADDRESS=0x3333333333333333333333333333333333333333 RATIFIER_ADDRESS=0x4444444444444444444444444444444444444444 MARKET_IDS=0x5555555555555555555555555555555555555555555555555555555555555555 +# Required by commands whose active targetRate strategy is variable_rate_avg. REFERENCE_MARKET_ID=0x7777777777777777777777777777777777777777777777777777777777777777 NATIVE_RESERVE_WEI=10000000000000000 MAXIMUM_LEND_EXPOSURE_ASSETS=10000000000 @@ -34,6 +36,6 @@ BETTERSTACK_INGESTING_HOST= # Optional Better Stack heartbeat URL. Heartbeat failures never interrupt market making. BETTERSTACK_HEARTBEAT_URL= # BOOTSTRAP_MARKETS replaces the YAML list. Every integer property must be a quoted decimal string. -BOOTSTRAP_MARKETS=[{"marketId":"0x5555555555555555555555555555555555555555555555555555555555555555","creditTarget":"10000000000","acceptanceAssets":"100000000","offerSize":"500000000","premiumBps":"-50","maximumMarketExposure":"20000000000","maximumTotalExposure":"30000000000","minimumRateBps":"200","maximumRateBps":"800","autoRefill":false}] +BOOTSTRAP_MARKETS=[{"marketId":"0x5555555555555555555555555555555555555555555555555555555555555555","targetRate":{"strategy":"variable_rate_avg"},"creditTarget":"10000000000","acceptanceAssets":"100000000","offerSize":"500000000","premiumBps":"-50","maximumMarketExposure":"20000000000","maximumTotalExposure":"30000000000","minimumRateBps":"200","maximumRateBps":"800","autoRefill":false}] # LADDER_MARKETS replaces YAML ladder. Every integer-valued property must be a quoted decimal string. -LADDER_MARKETS=[{"marketId":"0x5555555555555555555555555555555555555555555555555555555555555555","quotePremiumBps":"0","spreadBps":"200","stepBps":"100","rungCount":"3","sizeSkewBps":"0","lowerRateBudgetAssets":"10000000000","higherRateBudgetAssets":"10000000000","targetMarketExposureAssets":"20000000000","maximumTotalExposureAssets":"30000000000","minimumOfferAssets":"101000000","groupMode":"shared-rung","loopIntervalSeconds":"60","movementToleranceBps":"10","minimumRateBps":"200","maximumRateBps":"800"}] +LADDER_MARKETS=[{"marketId":"0x5555555555555555555555555555555555555555555555555555555555555555","targetRate":{"strategy":"hardcoded","hardcodedRateBps":"400"},"quotePremiumBps":"0","spreadBps":"200","stepBps":"100","rungCount":"3","sizeSkewBps":"0","lowerRateBudgetAssets":"10000000000","higherRateBudgetAssets":"10000000000","targetMarketExposureAssets":"20000000000","maximumTotalExposureAssets":"30000000000","minimumOfferAssets":"101000000","groupMode":"shared-rung","loopIntervalSeconds":"60","movementToleranceBps":"10","minimumRateBps":"200","maximumRateBps":"800"}] diff --git a/bots/market-making/README.md b/bots/market-making/README.md index e846bb57..cf16c28c 100644 --- a/bots/market-making/README.md +++ b/bots/market-making/README.md @@ -133,16 +133,19 @@ its exact tick, comparing the prospective offer with the complete current maker the SDK's live Mempool-policy validation without signing or broadcasting. The corresponding final cycle outcome uses `status: "logged"` rather than `"applied"`. -`bootstrap --monitor` requires at least one explicit `bootstrap` / `BOOTSTRAP_MARKETS` entry. It -serially runs a cycle every minute and streams each result. `SIGINT` or `SIGTERM` lets an in-flight -cycle finish, then invalidates every explicitly owned bootstrap group through the same mutation -queue and waits for bounded transaction receipts. The final record reports the number of cycles and -whether cleanup was applied, logged, or failed. Read-only monitoring logs the cleanup request and -never loads a private key. In live mode, Ecrecover bootstrap signs and publishes the validated payload -in one transaction. Setter bootstrap durably reserves the future group, confirms any replacement -cancellations, submits and confirms `setIsRootRatified`, revalidates the exact final proof payload with -the Mempool API, then publishes it in a second transaction and confirms ownership. A post-approval -validation failure does not publish and retains the reservation for safe cleanup. +`bootstrap --monitor` requires at least one explicit `bootstrap` / `BOOTSTRAP_MARKETS` entry. Each +market independently selects `targetRate.strategy: variable_rate_avg` (the existing Morpho Blue +variable-rate average) or `hardcoded` with `hardcodedRateBps`; `premiumBps` is then added to derive +the published offer rate. It serially runs a cycle every minute +and streams each result. `SIGINT` or `SIGTERM` lets an in-flight cycle finish, then invalidates every +explicitly owned bootstrap group through the same mutation queue and waits for bounded transaction +receipts. The final record reports the number of cycles and whether cleanup was applied, logged, or +failed. Read-only monitoring logs the cleanup request and never loads a private key. In live mode, +Ecrecover bootstrap signs and publishes the validated payload in one transaction. Setter bootstrap +durably reserves the future group, confirms any replacement cancellations, submits and confirms +`setIsRootRatified`, revalidates the exact final proof payload with the Mempool API, then publishes it +in a second transaction and confirms ownership. A post-approval validation failure does not publish +and retains the reservation for safe cleanup. Add `--verbose` to either one-shot or monitored bootstrap mode to include the complete market configuration, fresh credit, debt, cash balance, per-market and total exposure, active offer, @@ -157,7 +160,8 @@ unchanged. `ladder` requires at least one `ladder` / `LADDER_MARKETS` entry. It runs readiness first, derives fresh wallet, allowance, credit, position, active-group, and strategy-wide exposure capacities, and -then builds one deterministic quote set from the current Blue reference rate. Lower-rate rungs are +then builds one deterministic quote set from that market's independently selected target-rate +strategy. Lower-rate rungs are reduce-only borrow-side sells; higher-rate rungs are lend-side buys. The complete mixed-side tree is Mempool-validated before and after ratification. Ecrecover trees are signed and published in one transaction; Setter trees first submit and confirm `setIsRootRatified`, then publish the proof-only @@ -225,7 +229,8 @@ limited to the explicit maker-wide recovery command. For a maker with at least 101 USDC of both available balance and accrued credit, this one-rung-per-side preset caps each side at 150 USDC. USDC uses six decimals, so `150000000` is 150 USDC and `101000000` is the Router-compatible 101 USDC offer floor. Duplicate the exact market ID -already present in `MARKET_IDS`: +already present in `MARKET_IDS`. This legacy preset intentionally omits `targetRate`, so it uses the +backward-compatible `variable_rate_avg` default: ```dotenv LADDER_MARKETS=[{"marketId":"0x05959752fdeff325962b9d263edb421efc6e2186a49360dba6c32e86ebf6c84c","quotePremiumBps":"0","spreadBps":"200","stepBps":"100","rungCount":"1","sizeSkewBps":"0","lowerRateBudgetAssets":"150000000","higherRateBudgetAssets":"150000000","targetMarketExposureAssets":"300000000","maximumTotalExposureAssets":"300000000","minimumOfferAssets":"101000000","groupMode":"shared-rung","loopIntervalSeconds":"60","movementToleranceBps":"10","minimumRateBps":"200","maximumRateBps":"800"}] @@ -336,7 +341,7 @@ unit; for six-decimal USDC, `101000000` is 101 USDC. No value is inferred from a | -------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CHAIN_ID` | `chain.id` | Required. Must be `8453`; all protocol, token, market, and transaction operations run on Base. | | `RPC_URL` | `chain.rpcUrl` | Required. Current-state Base JSON-RPC endpoint used for blocks, balances, allowances, positions, contract reads, simulation, transaction submission, and receipts. | -| `REFERENCE_RPC_URL` | `chain.archiveRpcUrl` | Required. Archive-capable Base JSON-RPC endpoint used to read the reference Morpho Blue market at historical blocks. | +| `REFERENCE_RPC_URL` | `chain.archiveRpcUrl` | Required when the selected command has an active `variable_rate_avg` target. Archive-capable Base JSON-RPC endpoint used to read the reference Morpho Blue market at historical blocks. | | `MAKER_ADDRESS` | `identity.makerAddress` | Required. EVM address whose balance, allowance, credit, offers, and exposure the bot manages. In write mode it must match the selected signer. | | `KEY_STORAGE_METHOD` | `identity.keyStorageMethod` | Optional only for backward-compatible `MAKER_PRIVATE_KEY` use; otherwise `private-key`, `keystore`, or `aws`. Exactly one effective source is required in write mode. | | `MAKER_PRIVATE_KEY` | `identity.makerPrivateKey` | Local private-key source. Must be a 0x-prefixed 32-byte secp256k1 key. `--private-key` overrides config. Never include it in committed configuration or logs. | @@ -351,7 +356,7 @@ unit; for six-decimal USDC, `101000000` is 101 USDC. No value is inferred from a | `MORPHO_API_BASE_URL` | `apis.morphoBaseUrl` | Required. Morpho API origin used for Midnight books, market metadata, prospective-offer validation, and cursor-paginated maker offer groups. No API-key header is supported. | | `ROUTER_API_BASE_URL` | `apis.routerBaseUrl` | Required. Router API origin used only to verify the configured ratifier against `/v0/config/contracts`. No API-key header is supported. | | `MARKET_IDS` | `markets.allowlist` | Required comma-separated list of unique 0x-prefixed bytes32 Midnight market IDs. Every bootstrap or ladder `marketId` must appear here. | -| `REFERENCE_MARKET_ID` | `markets.referenceMarketId` | Required 0x-prefixed bytes32 Morpho Blue market ID whose historical variable borrow rate supplies the reference rate for all configured strategies. | +| `REFERENCE_MARKET_ID` | `markets.referenceMarketId` | Required when the selected command has an active `variable_rate_avg` target. Must be a 0x-prefixed bytes32 Morpho Blue market ID. | | `V0_OFFER_GROUP_IDS` | `markets.v0OfferGroupIds` | Optional comma-separated list of unique, explicitly strategy-owned bytes32 offer-group IDs; defaults to empty. Use it to adopt known pre-existing groups safely. | | `NATIVE_RESERVE_WEI` | `setup.nativeReserveWei` | Required unsigned integer. Minimum maker native-token balance, in wei, required by readiness for transaction fees. | | `MAXIMUM_LEND_EXPOSURE_ASSETS` | `setup.maximumLendExposureAssets` | Required unsigned integer in raw loan-token units. Minimum maker allowance to Midnight required by readiness; it is not a strategy position cap. | @@ -456,20 +461,22 @@ reference hard-fails when its latest checkpoint is more than five minutes behind ### Position-bootstrap fields Each `bootstrap` entry must use a unique `marketId` present in `markets.allowlist`. -There are no per-field defaults: every field in each entry is required. - -| Field | Unit / behavior | Validation | -| ----------------------- | --------------------------------------------------------------- | --------------------------------------------------------- | -| `marketId` | 0x-prefixed 32-byte Midnight market ID | Required, unique, and allowlisted | -| `creditTarget` | Raw credit units; complete at `creditTarget - acceptanceAssets` | Positive unsigned integer | -| `acceptanceAssets` | Raw acceptable shortfall | Non-negative and no greater than `creditTarget` | -| `offerSize` | Raw desired offer size before capacity caps | Positive unsigned integer | -| `premiumBps` | Integer BPS added to the reference rate | Zero or negative | -| `maximumMarketExposure` | Raw per-market exposure cap | Positive and no greater than `maximumTotalExposure` | -| `maximumTotalExposure` | Raw strategy-wide exposure cap | Positive | -| `minimumRateBps` | Inclusive final-rate minimum | Non-negative and no greater than `maximumRateBps` | -| `maximumRateBps` | Inclusive final-rate maximum | Non-negative | -| `autoRefill` | Resume after first observed completion if credit later falls | Boolean; completion memory lasts for one service instance | +`targetRate` defaults to `{ strategy: "variable_rate_avg" }` when omitted for backward compatibility; +every other field in each entry is required. + +| Field | Unit / behavior | Validation | +| ----------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `marketId` | 0x-prefixed 32-byte Midnight market ID | Required, unique, and allowlisted | +| `targetRate` | Target-rate method selection | `variable_rate_avg`, or `hardcoded` with positive `hardcodedRateBps`; defaults to `variable_rate_avg` | +| `creditTarget` | Raw credit units; complete at `creditTarget - acceptanceAssets` | Positive unsigned integer | +| `acceptanceAssets` | Raw acceptable shortfall | Non-negative and no greater than `creditTarget` | +| `offerSize` | Raw desired offer size before capacity caps | Positive unsigned integer | +| `premiumBps` | Integer BPS added to the reference rate | Zero or negative | +| `maximumMarketExposure` | Raw per-market exposure cap | Positive and no greater than `maximumTotalExposure` | +| `maximumTotalExposure` | Raw strategy-wide exposure cap | Positive | +| `minimumRateBps` | Inclusive final-rate minimum | Non-negative and no greater than `maximumRateBps` | +| `maximumRateBps` | Inclusive final-rate maximum | Non-negative | +| `autoRefill` | Resume after first observed completion if credit later falls | Boolean; completion memory lasts for one service instance | For a market below its accepted target, desired assets are the minimum of `offerSize`, remaining credit target, cash balance, remaining per-market exposure, and remaining total exposure. Replacement @@ -490,6 +497,27 @@ when integral; `marketId` remains a string and `autoRefill` remains a JSON boole every YAML bootstrap entry, which avoids ambiguous partial-array merge behavior. See [`.env.example`](./.env.example) for exact syntax. +Bootstrap and ladder select their methods independently. These two valid YAML combinations show both +directions: + +```yaml +# Bootstrap fixed at 4%; ladder follows the Blue variable-rate average. +bootstrap: + - marketId: '0x...' + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' } +ladder: + - marketId: '0x...' + targetRate: { strategy: 'variable_rate_avg' } + +# Bootstrap follows Blue; ladder is fixed at 4%. +bootstrap: + - marketId: '0x...' + targetRate: { strategy: 'variable_rate_avg' } +ladder: + - marketId: '0x...' + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' } +``` + `mm setup-check --monitor` repeats non-overlapping read-only readiness observations every minute until its shutdown signal or the first failed report. `mm bootstrap` first runs the same one-shot readiness gate as `setup-check`, then executes exactly one position-bootstrap cycle and prints its @@ -512,12 +540,13 @@ mutation and graceful-cleanup operation instead. Each `ladder` entry has a unique allowlisted `marketId`. Rates are integer BPS and asset/exposure amounts are exact raw loan-asset units. `quotePremiumBps` and `sizeSkewBps` are signed; all other -integer fields are nonnegative or positive as shown below. There are no per-field defaults: every -field in each entry is required. +integer fields are nonnegative or positive as shown below. `targetRate` defaults to +`{ strategy: "variable_rate_avg" }`; every other field in each entry is required. | Field | Unit / behavior | Validation | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | `marketId` | 0x-prefixed 32-byte Midnight market ID quoted by this entry. | Required, unique across the array, and present in `MARKET_IDS`. | +| `targetRate` | Target-rate method used as reference `R`. | `variable_rate_avg`, or `hardcoded` with positive `hardcodedRateBps`; defaults to `variable_rate_avg`. | | `quotePremiumBps` | Signed BPS added to the fresh reference rate before the ladder spread is applied. Positive moves both sides higher; negative moves both lower. | Signed decimal integer; the resulting funded rungs must remain inside the configured rate range. | | `spreadBps` | Full distance in BPS between the nearest lower and higher rates. Each nearest rung is half this value from the center. | Positive and even, so each half-spread is an exact integer BPS value. | | `stepBps` | Additional BPS between successive rungs on the same side, moving farther from the center. | Positive. | @@ -571,7 +600,8 @@ The ladder is state reconciliation, not a collection of independently refilled o one-shot `ladder` invocation and every non-overlapping `ladder --monitor` cycle: 1. Reads fresh market credit, wallet balance, allowance, market and strategy exposure, active owned - groups, group consumption, and the Blue reference rate. + groups, group consumption, and the configured target rate (including Blue history only for + `variable_rate_avg`). 2. Reconstructs the remaining active quote. A partially consumed group contributes only its remaining assets, and a fully consumed indexed group contributes no rung. A persisted group that has not appeared in the eventually consistent API remains pending-active so the bot cannot diff --git a/bots/market-making/docker-compose.yml b/bots/market-making/docker-compose.yml index 76d96c55..1dda668d 100644 --- a/bots/market-making/docker-compose.yml +++ b/bots/market-making/docker-compose.yml @@ -7,7 +7,7 @@ services: environment: CHAIN_ID: ${CHAIN_ID:-8453} RPC_URL: ${RPC_URL:?set RPC_URL} - REFERENCE_RPC_URL: ${REFERENCE_RPC_URL:?set REFERENCE_RPC_URL} + REFERENCE_RPC_URL: ${REFERENCE_RPC_URL:-} MAKER_PRIVATE_KEY: ${MAKER_PRIVATE_KEY:?set MAKER_PRIVATE_KEY} MAKER_ADDRESS: ${MAKER_ADDRESS:?set MAKER_ADDRESS} MIDNIGHT_ADDRESS: ${MIDNIGHT_ADDRESS:?set MIDNIGHT_ADDRESS} @@ -16,7 +16,7 @@ services: MORPHO_API_BASE_URL: ${MORPHO_API_BASE_URL:?set MORPHO_API_BASE_URL} ROUTER_API_BASE_URL: ${ROUTER_API_BASE_URL:?set ROUTER_API_BASE_URL} MARKET_IDS: ${MARKET_IDS:?set MARKET_IDS} - REFERENCE_MARKET_ID: ${REFERENCE_MARKET_ID:?set REFERENCE_MARKET_ID} + REFERENCE_MARKET_ID: ${REFERENCE_MARKET_ID:-} V0_OFFER_GROUP_IDS: ${V0_OFFER_GROUP_IDS:-} NATIVE_RESERVE_WEI: ${NATIVE_RESERVE_WEI:?set NATIVE_RESERVE_WEI} MAXIMUM_LEND_EXPOSURE_ASSETS: ${MAXIMUM_LEND_EXPOSURE_ASSETS:?set MAXIMUM_LEND_EXPOSURE_ASSETS} diff --git a/bots/market-making/market-making.example.yaml b/bots/market-making/market-making.example.yaml index ce50320d..7d6c82cd 100644 --- a/bots/market-making/market-making.example.yaml +++ b/bots/market-making/market-making.example.yaml @@ -3,6 +3,7 @@ chain: id: 8453 rpcUrl: 'https://base-rpc.example' + # Required by commands whose active targetRate strategy is variable_rate_avg. archiveRpcUrl: 'https://base-archive-rpc.example' identity: @@ -32,6 +33,7 @@ markets: allowlist: - '0x5555555555555555555555555555555555555555555555555555555555555555' - '0x6666666666666666666666666666666666666666666666666666666666666666' + # Required by commands whose active targetRate strategy is variable_rate_avg. referenceMarketId: '0x7777777777777777777777777777777777777777777777777777777777777777' v0OfferGroupIds: - '0x8888888888888888888888888888888888888888888888888888888888888888' @@ -46,6 +48,9 @@ setup: # Asset/credit/exposure amounts are exact raw loan-asset units. Rates are integer BPS. bootstrap: - marketId: '0x5555555555555555555555555555555555555555555555555555555555555555' + targetRate: + strategy: 'hardcoded' + hardcodedRateBps: '400' creditTarget: '10000000000' acceptanceAssets: '100000000' offerSize: '500000000' @@ -56,6 +61,8 @@ bootstrap: maximumRateBps: 800 autoRefill: false - marketId: '0x6666666666666666666666666666666666666666666666666666666666666666' + targetRate: + strategy: 'variable_rate_avg' creditTarget: '5000000000' acceptanceAssets: '50000000' offerSize: '250000000' @@ -72,6 +79,8 @@ bootstrap: # 10000+k*sizeSkewBps; the outermost funded rung receives division remainder. ladder: - marketId: '0x5555555555555555555555555555555555555555555555555555555555555555' + targetRate: + strategy: 'variable_rate_avg' quotePremiumBps: '0' spreadBps: '200' stepBps: '100' diff --git a/bots/market-making/playground/app.tsx b/bots/market-making/playground/app.tsx index 5891eeaa..569f9ff4 100644 --- a/bots/market-making/playground/app.tsx +++ b/bots/market-making/playground/app.tsx @@ -10,6 +10,7 @@ import { import React, { Component, useEffect, useRef, useState } from 'react' import { createRoot } from 'react-dom/client' +import type { FieldDefinition } from './field-visibility.utils' import type { BootstrapGraphicModel, BootstrapInput, @@ -19,6 +20,7 @@ import type { } from './model' import { CollectionImportError } from './collection-import.error' +import { visibleFields } from './field-visibility.utils' import { BOOTSTRAP_FIELDS, LADDER_FIELDS, @@ -43,7 +45,6 @@ import { playgroundErrorMessage } from './playground-error.utils' import { PlaygroundInitializationError } from './playground-initialization.error' type CollectionKind = keyof PlaygroundState -type FieldDefinition = readonly [string, string, string, string] type ExportFormat = 'bootstrap-json' | 'bootstrap-string' | 'ladder-json' | 'ladder-string' type Status = { message: string; status?: 'ok' | 'error' } const EXPORT_FORMATS: ExportFormat[] = [ @@ -480,7 +481,7 @@ const Playground = () => {
- {fields.map(([key, label, help, type]) => ( + {visibleFields(fields, item.targetRate).map(([key, label, help, type]) => ( { {key} ยท {help} - {type === 'select' ? ( + {type === 'target-rate-select' ? ( + + ) : type === 'select' ? ( field.handleChange( diff --git a/bots/market-making/playground/field-visibility.utils.ts b/bots/market-making/playground/field-visibility.utils.ts new file mode 100644 index 00000000..c8224714 --- /dev/null +++ b/bots/market-making/playground/field-visibility.utils.ts @@ -0,0 +1,11 @@ +import type { TargetRateInput } from './model' + +export type FieldDefinition = readonly [string, string, string, string] + +export const visibleFields = ( + fields: readonly FieldDefinition[], + targetRate: TargetRateInput +): readonly FieldDefinition[] => + fields.filter( + ([key]) => key !== 'targetRate.hardcodedRateBps' || targetRate.strategy === 'hardcoded' + ) diff --git a/bots/market-making/playground/model.ts b/bots/market-making/playground/model.ts index 06f85bb7..60393f0e 100644 --- a/bots/market-making/playground/model.ts +++ b/bots/market-making/playground/model.ts @@ -1,5 +1,6 @@ import type { BootstrapConfig } from '../src/domain/bootstrap/position-bootstrap' import type { LadderConfig } from '../src/domain/ladder/ladder' +import type { TargetRateConfigured } from '../src/domain/target-rate' import { BOOTSTRAP_MARKET_FIELDS, @@ -15,11 +16,17 @@ import { FragmentCodecError } from './fragment-codec.error' import { PreviewGenerationError } from './preview-generation.error' import { StrictJsonError } from './strict-json.error' +export type TargetRateInput = + | { strategy: 'variable_rate_avg' } + | { strategy: 'hardcoded'; hardcodedRateBps: string } export type BootstrapInput = Record< - Exclude<(typeof BOOTSTRAP_MARKET_FIELDS)[number], 'autoRefill'>, + Exclude<(typeof BOOTSTRAP_MARKET_FIELDS)[number], 'autoRefill' | 'targetRate'>, string -> & { autoRefill: boolean } -export type LadderInput = Record<(typeof LADDER_MARKET_FIELDS)[number], string> +> & { autoRefill: boolean; targetRate: TargetRateInput } +export type LadderInput = Record< + Exclude<(typeof LADDER_MARKET_FIELDS)[number], 'targetRate'>, + string +> & { targetRate: TargetRateInput } export type PlaygroundState = { bootstrap: BootstrapInput[] @@ -28,6 +35,13 @@ export type PlaygroundState = { export const BOOTSTRAP_FIELDS = [ ['marketId', 'Market ID', '0x-prefixed bytes32 market', 'text'], + ['targetRate.strategy', 'Target rate', 'Reference-rate strategy', 'target-rate-select'], + [ + 'targetRate.hardcodedRateBps', + 'Hardcoded target rate (BPS)', + 'Positive reference rate used by the hardcoded strategy', + 'target-rate-number' + ], ['creditTarget', 'Credit target', 'Positive raw credit units', 'number'], ['acceptanceAssets', 'Completion threshold', 'Allowed target shortfall', 'number'], ['offerSize', 'Pending-offer cap', 'Maximum desired offer assets', 'number'], @@ -40,6 +54,13 @@ export const BOOTSTRAP_FIELDS = [ ] as const export const LADDER_FIELDS = [ ['marketId', 'Market ID', '0x-prefixed bytes32 market', 'text'], + ['targetRate.strategy', 'Target rate', 'Reference-rate strategy', 'target-rate-select'], + [ + 'targetRate.hardcodedRateBps', + 'Hardcoded target rate (BPS)', + 'Positive reference rate used by the hardcoded strategy', + 'target-rate-number' + ], ['quotePremiumBps', 'Quote premium (BPS)', 'Signed center offset', 'number'], ['spreadBps', 'Full spread (BPS)', 'Positive even nearest-rung distance', 'number'], ['stepBps', 'Step (BPS)', 'Positive same-side rung distance', 'number'], @@ -61,6 +82,7 @@ const DEFAULT_MARKET_ID = `0x${'5'.repeat(64)}` export const createDefaultBootstrap = (marketId = DEFAULT_MARKET_ID): BootstrapInput => ({ marketId, + targetRate: { strategy: 'variable_rate_avg' }, creditTarget: '10000000000', acceptanceAssets: '100000000', offerSize: '500000000', @@ -74,6 +96,7 @@ export const createDefaultBootstrap = (marketId = DEFAULT_MARKET_ID): BootstrapI export const createDefaultLadder = (marketId = DEFAULT_MARKET_ID): LadderInput => ({ marketId, + targetRate: { strategy: 'variable_rate_avg' }, quotePremiumBps: '0', spreadBps: '200', stepBps: '100', @@ -96,8 +119,14 @@ export const createDefaultPlaygroundState = (): PlaygroundState => ({ ladder: [createDefaultLadder()] }) -const bootstrapInput = (config: BootstrapConfig): BootstrapInput => ({ +const targetRateInput = (config: TargetRateConfigured['targetRate']): TargetRateInput => + config.strategy === 'hardcoded' + ? { strategy: 'hardcoded', hardcodedRateBps: String(config.hardcodedRateBps) } + : { strategy: 'variable_rate_avg' } + +const bootstrapInput = (config: TargetRateConfigured): BootstrapInput => ({ marketId: config.marketId, + targetRate: targetRateInput(config.targetRate), creditTarget: String(config.creditTarget), acceptanceAssets: String(config.acceptanceAssets), offerSize: String(config.offerSize), @@ -109,8 +138,9 @@ const bootstrapInput = (config: BootstrapConfig): BootstrapInput => ({ autoRefill: config.autoRefill }) -const ladderInput = (config: LadderConfig): LadderInput => ({ +const ladderInput = (config: TargetRateConfigured): LadderInput => ({ marketId: config.marketId, + targetRate: targetRateInput(config.targetRate), quotePremiumBps: String(config.quotePremiumBps), spreadBps: String(config.spreadBps), stepBps: String(config.stepBps), @@ -173,14 +203,17 @@ export const deriveBootstrapGraphicModels = (items: BootstrapInput[]): Bootstrap parseBootstrap(items).map(item => { const minimum = BigInt(item.minimumRateBps) const maximum = BigInt(item.maximumRateBps) - const quoted = (minimum + maximum) / 2n - const reference = quoted - BigInt(item.premiumBps) + const premium = BigInt(item.premiumBps) + const reference = + item.targetRate.strategy === 'hardcoded' + ? BigInt(item.targetRate.hardcodedRateBps) + : (minimum + maximum) / 2n - premium + const quoted = reference + premium if ( reference <= 0n || - reference < minimum || - reference > maximum || - reference + BigInt(item.premiumBps) < minimum || - reference + BigInt(item.premiumBps) > maximum + (item.targetRate.strategy !== 'hardcoded' && (reference < minimum || reference > maximum)) || + quoted < minimum || + quoted > maximum ) { throw new PreviewGenerationError( 'Bootstrap derived reference and quoted rates must be positive and remain inside configured bounds' @@ -266,9 +299,14 @@ export const generateLadderGraphicModels = ( )[0]! const minimum = config.minimumRateBps const maximum = config.maximumRateBps - const center = (minimum + maximum) / 2n - const reference = center - config.quotePremiumBps - if (reference <= 0n || reference < minimum || reference > maximum) { + const reference = + input.targetRate.strategy === 'hardcoded' + ? BigInt(input.targetRate.hardcodedRateBps) + : (minimum + maximum) / 2n - config.quotePremiumBps + if ( + reference <= 0n || + (input.targetRate.strategy !== 'hardcoded' && (reference < minimum || reference > maximum)) + ) { throw new PreviewGenerationError( 'Ladder derived reference and center rates must remain inside configured bounds' ) diff --git a/bots/market-making/scripts/deploy-railway.ts b/bots/market-making/scripts/deploy-railway.ts index fdba3026..e7390765 100644 --- a/bots/market-making/scripts/deploy-railway.ts +++ b/bots/market-making/scripts/deploy-railway.ts @@ -12,6 +12,7 @@ import { resolve } from 'node:path' import { RailwayDeploymentError } from './railway-deployment.error' import { + assertFreshRailwayReferenceProvisioning, assertFullRailwaySignerProvisioning, isNonEmptyJsonArray, isTerminalRailwayDeploymentStatus, @@ -36,7 +37,6 @@ if (!PROJECT_ID) { const requiredRuntimeVariableNames = [ 'CHAIN_ID', 'RPC_URL', - 'REFERENCE_RPC_URL', 'MAKER_ADDRESS', 'MIDNIGHT_ADDRESS', 'LOAN_ASSET_ADDRESS', @@ -44,7 +44,6 @@ const requiredRuntimeVariableNames = [ 'MORPHO_API_BASE_URL', 'ROUTER_API_BASE_URL', 'MARKET_IDS', - 'REFERENCE_MARKET_ID', 'NATIVE_RESERVE_WEI', 'MAXIMUM_LEND_EXPOSURE_ASSETS', 'BOOTSTRAP_MARKETS', @@ -132,8 +131,9 @@ const listServices = async () => { const ensureService = async () => { const services = await listServices() const existingService = services.find(service => service.name === SERVICE) - if (existingService) return existingService + if (existingService) return { service: existingService, isFreshService: false } + assertFreshRailwayReferenceProvisioning(Bun.env, true) const { data, error } = await tryCatch( Promise.resolve($`railway add --service ${SERVICE} --json`.quiet().text()) ) @@ -146,7 +146,7 @@ const ensureService = async () => { throw new RailwayDeploymentError('Railway service creation returned incomplete identity') } - return createdService + return { service: createdService, isFreshService: true } } const listVolumes = async () => { @@ -272,7 +272,7 @@ await assertCli() await ensureContext() if (!DEPLOY_ONLY) { - const service = await ensureService() + const { service } = await ensureService() await setRuntimeVariable(['RAILWAY_DOCKERFILE_PATH', DOCKERFILE_PATH]) await setRuntimeVariable(['XDG_STATE_HOME', STATE_MOUNT_PATH]) diff --git a/bots/market-making/scripts/railway.utils.ts b/bots/market-making/scripts/railway.utils.ts index e2930b04..b6accd0d 100644 --- a/bots/market-making/scripts/railway.utils.ts +++ b/bots/market-making/scripts/railway.utils.ts @@ -20,6 +20,8 @@ type RailwayVolume = { } const optionalRuntimeVariableDefaults = [ + ['REFERENCE_RPC_URL', ' '], + ['REFERENCE_MARKET_ID', ' '], ['V0_OFFER_GROUP_IDS', ' '], ['REQUEST_TIMEOUT_MS', '10000'], ['TRANSACTION_RECEIPT_TIMEOUT_MS', '180000'], @@ -28,6 +30,8 @@ const optionalRuntimeVariableDefaults = [ ['BETTERSTACK_HEARTBEAT_URL', ' '] ] as const +const referenceVariableNames = new Set(['REFERENCE_RPC_URL', 'REFERENCE_MARKET_ID']) + type OptionalRuntimeVariableName = (typeof optionalRuntimeVariableDefaults)[number][0] type OptionalRuntimeVariable = readonly [name: OptionalRuntimeVariableName, value: string] @@ -87,22 +91,67 @@ export const isNonEmptyJsonArray = (raw: string) => { return Array.isArray(data) && data.length > 0 } +const everyConfiguredWorkflowUsesHardcodedRate = ( + environment: Readonly> +) => + ['BOOTSTRAP_MARKETS', 'LADDER_MARKETS'].every(name => { + const { data } = tryCatch(() => JSON.parse(environment[name] ?? '') as unknown) + return ( + Array.isArray(data) && + data.length > 0 && + data.every( + item => + isRecord(item) && isRecord(item.targetRate) && item.targetRate.strategy === 'hardcoded' + ) + ) + }) + /** - * Produces the complete optional Railway configuration for a full operator deployment. + * Rejects fresh Railway services that would start a variable-rate workflow without Blue references. + * @param environment - Invoking environment containing strategy and optional reference variables. + * @param isFreshService - Whether this provisioning run created the Railway service. + * @throws `RailwayDeploymentError` when a fresh variable-rate service lacks either Blue reference. + * @remarks Existing services preserve omitted Railway reference variables; hardcoded-only services do + * not require Blue configuration. + */ +export const assertFreshRailwayReferenceProvisioning = ( + environment: Readonly>, + isFreshService: boolean +) => { + if (!isFreshService || everyConfiguredWorkflowUsesHardcodedRate(environment)) return + + for (const name of ['REFERENCE_RPC_URL', 'REFERENCE_MARKET_ID'] as const) { + if (!environment[name]?.trim()) { + throw new RailwayDeploymentError(`Missing required environment variable: ${name}`) + } + } +} + +/** + * Produces optional Railway configuration for a full operator deployment. * @param environment - Invoking environment whose non-blank values override safe defaults. - * @returns Every optional variable exactly once, with timeouts reset to runtime defaults and - * trimmed string options represented by a whitespace sentinel when absent. - * @remarks Railway CLI 5.30.4 rejects empty stdin values. The bot trims the sentinel to an unset - * value, allowing full runs to clear stale optional configuration without triggering intermediate - * deployments. + * @returns Optional variables with timeouts reset to runtime defaults. Missing reference variables + * are cleared only when every configured workflow uses a hardcoded target rate; otherwise they are + * omitted so a full deployment preserves any existing Railway Blue configuration. + * @remarks Railway CLI 5.30.4 rejects empty stdin values. The bot trims whitespace sentinels to an + * unset value, allowing full runs to clear stale inactive configuration without triggering + * intermediate deployments. */ export const synchronizedOptionalRailwayVariables = ( environment: Readonly> ): OptionalRuntimeVariable[] => - optionalRuntimeVariableDefaults.map(([name, defaultValue]) => [ - name, - environment[name]?.trim() || defaultValue - ]) + optionalRuntimeVariableDefaults.flatMap(([name, defaultValue]) => { + const configuredValue = environment[name]?.trim() + if ( + referenceVariableNames.has(name) && + !configuredValue && + !everyConfiguredWorkflowUsesHardcodedRate(environment) + ) { + return [] + } + + return [[name, configuredValue || defaultValue]] + }) /** * Parses Railway service JSON without exposing unknown response fields. 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..6c2ec3c3 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 @@ -40,6 +40,14 @@ export interface LadderReferenceRateService { * @throws When the rate provider cannot return a fresh valid reference. */ readRate(marketId: Hex): Promise + /** + * Optionally reads the rate together with a freshness identity used to refresh timestamp-sensitive + * protocol offers even when the configured APR is unchanged. + * @param marketId - Canonical market identifier whose reference is required. + * @returns Current rate and stable freshness observation identity. + * @throws When the rate provider cannot return a fresh valid reference. + */ + readObservation?(marketId: Hex): Promise<{ rateBps: bigint; observationId: string }> } /** Consumer-owned blocking make boundary for ladder reconciliation and safety invalidation. */ @@ -381,8 +389,15 @@ export class LadderMarketMakerService { } let referenceRateBps: bigint + let referenceObservationId: string | undefined try { - referenceRateBps = await this.rates.readRate(config.marketId) + if (this.rates.readObservation) { + const observation = await this.rates.readObservation(config.marketId) + referenceRateBps = observation.rateBps + referenceObservationId = observation.observationId + } else { + referenceRateBps = await this.rates.readRate(config.marketId) + } } catch (error) { const result = await this.halt( config.marketId, @@ -404,7 +419,7 @@ export class LadderMarketMakerService { const recenter = active ? shouldRecenter(active.centerRateBps, targetRateBps, config.movementToleranceBps) : true - desired = + const generated = active && !recenter ? generateLadder({ config, @@ -413,6 +428,7 @@ export class LadderMarketMakerService { retainedCenterRateBps: active.centerRateBps }) : generateLadder({ config, referenceRateBps, capacities: market }) + desired = referenceObservationId ? { ...generated, referenceObservationId } : generated if (!active) decision = 'publish' else if (sameLadderQuoteSet(active, desired)) decision = 'rest' else decision = recenter ? 'recenter' : 'resize' diff --git a/bots/market-making/src/application/ladder/ladder-market-maker.utils.ts b/bots/market-making/src/application/ladder/ladder-market-maker.utils.ts index 83ba3465..ccce60bb 100644 --- a/bots/market-making/src/application/ladder/ladder-market-maker.utils.ts +++ b/bots/market-making/src/application/ladder/ladder-market-maker.utils.ts @@ -9,6 +9,7 @@ import type { LadderQuoteSet } from '../../domain/ladder/ladder' export const sameLadderQuoteSet = (left: LadderQuoteSet, right: LadderQuoteSet) => left.marketId === right.marketId && left.centerRateBps === right.centerRateBps && + left.referenceObservationId === right.referenceObservationId && left.groupMode === right.groupMode && left.lower.length === right.lower.length && left.higher.length === right.higher.length && diff --git a/bots/market-making/src/application/setup/setup-check.service.ts b/bots/market-making/src/application/setup/setup-check.service.ts index 64b9be2f..22ca1960 100644 --- a/bots/market-making/src/application/setup/setup-check.service.ts +++ b/bots/market-making/src/application/setup/setup-check.service.ts @@ -108,8 +108,8 @@ export type SetupCheckConfig = { ratifier: Address /** Non-empty set of Midnight market identifiers to validate concurrently. */ marketIds: readonly Hex[] - /** Morpho Blue market read through the archive-capable reference provider. */ - referenceMarketId: Hex + /** Morpho Blue market read through the archive-capable reference provider when required. */ + referenceMarketId?: Hex } /** API and on-chain facts used to validate one configured Midnight market. */ @@ -198,11 +198,13 @@ export class SetupCheckService { * @param state - Read-only provider port. * @param config - Validated setup requirements. * @param readOnly - Whether signer-only readiness reads must be skipped. + * @param referenceRequired - Whether an active target-rate strategy requires Blue history. */ constructor( private readonly state: SetupStateService, private readonly config: SetupCheckConfig, - private readonly readOnly = false + private readonly readOnly = false, + private readonly referenceRequired = true ) {} /** @@ -273,6 +275,9 @@ export class SetupCheckService { const derivedMakerRead = this.readOnly ? Promise.resolve(undefined) : captureSigner(() => this.state.getDerivedMaker()) + const referenceRead = this.referenceRequired + ? capture(() => this.state.checkReference(), 'archive-rpc') + : Promise.resolve(undefined) const reads = await Promise.all([ capture(() => this.state.getChainId()), capture(() => this.state.getCode(this.config.midnight)), @@ -282,7 +287,7 @@ export class SetupCheckService { capture(() => this.state.getRatifier(this.config.maker, this.config.ratifier)), capture(() => this.state.getLatestTimestamp()), Promise.all(bookReads.map(async book => ({ ...book, response: await book.response }))), - capture(() => this.state.checkReference(), 'archive-rpc'), + referenceRead, capture(() => this.state.inspectOffers(this.config.maker), 'morpho-api'), capture(() => this.state.checkPositionHealth()) ]) @@ -382,16 +387,24 @@ export class SetupCheckService { referenceReadable: true, archiveReadable: true } - const referenceCheck = !reference.ok - ? providerFailure('reference', reference.error, referenceRequired) - : setupResult( - 'reference', - reference.value.marketId === this.config.referenceMarketId && - reference.value.referenceReadable && - reference.value.archiveReadable, - reference.value, - referenceRequired - ) + const referenceCheck = + reference === undefined + ? { + name: 'reference' as const, + status: 'not-required' as const, + observed: { reason: 'no variable_rate_avg target-rate strategy is active' }, + required: 'only for variable_rate_avg target-rate strategies' + } + : !reference.ok + ? providerFailure('reference', reference.error, referenceRequired) + : setupResult( + 'reference', + reference.value.marketId === this.config.referenceMarketId && + reference.value.referenceReadable && + reference.value.archiveReadable, + reference.value, + referenceRequired + ) const offersRequired = { unknownNamespaces: [], unknownMarketIds: [], invertedMarketIds: [] } const offersCheck = !offers.ok ? providerFailure('offers', offers.error, offersRequired) diff --git a/bots/market-making/src/bootstrap.ts b/bots/market-making/src/bootstrap.ts index 26f27bba..df705adf 100644 --- a/bots/market-making/src/bootstrap.ts +++ b/bots/market-making/src/bootstrap.ts @@ -11,6 +11,7 @@ import type { } from './application/ladder/ladder-market-maker.service' import type { SetupStateService } from './application/setup/setup-check.service' import type { ConfigService } from './config/config.service' +import type { TargetRateStrategyConfig } from './domain/target-rate' import type { CliRuntimeOptions } from './infrastructure/cli/cli' import { PositionBootstrapService } from './application/bootstrap/position-bootstrap.service' @@ -20,9 +21,11 @@ import { serializeMarketMakingWrites } from './application/market-making/market- import { MarketMakingService } from './application/market-making/market-making.service' import { SetupCheckService } from './application/setup/setup-check.service' import { VersionService } from './application/version.service' +import { ConfigValidationError } from './config/config-validation.error' import { ConfigService as RuntimeConfigService } from './config/config.service' import { BootstrapConfigurationError } from './domain/bootstrap/bootstrap-configuration.error' import { LadderConfigurationError } from './domain/ladder/ladder-configuration.error' +import { requiresVariableRateReference } from './domain/target-rate' import { createBootstrapGroupOwnership } from './infrastructure/bootstrap/bootstrap-group-ownership.utils' import { createProductionBootstrapAdapters } from './infrastructure/bootstrap/production-bootstrap' import { Cli } from './infrastructure/cli/cli' @@ -45,6 +48,33 @@ const readOnlyWriter = (writeEvent?: CliRuntimeOptions['writeEvent']) => const parseEventWriter = (writeEvent?: CliRuntimeOptions['writeEvent']) => writeEvent === undefined ? undefined : (line: string) => writeEvent(JSON.parse(line)) +/** + * Enforces Blue configuration only for the workflows active in the selected command. + * @param config - Fully parsed runtime configuration. + * @param configurations - Bootstrap or ladder configurations active for this invocation. + * @throws `ConfigValidationError` when an active variable-rate strategy lacks Blue configuration. + */ +const assertReferenceConfigured = ( + config: ConfigService, + configurations: readonly { targetRate: TargetRateStrategyConfig }[] +) => { + if (!requiresVariableRateReference(configurations)) return + if (config.setup.referenceMarketId === undefined) { + throw new ConfigValidationError( + 'REFERENCE_MARKET_ID', + 'missing', + 'Missing required env var: REFERENCE_MARKET_ID' + ) + } + if (config.referenceRpcUrl === undefined) { + throw new ConfigValidationError( + 'REFERENCE_RPC_URL', + 'missing', + 'Missing required env var: REFERENCE_RPC_URL' + ) + } +} + const makerAccountAddress = async ( identity: Exclude ) => (await createMakerAccount(identity)).address @@ -92,7 +122,7 @@ const defaultState = async (config: ConfigService) => { return new ViemSetupStateService( createChainReader(config.rpcUrl, config.requestTimeoutMs), - createChainReader(config.referenceRpcUrl, config.requestTimeoutMs), + createChainReader(config.referenceRpcUrl ?? config.rpcUrl, config.requestTimeoutMs), (url, provider, timeoutMs) => requestJson(url, provider, Math.min(config.requestTimeoutMs, timeoutMs ?? Infinity)), { @@ -102,7 +132,7 @@ const defaultState = async (config: ConfigService) => { morphoApiBaseUrl: config.morphoApiBaseUrl, routerApiBaseUrl: config.routerApiBaseUrl, marketIds: config.setup.marketIds, - referenceMarketId: config.setup.referenceMarketId, + referenceMarketId: config.setup.referenceMarketId ?? config.setup.marketIds[0]!, v0OfferGroupIds: config.v0OfferGroupIds, readOwnedGroupIds: async () => [ ...new Set([...(await ownership.read()), ...(await ladderOwnership.readGroupIds())]) @@ -177,13 +207,25 @@ export const createApplication = ( new VersionService(), async options => { const config = await loadConfig(options) + assertReferenceConfigured(config, [...config.bootstrap, ...config.ladder]) const state = dependencies.createState?.(config) ?? (await defaultState(config)) - return new SetupCheckService(state, config.setup, config.readOnly) + return new SetupCheckService( + state, + config.setup, + config.readOnly, + requiresVariableRateReference([...config.bootstrap, ...config.ladder]) + ) }, async options => { const config = await loadConfig(options) + assertReferenceConfigured(config, config.bootstrap) const state = dependencies.createState?.(config) ?? (await defaultState(config)) - await new SetupCheckService(state, config.setup, config.readOnly).assertReady() + await new SetupCheckService( + state, + config.setup, + config.readOnly, + requiresVariableRateReference(config.bootstrap) + ).assertReady() const injectedAdapters = dependencies.createBootstrapAdapters?.(config) const writeReadOnlyEvent = parseEventWriter(options.writeEvent) const adapters = @@ -201,8 +243,14 @@ export const createApplication = ( }, async options => { const config = await loadConfig(options) + assertReferenceConfigured(config, config.ladder) const state = dependencies.createState?.(config) ?? (await defaultState(config)) - await new SetupCheckService(state, config.setup, config.readOnly).assertReady() + await new SetupCheckService( + state, + config.setup, + config.readOnly, + requiresVariableRateReference(config.ladder) + ).assertReady() const adapters = await (dependencies.createLadderAdapters?.(config) ?? createProductionLadderAdapters(config)) const writeReadOnlyEvent = parseEventWriter(options.writeEvent) @@ -236,8 +284,14 @@ export const createApplication = ( ) } + assertReferenceConfigured(config, [...config.bootstrap, ...config.ladder]) const state = dependencies.createState?.(config) ?? (await defaultState(config)) - const setup = new SetupCheckService(state, config.setup, config.readOnly) + const setup = new SetupCheckService( + state, + config.setup, + config.readOnly, + requiresVariableRateReference([...config.bootstrap, ...config.ladder]) + ) await setup.assertReady() const injectedBootstrapAdapters = dependencies.createBootstrapAdapters?.(config) diff --git a/bots/market-making/src/config/config-source.utils.ts b/bots/market-making/src/config/config-source.utils.ts index a6aa7a5e..1abad44c 100644 --- a/bots/market-making/src/config/config-source.utils.ts +++ b/bots/market-making/src/config/config-source.utils.ts @@ -104,6 +104,7 @@ const yamlKeys = { ], bootstrap: [ 'marketId', + 'targetRate', 'creditTarget', 'acceptanceAssets', 'offerSize', @@ -116,6 +117,7 @@ const yamlKeys = { ], ladder: [ 'marketId', + 'targetRate', 'quotePremiumBps', 'spreadBps', 'stepBps', diff --git a/bots/market-making/src/config/config.service.ts b/bots/market-making/src/config/config.service.ts index 16c0f9be..200ba944 100644 --- a/bots/market-making/src/config/config.service.ts +++ b/bots/market-making/src/config/config.service.ts @@ -5,6 +5,7 @@ import { inspect } from 'node:util' import type { SetupCheckConfig } from '../application/setup/setup-check.service' import type { BootstrapConfig } from '../domain/bootstrap/position-bootstrap' import type { LadderConfig } from '../domain/ladder/ladder' +import type { TargetRateConfigured } from '../domain/target-rate' import type { ConfigurationLoadOptions, ConfigurationSource } from './config-source.utils' import type { Environment } from './config.utils' import type { MakerIdentity } from './signer-identity.utils' @@ -13,8 +14,9 @@ import { configurationFromEnvironment, loadConfigurationSources } from './config import { ConfigValidationError } from './config-validation.error' import { addressValue, - bytes32Value, chainIdValue, + optionalBytes32Value, + optionalUrlValue, requestTimeoutValue, transactionReceiptTimeoutValue, unsignedBigIntValue, @@ -88,6 +90,9 @@ export class ConfigService { const identity: MakerIdentity = readOnly ? { readOnly: true, maker } : signerIdentity(environment, maker) + const marketIds = hexListValue(environment, 'MARKET_IDS', false) + const bootstrap = bootstrapConfigsValue(source.bootstrap, marketIds) + const ladder = ladderConfigsValue(source.ladder, marketIds) return new ConfigService({ identity, @@ -99,21 +104,18 @@ export class ConfigService { loanAsset: addressValue(environment, 'LOAN_ASSET_ADDRESS'), maximumLendExposure: unsignedBigIntValue(environment, 'MAXIMUM_LEND_EXPOSURE_ASSETS'), ratifier: addressValue(environment, 'RATIFIER_ADDRESS'), - marketIds: hexListValue(environment, 'MARKET_IDS', false), - referenceMarketId: bytes32Value(environment, 'REFERENCE_MARKET_ID') + marketIds, + referenceMarketId: optionalBytes32Value(environment, 'REFERENCE_MARKET_ID') }, rpcUrl: urlValue(environment, 'RPC_URL'), - referenceRpcUrl: urlValue(environment, 'REFERENCE_RPC_URL'), + referenceRpcUrl: optionalUrlValue(environment, 'REFERENCE_RPC_URL'), morphoApiBaseUrl: urlValue(environment, 'MORPHO_API_BASE_URL'), routerApiBaseUrl: urlValue(environment, 'ROUTER_API_BASE_URL'), v0OfferGroupIds: hexListValue(environment, 'V0_OFFER_GROUP_IDS', false), requestTimeoutMs: requestTimeoutValue(environment), transactionReceiptTimeoutMs: transactionReceiptTimeoutValue(environment), - bootstrap: bootstrapConfigsValue( - source.bootstrap, - hexListValue(environment, 'MARKET_IDS', false) - ), - ladder: ladderConfigsValue(source.ladder, hexListValue(environment, 'MARKET_IDS', false)) + bootstrap, + ladder }) } @@ -122,14 +124,14 @@ export class ConfigService { identity: MakerIdentity setup: SetupCheckConfig rpcUrl: string - referenceRpcUrl: string + referenceRpcUrl?: string morphoApiBaseUrl: string routerApiBaseUrl: string v0OfferGroupIds: readonly Hex[] requestTimeoutMs: number transactionReceiptTimeoutMs: number - bootstrap: readonly BootstrapConfig[] - ladder: readonly LadderConfig[] + bootstrap: readonly TargetRateConfigured[] + ladder: readonly TargetRateConfigured[] } ) {} diff --git a/bots/market-making/src/config/config.utils.ts b/bots/market-making/src/config/config.utils.ts index bdc50eb6..8ade9331 100644 --- a/bots/market-making/src/config/config.utils.ts +++ b/bots/market-making/src/config/config.utils.ts @@ -186,14 +186,15 @@ export const transactionReceiptTimeoutValue = (environment: Environment) => }) /** - * Reads one required bytes32 value. + * Parses an optional bytes32 variable when present. * @param environment - Environment map to inspect. - * @param name - Required bytes32 variable name. - * @returns The validated 32-byte hex value. - * @throws When missing or rejected by strict viem hex/size validation. + * @param name - Optional bytes32 variable name. + * @returns The validated 32-byte hex value, or `undefined` when absent. */ -export const bytes32Value = (environment: Environment, name: string) => - parseBytes32(requiredValue(environment, name), name) +export const optionalBytes32Value = (environment: Environment, name: string) => { + const value = environment[name]?.trim() + return value ? parseBytes32(value, name) : undefined +} /** * Reads one provider URL and removes a single trailing slash. @@ -209,3 +210,18 @@ export const urlValue = (environment: Environment, name: string) => { } return raw.endsWith('/') ? raw.slice(0, -1) : raw } + +/** + * Parses and normalizes an optional provider URL when present. + * @param environment - Environment map to inspect. + * @param name - Optional URL variable name. + * @returns A normalized URL, or `undefined` when absent. + */ +export const optionalUrlValue = (environment: Environment, name: string) => { + const value = environment[name]?.trim() + if (!value) return undefined + if (!URL.canParse(value)) { + throw new ConfigValidationError(name, 'invalid-url', `${name} must be a valid URL`) + } + return value.endsWith('/') ? value.slice(0, -1) : value +} diff --git a/bots/market-making/src/config/market-collections.ts b/bots/market-making/src/config/market-collections.ts index 922f3c77..6adcaf86 100644 --- a/bots/market-making/src/config/market-collections.ts +++ b/bots/market-making/src/config/market-collections.ts @@ -2,11 +2,12 @@ import type { Hex } from 'viem' import type { BootstrapConfig } from '../domain/bootstrap/position-bootstrap' import type { LadderConfig } from '../domain/ladder/ladder' +import type { TargetRateConfigured, TargetRateStrategyConfig } from '../domain/target-rate' import { BootstrapConfigurationError } from '../domain/bootstrap/bootstrap-configuration.error' import { validateBootstrapConfig } from '../domain/bootstrap/position-bootstrap' import { isBytes32, normalizeBytes32 } from '../domain/bytes32' -import { validateLadderConfig } from '../domain/ladder/ladder' +import { generateLadder, validateLadderConfig } from '../domain/ladder/ladder' import { LadderConfigurationError } from '../domain/ladder/ladder-configuration.error' import { ConfigValidationError } from './config-validation.error' @@ -72,6 +73,7 @@ export const hexListValue = ( export const BOOTSTRAP_MARKET_FIELDS = [ 'marketId', + 'targetRate', 'creditTarget', 'acceptanceAssets', 'offerSize', @@ -85,6 +87,7 @@ export const BOOTSTRAP_MARKET_FIELDS = [ export const LADDER_MARKET_FIELDS = [ 'marketId', + 'targetRate', 'quotePremiumBps', 'spreadBps', 'stepBps', @@ -125,10 +128,59 @@ const integerBigInt = (value: unknown, field: string, signed: boolean) => { return BigInt(value) } +const targetRateStrategyValue = (value: unknown, field: string): TargetRateStrategyConfig => { + if (value === undefined) return { strategy: 'variable_rate_avg' } + const targetRate = plainRecord(value, field) + if (typeof targetRate.strategy !== 'string') { + throw new ConfigValidationError( + `${field}.strategy`, + 'wrong-type', + `${field}.strategy must be a string` + ) + } + if (targetRate.strategy === 'variable_rate_avg') { + if (Object.keys(targetRate).some(key => key !== 'strategy')) { + throw new ConfigValidationError(field, 'unknown-key', `${field} contains an unsupported key`) + } + return { strategy: 'variable_rate_avg' } + } + if (targetRate.strategy !== 'hardcoded') { + throw new ConfigValidationError( + `${field}.strategy`, + 'invalid-strategy', + `${field}.strategy must be variable_rate_avg or hardcoded` + ) + } + if (Object.keys(targetRate).some(key => key !== 'strategy' && key !== 'hardcodedRateBps')) { + throw new ConfigValidationError(field, 'unknown-key', `${field} contains an unsupported key`) + } + if (targetRate.hardcodedRateBps === undefined) { + throw new ConfigValidationError( + `${field}.hardcodedRateBps`, + 'missing', + `${field}.hardcodedRateBps is required` + ) + } + const hardcodedRateBps = integerBigInt( + targetRate.hardcodedRateBps, + `${field}.hardcodedRateBps`, + false + ) + if (hardcodedRateBps <= 0n) { + throw new ConfigValidationError( + `${field}.hardcodedRateBps`, + 'out-of-range', + `${field}.hardcodedRateBps must be positive` + ) + } + return { strategy: 'hardcoded', hardcodedRateBps } +} + const exactRecord = ( value: unknown, prefix: string, - fields: readonly Field[] + fields: readonly Field[], + optionalFields: readonly Field[] = [] ) => { const record = plainRecord(value, prefix) const keys = Object.keys(record) @@ -138,12 +190,14 @@ const exactRecord = ( if (keys.some(key => !fields.includes(key as Field))) { throw new ConfigValidationError(prefix, 'unknown-key', `${prefix} contains an unsupported key`) } - if (keys.length !== fields.length) { - const missing = fields.find(field => record[field] === undefined) + const missing = fields.find( + field => !optionalFields.includes(field) && record[field] === undefined + ) + if (missing !== undefined) { throw new ConfigValidationError( - `${prefix}.${missing ?? 'field'}`, + `${prefix}.${missing}`, 'missing', - `${prefix}.${missing ?? 'field'} is required` + `${prefix}.${missing} is required` ) } return record @@ -158,13 +212,13 @@ const exactRecord = ( export const bootstrapConfigsValue = ( value: unknown, allowlistedMarkets: readonly Hex[] -): BootstrapConfig[] => { +): TargetRateConfigured[] => { if (!Array.isArray(value)) { throw new ConfigValidationError('bootstrap', 'wrong-type', 'bootstrap must be a list') } const configs = value.map((item, index) => { const prefix = `bootstrap[${index}]` - const record = exactRecord(item, prefix, BOOTSTRAP_MARKET_FIELDS) + const record = exactRecord(item, prefix, BOOTSTRAP_MARKET_FIELDS, ['targetRate']) const required = (name: (typeof BOOTSTRAP_MARKET_FIELDS)[number]) => record[name] const marketValue = required('marketId') if (typeof marketValue !== 'string') { @@ -174,8 +228,9 @@ export const bootstrapConfigsValue = ( `${prefix}.marketId must be a string` ) } - const config: BootstrapConfig = { + const config: TargetRateConfigured = { marketId: parseBytes32(marketValue, `${prefix}.marketId`), + targetRate: targetRateStrategyValue(record.targetRate, `${prefix}.targetRate`), creditTarget: integerBigInt(required('creditTarget'), `${prefix}.creditTarget`, false), acceptanceAssets: integerBigInt( required('acceptanceAssets'), @@ -214,6 +269,21 @@ export const bootstrapConfigsValue = ( } try { validateBootstrapConfig(config) + if (config.targetRate.strategy === 'hardcoded') { + const requestedRateBps = config.targetRate.hardcodedRateBps + config.premiumBps + if (requestedRateBps < config.minimumRateBps) { + throw new BootstrapConfigurationError( + 'requestedRateBps', + 'must be at least minimumRateBps' + ) + } + if (requestedRateBps > config.maximumRateBps) { + throw new BootstrapConfigurationError( + 'requestedRateBps', + 'must be at most maximumRateBps' + ) + } + } } catch (error) { if (error instanceof BootstrapConfigurationError) { throw new ConfigValidationError( @@ -254,13 +324,13 @@ const safeInteger = (value: unknown, field: string) => { export const ladderConfigsValue = ( value: unknown, allowlistedMarkets: readonly Hex[] -): LadderConfig[] => { +): TargetRateConfigured[] => { if (!Array.isArray(value)) { throw new ConfigValidationError('ladder', 'wrong-type', 'ladder must be a list') } const configs = value.map((item, index) => { const prefix = `ladder[${index}]` - const record = exactRecord(item, prefix, LADDER_MARKET_FIELDS) + const record = exactRecord(item, prefix, LADDER_MARKET_FIELDS, ['targetRate']) const required = (name: (typeof LADDER_MARKET_FIELDS)[number]) => record[name] const marketValue = required('marketId') const groupMode = required('groupMode') @@ -271,8 +341,9 @@ export const ladderConfigsValue = ( `${prefix} string fields must be strings` ) } - const config: LadderConfig = { + const config: TargetRateConfigured = { marketId: parseBytes32(marketValue, `${prefix}.marketId`), + targetRate: targetRateStrategyValue(record.targetRate, `${prefix}.targetRate`), quotePremiumBps: integerBigInt( required('quotePremiumBps'), `${prefix}.quotePremiumBps`, @@ -329,6 +400,9 @@ export const ladderConfigsValue = ( } try { validateLadderConfig(config) + if (config.targetRate.strategy === 'hardcoded') { + generateLadder({ config, referenceRateBps: config.targetRate.hardcodedRateBps }) + } } catch (error) { if (error instanceof LadderConfigurationError) { throw new ConfigValidationError( diff --git a/bots/market-making/src/domain/bootstrap/position-bootstrap.ts b/bots/market-making/src/domain/bootstrap/position-bootstrap.ts index 42ed39c5..cbd904e3 100644 --- a/bots/market-making/src/domain/bootstrap/position-bootstrap.ts +++ b/bots/market-making/src/domain/bootstrap/position-bootstrap.ts @@ -229,8 +229,7 @@ export const decidePositionBootstrap = ({ referenceObservationId: rate.observationId } - const observationMatches = - rate.mode === 'static' || activeOffer?.referenceObservationId === offer.referenceObservationId + const observationMatches = activeOffer?.referenceObservationId === offer.referenceObservationId if ( activeOffer && !requiresReconciliation && diff --git a/bots/market-making/src/domain/ladder/ladder.ts b/bots/market-making/src/domain/ladder/ladder.ts index a0fd03b4..da21d5bf 100644 --- a/bots/market-making/src/domain/ladder/ladder.ts +++ b/bots/market-making/src/domain/ladder/ladder.ts @@ -59,6 +59,7 @@ export type LadderRung = { export type LadderQuoteSet = { marketId: Hex centerRateBps: bigint + referenceObservationId?: string groupMode: LadderConfig['groupMode'] lower: readonly LadderRung[] higher: readonly LadderRung[] diff --git a/bots/market-making/src/domain/target-rate.ts b/bots/market-making/src/domain/target-rate.ts new file mode 100644 index 00000000..663673a6 --- /dev/null +++ b/bots/market-making/src/domain/target-rate.ts @@ -0,0 +1,16 @@ +/** Operator-selected method for deriving a workflow's target reference rate. */ +export type TargetRateStrategyConfig = + | { strategy: 'variable_rate_avg' } + | { strategy: 'hardcoded'; hardcodedRateBps: bigint } + +/** Adds target-rate method selection to an existing workflow configuration. */ +export type TargetRateConfigured = T & { targetRate: TargetRateStrategyConfig } + +/** + * Reports whether any active workflow derives its target from Morpho Blue history. + * @param configurations - Active bootstrap or ladder configurations to inspect. + * @returns `true` when at least one target-rate strategy requires Blue reference data. + */ +export const requiresVariableRateReference = ( + configurations: readonly TargetRateConfigured[] +) => configurations.some(configuration => configuration.targetRate.strategy === 'variable_rate_avg') diff --git a/bots/market-making/src/infrastructure/bootstrap/bootstrap-reference-rate.service.ts b/bots/market-making/src/infrastructure/bootstrap/bootstrap-reference-rate.service.ts index 72c8a265..4f471e3e 100644 --- a/bots/market-making/src/infrastructure/bootstrap/bootstrap-reference-rate.service.ts +++ b/bots/market-making/src/infrastructure/bootstrap/bootstrap-reference-rate.service.ts @@ -1,6 +1,7 @@ import type { Hex } from 'viem' import type { BootstrapReferenceRateService } from '../../application/bootstrap/position-bootstrap.service' +import type { TargetRateStrategyConfig } from '../../domain/target-rate' import { BootstrapAdapterError } from './bootstrap-adapter.error' @@ -25,6 +26,36 @@ export interface BlueReferenceReader { readAtOrBefore(timestamp: bigint): Promise } +/** Selects a configured target-rate method independently for each workflow market. */ +export class StrategyBootstrapReferenceRateService implements BootstrapReferenceRateService { + /** + * Creates a per-market strategy selector around the established Blue variable-rate adapter. + * @param strategies - Validated strategy configuration indexed by workflow market. + * @param variableRates - Existing Blue market variable-rate average implementation. + */ + constructor( + private readonly strategies: ReadonlyMap, + private readonly variableRates: BootstrapReferenceRateService + ) {} + + /** + * Resolves the configured hardcoded value or delegates to the Blue variable-rate average. + * @param marketId - Workflow market requesting its independently configured target rate. + * @returns A static observation or the existing Blue variable-rate observation. + * @throws `BootstrapAdapterError` when no strategy exists for the requested market. + */ + async readRate(marketId: Hex) { + const strategy = this.strategies.get(marketId) + if (!strategy) throw new BootstrapAdapterError('target-rate-strategy-missing') + if (strategy.strategy === 'variable_rate_avg') return this.variableRates.readRate(marketId) + return { + mode: 'static' as const, + rateBps: strategy.hardcodedRateBps, + observationId: `static:${strategy.hardcodedRateBps}:hour:${BigInt(Math.floor(Date.now() / 1_000)) / REFERENCE_REFRESH_SECONDS}` + } + } +} + /** Default six-hour, RPC-derived Morpho Blue supply-share reference adapter. */ export class BlueBootstrapReferenceRateService implements BootstrapReferenceRateService { /** Creates a variable-rate adapter. @param reader - Historical Blue reader. @param lookbackSeconds - Observation window in seconds. @param nowSeconds - Wall-clock unix time source used for freshness checks. */ diff --git a/bots/market-making/src/infrastructure/bootstrap/production-bootstrap.ts b/bots/market-making/src/infrastructure/bootstrap/production-bootstrap.ts index a000a89d..7e184650 100644 --- a/bots/market-making/src/infrastructure/bootstrap/production-bootstrap.ts +++ b/bots/market-making/src/infrastructure/bootstrap/production-bootstrap.ts @@ -24,6 +24,7 @@ import type { } from '../../application/bootstrap/position-bootstrap.service' import type { ConfigService } from '../../config/config.service' import type { BootstrapOffer } from '../../domain/bootstrap/position-bootstrap' +import type { HistoricalBlockReader } from '../reference/blue-reference-reader.utils' import type { BootstrapActiveGroup, BootstrapInventoryReader } from './bootstrap-position.service' import { pendingLadderQuoteSets } from '../ladder/ladder-active-publication.utils' @@ -32,10 +33,7 @@ import { createLadderGroupOwnership } from '../ladder/ladder-group-ownership.uti import { buildLadderTree } from '../ladder/ladder-offer.utils' import { createMakerAccount } from '../make/maker-account.utils' import { ReadOnlyBootstrapMakeService } from '../make/read-only-bootstrap-make.service' -import { - createBlueReferenceReader, - type HistoricalBlockReader -} from '../reference/blue-reference-reader.utils' +import { createBlueReferenceReader } from '../reference/blue-reference-reader.utils' import { BootstrapAdapterError } from './bootstrap-adapter.error' import { bootstrapExposureMarketIds } from './bootstrap-exposure.utils' import { createBootstrapGroupOwnership } from './bootstrap-group-ownership.utils' @@ -53,7 +51,10 @@ import { import { bootstrapContinuousFeeCap, createBootstrapOffer } from './bootstrap-offer.utils' import { readLivePendingBootstrapOffers } from './bootstrap-pending-offer.utils' import { MidnightBootstrapPositionService } from './bootstrap-position.service' -import { BlueBootstrapReferenceRateService } from './bootstrap-reference-rate.service' +import { + BlueBootstrapReferenceRateService, + StrategyBootstrapReferenceRateService +} from './bootstrap-reference-rate.service' import { createBootstrapRequirementClient } from './bootstrap-requirement-client.utils' import { prepareBootstrapRequirements } from './bootstrap-requirements.utils' import { assertBootstrapProspectiveSpread, bootstrapMarketGroupIds } from './bootstrap-spread.utils' @@ -156,7 +157,7 @@ export const createProductionBootstrapAdapters = ( }).extend(morphoViemExtension({ supportSignature: true, supportDeployless: true })) const referenceClient = createPublicClient({ chain: base, - transport: http(config.referenceRpcUrl, { timeout: config.requestTimeoutMs }) + transport: http(config.referenceRpcUrl ?? config.rpcUrl, { timeout: config.requestTimeoutMs }) }) const midnight = client.morpho.midnight(base.id) const ownership = createBootstrapGroupOwnership({ @@ -358,12 +359,16 @@ export const createProductionBootstrapAdapters = ( } const positions = new MidnightBootstrapPositionService(inventory, maker) - const rates = new BlueBootstrapReferenceRateService( + const blueRates = new BlueBootstrapReferenceRateService( createBlueReferenceReader( - config.setup.referenceMarketId, + config.setup.referenceMarketId ?? config.setup.marketIds[0]!, referenceClient as HistoricalBlockReader ) ) + const rates = new StrategyBootstrapReferenceRateService( + new Map(config.bootstrap.map(item => [item.marketId, item.targetRate] as const)), + blueRates + ) const completeBookOffers = async () => { const [groups, ladderPublications] = await Promise.all([readGroups(), ladderOwnership.read()]) const pendingLadderOffers = ( 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..0a35ca0b 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 @@ -41,6 +41,7 @@ type PersistedPublication = { quote: { marketId: string centerRateBps: string + referenceObservationId?: string groupMode: string lower: PersistedRung[] higher: PersistedRung[] @@ -100,6 +101,8 @@ const canonicalQuote = (value: unknown): LadderQuoteSet => { const quote = value as Record if ( (quote.groupMode !== 'shared-rung' && quote.groupMode !== 'per-book') || + (quote.referenceObservationId !== undefined && + typeof quote.referenceObservationId !== 'string') || !Array.isArray(quote.lower) || !Array.isArray(quote.higher) ) { @@ -108,6 +111,9 @@ const canonicalQuote = (value: unknown): LadderQuoteSet => { return { marketId: canonicalId(quote.marketId), centerRateBps: canonicalSignedAmount(quote.centerRateBps), + ...(typeof quote.referenceObservationId === 'string' + ? { referenceObservationId: quote.referenceObservationId } + : {}), groupMode: quote.groupMode, lower: quote.lower.map(canonicalRung), higher: quote.higher.map(canonicalRung) @@ -168,6 +174,9 @@ const serializePublication = (publication: OwnedLadderPublication): PersistedPub quote: { marketId: publication.quote.marketId, centerRateBps: String(publication.quote.centerRateBps), + ...(publication.quote.referenceObservationId + ? { referenceObservationId: publication.quote.referenceObservationId } + : {}), groupMode: publication.quote.groupMode, lower: publication.quote.lower.map(rung => ({ index: rung.index, diff --git a/bots/market-making/src/infrastructure/ladder/production-ladder.ts b/bots/market-making/src/infrastructure/ladder/production-ladder.ts index 8f364d1c..c1ad7dfd 100644 --- a/bots/market-making/src/infrastructure/ladder/production-ladder.ts +++ b/bots/market-making/src/infrastructure/ladder/production-ladder.ts @@ -33,7 +33,10 @@ import { recoverLegacyBootstrapOfferTick } from '../bootstrap/bootstrap-offer.utils' import { readLivePendingBootstrapOffers } from '../bootstrap/bootstrap-pending-offer.utils' -import { BlueBootstrapReferenceRateService } from '../bootstrap/bootstrap-reference-rate.service' +import { + BlueBootstrapReferenceRateService, + StrategyBootstrapReferenceRateService +} from '../bootstrap/bootstrap-reference-rate.service' import { createMakerAccount } from '../make/maker-account.utils' import { createBlueReferenceReader } from '../reference/blue-reference-reader.utils' import { @@ -145,7 +148,7 @@ export const createProductionLadderAdapters = ( }).extend(morphoViemExtension({ supportSignature: true, supportDeployless: true })) const referenceClient = createPublicClient({ chain: base, - transport: http(config.referenceRpcUrl, { timeout: config.requestTimeoutMs }) + transport: http(config.referenceRpcUrl ?? config.rpcUrl, { timeout: config.requestTimeoutMs }) }) const midnight = client.morpho.midnight(base.id) const bootstrapOwnership = createBootstrapGroupOwnership({ @@ -266,12 +269,20 @@ export const createProductionLadderAdapters = ( const blueRates = new BlueBootstrapReferenceRateService( createBlueReferenceReader( - config.setup.referenceMarketId, + config.setup.referenceMarketId ?? config.setup.marketIds[0]!, referenceClient as HistoricalBlockReader ) ) + const strategyRates = new StrategyBootstrapReferenceRateService( + new Map(config.ladder.map(item => [item.marketId, item.targetRate] as const)), + blueRates + ) const rates: LadderReferenceRateService = { - readRate: async marketId => (await blueRates.readRate(marketId)).rateBps + readRate: async marketId => (await strategyRates.readRate(marketId)).rateBps, + readObservation: async marketId => { + const observation = await strategyRates.readRate(marketId) + return { rateBps: observation.rateBps, observationId: observation.observationId } + } } const completeBookOffers = async () => { 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..db7a38dd 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 @@ -4,8 +4,7 @@ import { describe, expect, mock, test } from 'bun:test' import type { LadderMakeService, - LadderPositionService, - LadderReferenceRateService + LadderPositionService } from '../../../src/application/ladder/ladder-market-maker.service' import type { LadderConfig, @@ -47,6 +46,7 @@ const state = (capacity = 20n): LadderMarketState => ({ const harness = (configs: readonly LadderConfig[] = [config()]) => { let rate = 500n + let observationId = 'static:500:hour:1' let marketState = state() let readFailure: Hex | undefined let reconcileFailure: Hex | undefined @@ -65,10 +65,14 @@ const harness = (configs: readonly LadderConfig[] = [config()]) => { return marketState } } - const rates: LadderReferenceRateService = { - async readRate(id) { + const rates = { + async readRate(id: Hex) { reads.push(`rate:${id}`) return rate + }, + async readObservation(id: Hex) { + reads.push(`observation:${id}`) + return { rateBps: rate, observationId } } } const cleanup = mock(async () => { @@ -105,6 +109,7 @@ const harness = (configs: readonly LadderConfig[] = [config()]) => { cleanup, make, setRate: (value: bigint) => (rate = value), + setObservation: (value: string) => (observationId = value), setCapacity: (value: bigint) => (marketState = state(value)), failMarket: (id: Hex) => (readFailure = id), failReconcile: (id: Hex) => (reconcileFailure = id), @@ -311,6 +316,14 @@ describe('LadderMarketMakerService', () => { expect(subject.reconciliations).toHaveLength(4) }) + test('refreshes unchanged hardcoded ladder quotes when the time-bucket observation advances', async () => { + const subject = harness() + expect(await subject.service.runOnce()).toMatchObject([{ action: 'publish' }]) + subject.setObservation('static:500:hour:2') + + expect(await subject.service.runOnce()).toMatchObject([{ action: 'replace', reason: 'resize' }]) + }) + test('invalidates an active ladder when both sides fall below the offer floor', async () => { const subject = harness() await subject.service.runOnce() diff --git a/bots/market-making/test/bootstrap.test.ts b/bots/market-making/test/bootstrap.test.ts index fd4894ad..dc4a72bb 100644 --- a/bots/market-making/test/bootstrap.test.ts +++ b/bots/market-making/test/bootstrap.test.ts @@ -5,10 +5,14 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { SetupStateService } from '../src/application/setup/setup-check.service' +import type { + SetupCheckReport, + SetupStateService +} from '../src/application/setup/setup-check.service' import { SetupFailedError } from '../src/application/setup/setup-failed.error' import { createApplication } from '../src/bootstrap' +import { ConfigValidationError } from '../src/config/config-validation.error' const maker: Address = '0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A' const midnight: Address = '0x2222222222222222222222222222222222222222' @@ -327,6 +331,216 @@ describe('createApplication', () => { expect(events).toEqual(['readiness', 'bootstrap']) }) + test('starts a hardcoded-only bootstrap workflow without Blue reference readiness', async () => { + const state = readyState() + const checkReference = mock(async () => { + throw new Error('Blue archive unavailable') + }) + state.checkReference = checkReference + const application = createApplication( + { + ...environment, + REFERENCE_RPC_URL: undefined, + REFERENCE_MARKET_ID: undefined, + BOOTSTRAP_MARKETS: JSON.stringify([ + { + ...bootstrapConfiguration, + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' } + } + ]), + LADDER_MARKETS: JSON.stringify([ + { + ...ladderConfiguration, + targetRate: { strategy: 'variable_rate_avg' } + } + ]) + }, + { + createState: () => state, + createBootstrapAdapters: () => ({ + positions: { + readPosition: async () => ({ + credit: 100n, + debt: 0n, + cashBalance: 0n, + marketExposure: 100n, + totalExposure: 100n + }) + }, + rates: { + readRate: async () => ({ mode: 'static', rateBps: 400n, observationId: 'static:400' }) + }, + make: { + reconcile: async () => {}, + hardHalt: async () => {}, + cleanup: async () => {} + } + }) + } + ) + + await expect(application.run(['bootstrap'])).resolves.toBeDefined() + expect(checkReference).not.toHaveBeenCalled() + await expect(application.run(['ladder'])).rejects.toBeInstanceOf(ConfigValidationError) + }) + + test('keeps Blue reference readiness fail-closed for variable-rate bootstrap workflows', async () => { + const state = readyState() + const checkReference = mock(async () => { + throw new Error('Blue archive unavailable') + }) + state.checkReference = checkReference + const application = createApplication( + { + ...environment, + BOOTSTRAP_MARKETS: JSON.stringify([ + { + ...bootstrapConfiguration, + targetRate: { strategy: 'variable_rate_avg' } + } + ]) + }, + { createState: () => state } + ) + + await expect(application.run(['bootstrap'])).rejects.toBeInstanceOf(SetupFailedError) + expect(checkReference).toHaveBeenCalledTimes(1) + }) + + test('keeps Blue reference readiness fail-closed for variable-rate ladder workflows', async () => { + const state = readyState() + const checkReference = mock(async () => { + throw new Error('Blue archive unavailable') + }) + const createLadderAdapters = mock(() => { + throw new Error('ladder adapters must not start') + }) + state.checkReference = checkReference + const application = createApplication( + { + ...environment, + BOOTSTRAP_MARKETS: '[]', + LADDER_MARKETS: JSON.stringify([ + { + ...ladderConfiguration, + targetRate: { strategy: 'variable_rate_avg' } + } + ]) + }, + { createState: () => state, createLadderAdapters } + ) + + await expect(application.run(['ladder'])).rejects.toBeInstanceOf(SetupFailedError) + expect(checkReference).toHaveBeenCalledTimes(1) + expect(createLadderAdapters).not.toHaveBeenCalled() + }) + + test('starts a hardcoded-only ladder workflow without Blue reference readiness', async () => { + const state = readyState() + const checkReference = mock(async () => { + throw new Error('Blue archive unavailable') + }) + state.checkReference = checkReference + const application = createApplication( + { + ...environment, + REFERENCE_RPC_URL: undefined, + REFERENCE_MARKET_ID: undefined, + BOOTSTRAP_MARKETS: JSON.stringify([ + { + ...bootstrapConfiguration, + targetRate: { strategy: 'variable_rate_avg' } + } + ]), + LADDER_MARKETS: JSON.stringify([ + { + ...ladderConfiguration, + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '475' } + } + ]) + }, + { + createState: () => state, + createLadderAdapters: () => ({ + positions: { readMarket: async () => ({}) }, + rates: { readRate: async () => 475n }, + make: { + readActive: async () => undefined, + reconcile: async () => {}, + hardHalt: async () => {}, + cleanup: async () => {} + } + }) + } + ) + + await expect(application.run(['ladder'])).resolves.toBeDefined() + expect(checkReference).not.toHaveBeenCalled() + await expect(application.run(['bootstrap'])).rejects.toBeInstanceOf(ConfigValidationError) + }) + + test('setup-check composes hardcoded bootstrap and ladder strategies without Blue reference readiness', async () => { + const state = readyState() + const checkReference = mock(async () => { + throw new Error('Blue archive unavailable') + }) + state.checkReference = checkReference + const application = createApplication( + { + ...environment, + REFERENCE_RPC_URL: undefined, + REFERENCE_MARKET_ID: undefined, + BOOTSTRAP_MARKETS: JSON.stringify([ + { + ...bootstrapConfiguration, + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' } + } + ]), + LADDER_MARKETS: JSON.stringify([ + { + ...ladderConfiguration, + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '475' } + } + ]) + }, + { createState: () => state } + ) + + const report = (await application.run(['setup-check'])) as SetupCheckReport + + expect(report.ready).toBe(true) + expect(report.checks.find(check => check.name === 'reference')).toMatchObject({ + status: 'not-required', + observed: { reason: 'no variable_rate_avg target-rate strategy is active' } + }) + expect(checkReference).not.toHaveBeenCalled() + }) + + test('setup-check fails closed when the bootstrap and ladder strategy union requires Blue', async () => { + const application = createApplication( + { + ...environment, + REFERENCE_RPC_URL: undefined, + REFERENCE_MARKET_ID: undefined, + BOOTSTRAP_MARKETS: JSON.stringify([ + { + ...bootstrapConfiguration, + targetRate: { strategy: 'variable_rate_avg' } + } + ]), + LADDER_MARKETS: JSON.stringify([ + { + ...ladderConfiguration, + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '475' } + } + ]) + }, + { createState: readyState } + ) + + await expect(application.run(['setup-check'])).rejects.toBeInstanceOf(ConfigValidationError) + }) + test('mm ladder passes readiness before running one ladder cycle', async () => { const events: string[] = [] const state = readyState() @@ -755,6 +969,127 @@ describe('createApplication', () => { expect(ladderCleanup).toHaveBeenCalledTimes(1) }) + test('combined start composes hardcoded bootstrap and ladder workflows without Blue reference readiness', async () => { + const checkReference = mock(async () => { + throw new Error('Blue archive unavailable') + }) + const state = readyState() + state.checkReference = checkReference + const started: string[] = [] + const controller = new AbortController() + controller.abort() + const application = createApplication( + { + ...environment, + REFERENCE_RPC_URL: undefined, + REFERENCE_MARKET_ID: undefined, + BOOTSTRAP_MARKETS: JSON.stringify([ + { + ...bootstrapConfiguration, + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' } + } + ]), + LADDER_MARKETS: JSON.stringify([ + { + ...ladderConfiguration, + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '475' } + } + ]) + }, + { + createState: () => state, + createBootstrapAdapters: () => { + started.push('bootstrap') + return { + positions: { + readPosition: async () => ({ + credit: 0n, + debt: 0n, + cashBalance: 100n, + marketExposure: 0n, + totalExposure: 0n + }) + }, + rates: { + readRate: async () => ({ + mode: 'static', + rateBps: 400n, + observationId: 'static:400' + }) + }, + make: { + reconcile: async () => {}, + hardHalt: async () => {}, + cleanup: async () => {} + } + } + }, + createLadderAdapters: () => { + started.push('ladder') + return { + positions: { readMarket: async () => ({}) }, + rates: { readRate: async () => 475n }, + make: { + readActive: async () => undefined, + reconcile: async () => {}, + hardHalt: async () => {}, + cleanup: async () => {} + } + } + } + } + ) + + const report = await application.run(['start'], { signal: controller.signal }) + + expect(report).toMatchObject({ + status: 'stopped', + workflows: { + bootstrap: { status: 'fulfilled' }, + ladder: { status: 'fulfilled' } + } + }) + expect(started).toEqual(['bootstrap', 'ladder']) + expect(checkReference).not.toHaveBeenCalled() + }) + + test('combined start fails closed when the bootstrap and ladder strategy union requires Blue', async () => { + const started: string[] = [] + const application = createApplication( + { + ...environment, + REFERENCE_RPC_URL: undefined, + REFERENCE_MARKET_ID: undefined, + BOOTSTRAP_MARKETS: JSON.stringify([ + { + ...bootstrapConfiguration, + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' } + } + ]), + LADDER_MARKETS: JSON.stringify([ + { + ...ladderConfiguration, + targetRate: { strategy: 'variable_rate_avg' } + } + ]) + }, + { + createState: readyState, + createBootstrapAdapters: () => { + started.push('bootstrap') + throw new Error('bootstrap must not start') + }, + createLadderAdapters: () => { + started.push('ladder') + throw new Error('ladder must not start') + } + } + ) + + await expect(application.run(['start'])).rejects.toBeInstanceOf(ConfigValidationError) + expect(started).toEqual([]) + }) + test('wires explicit --config and default working-directory discovery into startup', async () => { const directory = await mkdtemp(join(tmpdir(), 'market-making-bootstrap-')) const configuration = ` diff --git a/bots/market-making/test/config/config-loading.test.ts b/bots/market-making/test/config/config-loading.test.ts index f6060467..aa8d108c 100644 --- a/bots/market-making/test/config/config-loading.test.ts +++ b/bots/market-making/test/config/config-loading.test.ts @@ -131,6 +131,7 @@ describe('ConfigService YAML and environment loading', () => { expect(config.bootstrap).toEqual([ { marketId, + targetRate: { strategy: 'variable_rate_avg' }, creditTarget: 10_000_000_000_000_000_001n, acceptanceAssets: 1n, offerSize: 2n, @@ -144,6 +145,86 @@ describe('ConfigService YAML and environment loading', () => { ]) }) + test('loads independent explicit bootstrap and ladder target-rate strategies from YAML', async () => { + const directory = await temporaryDirectory() + const path = join(directory, 'operator.yaml') + const contents = yaml(`ladder: + - marketId: "${marketId}" + targetRate: + strategy: variable_rate_avg + quotePremiumBps: "0" + spreadBps: "200" + stepBps: "100" + rungCount: "1" + sizeSkewBps: "0" + lowerRateBudgetAssets: "10" + higherRateBudgetAssets: "10" + targetMarketExposureAssets: "20" + maximumTotalExposureAssets: "20" + minimumOfferAssets: "1" + groupMode: shared-rung + loopIntervalSeconds: "60" + movementToleranceBps: "10" + minimumRateBps: "200" + maximumRateBps: "800" + - marketId: "${secondMarketId}" + targetRate: + strategy: hardcoded + hardcodedRateBps: "400" + quotePremiumBps: "0" + spreadBps: "200" + stepBps: "100" + rungCount: "1" + sizeSkewBps: "0" + lowerRateBudgetAssets: "10" + higherRateBudgetAssets: "10" + targetMarketExposureAssets: "20" + maximumTotalExposureAssets: "20" + minimumOfferAssets: "1" + groupMode: shared-rung + loopIntervalSeconds: "60" + movementToleranceBps: "10" + minimumRateBps: "200" + maximumRateBps: "800" +`) + .replace(` - "${marketId}"`, ` - "${marketId}"\n - "${secondMarketId}"`) + .replace( + ' premiumBps: -50', + ' targetRate:\n strategy: hardcoded\n hardcodedRateBps: "400"\n premiumBps: -50' + ) + await writeFile(path, contents) + + const config = await ConfigService.load({}, { configPath: path }) + + expect(config.bootstrap[0]?.targetRate).toEqual({ + strategy: 'hardcoded', + hardcodedRateBps: 400n + }) + expect(config.ladder.map(item => item.targetRate)).toEqual([ + { strategy: 'variable_rate_avg' }, + { strategy: 'hardcoded', hardcodedRateBps: 400n } + ]) + }) + + test('rejects an invalid nested YAML target-rate with an exact validation failure', async () => { + const directory = await temporaryDirectory() + const path = join(directory, 'operator.yaml') + await writeFile( + path, + yaml().replace( + ' premiumBps: -50', + ' targetRate:\n strategy: hardcoded\n premiumBps: -50' + ) + ) + + const error = await ConfigService.load({}, { configPath: path }).catch(error => error) + + expect(error).toBeInstanceOf(ConfigValidationError) + expect(error.field).toBe('bootstrap[0].targetRate.hardcodedRateBps') + expect(error.reason).toBe('missing') + expect(error.message).toBe('bootstrap[0].targetRate.hardcodedRateBps is required') + }) + test('loads env-only configuration when no default file exists', async () => { const directory = await temporaryDirectory() const config = await ConfigService.load(environment, { cwd: directory }) diff --git a/bots/market-making/test/config/config.service.test.ts b/bots/market-making/test/config/config.service.test.ts index 5fcab79d..cf38a184 100644 --- a/bots/market-making/test/config/config.service.test.ts +++ b/bots/market-making/test/config/config.service.test.ts @@ -100,15 +100,37 @@ describe('ConfigService', () => { expect(ConfigService.from({ ...environment, MARKET_IDS: ' , ' }).setup.marketIds).toEqual([]) }) - test('requires one exact Blue reference market id', () => { - expect(() => ConfigService.from({ ...environment, REFERENCE_MARKET_ID: undefined })).toThrow( - 'Missing required env var: REFERENCE_MARKET_ID' - ) + test('loads optional Blue configuration and validates it when provided', () => { + expect( + ConfigService.from({ + ...environment, + REFERENCE_MARKET_ID: undefined, + REFERENCE_RPC_URL: undefined + }).setup.referenceMarketId + ).toBeUndefined() expect(() => ConfigService.from({ ...environment, REFERENCE_MARKET_ID: '0x1234' })).toThrow( 'REFERENCE_MARKET_ID must be a 0x-prefixed 32-byte hex value' ) }) + test('trims optional Blue configuration and treats blank values as absent', () => { + const normalized = ConfigService.from({ + ...environment, + REFERENCE_MARKET_ID: ` ${referenceMarketId} `, + REFERENCE_RPC_URL: ' https://archive.example/path/ ' + }) + const absent = ConfigService.from({ + ...environment, + REFERENCE_MARKET_ID: ' ', + REFERENCE_RPC_URL: '' + }) + + expect(normalized.setup.referenceMarketId).toBe(referenceMarketId) + expect(normalized.referenceRpcUrl).toBe('https://archive.example/path') + expect(absent.setup.referenceMarketId).toBeUndefined() + expect(absent.referenceRpcUrl).toBeUndefined() + }) + test('loads a bounded provider timeout and rejects unsafe values', () => { expect( ConfigService.from({ ...environment, REQUEST_TIMEOUT_MS: '2500' }).requestTimeoutMs diff --git a/bots/market-making/test/config/ladder-config.test.ts b/bots/market-making/test/config/ladder-config.test.ts index 02593a2d..02d6a961 100644 --- a/bots/market-making/test/config/ladder-config.test.ts +++ b/bots/market-making/test/config/ladder-config.test.ts @@ -5,6 +5,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { ConfigValidationError } from '../../src/config/config-validation.error' import { ConfigService } from '../../src/config/config.service' const marketId: Hex = `0x${'55'.repeat(32)}` @@ -44,6 +45,19 @@ const item = (overrides: Record = {}) => ({ maximumRateBps: '800', ...overrides }) +const bootstrapItem = (overrides: Record = {}) => ({ + marketId, + creditTarget: '10', + acceptanceAssets: '1', + offerSize: '2', + premiumBps: '0', + maximumMarketExposure: '20', + maximumTotalExposure: '20', + minimumRateBps: '200', + maximumRateBps: '800', + autoRefill: false, + ...overrides +}) const directories: string[] = [] afterEach(async () => { @@ -51,6 +65,141 @@ afterEach(async () => { }) describe('ladder configuration loading', () => { + test('loads variable-rate and hardcoded target strategies independently', () => { + const config = ConfigService.from({ + ...baseEnvironment, + BOOTSTRAP_MARKETS: JSON.stringify([ + bootstrapItem({ targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' } }) + ]), + LADDER_MARKETS: JSON.stringify([item({ targetRate: { strategy: 'variable_rate_avg' } })]) + }) + + expect(config.bootstrap[0]?.targetRate).toEqual({ + strategy: 'hardcoded', + hardcodedRateBps: 400n + }) + expect(config.ladder[0]?.targetRate).toEqual({ strategy: 'variable_rate_avg' }) + }) + + test('defaults each omitted target strategy to variable-rate average', () => { + const config = ConfigService.from({ + ...baseEnvironment, + LADDER_MARKETS: JSON.stringify([item({ targetRate: undefined })]) + }) + + expect(config.ladder[0]?.targetRate).toEqual({ strategy: 'variable_rate_avg' }) + }) + + test('rejects a hardcoded bootstrap target whose premium-adjusted rate exceeds its bounds', () => { + expect(() => + ConfigService.from({ + ...baseEnvironment, + BOOTSTRAP_MARKETS: JSON.stringify([ + bootstrapItem({ + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '801' } + }) + ]) + }) + ).toThrow('must be at most maximumRateBps') + }) + + test('rejects a hardcoded ladder target whose outer rung exceeds its bounds', () => { + expect(() => + ConfigService.from({ + ...baseEnvironment, + LADDER_MARKETS: JSON.stringify([ + item({ + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '700' } + }) + ]) + }) + ).toThrow('higher rung is outside the configured hard range') + }) + + test.each([ + [ + 'BOOTSTRAP_MARKETS', + bootstrapItem, + { strategy: 'hardcoded' }, + 'bootstrap[0].targetRate.hardcodedRateBps', + 'missing', + 'bootstrap[0].targetRate.hardcodedRateBps is required' + ], + [ + 'LADDER_MARKETS', + item, + { strategy: 'hardcoded', hardcodedRateBps: '1e2' }, + 'ladder[0].targetRate.hardcodedRateBps', + 'invalid-integer', + 'ladder[0].targetRate.hardcodedRateBps must be an integer' + ], + [ + 'BOOTSTRAP_MARKETS', + bootstrapItem, + { strategy: 'hardcoded', hardcodedRateBps: 400 }, + 'bootstrap[0].targetRate.hardcodedRateBps', + 'invalid-integer', + 'bootstrap[0].targetRate.hardcodedRateBps must be an integer' + ], + [ + 'LADDER_MARKETS', + item, + { strategy: 'hardcoded', hardcodedRateBps: '0' }, + 'ladder[0].targetRate.hardcodedRateBps', + 'out-of-range', + 'ladder[0].targetRate.hardcodedRateBps must be positive' + ], + [ + 'BOOTSTRAP_MARKETS', + bootstrapItem, + { strategy: 'hardcoded', hardcodedRateBps: '-1' }, + 'bootstrap[0].targetRate.hardcodedRateBps', + 'invalid-integer', + 'bootstrap[0].targetRate.hardcodedRateBps must be an integer' + ], + [ + 'BOOTSTRAP_MARKETS', + bootstrapItem, + { strategy: 'unsupported' }, + 'bootstrap[0].targetRate.strategy', + 'invalid-strategy', + 'bootstrap[0].targetRate.strategy must be variable_rate_avg or hardcoded' + ], + [ + 'LADDER_MARKETS', + item, + { strategy: 'variable_rate_avg', hardcodedRateBps: '400' }, + 'ladder[0].targetRate', + 'unknown-key', + 'ladder[0].targetRate contains an unsupported key' + ], + [ + 'BOOTSTRAP_MARKETS', + bootstrapItem, + { strategy: 'hardcoded', hardcodedRateBps: '400', unsupported: true }, + 'bootstrap[0].targetRate', + 'unknown-key', + 'bootstrap[0].targetRate contains an unsupported key' + ] + ])( + 'rejects invalid %s target-rate configuration %#', + (field, makeItem, targetRate, expectedField, reason, message) => { + let error: unknown + try { + ConfigService.from({ + ...baseEnvironment, + [field]: JSON.stringify([makeItem({ targetRate })]) + }) + } catch (value) { + error = value + } + + expect(error).toBeInstanceOf(ConfigValidationError) + expect(error).toMatchObject({ field: expectedField, reason }) + expect((error as ConfigValidationError).message).toBe(message) + } + ) + test('defaults to an empty list and loads a root YAML ladder list', async () => { expect(ConfigService.from(baseEnvironment).ladder).toEqual([]) const directory = await mkdtemp(join(tmpdir(), 'ladder-config-')) diff --git a/bots/market-making/test/domain/bootstrap/position-bootstrap.test.ts b/bots/market-making/test/domain/bootstrap/position-bootstrap.test.ts index 1cbe7e2c..4c02bcc8 100644 --- a/bots/market-making/test/domain/bootstrap/position-bootstrap.test.ts +++ b/bots/market-making/test/domain/bootstrap/position-bootstrap.test.ts @@ -307,22 +307,23 @@ describe('decidePositionBootstrap', () => { ).toEqual({ kind: 'replace', activeOffer, offer }) }) - test('leaves a static offer resting when only observation metadata changes', () => { + test('refreshes a static offer when its time-bucket observation changes', () => { const activeOffer = { marketId, assets: 500n, rateBps: 450n, - referenceObservationId: 'static:old-observation' + referenceObservationId: 'static:500:hour:1' } + const offer = { ...activeOffer, referenceObservationId: 'static:500:hour:2' } expect( decidePositionBootstrap({ ...parameters, position: { ...parameters.position, credit: 0n }, - rate: { mode: 'static', rateBps: 500n, observationId: 'static:new-observation' }, + rate: { mode: 'static', rateBps: 500n, observationId: 'static:500:hour:2' }, activeOffer }) - ).toEqual({ kind: 'rest', offer: activeOffer }) + ).toEqual({ kind: 'replace', activeOffer, offer }) }) test('refreshes a rehydrated variable offer for a new observation', () => { 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..75634349 100644 --- a/bots/market-making/test/e2e/setup-check.e2e.test.ts +++ b/bots/market-making/test/e2e/setup-check.e2e.test.ts @@ -80,7 +80,7 @@ describe('market-making setup check on a pinned Base fork', () => { ['loan-allowance', 'passed'], ['ratifier', 'passed'], ['books', 'passed'], - ['reference', 'passed'], + ['reference', 'not-required'], ['offers', 'passed'], ['position-health', 'not-required'] ]) diff --git a/bots/market-making/test/infrastructure/bootstrap/bootstrap-reference-rate.service.test.ts b/bots/market-making/test/infrastructure/bootstrap/bootstrap-reference-rate.service.test.ts index 62bb7988..7c67e3b6 100644 --- a/bots/market-making/test/infrastructure/bootstrap/bootstrap-reference-rate.service.test.ts +++ b/bots/market-making/test/infrastructure/bootstrap/bootstrap-reference-rate.service.test.ts @@ -1,9 +1,82 @@ -import { describe, expect, test } from 'bun:test' +import type { Hex } from 'viem' + +import { afterEach, describe, expect, setSystemTime, test } from 'bun:test' import { BootstrapAdapterError } from '../../../src/infrastructure/bootstrap/bootstrap-adapter.error' -import { BlueBootstrapReferenceRateService } from '../../../src/infrastructure/bootstrap/bootstrap-reference-rate.service' +import { + BlueBootstrapReferenceRateService, + StrategyBootstrapReferenceRateService +} from '../../../src/infrastructure/bootstrap/bootstrap-reference-rate.service' const marketId = `0x${'11'.repeat(32)}` as const +const secondMarketId = `0x${'22'.repeat(32)}` as const + +afterEach(() => setSystemTime()) + +describe('StrategyBootstrapReferenceRateService', () => { + test('uses a configured hardcoded target without reading the Blue variable-rate average', async () => { + let variableReads = 0 + const service = new StrategyBootstrapReferenceRateService( + new Map([[marketId, { strategy: 'hardcoded', hardcodedRateBps: 400n }]]), + { + readRate: async () => { + variableReads += 1 + return { mode: 'variable', rateBps: 500n, observationId: 'hour:1' } + } + } + ) + + expect(await service.readRate(marketId)).toEqual({ + mode: 'static', + rateBps: 400n, + observationId: expect.stringMatching(/^static:400:hour:\d+$/) + }) + expect(variableReads).toBe(0) + }) + + test('selects variable-rate average and hardcoded targets independently by market', async () => { + const variableReads: Hex[] = [] + const service = new StrategyBootstrapReferenceRateService( + new Map([ + [marketId, { strategy: 'variable_rate_avg' }], + [secondMarketId, { strategy: 'hardcoded', hardcodedRateBps: 400n }] + ]), + { + readRate: async selectedMarketId => { + variableReads.push(selectedMarketId) + return { mode: 'variable', rateBps: 525n, observationId: 'hour:2' } + } + } + ) + + expect(await service.readRate(marketId)).toEqual({ + mode: 'variable', + rateBps: 525n, + observationId: 'hour:2' + }) + expect(await service.readRate(secondMarketId)).toEqual({ + mode: 'static', + rateBps: 400n, + observationId: expect.stringMatching(/^static:400:hour:\d+$/) + }) + expect(variableReads).toEqual([marketId]) + }) + + test('changes a hardcoded observation when its hourly refresh bucket advances', async () => { + const service = new StrategyBootstrapReferenceRateService( + new Map([[marketId, { strategy: 'hardcoded', hardcodedRateBps: 400n }]]), + { readRate: async () => ({ mode: 'variable', rateBps: 500n, observationId: 'hour:1' }) } + ) + + setSystemTime(new Date(3_599_000)) + const first = await service.readRate(marketId) + setSystemTime(new Date(3_600_000)) + const second = await service.readRate(marketId) + + expect(first.observationId).toBe('static:400:hour:0') + expect(second.observationId).toBe('static:400:hour:1') + }) +}) describe('BlueBootstrapReferenceRateService', () => { test('accepts a latest checkpoint at the freshness boundary', async () => { diff --git a/bots/market-making/test/infrastructure/bootstrap/production-bootstrap.test.ts b/bots/market-making/test/infrastructure/bootstrap/production-bootstrap.test.ts index 7a5d6f36..9431c82b 100644 --- a/bots/market-making/test/infrastructure/bootstrap/production-bootstrap.test.ts +++ b/bots/market-making/test/infrastructure/bootstrap/production-bootstrap.test.ts @@ -108,7 +108,7 @@ const group = (overrides: Record = {}) => ({ }) describe('createProductionBootstrapAdapters', () => { - test('constructs address-only readers and a terminal make adapter without a private key', async () => { + test('constructs address-only readers and selects the configured hardcoded bootstrap rate', async () => { const config = ConfigService.from( { CHAIN_ID: '8453', @@ -123,7 +123,22 @@ describe('createProductionBootstrapAdapters', () => { NATIVE_RESERVE_WEI: '10', MAXIMUM_LEND_EXPOSURE_ASSETS: '100', MORPHO_API_BASE_URL: 'https://api.example', - ROUTER_API_BASE_URL: 'https://router.example' + ROUTER_API_BASE_URL: 'https://router.example', + BOOTSTRAP_MARKETS: JSON.stringify([ + { + marketId, + creditTarget: '10', + acceptanceAssets: '1', + offerSize: '2', + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' }, + premiumBps: '0', + maximumMarketExposure: '20', + maximumTotalExposure: '20', + minimumRateBps: '200', + maximumRateBps: '800', + autoRefill: false + } + ]) }, { readOnly: true } ) @@ -131,6 +146,11 @@ describe('createProductionBootstrapAdapters', () => { const adapters = await createProductionBootstrapAdapters(config) expect(adapters.make).toBeInstanceOf(ReadOnlyBootstrapMakeService) + expect(await adapters.rates.readRate(marketId)).toEqual({ + mode: 'static', + rateBps: 400n, + observationId: expect.stringMatching(/^static:400:hour:\d+$/) + }) }) test('rejects a write configuration whose private key does not match the maker', async () => { 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..2ba1e5c7 100644 --- a/bots/market-making/test/infrastructure/ladder/production-ladder.test.ts +++ b/bots/market-making/test/infrastructure/ladder/production-ladder.test.ts @@ -47,6 +47,40 @@ const environment = { } describe('createProductionLadderAdapters', () => { + test('selects the configured hardcoded ladder target independently from bootstrap', async () => { + const config = ConfigService.from( + { + ...environment, + LADDER_MARKETS: JSON.stringify([ + { + marketId, + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '475' }, + quotePremiumBps: '0', + spreadBps: '200', + stepBps: '100', + rungCount: '1', + sizeSkewBps: '0', + lowerRateBudgetAssets: '10', + higherRateBudgetAssets: '10', + targetMarketExposureAssets: '20', + maximumTotalExposureAssets: '20', + minimumOfferAssets: '1', + groupMode: 'shared-rung', + loopIntervalSeconds: '60', + movementToleranceBps: '10', + minimumRateBps: '200', + maximumRateBps: '800' + } + ]) + }, + { readOnly: true } + ) + + const adapters = await createProductionLadderAdapters(config) + + expect(await adapters.rates.readRate(marketId)).toBe(475n) + }) + 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/test/playground/artifact.test.ts b/bots/market-making/test/playground/artifact.test.ts index 53bb3c2e..59f196b2 100644 --- a/bots/market-making/test/playground/artifact.test.ts +++ b/bots/market-making/test/playground/artifact.test.ts @@ -88,7 +88,7 @@ describe('playground browser artifact boundary', () => { const firstParty = inputs.filter(path => !path.includes('node_modules/')) expect( firstParty.every(path => - /playground\/(?:app|model|playground-error\.utils|(?:collection-import|collection-validation|fragment-codec|playground-initialization|preview-generation|strict-json)\.error)\.tsx?$|src\/config\/(?:market-collections|config-validation\.error)\.ts$|src\/domain\/(?:bootstrap|ladder)\/|src\/domain\/bytes32\.ts$|packages\/utils\//.test( + /playground\/(?:app|model|playground-error\.utils|field-visibility\.utils|(?:collection-import|collection-validation|fragment-codec|playground-initialization|preview-generation|strict-json)\.error)\.tsx?$|src\/config\/(?:market-collections|config-validation\.error)\.ts$|src\/domain\/(?:bootstrap|ladder)\/|src\/domain\/bytes32\.ts$|packages\/utils\//.test( path ) ), diff --git a/bots/market-making/test/playground/field-visibility.utils.test.ts b/bots/market-making/test/playground/field-visibility.utils.test.ts new file mode 100644 index 00000000..5ce2311b --- /dev/null +++ b/bots/market-making/test/playground/field-visibility.utils.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test' + +import type { TargetRateInput } from '../../playground/model' + +import { visibleFields } from '../../playground/field-visibility.utils' + +const fields = [ + ['targetRate.strategy', 'Strategy', 'Rate strategy', 'select'], + ['targetRate.hardcodedRateBps', 'Hardcoded rate', 'Static rate', 'number'], + ['spreadBps', 'Spread', 'Ladder spread', 'number'] +] as const + +const keysFor = (targetRate: TargetRateInput) => + visibleFields(fields, targetRate).map(([key]) => key) + +describe('playground field visibility', () => { + test('shows the hardcoded rate only for hardcoded target-rate strategies', () => { + expect(keysFor({ strategy: 'variable_rate_avg' })).toEqual(['targetRate.strategy', 'spreadBps']) + expect(keysFor({ strategy: 'hardcoded', hardcodedRateBps: '500' })).toEqual([ + 'targetRate.strategy', + 'targetRate.hardcodedRateBps', + 'spreadBps' + ]) + }) +}) diff --git a/bots/market-making/test/playground/model.test.ts b/bots/market-making/test/playground/model.test.ts index 4080bd98..28f24a77 100644 --- a/bots/market-making/test/playground/model.test.ts +++ b/bots/market-making/test/playground/model.test.ts @@ -87,6 +87,37 @@ describe('bootstrap + ladder only playground follow-up', () => { expect(() => deriveBootstrapGraphicModels(state.bootstrap)).toThrow('configured bounds') }) + test('renders a hardcoded bootstrap reference outside bounds when its premium-adjusted quote is valid', () => { + const state = createDefaultPlaygroundState() + state.bootstrap[0]!.minimumRateBps = '200' + state.bootstrap[0]!.maximumRateBps = '800' + state.bootstrap[0]!.targetRate = { strategy: 'hardcoded', hardcodedRateBps: '900' } + state.bootstrap[0]!.premiumBps = '-200' + + expect(deriveBootstrapGraphicModels(state.bootstrap)[0]).toMatchObject({ + referenceRateBps: '900', + quotedRateBps: '700' + }) + }) + + test('renders hardcoded target rates as the preview reference', () => { + const state = createDefaultPlaygroundState() + state.bootstrap[0]!.targetRate = { strategy: 'hardcoded', hardcodedRateBps: '400' } + state.bootstrap[0]!.premiumBps = '-50' + state.ladder[0]!.targetRate = { strategy: 'hardcoded', hardcodedRateBps: '500' } + state.ladder[0]!.quotePremiumBps = '50' + state.ladder[0]!.rungCount = '2' + + expect(deriveBootstrapGraphicModels(state.bootstrap)[0]).toMatchObject({ + referenceRateBps: '400', + quotedRateBps: '350' + }) + expect(generateLadderGraphicModels(state.ladder)[0]).toMatchObject({ + referenceRateBps: '500', + centerRateBps: '550' + }) + }) + test('rejects a deterministic ladder reference outside its own configured bounds', () => { const state = createDefaultPlaygroundState() state.ladder[0]!.quotePremiumBps = '-1000' @@ -94,6 +125,20 @@ describe('bootstrap + ladder only playground follow-up', () => { expect(validateLadderCollection(state.ladder).valid).toBe(true) }) + test('previews hardcoded ladder references outside bounds when generated rungs remain bounded', () => { + const state = createDefaultPlaygroundState() + const ladder = state.ladder[0]! + ladder.targetRate = { strategy: 'hardcoded', hardcodedRateBps: '900' } + ladder.quotePremiumBps = '-200' + ladder.rungCount = '1' + + expect(generateLadderGraphicModels(state.ladder)[0]).toMatchObject({ + referenceRateBps: '900', + centerRateBps: '700' + }) + expect(validateLadderCollection(state.ladder)).toEqual({ valid: true, errors: [] }) + }) + test('keeps higher rung rate, allocation, and cap correspondence under display reversal', () => { const state = createDefaultPlaygroundState() const ladder = state.ladder[0]! @@ -175,6 +220,22 @@ describe('bootstrap + ladder only playground follow-up', () => { ).toEqual({ ladder: state.ladder }) }) + test('round-trips hardcoded target rates through unlabelled imports and exports', () => { + const bootstrap = { + ...createDefaultBootstrap(), + targetRate: { strategy: 'hardcoded' as const, hardcodedRateBps: '450' } + } + const ladder = { + ...createDefaultLadder(), + targetRate: { strategy: 'hardcoded' as const, hardcodedRateBps: '500' } + } + + expect(parseCollectionsImport(JSON.stringify([bootstrap]))).toEqual({ bootstrap: [bootstrap] }) + expect(parseCollectionsImport(JSON.stringify([ladder]))).toEqual({ ladder: [ladder] }) + expect(exportBootstrapMarketsEnvValue([bootstrap])).toBe(JSON.stringify([bootstrap])) + expect(exportLadderMarketsEnvValue([ladder])).toBe(JSON.stringify([ladder])) + }) + test('rejects escaped prototype-pollution member names before collection validation', () => { const item = JSON.stringify(createDefaultBootstrap()) for (const unsafeName of ['\\u005f\\u005fproto__', '\\u0063onstructor', '\\u0070rototype']) { diff --git a/bots/market-making/test/playground/module-graph.test.ts b/bots/market-making/test/playground/module-graph.test.ts index 09f31af9..73817922 100644 --- a/bots/market-making/test/playground/module-graph.test.ts +++ b/bots/market-making/test/playground/module-graph.test.ts @@ -29,6 +29,7 @@ describe('playground browser module graph', () => { 'playground/app.tsx', 'playground/collection-import.error.ts', 'playground/collection-validation.error.ts', + 'playground/field-visibility.utils.ts', 'playground/fragment-codec.error.ts', 'playground/model.ts', 'playground/playground-error.utils.ts', diff --git a/bots/market-making/test/playground/react-contract.test.ts b/bots/market-making/test/playground/react-contract.test.ts index 68e49cc8..18460b34 100644 --- a/bots/market-making/test/playground/react-contract.test.ts +++ b/bots/market-making/test/playground/react-contract.test.ts @@ -37,6 +37,20 @@ describe('bootstrap + ladder only browser contract', () => { } }) + test('lets operators author variable and hardcoded target-rate strategies', async () => { + const [app, model] = await Promise.all([ + read('playground/app.tsx'), + read('playground/model.ts') + ]) + + for (const field of ['targetRate.strategy', 'targetRate.hardcodedRateBps']) { + expect(model).toContain(field) + expect(app).toContain(field) + } + expect(app).toContain('') + expect(app).toContain('') + }) + test('uses stable atomic fragment synchronization and paste-only import', async () => { const app = await read('playground/app.tsx') expect(app).toContain('decodePlaygroundFragment(window.location.hash)') diff --git a/bots/market-making/test/scripts/railway.utils.test.ts b/bots/market-making/test/scripts/railway.utils.test.ts index b4810732..f86549e0 100644 --- a/bots/market-making/test/scripts/railway.utils.test.ts +++ b/bots/market-making/test/scripts/railway.utils.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { + assertFreshRailwayReferenceProvisioning, assertFullRailwaySignerProvisioning, isNonEmptyJsonArray, isTerminalRailwayDeploymentStatus, @@ -70,9 +73,61 @@ describe('Railway CLI output parsing', () => { expect(parseRailwayVolumes('not-json')).toEqual([]) }) + test('requires Blue references when provisioning a fresh variable-rate service', () => { + const environment = { + BOOTSTRAP_MARKETS: JSON.stringify([ + { marketId: 'configured', targetRate: { strategy: 'variable_rate_avg' } } + ]), + LADDER_MARKETS: JSON.stringify([ + { + marketId: 'configured', + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' } + } + ]) + } + + expect(() => assertFreshRailwayReferenceProvisioning(environment, true)).toThrow( + 'Missing required environment variable: REFERENCE_RPC_URL' + ) + expect(() => assertFreshRailwayReferenceProvisioning(environment, false)).not.toThrow() + expect(() => + assertFreshRailwayReferenceProvisioning( + { + ...environment, + REFERENCE_RPC_URL: 'https://archive.example', + REFERENCE_MARKET_ID: '0xreference' + }, + true + ) + ).not.toThrow() + }) + + test('checks fresh-service references before Railway can create the service', () => { + const deploy = readFileSync(resolve(import.meta.dir, '../../scripts/deploy-railway.ts'), 'utf8') + + expect( + deploy.indexOf('assertFreshRailwayReferenceProvisioning(Bun.env, true)') + ).toBeGreaterThan(-1) + expect(deploy.indexOf('assertFreshRailwayReferenceProvisioning(Bun.env, true)')).toBeLessThan( + deploy.indexOf('railway add --service') + ) + }) + test('synchronizes every optional variable with explicit safe defaults', () => { const variables = Object.fromEntries( synchronizedOptionalRailwayVariables({ + BOOTSTRAP_MARKETS: JSON.stringify([ + { + marketId: 'configured', + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' } + } + ]), + LADDER_MARKETS: JSON.stringify([ + { + marketId: 'configured', + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' } + } + ]), REQUEST_TIMEOUT_MS: '25000' }) ) @@ -81,12 +136,52 @@ describe('Railway CLI output parsing', () => { BETTERSTACK_HEARTBEAT_URL: ' ', BETTERSTACK_INGESTING_HOST: ' ', BETTERSTACK_SOURCE_TOKEN: ' ', + REFERENCE_MARKET_ID: ' ', + REFERENCE_RPC_URL: ' ', REQUEST_TIMEOUT_MS: '25000', TRANSACTION_RECEIPT_TIMEOUT_MS: '180000', V0_OFFER_GROUP_IDS: ' ' }) }) + test('trims optional reference configuration before uploading it to Railway', () => { + const variables = Object.fromEntries( + synchronizedOptionalRailwayVariables({ + REFERENCE_RPC_URL: ' https://archive.example/ ', + REFERENCE_MARKET_ID: ' 0xreference ' + }) + ) + + expect(variables.REFERENCE_RPC_URL).toBe('https://archive.example/') + expect(variables.REFERENCE_MARKET_ID).toBe('0xreference') + }) + + test('preserves Railway reference variables when a workflow uses a variable rate', () => { + for (const targetRate of [undefined, { strategy: 'variable_rate_avg' }]) { + const variables = Object.fromEntries( + synchronizedOptionalRailwayVariables({ + BOOTSTRAP_MARKETS: JSON.stringify([{ marketId: 'configured', targetRate }]), + LADDER_MARKETS: JSON.stringify([ + { + marketId: 'configured', + targetRate: { strategy: 'hardcoded', hardcodedRateBps: '400' } + } + ]) + }) + ) + + expect(variables).not.toHaveProperty('REFERENCE_RPC_URL') + expect(variables).not.toHaveProperty('REFERENCE_MARKET_ID') + } + }) + + test('allows Compose deployments to omit inactive reference configuration', () => { + const compose = readFileSync(resolve(import.meta.dir, '../../docker-compose.yml'), 'utf8') + + expect(compose).toContain('REFERENCE_RPC_URL: ${REFERENCE_RPC_URL:-}') + expect(compose).toContain('REFERENCE_MARKET_ID: ${REFERENCE_MARKET_ID:-}') + }) + test('reads the newest complete deployment and rejects incomplete output', () => { const raw = JSON.stringify({ deployments: [