diff --git a/.github/workflows/services-perp-feeds.yml b/.github/workflows/services-perp-feeds.yml index 8a19e7b7..b7aacdbb 100644 --- a/.github/workflows/services-perp-feeds.yml +++ b/.github/workflows/services-perp-feeds.yml @@ -1,6 +1,6 @@ # .github/workflows/services-perp-feeds.yml # The USDCcNGN-PERP price publishers, and the cNGN future's forward publisher (forward-main.ts). The -# anvil suites run too: they deploy the real feed contracts (and FutureAssetV1, the live series the keepers run) from risk-core and check +# anvil suites run too: they deploy the real feed contracts (and FutureAssetV1, the live v1 series, and the v2 FutureAsset) from risk-core and check # they accept what the service signs, so they need forge and anvil. name: services-perp-feeds on: @@ -48,7 +48,7 @@ jobs: - run: pnpm --filter @numo/kms-signer build - run: pnpm --filter perp-feeds run check - run: pnpm --filter perp-feeds run build - - run: forge build src/feeds/LyraSpotFeed.sol src/feeds/LyraSpotDiffFeed.sol src/feeds/LyraForwardFeed.sol src/periphery/OracleDataSubmitter.sol src/legacy/v1/FutureAssetV1.sol src/SubAccounts.sol + - run: forge build src/feeds/LyraSpotFeed.sol src/feeds/LyraSpotDiffFeed.sol src/feeds/LyraForwardFeed.sol src/periphery/OracleDataSubmitter.sol src/legacy/v1/FutureAssetV1.sol src/assets/FutureAsset.sol src/SubAccounts.sol working-directory: contracts/risk-core - run: | anvil --port 8599 --silent & diff --git a/contracts/risk-core/scripts/ops/check_perp_pager.py b/contracts/risk-core/scripts/ops/check_perp_pager.py index 69297e71..dd38aec1 100644 --- a/contracts/risk-core/scripts/ops/check_perp_pager.py +++ b/contracts/risk-core/scripts/ops/check_perp_pager.py @@ -45,6 +45,17 @@ zero (up to 10 a tick, and alerts future-pool-deficit-stranded if none are left), so a deficit that lasts this long means it is not working or cannot: top the pool up. +MarginEngine v2's futures (FUTURE_V2_ARTIFACTS, comma-separated, default deployments/8453/MARGIN_ENGINE_V2.json: one +artifact per series, cNGN-30NOV26 now and EURC-30NOV26 once deployed; each skipped while missing). Linear, VM in USDC +cash through the engine's pool, so no reserve target and no escrow cap: the pool is cash only. + sm-coverage counts every future it reads, v1's and each v2 series' (sm_required_usd_linear, the whole + SM_MOVE step on one side), against the one SecurityModule they share. + future-pool-borrowing-v2 v2's pool cash is below -FUTURE_POOL_BORROW_PAGE_USD (Slack at any negative cash), per engine: + every series on it shares the pool. + future-pool-deficit-stuck-v2 that cash has stayed below zero for FUTURE_POOL_DEFICIT_STUCK_SEC, at any size. + keeper-unhealthy-v2 / insolvent-account-v2 v2's liquidation keeper (ENGINE_V2_KEEPER_HEALTH_URL, unset: not + watched), as keeper-unhealthy and insolvent-account; armed once healthy or a v2 future is open. + Pager (one of; secrets from SSM via run-with-ssm-pager.sh): PAGER_PROVIDER=pushover PUSHOVER_TOKEN, PUSHOVER_USER. Emergency priority: repeats every 60s until acknowledged in the app, and can be set to break through Do Not @@ -166,6 +177,8 @@ NEGATIVE_CASH_WARN_REPEAT_SEC = 6 * 3600 # The cNGN future. Its artifact exists only once it is deployed; until then none of this is read. FUTURE_ARTIFACT = ROOT / "deployments" / "8453" / "MARGIN_ENGINE.json" +# MarginEngine v2's series, one artifact each (FUTURE_V2_ARTIFACTS overrides, comma-separated). +FUTURE_V2_ARTIFACTS = [ROOT / "deployments" / "8453" / "MARGIN_ENGINE_V2.json"] SEL_CONTRACT_SIZE = "0x3d7cc610" # contractSize() -- `cast sig`, pinned in --self-test SEL_FUTURE_MARGIN_REQUIREMENTS = "0x84033e5e" # futureMarginRequirements(uint256) -> (mmReq, imReq) on MarginEngine SEL_FUTURE_CONTRACTS = "0xc3c74b34" # futureContracts(uint256) -> FutureContract; markPrice is word 5 -- `cast sig`, pinned in --self-test @@ -225,20 +238,22 @@ def feed_condition(ages: dict[str, int | None]) -> Condition | None: return Condition("feed-halt", f"cNGN-PERP feed halt: {', '.join(late)}. A stale index halts trading and liquidations.") -def keeper_condition(health: dict | None, now: int, armed: bool = True) -> Condition | None: +def keeper_condition(health: dict | None, now: int, armed: bool = True, name: str = "cNGN-PERP", + key: str = "keeper-unhealthy") -> Condition | None: """`armed` is False until the keeper has ever reported healthy and the market is still closed: a keeper that is not installed yet, or running its DRY_RUN smoke (go-live steps 14-17), is not - a page. Once the market is open, no keeper at all is the loudest condition there is.""" + a page. Once the market is open, no keeper at all is the loudest condition there is. `name` and `key` + tell a second keeper apart (v2's: "MarginEngine v2", keeper-unhealthy-v2).""" if not armed: return None if health is None: - return Condition("keeper-unhealthy", "cNGN-PERP keeper /health unreachable: nothing is liquidating.") + return Condition(key, f"{name} keeper /health unreachable: nothing is liquidating.") if health.get("dryRun", True): - return Condition("keeper-unhealthy", "cNGN-PERP keeper is in DRY_RUN: it decides but sends nothing.") + return Condition(key, f"{name} keeper is in DRY_RUN: it decides but sends nothing.") stale_after = 3 * max(int(health.get("pollIntervalMs", 15_000)) // 1000, 20) age = now - int(health.get("lastPassAt", 0)) if not health.get("lastPassOk") or age > stale_after: - return Condition("keeper-unhealthy", f"cNGN-PERP keeper unhealthy: last pass ok={health.get('lastPassOk')} {age}s ago.") + return Condition(key, f"{name} keeper unhealthy: last pass ok={health.get('lastPassOk')} {age}s ago.") return None @@ -345,24 +360,39 @@ def sm_required_usd_linear(one_side_usd: float, move: float = SM_MOVE) -> float: return one_side_usd * move -def sm_future_condition(one_side_usd: float, mm: float, premium: float, sm_cash_usd: float) -> Condition | None: - """sm-coverage for the future: pages at 1.2x of what the module holds.""" - required = sm_required_usd(one_side_usd, mm, premium) +def sm_coverage_condition(required_by: dict[str, float], sm_cash_usd: float) -> Condition | None: + """sm-coverage across every future the one SecurityModule backs (v1's, each v2 series'): what each needs for an + SM_MOVE step against one side, summed, which is conservative across bases. Pages at 1.2x of what the module holds.""" + required = sum(required_by.values()) if required <= 0 or required < SM_COVERAGE_PAGE_X * sm_cash_usd: return None + parts = ", ".join(f"{label} ${need:,.0f}" for label, need in required_by.items() if need > 0) return Condition("sm-coverage", f"cNGN SecurityModule ${sm_cash_usd:,.0f} covers less than 1/{SM_COVERAGE_PAGE_X:.1f} of a " - f"{SM_MOVE * 100:.0f}% move on the future's one side ${one_side_usd:,.0f} at MM {mm * 100:.0f}% " - f"(needs ${required:,.0f}). Fund the SecurityModule, or set cap = current OI.") + f"{SM_MOVE * 100:.0f}% move on the futures' one side (needs ${required:,.0f}: {parts}). " + "Fund the SecurityModule, or set cap = current OI.") -def sm_future_warning(one_side_usd: float, mm: float, premium: float, sm_cash_usd: float) -> str | None: - required = sm_required_usd(one_side_usd, mm, premium) +def sm_coverage_warning(required_by: dict[str, float], sm_cash_usd: float) -> str | None: + required = sum(required_by.values()) if required <= sm_cash_usd or required >= SM_COVERAGE_PAGE_X * sm_cash_usd: return None - return (f"cNGN SecurityModule ${sm_cash_usd:,.0f} is short of the ${required:,.0f} a {SM_MOVE * 100:.0f}% move on the future " + return (f"cNGN SecurityModule ${sm_cash_usd:,.0f} is short of the ${required:,.0f} a {SM_MOVE * 100:.0f}% move on the futures " f"would cost (page at {SM_COVERAGE_PAGE_X:.1f}x): fund it.") +def v1_coverage_label(one_side_usd: float, mm: float) -> str: + return f"USDC-cNGN one side ${one_side_usd:,.0f} at MM {mm * 100:.0f}%" + + +def sm_future_condition(one_side_usd: float, mm: float, premium: float, sm_cash_usd: float) -> Condition | None: + """sm-coverage for v1's future alone.""" + return sm_coverage_condition({v1_coverage_label(one_side_usd, mm): sm_required_usd(one_side_usd, mm, premium)}, sm_cash_usd) + + +def sm_future_warning(one_side_usd: float, mm: float, premium: float, sm_cash_usd: float) -> str | None: + return sm_coverage_warning({v1_coverage_label(one_side_usd, mm): sm_required_usd(one_side_usd, mm, premium)}, sm_cash_usd) + + def perp_oi_condition(one_side_usd: float) -> Condition | None: """The SecurityModule is sized for the future alone since the perp's wind-down: perp open interest is uncovered.""" if one_side_usd < 1: @@ -408,15 +438,37 @@ def future_pool_conditions(pool_cngn: float, pool_net: float, reserve_cngn: floa return pages, warnings -def pool_negative_for(state: dict, open_: bool, pool_net: float, now: float) -> float: +def pool_negative_for(state: dict, open_: bool, pool_net: float, now: float, key: str = "futurePoolNegativeSince") -> float: """Seconds the future pool's net has been below zero without a break, kept across runs in `state` - (futurePoolNegativeSince): any run that reads it at or above zero, or the market closed, restarts it.""" + (`key`: futurePoolNegativeSince for v1's pool, one per engine for v2's): any run that reads it at or above zero, or + the market closed, restarts it.""" if open_ and pool_net < -FUTURE_POOL_DUST_USD: - return now - state.setdefault("futurePoolNegativeSince", now) - state.pop("futurePoolNegativeSince", None) + return now - state.setdefault(key, now) + state.pop(key, None) return 0.0 +def v2_pool_conditions(label: str, pool: int, cash_usd: float, open_: bool, + negative_for_sec: float = 0) -> tuple[list[Condition], list[str]]: + """(pages, Slack warnings) for a MarginEngine v2 VM pool (`label`: the engine, "v2"). Cash only: VM is USDC, nothing + converted, so the net is the cash and there is no reserve to hold. Same thresholds as v1's pool. Nothing while no series + on the engine is open.""" + if not open_ or cash_usd >= -FUTURE_POOL_DUST_USD: + return [], [] + pages: list[Condition] = [] + warnings: list[str] = [] + if cash_usd < -FUTURE_POOL_BORROW_PAGE_USD: + pages.append(Condition(f"future-pool-borrowing-{label}", f"MarginEngine {label} VM pool #{pool} is ${-cash_usd:,.0f} below zero in " + "cash: winners were paid before losers paid in. Check the v2 future keepers' deficit cover, and top the pool up.")) + else: + warnings.append(f"MarginEngine {label} VM pool #{pool} is ${-cash_usd:,.2f} below zero in cash (page past ${FUTURE_POOL_BORROW_PAGE_USD:,.0f}).") + if negative_for_sec >= FUTURE_POOL_DEFICIT_STUCK_SEC: + pages.append(Condition(f"future-pool-deficit-stuck-{label}", f"MarginEngine {label} VM pool #{pool} has been ${-cash_usd:,.2f} below " + f"zero for {negative_for_sec / 60:,.0f} min: its future keepers settle losers every minute while it is, so " + "they are not working or have no loser left (future-pool-deficit-stranded): top up the pool account.")) + return pages, warnings + + def future_escrow_conditions(used_cngn: float, cap_cngn: float, reserve_cngn: float | None, open_: bool) -> tuple[list[Condition], list[str]]: """(pages, Slack warnings) for the cNGN escrow cap under the future's manager, which holds both the @@ -481,6 +533,56 @@ def read_future(url: str, future: dict) -> dict: } +def read_future_v2(url: str, future: dict) -> dict: + """A MarginEngine v2 series (USDC per unit of its base, a contract of the base): one side's notional at the mark, its + margin rates, whether it is open. The pool is the engine's, read once per engine (read_pool_cash).""" + manager_word = future["manager"].lower().removeprefix("0x").rjust(64, "0") + call = lambda to, data: rpc(url, "eth_call", [{"to": to, "data": data}, "latest"])[2:] # noqa: E731 + total = int(call(future["future"], SEL_TOTAL_POSITION + manager_word), 16) / 1e18 + size = int(call(future["future"], SEL_CONTRACT_SIZE), 16) / 1e18 + cap = int(call(future["future"], SEL_TOTAL_POSITION_CAP + manager_word), 16) + requirements = call(future["manager"], SEL_FUTURE_MARGIN_REQUIREMENTS + f"{int(future['marketId']):064x}") + mark = int(call(future["future"], SEL_FUTURE_CONTRACTS + f"{int(future['subId']):064x}")[5 * 64:6 * 64], 16) / 1e18 + return { + "one_side_usd": v2_one_side_usd(total, size, mark), + "mm": int(requirements[:64], 16) / 1e18, + "im": int(requirements[64:128], 16) / 1e18, + "open": cap > 0, + "mark": mark, + } + + +def v2_one_side_usd(total_contracts: float, contract_size: float, mark: float) -> float: + """One side's notional in USD: half the both-sides total, each contract contract_size of the base at the mark.""" + return total_contracts / 2 * contract_size * mark + + +def read_pool_cash(url: str, future: dict) -> float: + return account_balance(url, int(future["poolAccount"]), future["cash"]) / 1e18 + + +def record_watched(state: dict, future: dict) -> None: + """Records in the state file a future this run read: its manager under futureManagers and its asset under futures, + which the enable gates read (propose_margin_engine_v2_batch.py the manager, propose_eurc_future_v2_batch.py the + asset). Called only after the read succeeded, so a future that failed to read is not listed as watched.""" + for key, value in (("futureManagers", future["manager"]), ("futures", future["future"])): + watched = state.setdefault(key, []) + if value not in watched: + watched.append(value) + + +def future_v2_artifacts() -> list[tuple[Path, dict]]: + """Each v2 series' artifact that exists, with its path. A missing one (a series not yet deployed) is skipped.""" + raw = os.environ.get("FUTURE_V2_ARTIFACTS", "").strip() + paths = [Path(p.strip()) for p in raw.split(",") if p.strip()] if raw else FUTURE_V2_ARTIFACTS + return [(path, json.loads(path.read_text())) for path in paths if path.exists()] + + +def series_label(future: dict) -> str: + """cNGN-30NOV26-style, for messages: the artifact's base (default cNGN, MARGIN_ENGINE_V2.json names none) and expiry.""" + return f"{future.get('baseSymbol', 'cNGN')}-{time.strftime('%d%b%y', time.gmtime(int(future['expiry']))).upper()}" + + def future_artifact() -> dict | None: path = Path(os.environ.get("FUTURE_ARTIFACT", FUTURE_ARTIFACT)) return json.loads(path.read_text()) if path.exists() else None @@ -568,11 +670,11 @@ def market_open(url: str, stack: dict) -> bool: return int(raw, 16) > 0 -def insolvent_condition(health: dict | None) -> Condition | None: +def insolvent_condition(health: dict | None, name: str = "cNGN-PERP", key: str = "insolvent-account") -> Condition | None: accounts = (health or {}).get("insolventAccounts") or [] if not accounts: return None - return Condition("insolvent-account", f"cNGN-PERP insolvent account(s) {', '.join(accounts)}: the SecurityModule will pay.") + return Condition(key, f"{name} insolvent account(s) {', '.join(accounts)}: the SecurityModule will pay.") def peg_condition(status: dict | None, now_wall: float) -> Condition | None: @@ -788,6 +890,9 @@ def check_and_page(stack_path: Path, state: dict) -> int: coverage = None perp_oi = None future_pages: list[Condition] = [] + # What a SM_MOVE step on one side of each future would cost the SecurityModule they all share, by future. + coverage_parts: dict[str, float] = {} + futures_read = False try: perp_one_side = read_one_side_usd(url, stack) perp_oi = perp_oi_condition(perp_one_side) @@ -799,9 +904,12 @@ def check_and_page(stack_path: Path, state: dict) -> int: # Which manager's future this run read, for the MarginEngine enable gate: a pager that does not read # the engine's future (an old copy, a missing artifact, a failed read) must not pass it. state.pop("futureManager", None) + state.pop("futureManagers", None) + state.pop("futures", None) if future is not None: f = read_future(url, future) state["futureManager"] = future["manager"] + record_watched(state, future) override = os.environ.get("FUTURE_POOL_RESERVE_CNGN", "").strip() reserve = float(override) if override else f["reserve_target_cngn"] net = pool_net_usd(f["pool_cngn"], f["pool_cash_usd"], f["spot"]) @@ -818,17 +926,66 @@ def check_and_page(stack_path: Path, state: dict) -> int: f"pool {f['pool_cngn']:,.0f} cNGN (reserve {'unreadable' if reserve is None else f'{reserve:,.0f}'}{' set' if override else ' at the cap'}), " f"${f['pool_cash_usd']:,.2f} cash, net ${net:,.2f}{f' (below zero {negative_for / 60:,.0f} min)' if negative_for else ''}; " f"escrow {f['escrow_used_cngn']:,.0f} of {f['escrow_cap_cngn']:,.0f} cNGN") - coverage = sm_future_condition(f["one_side_usd"], f["mm"], f["premium"], sm_cash) - warning = sm_future_warning(f["one_side_usd"], f["mm"], f["premium"], sm_cash) - if warning and time.time() - state.get("smCoverageWarnedAt", 0) >= SM_COVERAGE_WARN_REPEAT_SEC: - mirror(f"{os.environ.get('PAGE_PREFIX', '')}{warning}") - state["smCoverageWarnedAt"] = time.time() - print(f"sm coverage: ${sm_cash:,.0f} against ${sm_required_usd(f['one_side_usd'], f['mm'], f['premium']):,.0f} for a " - f"{SM_MOVE * 100:.0f}% move on the future's one side ${f['one_side_usd']:,.0f} (page at {SM_COVERAGE_PAGE_X:.1f}x)") + coverage_parts[v1_coverage_label(f["one_side_usd"], f["mm"])] = sm_required_usd(f["one_side_usd"], f["mm"], f["premium"]) + futures_read = True else: print("future: not deployed (no artifact)") except Exception as exc: # noqa: BLE001 - print(f"sm coverage: unreadable ({str(exc)[:80]})") + print(f"future: unreadable ({str(exc)[:80]})") + + # MarginEngine v2: each series' side for the SecurityModule, and each engine's cash-only pool, once. + v2_open: dict[str, bool] = {} + v2_pools: dict[str, dict] = {} + try: + for path, series in future_v2_artifacts(): + label = series_label(series) + f2 = read_future_v2(url, series) + record_watched(state, series) + need = sm_required_usd_linear(f2["one_side_usd"]) + coverage_parts[f"{label} one side ${f2['one_side_usd']:,.0f}"] = need + futures_read = True + engine = series["manager"].lower() + v2_open[engine] = v2_open.get(engine, False) or f2["open"] + v2_pools.setdefault(engine, series) + print(f"future {label} (v2, {path.name}): one side ${f2['one_side_usd']:,.0f} at mark {f2['mark']:.9f}, IM {f2['im'] * 100:.0f}%, " + f"{'open' if f2['open'] else 'closed'}; SM needs ${need:,.0f} for a {SM_MOVE * 100:.0f}% step") + v2_warnings: list[str] = [] + for engine, series in v2_pools.items(): + # one engine today ("v2"); a further engine would take its own label from its artifact + engine_label = series.get("engineLabel", "v2") + cash = read_pool_cash(url, series) + negative_for = pool_negative_for(state, v2_open[engine], cash, time.time(), f"futurePoolNegativeSince:{engine}") + pages, warnings = v2_pool_conditions(engine_label, int(series["poolAccount"]), cash, v2_open[engine], negative_for) + future_pages += pages + v2_warnings += warnings + print(f"pool {engine_label} #{series['poolAccount']}: ${cash:,.2f} cash{f' (below zero {negative_for / 60:,.0f} min)' if negative_for else ''}") + if v2_warnings and time.time() - state.get("futureV2WarnedAt", 0) >= FUTURE_WARN_REPEAT_SEC: + for warning in v2_warnings: + mirror(f"{os.environ.get('PAGE_PREFIX', '')}{warning}") + state["futureV2WarnedAt"] = time.time() + except Exception as exc: # noqa: BLE001 + print(f"v2 futures: unreadable ({str(exc)[:80]})") + + if futures_read: + coverage = sm_coverage_condition(coverage_parts, sm_cash) + warning = sm_coverage_warning(coverage_parts, sm_cash) + if warning and time.time() - state.get("smCoverageWarnedAt", 0) >= SM_COVERAGE_WARN_REPEAT_SEC: + mirror(f"{os.environ.get('PAGE_PREFIX', '')}{warning}") + state["smCoverageWarnedAt"] = time.time() + print(f"sm coverage: ${sm_cash:,.0f} against ${sum(coverage_parts.values()):,.0f} for a {SM_MOVE * 100:.0f}% move on every " + f"future's one side ({'; '.join(f'{k}: ${v:,.0f}' for k, v in coverage_parts.items())}; page at {SM_COVERAGE_PAGE_X:.1f}x)") + + # v2's liquidation keeper, when its health URL is set: armed once it has reported healthy, or a v2 future is open. + v2_keeper: list[Condition | None] = [] + v2_health_url = os.environ.get("ENGINE_V2_KEEPER_HEALTH_URL", "").strip() + if v2_health_url: + v2_health = read_health(v2_health_url) + if keeper_healthy(v2_health, now): + state["keeperV2SeenAt"] = now + v2_armed = state.get("keeperV2SeenAt") is not None or any(v2_open.values()) + v2_keeper = [keeper_condition(v2_health, now, v2_armed, "MarginEngine v2", "keeper-unhealthy-v2"), + insolvent_condition(v2_health, "MarginEngine v2", "insolvent-account-v2")] + print(f"v2 keeper: {'unreachable' if v2_health is None else ('ok' if keeper_healthy(v2_health, now) else 'unhealthy')}") negative_cash = None try: @@ -845,7 +1002,8 @@ def check_and_page(stack_path: Path, state: dict) -> int: previous_sm = state.get("smCash") active = [c for c in (feed_condition(ages), keeper_condition(health, now, armed), insolvent_condition(health), sm_condition(previous_sm, sm_cash), unwatched_condition(heartbeat_urls()[0]), - peg_condition(read_index_status(), time.time()), coverage, perp_oi, oi, negative_cash, *future_pages, *gas, *matcher) if c is not None] + peg_condition(read_index_status(), time.time()), coverage, perp_oi, oi, negative_cash, *future_pages, *v2_keeper, + *gas, *matcher) if c is not None] to_page, resolved = step_state(state, active, now) state["smCash"] = sm_cash @@ -948,6 +1106,40 @@ def self_test() -> int: assert "2,468" in sm_future_warning(15_000, 0.15, 1.0124, 2_100) and sm_future_condition(15_000, 0.15, 1.0124, 2_100) is None assert sm_future_condition(15_000, 0.15, 1.0124, 2_000).key == "sm-coverage" assert sm_future_condition(15_000, 0.15, 1.0124, 2_469) is None and sm_future_warning(15_000, 0.15, 1.0124, 2_469) is None + # Across futures: one SecurityModule backs v1's and every v2 series', so their needs add up. Each alone is covered by + # $3,250 (v1 $2,468; v2 cNGN 15 contracts at 0.000743, $1,671.75); together they are not, and page at 1.2x. + v2_side = v2_one_side_usd(30, 1_000_000, 0.000743) + assert abs(v2_side - 11_145) < 1e-6 + parts = {"USDC-cNGN": sm_required_usd(15_000, 0.15, 1.0124), "cNGN-30NOV26": sm_required_usd_linear(v2_side)} + assert sm_coverage_condition({"cNGN-30NOV26": parts["cNGN-30NOV26"]}, 3_250) is None + assert "4,140" in sm_coverage_warning(parts, 3_500) and sm_coverage_condition(parts, 3_500) is None + assert sm_coverage_condition(parts, 3_250).key == "sm-coverage" and "cNGN-30NOV26 $1,672" in sm_coverage_condition(parts, 3_250).message + # an EURC series (1,000 EURC at 1.1625, 20 contracts) is one more part, priced by its own mark + parts["EURC-30NOV26"] = sm_required_usd_linear(v2_one_side_usd(20, 1_000, 1.1625)) + assert abs(parts["EURC-30NOV26"] - 10 * 1_000 * 1.1625 * 0.15) < 1e-6 + assert series_label({"expiry": 1796047200}) == "cNGN-30NOV26" + assert series_label({"expiry": 1796047200, "baseSymbol": "EURC"}) == "EURC-30NOV26" + # What the run read goes to the state file: each manager once, each future asset (v1's, then every v2 series). + watched: dict = {} + record_watched(watched, {"manager": "0xv1", "future": "0xf1"}) + record_watched(watched, {"manager": "0xv2", "future": "0xcngn"}) + record_watched(watched, {"manager": "0xv2", "future": "0xeurc"}) + assert watched == {"futureManagers": ["0xv1", "0xv2"], "futures": ["0xf1", "0xcngn", "0xeurc"]} + # v2's pool: cash only, no reserve, its own keys; the same $1 dust, $250 page and 75-minute stuck page as v1's. + assert v2_pool_conditions("v2", 37, -1_000, open_=False) == ([], []) + assert v2_pool_conditions("v2", 37, -1.0, open_=True, negative_for_sec=999_999) == ([], []) + pages, warns = v2_pool_conditions("v2", 37, -5, open_=True) + assert pages == [] and "#37 is $5.00 below zero in cash" in warns[0] + pages, warns = v2_pool_conditions("v2", 37, -300, open_=True, negative_for_sec=75 * 60) + assert [p.key for p in pages] == ["future-pool-borrowing-v2", "future-pool-deficit-stuck-v2"] and warns == [] + # each engine's timer is its own, apart from v1's + st2: dict = {"futurePoolNegativeSince": 1} + assert pool_negative_for(st2, True, -5, 1_000, "futurePoolNegativeSince:0xv2") == 0 + assert pool_negative_for(st2, True, -5, 1_600, "futurePoolNegativeSince:0xv2") == 600 and st2["futurePoolNegativeSince"] == 1 + # v2's keeper pages under its own keys + assert keeper_condition(None, now, True, "MarginEngine v2", "keeper-unhealthy-v2").key == "keeper-unhealthy-v2" + assert keeper_condition(None, now, False, "MarginEngine v2", "keeper-unhealthy-v2") is None + assert insolvent_condition({"insolventAccounts": ["40"]}, "MarginEngine v2", "insolvent-account-v2").key == "insolvent-account-v2" # The perp is wound down: any open interest on it pages, rounding does not. assert perp_oi_condition(0.4) is None and perp_oi_condition(801).key == "perp-oi" # The future's pool: nothing while closed; reserve page under a quarter, Slack under half; an unreadable diff --git a/contracts/risk-core/scripts/ops/engine-keeper-v2.env b/contracts/risk-core/scripts/ops/engine-keeper-v2.env new file mode 100644 index 00000000..a332a111 --- /dev/null +++ b/contracts/risk-core/scripts/ops/engine-keeper-v2.env @@ -0,0 +1,39 @@ +# Template for /etc/numo/engine-keeper-v2.env (numo-engine-keeper-v2.service): the liquidation keeper for MarginEngine v2. +# NOT INSTALLED (#258). Non-secret: KEEPER_KEY, RPC_URL and ALERT_WEBHOOK_URL come from SSM in the unit's ExecStart. +# Addresses from risk-core deployments/8453/MARGIN_ENGINE_V2.json. v1's keeper (engine-keeper.env) is unchanged and runs +# beside it until USDC-cNGN-30NOV26 settles. +# +# Per engine, not per series: a v2 series (EURC-30NOV26 once listed) is one more address in FUTURE. +CHAIN_ID=8453 +# TODO before install: the keeper's funding account under v2, created by the v2 keeper EOA +# (SubAccounts.createAccount(eoa, 0x0705…0950)) and funded with USDC cash. The keeper refuses to start without it. +KEEPER_ACCOUNT= +SUB_ACCOUNTS=0x7019244E25FA416e6Ca2ed2F3cA25277aef72843 +MANAGER=0x0705219a1594d5Fc5dC2573da4082cAf3e1B0950 +AUCTION=0xd07176B006cE8246e930d37b8bc44B1154f8c4cE +CASH=0xA74E49b4Ed7cb176bc02ef4D8a1A3240C9aD4272 +# No PERP: v2 lists none. Comma-separated, one per series. +FUTURE=0xcACEe66ad41fD1472a47D7D5Fe372FFbFac9AA75 +POOL_ACCOUNT=37 +SECURITY_MODULE_ACCOUNT=21 +# v2's deploy (2026-10-27, blocks 53164124–53164160): no account was under it before. +START_BLOCK=53164124 +DRY_RUN=true +MAX_BID_USD=1500 +# 9464 is the perp keeper's, 9465 v1's engine keeper's. +HEALTH_PORT=9466 +HEALTH_HOST=127.0.0.1 +MIN_KEEPER_ETH=0.002 +# A full futures side's IM at the enable cap: 15 contracts x 1,000,000 cNGN x ~0.000743 x 25% = ~2,790 USDC. It moves +# with the mark; propose_margin_engine_v2_batch.py's keeper gate re-reads it (engine_keeper_min_cash). +MIN_KEEPER_CASH_USD=2800 +CNGN_ESCROW=0x37c976bb5d4887a714ef19AF6B83e34fe2f37c98 +# The engine's market-1 spot feed (getMarketFeeds(1)[0]), USDC per cNGN: v2 has no perp index to price cNGN by. +CNGN_SPOT_FEED=0xFaC420d160C7c219A72DC676971670980bAC20a5 +MAX_CNGN_INVENTORY=8000000 +# EURC (v2 market 2): the escrow is whitelisted for v2 at cap 0 until `eurc-cap` (margin-engine-v2.md, step 6). Set now so +# a keeper paid in EURC prices and limits it; revisit MAX_EURC_INVENTORY with the eurc-cap step. +EURC_ESCROW=0xfD2AFbF4eF6E7a7f34A04D16Ff6da249Ca086cD9 +EURC_SPOT_FEED=0xaaE62Ae5df3747B8f43F895561043FB77D97C435 +EURC_HAIRCUT_BPS=200 +MAX_EURC_INVENTORY=40000 diff --git a/contracts/risk-core/scripts/ops/future-feeds-v2-cngn-30nov26.env b/contracts/risk-core/scripts/ops/future-feeds-v2-cngn-30nov26.env new file mode 100644 index 00000000..caac61df --- /dev/null +++ b/contracts/risk-core/scripts/ops/future-feeds-v2-cngn-30nov26.env @@ -0,0 +1,38 @@ +# /etc/numo/future-feeds-v2-cngn-30nov26.env (numo-future-feeds-v2@cngn-30nov26.service), for cNGN-30NOV26 on +# MarginEngine v2: from risk-core/deployments/8453/MARGIN_ENGINE_V2.json. NOT INSTALLED: a template until the v2 +# forward keeper goes on the box (#258). A change here is copied to the box and recorded in OPS_BOX_CHANGES.md +# the same day. Holds no secret. +# +# A v2 series is config only: an EURC-30NOV26 instance is a copy of this file with its own FORWARD_FEED, +# FUTURE_ASSET, INDEX_FEED (the EURC spot feed), CARRY, BASE_SYMBOL and relayer, installed as +# numo-future-feeds-v2@eurc-30nov26. +CHAIN_ID=8453 +FUTURE_VERSION=v2 +BASE_SYMBOL=cNGN +DATA_SUBMITTER=0xe0C06DD245f1e8C8bC516c66C66e64648987F912 +# The cNGN spot feed (USDC per cNGN), v1's index too: read as is. +INDEX_FEED=0xFaC420d160C7c219A72DC676971670980bAC20a5 +FORWARD_FEED=0x24474D8A95EB8Be9070ede58E09405B410c277Df +FUTURE_ASSET=0xcACEe66ad41fD1472a47D7D5Fe372FFbFac9AA75 +# The expiry, 2026-11-30 14:00 UTC: the same sub id as v1's USDC-cNGN-30NOV26, on a different FUTURE_ASSET. +FUTURE_SUB_ID=1796047200 +# MarginEngine v2. Its VM pool is account #37, cash only. +FUTURE_MANAGER=0x0705219a1594d5Fc5dC2573da4082cAf3e1B0950 +# Annual NGN-over-USD carry, 18dp: with no book the mark is index / (1 + CARRY * tau). 11.8%, the figure the +# deploy's INITIAL_MARK was checked against (MARGIN_ENGINE_V2.json carry). +CARRY=118000000000000000 +# v1's forward KMS key (0xd65F…3650), the v2 feed's only signer: each feed's EIP-712 domain binds signatures to it. +FORWARD_SIGNER_KMS_KEY_ID=arn:aws:kms:us-east-1:957261948743:key/4312f074-ddcb-4db3-89d5-02d042beaacd +# A relayer of this instance's own (run-with-ssm-forward.sh): sharing v1's would race it for nonces. +FORWARD_RELAYER_SSM_NAME=forward_relayer_key_v2_cngn +MARKETS_SERVICE_URL=https://api.numofx.com +FORWARD_TICK_MS=60000 +# Each side of the futures book must fill this much (USDC) before it sets the mark; else carry. A v2 contract is +# 1,000,000 cNGN, ~$720 at 0.00072: about 3 contracts a side. +FORWARD_MIN_BOOK_DEPTH_USDC=2000 +FORWARD_MAX_BASIS_BPS=300 +FORWARD_SAFETY_BPS=50 +FORWARD_MARK_LATE_MS=7200000 +FIXING_SAMPLE_INTERVAL_MS=300000 +CLOSE_OUT_BATCH=20 +SETTLE_PER_TICK=10 diff --git a/contracts/risk-core/scripts/ops/numo-engine-keeper-v2.service b/contracts/risk-core/scripts/ops/numo-engine-keeper-v2.service new file mode 100644 index 00000000..e8ab04d1 --- /dev/null +++ b/contracts/risk-core/scripts/ops/numo-engine-keeper-v2.service @@ -0,0 +1,38 @@ +# MarginEngine v2's liquidation keeper: services/perp-keeper with MANAGER = v2 (engine-keeper-v2.env). Watches every account +# under v2, starts auctions on those under maintenance and bids from its own funding account under v2. v1's keeper +# (numo-engine-keeper.service) is unchanged. +# +# NOT INSTALLED (#258). propose_margin_engine_v2_batch.py's keeper gate reads its /health (ENGINE_V2_KEEPER_HEALTH_URL, +# http://127.0.0.1:9466/health) and refuses `enable` until it is live with a full side's IM in cash. +# +# Its own key (/numo/keeper/engine_v2_keeper_key) and funding account: sharing v1's engine keeper EOA would race it for +# nonces. Runs from /home/ec2-user/exchange-future-v2 (the v2 future keeper's checkout), never exchange-future (v1's). +# +# Before starting: +# - SSM /numo/keeper/engine_v2_keeper_key: a fresh key, its EOA funded with gas; +# - its funding account: SubAccounts.createAccount(v2 keeper EOA, v2) from that EOA, then a cash deposit; +# - /etc/numo/engine-keeper-v2.env from engine-keeper-v2.env with KEEPER_ACCOUNT filled in, DRY_RUN=true first. +# +# Install: (cd /home/ec2-user/exchange-future-v2 && pnpm install --frozen-lockfile --filter perp-keeper... && pnpm --filter perp-keeper build) +# cp contracts/risk-core/scripts/ops/numo-engine-keeper-v2.service /etc/systemd/system/ +# systemctl daemon-reload && systemctl enable --now numo-engine-keeper-v2 +# and record it in OPS_BOX_CHANGES.md the same day. +[Unit] +Description=Numo MarginEngine v2 liquidation keeper (Base mainnet, SSM secrets) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=ec2-user +WorkingDirectory=/home/ec2-user/exchange-future-v2/services/perp-keeper +EnvironmentFile=/etc/numo/engine-keeper-v2.env +ExecStart=/bin/bash -c 'export KEEPER_KEY="$(aws ssm get-parameter --name /numo/keeper/engine_v2_keeper_key --with-decryption --query Parameter.Value --output text --region "$AWS_REGION")" RPC_URL="$(aws ssm get-parameter --name /numo/feeds/rpc_url --with-decryption --query Parameter.Value --output text --region "$AWS_REGION")" ALERT_WEBHOOK_URL="$(aws ssm get-parameter --name /numo/feeds/alert_webhook_url --with-decryption --query Parameter.Value --output text --region "$AWS_REGION" 2>/dev/null || true)" ALERT_PREFIX="[engine v2] " && exec /usr/bin/node dist/main.js' +Environment=AWS_REGION=us-east-1 +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/contracts/risk-core/scripts/ops/numo-future-feeds-v2@.service b/contracts/risk-core/scripts/ops/numo-future-feeds-v2@.service new file mode 100644 index 00000000..a18cfdeb --- /dev/null +++ b/contracts/risk-core/scripts/ops/numo-future-feeds-v2@.service @@ -0,0 +1,39 @@ +# The keeper for a MarginEngine v2 dated future (services/perp-feeds, dist/forward-main.js with FUTURE_VERSION=v2), +# one instance per series: numo-future-feeds-v2@cngn-30nov26 reads /etc/numo/future-feeds-v2-cngn-30nov26.env +# (future-feeds-v2-cngn-30nov26.env), and an EURC series is another instance and env file, no code. +# +# NOT INSTALLED (#258). v1's USDC-cNGN-30NOV26 keeps numo-future-feeds.service, unchanged, until it settles. +# +# Its own checkout, /home/ec2-user/exchange-future-v2: exchange-future is v1's keeper and the engine keeper's, +# and building there would move their code on their next restart. +# +# Before starting an instance: +# - its env file in /etc/numo, with FORWARD_RELAYER_SSM_NAME naming a funded relayer of its own under +# /numo/feeds (watched by check_signer_balance.py); +# - the forward feed's signer is the KMS key the env names (v2 cNGN: v1's forward key); +# - a dry run first: DRY_RUN=true in the env file, then off. +# +# Install: git clone https://github.com/numofx/exchange /home/ec2-user/exchange-future-v2 && git -C ... checkout +# (cd /home/ec2-user/exchange-future-v2 && pnpm install --frozen-lockfile --filter perp-feeds... && pnpm --filter @numo/kms-signer build && pnpm --filter perp-feeds build) +# cp contracts/risk-core/scripts/ops/numo-future-feeds-v2@.service /etc/systemd/system/ +# systemctl daemon-reload && systemctl enable --now numo-future-feeds-v2@cngn-30nov26 +# and record it in OPS_BOX_CHANGES.md the same day. +[Unit] +Description=Numo v2 future keeper %i: forward marks, fixing, close-out (Base mainnet, KMS signer, SSM relayer) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=ec2-user +WorkingDirectory=/home/ec2-user/exchange-future-v2/services/perp-feeds +EnvironmentFile=/etc/numo/future-feeds-v2-%i.env +ExecStart=/home/ec2-user/exchange-future-v2/contracts/risk-core/scripts/ops/run-with-ssm-forward.sh /usr/bin/node dist/forward-main.js +Environment=AWS_REGION=us-east-1 +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/contracts/risk-core/scripts/ops/perp-pager.env b/contracts/risk-core/scripts/ops/perp-pager.env index 0566e702..f45f3617 100644 --- a/contracts/risk-core/scripts/ops/perp-pager.env +++ b/contracts/risk-core/scripts/ops/perp-pager.env @@ -6,3 +6,7 @@ PERP_INDEX_STATUS_FILE=/var/lib/numo/perp-index-status.json PAGER_STATE_FILE=/var/lib/numo/perp-pager-state.json KEEPER_EOA=0xdE57b9E91358BB2f58DB7A7D588533f996373f18 KEEPER_ACCOUNT=23 +# MarginEngine v2's liquidation keeper (numo-engine-keeper-v2, #258). NOT ON THE BOX YET: uncomment once it is installed, +# and the pager pages keeper-unhealthy-v2 / insolvent-account-v2 for it. v2's futures are read from +# deployments/8453/MARGIN_ENGINE_V2.json by default (FUTURE_V2_ARTIFACTS, comma-separated, adds a series). +# ENGINE_V2_KEEPER_HEALTH_URL=http://127.0.0.1:9466/health diff --git a/contracts/risk-core/scripts/ops/run-with-ssm-forward.sh b/contracts/risk-core/scripts/ops/run-with-ssm-forward.sh index 970b6c40..ccc01297 100755 --- a/contracts/risk-core/scripts/ops/run-with-ssm-forward.sh +++ b/contracts/risk-core/scripts/ops/run-with-ssm-forward.sh @@ -8,7 +8,9 @@ # # rpc_url the same RPC the perp publishers use # forward_relayer_key a relayer of its OWN: sharing perp-feeds' relayer across two processes races -# for nonces (the 2026-10-03 mark outage was one process doing that with itself) +# for nonces (the 2026-10-03 mark outage was one process doing that with itself). +# A second keeper (a v2 series, numo-future-feeds-v2@.service) names its own in +# FORWARD_RELAYER_SSM_NAME, from its env file; unset, it is forward_relayer_key. # alert_webhook_url optional # # The forward SIGNER is not here: it is a KMS key, FORWARD_SIGNER_KMS_KEY_ID in the env file, signed @@ -28,7 +30,7 @@ get_optional() { } export RPC_URL="$(get rpc_url)" -export RELAYER_KEY="$(get forward_relayer_key)" +export RELAYER_KEY="$(get "${FORWARD_RELAYER_SSM_NAME:-forward_relayer_key}")" export ALERT_WEBHOOK_URL="$(get_optional alert_webhook_url)" exec "$@" diff --git a/scripts/verify.sh b/scripts/verify.sh index 3f6d79b3..bcfcaaf4 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -101,12 +101,12 @@ step node . "cngn-rebalance check" pnpm --filter cngn-rebalance run check step node . "cngn-rebalance test" pnpm --filter cngn-rebalance test # ---- services-perp-feeds.yml ----------------------------------------------------------- -# The anvil suites deploy the real feed contracts and FutureAssetV1 (the live 30NOV26 series the keepers run), so they need forge and anvil, and +# The anvil suites deploy the real feed contracts, FutureAssetV1 (the live 30NOV26 series) and the v2 FutureAsset, so they need forge and anvil, and # a fresh anvil on the port the tests read: started here, stopped however the tests end. perp_feeds_anvil_tests() { ( cd "$ROOT/contracts/risk-core" \ && forge build src/feeds/LyraSpotFeed.sol src/feeds/LyraSpotDiffFeed.sol src/feeds/LyraForwardFeed.sol \ - src/periphery/OracleDataSubmitter.sol src/legacy/v1/FutureAssetV1.sol src/SubAccounts.sol >/dev/null ) || return 1 + src/periphery/OracleDataSubmitter.sol src/legacy/v1/FutureAssetV1.sol src/assets/FutureAsset.sol src/SubAccounts.sol >/dev/null ) || return 1 anvil --port 8599 --silent & local anvil_pid=$! sleep 2 diff --git a/services/perp-feeds/README.md b/services/perp-feeds/README.md index 139b9c42..9552b38f 100644 --- a/services/perp-feeds/README.md +++ b/services/perp-feeds/README.md @@ -168,3 +168,33 @@ the ops box since 2026-10-13 for USDC-cNGN-30NOV26, from its own checkout `/home on `FutureAsset`. The anvil suites share one chain clock, so `pnpm test` runs test files one at a time (`--test-concurrency=1`): concurrently, one suite's `evm_increaseTime` expires another's signatures or skips its fixing window. + +### v2 series (`FUTURE_VERSION=v2`) + +The same process marks a MarginEngine v2 future (`src/assets/FutureAsset.sol`: cNGN-30NOV26, and +EURC-30NOV26 once deployed), one instance per series (`numo-future-feeds-v2@.service`, +`/etc/numo/future-feeds-v2-.env`; `future-feeds-v2-cngn-30nov26.env` is the first). v1's +USDC-cNGN-30NOV26 keeps its unit and code path, unchanged, until it settles. What v2 changes: + +- **Units.** Every price is USDC per unit of the base, the feeds' units and the contract's, so + nothing is inverted: the diff signed is `forward − index`, and `pullMark` reads `index + diff` as is. +- **Carry.** The forward without a book is `index / (1 + CARRY × τ)`, as the v2 deploy script listed + it. `CARRY` is signed: positive for cNGN (a forward discount, a negative diff), negative for EURC + (a premium). v1 refuses a negative one. +- **Depth.** A v2 contract is `contractSize` of the base (1,000,000 cNGN), so its notional is + `contracts × contractSize × price`; `FORWARD_MIN_BOOK_DEPTH_USDC` stays in USDC. +- **Startup.** It refuses to start when the contract's mark sits nearer the index read the other + version's way (`checkUnits`), and its alerts are headed `[future-feeds v2]`: both + 30NOV26 series have the same sub id. +- **Relayer.** Its own, named by `FORWARD_RELAYER_SSM_NAME` under `/numo/feeds`. The signer is the + same KMS key as v1's: each feed's EIP-712 domain binds a signature to that feed. + +- **The VM pool** (`coverPoolDeficit`): v2's VM is USDC and moves through the engine's pool (#37) as + cash, nothing converted, so the pool's net is its cash and a loser's settlement pays exactly what it + owes. Losers are settled largest first while the cash is below zero (the same $1 tolerance and + `SETTLE_PER_TICK`); no escrow leg, no spot feed, no `assetMap` read. Every series on an engine shares + its pool, so each series' keeper sees the same cash and settles its own series' losers. +- **Expiry** is the same calls as v1's: fixing samples, `fixSettlementFromIndex`, `closeOutExpired`. + +`src/forward-v2.anvil.e2e.test.ts` and `src/expiry-v2.anvil.e2e.test.ts` run it against the real v2 +`FutureAsset` for a cNGN and an EURC series. diff --git a/services/perp-feeds/src/alert.ts b/services/perp-feeds/src/alert.ts index d784a59c..0b923380 100644 --- a/services/perp-feeds/src/alert.ts +++ b/services/perp-feeds/src/alert.ts @@ -1,7 +1,8 @@ /** * Posts to ALERT_WEBHOOK_URL in the shape the ops scripts already use ({text, content}, which Slack * and Discord both read). Each alert key is held back for `cooldownMs` after it fires, so a refusal - * that lasts an hour pages a few times rather than sixty. + * that lasts an hour pages a few times rather than sixty. `tag` heads every message: a second + * instance of a keeper (a v2 series) names itself, since its alert keys are the first one's. */ export type Alerter = (key: string, message: string) => Promise; @@ -10,6 +11,7 @@ export function createAlerter( cooldownMs = 15 * 60_000, now: () => number = Date.now, fetchImpl: typeof fetch = fetch, + tag = 'perp-feeds', ): Alerter { const lastSent = new Map(); @@ -23,7 +25,7 @@ export function createAlerter( const response = await fetchImpl(webhookUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ text: `[perp-feeds] ${message}`, content: `[perp-feeds] ${message}` }), + body: JSON.stringify({ text: `[${tag}] ${message}`, content: `[${tag}] ${message}` }), signal: AbortSignal.timeout(10_000), }); // A 2xx means the webhook took it, not that anyone read it; a non-2xx means nobody will. diff --git a/services/perp-feeds/src/expiry-keeper.test.ts b/services/perp-feeds/src/expiry-keeper.test.ts index b3173fc1..bcdb8df0 100644 --- a/services/perp-feeds/src/expiry-keeper.test.ts +++ b/services/perp-feeds/src/expiry-keeper.test.ts @@ -56,11 +56,13 @@ type State = { cngnHeld?: bigint[]; // each account's cNGN escrow balance, by account id - 1 pool?: { cash: bigint; cngn: bigint }; // the VM pool's USDC cash (18dp USD) and cNGN failWrites?: Set; + cashVm?: boolean; // v2: VM is USDC, paid into the pool's cash as is }; function run(state: State, cfg = config) { const writes: { fn: string; args: readonly unknown[]; to: Address }[] = []; const readsFrom: Address[] = []; + const readFns: string[] = []; const alerts: string[] = []; const chain: ContractChain = { signer: privateKeyToAccount(keccak256(toHex('forward-signer'))), @@ -72,6 +74,7 @@ function run(state: State, cfg = config) { submit: async () => '0x', read: async (address: Address, _abi: unknown, functionName: string, args: readonly unknown[] = []): Promise => { readsFrom.push(address); + readFns.push(functionName); // thunks: each answer is computed only for the function asked, since some need args the others lack const values: Record unknown> = { futureContracts: () => ({ listed: true, settled: state.settled, expiry: EXPIRY, lastTradeTime: EXPIRY - WINDOW }), @@ -106,7 +109,7 @@ function run(state: State, cfg = config) { }, }; const keeper = new ExpiryKeeper(cfg, chain, async (key) => void alerts.push(key)); - return { keeper, writes, alerts, readsFrom }; + return { keeper, writes, alerts, readsFrom, readFns }; } function balanceOf(state: State, id: bigint, asset: Address): bigint { @@ -121,6 +124,12 @@ function settle(state: State, id: bigint) { const i = Number(id) - 1; const vm = state.pending?.[i] ?? 0n; if (vm >= 0n || !state.pending) return; + if (state.cashVm) { + state.pool ??= { cash: 0n, cngn: 0n }; + state.pool.cash += -vm; + state.pending[i] = 0n; + return; + } const held = state.cngnHeld?.[i] ?? 0n; const owed = -vm; const fromCngn = owed < held ? owed : held; @@ -319,6 +328,51 @@ describe('expiry keeper: VM pool deficit', () => { }); }); +describe('expiry keeper: v2 cash pool', () => { + // EURC-30NOV26 here, not cNGN: on v2 the keeper is the same for any base + const v2 = loadForwardConfig({ ...envOf(config), FUTURE_VERSION: 'v2', BASE_SYMBOL: 'EURC', CARRY: '-15000000000000000' }); + const v2run = (overrides: Partial) => run(state({ cashVm: true, ...overrides }), v2); + + it('judges the pool by its cash alone: a base balance in it counts for nothing, and no escrow or feed is read', async () => { + // -$7 cash with base in the pool is a deficit on v2 (it would be net zero on v1) + const { keeper, writes, readFns } = v2run({ balances: [-1n], pending: [usd(-9)], pool: { cash: usd(-7), cngn: cngn(10_000) } }); + const outcome = await keeper.coverPoolDeficit(); + assert.deepEqual(writes.map((w) => [w.fn, w.args[0]]), [['settleAccount', 1n]]); + assert.deepEqual(outcome, { netUsd: usd(2), settled: 1, failed: 0, stranded: false }); + for (const fn of ['assetDetails', 'assetMap', 'getMarketFeeds', 'getSpot']) assert.ok(!readFns.includes(fn), `read ${fn}`); + }); + + it('settles nobody at or above zero, nor within the $1 tolerance', async () => { + const { keeper, writes } = v2run({ balances: [1n], pending: [usd(-50)], pool: { cash: -usd(1), cngn: 0n } }); + assert.equal((await keeper.coverPoolDeficit()).settled, 0); + assert.deepEqual(writes, []); + }); + + it('settles the largest USDC losers first, never a winner, and stops once the cash is back', async () => { + const s = state({ cashVm: true, balances: [1n, 1n, 1n, -3n], pending: [usd(-3), usd(-10), usd(-6), usd(19)], pool: { cash: usd(-12), cngn: 0n } }); + const { keeper, writes } = run(s, v2); + const outcome = await keeper.coverPoolDeficit(); + assert.deepEqual(writes.map((w) => w.args[0]), [2n, 3n]); + assert.deepEqual(outcome, { netUsd: usd(4), settled: 2, failed: 0, stranded: false }); + }); + + it('alerts once, naming the base, when no loser is left', async () => { + const { keeper, alerts, writes } = v2run({ balances: [1n], pending: [usd(5)], pool: { cash: usd(-5), cngn: 0n } }); + assert.equal((await keeper.coverPoolDeficit()).stranded, true); + assert.equal((await keeper.coverPoolDeficit()).stranded, true); + assert.deepEqual(alerts, ['future-pool-deficit-stranded']); + assert.deepEqual(writes, []); + }); + + it('fixes and closes out as on v1', async () => { + const fixed = run(state({ cashVm: true, head: EXPIRY, firstSampleTime: EXPIRY - WINDOW, lastSampleTime: EXPIRY - 60n, largestGap: 300n }), v2); + assert.equal(await fixed.keeper.tick(), 'fixed'); + const closing = run(state({ cashVm: true, settled: true, balances: [0n, 5n] }), v2); + assert.equal(await closing.keeper.tick(), 'closed-out'); + assert.deepEqual(closing.writes.map((w) => [w.fn, w.args[0], w.to]), [['closeOutExpired', 2n, MANAGER_OF(2n)]]); + }); +}); + describe('fixing completeness, as the contract decides it', () => { const base = { firstSampleTime: EXPIRY - WINDOW, lastSampleTime: EXPIRY - 60n, largestGap: 300n, maxSampleGap: GAP }; it('is complete with every gap inside the limit', () => assert.equal(fixingComplete(base, EXPIRY), true)); diff --git a/services/perp-feeds/src/expiry-keeper.ts b/services/perp-feeds/src/expiry-keeper.ts index d7a0aa27..57503b8a 100644 --- a/services/perp-feeds/src/expiry-keeper.ts +++ b/services/perp-feeds/src/expiry-keeper.ts @@ -29,7 +29,7 @@ const engineAbi = parseAbi([ ]); const spotFeedAbi = parseAbi(['function getSpot() view returns (uint256, uint256)']); /** IMarginEngine.AssetType.Base: the market's cNGN escrow. */ -const BASE_ASSET_TYPE = 3; +const BASE_ASSET_TYPE = 3; // v1 only: v2's pool holds no base const ONE = 10n ** 18n; /** * A net within $1 of zero is covered. Settlement converts cNGN at the spot feed and rounds, and a pool holding @@ -70,7 +70,9 @@ type Pool = { id: bigint; cash: Address; escrow: Address; spotFeed: Address }; type Loser = { id: bigint; manager: Address; owedCngn: bigint; usdc: bigint }; /** - * Takes a USDC/cNGN future through expiry, with nothing but permissionless calls: + * Takes a dated future through expiry, with nothing but permissionless calls. v1 (USDC-cNGN, VM in + * cNGN) and v2 (any base, VM in USDC cash) differ only in the VM pool (0); the fixing and close-out + * calls are the same on both: * * 0. Every tick, keeps the VM pool's net at or above zero (coverPoolDeficit): while it is below, * settles losers, those paying the most USDC first, up to SETTLE_PER_TICK a tick. Nothing else @@ -263,6 +265,7 @@ export class ExpiryKeeper { const contract = await this.future(futureAbi, 'futureContracts', [this.config.FUTURE_SUB_ID]); // after settlement, close-out realises every account's VM at the settlement price if (!contract.listed || contract.settled) return { netUsd: null, settled: 0, failed: 0, stranded: false }; + if (this.config.FUTURE_VERSION === 'v2') return this.coverCashPool(); const pool = await this.pool(); const spot = await this.spot(pool); let net = await this.net(pool, spot); @@ -300,6 +303,61 @@ export class ExpiryKeeper { return { netUsd: net, settled, failed, stranded: false }; } + /** + * coverPoolDeficit on v2 (MarginEngine v2, FutureVMLib): VM is USDC and moves as cash through the pool, + * nothing converted, so the pool's net is its cash and a loser's settlement pays the pool exactly what it + * owes. No escrow leg, no spot feed, no market lookup: the pool is the engine's accId, the cash its + * cashAsset, whatever the base. Losers go largest first, up to SETTLE_PER_TICK a tick. + * + * Every v2 series on an engine shares its pool, so each series' keeper sees the same net; each settles + * only its own series' losers (settleAccount realises every future the account holds). + */ + private async coverCashPool(): Promise { + const engine = this.config.FUTURE_MANAGER; + const [poolId, cash] = await Promise.all([ + this.chain.read(engine, engineAbi, 'accId'), + this.chain.read
(engine, engineAbi, 'cashAsset'), + ]); + let net = await this.balance(poolId, cash); + if (net >= -POOL_NET_TOLERANCE) { + if (this.stranded) console.log(`[pool] net back to ${usd(net)}`); + this.stranded = false; + return { netUsd: net, settled: 0, failed: 0, stranded: false }; + } + const losers: { id: bigint; manager: Address; owed: bigint }[] = []; + for (const holder of await this.holders()) { + const vm = await this.future(pendingAbi, 'getPendingVM', [holder.id, this.config.FUTURE_SUB_ID]); + if (vm < 0n) losers.push({ ...holder, owed: -vm }); + } + losers.sort((a, b) => (a.owed > b.owed ? -1 : a.owed < b.owed ? 1 : 0)); + if (losers.length === 0) { + if (!this.stranded) { + this.stranded = true; + await this.alert( + 'future-pool-deficit-stranded', + `${this.config.BASE_SYMBOL} future VM pool #${poolId} is ${usd(-net)} below zero (cash) with no loser of ${this.config.FUTURE_SUB_ID} left to settle: top up the pool account`, + ); + } + return { netUsd: net, settled: 0, failed: 0, stranded: true }; + } + let settled = 0; + let failed = 0; + for (const loser of losers.slice(0, this.config.SETTLE_PER_TICK)) { + try { + const tx = await this.chain.write(loser.manager, managerAbi, 'settleAccount', [loser.id]); + settled++; + console.log(`[pool] settled loser ${loser.id} (owed ${usd(loser.owed)}) tx=${tx}`); + } catch (error) { + failed++; + await this.alert(`settle-failed-${loser.id}`, `settleAccount(${loser.id}) failed: ${(error as Error).message.slice(0, 300)}`); + } + net = await this.balance(poolId, cash); + if (net >= -POOL_NET_TOLERANCE) break; + } + console.log(`[pool] ${settled} settled, ${failed} failed, net ${net < 0n ? '-' : ''}${usd(net < 0n ? -net : net)}`); + return { netUsd: net, settled, failed, stranded: false }; + } + private async closeOut(): Promise { const holders = await this.holders(); if (holders.length === 0) return 'done'; diff --git a/services/perp-feeds/src/expiry-v2.anvil.e2e.test.ts b/services/perp-feeds/src/expiry-v2.anvil.e2e.test.ts new file mode 100644 index 00000000..8dc4fde5 --- /dev/null +++ b/services/perp-feeds/src/expiry-v2.anvil.e2e.test.ts @@ -0,0 +1,176 @@ +/** + * The expiry keeper in v2 mode against a REAL v2 FutureAsset (src/assets/FutureAsset.sol) on anvil, for + * two series, cNGN and EURC, apart only in config: it samples the index through a fixing window and + * fixes the settlement price from those samples, in USDC per unit of the base, nothing inverted. The + * v1 suite (expiry.anvil.e2e.test.ts) keeps FutureAssetV1 until USDC-cNGN-30NOV26 settles. + * + * The contract is listed with a 40-second window and a 20-second gap limit so the whole fixing runs + * in a minute of chain time, stepped 5 seconds at a time. That only holds if nothing else moves the + * clock meanwhile: the anvil suites share one node, which is why `pnpm test` runs files one at a time + * (--test-concurrency=1). Run concurrently, the perp suite's 60-second jump lands inside this window. + * + * Close-out and the cash pool are not here: they need a full MarginEngine v2 stack, which risk-core's + * fork suite (MarginEngineV2DeployFork) runs on real Base contracts. The keeper's scan, batching and v2 + * cash pool are unit-tested in expiry-keeper.test.ts. + * + * Skipped without ANVIL_RPC_URL. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { before, describe, it } from 'node:test'; + +import { + createPublicClient, + createWalletClient, + defineChain, + http, + keccak256, + parseAbi, + toHex, + type Abi, + type Address, + type Hex, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { createChain } from './chain.js'; +import { ExpiryKeeper } from './expiry-keeper.js'; +import { encodeManagerData, encodeSpotData, signFeedUpdate } from './feed-data.js'; +import { loadForwardConfig } from './forward-config.js'; +import { futureAbi } from './forward-publisher.js'; +import { ONE } from './forward-targets.js'; + +const RPC = process.env.ANVIL_RPC_URL; +const ARTIFACTS = join(import.meta.dirname, '../../../contracts/risk-core/out'); +// anvil's sixth default account: each anvil suite deploys and relays from its own. +const DEPLOYER_KEY = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba' as Hex; +const INDEX_SIGNER_KEY = keccak256(toHex('numo.expiry-keeper-v2.e2e.index-signer')); +const WINDOW = 40n; +const GAP = 20n; + +const adminAbi = parseAbi([ + 'function addSigner(address signer, bool isSigner)', + 'function setHeartbeat(uint64 heartbeat)', + 'function setIndexParams(address indexFeed, uint256 maxDev, uint256 minDev, uint256 narrowing, uint256 window, uint256 gap, uint256 fallbackDelay)', + 'function setContract(uint64 expiry, uint64 lastTradeTime, uint256 initialMarkPrice) returns (uint96)', +]); + +type Series = { base: string; indexUsdc: bigint; contractSize: bigint; carry: bigint }; + +const SERIES: Series[] = [ + { base: 'cNGN', indexUsdc: 724_089_970_981_047n, contractSize: 1_000_000n * ONE, carry: (118n * ONE) / 1000n }, + { base: 'EURC', indexUsdc: 1_162_500_000_000_000_000n, contractSize: 1_000n * ONE, carry: -(15n * ONE) / 1000n }, +]; + +function artifact(file: string, name: string): { abi: Abi; bytecode: Hex } { + const json = JSON.parse(readFileSync(join(ARTIFACTS, file, `${name}.json`), 'utf8')); + return { abi: json.abi, bytecode: json.bytecode.object }; +} + +for (const series of SERIES) describe(`v2 expiry keeper against a real FutureAsset on anvil: ${series.base}-`, { skip: !RPC }, () => { + const chain = defineChain({ + id: 31337, + name: 'anvil', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [RPC ?? ''] } }, + }); + const publicClient = createPublicClient({ chain, transport: http(RPC) }); + const wallet = createWalletClient({ account: privateKeyToAccount(DEPLOYER_KEY), chain, transport: http(RPC) }); + const at: Record<'submitter' | 'index' | 'future' | 'subAccounts', Address> = {} as never; + const indexUsdc = series.indexUsdc; + let expiry: bigint; + + async function deploy(file: string, name: string, args: unknown[] = []): Promise
{ + const { abi, bytecode } = artifact(file, name); + const hash = await wallet.deployContract({ abi, bytecode, args }); + return (await publicClient.waitForTransactionReceipt({ hash })).contractAddress!; + } + + async function send(address: Address, functionName: string, args: unknown[]) { + const hash = await wallet.writeContract({ address, abi: adminAbi, functionName, args } as never); + assert.equal((await publicClient.waitForTransactionReceipt({ hash })).status, 'success', `${functionName} reverted`); + } + + async function step() { + await publicClient.request({ method: 'evm_increaseTime' as never, params: [5] as never }); + await publicClient.request({ method: 'evm_mine' as never, params: [] as never }); + } + + async function head(): Promise { + return (await publicClient.getBlock({ blockTag: 'latest' })).timestamp; + } + + before(async () => { + at.submitter = await deploy('OracleDataSubmitter.sol', 'OracleDataSubmitter'); + at.subAccounts = await deploy('SubAccounts.sol', 'SubAccounts', ['Accounts', 'ACC']); + at.index = await deploy('LyraSpotFeed.sol', 'LyraSpotFeed'); + at.future = await deploy('FutureAsset.sol', 'FutureAsset', [at.subAccounts, series.contractSize]); + await send(at.index, 'addSigner', [privateKeyToAccount(INDEX_SIGNER_KEY).address, true]); + await send(at.index, 'setHeartbeat', [86_400n]); + + // the index, as its own signer would publish it + const indexChain = createChain({ RPC_URL: RPC!, CHAIN_ID: 31337, RELAYER_KEY: DEPLOYER_KEY, DATA_SUBMITTER: at.submitter, FEED_SIGNER_KEY: INDEX_SIGNER_KEY }); + await step(); + const now = await head(); + const update = await signFeedUpdate({ + signer: indexChain.signer, + kind: 'LyraSpotFeed', + feed: at.index, + chainId: 31337, + data: encodeSpotData(indexUsdc), + timestamp: now - 1n, + deadline: now + 60n, + }); + await indexChain.submit(encodeManagerData([update])); + + expiry = (await head()) + 60n; + await send(at.future, 'setIndexParams', [at.index, ONE / 10n, ONE / 100n, 0n, WINDOW, GAP, 3_600n]); + await send(at.future, 'setContract', [expiry, expiry - WINDOW, indexUsdc]); + }); + + it('samples the window and fixes the settlement price from the index', async () => { + const cfg = loadForwardConfig({ + RPC_URL: RPC!, + CHAIN_ID: '31337', + RELAYER_KEY: DEPLOYER_KEY, + FORWARD_SIGNER_KEY: keccak256(toHex('numo.expiry-keeper-v2.e2e.forward-signer')), + FUTURE_VERSION: 'v2', + BASE_SYMBOL: series.base, + DATA_SUBMITTER: at.submitter, + INDEX_FEED: at.index, + FORWARD_FEED: at.index, + FUTURE_ASSET: at.future, + // no engine here: the deficit cover is not exercised + FUTURE_MANAGER: '0x00000000000000000000000000000000000000E9', + FUTURE_SUB_ID: String(expiry), + CARRY: String(series.carry), + FIXING_SAMPLE_INTERVAL_MS: '8000', + }); + const alerts: string[] = []; + const keeper = new ExpiryKeeper(cfg, createChain(cfg, privateKeyToAccount(cfg.FORWARD_SIGNER_KEY as Hex)), async (key) => void alerts.push(key)); + await keeper.checkSampleInterval(); + + const outcomes: string[] = []; + for (let i = 0; i < 40; i++) { + const outcome = await keeper.tick(); + outcomes.push(outcome); + if (outcome === 'fixed' || outcome === 'fixing-incomplete') break; + await step(); + } + + assert.ok(outcomes.includes('sampled'), `it sampled: ${outcomes.join(',')}`); + assert.equal(outcomes.at(-1), 'fixed', `it fixed: ${outcomes.join(',')}`); + assert.deepEqual(alerts, [], 'no sample failed and the fixing was complete'); + + const contract = (await publicClient.readContract({ address: at.future, abi: futureAbi, functionName: 'futureContracts', args: [expiry] })) as { + settled: boolean; + markPrice: bigint; + }; + assert.equal(contract.settled, true); + assert.equal(contract.markPrice, indexUsdc, 'a flat index settles at the index, as is'); + + // and once settled, with nobody holding the series, the keeper has nothing left to do + assert.equal(await keeper.tick(), 'done'); + }); +}); diff --git a/services/perp-feeds/src/forward-config.test.ts b/services/perp-feeds/src/forward-config.test.ts index d3f93837..50b90fc9 100644 --- a/services/perp-feeds/src/forward-config.test.ts +++ b/services/perp-feeds/src/forward-config.test.ts @@ -37,6 +37,22 @@ describe('forward config', () => { assert.equal(loadForwardConfig(env({ CHAIN_ID: '84532', FORWARD_SIGNER_KEY: KEY })).CHAIN_ID, 84532); }); + it('is v1 unless told otherwise, and refuses a negative carry there', () => { + const kms = { FORWARD_SIGNER_KMS_KEY_ID: 'k' }; + const config = loadForwardConfig(env(kms)); + assert.equal(config.FUTURE_VERSION, 'v1'); + assert.equal(config.BASE_SYMBOL, 'cNGN'); + assert.throws(() => loadForwardConfig(env({ ...kms, CARRY: '-15000000000000000' })), /must not be negative on v1/); + }); + + it('takes a v2 series on any base, with a signed carry (EUR at a forward premium)', () => { + const config = loadForwardConfig(env({ FORWARD_SIGNER_KMS_KEY_ID: 'k', FUTURE_VERSION: 'v2', BASE_SYMBOL: 'EURC', CARRY: '-15000000000000000' })); + assert.equal(config.FUTURE_VERSION, 'v2'); + assert.equal(config.BASE_SYMBOL, 'EURC'); + assert.equal(config.CARRY, -15000000000000000n); + assert.throws(() => loadForwardConfig(env({ FORWARD_SIGNER_KMS_KEY_ID: 'k', FUTURE_VERSION: 'v3' }))); + }); + it('needs exactly one signer', () => { assert.throws(() => loadForwardConfig(env({ CHAIN_ID: '84532' })), /exactly one/); assert.throws( diff --git a/services/perp-feeds/src/forward-config.ts b/services/perp-feeds/src/forward-config.ts index 4f84e696..36800671 100644 --- a/services/perp-feeds/src/forward-config.ts +++ b/services/perp-feeds/src/forward-config.ts @@ -6,12 +6,16 @@ import { getAddress } from 'viem'; const address = z.string().regex(/^0x[0-9a-fA-F]{40}$/).transform((value) => getAddress(value)); const privateKey = z.string().regex(/^0x[0-9a-fA-F]{64}$/); const uint = z.string().regex(/^[0-9]+$/).transform((value) => BigInt(value)); +const int = z.string().regex(/^-?[0-9]+$/).transform((value) => BigInt(value)); const bool = z.union([z.literal('true'), z.literal('false')]).transform((value) => value === 'true'); const BASE_MAINNET = 8453; /** - * The forward publisher for a USDC/cNGN future (forward-main.ts). Its own process, signer and + * The forward publisher for a dated future (forward-main.ts), one process per series. FUTURE_VERSION + * says which contract: v1 (FutureAssetV1, USDC-cNGN-30NOV26, inverse) or v2 (FutureAsset, linear, any + * base quoted in USDC: cNGN-30NOV26, and EURC-30NOV26 once deployed). A v2 series is config only: + * its feed, future, index, carry and BASE_SYMBOL; its contract size is read from the chain. Its own process, signer and * relayer: the forward key must not be the index key (the index band is what bounds a bad forward), * and a relayer shared with perp-feeds would race it for nonces across processes. * @@ -29,7 +33,12 @@ const envSchema = z ALERT_WEBHOOK_URL: z.string().url().optional().or(z.literal('')), DRY_RUN: bool.default('false'), - // From risk-core/deployments/8453/MARGIN_ENGINE.json and core.json. + /** v1: FutureAssetV1 (src/legacy/v1). v2: FutureAsset (src/assets), linear, VM in USDC cash. */ + FUTURE_VERSION: z.enum(['v1', 'v2']).default('v1'), + /** The base the future is on, for log lines and alerts only: two series can share an expiry, so a sub id alone does not name one. */ + BASE_SYMBOL: z.string().min(1).default('cNGN'), + + // From risk-core/deployments/8453/MARGIN_ENGINE.json (v1) or MARGIN_ENGINE_V2.json (v2), and core.json. DATA_SUBMITTER: address, INDEX_FEED: address, FORWARD_FEED: address, @@ -40,8 +49,11 @@ const envSchema = z * market's cNGN escrow and spot feed), so nothing else about the pool is configured. */ FUTURE_MANAGER: address, - /** Annual NGN-over-USD carry, 18dp: the forward without a book is index * (1 + CARRY * tau). */ - CARRY: uint, + /** + * Annual carry of the base's rate over USD's, 18dp. The forward without a book is index * (1 + CARRY * tau) + * on v1, index / (1 + CARRY * tau) on v2. Signed: EUR's is negative (a forward premium); v1 refuses that. + */ + CARRY: int, MARKETS_SERVICE_URL: z.string().url().default('https://api.numofx.com'), /** How often to check. A mark is only pushed once FutureAsset.minMarkInterval has passed. */ @@ -49,6 +61,7 @@ const envSchema = z /** * Notional (USDC) each side of the futures book must fill before the book sets the mark: each * side's price is its depth-weighted average over this. Below it, the mark is the carry forward. + * A v1 contract's notional is its USDC size; a v2 contract's is contractSize of the base at the price. */ FORWARD_MIN_BOOK_DEPTH_USDC: z.coerce.number().positive().default(2_000), /** Farthest the mark may sit from the carry forward, however the book reads. */ @@ -83,6 +96,9 @@ const envSchema = z if (kms === raw) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'set exactly one of FORWARD_SIGNER_KMS_KEY_ID and FORWARD_SIGNER_KEY' }); } + if (env.FUTURE_VERSION === 'v1' && env.CARRY < 0n) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'CARRY must not be negative on v1' }); + } if (raw && env.CHAIN_ID === BASE_MAINNET) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'on Base mainnet the forward signer must be a KMS key, not FORWARD_SIGNER_KEY' }); } diff --git a/services/perp-feeds/src/forward-main.ts b/services/perp-feeds/src/forward-main.ts index f8b3e98d..e6fbd220 100644 --- a/services/perp-feeds/src/forward-main.ts +++ b/services/perp-feeds/src/forward-main.ts @@ -9,7 +9,8 @@ import { ForwardPublisher } from './forward-publisher.js'; import { ExpiryKeeper } from './expiry-keeper.js'; /** - * The keeper for a USDC/cNGN future, run as its own unit (numo-future-feeds.service): its own signer, + * The keeper for a dated future, one unit per series (numo-future-feeds.service for v1's + * USDC-cNGN-30NOV26, numo-future-feeds-v2@.service for each v2 series): its own signer, * held in KMS, and its own relayer. Each tick runs the forward publisher (forward-publisher.ts: marks * until last trade), then the VM pool's deficit cover and the expiry keeper (expiry-keeper.ts: losers * settled while the pool's net is below zero; fixing samples, the fixing, and close-out). Their writes @@ -59,18 +60,24 @@ export async function coverDeficit(expiry: Pick): string { + return config.FUTURE_VERSION === 'v2' ? `future-feeds ${config.BASE_SYMBOL} v2` : 'perp-feeds'; +} + async function main() { const config = loadForwardConfig(); - const alert = createAlerter(config.ALERT_WEBHOOK_URL || undefined); + const alert = createAlerter(config.ALERT_WEBHOOK_URL || undefined, undefined, undefined, undefined, alertTag(config)); const signer = await obtainSigner(config, alert); const chain = createChain(config, signer); const publisher = new ForwardPublisher(config, chain, alert); await publisher.checkLateThreshold(); + await publisher.checkUnits(); const expiry = new ExpiryKeeper(config, chain, alert); await expiry.checkSampleInterval(); console.log( `[forward] signer ${signer.address} (${config.FORWARD_SIGNER_KMS_KEY_ID ? 'KMS' : 'raw key'}), relayer ${chain.relayer.address}, ` + - `future ${config.FUTURE_ASSET} #${config.FUTURE_SUB_ID}${config.DRY_RUN ? ', DRY RUN' : ''}`, + `${config.BASE_SYMBOL} future ${config.FUTURE_VERSION} ${config.FUTURE_ASSET} #${config.FUTURE_SUB_ID}${config.DRY_RUN ? ', DRY RUN' : ''}`, ); for (;;) { diff --git a/services/perp-feeds/src/forward-publisher.test.ts b/services/perp-feeds/src/forward-publisher.test.ts index 837be89a..93a7c628 100644 --- a/services/perp-feeds/src/forward-publisher.test.ts +++ b/services/perp-feeds/src/forward-publisher.test.ts @@ -38,6 +38,7 @@ type State = { forwardFeed: Address; band: bigint; minMarkInterval?: bigint; + contractSize?: bigint; }; function fakeChain(state: State) { @@ -67,7 +68,7 @@ function fakeChain(state: State) { cumulativeVMPerContract: 0n, }, minMarkInterval: state.minMarkInterval ?? 3_600n, - contractSize: 1_000n * ONE, + contractSize: state.contractSize ?? 1_000n * ONE, maxMarkDeviation: ONE / 20n, markIndexBand: state.band, forwardFeed: state.forwardFeed, @@ -238,3 +239,44 @@ describe('forward publisher', () => { assert.ok(alerts.includes('forward-pull-failed')); }); }); + +describe('forward publisher, v2', () => { + // a v2 series on a base other than cNGN: USDC per EURC, a 1,000 EURC contract, EUR at a forward premium + const EURC_INDEX = 1_162_500_000_000_000_000n; + const v2Config = { ...config, FUTURE_VERSION: 'v2' as const, BASE_SYMBOL: 'EURC', CARRY: -(15n * ONE) / 1000n }; + const v2State = (overrides: Partial = {}) => + base({ markPrice: EURC_INDEX, index: EURC_INDEX, contractSize: 1_000n * ONE, ...overrides }); + + function signedDiff(submitted: Hex): bigint { + const [updates] = decodeAbiParameters( + [{ type: 'tuple[]', components: [{ name: 'receiver', type: 'address' }, { name: 'data', type: 'bytes' }] }], + submitted, + ); + const [feedData] = decodeAbiParameters( + [{ type: 'tuple', components: [{ name: 'data', type: 'bytes' }, { name: 'deadline', type: 'uint256' }, { name: 'timestamp', type: 'uint64' }, { name: 'signers', type: 'address[]' }, { name: 'signatures', type: 'bytes[]' }] }], + updates[0].data, + ); + const [, , , diff] = decodeAbiParameters([{ type: 'uint64' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'int96' }, { type: 'uint64' }], feedData.data); + return diff; + } + + it('signs the forward less the index as is, uninverted: a premium on a negative carry', async () => { + const { publisher, calls, submitted } = run(v2State(), v2Config); + assert.equal(await publisher.tick(), 'published'); + assert.deepEqual(calls, ['submit', 'pullMark']); + const diff = signedDiff(submitted[0]); + assert.ok(diff > 0n, `EUR at a forward premium: diff ${diff}`); + // 30 days at -1.5% is ~0.12%: a v1 inversion would have been a diff the size of the index + assert.ok(diff * 100n < EURC_INDEX, `diff ${diff} is a fraction of the index`); + }); + + it('checks the contract is in the units of its version at startup', async () => { + await run(v2State(), v2Config).publisher.checkUnits(); + await run(base()).publisher.checkUnits(); + // a v2 contract run as v1, and a v1 contract run as v2 + await assert.rejects(() => run(v2State()).publisher.checkUnits(), /FUTURE_VERSION=v1 does not match/); + await assert.rejects(() => run(base(), v2Config).publisher.checkUnits(), /FUTURE_VERSION=v2 does not match/); + // no index: not checked, not refused + await run(v2State({ index: null }), { ...v2Config }).publisher.checkUnits(); + }); +}); diff --git a/services/perp-feeds/src/forward-publisher.ts b/services/perp-feeds/src/forward-publisher.ts index fde31283..3ce63ce9 100644 --- a/services/perp-feeds/src/forward-publisher.ts +++ b/services/perp-feeds/src/forward-publisher.ts @@ -4,7 +4,7 @@ import type { Alerter } from './alert.js'; import { fetchBook } from './book.js'; import type { ContractChain } from './chain.js'; import type { ForwardConfig } from './forward-config.js'; -import { computeForwardTarget } from './forward-targets.js'; +import { computeForwardTarget, invert } from './forward-targets.js'; import { encodeForwardData, encodeManagerData, signFeedUpdate } from './feed-data.js'; import type { RestingOrder } from './mark-targets.js'; @@ -42,7 +42,7 @@ export type ForwardOutcome = | 'misconfigured'; /** - * Marks a USDC/cNGN future from its forward: each tick, once the contract's minMarkInterval has + * Marks a dated future from its forward (v1 USDC-cNGN, or a v2 series on any base, FUTURE_VERSION): each tick, once the contract's minMarkInterval has * passed, it works out the forward (forward-targets.ts), signs it into the future's LyraForwardFeed * and calls FutureAsset.pullMark, which reads it back, checks it against the index band and move * limit, and accrues VM. Stops at last trade. @@ -78,6 +78,31 @@ export class ForwardPublisher { } } + /** + * Refuses a FUTURE_VERSION that does not match the contract, by where its mark sits against the + * index: a v1 mark is cNGN per USDC (about 1 / index), a v2 mark USDC per unit of the base (about the + * index). It refuses when the mark sits nearer the index read the other version's way: for cNGN the + * two are millions apart, for a base near a dollar (EURC) a third or so, and a stale mark is never + * that far off. The wrong version would only compute marks the contract refuses; this says why at + * startup. Skipped, with a warning, while the index cannot be read. + */ + async checkUnits(): Promise { + const contract = await this.read('futureContracts', [this.config.FUTURE_SUB_ID]); + const indexUsdc = await this.chain.readIndex(this.config.INDEX_FEED); + if (!contract.listed || contract.markPrice === 0n || indexUsdc === null || indexUsdc === 0n) { + console.warn('[forward] units not checked: the contract is unlisted or the index is unreadable'); + return; + } + const v2 = this.config.FUTURE_VERSION === 'v2'; + const index = v2 ? indexUsdc : invert(indexUsdc); + const other = v2 ? invert(indexUsdc) : indexUsdc; + if (ratio(contract.markPrice, other) < ratio(contract.markPrice, index)) { + throw new Error( + `FUTURE_VERSION=${this.config.FUTURE_VERSION} does not match future ${this.config.FUTURE_ASSET}: its mark ${contract.markPrice} is nearer ${other} than the index in those units, ${index}`, + ); + } + } + async tick(): Promise { const contract = await this.read('futureContracts', [this.config.FUTURE_SUB_ID]); if (!contract.listed) { @@ -126,6 +151,7 @@ export class ForwardPublisher { this.read('contractSize'), ]); const target = computeForwardTarget({ + version: this.config.FUTURE_VERSION, indexUsdc, carry: this.config.CARRY, secondsToExpiry: contract.expiry - head, @@ -175,3 +201,8 @@ export class ForwardPublisher { } } } + +/** max / min of two positive prices, 18dp: how far apart they are, whichever is larger. */ +function ratio(a: bigint, b: bigint): bigint { + return a > b ? (a * 10n ** 18n) / b : (b * 10n ** 18n) / a; +} diff --git a/services/perp-feeds/src/forward-targets.test.ts b/services/perp-feeds/src/forward-targets.test.ts index ad989be3..b7f4b997 100644 --- a/services/perp-feeds/src/forward-targets.test.ts +++ b/services/perp-feeds/src/forward-targets.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { bookMid, carryForward, computeForwardTarget, depthPrice, invert, ONE, type ForwardInputs } from './forward-targets.js'; +import { bookMid, carryForward, computeForwardTarget, depthPrice, discountForward, invert, ONE, type ForwardInputs } from './forward-targets.js'; import type { RestingOrder } from './mark-targets.js'; // The feed holds USDC per cNGN; the index in cNGN per USDC is its inverse, as the publisher computes it @@ -137,3 +137,97 @@ describe('forward target', () => { assert.ok(error * 10n ** 12n <= t.mark, `${recovered} vs ${t.mark}: more than 1e-12 apart`); }); }); + +// v2: every price is USDC per unit of the base, as the feeds hold it; one contract is contractSize of the base. +const CNGN_INDEX = 724_089_970_981_047n; // 0.000724 USDC per cNGN, the live index on 2026-10-27 +const CNGN_SIZE = 1_000_000n * ONE; // cNGN per contract, cNGN-30NOV26's +const EURC_INDEX = 1_162_500_000_000_000_000n; // 1.1625 USDC per EURC +const EURC_SIZE = 1_000n * ONE; // EURC per contract + +function v2(overrides: Partial = {}): ForwardInputs { + return inputs({ + version: 'v2', + indexUsdc: CNGN_INDEX, + carry: (118n * ONE) / 1000n, // 11.8%, cNGN-30NOV26's + contractSize: CNGN_SIZE, + minDepthUsdc: 2_000n * ONE, + lastMark: CNGN_INDEX, + ...overrides, + }); +} + +describe('v2 discount forward', () => { + it('is index / (1 + carry * tau): a positive carry is a discount, a negative one a premium', () => { + // 20% over a fifth of a year: index / 1.04 + assert.equal(discountForward(1_040n * ONE, (2n * ONE) / 10n, 73n * DAY), 1_000n * ONE); + // -2% over a fifth of a year: index / 0.996 + assert.equal(discountForward(996n * ONE, -(2n * ONE) / 100n, 73n * DAY), 1_000n * ONE); + assert.equal(discountForward(CNGN_INDEX, (2n * ONE) / 10n, 0n), CNGN_INDEX); + }); +}); + +describe('v2 depth', () => { + it('values a contract at contractSize x price: fewer contracts fill the depth as the price rises', () => { + // one contract of 1,000,000 cNGN at 0.0008 is $800: $2,000 needs 2.5 contracts + const price = (8n * ONE) / 10_000n; + assert.equal(depthPrice([sized('sell', price, (25n * ONE) / 10n)], CNGN_SIZE, 2_000n * ONE, 'v2'), price); + assert.equal(depthPrice([sized('sell', price, (24n * ONE) / 10n)], CNGN_SIZE, 2_000n * ONE, 'v2'), null); + // the same 2.4 contracts, read as v1's USDC contracts ($1,000 each), would fill it + assert.equal(depthPrice([sized('sell', price, (24n * ONE) / 10n)], 1_000n * ONE, 2_000n * ONE), price); + }); + it('fills across levels, weighting each by the contracts taken', () => { + // EURC: 1,000 EURC at 1.16 is $1,160, at 1.17 $1,170. $2,000: all of 1 at 1.16, then 840/1170 of one at 1.17 + const a = (116n * ONE) / 100n; + const b = (117n * ONE) / 100n; + const second = (840n * ONE * ONE + 1_170n * ONE - 1n) / (1_170n * ONE); + const expected = (a * ONE + b * second) / (ONE + second); + assert.equal(depthPrice([sized('sell', a, ONE), sized('sell', b, 5n * ONE)], EURC_SIZE, 2_000n * ONE, 'v2'), expected); + }); +}); + +describe('v2 forward target', () => { + it('marks cNGN to index / (1 + carry * tau), and the diff is the mark less the index: negative, nothing inverted', () => { + const fair = discountForward(CNGN_INDEX, (118n * ONE) / 1000n, 73n * DAY); + const t = computeForwardTarget(v2({ lastMark: fair })); + assert.ok(t.ok); + assert.equal(t.basis, 'carry'); + assert.equal(t.mark, fair); + assert.equal(t.diffUsdc, fair - CNGN_INDEX); + assert.ok(t.diffUsdc < 0n, 'cNGN at a forward discount'); + // the contract's pullMark reads index + diff as is + assert.equal(CNGN_INDEX + t.diffUsdc, t.mark); + }); + + it('marks EURC, at a negative carry, to a premium: a positive diff', () => { + const carry = -(15n * ONE) / 1000n; // -1.5% + const fair = discountForward(EURC_INDEX, carry, 73n * DAY); + const t = computeForwardTarget(v2({ indexUsdc: EURC_INDEX, carry, contractSize: EURC_SIZE, lastMark: fair })); + assert.ok(t.ok); + assert.equal(t.mark, fair); + assert.ok(fair > EURC_INDEX && t.diffUsdc > 0n, 'EUR at a forward premium'); + assert.equal(EURC_INDEX + t.diffUsdc, t.mark); + }); + + it('marks to a v2 book mid when each side fills the depth in USDC', () => { + const fair = discountForward(CNGN_INDEX, (118n * ONE) / 1000n, 73n * DAY); + const tick = fair / 1000n; + // 3 contracts a side, ~$2,150 of notional at these prices + const book = [sized('buy', fair - tick, 3n * ONE), sized('sell', fair + 3n * tick, 3n * ONE)]; + const t = computeForwardTarget(v2({ lastMark: fair, orders: book })); + assert.ok(t.ok); + assert.equal(t.basis, 'book-mid'); + assert.equal(t.mark, fair + tick); + // the same book with 2 contracts a side is ~$1,430 a side: below the depth, so carry + const thin = computeForwardTarget(v2({ lastMark: fair, orders: [sized('buy', fair - tick, 2n * ONE), sized('sell', fair + 3n * tick, 2n * ONE)] })); + assert.ok(thin.ok); + assert.equal(thin.basis, 'carry'); + }); + + it('bands and move-limits around the index and last mark in USDC per cNGN', () => { + // a 45% carry over a fifth of a year is a 9% discount, against a 5% band + const t = computeForwardTarget(v2({ carry: (45n * ONE) / 100n, band: ONE / 20n, lastMark: (CNGN_INDEX * 95n) / 100n })); + assert.ok(t.ok); + assert.equal(t.mark, CNGN_INDEX - (CNGN_INDEX * ((ONE / 20n) * 9950n / 10_000n)) / ONE); + assert.ok(t.clampedBy.includes('index-band')); + }); +}); diff --git a/services/perp-feeds/src/forward-targets.ts b/services/perp-feeds/src/forward-targets.ts index 195f29b4..003b3173 100644 --- a/services/perp-feeds/src/forward-targets.ts +++ b/services/perp-feeds/src/forward-targets.ts @@ -1,14 +1,21 @@ /** - * The forward a USDC/cNGN future is marked to, and the diff its LyraForwardFeed stores. + * The forward a dated future (cNGN, or on v2 any base quoted in USDC) is marked to, and the diff its LyraForwardFeed stores. * - * Prices here are cNGN per USDC at 18dp, the future's own denomination, unless named `...Usdc` - * (USDC per cNGN, the index and forward feeds' denomination). The feed stores the forward as - * `index + diff` in USDC per cNGN; FutureAsset.pullMark inverts that back. + * Two contracts, by `version`: + * - v1 (src/legacy/v1/FutureAssetV1, USDC-cNGN-): prices here are cNGN per USDC at 18dp, the + * future's own denomination, unless named `...Usdc` (USDC per cNGN, the index and forward feeds' + * denomination). One contract is contractSize USDC. The feed stores the forward as `index + diff` in + * USDC per cNGN; pullMark inverts that back. The carry forward is index * (1 + carry * tau). + * - v2 (src/assets/FutureAsset, -: cNGN-30NOV26, and EURC-30NOV26 to come): every price is + * USDC per unit of the base, the feeds' units and the future's, so nothing is inverted and pullMark + * reads index + diff as is. One contract is contractSize of the base, worth contractSize * price USDC. + * The carry forward is index / (1 + carry * tau), carry being the base's rate over USD's: positive for + * cNGN (a forward discount, a negative diff), negative for EURC (a forward premium). * - * The target is the futures book's mid when both sides have depth, otherwise the carry forward - * index * (1 + carry * tau). Each side's price is its depth-weighted average over minDepthUsdc of - * notional, so a dust order at the top of the book cannot set the mark; a side that cannot fill that - * much counts as no book. Then it is clamped, in order: + * The target is the futures book's mid when both sides have depth, otherwise the carry forward. + * Each side's price is its depth-weighted average over minDepthUsdc of notional, so a dust order at + * the top of the book cannot set the mark; a side that cannot fill that much counts as no book. Then + * it is clamped, in order: * 1. to the carry forward +/- maxBasisBps, so a thin book cannot drag the mark away from carry; * 2. inside the contract's index band (markIndexBand), less a safety margin; * 3. inside the contract's per-mark move limit from the last mark, less the same margin. @@ -20,15 +27,20 @@ import type { RestingOrder } from './mark-targets.js'; export const ONE = 10n ** 18n; const YEAR = 365n * 24n * 60n * 60n; +/** Which FutureAsset is marked: v1 (inverse, a USDC contract) or v2 (linear, a contract of the base). */ +export type FutureVersion = 'v1' | 'v2'; + export type ForwardInputs = { - /** The index feed: USDC per cNGN. */ + /** Defaults to v1. */ + version?: FutureVersion; + /** The index feed: USDC per cNGN (v2: per unit of the base). */ indexUsdc: bigint; - /** Annual NGN-over-USD carry, 18dp. */ + /** Annual carry of the base's rate over USD's, 18dp. Signed on v2 (EUR's is negative). */ carry: bigint; secondsToExpiry: bigint; - /** The futures book: prices in cNGN per USDC, amounts in contracts (18dp). */ + /** The futures book: prices in the future's units (v1 cNGN per USDC, v2 USDC per cNGN), amounts in contracts (18dp). */ orders: RestingOrder[]; - /** USDC per contract (18dp), FutureAsset.contractSize. */ + /** FutureAsset.contractSize, 18dp: USDC per contract on v1, the base per contract on v2. */ contractSize: bigint; /** Notional (USDC, 18dp) each side must be able to fill for the book to set the mark. */ minDepthUsdc: bigint; @@ -50,18 +62,30 @@ export function invert(price: bigint): bigint { return ONE * ONE / price; } -/** index * (1 + carry * tau), cNGN per USDC. */ +/** index * (1 + carry * tau), cNGN per USDC: v1's forward. */ export function carryForward(index: bigint, carry: bigint, secondsToExpiry: bigint): bigint { if (secondsToExpiry <= 0n) return index; return index + (index * carry / ONE) * secondsToExpiry / YEAR; } +/** + * index / (1 + carry * tau), USDC per unit of the base: v2's forward. A positive carry (cNGN) is a + * forward discount, a negative one (EURC) a premium. The same arithmetic as + * deploy-margin-engine-v2.s.sol's fairForward, which the initial mark was checked against. + */ +export function discountForward(index: bigint, carry: bigint, secondsToExpiry: bigint): bigint { + if (secondsToExpiry <= 0n) return index; + return index * ONE / (ONE + carry * secondsToExpiry / YEAR); +} + /** * Volume-weighted price to fill `minDepthUsdc` of notional walking one side best-first, or null if - * the side cannot fill it. A contract's notional is its USDC size, whatever the price. + * the side cannot fill it. On v1 a contract's notional is its USDC size, whatever the price; on v2 + * it is contractSize of the base at the order's price. */ -export function depthPrice(side: RestingOrder[], contractSize: bigint, minDepthUsdc: bigint): bigint | null { +export function depthPrice(side: RestingOrder[], contractSize: bigint, minDepthUsdc: bigint, version: FutureVersion = 'v1'): bigint | null { if (contractSize <= 0n) throw new Error('contract size must be positive'); + if (version === 'v2') return depthPriceLinear(side, contractSize, minDepthUsdc); // contracts (18dp) needed, rounded up let needed = (minDepthUsdc * ONE + contractSize - 1n) / contractSize; if (needed === 0n) needed = 1n; @@ -77,14 +101,34 @@ export function depthPrice(side: RestingOrder[], contractSize: bigint, minDepthU return weighted / taken; } +/** depthPrice for v2: each level's contracts are worth contractSize * price USDC, so the count needed varies by level. */ +function depthPriceLinear(side: RestingOrder[], contractSize: bigint, minDepthUsdc: bigint): bigint | null { + let short = minDepthUsdc > 0n ? minDepthUsdc : 1n; // notional (USDC, 18dp) still to fill + let taken = 0n; + let weighted = 0n; + for (const order of side) { + if (short <= 0n) break; + const perContract = contractSize * order.price / ONE; // USDC one whole contract is worth here + if (perContract <= 0n) continue; + // contracts (18dp) to cover what is short, rounded up + const want = (short * ONE + perContract - 1n) / perContract; + const take = order.remaining < want ? order.remaining : want; + taken += take; + weighted += order.price * take; + short -= take * perContract / ONE; + } + if (short > 0n || taken === 0n) return null; + return weighted / taken; +} + /** Mid of the depth-weighted bid and ask, or null without depth on both sides or with a crossed book. */ -export function bookMid(orders: RestingOrder[], contractSize: bigint, minDepthUsdc: bigint): bigint | null { +export function bookMid(orders: RestingOrder[], contractSize: bigint, minDepthUsdc: bigint, version: FutureVersion = 'v1'): bigint | null { const live = orders.filter((o) => o.remaining > 0n); const bids = live.filter((o) => o.side === 'buy').sort((a, b) => (a.price > b.price ? -1 : a.price < b.price ? 1 : 0)); const asks = live.filter((o) => o.side === 'sell').sort((a, b) => (a.price < b.price ? -1 : a.price > b.price ? 1 : 0)); if (bids.length === 0 || asks.length === 0 || bids[0].price >= asks[0].price) return null; - const bid = depthPrice(bids, contractSize, minDepthUsdc); - const ask = depthPrice(asks, contractSize, minDepthUsdc); + const bid = depthPrice(bids, contractSize, minDepthUsdc, version); + const ask = depthPrice(asks, contractSize, minDepthUsdc, version); if (bid === null || ask === null) return null; return (bid + ask) / 2n; } @@ -102,9 +146,13 @@ function clampTo(price: bigint, reference: bigint, fraction: bigint): bigint { } export function computeForwardTarget(inputs: ForwardInputs): ForwardTarget { - const index = invert(inputs.indexUsdc); - const fair = carryForward(index, inputs.carry, inputs.secondsToExpiry); - const mid = bookMid(inputs.orders, inputs.contractSize, inputs.minDepthUsdc); + const v2 = inputs.version === 'v2'; + // the index and the forward in the future's own units + const index = v2 ? inputs.indexUsdc : invert(inputs.indexUsdc); + const fair = v2 + ? discountForward(index, inputs.carry, inputs.secondsToExpiry) + : carryForward(index, inputs.carry, inputs.secondsToExpiry); + const mid = bookMid(inputs.orders, inputs.contractSize, inputs.minDepthUsdc, inputs.version); const clampedBy: string[] = []; const keep = (10_000n - inputs.safetyBps) * ONE / 10_000n; // the share of each limit we use @@ -134,7 +182,8 @@ export function computeForwardTarget(inputs: ForwardInputs): ForwardTarget { return { ok: false, reason: `mark ${mark} is outside the move limit from ${inputs.lastMark}` }; } - const diffUsdc = invert(mark) - inputs.indexUsdc; + // v1's pullMark inverts index + diff; v2's reads it as is + const diffUsdc = (v2 ? mark : invert(mark)) - inputs.indexUsdc; const limit = 2n ** 95n; if (diffUsdc < -limit || diffUsdc >= limit) return { ok: false, reason: `diff ${diffUsdc} does not fit int96` }; return { ok: true, mark, diffUsdc, basis, clampedBy }; diff --git a/services/perp-feeds/src/forward-v2.anvil.e2e.test.ts b/services/perp-feeds/src/forward-v2.anvil.e2e.test.ts new file mode 100644 index 00000000..cf5a9738 --- /dev/null +++ b/services/perp-feeds/src/forward-v2.anvil.e2e.test.ts @@ -0,0 +1,237 @@ +/** + * The forward publisher in v2 mode against the REAL v2 contracts, deployed from risk-core's compiled + * artifacts onto a local anvil: per series a LyraSpotFeed index, a LyraForwardFeed on it, a v2 + * FutureAsset (src/assets/FutureAsset.sol: USDC per unit of the base, a contract of the base, nothing + * inverted) reading both, and the OracleDataSubmitter. The v1 suite (forward.anvil.e2e.test.ts) keeps + * FutureAssetV1 until USDC-cNGN-30NOV26 settles. + * + * Two series, the same code and only config apart: cNGN (0.000724 USDC per cNGN, a 1,000,000 cNGN + * contract, 11.8% carry: a forward discount, a negative diff) and EURC (1.1625 USDC per EURC, a 1,000 + * EURC contract, -1.5% carry: a forward premium, a positive diff). + * + * anvil --port 8599 & + * (cd ../../contracts/risk-core && forge build) + * ANVIL_RPC_URL=http://127.0.0.1:8599 pnpm test + * + * Skipped without ANVIL_RPC_URL. Shares the chain clock with the other anvil suites, so test files run + * one at a time (forward.anvil.e2e.test.ts says why). + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { before, describe, it } from 'node:test'; + +import { + createPublicClient, + createWalletClient, + defineChain, + http, + keccak256, + parseAbi, + toHex, + type Abi, + type Address, + type Hex, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { createChain } from './chain.js'; +import { encodeManagerData, encodeSpotData, signFeedUpdate } from './feed-data.js'; +import { loadForwardConfig } from './forward-config.js'; +import { futureAbi, ForwardPublisher } from './forward-publisher.js'; +import { discountForward, ONE } from './forward-targets.js'; +import type { RestingOrder } from './mark-targets.js'; + +const RPC = process.env.ANVIL_RPC_URL; +const ARTIFACTS = join(import.meta.dirname, '../../../contracts/risk-core/out'); +// anvil's fifth default account: each anvil suite deploys and relays from its own +const DEPLOYER_KEY = '0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a' as Hex; +const INDEX_SIGNER_KEY = keccak256(toHex('numo.future-feeds-v2.e2e.index-signer')); +const FORWARD_SIGNER_KEY = keccak256(toHex('numo.future-feeds-v2.e2e.forward-signer')); + +const adminAbi = parseAbi([ + 'function addSigner(address signer, bool isSigner)', + 'function setHeartbeat(uint64 heartbeat)', + 'function setIndexParams(address indexFeed, uint256 maxDev, uint256 minDev, uint256 narrowing, uint256 window, uint256 gap, uint256 fallbackDelay)', + 'function setMarkBounds(uint256 maxMarkDeviation, uint256 maxMarkDelay)', + 'function setForwardFeed(address forwardFeed, uint256 minMarkInterval)', + 'function setContract(uint64 expiry, uint64 lastTradeTime, uint256 initialMarkPrice) returns (uint96)', +]); +const forwardFeedAbi = parseAbi(['function getForwardPrice(uint64 expiry) view returns (uint256, uint256)']); + +type Series = { base: string; indexUsdc: bigint; contractSize: bigint; carry: bigint }; + +const SERIES: Series[] = [ + { base: 'cNGN', indexUsdc: 724_089_970_981_047n, contractSize: 1_000_000n * ONE, carry: (118n * ONE) / 1000n }, + { base: 'EURC', indexUsdc: 1_162_500_000_000_000_000n, contractSize: 1_000n * ONE, carry: -(15n * ONE) / 1000n }, +]; + +function artifact(file: string, name: string): { abi: Abi; bytecode: Hex } { + const json = JSON.parse(readFileSync(join(ARTIFACTS, file, `${name}.json`), 'utf8')); + return { abi: json.abi, bytecode: json.bytecode.object }; +} + +describe('v2 forward publisher against real contracts on anvil', { skip: !RPC }, () => { + const chain = defineChain({ + id: 31337, + name: 'anvil', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [RPC ?? ''] } }, + }); + const publicClient = createPublicClient({ chain, transport: http(RPC) }); + const deployer = privateKeyToAccount(DEPLOYER_KEY); + const wallet = createWalletClient({ account: deployer, chain, transport: http(RPC) }); + let submitter: Address; + let subAccounts: Address; + + async function deploy(file: string, name: string, args: unknown[] = []): Promise
{ + const { abi, bytecode } = artifact(file, name); + const hash = await wallet.deployContract({ abi, bytecode, args }); + return (await publicClient.waitForTransactionReceipt({ hash })).contractAddress!; + } + + async function send(address: Address, functionName: string, args: unknown[]) { + const hash = await wallet.writeContract({ address, abi: adminAbi, functionName, args } as never); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + assert.equal(receipt.status, 'success', `${functionName} reverted`); + } + + async function advance(seconds: number) { + await publicClient.request({ method: 'evm_increaseTime' as never, params: [seconds] as never }); + await publicClient.request({ method: 'evm_mine' as never, params: [] as never }); + } + + async function head(): Promise { + return (await publicClient.getBlock({ blockTag: 'latest' })).timestamp; + } + + before(async () => { + submitter = await deploy('OracleDataSubmitter.sol', 'OracleDataSubmitter'); + subAccounts = await deploy('SubAccounts.sol', 'SubAccounts', ['Accounts', 'ACC']); + }); + + for (const series of SERIES) { + describe(`${series.base}-`, () => { + const at: Record<'index' | 'forward' | 'future', Address> = {} as never; + let expiry: bigint; + + async function publishIndex() { + const indexChain = createChain({ RPC_URL: RPC!, CHAIN_ID: 31337, RELAYER_KEY: DEPLOYER_KEY, DATA_SUBMITTER: submitter, FEED_SIGNER_KEY: INDEX_SIGNER_KEY }); + const now = await head(); + const update = await signFeedUpdate({ + signer: indexChain.signer, + kind: 'LyraSpotFeed', + feed: at.index, + chainId: 31337, + data: encodeSpotData(series.indexUsdc), + timestamp: now - 1n, + deadline: now + 60n, + }); + await indexChain.submit(encodeManagerData([update])); + } + + function config(overrides: Record = {}) { + return loadForwardConfig({ + RPC_URL: RPC!, + CHAIN_ID: '31337', + RELAYER_KEY: DEPLOYER_KEY, + FORWARD_SIGNER_KEY, + FUTURE_VERSION: 'v2', + BASE_SYMBOL: series.base, + DATA_SUBMITTER: submitter, + INDEX_FEED: at.index, + FORWARD_FEED: at.forward, + FUTURE_ASSET: at.future, + // no engine here: the deficit cover is not exercised + FUTURE_MANAGER: '0x00000000000000000000000000000000000000E9', + FUTURE_SUB_ID: String(expiry), + CARRY: String(series.carry), + TIMESTAMP_SAFETY_SEC: '1', + ...overrides, + }); + } + + async function contract() { + return (await publicClient.readContract({ address: at.future, abi: futureAbi, functionName: 'futureContracts', args: [expiry] })) as { + markPrice: bigint; + lastMarkTime: bigint; + }; + } + + function publisher(cfg = config(), book: RestingOrder[] = [], alerts: string[] = [], key = FORWARD_SIGNER_KEY) { + return new ForwardPublisher(cfg, createChain(cfg, privateKeyToAccount(key)), async (k) => void alerts.push(k), async () => book); + } + + before(async () => { + at.index = await deploy('LyraSpotFeed.sol', 'LyraSpotFeed'); + at.forward = await deploy('LyraForwardFeed.sol', 'LyraForwardFeed', [at.index]); + at.future = await deploy('FutureAsset.sol', 'FutureAsset', [subAccounts, series.contractSize]); + + await send(at.index, 'addSigner', [privateKeyToAccount(INDEX_SIGNER_KEY).address, true]); + await send(at.index, 'setHeartbeat', [86_400n]); + await send(at.forward, 'addSigner', [privateKeyToAccount(FORWARD_SIGNER_KEY).address, true]); + await send(at.forward, 'setHeartbeat', [7_200n]); + + await advance(2); + await publishIndex(); + + const now = await head(); + expiry = now + 30n * 86_400n; + await send(at.future, 'setIndexParams', [at.index, ONE / 10n, ONE / 100n, 14n * 86_400n, 3_600n, 900n, 86_400n]); + await send(at.future, 'setMarkBounds', [ONE / 20n, 600n]); + await send(at.future, 'setForwardFeed', [at.forward, 1n]); + // listed at index / (1 + carry * tau), as deploy-margin-engine-v2.s.sol requires + await send(at.future, 'setContract', [expiry, expiry - 3_600n, discountForward(series.indexUsdc, series.carry, expiry - now)]); + }); + + it('passes the startup units check, which refuses the same contract run as v1', async () => { + await publisher().checkUnits(); + await assert.rejects(() => publisher(config({ FUTURE_VERSION: 'v1', CARRY: '0' })).checkUnits(), /FUTURE_VERSION=v1 does not match/); + }); + + it('signs index + diff the feed accepts, and pullMark reads it as is: the carry forward', async () => { + await advance(2); + const before = await contract(); + assert.equal(await publisher().tick(), 'published'); + + const after = await contract(); + assert.ok(after.lastMarkTime > before.lastMarkTime, 'a new mark landed'); + const expected = discountForward(series.indexUsdc, series.carry, expiry - after.lastMarkTime); + const error = after.markPrice > expected ? after.markPrice - expected : expected - after.markPrice; + // tau moves by the seconds between the publisher's head and the pull's block + assert.ok(error * 10n ** 6n <= expected, `mark ${after.markPrice} vs carry forward ${expected}`); + // the feed holds the forward itself, in the future's units: below the index for cNGN, above it for EURC + const [forward] = await publicClient.readContract({ address: at.forward, abi: forwardFeedAbi, functionName: 'getForwardPrice', args: [expiry] }); + assert.equal(forward, after.markPrice); + assert.equal(forward < series.indexUsdc, series.carry > 0n, `forward ${forward} vs index ${series.indexUsdc}`); + }); + + it('marks to a book whose sides each fill the depth, valuing a contract at contractSize x price', async () => { + await advance(2); + const fair = discountForward(series.indexUsdc, series.carry, expiry - (await head())); + const step = fair / 1000n; // 10 bps + // contracts (18dp) worth 3,000 USDC at the fair price: over the 2,000 depth, and well under it read as v1's USDC contracts + const contracts = (3_000n * ONE * ONE) / ((series.contractSize * fair) / ONE); + const book: RestingOrder[] = [ + { side: 'buy', price: fair - step, remaining: contracts }, + { side: 'sell', price: fair + 3n * step, remaining: contracts }, + ]; + assert.equal(await publisher(config(), book).tick(), 'published'); + const { markPrice } = await contract(); + const mid = fair + step; + const error = markPrice > mid ? markPrice - mid : mid - markPrice; + assert.ok(error * 10n ** 5n <= mid, `mark ${markPrice} vs book mid ${mid}`); + }); + + it('refuses a forward signed by any other key: the feed rejects it and the mark does not move', async () => { + await advance(2); + const before = await contract(); + const alerts: string[] = []; + const cfg = config({ FORWARD_SIGNER_KEY: INDEX_SIGNER_KEY }); + assert.equal(await publisher(cfg, [], alerts, INDEX_SIGNER_KEY).tick(), 'pull-failed'); + assert.ok(alerts.includes('forward-pull-failed')); + assert.equal((await contract()).lastMarkTime, before.lastMarkTime, 'no mark landed'); + }); + }); + } +}); diff --git a/services/perp-keeper/README.md b/services/perp-keeper/README.md index ca528932..b5d6db4b 100644 --- a/services/perp-keeper/README.md +++ b/services/perp-keeper/README.md @@ -1,7 +1,8 @@ # perp-keeper Liquidation keeper for one manager's accounts: `MANAGER` is the perp SRM (`numo-perp-keeper`, which still -reads it as `SRM`) or the MarginEngine (`numo-engine-keeper`, the cNGN future's accounts). Nothing else is +reads it as `SRM`), the MarginEngine (`numo-engine-keeper`, the cNGN future's accounts) or MarginEngine v2 +(`numo-engine-keeper-v2`, [below](#marginengine-v2)), one instance each. Nothing else is manager-specific: DutchAuction settles an account through its manager before an auction starts and before every bid (`settlePerpsWithIndex`, which on the engine also realises every future's VM), and reads its margin through the manager. The engine's VM pool (`POOL_ACCOUNT`) is never a candidate. Every 15 seconds it: @@ -53,6 +54,15 @@ across its accounts: a bid that would pass it is sized down to the room left, th (`keeper-cngn-over-limit`). Inherited cNGN is alerted (`keeper-cngn-inventory`) and left for an operator to sell on spot or hold, like inherited perp. +## MarginEngine v2 + +v2 is the same keeper with a different env (`contracts/risk-core/scripts/ops/engine-keeper-v2.env`): `MANAGER` v2, +`POOL_ACCOUNT` 37, no `PERP` (v2 lists none: the perp cap, the keeper's inherited perp and `--settle-frozen` are +skipped), `CNGN_SPOT_FEED` the engine's market-1 spot feed (cNGN is priced there, not at a perp index, and a stale +feed holds back only the accounts holding cNGN, as EURC's does), and `FUTURE` a comma-separated list, one per +series (cNGN-30NOV26 now, EURC-30NOV26 later): each future's cap is watched on its own (`future-oi-cap-
`). +`/health` adds `perp`, `futures` and `cngnSpotFeed`. Its own key, funding account and port (9466). + ## EURC collateral (MarginEngine market 2) Collateral is a list: each asset has its own escrow, price source, haircut and inventory limit. An @@ -143,8 +153,8 @@ through `CashAsset.deposit`). Its cash bounds the largest auction it can take in ```bash pnpm test # every decision branch and health alarm BASE_RPC_URL=... ./scripts/e2e.sh # the real stack on an anvil fork: deploy, crash, liquidate -BASE_RPC_URL=... ./scripts/e2e-engine.sh # the live MarginEngine on a fork: an engine account under - # maintenance auctioned and bought into from the engine +BASE_RPC_URL=... ./scripts/e2e-engine.sh # the live MarginEngine and MarginEngine v2 on a fork: an engine account + # under maintenance auctioned and bought into, on each engine ``` The fork test is what found three bugs no unit test could: a start that ran out of gas one block after diff --git a/services/perp-keeper/scripts/e2e-engine.sh b/services/perp-keeper/scripts/e2e-engine.sh index dc6d404f..46f4cf61 100755 --- a/services/perp-keeper/scripts/e2e-engine.sh +++ b/services/perp-keeper/scripts/e2e-engine.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# The keeper against the live MarginEngine on an anvil fork of Base at head (engine-keeper.anvil.test.ts): -# no deploy step, the engine is already there. Needs BASE_RPC_URL. +# The keeper against the live MarginEngine and MarginEngine v2 on an anvil fork of Base at head +# (engine-keeper.anvil.test.ts, engine-keeper-v2.anvil.test.ts): no deploy step, both engines are already there. +# One file at a time: they share the fork. Needs BASE_RPC_URL. set -euo pipefail : "${BASE_RPC_URL:?set BASE_RPC_URL}" PORT="${E2E_ENGINE_PORT:-8605}" @@ -9,4 +10,4 @@ anvil --fork-url "$BASE_RPC_URL" --chain-id 31337 --port "$PORT" --silent & ANVIL=$! trap 'kill $ANVIL 2>/dev/null || true' EXIT for _ in $(seq 1 30); do cast chain-id --rpc-url "http://127.0.0.1:$PORT" >/dev/null 2>&1 && break; sleep 1; done -cd "$HERE" && ENGINE_KEEPER_E2E_RPC_URL="http://127.0.0.1:$PORT" node --import tsx --test src/engine-keeper.anvil.test.ts +cd "$HERE" && ENGINE_KEEPER_E2E_RPC_URL="http://127.0.0.1:$PORT" node --import tsx --test --test-concurrency=1 src/engine-keeper.anvil.test.ts src/engine-keeper-v2.anvil.test.ts diff --git a/services/perp-keeper/src/chain.ts b/services/perp-keeper/src/chain.ts index 4f84a1c9..83ad20da 100644 --- a/services/perp-keeper/src/chain.ts +++ b/services/perp-keeper/src/chain.ts @@ -443,14 +443,16 @@ export function createKeeperChain(config: Config, transportOverride?: Transport) * finish or terminate its auction first. Run until both lists are empty. */ async settleFrozenPositions(dryRun: boolean): Promise<{ settled: bigint[]; underLiquidation: bigint[] }> { - const disabled = await read({ address: config.PERP, abi: perpAbi, functionName: 'isDisabled' }); + const perp = config.PERP; + if (perp === undefined) throw new Error('--settle-frozen needs PERP: this keeper is configured with no perp'); + const disabled = await read({ address: perp, abi: perpAbi, functionName: 'isDisabled' }); if (!disabled) throw new Error('the perp is not disabled: --settle-frozen only runs after the vault freezes it'); const settled: bigint[] = []; const underLiquidation: bigint[] = []; // Every account under the SRM, the keeper's own bid accounts included: they hold positions too. const candidates = [...new Set([...(await this.discoverAccounts()), ...ownAccounts])]; for (const accountId of candidates) { - if ((await balance(accountId, config.PERP)) === 0n) continue; + if ((await balance(accountId, perp)) === 0n) continue; const auction = await read({ address: config.AUCTION, abi: auctionAbi, functionName: 'getAuction', args: [accountId] }); if (auction.ongoing) { underLiquidation.push(accountId); @@ -484,8 +486,10 @@ export function createKeeperChain(config: Config, transportOverride?: Transport) async readHealth(): Promise { const own = [...ownAccounts]; + const perp = config.PERP; const [ownPerp, ownMargins] = await Promise.all([ - Promise.all(own.map((id) => balance(id, config.PERP))), + // an engine with no perp (v2): nothing to inherit + Promise.all(own.map((id) => (perp === undefined ? Promise.resolve(0n) : balance(id, perp)))), Promise.all( own.map((id) => optional(() => @@ -498,13 +502,15 @@ export function createKeeperChain(config: Config, transportOverride?: Transport) const margins = ownMargins[i]; return margins !== null && margins !== undefined && margins[0] < 0n; }); - const future = config.FUTURE; - const [futureTotalPosition, futureTotalPositionCap] = future - ? await Promise.all([ + const futures = await Promise.all( + config.FUTURES.map(async (future) => { + const [totalPosition, totalPositionCap] = await Promise.all([ read({ address: future, abi: perpAbi, functionName: 'totalPosition', args: [config.MANAGER] }), read({ address: future, abi: perpAbi, functionName: 'totalPositionCap', args: [config.MANAGER] }), - ]) - : [0n, 0n]; + ]); + return { future, totalPosition, totalPositionCap }; + }), + ); const [securityModuleCash, totalInsolventMM, cashExchangeRate, temporaryWithdrawFeeEnabled, keeperCash, keeperEthWei, totalPosition, totalPositionCap, keeperCollateral] = await Promise.all([ balance(config.SECURITY_MODULE_ACCOUNT, config.CASH), @@ -513,8 +519,8 @@ export function createKeeperChain(config: Config, transportOverride?: Transport) read({ address: config.CASH, abi: cashAbi, functionName: 'temporaryWithdrawFeeEnabled' }), balance(config.KEEPER_ACCOUNT, config.CASH), ethBalance(keeper.address), - read({ address: config.PERP, abi: perpAbi, functionName: 'totalPosition', args: [config.MANAGER] }), - read({ address: config.PERP, abi: perpAbi, functionName: 'totalPositionCap', args: [config.MANAGER] }), + perp === undefined ? Promise.resolve(0n) : read({ address: perp, abi: perpAbi, functionName: 'totalPosition', args: [config.MANAGER] }), + perp === undefined ? Promise.resolve(0n) : read({ address: perp, abi: perpAbi, functionName: 'totalPositionCap', args: [config.MANAGER] }), this.keeperCollateral(), ]); return { @@ -529,8 +535,7 @@ export function createKeeperChain(config: Config, transportOverride?: Transport) keeperAccountsUnderMargin, totalPosition, totalPositionCap, - futureTotalPosition, - futureTotalPositionCap, + futures, }; }, diff --git a/services/perp-keeper/src/config.ts b/services/perp-keeper/src/config.ts index c4d03d31..aa62609d 100644 --- a/services/perp-keeper/src/config.ts +++ b/services/perp-keeper/src/config.ts @@ -38,9 +38,20 @@ const envSchema = z.object({ SRM: address.optional(), AUCTION: address, CASH: address, - PERP: address, - /** The cNGN future, under the MarginEngine: its cap is watched in /health alongside the perp's. */ - FUTURE: address.optional(), + /** + * The perp: its cap and the keeper's inherited perp are watched, and with no CNGN_SPOT_FEED its index + * prices cNGN. Unset on an engine with no perp (MarginEngine v2): nothing perp is read. + */ + PERP: address.optional(), + /** + * The engine's dated futures, comma-separated (v1: USDC-cNGN-30NOV26; v2: cNGN-30NOV26, and EURC-30NOV26 + * once listed): each one's cap is watched in /health alongside the perp's. A series is only a line here. + */ + FUTURE: z + .string() + .optional() + .transform((value) => (value ?? '').split(',').map((part) => part.trim()).filter((part) => part !== '')) + .pipe(z.array(address)), SECURITY_MODULE_ACCOUNT: z.coerce.bigint(), /** The MarginEngine's VM pool (MARGIN_ENGINE.json poolAccount): never auctioned, never a candidate. */ POOL_ACCOUNT: z.coerce.bigint().optional(), @@ -81,6 +92,12 @@ const envSchema = z.object({ * does hold cNGN is still correct, just priced without the haircut below. */ CNGN_ESCROW: address.optional(), + /** + * The engine's cNGN spot feed (getMarketFeeds(1)[0], USDC per cNGN). Set, it prices cNGN instead of the + * perp index, as on an engine with no perp (MarginEngine v2); a stale feed then holds back only accounts + * holding cNGN, as EURC's does. + */ + CNGN_SPOT_FEED: address.optional(), /** * Haircut the keeper takes on cNGN it is paid in, in bps of its index value: it bids an insolvent * auction once the payout covers the deficit plus this much of the cNGN, and reads a solvent @@ -122,21 +139,23 @@ const envSchema = z.object({ }); type Parsed = z.infer; -export type Config = Omit & { +export type Config = Omit & { MANAGER: Parsed['SUB_ACCOUNTS']; + /** FUTURE, as a list: none, one, or one per series. */ + FUTURES: Parsed['FUTURE']; /** Every base collateral the keeper reads, prices and limits, from the *_ESCROW settings; empty for cash only. */ COLLATERAL: CollateralConfig[]; }; export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { - const { SRM, MANAGER, ...rest } = envSchema.parse(env); + const { SRM, MANAGER, FUTURE, ...rest } = envSchema.parse(env); if (MANAGER && SRM && MANAGER !== SRM) throw new Error(`MANAGER ${MANAGER} and SRM ${SRM} disagree: set one`); const manager = MANAGER ?? SRM; if (!manager) throw new Error('MANAGER (or SRM) is required: the manager whose accounts this keeper liquidates'); - return { ...rest, MANAGER: manager, COLLATERAL: collateral(rest) }; + return { ...rest, MANAGER: manager, FUTURES: FUTURE, COLLATERAL: collateral(rest) }; } -function collateral(env: Omit): CollateralConfig[] { +function collateral(env: Omit): CollateralConfig[] { // Half an EURC setup is refused rather than run: an escrow without a price would bid on EURC // portfolios with no haircut, and a feed without an escrow reads nothing. if ((env.EURC_ESCROW === undefined) !== (env.EURC_SPOT_FEED === undefined)) { @@ -150,10 +169,17 @@ function collateral(env: Omit): CollateralConfig[] { } const assets: CollateralConfig[] = []; if (env.CNGN_ESCROW !== undefined) { + // the engine's spot feed when set (v2 has no perp), else the perp index, as the perp SRM and v1 engine price it + const price = env.CNGN_SPOT_FEED !== undefined + ? { kind: 'spotFeed' as const, address: env.CNGN_SPOT_FEED } + : env.PERP !== undefined + ? { kind: 'perpIndex' as const, address: env.PERP } + : null; + if (price === null) throw new Error('CNGN_ESCROW needs a price: CNGN_SPOT_FEED, or PERP for its index'); assets.push({ asset: 'cNGN', escrow: env.CNGN_ESCROW, - price: { kind: 'perpIndex', address: env.PERP }, + price, haircutBps: env.CNGN_HAIRCUT_BPS, maxInventory: env.MAX_CNGN_INVENTORY, }); diff --git a/services/perp-keeper/src/decide.test.ts b/services/perp-keeper/src/decide.test.ts index 97fb9888..904973b0 100644 --- a/services/perp-keeper/src/decide.test.ts +++ b/services/perp-keeper/src/decide.test.ts @@ -364,8 +364,7 @@ describe('assessHealth', () => { keeperAccountsUnderMargin: [], totalPosition: 0n, totalPositionCap: 50_000_000n * E18, - futureTotalPosition: 0n, - futureTotalPositionCap: 0n, + futures: [], }; const healthRules = { minSecurityModuleCash: 1_000n * E18, minKeeperCash: 1_000n * E18, minKeeperEthWei: E18 / 100n, capWarnBps: 8_000n, maxInventory: { cNGN: 10_000_000n * E18 } }; @@ -416,6 +415,17 @@ describe('assessHealth', () => { assert.deepEqual(keys, ['socialized-loss', 'withdraw-fee', 'sm-short', 'keeper-inventory', 'oi-cap']); }); + it('watches each future\'s cap on its own: one per series, keyed by its address', () => { + const futures = [ + { future: '0x00000000000000000000000000000000000000F1', totalPosition: 25n * E18, totalPositionCap: 30n * E18 }, + { future: '0x00000000000000000000000000000000000000F2', totalPosition: 1n * E18, totalPositionCap: 30n * E18 }, + { future: '0x00000000000000000000000000000000000000F3', totalPosition: 0n, totalPositionCap: 0n }, + ]; + assert.deepEqual(assessHealth({ ...healthy, futures }, healthRules), [ + { key: 'future-oi-cap-0x00000000000000000000000000000000000000f1', message: 'future 0x00000000000000000000000000000000000000F1 total position 25.0000 is 8333bps of its cap' }, + ]); + }); + it('formats 18dp amounts for humans', () => { assert.equal(fmt(1_234_500_000_000_000_000_000n), '1234.5000'); assert.equal(fmt(-E18 / 2n), '-0.5000'); diff --git a/services/perp-keeper/src/engine-keeper-v2.anvil.test.ts b/services/perp-keeper/src/engine-keeper-v2.anvil.test.ts new file mode 100644 index 00000000..5e856f50 --- /dev/null +++ b/services/perp-keeper/src/engine-keeper-v2.anvil.test.ts @@ -0,0 +1,176 @@ +/** + * The keeper as numo-engine-keeper-v2: MANAGER is the live MarginEngine v2, on an anvil fork of Base at head (v2 + * deployed and set up 2026-10-27, closed until `enable`). The same run as engine-keeper.anvil.test.ts on v1, configured + * the v2 way (engine-keeper-v2.env): no PERP, cNGN priced at the engine's spot feed, FUTURE the v2 cNGN-30NOV26 + * (USDC per cNGN, a 1,000,000 cNGN contract, VM in USDC), the pool #37. The vault opens the future's cap (the + * future half of `enable`), bob sells alice one contract, the vault raises the futures margin so alice is under + * maintenance but solvent, and `runOnce` starts the auction and buys into it from the keeper's account under v2. + * + * anvil --fork-url $BASE_RPC_URL --chain-id 31337 --port 8605 & + * ENGINE_KEEPER_E2E_RPC_URL=http://127.0.0.1:8605 pnpm test + * + * Skipped without the env. + */ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; + +import { createPublicClient, createWalletClient, defineChain, encodeAbiParameters, http, keccak256, pad, parseAbi, toHex, type Address } from 'viem'; +import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts'; + +import { createKeeperChain, withGasHeadroom } from './chain.js'; +import { loadConfig } from './config.js'; +import { runOnce } from './keeper.js'; + +const RPC = process.env.ENGINE_KEEPER_E2E_RPC_URL; + +// risk-core deployments/8453/MARGIN_ENGINE_V2.json +const ENGINE = '0x0705219a1594d5Fc5dC2573da4082cAf3e1B0950' as Address; +const FUTURE = '0xcACEe66ad41fD1472a47D7D5Fe372FFbFac9AA75' as Address; +const SUB_ID = 1796047200n; +const POOL_ACCOUNT = 37n; +const CASH = '0xA74E49b4Ed7cb176bc02ef4D8a1A3240C9aD4272' as Address; +const CNGN_ESCROW = '0x37c976bb5d4887a714ef19AF6B83e34fe2f37c98' as Address; +const CNGN_SPOT_FEED = '0xFaC420d160C7c219A72DC676971670980bAC20a5' as Address; +const AUCTION = '0xd07176B006cE8246e930d37b8bc44B1154f8c4cE' as Address; +const SECURITY_MODULE_ACCOUNT = 21n; +const SUB_ACCOUNTS = '0x7019244E25FA416e6Ca2ed2F3cA25277aef72843' as Address; +const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as Address; +const VAULT = '0x1dcA42ab54Bd3862853A821F84B29BF65245F435' as Address; +const USDC_BALANCE_SLOT = 9n; +const E18 = 10n ** 18n; + +const key = (label: string) => keccak256(toHex(`numo.engine-keeper-v2.e2e.${label}`)); +const KEYS = { alice: key('alice'), bob: key('bob'), keeper: key('keeper'), operator: key('operator') } as const; + +const abi = parseAbi([ + 'function setTotalPositionCap(address manager, uint256 cap)', + 'function setFutureMarginRequirements(uint256 marketId, uint256 mmReq, uint256 imReq)', + 'function futureMarginRequirements(uint256 marketId) view returns (uint256 mmReq, uint256 imReq)', + 'function futureContracts(uint256 subId) view returns (bool listed, bool settled, uint64 expiry, uint64 lastTradeTime, uint64 lastMarkTime, uint256 markPrice, int256 cumulativeVMPerContract)', + 'function createAccount(address owner, address manager) returns (uint256)', + 'function createAccountWithApproval(address owner, address spender, address manager) returns (uint256)', + 'function lastAccountId() view returns (uint256)', + 'function approve(address spender, uint256 amount) returns (bool)', + 'function deposit(uint256 recipientAccount, uint256 stableAmount)', + 'function submitTransfers((uint256 fromAcc, uint256 toAcc, address asset, uint256 subId, int256 amount, bytes32 assetData)[] transfers, bytes managerData) returns (uint256)', + 'function getBalance(uint256 accountId, address asset, uint256 subId) view returns (int256)', + 'function getMargin(uint256 accountId, bool isInitial) view returns (int256)', + 'function getAuction(uint256 accountId) view returns ((uint256 accountId, uint256 scenarioId, bool insolvent, bool ongoing, uint256 cachedMM, uint256 startTime, uint256 reservedCash))', +]); + +describe('the keeper on MarginEngine v2 (numo-engine-keeper-v2), on a fork of Base', { skip: !RPC }, () => { + const chain = defineChain({ id: 31337, name: 'anvil-fork', nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { default: { http: [RPC ?? ''] } } }); + const client = createPublicClient({ chain, transport: http(RPC) }); + const accounts: Record = {}; + let startBlock = 0n; + + async function rpc(method: string, params: unknown[]) { + return client.request({ method: method as never, params: params as never }); + } + + async function write(from: PrivateKeyAccount | Address, address: Address, functionName: string, args: unknown[]) { + await client.simulateContract({ address, abi, functionName: functionName as never, args: args as never, account: from as never }); + const estimate = await client.estimateContractGas({ address, abi, functionName: functionName as never, args: args as never, account: from as never }); + const hash = await createWalletClient({ account: from, chain, transport: http(RPC) }).writeContract({ + address, abi, functionName: functionName as never, args: args as never, chain, account: from as never, gas: withGasHeadroom(estimate), + }); + const receipt = await client.waitForTransactionReceipt({ hash }); + assert.equal(receipt.status, 'success', `${functionName} reverted`); + } + + async function giveUsdc(to: Address, amount: bigint) { + const slot = keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [to, USDC_BALANCE_SLOT])); + await rpc('anvil_setStorageAt', [USDC, slot, pad(toHex(amount), { size: 32 })]); + } + + async function newAccount(owner: PrivateKeyAccount, approvedTo?: Address): Promise { + if (approvedTo) await write(owner, SUB_ACCOUNTS, 'createAccountWithApproval', [owner.address, approvedTo, ENGINE]); + else await write(owner, SUB_ACCOUNTS, 'createAccount', [owner.address, ENGINE]); + return (await client.readContract({ address: SUB_ACCOUNTS, abi, functionName: 'lastAccountId' })) as bigint; + } + + async function depositCash(owner: PrivateKeyAccount, accountId: bigint, usdc: bigint) { + await giveUsdc(owner.address, usdc); + await write(owner, USDC, 'approve', [CASH, usdc]); + await write(owner, CASH, 'deposit', [accountId, usdc]); + } + + const futureOf = async (id: bigint) => + (await client.readContract({ address: SUB_ACCOUNTS, abi, functionName: 'getBalance', args: [id, FUTURE, SUB_ID] })) as bigint; + const auctionOf = async (id: bigint) => + (await client.readContract({ address: AUCTION, abi, functionName: 'getAuction', args: [id] })) as { ongoing: boolean; insolvent: boolean }; + + function keeperConfig() { + return loadConfig({ + RPC_URL: RPC!, + CHAIN_ID: '31337', + KEEPER_KEY: KEYS.keeper, + KEEPER_ACCOUNT: accounts.keeper!.toString(), + DRY_RUN: 'false', + SUB_ACCOUNTS, + MANAGER: ENGINE, + AUCTION, + CASH, + FUTURE, + CNGN_ESCROW, + CNGN_SPOT_FEED, + POOL_ACCOUNT: POOL_ACCOUNT.toString(), + SECURITY_MODULE_ACCOUNT: SECURITY_MODULE_ACCOUNT.toString(), + START_BLOCK: startBlock.toString(), + SCAN_CONFIRMATIONS: '0', + }); + } + + const alerts: string[] = []; + const alert = async (k: string, message: string) => { + alerts.push(k); + console.log(`[alert] ${k}: ${message}`); + }; + + before(async () => { + await rpc('anvil_impersonateAccount', [VAULT]); + for (const address of [VAULT, ...Object.values(KEYS).map((k) => privateKeyToAccount(k).address)]) { + await rpc('anvil_setBalance', [address, toHex(E18)]); + } + startBlock = await client.getBlockNumber(); + // The future half of the enable batch: its cap under the engine. + await write(VAULT, FUTURE, 'setTotalPositionCap', [ENGINE, 30n * E18]); + + const operator = privateKeyToAccount(KEYS.operator); + const alice = privateKeyToAccount(KEYS.alice); + const bob = privateKeyToAccount(KEYS.bob); + const keeper = privateKeyToAccount(KEYS.keeper); + accounts.alice = await newAccount(alice, operator.address); + accounts.bob = await newAccount(bob, operator.address); + accounts.keeper = await newAccount(keeper); + await depositCash(alice, accounts.alice, 300n * 10n ** 6n); // 1 contract (~$720 at 0.00072) at 25% IM is ~180 USDC + await depositCash(bob, accounts.bob, 5_000n * 10n ** 6n); + await depositCash(keeper, accounts.keeper, 5_000n * 10n ** 6n); + + // Bob sells alice one contract at the mark. + const contract = (await client.readContract({ address: FUTURE, abi, functionName: 'futureContracts', args: [SUB_ID] })) as readonly unknown[]; + const mark = contract[5] as bigint; + await write(operator, SUB_ACCOUNTS, 'submitTransfers', [ + [{ fromAcc: accounts.bob, toAcc: accounts.alice, asset: FUTURE, subId: SUB_ID, amount: E18, assetData: pad(toHex(mark), { size: 32 }) }], + '0x', + ]); + // The vault raises the futures margin: alice's maintenance is now half of ~$720, over her 300. + await write(VAULT, ENGINE, 'setFutureMarginRequirements', [1n, E18 / 2n, (E18 * 6n) / 10n]); + assert.ok(((await client.readContract({ address: ENGINE, abi, functionName: 'getMargin', args: [accounts.alice, false] })) as bigint) < 0n, 'alice under maintenance'); + }); + + it('starts a solvent auction on the engine account under maintenance, and on nobody else', async () => { + const actions = await runOnce(keeperConfig(), createKeeperChain(keeperConfig()), alert); + const started = actions.filter((a) => a.kind === 'start').map((a) => a.accountId); + assert.deepEqual(started, [accounts.alice!], `started ${started}`); + assert.ok(!started.includes(POOL_ACCOUNT), 'never the VM pool'); + const auction = await auctionOf(accounts.alice!); + assert.ok(auction.ongoing && !auction.insolvent, 'a solvent auction on alice'); + }); + + it('buys into it from a bid account under the engine, and the funding account stays cash only', async () => { + await runOnce(keeperConfig(), createKeeperChain(keeperConfig()), alert); + assert.ok((await futureOf(accounts.alice!)) < E18, 'alice partly liquidated'); + assert.equal(await futureOf(accounts.keeper!), 0n, 'the funding account holds no future: the position went to a bid account'); + }); +}); diff --git a/services/perp-keeper/src/engine-keeper.test.ts b/services/perp-keeper/src/engine-keeper.test.ts index f9ee7f97..7e9fa290 100644 --- a/services/perp-keeper/src/engine-keeper.test.ts +++ b/services/perp-keeper/src/engine-keeper.test.ts @@ -78,10 +78,55 @@ describe('collateral configuration', () => { }); }); +describe('an engine with no perp (MarginEngine v2)', () => { + const V2 = '0x0705219a1594d5Fc5dC2573da4082cAf3e1B0950'; + const CNGN_SPOT_FEED = '0xFaC420d160C7c219A72DC676971670980bAC20a5'; + const CNGN_FUTURE = '0xcACEe66ad41fD1472a47D7D5Fe372FFbFac9AA75'; + const EURC_FUTURE = getAddress('0x00000000000000000000000000000000000e0f02'); // a placeholder: EURC-30NOV26 is not deployed + const { PERP: _perp, ...noPerp } = base; + + test('takes no PERP, prices cNGN from the engine spot feed, and one FUTURE per series', () => { + const config = loadConfig({ ...noPerp, MANAGER: V2, CNGN_ESCROW, CNGN_SPOT_FEED, FUTURE: `${CNGN_FUTURE}, ${EURC_FUTURE}` }); + assert.equal(config.PERP, undefined); + assert.deepEqual(config.FUTURES, [CNGN_FUTURE, EURC_FUTURE]); + assert.deepEqual(config.COLLATERAL[0]!.price, { kind: 'spotFeed', address: CNGN_SPOT_FEED }); + }); + + test('refuses cNGN with nothing to price it by', () => { + assert.throws(() => loadConfig({ ...noPerp, MANAGER: V2, CNGN_ESCROW }), /CNGN_ESCROW needs a price/); + }); + + test('a v1 config reads as it did: one FUTURE, cNGN at the perp index', () => { + const config = loadConfig({ ...base, MANAGER: ENGINE, CNGN_ESCROW, FUTURE: CNGN_FUTURE }); + assert.deepEqual(config.FUTURES, [CNGN_FUTURE]); + assert.deepEqual(config.COLLATERAL[0]!.price, { kind: 'perpIndex', address: base.PERP }); + assert.deepEqual(loadConfig({ ...base, MANAGER: ENGINE }).FUTURES, []); + }); + + test('reads its health with no perp, each future\'s cap on its own, and never the perp index', async () => { + const { transport, indexReads } = staleEurcChain(V2); + const config = loadConfig({ + ...noPerp, MANAGER: V2, CHAIN_ID: '31337', START_BLOCK: '900', CNGN_ESCROW, CNGN_SPOT_FEED, EURC_ESCROW, EURC_SPOT_FEED, + FUTURE: `${CNGN_FUTURE},${EURC_FUTURE}`, + }); + const chain = createKeeperChain(config, transport); + const health = await chain.readHealth(); + assert.equal(health.totalPosition, 0n); + assert.equal(health.totalPositionCap, 0n); + assert.equal(health.keeperPerpPosition, 0n); + assert.deepEqual(health.futures.map((f) => f.future), [CNGN_FUTURE, EURC_FUTURE]); + await assert.rejects(() => chain.settleFrozenPositions(true), /needs PERP/); + // a pass: #52 holds cNGN, whose feed is stale here, so it is held back like an EURC holder, not priced off a perp + const actions = await runOnce(config, chain, async () => {}); + assert.deepEqual(actions.find((a) => a.accountId === 52n), { kind: 'none', accountId: 52n, note: 'cannot price cNGN (feed unreadable, stale?): no bid' }); + assert.equal(indexReads.length, 0, 'the perp index is never read'); + }); +}); + // A chain where the EURC feed is stale: getSpot reverts, and so does the engine's margin read for the one // account that holds EURC (#50). #51 holds nothing and is under maintenance; #52 holds cNGN. The keeper's // funding account #40 holds 300 EURC from an earlier liquidation. -function staleEurcChain() { +function staleEurcChain(manager: Address = ENGINE) { const keeperAddress = privateKeyToAccount(base.KEEPER_KEY).address; const abi = parseAbi([ 'function getBalance(uint256 accountId, address asset, uint256 subId) view returns (int256)', @@ -100,6 +145,7 @@ function staleEurcChain() { ]); const E18 = 10n ** 18n; const spotReads: string[] = []; + const indexReads: string[] = []; const balances: Record = { [`50:${EURC_ESCROW}`]: 1_000n * E18, [`52:${CNGN_ESCROW}`]: 1_000_000n * E18, [`40:${EURC_ESCROW}`]: 300n * E18 }; const created = [50n, 51n, 52n].map((accountId) => ({ address: base.SUB_ACCOUNTS, data: '0x', blockNumber: '0x64', transactionHash: keccak256('0x02'), transactionIndex: '0x0', @@ -107,7 +153,7 @@ function staleEurcChain() { topics: encodeEventTopics({ abi: parseAbi(['event AccountCreated(address indexed owner, uint256 indexed accountId, address indexed manager)']), eventName: 'AccountCreated', - args: { owner: '0x0000000000000000000000000000000000000001', accountId, manager: ENGINE }, + args: { owner: '0x0000000000000000000000000000000000000001', accountId, manager }, }), })); const result = (functionName: string, value: unknown) => encodeFunctionResult({ abi, functionName: functionName as never, result: value as never }); @@ -117,7 +163,7 @@ function staleEurcChain() { switch (functionName) { case 'getBalance': return result(functionName, balances[`${a[0]}:${getAddress(a[1] as string)}`] ?? 0n); case 'ownerOf': return result(functionName, keeperAddress); - case 'manager': return result(functionName, ENGINE); + case 'manager': return result(functionName, manager); case 'getMarginAndMarkToMarket': if (a[0] === 50n) throw new Error('execution reverted: CLF_DataTooOld()'); return result(functionName, a[0] === 51n ? [-1n * E18, -2n * E18, 500n * E18] : [100n * E18, 50n * E18, 1_000n * E18]); @@ -126,7 +172,7 @@ function staleEurcChain() { case 'totalInsolventMM': return result(functionName, 0n); case 'getCashToStableExchangeRate': return result(functionName, E18); case 'temporaryWithdrawFeeEnabled': return result(functionName, false); - case 'getIndexPrice': return result(functionName, [72n * E18 / 100_000n, E18]); + case 'getIndexPrice': indexReads.push(to); return result(functionName, [72n * E18 / 100_000n, E18]); case 'totalPosition': return result(functionName, 0n); case 'totalPositionCap': return result(functionName, 50_000_000n * E18); case 'getSpot': @@ -151,7 +197,7 @@ function staleEurcChain() { } }; // No retries: each revert is one logical read, so spotReads counts reads, not transport attempts. - return { transport: custom({ request } as never, { retryCount: 0 }), spotReads }; + return { transport: custom({ request } as never, { retryCount: 0 }), spotReads, indexReads }; } describe('a stale EURC feed', () => { diff --git a/services/perp-keeper/src/health.ts b/services/perp-keeper/src/health.ts index f559723e..0432e192 100644 --- a/services/perp-keeper/src/health.ts +++ b/services/perp-keeper/src/health.ts @@ -24,9 +24,8 @@ export type StackHealth = { /** PerpAsset totalPosition for the SRM against its cap: both count |long| + |short|. */ totalPosition: bigint; totalPositionCap: bigint; - /** FutureAsset totalPosition under MANAGER against its cap (0 and 0 when no FUTURE is configured). */ - futureTotalPosition: bigint; - futureTotalPositionCap: bigint; + /** Each configured FutureAsset's totalPosition under MANAGER against its cap: one per series (none without FUTURE). */ + futures: { future: string; totalPosition: bigint; totalPositionCap: bigint }[]; }; export type HealthRules = { @@ -109,11 +108,13 @@ export function assessHealth(health: StackHealth, rules: HealthRules): HealthAle message: `perp total position ${fmt(health.totalPosition)} is ${(health.totalPosition * 10_000n) / health.totalPositionCap}bps of its cap`, }); } - if (health.futureTotalPositionCap > 0n && health.futureTotalPosition * 10_000n >= health.futureTotalPositionCap * rules.capWarnBps) { - alerts.push({ - key: 'future-oi-cap', - message: `future total position ${fmt(health.futureTotalPosition)} is ${(health.futureTotalPosition * 10_000n) / health.futureTotalPositionCap}bps of its cap`, - }); + for (const { future, totalPosition, totalPositionCap } of health.futures) { + if (totalPositionCap > 0n && totalPosition * 10_000n >= totalPositionCap * rules.capWarnBps) { + alerts.push({ + key: `future-oi-cap-${future.toLowerCase()}`, + message: `future ${future} total position ${fmt(totalPosition)} is ${(totalPosition * 10_000n) / totalPositionCap}bps of its cap`, + }); + } } return alerts; } diff --git a/services/perp-keeper/src/main.ts b/services/perp-keeper/src/main.ts index 90d33256..f7f3b43c 100644 --- a/services/perp-keeper/src/main.ts +++ b/services/perp-keeper/src/main.ts @@ -8,7 +8,8 @@ import { fmt } from './health.js'; import { runOnce, type PassSummary } from './keeper.js'; /** - * USDCcNGN-PERP liquidation keeper: watches every account under the perp SRM, starts auctions on + * Liquidation keeper for one manager (the perp SRM, the MarginEngine, or MarginEngine v2, each its own + * instance): watches every account under it, starts auctions on * those below maintenance margin, bids on them (solvent at a discount, insolvent for the security * module's payout), converts and terminates auctions as they run out or recover, and alerts on the * stack's solvency. Dry run (simulate, never send) until DRY_RUN=false. @@ -26,6 +27,7 @@ async function main() { } console.log(`[keeper] manager=${config.MANAGER} eoa=${chain.keeper.address} account=${config.KEEPER_ACCOUNT} dryRun=${config.DRY_RUN}`); + console.log(`[keeper] perp=${config.PERP ?? 'none'} futures=${config.FUTURES.join(',') || 'none'}`); // What it can be paid in, and how it prices each: a missing line here is a collateral bid unhaircut. console.log(`[keeper] collateral: ${config.COLLATERAL.length === 0 ? 'none (cash only)' : config.COLLATERAL.map(describeCollateral).join('; ')}`); @@ -77,8 +79,12 @@ async function main() { keeperAddress: chain.keeper.address, maxBidUsd: config.MAX_BID_USD?.toString() ?? null, pollIntervalMs: config.POLL_INTERVAL_MS, + // The perp and futures it watches (null / none: an engine without them). + perp: config.PERP ?? null, + futures: config.FUTURES, // The cNGN escrow this keeper bids for (null: cash-only); the batch-5 gate reads it. cngnEscrow: config.CNGN_ESCROW ?? null, + cngnSpotFeed: config.CNGN_SPOT_FEED ?? null, cngnHaircutBps: config.CNGN_HAIRCUT_BPS.toString(), maxCngnInventory: config.MAX_CNGN_INVENTORY?.toString() ?? null, // The EURC escrow and spot feed (null: not configured), for the EURC enable gate to read.