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' ? (
+
+ form.setFieldValue(
+ `${kind}.${index}.targetRate` as never,
+ (event.target.value === 'hardcoded'
+ ? {
+ strategy: 'hardcoded',
+ hardcodedRateBps:
+ item.targetRate.strategy === 'hardcoded'
+ ? item.targetRate.hardcodedRateBps
+ : '500'
+ }
+ : { strategy: 'variable_rate_avg' }) as never
+ )
+ }
+ >
+ variable_rate_avg
+ hardcoded
+
+ ) : 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('variable_rate_avg ')
+ expect(app).toContain('hardcoded ')
+ })
+
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: [