diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 7a74c37..5697804 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -51,44 +51,22 @@ jobs: working-directory: ./keeper run: pnpm install --frozen-lockfile - # pnpm git path deps must keep `path: /contracts/abi` in the lockfile - # resolution. If that field is dropped (integrity-only), a cold CI store - # unpacks the whole futures-marketplace repo and TypeCheck fails with - # TS2307 on futures-marketplace-abi/Futures.ts. - - name: Verify futures ABI package layout + # Keep the generated HashPowerFutures artifact vendored with the keeper + # until the upstream ABI package publishes the renamed module. + - name: Verify vendored HashPowerFutures ABI working-directory: ./keeper run: | set -euo pipefail - pkg="node_modules/futures-marketplace-abi" - if [[ ! -e "$pkg" ]]; then - echo "::error::$pkg is missing after pnpm install" - ls -la node_modules | head -40 + abi="src/abi/HashPowerFutures.ts" + if [[ ! -f "$abi" ]]; then + echo "::error::$abi is missing" exit 1 fi - if [[ ! -f "$pkg/Futures.ts" ]]; then - echo "::error::$pkg/Futures.ts missing; lockfile likely dropped path:/contracts/abi (full repo unpacked)" - ls -la "$pkg" | head -40 - if [[ -f "$pkg/contracts/abi/Futures.ts" ]]; then - echo "::error::Found Futures.ts under $pkg/contracts/abi — restore path: /contracts/abi in pnpm-lock.yaml resolution" - fi + if ! grep -q 'export const HashPowerFuturesAbi' "$abi"; then + echo "::error::$abi does not export HashPowerFuturesAbi" exit 1 fi - if [[ ! -f "$pkg/package.json" ]]; then - echo "::error::$pkg/package.json missing" - ls -la "$pkg" | head -40 - exit 1 - fi - if grep -q '_pnpmPlaceholder' "$pkg/package.json"; then - echo "::error::$pkg/package.json is a pnpm placeholder; lockfile likely lost path:/contracts/abi" - exit 1 - fi - if ! grep -q '"name": "futures-marketplace-abi"' "$pkg/package.json"; then - echo "::error::$pkg/package.json is not the abi package manifest" - cat "$pkg/package.json" - exit 1 - fi - echo "futures-marketplace-abi layout ok:" - ls -la "$pkg" | head -20 + echo "Vendored HashPowerFutures ABI present" - name: Lint working-directory: ./keeper @@ -110,18 +88,18 @@ jobs: uses: actions/checkout@v5 with: repository: Lumerin-protocol/derivatives-marketplace - # createOrder with explicit TIF + getRiskView; implements this branch's - # engine interface (PR #90). - ref: 8df78601bbbad049cee962509f9c9e8b9d9b2503 + # Signed netEntryValue on the position tuple; implements this branch's + # engine interface (PR #97). + ref: 316be14c529071df4d2ff71321ab4a6fbedcfaaf path: perps - name: Checkout futures-marketplace uses: actions/checkout@v5 with: repository: Lumerin-protocol/futures-marketplace - # createOrder with explicit TIF + getRiskView; implements this branch's - # engine interface (PR #247). - ref: 16d026960fa15d0fb73822e6750ae8e3f14a4c6d + # HashPowerFutures rename and per-delivery order reads; implements this + # branch's engine interface (PR #258). + ref: 5a543ae0c59c651790bc99a8550da9a2ffd30c2b path: futures-marketplace - name: Install perps contracts dependencies diff --git a/.github/workflows/market-maker-tests.yml b/.github/workflows/market-maker-tests.yml index 0ac2149..d9d1611 100644 --- a/.github/workflows/market-maker-tests.yml +++ b/.github/workflows/market-maker-tests.yml @@ -40,17 +40,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: package_json_file: market-maker/package.json - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: "22" + node-version: "24" cache: "pnpm" cache-dependency-path: market-maker/pnpm-lock.yaml @@ -63,8 +63,8 @@ jobs: - name: Typecheck run: pnpm typecheck - # Skip the pretest (which calls sync-abi.sh, requires the perps and - # futures-marketplace contract repos to be checked out side-by-side). - # Tests cover core/ and apps/ logic; ABI shape is validated at runtime. + # Must go through the package script: it supplies `--import=amaro/strip`, + # without which node refuses to strip types from the contract ABIs that + # ship as .ts inside node_modules. - name: Run tests - run: node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts' + run: pnpm test diff --git a/contracts/abi/ContractErrors.json b/contracts/abi/ContractErrors.json index 325dbdd..d140f22 100644 --- a/contracts/abi/ContractErrors.json +++ b/contracts/abi/ContractErrors.json @@ -4,6 +4,11 @@ "name": "NoPoints", "type": "error" }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, { "inputs": [ { diff --git a/contracts/abi/ContractErrors.ts b/contracts/abi/ContractErrors.ts index b0b170f..91a5946 100644 --- a/contracts/abi/ContractErrors.ts +++ b/contracts/abi/ContractErrors.ts @@ -4,6 +4,11 @@ export const contractErrors = [ "name": "NoPoints", "type": "error" }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, { "inputs": [ { diff --git a/contracts/abi/IPortfolioMarginEngine.json b/contracts/abi/IPortfolioMarginEngine.json index bb87587..ecb685b 100644 --- a/contracts/abi/IPortfolioMarginEngine.json +++ b/contracts/abi/IPortfolioMarginEngine.json @@ -37,6 +37,30 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "computePortfolioMargins", + "outputs": [ + { + "internalType": "uint256", + "name": "im", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "mm", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -69,6 +93,25 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "isLiquidatable", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/contracts/abi/IPortfolioMarginEngine.ts b/contracts/abi/IPortfolioMarginEngine.ts index 2b93977..138323c 100644 --- a/contracts/abi/IPortfolioMarginEngine.ts +++ b/contracts/abi/IPortfolioMarginEngine.ts @@ -37,6 +37,30 @@ export const IPortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "computePortfolioMargins", + "outputs": [ + { + "internalType": "uint256", + "name": "im", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "mm", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -69,6 +93,25 @@ export const IPortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "isLiquidatable", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/contracts/abi/PortfolioMarginEngine.json b/contracts/abi/PortfolioMarginEngine.json index 54d4ff2..fc0df67 100644 --- a/contracts/abi/PortfolioMarginEngine.json +++ b/contracts/abi/PortfolioMarginEngine.json @@ -71,6 +71,11 @@ "name": "OracleNotSet", "type": "error" }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, { "inputs": [ { @@ -361,6 +366,30 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "computePortfolioMargins", + "outputs": [ + { + "internalType": "uint256", + "name": "im", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "mm", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "getLinearMarkets", @@ -452,6 +481,25 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "isLiquidatable", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/contracts/abi/PortfolioMarginEngine.ts b/contracts/abi/PortfolioMarginEngine.ts index 3bb317b..baaa180 100644 --- a/contracts/abi/PortfolioMarginEngine.ts +++ b/contracts/abi/PortfolioMarginEngine.ts @@ -71,6 +71,11 @@ export const PortfolioMarginEngineAbi = [ "name": "OracleNotSet", "type": "error" }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, { "inputs": [ { @@ -361,6 +366,30 @@ export const PortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "computePortfolioMargins", + "outputs": [ + { + "internalType": "uint256", + "name": "im", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "mm", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "getLinearMarkets", @@ -452,6 +481,25 @@ export const PortfolioMarginEngineAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "isLiquidatable", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index 6fac367..3bfec98 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -68,7 +68,7 @@ contract PortfolioMarginEngine is using EnumerableSet for EnumerableSet.AddressSet; uint256 private constant MAX_ORACLE_STALENESS = 1 hours; - string public constant VERSION = "2.0.0"; + string public constant VERSION = "2.1.0"; // ── Storage ───────────────────────────────────────────────────────────── @@ -119,6 +119,7 @@ contract PortfolioMarginEngine is error LinearMarketNotRegistered(); error OracleNotSet(); error InvalidOracle(); + error OracleStale(); error VaultMismatch(); /// @dev A dependency did not answer a call the engine depends on: no code at the /// address, or the call reverted. Covers every dependency; which one is bad is @@ -252,6 +253,14 @@ contract PortfolioMarginEngine is return _computeMargin(user, false); } + /// @notice Compute IM and MM from one market/options snapshot and one oracle read. + function computePortfolioMargins(address user) external view returns (uint256 im, uint256 mm) { + MarginInputs memory inputs = _marginInputs(user, _linearAggregate(user)); + uint256 spotPrice = _getSpotPriceWad(); + im = _marginFromInputs(inputs, true, spotPrice); + mm = _marginFromInputs(inputs, false, spotPrice); + } + /// @notice Margin charged against a delta-one resting order's notional (both token /// decimals). /// @dev The IM spot shock is the single knob sizing unmatched linear exposure across @@ -272,6 +281,15 @@ contract PortfolioMarginEngine is return vault.balanceOf(user) >= _computeMargin(user, false); } + /// @notice Whether the account is liquidatable: vault balance below portfolio MM. + /// The exact predicate the venues' liquidation entry points enforce. + /// @dev See {IPortfolioMarginEngine-isLiquidatable}. Deliberately the strict inverse + /// of {isHealthy}; kept as its own entry point because it is the question keepers + /// and venue UIs ask, and `isHealthy` is not part of the venue-facing interface. + function isLiquidatable(address user) external view returns (bool) { + return vault.balanceOf(user) < _computeMargin(user, false); + } + /// @notice Check if user can place an order requiring additionalIM (in token decimals). function canPlaceOrder(address user, uint256 additionalIM) external view returns (bool) { return vault.balanceOf(user) >= _computeMargin(user, true) + additionalIM; @@ -291,12 +309,15 @@ contract PortfolioMarginEngine is /// wanting a per-order gate want `linearOrderMargin` instead. function orderMarginOf(address user) external view returns (uint256) { LinearAggregate memory agg = _linearAggregate(user); - uint256 withOrders = _marginFromAggregate(user, agg, true); + if (agg.buyOrderDelta == 0 && agg.sellOrderDelta == 0 && agg.fillLoss == 0) return 0; + MarginInputs memory inputs = _marginInputs(user, agg); + uint256 spotPrice = _getSpotPriceWad(); + uint256 withOrders = _marginFromInputs(inputs, true, spotPrice); - agg.buyOrderDelta = 0; - agg.sellOrderDelta = 0; - agg.fillLoss = 0; - uint256 withoutOrders = _marginFromAggregate(user, agg, true); + inputs.linear.buyOrderDelta = 0; + inputs.linear.sellOrderDelta = 0; + inputs.linear.fillLoss = 0; + uint256 withoutOrders = _marginFromInputs(inputs, true, spotPrice); return withOrders > withoutOrders ? withOrders - withoutOrders : 0; } @@ -310,13 +331,12 @@ contract PortfolioMarginEngine is /// /// Keyed on delta, not order count, so an order carrying no risk cannot deadlock /// liquidation — an expired futures order still occupies its participant index but - /// contributes nothing here. Short-circuits on the first market with exposure, so - /// the common case costs one `getRiskView`. + /// contributes nothing here. Markets answer this from their order indexes or + /// aggregate caches, without computing position PnL or reading an oracle. function hasRestingOrderDelta(address user) external view returns (bool) { uint256 len = linearMarkets.length(); for (uint256 i = 0; i < len; i++) { - ILinearMarket.RiskView memory account = ILinearMarket(linearMarkets.at(i)).getRiskView(user); - if (account.buyOrderDelta != 0 || account.sellOrderDelta != 0) return true; + if (ILinearMarket(linearMarkets.at(i)).hasRestingOrderDelta(user)) return true; } return false; } @@ -336,37 +356,58 @@ contract PortfolioMarginEngine is uint256 fundingOwed; } + struct MarginInputs { + LinearAggregate linear; + int256 netDelta; + int256 netGamma; + int256 netVega; + uint256 optionsReserved; + } + function _computeMargin(address user, bool isIM) private view returns (uint256) { return _marginFromAggregate(user, _linearAggregate(user), isIM); } - /// @dev Folds options greeks into the linear aggregate and prices it. Split out from - /// `_computeMargin` so `orderMarginOf` can re-price the same aggregate with the - /// order fields zeroed without a second round of external reads. - function _marginFromAggregate(address user, LinearAggregate memory agg, bool isIM) + /// @dev Fold options into an already-collected linear snapshot. + function _marginInputs(address user, LinearAggregate memory agg) private view - returns (uint256) + returns (MarginInputs memory inputs) { - // 1. Options Greeks — WAD-scaled signed delta, unsigned gamma/vega (optional) - int256 netDelta = agg.netDelta; - uint256 netGamma = 0; - uint256 netVega = 0; - uint256 optReservedTokens = 0; + inputs.linear = agg; + inputs.netDelta = agg.netDelta; if (address(optionsEngine) != address(0)) { - (int256 optDelta, uint256 optGamma, uint256 optVega) = optionsEngine.getNetGreeks(user); - netDelta += optDelta; - netGamma = optGamma; - netVega = optVega; - optReservedTokens = M.fromWad(optionsEngine.getOptionsReservedMargin(user), collateralDecimals); + (int256 optDelta, int256 optGamma, int256 optVega) = optionsEngine.getNetGreeks(user); + inputs.netDelta += optDelta; + inputs.netGamma = optGamma; + inputs.netVega = optVega; + inputs.optionsReserved = M.fromWad(optionsEngine.getOptionsReservedMargin(user), collateralDecimals); } + } + /// @dev Price one shared account snapshot at either IM or MM shocks. + function _marginFromAggregate(address user, LinearAggregate memory agg, bool isIM) + private + view + returns (uint256) + { + return _marginFromInputs(_marginInputs(user, agg), isIM, _getSpotPriceWad()); + } + + function _marginFromInputs(MarginInputs memory inputs, bool isIM, uint256 spotPrice) + private + view + returns (uint256) + { + LinearAggregate memory agg = inputs.linear; // 2. Stress both fill legs (WAD-scaled) and keep the worse. Gamma and vega ride // along unchanged in both — only delta moves with the orders. - uint256 worstLoss = - _worstStressLoss(netDelta + int256(agg.buyOrderDelta), netGamma, netVega, isIM); - uint256 sellLoss = - _worstStressLoss(netDelta - int256(agg.sellOrderDelta), netGamma, netVega, isIM); + uint256 worstLoss = _worstStressLoss( + inputs.netDelta + int256(agg.buyOrderDelta), inputs.netGamma, inputs.netVega, isIM, spotPrice + ); + uint256 sellLoss = _worstStressLoss( + inputs.netDelta - int256(agg.sellOrderDelta), inputs.netGamma, inputs.netVega, isIM, spotPrice + ); if (sellLoss > worstLoss) worstLoss = sellLoss; // Convert stress loss from WAD to token decimals @@ -379,7 +420,7 @@ contract PortfolioMarginEngine is ? agg.unrealizedLossPerMarket : (agg.netUnrealizedPnl < 0 ? uint256(-agg.netUnrealizedPnl) : 0); - return stressTokens + agg.fillLoss + optReservedTokens + pnlTokens + agg.fundingOwed; + return stressTokens + agg.fillLoss + inputs.optionsReserved + pnlTokens + agg.fundingOwed; } /// @dev One batched getRiskView call per registered linear market: sums the WAD-lifted @@ -406,10 +447,10 @@ contract PortfolioMarginEngine is } } - /// @dev Evaluate 4 stress scenarios and return the worst-case loss (WAD). - /// Scenarios: (±Δs, ±Δσ) where Δs = spotShock * spotPrice (dollar move) + /// @dev Return the worst loss over (±Δs, ±Δσ), where each linear term is minimized + /// independently and the gamma term is unchanged across spot directions. /// PnL ≈ delta·Δs + ½·gamma·Δs² + vega·Δσ - function _worstStressLoss(int256 netDelta, uint256 netGamma, uint256 netVega, bool isIM) + function _worstStressLoss(int256 netDelta, int256 netGamma, int256 netVega, bool isIM, uint256 spotPrice) private view returns (uint256 worst) @@ -418,43 +459,17 @@ contract PortfolioMarginEngine is uint256 volShock = isIM ? imVolShock : mmVolShock; // Convert percentage shock → dollar move (WAD) - uint256 spotPrice = _getSpotPriceWad(); uint256 deltaS = spotShockFrac * spotPrice / WAD; // Pre-compute gamma term: ½ · gamma · Δs² - uint256 gammaTerm = netGamma * deltaS / WAD * deltaS / (2 * WAD); + int256 gammaTerm = netGamma * int256(deltaS) / int256(WAD) * int256(deltaS) / int256(2 * WAD); - // Scenario 1: spot +, vol + - worst = _scenarioLoss(netDelta, gammaTerm, netVega, int256(deltaS), int256(volShock)); - - // Scenario 2: spot +, vol - - uint256 loss = _scenarioLoss(netDelta, gammaTerm, netVega, int256(deltaS), -int256(volShock)); - if (loss > worst) worst = loss; - - // Scenario 3: spot -, vol + - loss = _scenarioLoss(netDelta, gammaTerm, netVega, -int256(deltaS), int256(volShock)); - if (loss > worst) worst = loss; - - // Scenario 4: spot -, vol - - loss = _scenarioLoss(netDelta, gammaTerm, netVega, -int256(deltaS), -int256(volShock)); - if (loss > worst) worst = loss; - } - - /// @dev Compute loss for a single scenario. Returns max(0, -PnL) in WAD. - /// PnL = delta·Δs/WAD + gammaTerm + vega·Δσ/WAD - /// Note: gammaTerm is pre-computed and always the same magnitude across ±spotShock - /// (quadratic in |Δs|), so we always ADD it regardless of direction. - function _scenarioLoss(int256 netDelta, uint256 gammaTerm, uint256 netVega, int256 deltaS, int256 deltaVol) - private - pure - returns (uint256) - { - int256 deltaPnl = netDelta * deltaS / int256(WAD); - int256 vegaPnl = int256(netVega) * deltaVol / int256(WAD); - // Gamma term is ½γ(Δs)² — always non-negative, always adds to P&L - // (positive gamma profits from moves, negative gamma loses) - int256 pnl = deltaPnl + int256(gammaTerm) + vegaPnl; - return pnl < 0 ? uint256(-pnl) : 0; + int256 deltaPnl = netDelta * int256(deltaS) / int256(WAD); + int256 vegaPnl = netVega * int256(volShock) / int256(WAD); + uint256 deltaLoss = deltaPnl < 0 ? uint256(-deltaPnl) : uint256(deltaPnl); + uint256 vegaLoss = vegaPnl < 0 ? uint256(-vegaPnl) : uint256(vegaPnl); + int256 worstPnl = gammaTerm - int256(deltaLoss) - int256(vegaLoss); + worst = worstPnl < 0 ? uint256(-worstPnl) : 0; } @@ -495,14 +510,13 @@ contract PortfolioMarginEngine is } } - /// @dev Read the index oracle and scale to WAD. Reverts when no oracle is - /// configured — an unset oracle must not silently zero out the delta/gamma - /// stress loss. Returns 0 on a stale/non-positive answer (zero stress, same - /// degradation semantics as the products' own oracle reads). + /// @dev Read the index oracle and scale to WAD. Missing, invalid, or stale + /// prices must fail closed: returning zero would erase delta/gamma stress. function _getSpotPriceWad() private view returns (uint256) { if (address(priceOracle) == address(0)) revert OracleNotSet(); (, int256 answer,, uint256 updatedAt,) = priceOracle.latestRoundData(); - if (answer <= 0 || block.timestamp - updatedAt > MAX_ORACLE_STALENESS) return 0; + if (answer <= 0 || updatedAt == 0 || updatedAt > block.timestamp) revert InvalidOracle(); + if (block.timestamp - updatedAt > MAX_ORACLE_STALENESS) revert OracleStale(); return M.toWad(uint256(answer), oracleDecimals); } @@ -548,7 +562,7 @@ contract PortfolioMarginEngine is function _validateOptionsContract(address _optionsEngine)private view{ try IOptionsEnginePortfolioView(_optionsEngine).getNetGreeks(address(this)) returns ( - int256, uint256, uint256 + int256, int256, int256 ) { } catch { revert InvalidDependency(); } diff --git a/contracts/contracts/interfaces/ILinearMarket.sol b/contracts/contracts/interfaces/ILinearMarket.sol index da9eb6f..0dee54d 100644 --- a/contracts/contracts/interfaces/ILinearMarket.sol +++ b/contracts/contracts/interfaces/ILinearMarket.sol @@ -70,4 +70,9 @@ interface ILinearMarket { /// silently take `pendingFunding` as the old `orderMargin` instead of /// reverting. A fresh selector makes version skew fail loud. function getRiskView(address user) external view returns (RiskView memory); + + /// @notice Whether this market reports any currently margin-relevant resting-order delta. + /// @dev This narrow read keeps portfolio-wide orders-first liquidation checks off the + /// substantially more expensive position, oracle, and fill-loss path in `getRiskView`. + function hasRestingOrderDelta(address user) external view returns (bool); } diff --git a/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol b/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol index 6af0dd9..b2686a6 100644 --- a/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol +++ b/contracts/contracts/interfaces/IOptionsEnginePortfolioView.sol @@ -12,7 +12,7 @@ interface IOptionsEnginePortfolioView { /// own vault. See `ILinearMarket.vault`. function vault() external view returns (ICollateralVault); - function getNetGreeks(address user) external view returns (int256 netDelta, uint256 netGamma, uint256 netVega); + function getNetGreeks(address user) external view returns (int256 netDelta, int256 netGamma, int256 netVega); function getOptionsReservedMargin(address user) external view returns (uint256); } diff --git a/contracts/contracts/interfaces/IPortfolioMarginEngine.sol b/contracts/contracts/interfaces/IPortfolioMarginEngine.sol index 4cc73cf..f59b6d5 100644 --- a/contracts/contracts/interfaces/IPortfolioMarginEngine.sol +++ b/contracts/contracts/interfaces/IPortfolioMarginEngine.sol @@ -19,6 +19,18 @@ interface IPortfolioMarginEngine { /// @notice Portfolio Maintenance Margin in token decimals. function computePortfolioMM(address user) external view returns (uint256); + /// @notice Portfolio Initial and Maintenance Margin from one shared market snapshot. + function computePortfolioMargins(address user) external view returns (uint256 im, uint256 mm); + + /// @notice Whether the account is liquidatable: vault balance below portfolio MM. + /// @dev The canonical cross-venue health predicate — liquidatability is a property of + /// the portfolio, not of any single venue, so it lives here. No venue-local state + /// check is needed: an account with no state anywhere has MM = 0, and a balance + /// below zero is impossible. Whether a specific venue holds anything actionable + /// is a separate question, answered by that venue's `hasRestingOrderDelta` and + /// position views. + function isLiquidatable(address user) external view returns (bool); + /// @notice Margin charged against a delta-one resting order's notional (both token /// decimals). /// @dev Lets a market size order margin from the engine's risk knob without importing diff --git a/contracts/contracts/mocks/FuturesMock.sol b/contracts/contracts/mocks/FuturesMock.sol index c220e12..25bd81a 100644 --- a/contracts/contracts/mocks/FuturesMock.sol +++ b/contracts/contracts/mocks/FuturesMock.sol @@ -74,4 +74,8 @@ contract FuturesMock is ILinearMarket { sellOrderFillLoss: _sellOrderFillLoss[user] }); } + + function hasRestingOrderDelta(address user) external view returns (bool) { + return _buyOrderDelta[user] != 0 || _sellOrderDelta[user] != 0; + } } diff --git a/contracts/contracts/mocks/MarginEngineMock.sol b/contracts/contracts/mocks/MarginEngineMock.sol index c66df2d..57d03ea 100644 --- a/contracts/contracts/mocks/MarginEngineMock.sol +++ b/contracts/contracts/mocks/MarginEngineMock.sol @@ -30,6 +30,10 @@ contract MarginEngineMock is IPortfolioMarginEngine { return 0; } + function computePortfolioMargins(address user) external view returns (uint256 im, uint256 mm) { + return (_im[user], 0); + } + /// @dev Consistent with the zero shock below: this mock never charges order margin. function linearOrderMargin(uint256) external pure returns (uint256) { return 0; @@ -45,6 +49,11 @@ contract MarginEngineMock is IPortfolioMarginEngine { return false; } + /// @dev MM is always zero here, and a balance below zero is impossible. + function isLiquidatable(address) external pure returns (bool) { + return false; + } + function imSpotShock() external pure returns (uint256) { return 0; } diff --git a/contracts/contracts/mocks/OptionsEngineMock.sol b/contracts/contracts/mocks/OptionsEngineMock.sol index c2edb49..a175e3b 100644 --- a/contracts/contracts/mocks/OptionsEngineMock.sol +++ b/contracts/contracts/mocks/OptionsEngineMock.sol @@ -8,8 +8,8 @@ import { IOptionsEnginePortfolioView } from "../interfaces/IOptionsEnginePortfol contract OptionsEngineMock is IOptionsEnginePortfolioView { struct Greeks { int256 netDelta; - uint256 netGamma; - uint256 netVega; + int256 netGamma; + int256 netVega; } mapping(address => Greeks) private _greeks; @@ -22,7 +22,7 @@ contract OptionsEngineMock is IOptionsEnginePortfolioView { vault = _vault; } - function setNetGreeks(address user, int256 delta, uint256 gamma, uint256 vega) external { + function setNetGreeks(address user, int256 delta, int256 gamma, int256 vega) external { _greeks[user] = Greeks(delta, gamma, vega); } @@ -30,7 +30,7 @@ contract OptionsEngineMock is IOptionsEnginePortfolioView { _reserved[user] = amount; } - function getNetGreeks(address user) external view returns (int256, uint256, uint256) { + function getNetGreeks(address user) external view returns (int256, int256, int256) { Greeks memory g = _greeks[user]; return (g.netDelta, g.netGamma, g.netVega); } diff --git a/contracts/contracts/mocks/PerpsDEXMock.sol b/contracts/contracts/mocks/PerpsDEXMock.sol index 1f89a35..c446c32 100644 --- a/contracts/contracts/mocks/PerpsDEXMock.sol +++ b/contracts/contracts/mocks/PerpsDEXMock.sol @@ -8,7 +8,7 @@ import { ILinearMarket } from "../interfaces/ILinearMarket.sol"; contract PerpsDEXMock is ILinearMarket { struct Position { int256 netQuantity; - uint256 aggregatedEntryPrice; + int256 netEntryValue; } uint8 public constant QUANTITY_DECIMALS = 6; @@ -23,32 +23,23 @@ contract PerpsDEXMock is ILinearMarket { } mapping(address => Position) private _positions; - mapping(address => uint256) private _balances; mapping(address => int256) private _unrealizedPnl; - mapping(address => uint256) private _maintenanceMargin; mapping(address => int256) private _pendingFunding; mapping(address => uint256) private _buyOrderDelta; mapping(address => uint256) private _sellOrderDelta; mapping(address => uint256) private _buyOrderFillLoss; mapping(address => uint256) private _sellOrderFillLoss; + bool private _riskViewDisabled; function setUserPosition(address user, int256 qty, uint256 entryPrice) external { - _positions[user] = Position(qty, entryPrice); - } - - function setBalance(address user, uint256 bal) external { - _balances[user] = bal; + int256 netEntryValue = qty * int256(entryPrice) / int256(10 ** QUANTITY_DECIMALS); + _positions[user] = Position(qty, netEntryValue); } function setUnrealizedPnl(address user, int256 pnl) external { _unrealizedPnl[user] = pnl; } - /// @dev Only MM is modelled: it is the threshold `isLiquidatable` compares balance against. - function setMaintenanceMargin(address user, uint256 mm) external { - _maintenanceMargin[user] = mm; - } - function getUserPosition(address user) external view returns (Position memory) { return _positions[user]; } @@ -60,6 +51,7 @@ contract PerpsDEXMock is ILinearMarket { } function getRiskView(address user) external view returns (RiskView memory) { + if (_riskViewDisabled) revert(); return RiskView({ netPositionDelta: _positions[user].netQuantity * 1e6 / int256(10 ** QUANTITY_DECIMALS), unrealizedPnl: _unrealizedPnl[user], @@ -95,8 +87,11 @@ contract PerpsDEXMock is ILinearMarket { _sellOrderFillLoss[user] = sellLoss; } - function isLiquidatable(address user) external view returns (bool) { - if (_positions[user].netQuantity == 0) return false; - return _balances[user] < _maintenanceMargin[user]; + function setRiskViewDisabled(bool disabled) external { + _riskViewDisabled = disabled; + } + + function hasRestingOrderDelta(address user) external view returns (bool) { + return _buyOrderDelta[user] != 0 || _sellOrderDelta[user] != 0; } } diff --git a/contracts/package.json b/contracts/package.json index e084762..4c1b8f2 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -36,10 +36,5 @@ "dotenv": "^16.4.1", "viem": "^2.52.2" }, - "packageManager": "pnpm@10.28.1", - "pnpm": { - "onlyBuiltDependencies": [ - "hardhat-viem-abi" - ] - } + "packageManager": "pnpm@11.22.0" } diff --git a/contracts/pnpm-lock.yaml b/contracts/pnpm-lock.yaml index b3a8f2a..2b9c202 100644 --- a/contracts/pnpm-lock.yaml +++ b/contracts/pnpm-lock.yaml @@ -755,7 +755,7 @@ packages: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} hardhat-viem-abi@https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/54198ea8c9ad9b05c0c23a057ae3b1c7ab82a93a#path:packages/hardhat-viem-abi: - resolution: {path: packages/hardhat-viem-abi, tarball: https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/54198ea8c9ad9b05c0c23a057ae3b1c7ab82a93a} + resolution: {gitHosted: true, path: packages/hardhat-viem-abi, tarball: https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/54198ea8c9ad9b05c0c23a057ae3b1c7ab82a93a} version: 1.0.0-alpha.2 engines: {node: '>=22'} peerDependencies: diff --git a/contracts/pnpm-workspace.yaml b/contracts/pnpm-workspace.yaml new file mode 100644 index 0000000..17496f7 --- /dev/null +++ b/contracts/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: false + hardhat-viem-abi@https://codeload.github.com/lsheva/hardhat-viem-abi/tar.gz/54198ea8c9ad9b05c0c23a057ae3b1c7ab82a93a#path:packages/hardhat-viem-abi: true diff --git a/contracts/tests/fixtures.ts b/contracts/tests/fixtures.ts index 4bb4ef1..83e7445 100644 --- a/contracts/tests/fixtures.ts +++ b/contracts/tests/fixtures.ts @@ -98,7 +98,7 @@ export async function deployPortfolioMarginEngineFixture(conn: NetworkConnection const { viem } = conn; const [owner] = await viem.getWalletClients(); const { usdc, vault } = await deployCollateralVaultProxy(conn); - const { perpsMock, optionsMock, futuresMock, pme } = await deployPortfolioMarginEngineStack( + const { perpsMock, optionsMock, futuresMock, oracleMock, pme } = await deployPortfolioMarginEngineStack( conn, vault.address, ); @@ -108,7 +108,7 @@ export async function deployPortfolioMarginEngineFixture(conn: NetworkConnection await vault.write.deposit([PME_OWNER_DEPOSIT], { account: owner.account }); await vault.write.setMarginEngine([pme.address], { account: owner.account }); - return { vault, perpsMock, optionsMock, futuresMock, pme, usdc, user, owner }; + return { vault, perpsMock, optionsMock, futuresMock, oracleMock, pme, usdc, user, owner }; } /** End-to-end: vault + PME + product mocks, Alice funded and deposited. */ diff --git a/contracts/tests/gas-portfolioMarginEngine.test.ts b/contracts/tests/gas-portfolioMarginEngine.test.ts new file mode 100644 index 0000000..eb68752 --- /dev/null +++ b/contracts/tests/gas-portfolioMarginEngine.test.ts @@ -0,0 +1,64 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { network } from "hardhat"; +import { encodeFunctionData } from "viem"; +import { DEFAULT_MARKET_PRICE, deployPortfolioMarginEngineFixture } from "./fixtures.js"; + +const { networkHelpers, viem } = await network.connect(); + +describe("Gas: PortfolioMarginEngine", () => { + it("computePortfolioIM representative portfolio", async () => { + const { pme, perpsMock, optionsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await perpsMock.write.setUserPosition([user, 1_000_000n, DEFAULT_MARKET_PRICE]); + await perpsMock.write.setOrderDeltas([user, 500_000n, 250_000n]); + await optionsMock.write.setNetGreeks([ + user, + 100_000_000_000_000_000n, + 1_000_000_000_000_000_000n, + 1_000_000_000_000_000_000n, + ]); + + const publicClient = await viem.getPublicClient(); + const gas = await publicClient.estimateGas({ + account: user, + to: pme.address, + data: encodeFunctionData({ + abi: pme.abi, + functionName: "computePortfolioIM", + args: [user], + }), + }); + console.log(` computePortfolioIM representative: ${gas.toLocaleString()} gas`); + assert.ok(gas > 0n); + }); + + it("orderMarginOf no resting orders", async () => { + const { pme, perpsMock, optionsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await perpsMock.write.setUserPosition([user, 1_000_000n, DEFAULT_MARKET_PRICE]); + await optionsMock.write.setNetGreeks([ + user, + 100_000_000_000_000_000n, + 1_000_000_000_000_000_000n, + 1_000_000_000_000_000_000n, + ]); + + const publicClient = await viem.getPublicClient(); + const gas = await publicClient.estimateGas({ + account: user, + to: pme.address, + data: encodeFunctionData({ + abi: pme.abi, + functionName: "orderMarginOf", + args: [user], + }), + }); + console.log(` orderMarginOf no orders: ${gas.toLocaleString()} gas`); + assert.equal(await pme.read.orderMarginOf([user]), 0n); + }); +}); diff --git a/contracts/tests/portfolioMarginEngine.test.ts b/contracts/tests/portfolioMarginEngine.test.ts index ed2bb2b..5dba1ef 100644 --- a/contracts/tests/portfolioMarginEngine.test.ts +++ b/contracts/tests/portfolioMarginEngine.test.ts @@ -33,6 +33,19 @@ describe("PortfolioMarginEngine", () => { }); describe("perps-only position", () => { + it("exposes signed net entry value", async () => { + const { perpsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await perpsMock.write.setUserPosition([user, -ONE_LOT_QTY, DEFAULT_MARKET_PRICE]); + + assert.deepEqual(await perpsMock.read.getUserPosition([user]), { + netQuantity: -ONE_LOT_QTY, + netEntryValue: -DEFAULT_MARKET_PRICE, + }); + }); + it("computes margin from perps delta stress", async () => { const { pme, perpsMock, user } = await networkHelpers.loadFixture( deployPortfolioMarginEngineFixture, @@ -279,6 +292,17 @@ describe("PortfolioMarginEngine", () => { assert.equal(await pme.read.hasRestingOrderDelta([user]), true); }); + it("does not compute full market risk views", async () => { + const { pme, perpsMock, futuresMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await perpsMock.write.setRiskViewDisabled([true]); + await futuresMock.write.setOrderDeltas([user, ONE_LOT_QTY, 0n]); + + assert.equal(await pme.read.hasRestingOrderDelta([user]), true); + }); + it("catches either side", async () => { const { pme, perpsMock, user } = await networkHelpers.loadFixture( deployPortfolioMarginEngineFixture, @@ -387,8 +411,10 @@ describe("PortfolioMarginEngine", () => { await perpsMock.write.setUserPosition([user, ONE_LOT_QTY, DEFAULT_MARKET_PRICE]); const im = await pme.read.computePortfolioIM([user]); const mm = await pme.read.computePortfolioMM([user]); + const [combinedIm, combinedMm] = await pme.read.computePortfolioMargins([user]); assert.ok(im > mm, "IM > MM for same position"); + assert.deepEqual([combinedIm, combinedMm], [im, mm], "combined read matches standalone margins"); }); }); @@ -619,6 +645,37 @@ describe("PortfolioMarginEngine", () => { }); + describe("oracle freshness", () => { + it("reverts margin reads when the oracle is stale", async () => { + const { pme, oracleMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await oracleMock.write.freezeTimestamp(); + await networkHelpers.time.increase(3601); + + await viem.assertions.revertWithCustomError( + pme.read.computePortfolioIM([user]), + pme, + "OracleStale", + ); + }); + + it("reverts margin reads when the oracle answer is non-positive", async () => { + const { pme, oracleMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await oracleMock.write.setPrice([0n, 6]); + + await viem.assertions.revertWithCustomError( + pme.read.computePortfolioIM([user]), + pme, + "InvalidOracle", + ); + }); + }); + describe("gamma and vega", () => { it("gamma reduces stress loss for long gamma position", async () => { const { pme, optionsMock, user } = await networkHelpers.loadFixture( @@ -632,14 +689,13 @@ describe("PortfolioMarginEngine", () => { }); it("short gamma increases stress loss", async () => { - const { pme, perpsMock, optionsMock, user } = await networkHelpers.loadFixture( + const { pme, optionsMock, user } = await networkHelpers.loadFixture( deployPortfolioMarginEngineFixture, ); - await perpsMock.write.setUserPosition([user, 0n, 0n]); - await optionsMock.write.setNetGreeks([user, 0n, 0n, 0n]); + await optionsMock.write.setNetGreeks([user, 0n, -WAD, 0n]); const im = await pme.read.computePortfolioIM([user]); - assert.equal(im, 0n, "delta-neutral, no gamma/vega → 0 margin"); + assert.ok(im > 0n, "negative gamma loses under either spot move"); }); it("vega exposure adds to margin", async () => { @@ -653,5 +709,16 @@ describe("PortfolioMarginEngine", () => { assert.ok(im > 0n, "pure vega position has positive stress margin"); assert.equal(im, 100_000n, "vega stress = vega * volShock in token decimals"); }); + + it("short vega is stressed in the opposite volatility scenario", async () => { + const { pme, optionsMock, user } = await networkHelpers.loadFixture( + deployPortfolioMarginEngineFixture, + ); + + await optionsMock.write.setNetGreeks([user, 0n, 0n, -WAD]); + const im = await pme.read.computePortfolioIM([user]); + + assert.equal(im, 100_000n, "negative vega loses under the positive vol shock"); + }); }); }); diff --git a/indexer/package.json b/indexer/package.json index 3d9f116..0477030 100644 --- a/indexer/package.json +++ b/indexer/package.json @@ -34,5 +34,5 @@ "matchstick-as": "0.6.0", "typescript": "^5.9.3" }, - "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316" + "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621" } diff --git a/keeper/package.json b/keeper/package.json index ea42073..16d8813 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -24,8 +24,7 @@ "@hashpower/portfolio-margin": "github:Lumerin-protocol/collateral-margin#c34b4a360d6616d017b157a4a9e27e1a8e60079c&path:/portfolio-margin", "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#c34b4a360d6616d017b157a4a9e27e1a8e60079c&path:/contracts/abi", - "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#f7e219f704646ab654a2a8d0286c0f477148e299&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#547ade13eb944b0e86bbef325749ed627cc886b4&path:/contracts/abi", + "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#8b7ed0f3572d0ea8039a11757b7c1b963be75535&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, @@ -35,5 +34,5 @@ "@typescript/native-preview": "7.0.0-dev.20260511.1", "pino-pretty": "^13.1.3" }, - "packageManager": "pnpm@11.1.1" + "packageManager": "pnpm@11.22.0" } diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index d96ef49..7a38658 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -18,11 +18,8 @@ importers: specifier: github:Lumerin-protocol/collateral-margin#c34b4a360d6616d017b157a4a9e27e1a8e60079c&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/contracts/abi derivatives-marketplace-abi: - specifier: github:Lumerin-protocol/derivatives-marketplace#f7e219f704646ab654a2a8d0286c0f477148e299&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299#path:/contracts/abi - futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#547ade13eb944b0e86bbef325749ed627cc886b4&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4#path:/contracts/abi + specifier: github:Lumerin-protocol/derivatives-marketplace#8b7ed0f3572d0ea8039a11757b7c1b963be75535&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -106,7 +103,7 @@ packages: os: [win32] '@hashpower/portfolio-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/portfolio-margin': - resolution: {gitHosted: true, integrity: sha512-mxgdMq81THnD1ytwHTwSxHhAVsYdGw6YjcwHAxzUQG0mMrfjdeSx/Yipcg117u3QUSSnU9L8YJ4J09xmujZOOQ==, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c} + resolution: {gitHosted: true, integrity: sha512-mxgdMq81THnD1ytwHTwSxHhAVsYdGw6YjcwHAxzUQG0mMrfjdeSx/Yipcg117u3QUSSnU9L8YJ4J09xmujZOOQ==, path: /portfolio-margin, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c} version: 0.1.0 engines: {node: '>=22'} @@ -204,7 +201,7 @@ packages: engines: {node: '>=8.0.0'} collateral-margin-abi@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-mxgdMq81THnD1ytwHTwSxHhAVsYdGw6YjcwHAxzUQG0mMrfjdeSx/Yipcg117u3QUSSnU9L8YJ4J09xmujZOOQ==, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c} + resolution: {gitHosted: true, integrity: sha512-mxgdMq81THnD1ytwHTwSxHhAVsYdGw6YjcwHAxzUQG0mMrfjdeSx/Yipcg117u3QUSSnU9L8YJ4J09xmujZOOQ==, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c} version: 0.0.0 colorette@2.0.20: @@ -213,8 +210,8 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-WoZc9oXcWv0waw6+iZLtWfVyG7mZ6ngTWE5YOi0Nu8+dZ9rsnetk+044w3BHRNMqp0TvX3YuMHMSf6TAIaxtdw==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535#path:/contracts/abi: + resolution: {gitHosted: true, integrity: sha512-JkJW4F+2DgaUOFLgP0OO9QuvJl4bh4m5gle+CVlKH8o+WBRqY2+c3OoFl7ml9lzdwGGwWjOFdchqY4KP/a6OnQ==, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535} version: 0.0.0 end-of-stream@1.4.5: @@ -229,10 +226,6 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-LQiUPSYCFvY86dYZEk61cpMWwy3VvhAaYgnn/NUdY+RoifWHKV3PIrpx0Am9x27U840+KyMhkdOMDoGjPJq9MQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4} - version: 0.0.0 - help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -452,7 +445,7 @@ snapshots: dateformat@4.6.3: {} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299#path:/contracts/abi: {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535#path:/contracts/abi: {} end-of-stream@1.4.5: dependencies: @@ -464,8 +457,6 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4#path:/contracts/abi: {} - help-me@5.0.0: {} isows@1.0.7(ws@8.21.0): diff --git a/keeper/scripts/audit-indexer-sync.ts b/keeper/scripts/audit-indexer-sync.ts index 34c8ccd..431457f 100644 --- a/keeper/scripts/audit-indexer-sync.ts +++ b/keeper/scripts/audit-indexer-sync.ts @@ -7,7 +7,7 @@ */ import { createPublicClient, http, type Address, type Hex } from "viem"; import { baseSepolia } from "viem/chains"; -import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; +import { HashPowerFuturesAbi } from "../src/abi/HashPowerFutures.ts"; const ENDPOINT = "https://api.goldsky.com/api/public/project_cmmz59uoa7b5201wthnkxbuqy/subgraphs/hpow-futures/dev-latest/gn"; @@ -76,7 +76,7 @@ async function fetchTrades(): Promise { async function getChainPositionCount(blockNumber: number): Promise { const ids = await client.readContract({ address: FUT, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getActiveExpirationDates", args: [USER as Address], blockNumber: BigInt(blockNumber), diff --git a/keeper/scripts/debug-delivery-bootstrap.ts b/keeper/scripts/debug-delivery-bootstrap.ts index 7f81582..d03977f 100644 --- a/keeper/scripts/debug-delivery-bootstrap.ts +++ b/keeper/scripts/debug-delivery-bootstrap.ts @@ -7,7 +7,7 @@ */ import { createPublicClient, http, type Address } from "viem"; import { baseSepolia, base, hardhat } from "viem/chains"; -import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; +import { HashPowerFuturesAbi } from "../src/abi/HashPowerFutures.ts"; const FUTURES = process.env.FUTURES_ADDRESS as Address; const NETWORK = process.env.NETWORK ?? "base-sepolia"; @@ -32,7 +32,7 @@ console.log("\n--- Stage 1: getActiveExpirationDates via multicall ---"); const dateLists = await client.multicall({ contracts: USERS.map((u) => ({ address: FUTURES, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getActiveExpirationDates" as const, args: [u] as const, })), @@ -60,7 +60,7 @@ console.log(`\n--- Stage 2: getUserPosition for ${pairs.length} aggregates ---`) const positions = await client.multicall({ contracts: pairs.map((p) => ({ address: FUTURES, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getUserPosition" as const, args: [p.user, p.expirationAt] as const, })), diff --git a/keeper/src/abi/HashPowerFutures.ts b/keeper/src/abi/HashPowerFutures.ts new file mode 100644 index 0000000..f4d4ae3 --- /dev/null +++ b/keeper/src/abi/HashPowerFutures.ts @@ -0,0 +1,1979 @@ +export const HashPowerFuturesAbi = [ + { + "inputs": [ + { + "internalType": "contract ICollateralVault", + "name": "_vault", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [], + "name": "ArrayLengthMismatch", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [], + "name": "EmptyBatch", + "type": "error" + }, + { + "inputs": [], + "name": "ExpirationDateNotAvailable", + "type": "error" + }, + { + "inputs": [], + "name": "ExpirationDateShouldBeInTheFuture", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientMarginBalance", + "type": "error" + }, + { + "inputs": [], + "name": "InsuranceFundNotConfigured", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidFee", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidPrice", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidQty", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidReduceQuantity", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidTimeInForce", + "type": "error" + }, + { + "inputs": [], + "name": "MaxOrdersPerParticipantPerExpirationReached", + "type": "error" + }, + { + "inputs": [], + "name": "MaxPriceLevelsReached", + "type": "error" + }, + { + "inputs": [], + "name": "MaxPriceLevelsReached", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "NotLiquidatable", + "type": "error" + }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotBelongToSender", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotBelongToUser", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotExists", + "type": "error" + }, + { + "inputs": [], + "name": "OrdersStillOpen", + "type": "error" + }, + { + "inputs": [], + "name": "OverLiquidation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "PositionExpirationNotStartedYet", + "type": "error" + }, + { + "inputs": [], + "name": "PositionNotExists", + "type": "error" + }, + { + "inputs": [], + "name": "SettlementDateNotReached", + "type": "error" + }, + { + "inputs": [], + "name": "TimeInForceNotFilled", + "type": "error" + }, + { + "inputs": [], + "name": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "type": "error" + }, + { + "inputs": [], + "name": "UnsupportedTokenDecimals", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "int256", + "name": "min", + "type": "int256" + }, + { + "internalType": "int256", + "name": "max", + "type": "int256" + } + ], + "name": "ValueOutOfRange", + "type": "error" + }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "BadDebt", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "newFutureExpirationDatesCount", + "type": "uint8" + } + ], + "name": "FutureExpirationDatesCountUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "hook", + "type": "address" + } + ], + "name": "HookUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "newLiquidationFeeBps", + "type": "uint16" + } + ], + "name": "LiquidationFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "newLiquidationMarginPercent", + "type": "uint8" + } + ], + "name": "LiquidationMarginPercentUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "newLiquidatorShareBps", + "type": "uint16" + } + ], + "name": "LiquidatorShareBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "int16", + "name": "newMakerFeeBps", + "type": "int16" + } + ], + "name": "MakerFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "newOracle", + "type": "address" + } + ], + "name": "OracleUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + } + ], + "name": "OrderCancelled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "name": "OrderCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "OrderLiquidated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "makerOrderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "maker", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "taker", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tradePrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "makerFee", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerFee", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "makerNetQtyAfter", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerNetQtyAfter", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "makerEntryPriceAfter", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "takerEntryPriceAfter", + "type": "uint256" + } + ], + "name": "OrderMatched", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "indexed": false, + "internalType": "int256", + "name": "newQuantity", + "type": "int256" + } + ], + "name": "OrderUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "newPortfolioMargin", + "type": "address" + } + ], + "name": "PortfolioMarginUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "closedQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "pnl", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "liquidatorFee", + "type": "uint256" + } + ], + "name": "PositionLiquidated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "closedQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "pnl", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "settlementPrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "settledBy", + "type": "address" + } + ], + "name": "PositionSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "recordedBy", + "type": "address" + } + ], + "name": "SettlementPriceRecorded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "int16", + "name": "newTakerFeeBps", + "type": "int16" + } + ], + "name": "TakerFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "inputs": [], + "name": "CONTRACT_SIZE_HPS_DAY", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "EXPIRATION_INTERVAL_DAYS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_ORACLE_STALENESS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_ORDERS_PER_PARTICIPANT_PER_EXPIRATION", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_PRICE_LEVELS_PER_SIDE", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "QUANTITY_DECIMALS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "UPGRADE_INTERFACE_VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "cancelOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "collectedFeesBalance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "_quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "_tif", + "type": "uint8" + } + ], + "name": "createOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "timeInForce", + "type": "uint8" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderIntent[]", + "name": "_intents", + "type": "tuple[]" + } + ], + "name": "createOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_participants", + "type": "address[]" + } + ], + "name": "dropActiveOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "expirationIntervalDays", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "firstFutureExpirationDate", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "futureExpirationDatesCount", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getActiveExpirationDates", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getExpirationDates", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getMarketPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "getOrder", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "internalType": "struct HashPowerFuturesBase.Order", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getOrderAggregateAtExpiration", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "buyQty", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellQty", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "buyValue", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellValue", + "type": "uint256" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderAggregate", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_maxLevels", + "type": "uint256" + } + ], + "name": "getOrderBookPrices", + "outputs": [ + { + "internalType": "uint256[]", + "name": "bids", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "asks", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "_isBid", + "type": "bool" + } + ], + "name": "getQuantityAtPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getRiskView", + "outputs": [ + { + "components": [ + { + "internalType": "int256", + "name": "netPositionDelta", + "type": "int256" + }, + { + "internalType": "int256", + "name": "unrealizedPnl", + "type": "int256" + }, + { + "internalType": "int256", + "name": "pendingFunding", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "buyOrderDelta", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellOrderDelta", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "buyOrderFillLoss", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellOrderFillLoss", + "type": "uint256" + } + ], + "internalType": "struct ILinearMarket.RiskView", + "name": "view_", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getUnrealizedPnl", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getUserOrdersAtExpiration", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "orderIds", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getUserPosition", + "outputs": [ + { + "components": [ + { + "internalType": "int256", + "name": "netQuantity", + "type": "int256" + }, + { + "internalType": "int256", + "name": "netEntryValue", + "type": "int256" + } + ], + "internalType": "struct HashPowerFuturesBase.Position", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "hasRestingOrderDelta", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "hook", + "outputs": [ + { + "internalType": "contract IPointsHook", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "_priceOracle", + "type": "address" + }, + { + "internalType": "uint8", + "name": "_liquidationMarginPercent", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "_futureExpirationDatesCount", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "_firstFutureExpirationDate", + "type": "uint256" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "liquidateOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "bytes32[]", + "name": "_orderIds", + "type": "bytes32[]" + } + ], + "name": "liquidateOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_closeQty", + "type": "uint256" + } + ], + "name": "liquidatePosition", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256[]", + "name": "_expirationAts", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "_closeQtys", + "type": "uint256[]" + } + ], + "name": "liquidatePositions", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "liquidationFeeBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "liquidationMarginPercent", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "liquidatorShareBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "makerFeeBps", + "outputs": [ + { + "internalType": "int16", + "name": "", + "type": "int16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minimumPriceIncrement", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "portfolioMargin", + "outputs": [ + { + "internalType": "contract IPortfolioMarginEngine", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "priceOracle", + "outputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "name": "recordSettlementPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + }, + { + "internalType": "int256", + "name": "_newQuantity", + "type": "int256" + } + ], + "name": "reduceOrderSize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32[]", + "name": "_orderIds", + "type": "bytes32[]" + } + ], + "name": "removeOutdatedOrders", + "outputs": [ + { + "internalType": "uint256", + "name": "removed", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_participants", + "type": "address[]" + } + ], + "name": "resetState", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "_futureExpirationDatesCount", + "type": "uint8" + } + ], + "name": "setFutureExpirationDatesCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_hook", + "type": "address" + } + ], + "name": "setHook", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "_bps", + "type": "uint16" + } + ], + "name": "setLiquidationFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "_liquidationMarginPercent", + "type": "uint8" + } + ], + "name": "setLiquidationMarginPercent", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "_bps", + "type": "uint16" + } + ], + "name": "setLiquidatorShareBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int16", + "name": "_makerFeeBps", + "type": "int16" + } + ], + "name": "setMakerFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "_oracle", + "type": "address" + } + ], + "name": "setOracle", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IPortfolioMarginEngine", + "name": "_pm", + "type": "address" + } + ], + "name": "setPortfolioMargin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int16", + "name": "_takerFeeBps", + "type": "int16" + } + ], + "name": "setTakerFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "settlePosition", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_users", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "_expirationAts", + "type": "uint256[]" + } + ], + "name": "settlePositions", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "settlementPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "_quantity", + "type": "int256" + } + ], + "name": "simulateOrder", + "outputs": [ + { + "internalType": "int256", + "name": "filledQuantity", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "averageFillPrice", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "remainingQuantity", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "takerFeeBps", + "outputs": [ + { + "internalType": "int16", + "name": "", + "type": "int16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32[]", + "name": "_cancelIds", + "type": "bytes32[]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "internalType": "int256", + "name": "newQuantity", + "type": "int256" + } + ], + "internalType": "struct HashPowerFuturesBase.ReduceIntent[]", + "name": "_reduces", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "timeInForce", + "type": "uint8" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderIntent[]", + "name": "_intents", + "type": "tuple[]" + } + ], + "name": "updateOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "vault", + "outputs": [ + { + "internalType": "contract ICollateralVault", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "withdrawCollectedFees", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; diff --git a/keeper/src/config.ts b/keeper/src/config.ts index 71eb100..c3b214e 100644 --- a/keeper/src/config.ts +++ b/keeper/src/config.ts @@ -166,25 +166,6 @@ export interface Config { */ balanceCriticalWei: bigint; }; - outdatedOrders: { - /** - * Cadence of the futures expired-order sweep in ms. Default 5 min — - * expired orders aren't time-critical (they just pin a slot under - * `MAX_ORDERS_PER_PARTICIPANT` and leave a dead level on the book), - * so we don't need the sub-minute cadence used by liquidations. Set - * to 0 to disable the sweep entirely (e.g. when another keeper is - * the designated cleaner). - */ - sweepIntervalMs: number; - /** - * Maximum number of `removeOutdatedOrder` calls bundled into a - * single `Futures.multicall(bytes[])` tx. Each call is roughly - * 50-80k gas (one `_closeOrder` traversal); 50 keeps us well under - * Base's 30M block-gas limit (~4M worst case). Larger user-side - * fan-outs split across multiple sequential txs. - */ - maxBatchSize: number; - }; delivery: { /** * Opt-in: when true, the keeper permissionlessly calls @@ -221,8 +202,8 @@ export interface Config { */ bootstrapUsers: readonly Address[]; /** - * Maximum number of `settlePosition` calls bundled into a single - * `Futures.multicall(bytes[])` transaction. Trades a single nonce per + * Maximum number of position pairs passed to a single + * `Futures.settlePositions(address[],uint256[])` transaction. Trades a single nonce per * sweep tick (no replacement-underpriced races) for one bigger tx. * Capped to keep gas usage well under the block limit — Base has 30M * block gas, each `settlePosition` is roughly 200-300k gas, so 50 is @@ -412,10 +393,6 @@ export function loadConfig(): Config { balanceLowWei: BigInt(process.env.BALANCE_LOW_WEI ?? "10000000000000000"), balanceCriticalWei: BigInt(process.env.BALANCE_CRITICAL_WEI ?? "1000000000000000"), }, - outdatedOrders: { - sweepIntervalMs: Number(process.env.OUTDATED_ORDERS_SWEEP_INTERVAL_MS ?? "300000"), - maxBatchSize: Number(process.env.OUTDATED_ORDERS_MAX_BATCH_SIZE ?? "50"), - }, delivery: { enabled: process.env.DELIVERY_KEEPER_ENABLED === "true", sweepIntervalMs: Number(process.env.DELIVERY_SWEEP_INTERVAL_MS ?? "60000"), diff --git a/keeper/src/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts index ceaa3ae..5082b63 100644 --- a/keeper/src/delivery/coordinator.ts +++ b/keeper/src/delivery/coordinator.ts @@ -1,7 +1,6 @@ import { BaseError, ContractFunctionRevertedError, - encodeFunctionData, getAddress, type Address, type Hex, @@ -9,7 +8,7 @@ import { } from "viem"; import { withUnstickRetry } from "../tx/unstick.ts"; import type pino from "pino"; -import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; +import { HashPowerFuturesAbi } from "../abi/HashPowerFutures.ts"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; @@ -76,13 +75,13 @@ export class DeliveryCoordinator { this.unwatchers.push( this.chain.publicClient.watchContractEvent({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, eventName: "OrderMatched", onLogs: (logs) => this.onOrderMatched(logs), }), this.chain.publicClient.watchContractEvent({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, eventName: "PositionSettled", onLogs: (logs) => this.onPositionSettled(logs), }), @@ -152,14 +151,14 @@ export class DeliveryCoordinator { const [matched, settled] = await Promise.all([ this.chain.publicClient.getContractEvents({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, eventName: "OrderMatched", fromBlock: start, toBlock: end, }), this.chain.publicClient.getContractEvents({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, eventName: "PositionSettled", fromBlock: start, toBlock: end, @@ -259,7 +258,7 @@ export class DeliveryCoordinator { try { expirationAts = (await this.chain.publicClient.readContract({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getActiveExpirationDates", args: [user], })) as readonly bigint[]; @@ -272,7 +271,7 @@ export class DeliveryCoordinator { const positions = (await this.chain.publicClient.multicall({ contracts: expirationAts.map((expirationAt) => ({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getUserPosition" as const, args: [user, expirationAt] as const, })), @@ -390,7 +389,7 @@ export class DeliveryCoordinator { positions.map((pos) => this.chain.publicClient.simulateContract({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "settlePosition", args: [pos.user, pos.expirationAt], account: this.chain.account, @@ -431,31 +430,14 @@ export class DeliveryCoordinator { if (this.config.keeper.dryRun) { this.logger.info( { batchSize: settleable.length }, - "[dryRun] would call Futures.multicall(settlePosition × N)", + "[dryRun] would call Futures.settlePositions", ); for (const pos of settleable) this.dropTracked(pos.user, pos.expirationAt); return; } - const calldatas: Hex[] = []; - const encodable: TrackedPosition[] = []; - for (const pos of settleable) { - try { - const data = encodeFunctionData({ - abi: FuturesAbi, - functionName: "settlePosition", - args: [pos.user, pos.expirationAt], - }); - calldatas.push(data); - encodable.push(pos); - } catch (err) { - this.logger.error( - { err, user: pos.user, expirationAt: pos.expirationAt.toString() }, - "delivery: encodeFunctionData threw — dropping malformed entry from batch", - ); - } - } - if (calldatas.length === 0) return; + const users = settleable.map((pos) => pos.user); + const expirationAts = settleable.map((pos) => pos.expirationAt); type WriteParams = Parameters< typeof this.chain.walletClient.writeContract @@ -465,9 +447,9 @@ export class DeliveryCoordinator { hash = await withUnstickRetry(this.chain, this.logger, () => this.chain.walletClient.writeContract({ address: this.config.futures.address, - abi: FuturesAbi, - functionName: "multicall", - args: [calldatas], + abi: HashPowerFuturesAbi, + functionName: "settlePositions", + args: [users, expirationAts], account: this.chain.account, chain: this.chain.walletClient.chain ?? null, } as unknown as WriteParams), @@ -481,10 +463,10 @@ export class DeliveryCoordinator { return; } this.logger.warn( - { err, batchSize: encodable.length }, + { err, batchSize: settleable.length }, "delivery batch: write reverted — falling back to per-position retries", ); - for (const pos of encodable) { + for (const pos of settleable) { try { await this.attemptSettle(pos); } catch (innerErr) { @@ -505,13 +487,13 @@ export class DeliveryCoordinator { { hash, blockNumber: receipt.blockNumber.toString(), - batchSize: encodable.length, + batchSize: settleable.length, ...formatGasCost(receipt, this.ethUsdFeed), }, - "delivery batch: multicall confirmed", + "delivery batch: settlePositions confirmed", ); - for (const pos of encodable) { + for (const pos of settleable) { this.dropTracked(pos.user, pos.expirationAt); } } @@ -529,7 +511,7 @@ export class DeliveryCoordinator { try { const sim = (await this.chain.publicClient.simulateContract({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "settlePosition", args, account: this.chain.account, diff --git a/keeper/src/discovery/tracker.ts b/keeper/src/discovery/tracker.ts index a186dc8..b5524b4 100644 --- a/keeper/src/discovery/tracker.ts +++ b/keeper/src/discovery/tracker.ts @@ -10,7 +10,7 @@ import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; import { CollateralVaultAbi as collateralVaultAbi } from "collateral-margin-abi/CollateralVault.ts"; import { HashPowerPerpsDEXAbi as perpsAbi } from "derivatives-marketplace-abi/HashPowerPerpsDEX.ts"; -import { FuturesAbi as futuresAbi } from "futures-marketplace-abi/Futures.ts"; +import { HashPowerFuturesAbi as futuresAbi } from "../abi/HashPowerFutures.ts"; /** * Set of user addresses with collateral or open positions/orders that the diff --git a/keeper/src/index.ts b/keeper/src/index.ts index 1ed6ebd..f3241c2 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -12,7 +12,6 @@ import { Notifier } from "./alert/notifier.ts"; import { Healthcheck } from "./runtime/healthcheck.ts"; import { Scheduler } from "./runtime/scheduler.ts"; import { BalanceMonitor } from "./runtime/balanceMonitor.ts"; -import { OutdatedOrderSweeper } from "./runtime/outdatedOrderSweeper.ts"; import { PerpsVenue } from "./venues/perps.ts"; import { FuturesVenue } from "./venues/futures.ts"; import { PriceFeed } from "./oracle/priceFeed.ts"; @@ -162,18 +161,6 @@ async function main(): Promise { // concern, not specific to any one venue. const balanceMonitor = new BalanceMonitor(chain, config, logger); - // Futures-specific maintenance: walks tracked participants and closes - // any of their orders past `expirationAt` via the permissionless - // `Futures.removeOutdatedOrder` entrypoint (Futures v2.11.0+ no longer - // auto-sweeps on `createOrder`). Cheap, off the hot path — see - // `runtime/outdatedOrderSweeper.ts` for the gas-trade reasoning. Set - // OUTDATED_ORDERS_SWEEP_INTERVAL_MS=0 to disable when another keeper - // owns this responsibility for the deployment. - const outdatedOrderSweeper = - config.outdatedOrders.sweepIntervalMs > 0 - ? new OutdatedOrderSweeper(chain, config, tracker, logger, ethUsdFeed) - : undefined; - // Newly-tracked users should not wait for the next sweep tick. Kicking the // executor wakes any idle workers so they can pick up the new user as soon // as the next sweep enriches the queue. (We can't enqueue here without an @@ -206,7 +193,6 @@ async function main(): Promise { priceFeed.stop(); balanceMonitor.stop(); ethUsdFeed?.stop(); - outdatedOrderSweeper?.stop(); deliveryCoordinator?.stop(); await executor.stop(); if (webhookIngester !== undefined) await webhookIngester.stop(); @@ -301,10 +287,6 @@ async function main(): Promise { await deliveryCoordinator.bootstrapFromUsers(seedUsers); } await scheduler.runSweep(); - // Start the expired-order sweeper after backfill so its eager first - // tick sees the populated tracker, not an empty one. Skipped entirely - // when `outdatedOrders.sweepIntervalMs` is 0 (operator opt-out). - if (outdatedOrderSweeper !== undefined) await outdatedOrderSweeper.start(); // Backfill fires `tracker.onAdded` for every existing user, which the // predictor consumes via `rebuild`. Those rebuilds are fire-and-forget, // so we wait until `inflightRebuilds` drains before claiming "running" diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index 718e9ad..d1137f2 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -4,8 +4,9 @@ import type { Config } from "../config.ts"; import { CollateralVaultAbi } from "collateral-margin-abi/CollateralVault.ts"; import { PortfolioMarginEngineAbi } from "collateral-margin-abi/PortfolioMarginEngine.ts"; import { HashPowerPerpsDEXAbi } from "derivatives-marketplace-abi/HashPowerPerpsDEX.ts"; -import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; +import { HashPowerFuturesAbi } from "../abi/HashPowerFutures.ts"; import type { AccountSnapshot, MMParams } from "@hashpower/portfolio-margin"; +import { PerpsPositionAbi } from "../venues/perpsPositionAbi.ts"; /** * Read the engine-wide constants once. They only change on PME admin @@ -63,19 +64,20 @@ export async function readMMParams( * Read everything needed to evaluate `mmSurplus(P)` for a single user as a * function of price. Two RPC round-trips: * - * 1. Bulk multicall: balance, both venues' `getRiskView` / `getOrderValues`, - * the perp position, futures activeExpirationAts. - * 2. Per-expiry multicall: hydrate each aggregate via `getUserPosition`, plus - * its `settlementPrice` — an expiry that has settled but not yet been swept - * out of the active set is marked at that pinned price and carries no delta, - * so the predictor cannot treat it like a live leg. + * 1. Bulk multicall: balance, perps risk/aggregate/position, futures risk, + * futures active position expiries, and the tradable delivery window. + * 2. Per-expiry multicall: hydrate each futures position via `getUserPosition` + * + `settlementPrice`, and sum `getOrderAggregateAtExpiration` over the + * tradable window for unclamped limit-price totals. * - * Round-trip 2 collapses to zero calls when the user has no futures - * positions (the common case for perps-only users). + * Round-trip 2 collapses to zero position/settlement calls when the user has + * no futures positions (the common case for perps-only users). Order-aggregate + * calls still run when the tradable window is non-empty. * * `getRiskView` carries the per-side order delta but reports fill loss only at the * current mark, and the clamp makes that non-invertible once it reads zero — so the - * per-side limit-price totals come from `getOrderValues` and the predictor derives + * per-side limit-price totals come from the order-aggregate cache (perps: + * `getOrderAggregate`; futures: summed AtExpiration) and the predictor derives * fill loss at whatever price it is evaluating. Pending funding also rides in * `getRiskView`, replacing the separate `getPendingFunding` read. */ @@ -88,10 +90,10 @@ export async function readAccountSnapshot( balance, perpPosition, perpRisk, - perpOrderValues, + perpOrderAggregate, futuresRisk, - futuresOrderValues, activeExpirationAts, + tradableExpirationAts, ] = await chain.publicClient.multicall({ contracts: [ { @@ -102,7 +104,7 @@ export async function readAccountSnapshot( }, { address: config.perps.address, - abi: HashPowerPerpsDEXAbi, + abi: PerpsPositionAbi, functionName: "getUserPosition" as const, args: [user] as const, }, @@ -115,51 +117,72 @@ export async function readAccountSnapshot( { address: config.perps.address, abi: HashPowerPerpsDEXAbi, - functionName: "getOrderValues" as const, + functionName: "getOrderAggregate" as const, args: [user] as const, }, { address: config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getRiskView" as const, args: [user] as const, }, { address: config.futures.address, - abi: FuturesAbi, - functionName: "getOrderValues" as const, + abi: HashPowerFuturesAbi, + functionName: "getActiveExpirationDates" as const, args: [user] as const, }, { address: config.futures.address, - abi: FuturesAbi, - functionName: "getActiveExpirationDates" as const, - args: [user] as const, + abi: HashPowerFuturesAbi, + functionName: "getExpirationDates" as const, }, ] as const, allowFailure: false, }); const expirationAts = activeExpirationAts as readonly bigint[]; + const orderExpirationAts = tradableExpirationAts as readonly bigint[]; const futuresPositions: AccountSnapshot["futures"]["positions"] = []; - if (expirationAts.length > 0) { + + type OrderAggregate = { + buyQty: bigint; + sellQty: bigint; + buyValue: bigint; + sellValue: bigint; + }; + let futuresOrderAggregate: OrderAggregate = { + buyQty: 0n, + sellQty: 0n, + buyValue: 0n, + sellValue: 0n, + }; + + if (expirationAts.length > 0 || orderExpirationAts.length > 0) { const perExpiry = await chain.publicClient.multicall({ contracts: [ ...expirationAts.map((expirationAt) => ({ address: config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getUserPosition" as const, args: [user, expirationAt] as const, })), ...expirationAts.map((expirationAt) => ({ address: config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "settlementPrice" as const, args: [expirationAt] as const, })), + ...orderExpirationAts.map((expirationAt) => ({ + address: config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getOrderAggregateAtExpiration" as const, + args: [user, expirationAt] as const, + })), ], allowFailure: false, }); + for (let i = 0; i < expirationAts.length; i++) { const pos = perExpiry[i] as { netQuantity: bigint; netEntryValue: bigint } | undefined; const settlementPrice = perExpiry[expirationAts.length + i] as bigint | undefined; @@ -173,6 +196,18 @@ export async function readAccountSnapshot( settlementPrice: settlementPrice ?? 0n, }); } + + const orderOffset = expirationAts.length * 2; + for (let i = 0; i < orderExpirationAts.length; i++) { + const aggregate = perExpiry[orderOffset + i] as OrderAggregate | undefined; + if (aggregate === undefined) continue; + futuresOrderAggregate = { + buyQty: futuresOrderAggregate.buyQty + aggregate.buyQty, + sellQty: futuresOrderAggregate.sellQty + aggregate.sellQty, + buyValue: futuresOrderAggregate.buyValue + aggregate.buyValue, + sellValue: futuresOrderAggregate.sellValue + aggregate.sellValue, + }; + } } const funding = perpRisk.pendingFunding; @@ -181,27 +216,34 @@ export async function readAccountSnapshot( balance: balance as bigint, perp: { netQty: perpPosition.netQuantity, - entryPrice: perpPosition.aggregatedEntryPrice, - orders: restingOrders(perpRisk, perpOrderValues), + entryPrice: + perpPosition.netQuantity === 0n + ? 0n + : (abs(perpPosition.netEntryValue) * 1_000_000n) / abs(perpPosition.netQuantity), + orders: restingOrders(perpRisk, perpOrderAggregate), // PME uses `max(0, pendingFunding)` — only what the user owes. fundingOwed: funding > 0n ? funding : 0n, }, futures: { positions: futuresPositions, - orders: restingOrders(futuresRisk, futuresOrderValues), + orders: restingOrders(futuresRisk, futuresOrderAggregate), }, }; } -/** Pair a venue's `getRiskView` deltas with its `getOrderValues` limit-price totals. */ +/** Pair a venue's risk deltas with its cached order aggregate. */ function restingOrders( risk: { buyOrderDelta: bigint; sellOrderDelta: bigint }, - values: readonly [bigint, bigint], + aggregate: { buyValue: bigint; sellValue: bigint }, ): AccountSnapshot["perp"]["orders"] { return { buyDelta: risk.buyOrderDelta, sellDelta: risk.sellOrderDelta, - buyValue: values[0], - sellValue: values[1], + buyValue: aggregate.buyValue, + sellValue: aggregate.sellValue, }; } + +function abs(value: bigint): bigint { + return value < 0n ? -value : value; +} diff --git a/keeper/src/runtime/outdatedOrderSweeper.ts b/keeper/src/runtime/outdatedOrderSweeper.ts deleted file mode 100644 index 8043fe2..0000000 --- a/keeper/src/runtime/outdatedOrderSweeper.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { - BaseError, - ContractFunctionRevertedError, - encodeFunctionData, - type Address, - type Hex, -} from "viem"; -import type pino from "pino"; -import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; -import { withUnstickRetry } from "../tx/unstick.ts"; -import { formatGasCost } from "../tx/gasCost.ts"; -import type { Chain } from "../chain.ts"; -import type { Config } from "../config.ts"; -import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; -import type { ParticipantTracker } from "../discovery/tracker.ts"; - -/** - * Periodic sweep that closes expired Futures orders via the permissionless - * `Futures.removeOutdatedOrder(orderId)` entrypoint. - * - * Why this lives in the keeper at all: as of Futures v2.11.0 `createOrder` / - * `createOrders` no longer auto-sweep the caller's stale orders on the hot - * path (it was costing ~50-100k gas per placement just to walk an empty - * expired list). Cleanup is now an explicit, permissionless cron job — and - * the keeper is the natural operator for it because: - * - * 1. It already discovers participants (`ParticipantTracker`). - * 2. It already has the signer + tx-retry plumbing (`withUnstickRetry`). - * 3. Expired orders pin the owner against `MAX_ORDERS_PER_PARTICIPANT` - * and leave dead price levels on the book. Letting them rot makes - * every health probe and book read slightly slower forever. - * - * Today the keeper eats the gas with no on-chain reward — see the - * `TODO(keeper-incentive)` block in `Futures.sol` next to `removeOutdatedOrder` - * for a sketch of a maker-fee-escrow bounty that could pay for this work. - * - * Hot path: - * - * tick → for each tracked user: - * 1. readContract `getUserOrders(user)` — empty? skip - * 2. multicall `getOrder(id)` for each id → filter expired - * 3. one `Futures.multicall([removeOutdatedOrder(id1), ...])` write - * (capped at `outdatedOrders.maxBatchSize`; larger user-side - * fan-outs are split into N batches, each its own tx). - * - * Recoverable reverts during simulation (`OrderNotExists`, `OrderNotExpired`) - * just drop the id from the batch — they happen when an id closed between - * our read and our write (user cancel, match, prior keeper instance won the - * race). We log them at debug because they're entirely benign. - * - * Non-futures venues (perps) don't have order expiry so this module is - * Futures-only by design. - */ - -const RECOVERABLE_REVERTS = new Set(["OrderNotExists", "OrderNotExpired"]); - -interface ExpiredOrder { - user: Address; - orderId: Hex; - expirationAt: bigint; -} - -export class OutdatedOrderSweeper { - private timer: NodeJS.Timeout | undefined; - private running = false; - private inflightSweep = false; - - private readonly chain: Chain; - private readonly config: Config; - private readonly tracker: ParticipantTracker; - private readonly logger: pino.Logger; - private readonly ethUsdFeed: EthUsdFeed | undefined; - - constructor( - chain: Chain, - config: Config, - tracker: ParticipantTracker, - logger: pino.Logger, - ethUsdFeed?: EthUsdFeed, - ) { - this.chain = chain; - this.config = config; - this.tracker = tracker; - this.logger = logger.child({ component: "outdatedOrderSweeper" }); - // Optional ETH/USD source for `gasCostUsd` on confirmed-tx logs. - this.ethUsdFeed = ethUsdFeed; - } - - /** - * Run a single sweep cycle to completion. Public for tests. Idempotent - * across concurrent calls — a second invocation while one is in flight - * is dropped (we don't want overlapping sweeps racing on the same nonce). - */ - async runSweep(): Promise { - if (this.inflightSweep) { - this.logger.debug("sweep skipped — previous sweep still running"); - return 0; - } - this.inflightSweep = true; - try { - const users = this.tracker.list(); - if (users.length === 0) return 0; - - // Pull the chain's view of "now" rather than `Date.now()`. Block - // timestamps lag wall clock by up to a slot (~2s on Base), and the - // contract's `OrderNotExpired` guard uses `block.timestamp` — using - // the same clock here keeps us from broadcasting txs that'll just - // revert during the brief window around expiry. - const blockTimestamp = await this.readBlockTimestamp(); - if (blockTimestamp === undefined) return 0; - - const expired = await this.discoverExpired(users, blockTimestamp); - if (expired.length === 0) { - this.logger.debug( - { tracked: users.length }, - "sweep clean — no expired orders", - ); - return 0; - } - - this.logger.info( - { tracked: users.length, expired: expired.length }, - "sweep: closing expired orders", - ); - - const max = Math.max(1, this.config.outdatedOrders.maxBatchSize); - let closed = 0; - for (let i = 0; i < expired.length; i += max) { - const slice = expired.slice(i, i + max); - try { - closed += await this.closeBatch(slice); - } catch (err) { - this.logger.error( - { err, batchSize: slice.length }, - "sweep: batch threw — continuing with next batch", - ); - } - } - return closed; - } catch (err) { - this.logger.warn({ err }, "sweep failed — will retry next tick"); - return 0; - } finally { - this.inflightSweep = false; - } - } - - /** - * Immediate sweep at boot (catches stale orders that built up while the - * keeper was down), then periodic polls at - * `outdatedOrders.sweepIntervalMs`. Idempotent — repeated calls are a - * no-op so the standard wiring sequence in `index.ts` doesn't need - * special-cased guards. - */ - async start(): Promise { - if (this.running) return; - this.running = true; - // Fire one eager sweep so an operator deploying after a long outage - // doesn't have to wait a full interval to see the backlog drained. - await this.runSweep(); - this.timer = setInterval(() => { - void this.runSweep(); - }, this.config.outdatedOrders.sweepIntervalMs); - if (typeof this.timer.unref === "function") this.timer.unref(); - } - - stop(): void { - if (!this.running) return; - this.running = false; - if (this.timer !== undefined) { - clearInterval(this.timer); - this.timer = undefined; - } - } - - private async readBlockTimestamp(): Promise { - try { - const block = await this.chain.publicClient.getBlock({ - blockTag: "latest", - }); - return block.timestamp; - } catch (err) { - this.logger.warn({ err }, "getBlock(latest) failed — skipping sweep"); - return undefined; - } - } - - /** - * For each tracked user, read its order ids and hydrate to find - * `expirationAt < blockTimestamp`. Per-user RPC failure is logged and - * skipped — one bad address (e.g. recently dropped from the tracker) - * shouldn't block the rest of the sweep. - */ - private async discoverExpired( - users: readonly Address[], - blockTimestamp: bigint, - ): Promise { - const expired: ExpiredOrder[] = []; - - for (const user of users) { - let orderIds: readonly Hex[]; - try { - orderIds = (await this.chain.publicClient.readContract({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "getUserOrders", - args: [user], - })) as readonly Hex[]; - } catch (err) { - this.logger.warn( - { err, user }, - "getUserOrders failed — skipping user this sweep", - ); - continue; - } - if (orderIds.length === 0) continue; - - let orders: ReadonlyArray<{ expirationAt: bigint }>; - try { - orders = (await this.chain.publicClient.multicall({ - contracts: orderIds.map((id) => ({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "getOrder" as const, - args: [id] as const, - })), - allowFailure: false, - })) as ReadonlyArray<{ expirationAt: bigint }>; - } catch (err) { - this.logger.warn( - { err, user, orderCount: orderIds.length }, - "multicall(getOrder) failed — skipping user this sweep", - ); - continue; - } - - for (let i = 0; i < orderIds.length; i++) { - const order = orders[i]; - const orderId = orderIds[i] as Hex; - if (order === undefined) continue; - // Matches the contract guard: `expirationAt >= block.timestamp` reverts - // `OrderNotExpired`. Use strict-less-than here so we don't broadcast - // a tx in the very-edge case `expirationAt == blockTimestamp` (next - // block will satisfy it cleanly). - if (order.expirationAt < blockTimestamp) { - expired.push({ user, orderId, expirationAt: order.expirationAt }); - } - } - } - return expired; - } - - /** - * Simulates each `removeOutdatedOrder(id)` to filter stale entries - * (`OrderNotExists` / `OrderNotExpired` — usually a race against a user - * cancel or a prior keeper run), then encodes the survivors into one - * `Futures.multicall(bytes[])` write. - * - * Returns the number of orders actually broadcast for closure (zero on - * dry-run or empty-batch-after-filter — both are normal). Throws only on - * unexpected reverts during the write phase; transient RPC failures are - * caught and logged so the next sweep retries. - */ - private async closeBatch(batch: readonly ExpiredOrder[]): Promise { - type SimParams = Parameters< - typeof this.chain.publicClient.simulateContract - >[0]; - - const simResults = await Promise.allSettled( - batch.map((entry) => - this.chain.publicClient.simulateContract({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "removeOutdatedOrder", - args: [entry.orderId], - account: this.chain.account, - } as unknown as SimParams), - ), - ); - - const survivors: ExpiredOrder[] = []; - for (let i = 0; i < batch.length; i++) { - const entry = batch[i] as ExpiredOrder; - const r = simResults[i] as PromiseSettledResult; - if (r.status === "fulfilled") { - survivors.push(entry); - continue; - } - const decoded = decodeRecoverableRevert(r.reason); - if (decoded !== undefined) { - this.logger.debug( - { orderId: entry.orderId, user: entry.user, revert: decoded }, - "skipping stale candidate (state moved between read and simulate)", - ); - continue; - } - this.logger.warn( - { err: r.reason, orderId: entry.orderId, user: entry.user }, - "simulate failed with non-recoverable error — dropping from batch", - ); - } - - if (survivors.length === 0) return 0; - - if (this.config.keeper.dryRun) { - this.logger.info( - { batchSize: survivors.length }, - "[dryRun] would call Futures.multicall(removeOutdatedOrder × N)", - ); - return 0; - } - - const calldatas: Hex[] = survivors.map((entry) => - encodeFunctionData({ - abi: FuturesAbi, - functionName: "removeOutdatedOrder", - args: [entry.orderId], - }), - ); - - type WriteParams = Parameters< - typeof this.chain.walletClient.writeContract - >[0]; - let hash: Hex; - try { - // Same wallet that liquidates / settles — if a previous run left a - // stuck pending tx in the mempool we need to clear it before this - // sweep can broadcast. `withUnstickRetry` handles the common case - // automatically; anything still broken after that surfaces normally. - hash = await withUnstickRetry(this.chain, this.logger, () => - this.chain.walletClient.writeContract({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "multicall", - args: [calldatas], - account: this.chain.account, - chain: this.chain.walletClient.chain ?? null, - } as unknown as WriteParams), - ); - } catch (err) { - // Transient tx-submission failure → next sweep retries. We don't - // want unhandled rejection on the setInterval-fired path to crash - // the keeper, so always swallow and log. - this.logger.warn( - { err, batchSize: survivors.length }, - "tx submission failed — sweep will retry", - ); - return 0; - } - - const receipt = await this.chain.publicClient.waitForTransactionReceipt({ - hash, - confirmations: this.config.coordinator.confirmationBlocks, - }); - this.logger.info( - { - hash, - blockNumber: receipt.blockNumber.toString(), - batchSize: survivors.length, - ...formatGasCost(receipt, this.ethUsdFeed), - }, - "multicall(removeOutdatedOrder × N) confirmed", - ); - return survivors.length; - } -} - -function decodeRecoverableRevert(err: unknown): string | undefined { - if (!(err instanceof BaseError)) return undefined; - const revert = err.walk((e) => e instanceof ContractFunctionRevertedError); - if (!(revert instanceof ContractFunctionRevertedError)) return undefined; - const name = revert.data?.errorName; - if (typeof name !== "string") return undefined; - return RECOVERABLE_REVERTS.has(name) ? name : undefined; -} - -export const __testing = { decodeRecoverableRevert }; diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index 18b4f08..b5b7aba 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -2,7 +2,7 @@ import { pad, toHex, type Abi, type Address, type Hex } from "viem"; import type pino from "pino"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; -import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; +import { HashPowerFuturesAbi } from "../abi/HashPowerFutures.ts"; import { sendLiquidate } from "../tx/liquidate.ts"; import { readAccountSnapshot, readMMParams } from "../predict/snapshot.ts"; import { type MMParams, solveFuturesClosesToTarget } from "@hashpower/portfolio-margin"; @@ -31,7 +31,7 @@ const LIQUIDATE_ORDERS_ABI = [ ] as const; const FUTURES_LIQUIDATE_ORDERS_ABI = [ - ...FuturesAbi.filter( + ...HashPowerFuturesAbi.filter( (item) => !( typeof item === "object" && @@ -80,19 +80,13 @@ export class FuturesVenue implements Venue { } async readOpenOrders(user: Address): Promise { - const orderIds = (await this.chain.publicClient.readContract({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "getUserOrders", - args: [user], - })) as readonly Hex[]; - + const orderIds = await this.readActiveOrderIds(user); if (orderIds.length === 0) return []; const orders = await this.chain.publicClient.multicall({ contracts: orderIds.map((id) => ({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getOrder" as const, args: [id] as const, })), @@ -109,13 +103,13 @@ export class FuturesVenue implements Venue { const [expirationAts, marketPrice] = await Promise.all([ this.chain.publicClient.readContract({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getActiveExpirationDates", args: [user], }) as Promise, this.chain.publicClient.readContract({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getMarketPrice", }) as Promise, ]); @@ -125,7 +119,7 @@ export class FuturesVenue implements Venue { const positions = await this.chain.publicClient.multicall({ contracts: expirationAts.map((expirationAt) => ({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getUserPosition" as const, args: [user, expirationAt] as const, })), @@ -160,12 +154,7 @@ export class FuturesVenue implements Venue { ): Promise { let targetIds = ids; if (targetIds === undefined) { - targetIds = (await this.chain.publicClient.readContract({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "getUserOrders", - args: [user], - })) as readonly Hex[]; + targetIds = await this.readActiveOrderIds(user); } if (targetIds.length === 0) { return { skipped: "notLiquidatable" }; @@ -194,7 +183,7 @@ export class FuturesVenue implements Venue { this.getMMParams(), this.chain.publicClient.readContract({ address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getMarketPrice", }) as Promise, ]); @@ -229,7 +218,7 @@ export class FuturesVenue implements Venue { config: this.config, logger: this.logger, address: this.config.futures.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "liquidatePositions", args: [user, expirationAts, closeQtys], feeEventName: "PositionLiquidated", @@ -245,6 +234,36 @@ export class FuturesVenue implements Venue { : { feeEarned: result.feeEarned, positionsClosed: Number(contractsClosed) }; } + /** + * Active-window resting order ids: `getExpirationDates()` then + * `getUserOrdersAtExpiration` per delivery (no cross-expiry on-chain getter). + */ + private async readActiveOrderIds(user: Address): Promise { + const expirationAts = (await this.chain.publicClient.readContract({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getExpirationDates", + })) as readonly bigint[]; + + if (expirationAts.length === 0) return []; + + const perExpiry = await this.chain.publicClient.multicall({ + contracts: expirationAts.map((expirationAt) => ({ + address: this.config.futures.address, + abi: HashPowerFuturesAbi, + functionName: "getUserOrdersAtExpiration" as const, + args: [user, expirationAt] as const, + })), + allowFailure: false, + }); + + const orderIds: Hex[] = []; + for (const ids of perExpiry as readonly (readonly Hex[])[]) { + for (const id of ids) orderIds.push(id); + } + return orderIds; + } + private async getMMParams(): Promise { if (this.mmParams !== undefined) return this.mmParams; this.mmParams = await readMMParams(this.chain, this.config); diff --git a/keeper/src/venues/perps.ts b/keeper/src/venues/perps.ts index 228387f..0689acb 100644 --- a/keeper/src/venues/perps.ts +++ b/keeper/src/venues/perps.ts @@ -7,6 +7,7 @@ import { sendLiquidate } from "../tx/liquidate.ts"; import { readAccountSnapshot, readMMParams } from "../predict/snapshot.ts"; import { type MMParams, solvePerpCloseToTarget } from "@hashpower/portfolio-margin"; import type { EthUsdFeed } from "../oracle/ethUsdFeed.ts"; +import { PerpsPositionAbi } from "./perpsPositionAbi.ts"; import type { LiquidateOrdersOutcome, MarketId, @@ -81,13 +82,13 @@ export class PerpsVenue implements Venue { } async readPositions(user: Address): Promise { - // Single-market netted position. We need entryPrice + qty + market price - // to derive `unrealizedLoss` and `notional`. + // Single-market netted position. The signed entry value lets us derive PnL + // directly without reconstructing a rounded average entry price. const [position, marketPrice] = await this.chain.publicClient.multicall({ contracts: [ { address: this.config.perps.address, - abi: HashPowerPerpsDEXAbi, + abi: PerpsPositionAbi, functionName: "getUserPosition" as const, args: [user] as const, }, @@ -104,9 +105,8 @@ export class PerpsVenue implements Venue { const absQty = abs(position.netQuantity); const isLong = position.netQuantity > 0n; - // PnL in token decimals: priceDiff * netQty / 10^QUANTITY_DECIMALS - const priceDiff = marketPrice - position.aggregatedEntryPrice; - const pnl = (priceDiff * position.netQuantity) / QUANTITY_SCALE; + // PnL in token decimals: mark value minus the signed entry value. + const pnl = (marketPrice * position.netQuantity) / QUANTITY_SCALE - position.netEntryValue; const unrealizedLoss = pnl < 0n ? -pnl : 0n; const notional = (marketPrice * absQty) / QUANTITY_SCALE; diff --git a/keeper/src/venues/perpsPositionAbi.ts b/keeper/src/venues/perpsPositionAbi.ts new file mode 100644 index 0000000..6baf9bd --- /dev/null +++ b/keeper/src/venues/perpsPositionAbi.ts @@ -0,0 +1,19 @@ +/** Exact local fragment while the pinned perps ABI still exposes the legacy position tuple. */ +export const PerpsPositionAbi = [ + { + type: "function", + name: "getUserPosition", + stateMutability: "view", + inputs: [{ name: "_user", type: "address" }], + outputs: [ + { + name: "", + type: "tuple", + components: [ + { name: "netQuantity", type: "int256" }, + { name: "netEntryValue", type: "int256" }, + ], + }, + ], + }, +] as const; diff --git a/keeper/src/venues/types.ts b/keeper/src/venues/types.ts index 03c3b19..2bd1d8a 100644 --- a/keeper/src/venues/types.ts +++ b/keeper/src/venues/types.ts @@ -79,7 +79,8 @@ export interface Venue { /** * Calls `liquidateOrders(user, ids[])` on the venue. Keeper-chosen ids; * on-chain stop-on-failure keeps prior cancels and stops when healthy. - * When `ids` is omitted the venue reads `getUserOrders` first. + * When `ids` is omitted the venue discovers resting ids first (perps: + * `getUserOrders`; futures: `getExpirationDates` + per-expiry order ids). */ liquidateOrders(user: Address, ids?: readonly Hex[]): Promise; diff --git a/keeper/tests/delivery/coordinator.test.ts b/keeper/tests/delivery/coordinator.test.ts index 97f5890..3e447f8 100644 --- a/keeper/tests/delivery/coordinator.test.ts +++ b/keeper/tests/delivery/coordinator.test.ts @@ -95,6 +95,7 @@ interface ChainStubOptions { activeDatesByUser?: Record; positionsByUserDate?: Record; readContractError?: (functionName: string) => Error | undefined; + writes?: Array<{ functionName: string; args: readonly unknown[] }>; } function posKey(user: Address, expirationAt: bigint): string { @@ -176,7 +177,16 @@ function makeChain(opts: ChainStubOptions = {}): Chain { }, walletClient: { chain: null, - writeContract: async () => writeHash, + writeContract: async ({ + functionName, + args, + }: { + functionName: string; + args: readonly unknown[]; + }) => { + opts.writes?.push({ functionName, args }); + return writeHash; + }, }, } as unknown as Chain; } @@ -285,6 +295,7 @@ describe("delivery/coordinator: bootstrap + settle", () => { it("settleBatch simulates settlePosition(user, expirationAt) and drops on success", async () => { const simulated: unknown[][] = []; + const writes: Array<{ functionName: string; args: readonly unknown[] }> = []; const chain = makeChain({ // Far-future timestamp so bootstrap's trailing sweep is a no-op. blockTimestamp: 1n, @@ -296,6 +307,7 @@ describe("delivery/coordinator: bootstrap + settle", () => { positionsByUserDate: { [posKey(USER_A, DELIVERY_A)]: { netQuantity: 1n, netEntryValue: 50n }, }, + writes, }); const coord = new DeliveryCoordinator(chain, makeConfig({ settleDelayMs: 0 }), silentLogger); await coord.bootstrapFromUsers([USER_A]); @@ -307,6 +319,13 @@ describe("delivery/coordinator: bootstrap + settle", () => { USER_A.toLowerCase(), ); assert.equal(simulated[0]?.[1], DELIVERY_A); + assert.equal(writes.length, 1); + assert.equal(writes[0]?.functionName, "settlePositions"); + assert.equal( + ((writes[0]?.args[0] as Address[])[0] as string).toLowerCase(), + USER_A.toLowerCase(), + ); + assert.deepEqual(writes[0]?.args[1], [DELIVERY_A]); assert.equal(coord.has(USER_A, DELIVERY_A), false); }); diff --git a/keeper/tests/integration/artifacts.ts b/keeper/tests/integration/artifacts.ts index 1266b19..97de775 100644 --- a/keeper/tests/integration/artifacts.ts +++ b/keeper/tests/integration/artifacts.ts @@ -126,7 +126,12 @@ export const artifacts = { multicall3: () => readArtifact("perps", "contracts/Multicall3", "Multicall3"), // ── futures (sibling repo) ──────────────────────────────────────────── - futures: () => readArtifact("futures", "contracts/Futures", "Futures"), + futures: () => + readArtifact( + "futures", + "contracts/HashPowerFutures", + "HashPowerFutures", + ), } as const; /** Resolved repo paths — exported for diagnostic logs. */ diff --git a/keeper/tests/integration/buildKeeper.ts b/keeper/tests/integration/buildKeeper.ts index 2da307e..26983cb 100644 --- a/keeper/tests/integration/buildKeeper.ts +++ b/keeper/tests/integration/buildKeeper.ts @@ -86,7 +86,7 @@ export interface BuildKeeperOverrides { */ deliveryBootstrapUsers?: readonly Address[]; /** - * Maximum settlePosition calls bundled into one Futures.multicall tx by + * Maximum position pairs passed to one Futures.settlePositions tx by * the delivery coordinator. Defaults to 50 for parity with production. * Override to a small value to assert batching behaviour explicitly * (e.g. set to 1 to force per-id calls, or 2 to assert chunked sweeps). @@ -252,13 +252,6 @@ function buildConfig( balanceLowWei: 10_000_000_000_000_000n, balanceCriticalWei: 1_000_000_000_000_000n, }, - outdatedOrders: { - // Disabled by default in integration tests — they cover liquidation - // and delivery flows; expired-order sweep has its own unit tests. - // Tests that want to exercise it can override via a future flag. - sweepIntervalMs: 0, - maxBatchSize: 50, - }, delivery: { enabled: overrides.delivery === true, // Tighter than production so tests don't have to wait a minute for diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index a29da42..d6a25ea 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -248,8 +248,8 @@ export async function deployStack(rpcUrl: string): Promise { // hashpower settles per-day, so only the expiry spacing schedules the book). const firstExpirationAt = latestBlock.timestamp + BigInt(FUTURES_EXPIRATION_INTERVAL_DAYS * 24 * 3600); - // initialize(hashrateOracle, liquidationMarginPercent, minimumPriceIncrement, - // expirationIntervalDays, futureExpirationDatesCount, firstFutureExpirationDate) + // initialize(hashrateOracle, liquidationMarginPercent, + // futureExpirationDatesCount, firstFutureExpirationDate) const futures = await deployProxy( publicClient, owner.client, @@ -259,8 +259,6 @@ export async function deployStack(rpcUrl: string): Promise { [ hashpriceOracle, FUTURES_LIQUIDATION_MARGIN_PCT, - MIN_PRICE_INCREMENT, - FUTURES_EXPIRATION_INTERVAL_DAYS, FUTURES_FUTURE_DELIVERY_DATES_COUNT, firstExpirationAt, ], diff --git a/keeper/tests/integration/hardhat.config.ts b/keeper/tests/integration/hardhat.config.ts new file mode 100644 index 0000000..0f3a8cb --- /dev/null +++ b/keeper/tests/integration/hardhat.config.ts @@ -0,0 +1,15 @@ +/** + * Keeper integration-node configuration only. + * + * Sibling implementations can exceed EIP-170 while under active development; + * the integration suite exercises their behavior, not deployability. + */ +export default { + networks: { + hardhat: { + type: "edr-simulated", + chainType: "l1", + allowUnlimitedContractSize: true, + }, + }, +}; diff --git a/keeper/tests/integration/helpers.ts b/keeper/tests/integration/helpers.ts index 1d845c6..20c1563 100644 --- a/keeper/tests/integration/helpers.ts +++ b/keeper/tests/integration/helpers.ts @@ -3,6 +3,7 @@ import type { Address, Hex } from "viem"; import type { PlanOutcome } from "../../src/coordinator/planner.ts"; import type { KeeperHarness } from "./buildKeeper.ts"; import type { DeployedStack } from "./deployStack.ts"; +import { PerpsPositionAbi } from "../../src/venues/perpsPositionAbi.ts"; /** * Integration-test helpers. @@ -73,7 +74,7 @@ export async function runOneSweep(keeper: KeeperHarness, user: Address): Promise export interface PerpsPosition { /** Signed; positive = long, negative = short, zero = flat. */ netQuantity: bigint; - aggregatedEntryPrice: bigint; + netEntryValue: bigint; } export async function readPerpsPosition( @@ -82,7 +83,7 @@ export async function readPerpsPosition( ): Promise { return (await stack.publicClient.readContract({ address: stack.addresses.perps, - abi: stack.abis.perps, + abi: PerpsPositionAbi, functionName: "getUserPosition", args: [user], })) as PerpsPosition; @@ -138,12 +139,30 @@ export async function readFuturesOrderIds( stack: DeployedStack, user: Address, ): Promise { - return (await stack.publicClient.readContract({ + const expirationAts = (await stack.publicClient.readContract({ address: stack.addresses.futures, abi: stack.abis.futures, - functionName: "getUserOrders", - args: [user], - })) as readonly Hex[]; + functionName: "getExpirationDates", + })) as readonly bigint[]; + if (expirationAts.length === 0) return []; + + const perExpiry = await Promise.all( + expirationAts.map( + (expirationAt) => + stack.publicClient.readContract({ + address: stack.addresses.futures, + abi: stack.abis.futures, + functionName: "getUserOrdersAtExpiration", + args: [user, expirationAt], + }) as Promise, + ), + ); + + const orderIds: Hex[] = []; + for (const ids of perExpiry) { + for (const id of ids) orderIds.push(id); + } + return orderIds; } /** Resolves to true once `user` is flat on perps. */ diff --git a/keeper/tests/integration/nodeProcess.ts b/keeper/tests/integration/nodeProcess.ts index 852d98b..b0df3e7 100644 --- a/keeper/tests/integration/nodeProcess.ts +++ b/keeper/tests/integration/nodeProcess.ts @@ -3,10 +3,10 @@ import { resolve } from "node:path"; import { createPublicClient, http } from "viem"; /** - * Spawn `pnpm exec hardhat node` from `collateral-margin/contracts/`, the only - * package in this repo that already has Hardhat 3 + viem wired up. The node - * is shared across every integration test — scenarios use `evm_snapshot` / - * `evm_revert` to isolate themselves (see `loadFixture.ts`). + * Spawn a Hardhat node from `collateral-margin/contracts/`, the only package + * in this repo that already has Hardhat 3 + viem wired up. The keeper-specific + * config disables the contract-size limit for sibling implementation artifacts + * without changing any production network configuration. * * We deliberately do NOT spin up Hardhat in `keeper/` itself: the sibling * perps and futures repos each have a deep Solidity dep tree (OZ, OZ @@ -29,8 +29,8 @@ const POLL_INTERVAL_MS = 200; export interface StartHardhatNodeOptions { /** - * Absolute path to the directory whose `hardhat.config.ts` we should run - * `pnpm exec hardhat node` from. Defaults to the workspace's + * Absolute path to the directory whose Hardhat installation should run + * the integration config. Defaults to the workspace's * `collateral-margin/contracts/` (`../../contracts` relative to this file). */ hardhatProjectDir?: string; @@ -53,18 +53,27 @@ export async function startHardhatNode( options: StartHardhatNodeOptions = {}, ): Promise { const cwd = options.hardhatProjectDir ?? resolve(import.meta.dirname, "../../../contracts"); + const config = resolve(import.meta.dirname, "hardhat.config.ts"); const rpcUrl = options.rpcUrl ?? DEFAULT_RPC_URL; const readyTimeoutMs = options.readyTimeoutMs ?? READY_TIMEOUT_MS; - const proc = spawn("pnpm", ["exec", "hardhat", "node"], { - cwd, - // `detached: true` puts the child in its own process group so we can - // kill the whole tree on shutdown — Hardhat spawns helpers (the EDR - // worker, the JSON-RPC server) that would otherwise outlive SIGTERM. - detached: true, - env: { ...process.env, FORCE_COLOR: "0" }, - stdio: ["ignore", options.verbose ? "inherit" : "ignore", options.verbose ? "inherit" : "pipe"], - }); + const proc = spawn( + "pnpm", + ["exec", "hardhat", "--config", config, "--network", "hardhat", "node"], + { + cwd, + // `detached: true` puts the child in its own process group so we can + // kill the whole tree on shutdown — Hardhat spawns helpers (the EDR + // worker, the JSON-RPC server) that would otherwise outlive SIGTERM. + detached: true, + env: { ...process.env, FORCE_COLOR: "0" }, + stdio: [ + "ignore", + options.verbose ? "inherit" : "ignore", + options.verbose ? "inherit" : "pipe", + ], + }, + ); // Even when stderr is piped silently we still want to surface crashes: // attach a one-shot handler that captures the first ~256 chars so the diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index be7f4f3..d0c29cb 100644 --- a/keeper/tests/predict/coordinator.test.ts +++ b/keeper/tests/predict/coordinator.test.ts @@ -118,7 +118,10 @@ function buildHarness({ case "balanceOf": return balance; case "getUserPosition": - return { netQuantity: perpNetQty, aggregatedEntryPrice: perpEntry }; + return { + netQuantity: perpNetQty, + netEntryValue: (perpNetQty * perpEntry) / 1_000_000n, + }; case "getRiskView": return { netPositionDelta: 0n, @@ -129,10 +132,14 @@ function buildHarness({ buyOrderFillLoss: 0n, sellOrderFillLoss: 0n, }; - case "getOrderValues": - return [0n, 0n]; + case "getOrderAggregate": + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; + case "getOrderAggregateAtExpiration": + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; case "getActiveExpirationDates": return []; + case "getExpirationDates": + return []; case "computePortfolioIM": return balance / 2n; case "computePortfolioMM": { diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts index bfaad08..7dd7bd2 100644 --- a/keeper/tests/predict/coordinatorAlerts.test.ts +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -93,7 +93,7 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi case "balanceOf": return balance; case "getUserPosition": - return { netQuantity: 1_000_000n, aggregatedEntryPrice: perpEntry }; + return { netQuantity: 1_000_000n, netEntryValue: perpEntry }; case "getRiskView": return { netPositionDelta: 0n, @@ -104,10 +104,14 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi buyOrderFillLoss: 0n, sellOrderFillLoss: 0n, }; - case "getOrderValues": - return [0n, 0n]; + case "getOrderAggregate": + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; + case "getOrderAggregateAtExpiration": + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; case "getActiveExpirationDates": return []; + case "getExpirationDates": + return []; case "computePortfolioIM": return imAtPriceTokens(currentPrice); case "computePortfolioMM": diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts index 243fc6a..4aa760d 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -30,11 +30,13 @@ function makeConfig(): Config { function makeChain(scripted: { activeExpirationAts?: readonly bigint[]; + /** Tradable window for futures order aggregates; defaults to activeExpirationAts. */ + tradableExpirationAts?: readonly bigint[]; futuresPositions?: Record; /** Keyed by expiry; absent means the expiry has not settled. */ settlementPrices?: Record; perpNetQty?: bigint; - perpEntry?: bigint; + perpNetEntryValue?: bigint; perpFunding?: bigint; perpOrders?: RestingOrders; futuresOrders?: RestingOrders; @@ -69,7 +71,7 @@ function makeChain(scripted: { } return { netQuantity: scripted.perpNetQty ?? 0n, - aggregatedEntryPrice: scripted.perpEntry ?? 0n, + netEntryValue: scripted.perpNetEntryValue ?? 0n, }; } case "settlementPrice": { @@ -90,13 +92,44 @@ function makeChain(scripted: { sellOrderFillLoss: 0n, }; } - case "getOrderValues": { - const orders = - (c.address === PERPS ? scripted.perpOrders : scripted.futuresOrders) ?? NO_ORDERS; - return [orders.buyValue, orders.sellValue]; + case "getOrderAggregate": { + // Perps-only cross-user aggregate. + const orders = scripted.perpOrders ?? NO_ORDERS; + return { + buyQty: 0n, + sellQty: 0n, + buyValue: orders.buyValue, + sellValue: orders.sellValue, + }; + } + case "getOrderAggregateAtExpiration": { + const orders = scripted.futuresOrders ?? NO_ORDERS; + const dates = + scripted.tradableExpirationAts ?? scripted.activeExpirationAts ?? []; + // Put the full venue totals on the first expiry so a single-window + // sum matches the scripted RestingOrders values. + const expirationAt = c.args?.[1] as bigint; + const isFirst = dates.length === 0 || expirationAt === dates[0]; + return { + buyQty: 0n, + sellQty: 0n, + buyValue: isFirst ? orders.buyValue : 0n, + sellValue: isFirst ? orders.sellValue : 0n, + }; } case "getActiveExpirationDates": return scripted.activeExpirationAts ?? []; + case "getExpirationDates": { + if (scripted.tradableExpirationAts !== undefined) { + return scripted.tradableExpirationAts; + } + if (scripted.activeExpirationAts !== undefined) { + return scripted.activeExpirationAts; + } + // Flat accounts still need one window slot when futures order + // totals are scripted without explicit expiries. + return scripted.futuresOrders === undefined ? [] : [0n]; + } case "imSpotShock": return scripted.imShock ?? 10n ** 17n; case "mmSpotShock": @@ -131,13 +164,14 @@ describe("predict/snapshot: readAccountSnapshot", () => { assert.equal(snap.user, USER); assert.equal(snap.balance, 0n); assert.equal(snap.perp.netQty, 0n); + assert.equal(snap.perp.entryPrice, 0n); assert.equal(snap.perp.fundingOwed, 0n); assert.equal(snap.futures.positions.length, 0); assert.deepEqual(snap.perp.orders, NO_ORDERS); assert.deepEqual(snap.futures.orders, NO_ORDERS); }); - it("pairs each venue's getRiskView deltas with its getOrderValues totals", async () => { + it("pairs each venue's risk deltas with its order aggregate totals", async () => { const perpOrders: RestingOrders = { buyDelta: 2_000_000n, sellDelta: 500_000n, @@ -171,6 +205,16 @@ describe("predict/snapshot: readAccountSnapshot", () => { assert.equal(snap.perp.fundingOwed, 1_000n); }); + it("derives the perps average entry price from signed entry value", async () => { + const chain = makeChain({ + perpNetQty: -2_000_000n, + perpNetEntryValue: -240_000_000n, + }); + const snap = await readAccountSnapshot(chain, makeConfig(), USER); + + assert.equal(snap.perp.entryPrice, 120_000_000n); + }); + it("hydrates futures aggregates from active delivery dates", async () => { const chain = makeChain({ activeExpirationAts: [EXPIRY_A, EXPIRY_B], diff --git a/keeper/tests/runtime/healthcheck.test.ts b/keeper/tests/runtime/healthcheck.test.ts index ba0c9bf..0236ba2 100644 --- a/keeper/tests/runtime/healthcheck.test.ts +++ b/keeper/tests/runtime/healthcheck.test.ts @@ -50,10 +50,6 @@ const STUB_CONFIG: Config = { balanceLowWei: 10_000_000_000_000_000n, balanceCriticalWei: 1_000_000_000_000_000n, }, - outdatedOrders: { - sweepIntervalMs: 0, - maxBatchSize: 50, - }, delivery: { enabled: false, sweepIntervalMs: 60_000, diff --git a/keeper/tests/runtime/outdatedOrderSweeper.test.ts b/keeper/tests/runtime/outdatedOrderSweeper.test.ts deleted file mode 100644 index ebdb98d..0000000 --- a/keeper/tests/runtime/outdatedOrderSweeper.test.ts +++ /dev/null @@ -1,615 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { - BaseError, - ContractFunctionRevertedError, - decodeFunctionData, - type Address, - type Hex, -} from "viem"; -import pino from "pino"; -import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; -import { OutdatedOrderSweeper } from "../../src/runtime/outdatedOrderSweeper.ts"; -import type { Chain } from "../../src/chain.ts"; -import type { Config } from "../../src/config.ts"; -import type { ParticipantTracker } from "../../src/discovery/tracker.ts"; - -const FUTURES: Address = "0x00000000000000000000000000000000000000F1"; -const USER_A: Address = "0x000000000000000000000000000000000000000a"; -const USER_B: Address = "0x000000000000000000000000000000000000000B"; -const SIGNER: Address = "0x00000000000000000000000000000000000000A1"; - -interface LogCall { - level: "info" | "warn" | "error" | "debug"; - msg: string; - ctx: Record; -} - -function makeRecordingLogger(): { logger: pino.Logger; calls: LogCall[] } { - const calls: LogCall[] = []; - const record = - (level: LogCall["level"]) => (ctxOrMsg: unknown, msg?: string) => { - if (typeof ctxOrMsg === "string") { - calls.push({ level, msg: ctxOrMsg, ctx: {} }); - } else { - calls.push({ - level, - msg: msg ?? "", - ctx: ctxOrMsg as Record, - }); - } - }; - const logger = { - info: record("info"), - warn: record("warn"), - error: record("error"), - debug: record("debug"), - trace: () => undefined, - fatal: () => undefined, - child: () => logger, - } as unknown as pino.Logger; - return { logger, calls }; -} - -interface FakeOrder { - participant: Address; - expirationAt: bigint; -} - -interface FakeChainOpts { - blockTimestamp: bigint; - orderIdsByUser: Map; - orders: Map; - /** Simulate failure: returns an `errorName` for the given orderId, else undefined. */ - simulateRevert?: (orderId: Hex) => string | undefined; -} - -interface Recorded { - readContractCalls: number; - multicallReadCalls: number; - simulateCalls: Hex[]; - writeCalls: Array<{ functionName: string; calldatas: Hex[] }>; -} - -function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { - const recorded: Recorded = { - readContractCalls: 0, - multicallReadCalls: 0, - simulateCalls: [], - writeCalls: [], - }; - - const publicClient = { - getBlock: async () => ({ timestamp: opts.blockTimestamp }), - readContract: async ({ - functionName, - args, - }: { - functionName: string; - args: unknown[]; - }) => { - recorded.readContractCalls++; - if (functionName !== "getUserOrders") { - throw new Error(`unexpected readContract: ${functionName}`); - } - const user = args[0] as Address; - return opts.orderIdsByUser.get(user) ?? []; - }, - multicall: async ({ - contracts, - }: { - contracts: Array<{ functionName: string; args: unknown[] }>; - }) => { - recorded.multicallReadCalls++; - return contracts.map((c) => { - if (c.functionName !== "getOrder") { - throw new Error(`unexpected multicall fn: ${c.functionName}`); - } - const id = c.args[0] as Hex; - const order = opts.orders.get(id); - if (order === undefined) { - throw new Error(`order not found in fake state: ${id}`); - } - // Return shape matches the on-chain Order struct; sweeper only - // reads `expirationAt` but include the other fields so tests - // stay close to the real ABI. - return { - participant: order.participant, - price: 0n, - quantity: 1n, - expirationAt: order.expirationAt, - }; - }); - }, - simulateContract: async ({ - functionName, - args, - }: { - functionName: string; - args: unknown[]; - }) => { - if (functionName !== "removeOutdatedOrder") { - throw new Error(`unexpected simulate fn: ${functionName}`); - } - const orderId = args[0] as Hex; - recorded.simulateCalls.push(orderId); - const errorName = opts.simulateRevert?.(orderId); - if (errorName !== undefined) { - throw new MockRevertError(errorName); - } - return { request: { functionName, args } }; - }, - waitForTransactionReceipt: async () => ({ - blockNumber: 1n, - gasUsed: 200_000n, - logs: [], - }), - }; - - const walletClient = { - chain: null, - writeContract: async ({ - functionName, - args, - }: { - functionName: string; - args: unknown[]; - }) => { - if (functionName !== "multicall") { - throw new Error(`unexpected write fn: ${functionName}`); - } - recorded.writeCalls.push({ functionName, calldatas: args[0] as Hex[] }); - return "0xabc" as Hex; - }, - }; - - const chain: Chain = { - publicClient, - walletClient, - account: { address: SIGNER }, - } as unknown as Chain; - return { chain, recorded }; -} - -/** Mimics a viem ContractFunctionRevertedError so `BaseError.walk` finds it. */ -class MockRevertError extends BaseError { - override name = "ContractFunctionExecutionError"; - constructor(errorName: string) { - const inner = new ContractFunctionRevertedError({ - abi: FuturesAbi, - data: undefined, - functionName: "removeOutdatedOrder", - }); - (inner as unknown as { data: { errorName: string } }).data = { errorName }; - super("simulated revert", { cause: inner }); - } -} - -function makeConfig(overrides: Partial = {}): Config { - return { - futures: { address: FUTURES }, - keeper: { dryRun: false }, - coordinator: { confirmationBlocks: 0 }, - outdatedOrders: { - sweepIntervalMs: 1_000_000, // intervals never auto-fire in tests - maxBatchSize: 50, - ...overrides, - }, - } as unknown as Config; -} - -function makeTracker(users: Address[]): ParticipantTracker { - return { list: () => users } as unknown as ParticipantTracker; -} - -describe("OutdatedOrderSweeper", () => { - it("is a no-op when the tracker is empty", async () => { - const { logger, calls } = makeRecordingLogger(); - const { chain, recorded } = makeChain({ - blockTimestamp: 1_000n, - orderIdsByUser: new Map(), - orders: new Map(), - }); - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig(), - makeTracker([]), - logger, - ); - const closed = await sweeper.runSweep(); - assert.equal(closed, 0); - assert.equal(recorded.writeCalls.length, 0); - assert.equal(recorded.readContractCalls, 0); - assert.equal(calls.filter((c) => c.level === "warn").length, 0); - }); - - it("skips users with no orders without sending a write", async () => { - const { logger } = makeRecordingLogger(); - const { chain, recorded } = makeChain({ - blockTimestamp: 1_000n, - orderIdsByUser: new Map([[USER_A, []]]), - orders: new Map(), - }); - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig(), - makeTracker([USER_A]), - logger, - ); - const closed = await sweeper.runSweep(); - assert.equal(closed, 0); - assert.equal(recorded.writeCalls.length, 0); - assert.equal(recorded.multicallReadCalls, 0); - }); - - it("ignores orders whose expirationAt is still in the future", async () => { - const { logger } = makeRecordingLogger(); - const orderId = ("0x" + "11".repeat(32)) as Hex; - const { chain, recorded } = makeChain({ - blockTimestamp: 1_000n, - orderIdsByUser: new Map([[USER_A, [orderId]]]), - orders: new Map([ - [orderId, { participant: USER_A, expirationAt: 5_000n }], // future - ]), - }); - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig(), - makeTracker([USER_A]), - logger, - ); - const closed = await sweeper.runSweep(); - assert.equal(closed, 0); - assert.equal(recorded.simulateCalls.length, 0); - assert.equal(recorded.writeCalls.length, 0); - }); - - it("batches all expired orders for a user into a single multicall write", async () => { - const { logger, calls } = makeRecordingLogger(); - const id1 = ("0x" + "11".repeat(32)) as Hex; - const id2 = ("0x" + "22".repeat(32)) as Hex; - const id3 = ("0x" + "33".repeat(32)) as Hex; - const { chain, recorded } = makeChain({ - blockTimestamp: 10_000n, - orderIdsByUser: new Map([[USER_A, [id1, id2, id3]]]), - orders: new Map([ - [id1, { participant: USER_A, expirationAt: 5_000n }], // expired - [id2, { participant: USER_A, expirationAt: 9_999n }], // expired - [id3, { participant: USER_A, expirationAt: 20_000n }], // future - ]), - }); - - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig(), - makeTracker([USER_A]), - logger, - ); - const closed = await sweeper.runSweep(); - - assert.equal(closed, 2); - assert.equal(recorded.writeCalls.length, 1); - assert.deepEqual(recorded.simulateCalls, [id1, id2]); - - // Decode each calldata to confirm both are `removeOutdatedOrder()`. - const decoded = recorded.writeCalls[0]!.calldatas.map((cd) => - decodeFunctionData({ abi: FuturesAbi, data: cd }), - ); - assert.equal(decoded.length, 2); - assert.equal(decoded[0]?.functionName, "removeOutdatedOrder"); - assert.equal(decoded[0]?.args?.[0], id1); - assert.equal(decoded[1]?.functionName, "removeOutdatedOrder"); - assert.equal(decoded[1]?.args?.[0], id2); - - assert.ok( - calls.some((c) => c.level === "info" && c.msg.includes("confirmed")), - "expected an INFO log when the multicall write confirms", - ); - }); - - it("aggregates expired orders across multiple tracked users into one tx", async () => { - const { logger } = makeRecordingLogger(); - const idA = ("0x" + "aa".repeat(32)) as Hex; - const idB = ("0x" + "bb".repeat(32)) as Hex; - const { chain, recorded } = makeChain({ - blockTimestamp: 10_000n, - orderIdsByUser: new Map([ - [USER_A, [idA]], - [USER_B, [idB]], - ]), - orders: new Map([ - [idA, { participant: USER_A, expirationAt: 5_000n }], - [idB, { participant: USER_B, expirationAt: 5_000n }], - ]), - }); - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig(), - makeTracker([USER_A, USER_B]), - logger, - ); - const closed = await sweeper.runSweep(); - assert.equal(closed, 2); - assert.equal( - recorded.writeCalls.length, - 1, - "one multicall write for cross-user batch", - ); - assert.equal(recorded.writeCalls[0]!.calldatas.length, 2); - }); - - it("splits across multiple writes when batch size cap is exceeded", async () => { - const { logger } = makeRecordingLogger(); - const ids: Hex[] = []; - const orders = new Map(); - for (let i = 0; i < 5; i++) { - const id = ("0x" + String(i).padStart(2, "0").repeat(32)) as Hex; - ids.push(id); - orders.set(id, { participant: USER_A, expirationAt: 1n }); - } - const { chain, recorded } = makeChain({ - blockTimestamp: 1_000n, - orderIdsByUser: new Map([[USER_A, ids]]), - orders, - }); - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig({ maxBatchSize: 2 }), - makeTracker([USER_A]), - logger, - ); - const closed = await sweeper.runSweep(); - assert.equal(closed, 5); - // 5 expired / batch of 2 → ceil(5/2) = 3 writes - assert.equal(recorded.writeCalls.length, 3); - assert.deepEqual( - recorded.writeCalls.map((c) => c.calldatas.length), - [2, 2, 1], - ); - }); - - it("drops stale-state candidates flagged by simulate (OrderNotExists / OrderNotExpired)", async () => { - // Race scenario: between our `getOrder` read and our simulate, the - // user (or a concurrent keeper) closed orderId1, and orderId2 had its - // expirationAt bumped. The sweeper must skip them silently and still - // broadcast a write for the survivor (orderId3). - const { logger, calls } = makeRecordingLogger(); - const id1 = ("0x" + "11".repeat(32)) as Hex; - const id2 = ("0x" + "22".repeat(32)) as Hex; - const id3 = ("0x" + "33".repeat(32)) as Hex; - const { chain, recorded } = makeChain({ - blockTimestamp: 10_000n, - orderIdsByUser: new Map([[USER_A, [id1, id2, id3]]]), - orders: new Map([ - [id1, { participant: USER_A, expirationAt: 5_000n }], - [id2, { participant: USER_A, expirationAt: 6_000n }], - [id3, { participant: USER_A, expirationAt: 7_000n }], - ]), - simulateRevert: (id) => { - if (id === id1) return "OrderNotExists"; - if (id === id2) return "OrderNotExpired"; - return undefined; - }, - }); - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig(), - makeTracker([USER_A]), - logger, - ); - const closed = await sweeper.runSweep(); - assert.equal(closed, 1); - assert.equal(recorded.writeCalls.length, 1); - assert.equal(recorded.writeCalls[0]!.calldatas.length, 1); - // Stale-state skips are debug — they're benign and shouldn't pollute INFO. - assert.equal( - calls.filter( - (c) => c.level === "warn" && c.msg.includes("non-recoverable"), - ).length, - 0, - ); - }); - - it("skips the write entirely on dry-run but still simulates", async () => { - const { logger, calls } = makeRecordingLogger(); - const id1 = ("0x" + "11".repeat(32)) as Hex; - const { chain, recorded } = makeChain({ - blockTimestamp: 10_000n, - orderIdsByUser: new Map([[USER_A, [id1]]]), - orders: new Map([[id1, { participant: USER_A, expirationAt: 1n }]]), - }); - const config = makeConfig(); - (config as { keeper: { dryRun: boolean } }).keeper.dryRun = true; - const sweeper = new OutdatedOrderSweeper( - chain, - config, - makeTracker([USER_A]), - logger, - ); - const closed = await sweeper.runSweep(); - assert.equal(closed, 0); - assert.equal(recorded.writeCalls.length, 0); - assert.equal( - recorded.simulateCalls.length, - 1, - "simulate runs so dry-run still surfaces reverts", - ); - assert.ok(calls.some((c) => c.msg.startsWith("[dryRun]"))); - }); - - it("does not crash when one user's getUserOrders fails — continues with the next user", async () => { - // Per-user RPC blips shouldn't drop the whole sweep tick. - const { logger, calls } = makeRecordingLogger(); - const idB = ("0x" + "bb".repeat(32)) as Hex; - const orderIdsByUser = new Map([[USER_B, [idB]]]); - const orders = new Map([ - [idB, { participant: USER_B, expirationAt: 1n }], - ]); - const blockTimestamp = 10_000n; - - // Custom chain that fails getUserOrders(USER_A) only. - const recorded: Recorded = { - readContractCalls: 0, - multicallReadCalls: 0, - simulateCalls: [], - writeCalls: [], - }; - const publicClient = { - getBlock: async () => ({ timestamp: blockTimestamp }), - readContract: async ({ args }: { args: unknown[] }) => { - recorded.readContractCalls++; - const user = args[0] as Address; - if (user === USER_A) throw new Error("rpc 503"); - return orderIdsByUser.get(user) ?? []; - }, - multicall: async ({ - contracts, - }: { - contracts: Array<{ args: unknown[] }>; - }) => { - recorded.multicallReadCalls++; - return contracts.map((c) => { - const order = orders.get(c.args[0] as Hex); - if (order === undefined) throw new Error("missing"); - return { - participant: order.participant, - price: 0n, - quantity: 1n, - expirationAt: order.expirationAt, - }; - }); - }, - simulateContract: async ({ args }: { args: unknown[] }) => { - recorded.simulateCalls.push(args[0] as Hex); - return { request: {} }; - }, - waitForTransactionReceipt: async () => ({ - blockNumber: 1n, - gasUsed: 0n, - logs: [], - }), - }; - const walletClient = { - chain: null, - writeContract: async ({ args }: { args: unknown[] }) => { - recorded.writeCalls.push({ - functionName: "multicall", - calldatas: args[0] as Hex[], - }); - return "0xabc" as Hex; - }, - }; - const chain: Chain = { - publicClient, - walletClient, - account: { address: SIGNER }, - } as unknown as Chain; - - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig(), - makeTracker([USER_A, USER_B]), - logger, - ); - const closed = await sweeper.runSweep(); - assert.equal( - closed, - 1, - "USER_B's order still gets closed despite USER_A's RPC failure", - ); - assert.ok( - calls.some( - (c) => c.level === "warn" && c.msg.includes("getUserOrders failed"), - ), - "expected a warn log for the failed user", - ); - }); - - it("drops the sweep cleanly when getBlock fails (skip rather than guess at timestamp)", async () => { - const { logger, calls } = makeRecordingLogger(); - const chain: Chain = { - publicClient: { - getBlock: async () => { - throw new Error("rpc 503"); - }, - }, - walletClient: {}, - account: { address: SIGNER }, - } as unknown as Chain; - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig(), - makeTracker([USER_A]), - logger, - ); - const closed = await sweeper.runSweep(); - assert.equal(closed, 0); - assert.ok( - calls.some((c) => c.level === "warn" && c.msg.includes("getBlock")), - ); - }); - - it("coalesces overlapping sweeps — second concurrent runSweep is dropped", async () => { - // setInterval can fire while a previous sweep is still in flight on - // slow RPCs. Overlapping sweeps would race on the same nonce, so the - // sweeper must drop the redundant call. - const { logger } = makeRecordingLogger(); - const id1 = ("0x" + "11".repeat(32)) as Hex; - let releaseFirstSweep: () => void = () => undefined; - const firstSweepBlocked = new Promise((resolve) => { - releaseFirstSweep = resolve; - }); - let getBlockCount = 0; - const chain: Chain = { - publicClient: { - getBlock: async () => { - getBlockCount++; - if (getBlockCount === 1) await firstSweepBlocked; - return { timestamp: 0n }; - }, - readContract: async () => [], - }, - walletClient: {}, - account: { address: SIGNER }, - } as unknown as Chain; - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig(), - makeTracker([USER_A]), - logger, - ); - - const first = sweeper.runSweep(); - const second = sweeper.runSweep(); // Should bail immediately. - const secondResult = await second; - assert.equal( - secondResult, - 0, - "concurrent sweep returns 0 without doing work", - ); - releaseFirstSweep(); - await first; - assert.equal( - getBlockCount, - 1, - "block timestamp read once — second sweep was dropped", - ); - }); - - it("stop() is idempotent and clears the interval", () => { - const { logger } = makeRecordingLogger(); - const { chain } = makeChain({ - blockTimestamp: 0n, - orderIdsByUser: new Map(), - orders: new Map(), - }); - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig(), - makeTracker([]), - logger, - ); - sweeper.stop(); // no-op pre-start - sweeper.stop(); // no-op repeated - }); -}); diff --git a/keeper/tests/venues/futures.test.ts b/keeper/tests/venues/futures.test.ts index f2e86c7..749ad8d 100644 --- a/keeper/tests/venues/futures.test.ts +++ b/keeper/tests/venues/futures.test.ts @@ -47,12 +47,21 @@ const silentLogger = { const DELIVERY_AT = 1_756_416_000n; -function makeReadHandler(marketPrice: bigint, listResult: readonly unknown[]) { +function makeReadHandler( + marketPrice: bigint, + listResult: readonly unknown[], + opts: { orderIdsByExpiry?: Record } = {}, +) { return (call: ReadCall): unknown => { if (call.functionName === "getMarketPrice") return marketPrice; - if (call.functionName === "getUserOrders" || call.functionName === "getActiveExpirationDates") { + if (call.functionName === "getActiveExpirationDates") { return listResult; } + if (call.functionName === "getExpirationDates") { + return opts.orderIdsByExpiry === undefined + ? [] + : Object.keys(opts.orderIdsByExpiry).map((k) => BigInt(k)); + } throw new Error(`unexpected readContract call: ${call.functionName}`); }; } @@ -66,7 +75,7 @@ describe("futures venue: marketLabel", () => { }); describe("futures venue: readOpenOrders", () => { - it("returns empty when getUserOrders is empty (no extra multicall)", async () => { + it("returns empty when the tradable window has no dates (no multicall)", async () => { let multicallCount = 0; const chain = makeChainStub({ readContract: makeReadHandler(100n, []), @@ -78,7 +87,7 @@ describe("futures venue: readOpenOrders", () => { const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); const orders = await venue.readOpenOrders(BUYER); assert.equal(orders.length, 0); - assert.equal(multicallCount, 0, "no multicall when no orders"); + assert.equal(multicallCount, 0, "no multicall when no tradable dates"); }); it("hydrates each order's expirationAt as its marketId", async () => { @@ -86,14 +95,27 @@ describe("futures venue: readOpenOrders", () => { "0x000000000000000000000000000000000000000000000000000000000000000a", "0x000000000000000000000000000000000000000000000000000000000000000b", ]; + const expiryB = DELIVERY_AT + 86_400n; + let multicallStep = 0; const chain = makeChainStub({ - readContract: makeReadHandler(100n, orderIds), + readContract: makeReadHandler(100n, [], { + orderIdsByExpiry: { + [DELIVERY_AT.toString()]: [orderIds[0]!], + [expiryB.toString()]: [orderIds[1]!], + }, + }), multicall: (calls) => { + multicallStep++; + if (multicallStep === 1) { + assert.equal(calls.length, 2); + for (const c of calls) assert.equal(c.functionName, "getUserOrdersAtExpiration"); + return [[orderIds[0]!], [orderIds[1]!]]; + } assert.equal(calls.length, 2); for (const c of calls) assert.equal(c.functionName, "getOrder"); return [ { participant: BUYER, expirationAt: DELIVERY_AT, price: 50n, quantity: 1n }, - { participant: BUYER, expirationAt: DELIVERY_AT + 86_400n, price: 60n, quantity: -1n }, + { participant: BUYER, expirationAt: expiryB, price: 60n, quantity: -1n }, ]; }, }); @@ -102,7 +124,7 @@ describe("futures venue: readOpenOrders", () => { assert.equal(orders.length, 2); assert.equal(orders[0]?.id, orderIds[0]); assert.equal(orders[0]?.marketId, expirationAtMarketId(DELIVERY_AT)); - assert.equal(orders[1]?.marketId, expirationAtMarketId(DELIVERY_AT + 86_400n)); + assert.equal(orders[1]?.marketId, expirationAtMarketId(expiryB)); }); }); diff --git a/keeper/tests/venues/perps.test.ts b/keeper/tests/venues/perps.test.ts index aed92ca..9e556b5 100644 --- a/keeper/tests/venues/perps.test.ts +++ b/keeper/tests/venues/perps.test.ts @@ -95,7 +95,7 @@ describe("perps venue: readPositions", () => { it("returns no position when netQuantity is 0", async () => { const chain = makeChainStub({ multicall: () => [ - { netQuantity: 0n, aggregatedEntryPrice: 50n }, + { netQuantity: 0n, netEntryValue: 0n }, 100n, // marketPrice ], }); @@ -110,7 +110,7 @@ describe("perps venue: readPositions", () => { const marketPrice = 150n; // up → long is in profit, no loss const chain = makeChainStub({ multicall: () => [ - { netQuantity: qty, aggregatedEntryPrice: entryPrice }, + { netQuantity: qty, netEntryValue: (qty * entryPrice) / QTY_SCALE }, marketPrice, ], }); @@ -127,7 +127,7 @@ describe("perps venue: readPositions", () => { const marketPrice = 150n; // -50 per contract × 3 contracts = 150 loss const chain = makeChainStub({ multicall: () => [ - { netQuantity: qty, aggregatedEntryPrice: entryPrice }, + { netQuantity: qty, netEntryValue: (qty * entryPrice) / QTY_SCALE }, marketPrice, ], }); @@ -144,7 +144,7 @@ describe("perps venue: readPositions", () => { const marketPrice = 130n; // +30 against the short × 4 = 120 loss const chain = makeChainStub({ multicall: () => [ - { netQuantity: qty, aggregatedEntryPrice: entryPrice }, + { netQuantity: qty, netEntryValue: (qty * entryPrice) / QTY_SCALE }, marketPrice, ], }); @@ -155,10 +155,25 @@ describe("perps venue: readPositions", () => { assert.equal(pos.notional, marketPrice * 4n); }); + it("computes PnL directly from net entry value without average-price rounding", async () => { + const qty = 1_500_000n; + const chain = makeChainStub({ + multicall: () => [ + { netQuantity: qty, netEntryValue: 151n }, + 100n, + ], + }); + const venue = new PerpsVenue(chain, makeConfigStub(), silentLogger); + const [pos] = await venue.readPositions(USER); + + assert.ok(pos); + assert.equal(pos.unrealizedLoss, 1n); + }); + it("synthesises a deterministic positionId from the user address (bytes32(user))", async () => { const chain = makeChainStub({ multicall: () => [ - { netQuantity: 1n * QTY_SCALE, aggregatedEntryPrice: 100n }, + { netQuantity: 1n * QTY_SCALE, netEntryValue: 100n }, 100n, ], }); diff --git a/keeper/tests/venues/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts index bc70084..ba8d14e 100644 --- a/keeper/tests/venues/reduceToTarget.test.ts +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -30,18 +30,18 @@ const EMPTY_RISK_VIEW = { } as const; /** - * The bulk read `readAccountSnapshot` issues, in order: balance, the perp position, - * then each venue's `getRiskView` / `getOrderValues` pair, then the active futures - * expiries. Only the expiry list varies between these cases. + * The bulk read `readAccountSnapshot` issues, in order: balance, perp position, + * perps risk/aggregate, futures risk, active position expiries, tradable window. + * Only the expiry lists vary between these cases. */ function snapshotMulticall(balance: bigint, expiries: readonly bigint[]) { return [ balance, - { netQuantity: 0n, aggregatedEntryPrice: 0n }, + { netQuantity: 0n, netEntryValue: 0n }, EMPTY_RISK_VIEW, [0n, 0n], EMPTY_RISK_VIEW, - [0n, 0n], + expiries, expiries, ]; } @@ -87,13 +87,14 @@ function makeChainStub(opts: { return snapshotMulticall(opts.balance, [EXPIRY]); } if (fns[0] === "getUserPosition") { - // The per-expiry read batches `getUserPosition` and `settlementPrice`; - // an unsettled expiry prices at 0. - return contracts.map((c) => - c.functionName === "settlementPrice" - ? 0n - : { netQuantity: opts.netQuantity, netEntryValue: opts.netEntryValue }, - ); + // Per-expiry batch: positions, settlement prices, order aggregates. + return contracts.map((c) => { + if (c.functionName === "settlementPrice") return 0n; + if (c.functionName === "getOrderAggregateAtExpiration") { + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; + } + return { netQuantity: opts.netQuantity, netEntryValue: opts.netEntryValue }; + }); } throw new Error(`unexpected multicall head: ${fns[0]}`); }, @@ -155,6 +156,9 @@ describe("futures venue: reduceToTarget", () => { if (fns[0] === "getUserPosition") { return contracts.map((c) => { if (c.functionName === "settlementPrice") return 0n; + if (c.functionName === "getOrderAggregateAtExpiration") { + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; + } const expirationAt = c.args?.[1] as bigint; return { netQuantity: 4n, diff --git a/market-maker/package.json b/market-maker/package.json index 6963482..efd1030 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -39,7 +39,6 @@ "amaro": "^1.1.9", "collateral-margin-contracts": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts", "fraction.js": "^5.2.2", - "futures-contracts": "github:Lumerin-protocol/futures-marketplace#547ade13eb944b0e86bbef325749ed627cc886b4&path:/contracts", "js-yaml": "^4.1.0", "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#f7e219f704646ab654a2a8d0286c0f477148e299&path:/contracts", "pino": "^10.3.1", @@ -53,11 +52,5 @@ "pino-pretty": "^13.1.3", "typescript": "^5.8.0" }, - "packageManager": "pnpm@10.28.1", - "pnpm": { - "onlyBuiltDependencies": [ - "keccak@3.0.4", - "secp256k1@4.0.4" - ] - } + "packageManager": "pnpm@11.22.0" } diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 6457c1c..127eddb 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: fraction.js: specifier: ^5.2.2 version: 5.3.4 - futures-contracts: - specifier: github:Lumerin-protocol/futures-marketplace#547ade13eb944b0e86bbef325749ed627cc886b4&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) js-yaml: specifier: ^4.1.0 version: 4.1.1 @@ -459,10 +456,6 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012': - resolution: {tarball: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012} - version: 0.0.0 - '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -638,31 +631,9 @@ packages: resolution: {integrity: sha512-m6iorjyhPK9ow5/trNs7qsBC/SOzJCO51pvvAF2W9nOiZ1t0RtCd+rlRmRmlWTv4M33V0wzIUeamJ2BPbzgUXA==} hasBin: true - '@peculiar/asn1-schema@2.7.0': - resolution: {integrity: sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==} - - '@peculiar/utils@2.0.3': - resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} - '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@safe-global/api-kit@5.0.1': - resolution: {integrity: sha512-nbkSjrRTZh+LlD7e0A4fujKgLZnU5262e1iYTWsGx4ft6oo5kTAxc5pY0icls5PjmZI0UkMhYIv5izeBP7bcWg==} - - '@safe-global/protocol-kit@8.0.4': - resolution: {integrity: sha512-j6PXStIUYEGATdyE8CWMzE1uVAkDBta3o+3atwPTKVE5OIWuqk+QQ7s4kbm1x42yaMf9e5e7LxIJ8E8WmI3i0g==} - - '@safe-global/safe-deployments@1.37.60': - resolution: {integrity: sha512-PS+VLRm+FikrUNOutsIEE4Ytbtb+O57SHo8VSzYynR7lcSY1gfZ+7DUnKKLVDGCspoJIgh99jqJWGh9eqfn46Q==} - engines: {node: '>=22.0.0', pnpm: '>=10.16.0'} - - '@safe-global/safe-modules-deployments@3.0.8': - resolution: {integrity: sha512-RKct6dNFg4KbbpXchyosHmIygy58wLe+1WIATQ2VILGpcivBDcZdyvSl64BoocXCL2CroCBMJMzy0mW4iwZ0Zg==} - - '@safe-global/types-kit@4.0.1': - resolution: {integrity: sha512-zmIYyAH9mcBcqHszPgcfNjOJYuPSvWCcc/f8zeznh7N1HSA7jEoFErO06O4QDfwAAS4aEyoyjhPyfHbVYRheZg==} - '@scroll-tech/contracts@2.0.0': resolution: {integrity: sha512-O8sVaA/bVKH/mp+bBfUjZ/vYr5mdBExCpKRLre4r9TbXTtiaY9Uo5xU8dcG3weLxyK0BZqDTP2aCNp4Q0f7SeA==} @@ -1071,10 +1042,6 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} - asn1js@3.0.10: - resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} - engines: {node: '>=12.0.0'} - assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} @@ -1245,12 +1212,12 @@ packages: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c} + resolution: {gitHosted: true, path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c} version: 1.0.0 engines: {node: 24.x} collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/9372f537a57682bb126aede41aac51be004828a6: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/9372f537a57682bb126aede41aac51be004828a6} + resolution: {gitHosted: true, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/9372f537a57682bb126aede41aac51be004828a6} version: 1.0.0 color-convert@2.0.1: @@ -1337,7 +1304,7 @@ packages: engines: {node: '>= 0.8'} derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299} + resolution: {gitHosted: true, path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299} version: 1.0.0 engines: {node: 24.x} @@ -1379,7 +1346,7 @@ packages: engines: {node: '>=6'} era-contracts@https://codeload.github.com/matter-labs/era-contracts/tar.gz/446d391d34bdb48255d5f8fef8a8248925fc98b9: - resolution: {tarball: https://codeload.github.com/matter-labs/era-contracts/tar.gz/446d391d34bdb48255d5f8fef8a8248925fc98b9} + resolution: {gitHosted: true, tarball: https://codeload.github.com/matter-labs/era-contracts/tar.gz/446d391d34bdb48255d5f8fef8a8248925fc98b9} version: 0.1.0 es-define-property@1.0.1: @@ -1550,11 +1517,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4} - version: 1.0.0 - engines: {node: 24.x} - get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1633,7 +1595,7 @@ packages: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} hashprice-oracle@https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50} + resolution: {gitHosted: true, tarball: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50} version: 1.0.0 hasown@2.0.3: @@ -1913,6 +1875,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012: + resolution: {gitHosted: true, tarball: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012} + version: 0.0.0 + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -2116,13 +2082,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pvtsutils@1.3.6: - resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} - - pvutils@1.1.5: - resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} - engines: {node: '>=16.0.0'} - quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3540,8 +3499,6 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012': {} - '@noble/ciphers@1.3.0': {} '@noble/curves@1.4.2': @@ -3737,63 +3694,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@peculiar/asn1-schema@2.7.0': - dependencies: - '@peculiar/utils': 2.0.3 - asn1js: 3.0.10 - tslib: 2.8.1 - optional: true - - '@peculiar/utils@2.0.3': - dependencies: - tslib: 2.8.1 - optional: true - '@pinojs/redact@0.4.0': {} - '@safe-global/api-kit@5.0.1(typescript@5.9.3)': - dependencies: - '@safe-global/protocol-kit': 8.0.4(typescript@5.9.3) - '@safe-global/types-kit': 4.0.1(typescript@5.9.3) - node-fetch: 2.7.0 - viem: 2.55.5(typescript@5.9.3) - transitivePeerDependencies: - - bufferutil - - encoding - - typescript - - utf-8-validate - - zod - - '@safe-global/protocol-kit@8.0.4(typescript@5.9.3)': - dependencies: - '@safe-global/safe-deployments': 1.37.60 - '@safe-global/safe-modules-deployments': 3.0.8 - '@safe-global/types-kit': 4.0.1(typescript@5.9.3) - abitype: 1.2.3(typescript@5.9.3) - semver: 7.8.5 - viem: 2.55.5(typescript@5.9.3) - optionalDependencies: - '@noble/curves': 1.9.1 - '@peculiar/asn1-schema': 2.7.0 - transitivePeerDependencies: - - bufferutil - - typescript - - utf-8-validate - - zod - - '@safe-global/safe-deployments@1.37.60': - dependencies: - semver: 7.8.5 - - '@safe-global/safe-modules-deployments@3.0.8': {} - - '@safe-global/types-kit@4.0.1(typescript@5.9.3)': - dependencies: - abitype: 1.2.3(typescript@5.9.3) - transitivePeerDependencies: - - typescript - - zod - '@scroll-tech/contracts@2.0.0': {} '@scure/base@1.1.9': {} @@ -4326,13 +4228,6 @@ snapshots: array-union@2.1.0: {} - asn1js@3.0.10: - dependencies: - pvtsutils: 1.3.6 - pvutils: 1.1.5 - tslib: 2.8.1 - optional: true - assertion-error@1.1.0: {} async-retry@1.3.3: @@ -4622,14 +4517,14 @@ snapshots: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299#path:/contracts(@nomicfoundation/hardhat-ethers@3.1.3(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)))(@types/node@22.19.17)(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)): dependencies: '@chainlink/contracts': 1.5.0(@types/node@22.19.17)(ethers@5.8.0) - '@multicall/multicall3': https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 + '@multicall/multicall3': multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 '@noble/curves': 1.9.1 '@openzeppelin/contracts': 5.1.0 '@openzeppelin/contracts-upgradeable': 5.1.0(@openzeppelin/contracts@5.1.0) '@openzeppelin/hardhat-upgrades': 3.9.1(@nomicfoundation/hardhat-ethers@3.1.3(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)))(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)) collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/9372f537a57682bb126aede41aac51be004828a6 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50 - multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' + multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 solidity-linked-list: 6.5.0 transitivePeerDependencies: - '@nomicfoundation/hardhat-ethers' @@ -4903,29 +4798,6 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): - dependencies: - '@chainlink/contracts': 1.5.0(@types/node@22.19.17)(ethers@5.8.0) - '@noble/curves': 1.9.1 - '@openzeppelin/contracts': 5.1.0 - '@openzeppelin/contracts-upgradeable': 5.1.0(@openzeppelin/contracts@5.1.0) - '@safe-global/api-kit': 5.0.1(typescript@5.9.3) - '@safe-global/protocol-kit': 8.0.4(typescript@5.9.3) - '@safe-global/types-kit': 4.0.1(typescript@5.9.3) - collateral-margin: link:.. - multicall3: '@multicall/multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012' - solidity-linked-list: 6.5.0 - viem: 2.55.5(typescript@5.9.3) - transitivePeerDependencies: - - '@types/node' - - bufferutil - - encoding - - ethers - - supports-color - - typescript - - utf-8-validate - - zod - get-caller-file@2.0.5: {} get-func-name@2.0.2: {} @@ -5334,6 +5206,8 @@ snapshots: ms@2.1.3: {} + multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012: {} + nice-try@1.0.5: {} node-addon-api@2.0.2: {} @@ -5542,14 +5416,6 @@ snapshots: punycode@2.3.1: {} - pvtsutils@1.3.6: - dependencies: - tslib: 2.8.1 - optional: true - - pvutils@1.1.5: - optional: true - quansync@0.2.11: {} queue-microtask@1.2.3: {} diff --git a/market-maker/pnpm-workspace.yaml b/market-maker/pnpm-workspace.yaml new file mode 100644 index 0000000..65c6398 --- /dev/null +++ b/market-maker/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + "@arbitrum/nitro-contracts": false + keccak: true + secp256k1: true diff --git a/market-maker/src/abi/HashPowerFutures.ts b/market-maker/src/abi/HashPowerFutures.ts new file mode 100644 index 0000000..f4d4ae3 --- /dev/null +++ b/market-maker/src/abi/HashPowerFutures.ts @@ -0,0 +1,1979 @@ +export const HashPowerFuturesAbi = [ + { + "inputs": [ + { + "internalType": "contract ICollateralVault", + "name": "_vault", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [], + "name": "ArrayLengthMismatch", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [], + "name": "EmptyBatch", + "type": "error" + }, + { + "inputs": [], + "name": "ExpirationDateNotAvailable", + "type": "error" + }, + { + "inputs": [], + "name": "ExpirationDateShouldBeInTheFuture", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientMarginBalance", + "type": "error" + }, + { + "inputs": [], + "name": "InsuranceFundNotConfigured", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidDependency", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidFee", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidOracle", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidPrice", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidQty", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidReduceQuantity", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidTimeInForce", + "type": "error" + }, + { + "inputs": [], + "name": "MaxOrdersPerParticipantPerExpirationReached", + "type": "error" + }, + { + "inputs": [], + "name": "MaxPriceLevelsReached", + "type": "error" + }, + { + "inputs": [], + "name": "MaxPriceLevelsReached", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "NotLiquidatable", + "type": "error" + }, + { + "inputs": [], + "name": "OracleStale", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotBelongToSender", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotBelongToUser", + "type": "error" + }, + { + "inputs": [], + "name": "OrderNotExists", + "type": "error" + }, + { + "inputs": [], + "name": "OrdersStillOpen", + "type": "error" + }, + { + "inputs": [], + "name": "OverLiquidation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "PositionExpirationNotStartedYet", + "type": "error" + }, + { + "inputs": [], + "name": "PositionNotExists", + "type": "error" + }, + { + "inputs": [], + "name": "SettlementDateNotReached", + "type": "error" + }, + { + "inputs": [], + "name": "TimeInForceNotFilled", + "type": "error" + }, + { + "inputs": [], + "name": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "type": "error" + }, + { + "inputs": [], + "name": "UnsupportedTokenDecimals", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "int256", + "name": "min", + "type": "int256" + }, + { + "internalType": "int256", + "name": "max", + "type": "int256" + } + ], + "name": "ValueOutOfRange", + "type": "error" + }, + { + "inputs": [], + "name": "VaultMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "BadDebt", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "newFutureExpirationDatesCount", + "type": "uint8" + } + ], + "name": "FutureExpirationDatesCountUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "hook", + "type": "address" + } + ], + "name": "HookUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "newLiquidationFeeBps", + "type": "uint16" + } + ], + "name": "LiquidationFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "newLiquidationMarginPercent", + "type": "uint8" + } + ], + "name": "LiquidationMarginPercentUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "newLiquidatorShareBps", + "type": "uint16" + } + ], + "name": "LiquidatorShareBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "int16", + "name": "newMakerFeeBps", + "type": "int16" + } + ], + "name": "MakerFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "newOracle", + "type": "address" + } + ], + "name": "OracleUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + } + ], + "name": "OrderCancelled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "name": "OrderCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "OrderLiquidated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "makerOrderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "maker", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "taker", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tradePrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "makerFee", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerFee", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "makerNetQtyAfter", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "takerNetQtyAfter", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "makerEntryPriceAfter", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "takerEntryPriceAfter", + "type": "uint256" + } + ], + "name": "OrderMatched", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "indexed": false, + "internalType": "int256", + "name": "newQuantity", + "type": "int256" + } + ], + "name": "OrderUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "newPortfolioMargin", + "type": "address" + } + ], + "name": "PortfolioMarginUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "liquidator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "closedQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "pnl", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "liquidatorFee", + "type": "uint256" + } + ], + "name": "PositionLiquidated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "closedQuantity", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "pnl", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "settlementPrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "settledBy", + "type": "address" + } + ], + "name": "PositionSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "recordedBy", + "type": "address" + } + ], + "name": "SettlementPriceRecorded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "int16", + "name": "newTakerFeeBps", + "type": "int16" + } + ], + "name": "TakerFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "inputs": [], + "name": "CONTRACT_SIZE_HPS_DAY", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "EXPIRATION_INTERVAL_DAYS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_ORACLE_STALENESS", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_ORDERS_PER_PARTICIPANT_PER_EXPIRATION", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_PRICE_LEVELS_PER_SIDE", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "QUANTITY_DECIMALS", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "UPGRADE_INTERFACE_VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "cancelOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "collectedFeesBalance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "_quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "_tif", + "type": "uint8" + } + ], + "name": "createOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "timeInForce", + "type": "uint8" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderIntent[]", + "name": "_intents", + "type": "tuple[]" + } + ], + "name": "createOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_participants", + "type": "address[]" + } + ], + "name": "dropActiveOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "expirationIntervalDays", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "firstFutureExpirationDate", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "futureExpirationDatesCount", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + } + ], + "name": "getActiveExpirationDates", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getExpirationDates", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getMarketPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "getOrder", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "participant", + "type": "address" + }, + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "internalType": "struct HashPowerFuturesBase.Order", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getOrderAggregateAtExpiration", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "buyQty", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellQty", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "buyValue", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellValue", + "type": "uint256" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderAggregate", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_maxLevels", + "type": "uint256" + } + ], + "name": "getOrderBookPrices", + "outputs": [ + { + "internalType": "uint256[]", + "name": "bids", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "asks", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "_isBid", + "type": "bool" + } + ], + "name": "getQuantityAtPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getRiskView", + "outputs": [ + { + "components": [ + { + "internalType": "int256", + "name": "netPositionDelta", + "type": "int256" + }, + { + "internalType": "int256", + "name": "unrealizedPnl", + "type": "int256" + }, + { + "internalType": "int256", + "name": "pendingFunding", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "buyOrderDelta", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellOrderDelta", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "buyOrderFillLoss", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "sellOrderFillLoss", + "type": "uint256" + } + ], + "internalType": "struct ILinearMarket.RiskView", + "name": "view_", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "getUnrealizedPnl", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getUserOrdersAtExpiration", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "orderIds", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "getUserPosition", + "outputs": [ + { + "components": [ + { + "internalType": "int256", + "name": "netQuantity", + "type": "int256" + }, + { + "internalType": "int256", + "name": "netEntryValue", + "type": "int256" + } + ], + "internalType": "struct HashPowerFuturesBase.Position", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_participant", + "type": "address" + } + ], + "name": "hasRestingOrderDelta", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "hook", + "outputs": [ + { + "internalType": "contract IPointsHook", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "_priceOracle", + "type": "address" + }, + { + "internalType": "uint8", + "name": "_liquidationMarginPercent", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "_futureExpirationDatesCount", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "_firstFutureExpirationDate", + "type": "uint256" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + } + ], + "name": "liquidateOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "bytes32[]", + "name": "_orderIds", + "type": "bytes32[]" + } + ], + "name": "liquidateOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_closeQty", + "type": "uint256" + } + ], + "name": "liquidatePosition", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256[]", + "name": "_expirationAts", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "_closeQtys", + "type": "uint256[]" + } + ], + "name": "liquidatePositions", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "liquidationFeeBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "liquidationMarginPercent", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "liquidatorShareBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "makerFeeBps", + "outputs": [ + { + "internalType": "int16", + "name": "", + "type": "int16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minimumPriceIncrement", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "portfolioMargin", + "outputs": [ + { + "internalType": "contract IPortfolioMarginEngine", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "priceOracle", + "outputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + } + ], + "name": "recordSettlementPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_orderId", + "type": "bytes32" + }, + { + "internalType": "int256", + "name": "_newQuantity", + "type": "int256" + } + ], + "name": "reduceOrderSize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32[]", + "name": "_orderIds", + "type": "bytes32[]" + } + ], + "name": "removeOutdatedOrders", + "outputs": [ + { + "internalType": "uint256", + "name": "removed", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_participants", + "type": "address[]" + } + ], + "name": "resetState", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "_futureExpirationDatesCount", + "type": "uint8" + } + ], + "name": "setFutureExpirationDatesCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_hook", + "type": "address" + } + ], + "name": "setHook", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "_bps", + "type": "uint16" + } + ], + "name": "setLiquidationFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "_liquidationMarginPercent", + "type": "uint8" + } + ], + "name": "setLiquidationMarginPercent", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "_bps", + "type": "uint16" + } + ], + "name": "setLiquidatorShareBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int16", + "name": "_makerFeeBps", + "type": "int16" + } + ], + "name": "setMakerFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract AggregatorV3Interface", + "name": "_oracle", + "type": "address" + } + ], + "name": "setOracle", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IPortfolioMarginEngine", + "name": "_pm", + "type": "address" + } + ], + "name": "setPortfolioMargin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int16", + "name": "_takerFeeBps", + "type": "int16" + } + ], + "name": "setTakerFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + } + ], + "name": "settlePosition", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_users", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "_expirationAts", + "type": "uint256[]" + } + ], + "name": "settlePositions", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "settlementPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_expirationAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_price", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "_quantity", + "type": "int256" + } + ], + "name": "simulateOrder", + "outputs": [ + { + "internalType": "int256", + "name": "filledQuantity", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "averageFillPrice", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "remainingQuantity", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "takerFeeBps", + "outputs": [ + { + "internalType": "int16", + "name": "", + "type": "int16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32[]", + "name": "_cancelIds", + "type": "bytes32[]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "internalType": "int256", + "name": "newQuantity", + "type": "int256" + } + ], + "internalType": "struct HashPowerFuturesBase.ReduceIntent[]", + "name": "_reduces", + "type": "tuple[]" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "expirationAt", + "type": "uint256" + }, + { + "internalType": "int256", + "name": "quantity", + "type": "int256" + }, + { + "internalType": "enum HashPowerFuturesBase.TimeInForce", + "name": "timeInForce", + "type": "uint8" + } + ], + "internalType": "struct HashPowerFuturesBase.OrderIntent[]", + "name": "_intents", + "type": "tuple[]" + } + ], + "name": "updateOrders", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "vault", + "outputs": [ + { + "internalType": "contract ICollateralVault", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "withdrawCollectedFees", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; diff --git a/market-maker/src/adapters/futures/events.ts b/market-maker/src/adapters/futures/events.ts index c8074df..408d997 100644 --- a/market-maker/src/adapters/futures/events.ts +++ b/market-maker/src/adapters/futures/events.ts @@ -4,7 +4,7 @@ import type { VenueEvent, VenueEvents, } from "../../core/adapter.ts"; -import { FuturesAbi } from "futures-contracts/abi/Futures"; +import { HashPowerFuturesAbi } from "../../abi/HashPowerFutures.ts"; /** Instrument id for a futures expiry, e.g. `futures:1893456000`. */ export function futuresInstrumentId(expirationAt: bigint): string { @@ -17,7 +17,7 @@ type FuturesLog = Log< false, undefined, false, - typeof FuturesAbi + typeof HashPowerFuturesAbi >; /** Multiplexes one viem watcher across many subscribers. Decode-only. */ @@ -45,7 +45,7 @@ export class FuturesVenueEvents implements VenueEvents { private attachWatcher(): void { this.unwatch = this.publicClient.watchContractEvent({ address: this.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, onLogs: (logs) => { for (const log of logs) { const evt = decodeEvent(log as FuturesLog); diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index c43151d..7ee8821 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -14,7 +14,7 @@ import type { ReduceIntent, } from "../../core/adapter.ts"; import { TimeInForce } from "../../core/adapter.ts"; -import { FuturesAbi } from "futures-contracts/abi/Futures"; +import { HashPowerFuturesAbi } from "../../abi/HashPowerFutures.ts"; import { fillLossFromNotionals } from "../../core/math.ts"; import type { FuturesVenueAdapter } from "./venue.ts"; import { FuturesOwnOrders } from "./ownOrders.ts"; @@ -63,7 +63,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { async getPosition(): Promise { const pos = await this.venue.publicClient.readContract({ address: this.venue.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getUserPosition", args: [this.venue.wallet.account.address, this.expirationAt], }); @@ -181,7 +181,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { encodeCancel(intent: CancelIntent): `0x${string}` { return encodeFunctionData({ - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "cancelOrder", args: [intent.orderId], }); @@ -297,7 +297,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { try { return await this.venue.publicClient.estimateContractGas({ address: this.venue.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "createOrder", // Futures 3.0: createOrder(price, expirationAt, signedQuantity, timeInForce) args: [1_000_000n, this.expirationAt, 1n, 0], @@ -312,7 +312,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { if (this.tickCache !== null) return this.tickCache; const tick = await this.venue.publicClient.readContract({ address: this.venue.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "minimumPriceIncrement", }); this.tickCache = tick; @@ -341,7 +341,7 @@ class FuturesBook implements BookSource { // Same shape as perps `getOrderBookPrices(depth)`, with expirationAt first. const [bidPrices, askPrices] = await v.publicClient.readContract({ address: v.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getOrderBookPrices", args: [expirationAt, depth], }); @@ -351,13 +351,13 @@ class FuturesBook implements BookSource { const allCalls = [ ...bidPrices.map((p) => ({ address: v.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getQuantityAtPrice" as const, args: [expirationAt, p, true] as const, })), ...askPrices.map((p) => ({ address: v.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getQuantityAtPrice" as const, args: [expirationAt, p, false] as const, })), diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 9c31311..20ddcfe 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -5,21 +5,31 @@ import type { OwnOrderSource, Unsubscribe, } from "../../core/adapter.ts"; -import { FuturesAbi } from "futures-contracts/abi/Futures"; +import { HashPowerFuturesAbi } from "../../abi/HashPowerFutures.ts"; import type { FuturesVenueAdapter } from "./venue.ts"; import { futuresInstrumentId } from "./events.ts"; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; +const FUTURES_USER_ORDERS_AT_EXPIRATION_ABI = [ + { + type: "function", + name: "getUserOrdersAtExpiration", + stateMutability: "view", + inputs: [ + { name: "_user", type: "address" }, + { name: "_expirationAt", type: "uint256" }, + ], + outputs: [{ name: "orderIds", type: "bytes32[]" }], + }, +] as const; /** * Cache-backed own-order source for a single futures expiry. * - * The contract has no per-participant order view scoped by delivery date, so - * we read all of the wallet's orders and keep only those matching this - * instrument's `expirationAt`: + * The contract exposes a participant-order view scoped by delivery date: * - * 1. `bootstrap()` reads `getUserOrders(wallet)` + `getOrder(id)` and - * caches the orders whose `expirationAt === expirationAt`. + * 1. `bootstrap()` reads `getUserOrdersAtExpiration(wallet, expirationAt)` + * plus `getOrder(id)`. * 2. `subscribe()` listens to venue events. `order-created` is filtered by * participant AND instrumentId (which encodes the expiry). `order-cancelled` * carries no expiry, so we apply it only if the id is in *this* cache — @@ -73,9 +83,9 @@ export class FuturesOwnOrders implements OwnOrderSource { const orderIds = await this.venue.publicClient.readContract({ address: this.venue.address, - abi: FuturesAbi, - functionName: "getUserOrders", - args: [owner], + abi: FUTURES_USER_ORDERS_AT_EXPIRATION_ABI, + functionName: "getUserOrdersAtExpiration", + args: [owner, this.expirationAt], }); if (orderIds.length === 0) { @@ -86,7 +96,7 @@ export class FuturesOwnOrders implements OwnOrderSource { const allCalls = orderIds.map((id) => ({ address: this.venue.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getOrder" as const, args: [id] as const, })); @@ -110,7 +120,7 @@ export class FuturesOwnOrders implements OwnOrderSource { expirationAt: bigint; }; if (!o.participant || o.participant === ZERO_ADDRESS) continue; - // Keep only orders belonging to this expiry. + // Defensive against an inconsistent RPC response. if (o.expirationAt !== this.expirationAt) continue; if (o.quantity === 0n) continue; const absQty = o.quantity < 0n ? -o.quantity : o.quantity; diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index f232b50..19bb9d5 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -12,7 +12,7 @@ import type { WalletContext, } from "../../core/adapter.ts"; import type { NetworkClients } from "../../core/client.ts"; -import { FuturesAbi } from "futures-contracts/abi/Futures"; +import { HashPowerFuturesAbi } from "../../abi/HashPowerFutures.ts"; import { CollateralVaultAbi } from "collateral-margin-contracts/abi/CollateralVault.ts"; import { PortfolioMarginEngineAbi } from "collateral-margin-contracts/abi/PortfolioMarginEngine.ts"; import { Multicall3Abi } from "perps-contracts/abi/Multicall3.ts"; @@ -127,7 +127,7 @@ export class FuturesVenueAdapter implements VenueAdapter { const { token } = await this.resolveAddresses(); const oracle = await this.publicClient.readContract({ address: this.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "priceOracle", }); const [oracleDecimals, tokenDecimals] = await this.publicClient.multicall({ @@ -219,7 +219,7 @@ export class FuturesVenueAdapter implements VenueAdapter { private async readExpirationAts(): Promise { const dates = await this.publicClient.readContract({ address: this.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "getExpirationDates", }); return [...dates]; @@ -260,36 +260,6 @@ export class FuturesVenueAdapter implements VenueAdapter { } } - /** @deprecated Prefer {@link sendCall} with a single `updateOrders` encoding. */ - async multicall( - calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint; nonce?: number } = {}, - ): Promise<`0x${string}`> { - try { - return await this.wallet.walletClient.writeContract({ - address: this.address, - abi: FuturesAbi, - functionName: "multicall", - args: [calls], - account: this.wallet.account, - chain: this.chain, - maxFeePerGas: opts.maxFeePerGas, - nonce: opts.nonce, - }); - } catch (err) { - throw attachTenderlyUrl(err, { - chainId: this.chain.id, - from: this.wallet.account.address, - to: this.address, - data: encodeFunctionData({ - abi: FuturesAbi, - functionName: "multicall", - args: [calls], - }), - }); - } - } - // ── Internal helpers ──────────────────────────────────────────────────── async resolveAddresses(): Promise<{ @@ -313,12 +283,12 @@ export class FuturesVenueAdapter implements VenueAdapter { contracts: [ { address: this.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "vault", }, { address: this.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "portfolioMargin", }, ], @@ -374,7 +344,7 @@ export class FuturesVenueAdapter implements VenueAdapter { } const liqMarginPct = await this.publicClient.readContract({ address: this.address, - abi: FuturesAbi, + abi: HashPowerFuturesAbi, functionName: "liquidationMarginPercent", }); this.marginPercentCache = BigInt(liqMarginPct); @@ -434,7 +404,12 @@ class FuturesCollateralAccount implements BatchableCollateralAccount { ] as MarginReadPlan["shared"]; const venue = [ - { address: this.venue.address, abi: FuturesAbi, functionName: "getUnrealizedPnl", args: [owner] }, + { + address: this.venue.address, + abi: HashPowerFuturesAbi, + functionName: "getUnrealizedPnl", + args: [owner], + }, ] as MarginReadPlan["venue"]; const decode = (results: readonly unknown[]): CollateralSnapshot => { diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index 3486895..d7e1703 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -21,6 +21,7 @@ import { TimeInForce } from "../../core/adapter.ts"; import { HashPowerPerpsDEXAbi } from "perps-contracts/abi/HashPowerPerpsDEX.ts"; import { calculateNotional, fillLossFromNotionals } from "../../core/math.ts"; import type { PerpsVenueAdapter } from "./venue.ts"; +import { PerpsPositionAbi } from "./positionAbi.ts"; const PERPS_INSTRUMENT_ID = "perps"; @@ -51,13 +52,16 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { const owner = this.venue.wallet.account.address; const pos = await this.venue.publicClient.readContract({ address: this.venue.address, - abi: HashPowerPerpsDEXAbi, + abi: PerpsPositionAbi, functionName: "getUserPosition", args: [owner], }); + const absQuantity = pos.netQuantity < 0n ? -pos.netQuantity : pos.netQuantity; + const absEntryValue = pos.netEntryValue < 0n ? -pos.netEntryValue : pos.netEntryValue; return { netQuantity: pos.netQuantity, - entryPrice: pos.aggregatedEntryPrice, + entryPrice: + absQuantity === 0n ? 0n : (absEntryValue * 1_000_000n) / absQuantity, }; } diff --git a/market-maker/src/adapters/perps/positionAbi.ts b/market-maker/src/adapters/perps/positionAbi.ts new file mode 100644 index 0000000..6baf9bd --- /dev/null +++ b/market-maker/src/adapters/perps/positionAbi.ts @@ -0,0 +1,19 @@ +/** Exact local fragment while the pinned perps ABI still exposes the legacy position tuple. */ +export const PerpsPositionAbi = [ + { + type: "function", + name: "getUserPosition", + stateMutability: "view", + inputs: [{ name: "_user", type: "address" }], + outputs: [ + { + name: "", + type: "tuple", + components: [ + { name: "netQuantity", type: "int256" }, + { name: "netEntryValue", type: "int256" }, + ], + }, + ], + }, +] as const; diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index c1586f3..60873ab 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -168,36 +168,6 @@ export class PerpsVenueAdapter implements VenueAdapter { } } - /** @deprecated Prefer {@link sendCall} with a single `updateOrders` encoding. */ - async multicall( - calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint; nonce?: number } = {}, - ): Promise<`0x${string}`> { - try { - return await this.wallet.walletClient.writeContract({ - address: this.address, - abi: HashPowerPerpsDEXAbi, - functionName: "multicall", - args: [calls], - account: this.wallet.account, - chain: this.chain, - maxFeePerGas: opts.maxFeePerGas, - nonce: opts.nonce, - }); - } catch (err) { - throw attachTenderlyUrl(err, { - chainId: this.chain.id, - from: this.wallet.account.address, - to: this.address, - data: encodeFunctionData({ - abi: HashPowerPerpsDEXAbi, - functionName: "multicall", - args: [calls], - }), - }); - } - } - // ── Internal helpers used by the collateral account & instrument ───────── async resolveAddresses(): Promise<{ diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index 70f7c27..71bfb68 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -385,7 +385,7 @@ export interface InstrumentAdapter { export type VenueKind = "perps" | "futures"; /** - * Per-venue interface. One per process; owns the wallet, the multicall route, + * Per-venue interface. One per process; owns the wallet, read-batching route, * the venue-events stream, and the collateral account. Single-instrument * venues (perps, futures) expose `getInstrument()` directly; a future * multi-instrument venue (options) would expose `listInstruments()` instead. @@ -427,13 +427,4 @@ export interface VenueAdapter { data: `0x${string}`, opts: { maxFeePerGas?: bigint; nonce?: number }, ): Promise<`0x${string}`>; - - /** - * @deprecated Prefer {@link sendCall} with a single `updateOrders` encoding. - * Multicall wrapping is no longer used by the portfolio coordinator. - */ - multicall( - calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint; nonce?: number }, - ): Promise<`0x${string}`>; } diff --git a/market-maker/tests-pending/market-maker.process.test.ts b/market-maker/tests-pending/market-maker.process.test.ts index 7e3528a..0976b77 100644 --- a/market-maker/tests-pending/market-maker.process.test.ts +++ b/market-maker/tests-pending/market-maker.process.test.ts @@ -615,8 +615,8 @@ describe("MM process — post-fill on-chain state", () => { it("should not be liquidatable with sufficient collateral", async () => { const isLiquidatable = (await publicClient.readContract({ - address: deployment.contracts.perpsAddress, - abi: hashPowerPerpsDexAbi, + address: deployment.contracts.pmeAddress, + abi: PortfolioMarginEngineAbi, functionName: "isLiquidatable", args: [MM_ACCOUNT.address], })) as boolean; diff --git a/market-maker/tests/core/errSerializer.test.ts b/market-maker/tests/core/errSerializer.test.ts index 58c6ebb..d2458d6 100644 --- a/market-maker/tests/core/errSerializer.test.ts +++ b/market-maker/tests/core/errSerializer.test.ts @@ -71,12 +71,12 @@ describe("serializeError", () => { const err = Object.assign(new Error("contract reverted"), { shortMessage: "reverted", contractAddress: "0xabc", - functionName: "multicall", + functionName: "updateOrders", sender: "0xdef", }); const out = serializeError(err); assert.equal(out.contractAddress, "0xabc"); - assert.equal(out.functionName, "multicall"); + assert.equal(out.functionName, "updateOrders"); assert.equal(out.sender, "0xdef"); }); diff --git a/market-maker/tests/core/perpsInstrument.test.ts b/market-maker/tests/core/perpsInstrument.test.ts new file mode 100644 index 0000000..2577647 --- /dev/null +++ b/market-maker/tests/core/perpsInstrument.test.ts @@ -0,0 +1,81 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Address } from "viem"; +import { PerpsInstrumentAdapter } from "../../src/adapters/perps/instrument.ts"; +import type { PerpsVenueAdapter } from "../../src/adapters/perps/venue.ts"; + +const OWNER = "0x1111111111111111111111111111111111111111" as Address; +const PERPS = "0x2222222222222222222222222222222222222222" as Address; + +function makeInstrument(position: { netQuantity: bigint; netEntryValue: bigint }) { + const venue = { + address: PERPS, + wallet: { account: { address: OWNER } }, + publicClient: { + readContract: async (call: { + functionName: string; + args: readonly unknown[]; + abi: readonly unknown[]; + }) => { + assert.equal(call.functionName, "getUserPosition"); + assert.deepEqual(call.args, [OWNER]); + assert.deepEqual(call.abi, [ + { + type: "function", + name: "getUserPosition", + stateMutability: "view", + inputs: [{ name: "_user", type: "address" }], + outputs: [ + { + name: "", + type: "tuple", + components: [ + { name: "netQuantity", type: "int256" }, + { name: "netEntryValue", type: "int256" }, + ], + }, + ], + }, + ]); + return position; + }, + }, + } as unknown as PerpsVenueAdapter; + + return new PerpsInstrumentAdapter(venue); +} + +describe("perps instrument position", () => { + it("derives a long average entry price from net entry value", async () => { + const position = await makeInstrument({ + netQuantity: 2_000_000n, + netEntryValue: 241_000_000n, + }).getPosition(); + + assert.deepEqual(position, { + netQuantity: 2_000_000n, + entryPrice: 120_500_000n, + }); + }); + + it("derives a positive average entry price for a short", async () => { + const position = await makeInstrument({ + netQuantity: -2_500_000n, + netEntryValue: -300_000_000n, + }).getPosition(); + + assert.deepEqual(position, { + netQuantity: -2_500_000n, + entryPrice: 120_000_000n, + }); + }); + + it("uses zero entry price when flat", async () => { + const position = await makeInstrument({ + netQuantity: 0n, + netEntryValue: 0n, + }).getPosition(); + + assert.deepEqual(position, { netQuantity: 0n, entryPrice: 0n }); + }); +}); diff --git a/points-indexer/package.json b/points-indexer/package.json index 34e8f44..8badcc6 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -39,17 +39,11 @@ "assemblyscript": "^0.19.23", "collateral-margin-contracts": "link:../contracts", "hardhat": "^3.9.1", - "hardhat-matchstick-ts": "github:lsheva/matchstick-ts#6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad&path:packages/hardhat-matchstick-ts", + "hardhat-matchstick-ts": "github:lsheva/matchstick-ts#v0.4.2&path:packages/hardhat-matchstick-ts", "matchstick-as": "0.6.0", - "matchstick-ts": "github:lsheva/matchstick-ts#6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad&path:packages/matchstick-ts", + "matchstick-ts": "github:lsheva/matchstick-ts#v0.4.2&path:packages/matchstick-ts", "typescript": "^5.9.3", "viem": "2.52.2" }, - "pnpm": { - "onlyBuiltDependencies": [ - "matchstick-ts", - "hardhat-matchstick-ts" - ] - }, - "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316" + "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621" } diff --git a/points-indexer/pnpm-lock.yaml b/points-indexer/pnpm-lock.yaml index 2029541..9d0dcaa 100644 --- a/points-indexer/pnpm-lock.yaml +++ b/points-indexer/pnpm-lock.yaml @@ -43,14 +43,14 @@ importers: specifier: ^3.9.1 version: 3.9.1 hardhat-matchstick-ts: - specifier: github:lsheva/matchstick-ts#6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad&path:packages/hardhat-matchstick-ts - version: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad#path:packages/hardhat-matchstick-ts(@nomicfoundation/hardhat-network-helpers@3.0.11(hardhat@3.9.1))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.1) + specifier: github:lsheva/matchstick-ts#v0.4.2&path:packages/hardhat-matchstick-ts + version: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9#path:packages/hardhat-matchstick-ts(@nomicfoundation/hardhat-network-helpers@3.0.11(hardhat@3.9.1))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.1) matchstick-as: specifier: 0.6.0 version: 0.6.0 matchstick-ts: - specifier: github:lsheva/matchstick-ts#6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad&path:packages/matchstick-ts - version: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad#path:packages/matchstick-ts(@graphprotocol/graph-cli@0.98.1(@types/node@25.9.2)(typescript@5.9.3)(zod@3.25.76))(@graphprotocol/graph-ts@0.38.2)(matchstick-as@0.6.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) + specifier: github:lsheva/matchstick-ts#v0.4.2&path:packages/matchstick-ts + version: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9#path:packages/matchstick-ts(@graphprotocol/graph-cli@0.98.1(@types/node@25.9.2)(typescript@5.9.3)(zod@3.25.76))(@graphprotocol/graph-ts@0.38.2)(matchstick-as@0.6.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1367,9 +1367,9 @@ packages: resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - hardhat-matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad#path:packages/hardhat-matchstick-ts: - resolution: {path: packages/hardhat-matchstick-ts, tarball: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad} - version: 0.4.1 + hardhat-matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9#path:packages/hardhat-matchstick-ts: + resolution: {gitHosted: true, path: packages/hardhat-matchstick-ts, tarball: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9} + version: 0.4.2 engines: {node: '>=22.6'} peerDependencies: '@nomicfoundation/hardhat-network-helpers': ^3 @@ -1721,9 +1721,9 @@ packages: matchstick-as@0.6.0: resolution: {integrity: sha512-E36fWsC1AbCkBFt05VsDDRoFvGSdcZg6oZJrtIe/YDBbuFh8SKbR5FcoqDhNWqSN+F7bN/iS2u8Md0SM+4pUpw==} - matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad#path:packages/matchstick-ts: - resolution: {path: packages/matchstick-ts, tarball: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad} - version: 0.4.1 + matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9#path:packages/matchstick-ts: + resolution: {gitHosted: true, path: packages/matchstick-ts, tarball: https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9} + version: 0.4.2 engines: {node: '>=22.6'} hasBin: true peerDependencies: @@ -3763,7 +3763,7 @@ snapshots: graphql@16.11.0: {} - hardhat-matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad#path:packages/hardhat-matchstick-ts(@nomicfoundation/hardhat-network-helpers@3.0.11(hardhat@3.9.1))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.1): + hardhat-matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9#path:packages/hardhat-matchstick-ts(@nomicfoundation/hardhat-network-helpers@3.0.11(hardhat@3.9.1))(@nomicfoundation/hardhat-viem@3.0.9(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)))(hardhat@3.9.1): dependencies: '@nomicfoundation/hardhat-network-helpers': 3.0.11(hardhat@3.9.1) '@nomicfoundation/hardhat-viem': 3.0.9(hardhat@3.9.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)) @@ -4135,7 +4135,7 @@ snapshots: dependencies: wabt: 1.0.24 - matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/6c7e4b5d584a9fc75b66d1f6c03820e20313c9ad#path:packages/matchstick-ts(@graphprotocol/graph-cli@0.98.1(@types/node@25.9.2)(typescript@5.9.3)(zod@3.25.76))(@graphprotocol/graph-ts@0.38.2)(matchstick-as@0.6.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)): + matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9#path:packages/matchstick-ts(@graphprotocol/graph-cli@0.98.1(@types/node@25.9.2)(typescript@5.9.3)(zod@3.25.76))(@graphprotocol/graph-ts@0.38.2)(matchstick-as@0.6.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76)): dependencies: '@graphprotocol/graph-cli': 0.98.1(@types/node@25.9.2)(typescript@5.9.3)(zod@3.25.76) '@graphprotocol/graph-ts': 0.38.2 diff --git a/points-indexer/pnpm-workspace.yaml b/points-indexer/pnpm-workspace.yaml new file mode 100644 index 0000000..9c2036b --- /dev/null +++ b/points-indexer/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +# pnpm 11 keys allowBuilds by exact resolution, so the matchstick entries must be +# updated whenever the git specs move to a different commit. +allowBuilds: + esbuild: false + hardhat-matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9#path:packages/hardhat-matchstick-ts: true + matchstick-ts@https://codeload.github.com/lsheva/matchstick-ts/tar.gz/d613d674898f6d0d01a880dd4e1b514f707b93b9#path:packages/matchstick-ts: true diff --git a/portfolio-margin/package.json b/portfolio-margin/package.json index 1023d45..ceeb2c1 100644 --- a/portfolio-margin/package.json +++ b/portfolio-margin/package.json @@ -31,5 +31,5 @@ "engines": { "node": ">=22" }, - "packageManager": "pnpm@11.1.1" + "packageManager": "pnpm@11.22.0" } diff --git a/portfolio-margin/src/types.ts b/portfolio-margin/src/types.ts index 472e406..15016a2 100644 --- a/portfolio-margin/src/types.ts +++ b/portfolio-margin/src/types.ts @@ -8,7 +8,7 @@ export type Address = `0x${string}`; /** * A venue's resting book reduced to what the margin math needs, as reported by - * `ILinearMarket.getRiskView` plus the venue's `getOrderValues`. + * `ILinearMarket.getRiskView` plus the venue's `getOrderAggregate`. * * Nothing here is constant in P. The engine stresses order delta as part of net * delta, and the fill-loss terms are `max(0, value − P × delta / 10^tokenDecimals)` @@ -37,8 +37,8 @@ export interface RestingOrders { * predictor can re-evaluate at any new price without further RPC reads. * * Shapes deliberately mirror the on-chain getters: - * - perps: `getRiskView` + `getOrderValues` + `getUserPosition` - * - futures: `getRiskView` + `getOrderValues` + `getActiveExpirationDates`/`getUserPosition` + * - perps: `getRiskView` + `getOrderAggregate` + `getUserPosition` + * - futures: `getRiskView` + `getOrderAggregate` + `getActiveExpirationDates`/`getUserPosition` * * Bigints throughout because PME math is performed in token-decimal units * (typically USDC = 6 decimals) with intermediate WAD scaling. JS numbers